[object Object]

← back to Designerwallcoverings

maharam-onboard: knoll-model sample-only pipeline (1429 wallcoverings, dedup mfr_sku, PG-first shopify_product_id write-back, draft->gated trickle-activate) — DRY-RUN verified, $0, nothing live

ba9fbe309c4fe868c0954a3bdf601352c580bc6e · 2026-07-26 08:48:51 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit ba9fbe309c4fe868c0954a3bdf601352c580bc6e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 26 08:48:51 2026 -0700

    maharam-onboard: knoll-model sample-only pipeline (1429 wallcoverings, dedup mfr_sku, PG-first shopify_product_id write-back, draft->gated trickle-activate) — DRY-RUN verified, $0, nothing live
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/lib/shopify.mjs                    |  87 +++++++++
 scripts/maharam-onboard/.gitignore         |   2 +
 scripts/maharam-onboard/build-payloads.mjs | 145 ++++++++++++++
 scripts/maharam-onboard/create-drafts.mjs  | 118 +++++++++++
 scripts/maharam-onboard/go-live.mjs        | 110 +++++++++++
 scripts/maharam-onboard/out/.gitkeep       |   0
 scripts/variant-budget/budget.cjs          | 303 +++++++++++++++++++++++++++++
 7 files changed, 765 insertions(+)

diff --git a/scripts/lib/shopify.mjs b/scripts/lib/shopify.mjs
new file mode 100644
index 0000000..267920b
--- /dev/null
+++ b/scripts/lib/shopify.mjs
@@ -0,0 +1,87 @@
+/**
+ * Shared Shopify Admin client seam for DW scripts.
+ *
+ * One place that owns the store endpoint, API version, token load, retry/backoff,
+ * and Shopify cost-throttle sleep — so individual scripts stop re-inlining their own
+ * `X-Shopify-Access-Token` + fetch-retry boilerplate. The `gql()` THROTTLED backoff +
+ * cost-throttle pattern is lifted verbatim from scripts/price-sheets/add-roll-variant.mjs.
+ *
+ * Centralizing the write path here is also what makes the "writes go through
+ * shopify_api_queue" rule enforceable: there's now a single function a guard can wrap.
+ *
+ * Exports:
+ *   SHOP, VER, TOKEN, ENDPOINT     — connection constants
+ *   gql(query, vars)               — GraphQL Admin call w/ THROTTLED backoff + throttle sleep
+ *   rest(path, { method, body })   — REST Admin call (path begins after /admin/api/<VER>)
+ *   restAll(path)                  — REST GET that follows Link rel="next" pagination
+ *   getLocation()                  — primary location gid (read_locations scope required)
+ */
+import fs from 'node:fs';
+
+export const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+export const VER = '2024-10';
+export const TOKEN = (
+  fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
+    .match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || []
+)[1]?.trim();
+if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN in ~/Projects/secrets-manager/.env'); process.exit(1); }
+
+export const ENDPOINT = `https://${SHOP}/admin/api/${VER}`;
+const GQL_URL = `${ENDPOINT}/graphql.json`;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+export async function gql(query, vars) {
+  for (let a = 0; a < 8; a++) {
+    let j;
+    try {
+      const r = await fetch(GQL_URL, {
+        method: 'POST',
+        headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ query, variables: vars }),
+      });
+      j = await r.json();
+    } catch (e) { await sleep(1500 * (a + 1)); continue; }
+    if (j.errors) {
+      if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; }
+      return { __err: j.errors };
+    }
+    const t = j.extensions?.cost?.throttleStatus;
+    if (t && t.currentlyAvailable < 400) await sleep(1200);
+    return j.data;
+  }
+  throw new Error('gql retries exhausted');
+}
+
+export async function rest(path, { method = 'GET', body } = {}, tries = 5) {
+  for (let i = 0; i < tries; i++) {
+    const r = await fetch(`${ENDPOINT}${path}`, {
+      method,
+      headers: { 'X-Shopify-Access-Token': TOKEN, ...(body ? { 'Content-Type': 'application/json' } : {}) },
+      ...(body ? { body: JSON.stringify(body) } : {}),
+    });
+    if (r.status === 429 || r.status >= 500) { await sleep(1500 * (i + 1)); continue; }
+    await sleep(90);
+    return r;
+  }
+  throw new Error('rest fail ' + path);
+}
+
+export async function restAll(path, key) {
+  const collection = key || path.replace(/^\/([a-z_]+)\.json.*/, '$1');
+  const out = [];
+  let url = path;
+  while (url) {
+    const r = await rest(url);
+    const link = r.headers.get('Link') || '';
+    out.push(...(((await r.json())[collection]) || []));
+    const m = link.split(',').find(s => s.includes('rel="next"'));
+    url = m ? m.slice(m.indexOf('<') + 1, m.indexOf('>')).replace(/^https:\/\/[^/]+\/admin\/api\/[^/]+/, '') : null;
+  }
+  return out;
+}
+
+export async function getLocation() {
+  const loc = await gql(`{locations(first:5){nodes{id}}}`);
+  if (loc?.__err) throw new Error('cannot read locations: ' + JSON.stringify(loc.__err).slice(0, 200));
+  return loc.locations.nodes[0].id;
+}
diff --git a/scripts/maharam-onboard/.gitignore b/scripts/maharam-onboard/.gitignore
new file mode 100644
index 0000000..4467388
--- /dev/null
+++ b/scripts/maharam-onboard/.gitignore
@@ -0,0 +1,2 @@
+out/*.jsonl
+out/*.log
diff --git a/scripts/maharam-onboard/build-payloads.mjs b/scripts/maharam-onboard/build-payloads.mjs
new file mode 100644
index 0000000..3863ba0
--- /dev/null
+++ b/scripts/maharam-onboard/build-payloads.mjs
@@ -0,0 +1,145 @@
+#!/usr/bin/env node
+/**
+ * Build Maharam DRAFT payloads from dw_unified.maharam_catalog → out/payloads.jsonl.
+ * Mirrors knoll-onboard/build-payloads.mjs, but Maharam is a QUOTE-ONLY line:
+ * per the Steve-approved memo (maharam-onboarding-2026-07-22) and the live
+ * Koroseal / Architectural-Wallcoverings pattern, each product is SAMPLE-ONLY —
+ * one `{DW_SKU}-Sample` variant at the $4.25 memo price, option "Type", no public
+ * sell price; the body carries a "call or email for a quote" CTA.
+ *
+ * vendor='Maharam' drives the SMART collection `maharam-wallcoverings`
+ * (rule: vendor EQUALS Maharam), so activation alone auto-populates it.
+ *
+ * Eligible pool = proposed_dw_type='Wallcovering' AND dedup_skip=false AND
+ * activation_ready=true (the 1,429 ready wallcoverings). The 164 Gemma fabric
+ * (roll-on-fabric rule) and 8 no-description rows are dedup_skip=true → HELD.
+ * Dedup is by mfr_sku. Every emitted row must have ≥1 image and a title that
+ * never contains "Wallpaper" or "Unknown".
+ *
+ *   node build-payloads.mjs        # build payloads.jsonl + print first 3 + held counts
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execSync } from 'node:child_process';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(HERE, 'out');
+const DB = 'postgresql:///dw_unified?host=/tmp';
+const BRAND = 'Maharam';
+
+const titleCase = s => (s || '').replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase()).trim();
+// "Wallpaper" is banned in customer-facing text → always "Wallcovering"
+const clean = s => (s || '').replace(/wall\s*paper/ig, 'Wallcovering').replace(/\s+/g, ' ').trim();
+const isBadWord = s => !s || /unknown/i.test(s);
+
+function rows() {
+  const json = execSync(`psql "${DB}" -tAc "select coalesce(json_agg(row_to_json(t))::text,'[]') from (
+    select mfr_sku, dw_sku, pattern_name, color_name, collection, product_type, proposed_dw_type,
+           width, length, repeat_v, repeat_h, material, fire_rating, finish, features, application,
+           sample_price, dedup_skip, activation_ready, description,
+           image_url, all_images, product_url
+    from maharam_catalog
+    where dw_sku is not null and mfr_sku is not null
+    order by dw_sku) t"`, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
+  return JSON.parse(json.trim());
+}
+
+function titleFor(r) {
+  let pat = titleCase(clean(r.pattern_name));
+  if (isBadWord(pat)) pat = titleCase(clean(r.mfr_sku));        // never "Unknown"/empty
+  const rawCol = clean(r.color_name);
+  const col = rawCol && !isBadWord(rawCol) && rawCol.toLowerCase() !== (r.pattern_name || '').toLowerCase()
+    ? titleCase(rawCol) : '';
+  return `${pat}${col ? ' ' + col : ''} | ${BRAND}`.replace(/\s+/g, ' ').trim();
+}
+function tags(r) {
+  const t = new Set([BRAND, 'Wallcovering', 'Commercial', 'quotes']);
+  if (r.collection && !new RegExp(BRAND, 'i').test(r.collection)) t.add(titleCase(r.collection));
+  if (r.material) t.add(titleCase(r.material.split(',')[0]));
+  return [...t].filter(Boolean).join(', ');
+}
+function repeatStr(r) {
+  const v = (r.repeat_v && r.repeat_v !== '0') ? r.repeat_v : '';
+  const h = (r.repeat_h && r.repeat_h !== '0') ? r.repeat_h : '';
+  if (!v && !h) return '';
+  return [h && `H ${h}`, v && `V ${v}`].filter(Boolean).join(' · ');
+}
+function bodyHtml(r) {
+  const rowsHtml = [
+    ['Pattern', clean(r.pattern_name)], ['Color', r.color_name], ['Material', r.material],
+    ['Width', r.width], ['Length', r.length], ['Repeat', repeatStr(r)],
+    ['Finish', r.finish], ['Fire Rating', r.fire_rating], ['Application', r.application],
+    ['Manufacturer SKU', r.mfr_sku], ['Collection', new RegExp(BRAND, 'i').test(r.collection || '') ? '' : r.collection],
+  ].filter(([, v]) => v != null && String(v).trim() !== '' && !isBadWord(String(v)));
+  let pat = titleCase(clean(r.pattern_name)); if (isBadWord(pat)) pat = titleCase(clean(r.mfr_sku));
+  return `<p>${pat} from ${BRAND} — a high-performance commercial wallcovering. Request a $4.25 memo sample to see the material in hand; <strong>call or email for a trade quote</strong> on full rolls.</p>`
+    + `<table>${rowsHtml.map(([k, v]) => `<tr><td><strong>${k}</strong></td><td>${clean(String(v))}</td></tr>`).join('')}</table>`;
+}
+function metafields(r) {
+  const mf = [];
+  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '' && !isBadWord(String(value))) mf.push({ namespace: ns, key, type, value: String(value) }); };
+  S('custom', 'manufacturer_sku', r.mfr_sku);
+  S('dwc', 'manufacturer_sku', r.mfr_sku);
+  S('global', 'Brand', BRAND);
+  S('global', 'width', r.width);
+  S('global', 'length', r.length);
+  S('global', 'repeat', repeatStr(r));
+  S('global', 'unit_of_measure', 'Call or Email for Quote');
+  S('custom', 'color', r.color_name);
+  S('custom', 'real_color_name', r.color_name);
+  return mf;
+}
+function galleryUrls(r) {
+  const urls = new Set();
+  if (r.image_url) urls.add(r.image_url);
+  try { for (const u of JSON.parse(r.all_images || '[]')) if (u) urls.add(u); } catch {}
+  return [...urls];
+}
+
+function main() {
+  const data = rows();
+  const seen = new Set();
+  const fd = fs.openSync(path.join(OUT, 'payloads.jsonl'), 'w');
+  let n = 0, heldFabric = 0, heldNoDesc = 0, heldNoImg = 0, heldOther = 0, dupe = 0;
+  for (const r of data) {
+    if (!r.mfr_sku || seen.has(r.mfr_sku)) { dupe++; continue; }   // dedup by mfr_sku
+    seen.add(r.mfr_sku);
+    // eligibility — wallcovering, not held, activation-ready
+    if (r.proposed_dw_type !== 'Wallcovering') { heldFabric++; continue; }
+    if (r.dedup_skip === true) { if (r.description) heldOther++; else heldNoDesc++; continue; }
+    if (r.activation_ready !== true) { heldOther++; continue; }
+    const gallery = galleryUrls(r);
+    if (gallery.length === 0) { heldNoImg++; continue; }           // ≥1 image required
+    const sku = r.dw_sku;                                          // DWAX-* already assigned in staging
+    const samplePrice = (r.sample_price != null ? Number(r.sample_price) : 4.25).toFixed(2);
+    const o = {
+      mfr_sku: r.mfr_sku, sku, dw_sku: r.dw_sku, quote: true,
+      image_url: gallery[0], gallery,
+      product: {
+        title: titleFor(r), vendor: BRAND, product_type: 'Wallcovering',
+        status: 'draft', tags: tags(r), body_html: bodyHtml(r),
+        options: [{ name: 'Type' }],
+        variants: [
+          // SAMPLE-ONLY: the sole sellable variant is the $4.25 memo. No inventory at
+          // draft time (inventory_management:null) — go-live enables tracking + qty.
+          { sku: `${sku}-Sample`, price: samplePrice, option1: 'Sample', taxable: true, requires_shipping: true, inventory_management: null },
+        ],
+      },
+      metafields: metafields(r),
+    };
+    fs.writeSync(fd, JSON.stringify(o) + '\n'); n++;
+  }
+  fs.closeSync(fd);
+  console.log(`✓ ${n} payloads → out/payloads.jsonl`);
+  console.log(`  held: fabric/non-WC ${heldFabric} · no-desc ${heldNoDesc} · no-image ${heldNoImg} · other ${heldOther} · dupe-mfr ${dupe}`);
+  const sample = fs.readFileSync(path.join(OUT, 'payloads.jsonl'), 'utf8').trim().split('\n').filter(Boolean).slice(0, 3).map(l => JSON.parse(l));
+  for (const o of sample) console.log(`  [${o.sku}] "${o.product.title}" · Sample $${o.product.variants[0].price} · mf:${o.metafields.length} · tags:${o.product.tags.split(', ').length} · imgs:${o.gallery.length}`);
+  // guardrail: no banned words in any emitted title
+  const all = fs.readFileSync(path.join(OUT, 'payloads.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
+  const bad = all.filter(o => /wall\s*paper|unknown/i.test(o.product.title));
+  console.log(bad.length ? `  ⛔ ${bad.length} titles contain banned word(s): ${bad.slice(0,3).map(o=>o.product.title).join(' | ')}` : `  ✓ 0 titles contain "Wallpaper"/"Unknown"`);
+  const noImg = all.filter(o => !o.gallery || o.gallery.length === 0);
+  console.log(noImg.length ? `  ⛔ ${noImg.length} payloads have no image` : `  ✓ every payload has ≥1 image`);
+}
+main();
diff --git a/scripts/maharam-onboard/create-drafts.mjs b/scripts/maharam-onboard/create-drafts.mjs
new file mode 100644
index 0000000..b03b578
--- /dev/null
+++ b/scripts/maharam-onboard/create-drafts.mjs
@@ -0,0 +1,118 @@
+#!/usr/bin/env node
+/**
+ * Create Maharam products as DRAFT on the live store (designer-laboratory-sandbox),
+ * set metafields, attach the image gallery, and — PostgreSQL-FIRST — write the real
+ * shopify_product_id back to maharam_catalog the moment the product is minted.
+ * Mirrors knoll-onboard/create-drafts.mjs (shared ../lib/shopify.mjs owns token/backoff).
+ *
+ *   node create-drafts.mjs                     # DRY-RUN: show plan
+ *   node create-drafts.mjs --apply --limit=2   # canary
+ *   node create-drafts.mjs --apply             # all remaining (resumable, day-cap aware)
+ *
+ * Idempotent: skips any DWAX sku already live on Shopify OR already in out/created.jsonl.
+ * Cap-aware: draws from the shared daily variant budget (default 'backlog' trickle pool,
+ *   NOT 'upload' — so it never starves the new-arrivals uploader). Override: DW_MAHARAM_BUDGET.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRequire } from 'node:module';
+import { execFileSync } from 'node:child_process';
+import { rest, gql, restAll, SHOP, TOKEN } from '../lib/shopify.mjs';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(HERE, 'out');
+const require = createRequire(import.meta.url);
+const budget = require('../variant-budget/budget.cjs');
+const BUDGET_CAT = process.env.DW_MAHARAM_BUDGET || 'backlog';
+const APPLY = process.argv.includes('--apply');
+const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
+const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
+const PGENV = { ...process.env, PGHOST: '/tmp', PGDATABASE: 'dw_unified' };
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const M_MF = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{field message} } }`;
+
+const loadJsonl = f => fs.existsSync(path.join(OUT, f))
+  ? fs.readFileSync(path.join(OUT, f), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)) : [];
+
+// PG-FIRST write-back: stamp the real product_id onto the staging row immediately.
+function dbWriteBack(sku, pid) {
+  try {
+    execFileSync(PSQL, ['-d', 'dw_unified', '-c',
+      `UPDATE maharam_catalog SET shopify_product_id='${pid}', updated_at=NOW() WHERE dw_sku='${sku.replace(/'/g, "''")}';`],
+      { env: PGENV, stdio: 'ignore' });
+  } catch (e) { /* non-fatal — created.jsonl is the durable record; go-live re-writes it too */ }
+}
+
+async function attachImage(pid, url, filename) {
+  if (!url) return 'no-url';
+  try {
+    const resp = await fetch(url, { signal: AbortSignal.timeout(30000) });
+    if (!resp.ok) return `dl-${resp.status}`;
+    const b64 = Buffer.from(await resp.arrayBuffer()).toString('base64');
+    const r = await rest(`/products/${pid}/images.json`, { method: 'POST', body: { image: { attachment: b64, filename } } });
+    return (r.status === 200 || r.status === 201) ? 'ok' : `up-${r.status}`;
+  } catch (e) { return 'err'; }
+}
+
+async function liveMaharamSkus() {
+  try {
+    const prods = await restAll('/products.json?vendor=Maharam&fields=id,variants&limit=250', 'products');
+    const s = new Set();
+    for (const p of prods) for (const v of (p.variants || [])) if (v.sku) s.add(v.sku.toUpperCase());
+    return s;
+  } catch { return new Set(); }
+}
+
+async function main() {
+  const payloads = loadJsonl('payloads.jsonl');
+  const liveSkus = await liveMaharamSkus();
+  const created = loadJsonl('created.jsonl');
+  const createdSku = new Set(created.map(c => c.sku.toUpperCase()));
+  let todo = payloads.filter(p => !liveSkus.has(p.sku.toUpperCase()) && !liveSkus.has(`${p.sku}-SAMPLE`.toUpperCase()) && !createdSku.has(p.sku.toUpperCase()));
+  if (LIMIT) todo = todo.slice(0, LIMIT);
+
+  const headroom = budget.remaining(BUDGET_CAT);
+  console.log(`Store ${SHOP} · token …${TOKEN.slice(-4)} · payloads ${payloads.length} · live ${liveSkus.size} · to create ${todo.length} · '${BUDGET_CAT}' headroom ${headroom} variants (~${headroom} products, sample-only) · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
+  if (!APPLY) {
+    for (const o of todo.slice(0, 3)) console.log(`  [${o.sku}] "${o.product.title}" · Sample $${o.product.variants[0].price} · mf:${o.metafields.length} · imgs:${o.gallery.length}`);
+    console.log(`DRY-RUN. --apply (optionally --limit=N) to create drafts.`);
+    return;
+  }
+
+  const fd = fs.openSync(path.join(OUT, 'created.jsonl'), 'a');
+  let ok = 0, fail = 0, mfFail = 0, imgFail = 0;
+  for (const o of todo) {
+    const grant = budget.take(BUDGET_CAT, o.product.variants.length);
+    if (grant < o.product.variants.length) { console.error(`\n⛔ '${BUDGET_CAT}' budget exhausted today (granted ${grant}). Stop — resume tomorrow (idempotent).`); break; }
+    try {
+      const res = await rest('/products.json', { method: 'POST', body: { product: o.product } });
+      const body = await res.json().catch(() => ({}));
+      if (res.status === 201 && body?.product?.id) {
+        const pid = body.product.id;
+        dbWriteBack(o.sku, pid);                                   // PG-FIRST
+        if (o.metafields.length) {
+          const gid = `gid://shopify/Product/${pid}`;
+          const d = await gql(M_MF, { mf: o.metafields.map(m => ({ ownerId: gid, namespace: m.namespace, key: m.key, type: m.type, value: m.value })) });
+          const ue = d?.metafieldsSet?.userErrors || d?.__err || [];
+          if (ue.length) { mfFail++; console.error(`  ⚠ mf ${o.sku}: ${JSON.stringify(ue).slice(0,160)}`); }
+        }
+        let imgOk = 0;
+        for (let i = 0; i < o.gallery.length; i++) {
+          const res2 = await attachImage(pid, o.gallery[i], `${o.mfr_sku}_${i}.jpg`);
+          if (res2 === 'ok') imgOk++; else if (i === 0 && res2 !== 'no-url') console.error(`  ⚠ img ${o.sku}: ${res2}`);
+        }
+        if (imgOk === 0) imgFail++;
+        fs.writeSync(fd, JSON.stringify({ mfr_sku: o.mfr_sku, sku: o.sku, product_id: String(pid), imgs: imgOk, status: 'draft', created_at: new Date().toISOString() }) + '\n');
+        ok++; if (ok % 20 === 0) console.log(`  ...created ${ok} (fail ${fail}, mfFail ${mfFail}, imgFail ${imgFail})`);
+      } else {
+        const errStr = JSON.stringify(body).toLowerCase();
+        if (/exceed|limit|too many|maximum/.test(errStr) && /variant|product|day/.test(errStr)) { console.error(`\n⛔ Shopify cap on ${o.sku}. Stop — resume tomorrow.`); break; }
+        fail++; console.error(`  FAIL ${o.sku}: HTTP ${res.status} ${JSON.stringify(body).slice(0,200)}`);
+      }
+    } catch (e) { fail++; console.error(`  ERR ${o.sku}: ${String(e).slice(0,160)}`); await sleep(700); }
+  }
+  fs.closeSync(fd);
+  console.log(`\nDONE. created=${ok} failed=${fail} mfFail=${mfFail} imgFail=${imgFail} of ${todo.length}`);
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/maharam-onboard/go-live.mjs b/scripts/maharam-onboard/go-live.mjs
new file mode 100644
index 0000000..bcbaef9
--- /dev/null
+++ b/scripts/maharam-onboard/go-live.mjs
@@ -0,0 +1,110 @@
+#!/usr/bin/env node
+/**
+ * Go-live for the Maharam drafts created by create-drafts.mjs (reads out/created.jsonl).
+ * Sample-only line: enable inventory tracking on the single $4.25 Sample variant,
+ * activate at Ventura Blvd + on_hand = 2026 (so the memo is orderable), publish to the
+ * 12 DW sales channels (Google & YouTube deliberately EXCLUDED — the sole variant is the
+ * $4.25 sample → GMC price disapproval), status = ACTIVE. vendor='Maharam' → the SMART
+ * collection `maharam-wallcoverings` (rule vendor EQUALS Maharam) auto-populates on
+ * activation. Writes shopify_product_id + handle back to maharam_catalog (PG-first mirror)
+ * and flips dw_sku_registry → active. Idempotent: skips products already ACTIVE.
+ *
+ * This is the GATED trickle step — run it metered (--limit=N) or on a drip schedule.
+ *
+ *   node go-live.mjs                   # dry-run summary
+ *   node go-live.mjs --apply --limit=2 # canary
+ *   node go-live.mjs --apply --limit=N # trickle N/tick
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFileSync } from 'node:child_process';
+import { gql } from '../lib/shopify.mjs';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(HERE, 'out');
+const APPLY = process.argv.includes('--apply');
+const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
+const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
+const PGENV = { ...process.env, PGHOST: '/tmp', PGDATABASE: 'dw_unified' };
+
+const LOCATION_ID = 'gid://shopify/Location/5795643504'; // 15442 Ventura Blvd.
+const TARGET_QTY = 2026;
+// Google & YouTube (29646651457) deliberately EXCLUDED — the ONLY variant is the $4.25
+// sample → Shopify would sync $4.25 to GMC → price disapproval. Google fed only by the TSV.
+const PUBLICATIONS = [
+  22208643184, 22497296496, 29739483201, 29776969793, 37904089153,
+  43657658419, 44234276915, 44317474867, 44317507635, 71898464307, 115856375859, 140027723827,
+].map(n => `gid://shopify/Publication/${n}`);
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+const Q_VARIANTS = `query($id:ID!){ product(id:$id){ status handle variants(first:10){edges{node{inventoryItem{id}}}}}}`;
+const M_TRACK = `mutation($id:ID!){ inventoryItemUpdate(id:$id, input:{tracked:true}){ userErrors{message} } }`;
+const M_ACTIVATE = `mutation($iid:ID!,$loc:ID!){ inventoryActivate(inventoryItemId:$iid, locationId:$loc){ userErrors{message} } }`;
+const M_SETQTY = `mutation($input:InventorySetQuantitiesInput!){ inventorySetQuantities(input:$input){ userErrors{message code} } }`;
+const M_PUBLISH = `mutation($id:ID!,$pubs:[PublicationInput!]!){ publishablePublish(id:$id, input:$pubs){ userErrors{message} } }`;
+const M_ACTIVE = `mutation($id:ID!){ productUpdate(input:{id:$id, status:ACTIVE}){ product{status} userErrors{message} } }`;
+
+function dbWriteBack(sku, pid, handle) {
+  try {
+    execFileSync(PSQL, ['-d', 'dw_unified', '-c',
+      `UPDATE maharam_catalog SET shopify_product_id='${pid}', updated_at=NOW() WHERE dw_sku='${sku.replace(/'/g, "''")}';
+       UPDATE dw_sku_registry SET shopify_product_id='${pid}', shopify_handle=${handle ? `'${handle.replace(/'/g, "''")}'` : 'NULL'}, status='active', updated_at=NOW() WHERE dw_sku='${sku.replace(/'/g, "''")}';`],
+      { env: PGENV, stdio: 'ignore' });
+  } catch (e) { /* non-fatal — Shopify write is authoritative; mirror re-syncs */ }
+}
+
+async function goLive(pid, sku) {
+  const gidP = `gid://shopify/Product/${pid}`;
+  const errs = [];
+  const d = await gql(Q_VARIANTS, { id: gidP });
+  if (!d?.product) return { missing: true };
+  if (d.product.status === 'ACTIVE') { dbWriteBack(sku, pid, d.product.handle); return { skipped: true }; }
+  const items = d.product.variants.edges.map(e => e.node.inventoryItem.id);
+  for (const iid of items) {
+    let r = await gql(M_TRACK, { id: iid }); (r.inventoryItemUpdate?.userErrors || []).forEach(e => errs.push('track:' + e.message));
+    r = await gql(M_ACTIVATE, { iid, loc: LOCATION_ID });
+    (r.inventoryActivate?.userErrors || []).filter(e => !/already/i.test(e.message)).forEach(e => errs.push('activate:' + e.message));
+  }
+  const r2 = await gql(M_SETQTY, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true,
+    quantities: items.map(iid => ({ inventoryItemId: iid, locationId: LOCATION_ID, quantity: TARGET_QTY })) } });
+  (r2.inventorySetQuantities?.userErrors || []).forEach(e => errs.push('setqty:' + e.message));
+  const r3 = await gql(M_PUBLISH, { id: gidP, pubs: PUBLICATIONS.map(p => ({ publicationId: p })) });
+  (r3.publishablePublish?.userErrors || []).forEach(e => errs.push('publish:' + e.message));
+  const r4 = await gql(M_ACTIVE, { id: gidP });
+  (r4.productUpdate?.userErrors || []).forEach(e => errs.push('active:' + e.message));
+  const status = r4.productUpdate?.product?.status;
+  if (status === 'ACTIVE') dbWriteBack(sku, pid, d.product.handle);
+  return { status, errs };
+}
+
+function loadJsonl(file) {
+  const p = path.join(OUT, file);
+  if (!fs.existsSync(p)) return [];
+  return fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
+}
+
+(async () => {
+  const done = new Set(loadJsonl('golive-done.jsonl').map(r => r.sku));
+  let todo = loadJsonl('created.jsonl').filter(r => !done.has(r.sku));
+  if (LIMIT) todo = todo.slice(0, LIMIT);
+  console.log(`go-live: ${todo.length} drafts · loc ${LOCATION_ID} · qty ${TARGET_QTY} · ${PUBLICATIONS.length} channels (Google excluded) · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
+  if (!APPLY) { console.log('first 3:', todo.slice(0, 3).map(r => r.sku).join(', ')); console.log('DRY-RUN. --apply to execute.'); return; }
+
+  const fd = fs.openSync(path.join(OUT, 'golive-done.jsonl'), 'a');
+  let ok = 0, skip = 0, withErr = 0, miss = 0;
+  for (const r of todo) {
+    try {
+      const res = await goLive(r.product_id, r.sku);
+      if (res.missing) { miss++; console.error(`  ? ${r.sku}: product not found`); }
+      else if (res.skipped) skip++;
+      else if (res.errs?.length) { withErr++; console.error(`  ⚠ ${r.sku} ${res.status||'?'}: ${res.errs.slice(0,3).join(' | ')}`); }
+      else { ok++; }
+      if (res.status === 'ACTIVE' || res.skipped) fs.writeSync(fd, JSON.stringify({ sku: r.sku, product_id: r.product_id }) + '\n');
+      if ((ok+skip+withErr+miss) % 20 === 0) console.log(`  ...${ok+skip+withErr+miss}/${todo.length} (active=${ok} skip=${skip} err=${withErr} miss=${miss})`);
+      await sleep(350);
+    } catch (e) { withErr++; console.error(`  ERR ${r.sku}: ${String(e).slice(0,160)}`); await sleep(600); }
+  }
+  fs.closeSync(fd);
+  console.log(`\nDONE. active=${ok} skipped=${skip} with-errors=${withErr} missing=${miss} of ${todo.length}`);
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/maharam-onboard/out/.gitkeep b/scripts/maharam-onboard/out/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/scripts/variant-budget/budget.cjs b/scripts/variant-budget/budget.cjs
new file mode 100644
index 0000000..58f6308
--- /dev/null
+++ b/scripts/variant-budget/budget.cjs
@@ -0,0 +1,303 @@
+'use strict';
+/**
+ * Shared daily Shopify variant-creation budget ledger (DTD-C, 2026-06-17).
+ *
+ * Shopify caps NEW variant creation at ~1,000/day. Two direct writers compete:
+ * roll-build (build-roll-scale.js) and sample-split (scale-execute.js). Before
+ * today they raced blindly — whoever ran first ate the cap and starved the other.
+ * This is the single coordination point both call: take(who, n) -> granted.
+ *
+ * Phase is DATA-DRIVEN and auto-reverts (the dissent's warning): while the FINITE
+ * roll backlog is unbuilt, rolls get the larger share; once it clears, the budget
+ * hands back to the long sample-split backfill. No calendar guess.
+ *   roll backlog remaining > 0  ->  roll 550 / sample 250
+ *   roll backlog cleared        ->  roll 250 / sample 550
+ * Hard ceiling: total grants/day <= BUDGET_TOTAL (800, safe headroom under ~1k).
+ *
+ * Fail-OPEN: if the DB/ledger is unreachable we grant the request and log a warn,
+ * so a ledger bug can never freeze the live pipelines (status quo was no cap).
+ * Zero deps. mkdir-mutex for cross-process atomicity.
+ */
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const { execFileSync } = require('child_process');
+
+// REBALANCE 2026-06-18 (Steve): the ~1,000/day Shopify ROLLING cap was saturated every
+// morning, starving the FINITE backlogs (Anna French 145, the all-day uploader) — af-build
+// + af-noroll both hit the limit at 5:30/7:00am. Ceiling lowered 800→500 so the budget
+// PARTICIPANTS (roll/sample/upload/osborne) collectively create ≤500/day, leaving ~500 of
+// Shopify's real cap free for the finite NON-budget jobs (af-build runs first at 5:30am).
+// Open-ended sample-split is throttled hard (cap below). REVERSIBLE — restore 800 + sample
+// 250/550 once Anna French + the uploader backlog clear. Tune live via DW_VARIANT_BUDGET.
+// 2026-06-19 (Steve "18/hr to fill cap, leave Anna French room"): NEW-PRODUCT UPLOAD PRIORITY.
+// Ceiling = upload 864 (18 products/hr × 2 variants × 24 slots) + osborne floor 20 = 884, so the
+// budgeted jobs take 884 of Shopify's ~1,000/day and leave ~116 for non-budget Anna French af-build.
+// roll/sample throttled to 0 (below) so they can't pre-empt the uploader's daily slice at 5:30am.
+// REVERSIBLE — restore 500 + roll/sample 400/80 (rollPhase) once the new-SKU push goal is met.
+// 2026-06-21 (Steve EVEN 50/50): the 5-field backlog (~17,177 SKUs that can't be bought) now
+// gets a guaranteed HALF of the cap via its own 'backlog' category. Ceiling raised to the full
+// Shopify ~1,000/day (af-build/Anna French finite job is done, so its old ~116 headroom is free):
+//   upload 500 (250 new products/day) + backlog 500 (500 backlog SKUs/day) + osborne 20 floor.
+// Backlog clears in ~35 days; REVERSIBLE — restore DW_UPLOAD_CAP=864 + drop backlog once the
+// dw-five-field-canary reports backlog=0. Tune live via DW_VARIANT_BUDGET / DW_UPLOAD_CAP / DW_BACKLOG_CAP.
+// 2026-06-25 (Steve "I need room for testing during the day.. like 40"): ceiling 1000→960 to RESERVE
+// ~40 variants/day (≈20 products) of Shopify's rolling ~1,000 cap for Steve's manual test creations.
+// The 40 is carved from the BACKLOG residual (priority order upload 864 → osborne 20 → backlog), so
+// new arrivals keep their full 18/hr — only the background 5-field drain trims (116→~76/day).
+// REVERSIBLE — set DW_VARIANT_BUDGET=1000 (or unset) to drop the reserve.
+const BUDGET_TOTAL = parseInt(process.env.DW_VARIANT_BUDGET || '960', 10);
+const LEDGER_DIR = path.join(os.homedir(), '.claude', 'variant-budget');
+const LOCK = path.join(LEDGER_DIR, '.lock');
+const REPO_ROOT = path.resolve(__dirname, '..', '..');           // designerwallcoverings/
+const ROLL_DONE = path.join(REPO_ROOT, 'roll-scale-done2.txt');  // build-roll-scale.js resume file
+const PGENV = { PGHOST: '/tmp', PGPORT: '5432', PGUSER: 'stevestudio2', PGDATABASE: 'dw_unified',
+  PATH: '/opt/homebrew/opt/postgresql@14/bin:/usr/local/bin:/usr/bin:/bin' };
+const VALID = new Set(['roll', 'sample', 'upload', 'osborne', 'backlog']);
+// 'upload' = the all-day new-product uploader (cadence-import). Modest 200/day slice
+// carved from the shared 800 ceiling via competition (NOT a hard reservation): on light
+// days it's free leftover; on max-capacity days it trims roll/sample slightly. Keeps new
+// products loading all day without a separate budget. (Steve 2026-06-17 "upload staged items".)
+const UPLOAD_CAP = parseInt(process.env.DW_UPLOAD_CAP || '864', 10);   // 2026-06-23 UPLOAD-PRIORITY restored (Steve via DTD/officer-yolo): 500→864 = 18 new products/hr (432/day, full vendor rotation). Backlog gets the residual 116. Was 500 (even 50/50 since 2026-06-21).
+// 'backlog' = the 5-field activation drain (bulk-fivefield-exec.py). Reserved HALF the cap (500/day)
+// in its OWN category so the new-product uploader can never starve it (the bug that left it ~140/day).
+// 500 backlog SKUs/day (1 variant each) → ~17,177 clears in ~35 days. Drops to 0 / reverts on completion.
+const BACKLOG_CAP = parseInt(process.env.DW_BACKLOG_CAP || '116', 10);   // 2026-06-23 (Steve): residual under the 1000 cap after upload 864 + osborne 20. Backfill is a background trickle (~116 SKUs/day) until upload demand drops; raise back toward 500 once new-arrivals goal is met.
+// 'osborne' = the finite Osborne & Little onboard (761 products = ~1522 variants). Steve
+// 2026-06-18: trickle at the SAME polite ~10-products/day cadence as every other vendor — NOT
+// a 148/day blast. 10 products = 20 variants/day (Roll + Sample each). 761 → ~76 days. The big
+// 300 reservation that throttled roll/sample is gone; 20 barely dents the others.
+const OSBORNE_CAP = parseInt(process.env.DW_OSBORNE_CAP || '20', 10);   // 20 variants = 10 products/day
+// 'roll' = the roll-build + Bucket-B sample-only recovery writers. DEFAULT 0 (roll is
+// throttled to 0 in both phases so it can't pre-empt the protected upload/backlog slices —
+// the 2026-06-19 upload-priority decision). A one-shot job MAY set DW_ROLL_CAP=N to land a
+// finite, Steve-approved roll cohort (e.g. the 175 Bucket-B priced rolls). Because take('roll')
+// is still clamped to remainingGlobal (BUDGET_TOTAL - spentTotal), a nonzero roll cap can NEVER
+// push the combined daily total past the 1000 ceiling — it only changes the priority order.
+// REVERSIBLE — unset the env (or let the one-shot job exit) and roll returns to 0.
+const ROLL_CAP = parseInt(process.env.DW_ROLL_CAP || '0', 10);
+// 2026-07-02 END-OF-DAY RECLAIM (Steve via DTD 2/2 verdict C): the ledger shows ~60/day expiring
+// unused at midnight (upload spends ~824 of 864, osborne 0 of 20) while the finite 5-field backlog
+// drain is capped at its 116 residual. From RECLAIM_HOUR (21:00) onward, take('backlog') may draw
+// from the GLOBAL unspent remainder instead of only its own cap — upload has had the whole day to
+// use its slice, and un-drawn budget evaporates at the midnight reset anyway. Osborne's unspent
+// floor stays subtracted (protected), and the 40/day testing reserve is outside BUDGET_TOTAL=960
+// entirely, so neither can be reclaimed. Backlog rises ~116→~176/day; runway ~134→~88 days.
+// REVERSIBLE — set DW_BACKLOG_RECLAIM_HOUR=-1 to disable (any value outside 0..23 disables).
+const RECLAIM_HOUR = parseInt(process.env.DW_BACKLOG_RECLAIM_HOUR ?? '21', 10);
+const reclaimActive = () =>
+  RECLAIM_HOUR >= 0 && RECLAIM_HOUR <= 23 && new Date().getHours() >= RECLAIM_HOUR;
+// Guaranteed floor (Steve 2026-06-18): reserve osborne's small daily slice INSIDE the shared
+// ceiling so roll/sample/upload can never draw the budget down past it — the daily 10 is
+// protected, but the reservation is tiny so nothing else is meaningfully throttled. Combined
+// with the 00:05 run-order (osborne fires first after the midnight cap reset, ahead of the
+// non-ledger cap-eaters at 5:30+) the 10/day is a true floor. Defaults to the cap.
+const OSBORNE_FLOOR = parseInt(process.env.DW_OSBORNE_FLOOR || String(OSBORNE_CAP), 10);
+const OSB_OUT = path.join(REPO_ROOT, 'scripts', 'osborne-onboard', 'out');
+
+const today = () => {
+  const d = new Date();
+  return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
+};
+const ledgerFile = () => path.join(LEDGER_DIR, `${today()}.json`);
+
+// ── data-driven phase: is the finite roll backlog still unbuilt? ───────────────
+function phase() {
+  let buildable = null, done = 0;
+  try {
+    const out = execFileSync('psql', ['-tAc',
+      "SELECT count(*) FROM product_map WHERE map_source IN " +
+      "('auth_new_map_2026','auth_new_map_2026_prefix','tier_b_retail_div085','kravet_site_2026') " +
+      "AND map_unit IS NOT NULL"], { env: PGENV, encoding: 'utf8' });
+    buildable = parseInt(out.trim(), 10);
+  } catch (e) { /* DB down → buildable stays null → backfill-phase default */ }
+  try { done = fs.readFileSync(ROLL_DONE, 'utf8').split('\n').filter(Boolean).length; } catch (e) {}
+
+  // remaining unknown (DB down) → default to backfill-phase (favor the always-present
+  // sample backfill; never over-allocate to rolls on a guess).
+  const remaining = (buildable == null) ? 0 : Math.max(0, buildable - done);
+  const rollPhase = remaining > 0;
+  return {
+    rollPhase, rollBacklogRemaining: remaining, buildable, done,
+    // DTD verdict B (3/3, 2026-06-17): trim roll-priority roll 550→400 to free a steady
+    // slice for the finite Osborne revenue onboard. Roll stays the largest share; backlog
+    // ~1–2 days slower. Caps still sum >800 (competition), so to make Osborne's slice a real
+    // floor (not leftover), upload is trimmed in roll-priority so roll+sample+osborne+upload
+    // ≈ ceiling: 400+250+150+ (osborne takes its 150 before upload's residual).
+    // 2026-06-18 rebalance: sample (the open-ended $4.25 backfill) slashed 250→80 / 550→120 so
+    // it yields the rolling-cap headroom to the FINITE jobs (Anna French af-build + uploader).
+    // Restore 250/550 when those backlogs clear.
+    // 2026-06-19 UPLOAD-PRIORITY (Steve): roll/sample throttled to 0 so the 864-variant uploader
+    // slice is never pre-empted. osborne keeps its small finite floor. REVERSIBLE — restore
+    // roll/sample 400/80 (rollPhase) · 250/120 (backfill) when the new-SKU push goal is met.
+    caps: rollPhase
+      ? { roll: ROLL_CAP, sample: 0, upload: UPLOAD_CAP, osborne: OSBORNE_CAP, backlog: BACKLOG_CAP }
+      : { roll: ROLL_CAP, sample: 0, upload: UPLOAD_CAP, osborne: OSBORNE_CAP, backlog: BACKLOG_CAP },
+  };
+}
+
+// ── osborne hard-floor reservation (data-driven; auto-reverts on completion) ────
+// The unspent floor, but never more than the ACTUAL remaining osborne work (2 variants/
+// product). Auto-zeroes the day the 761-product onboard finishes → reserved slice hands
+// straight back to roll/sample/upload (the dissent's "never starve on a stale guess" rule).
+function osborneReservation(spentOsborne) {
+  let remainingProducts = 0;
+  try {
+    const total = fs.readFileSync(path.join(OSB_OUT, 'payloads.jsonl'), 'utf8').split('\n').filter(Boolean).length;
+    let created = 0;
+    try { created = fs.readFileSync(path.join(OSB_OUT, 'created.jsonl'), 'utf8').split('\n').filter(Boolean).length; } catch (_) {}
+    remainingProducts = Math.max(0, total - created);
+  } catch (_) { return 0; }   // no osborne manifest → reserve nothing
+  return Math.min(Math.max(0, OSBORNE_FLOOR - spentOsborne), remainingProducts * 2);
+}
+
+// ── mkdir-mutex (atomic create; stale-break after 10s) ─────────────────────────
+function withLock(fn) {
+  fs.mkdirSync(LEDGER_DIR, { recursive: true });
+  const deadline = Date.now() + 3000;
+  for (;;) {
+    try { fs.mkdirSync(LOCK); break; }
+    catch (e) {
+      try { if (Date.now() - fs.statSync(LOCK).mtimeMs > 10000) { fs.rmdirSync(LOCK); continue; } } catch (_) {}
+      if (Date.now() > deadline) break; // give up waiting → proceed (fail-open); risk is tiny over-grant
+      execFileSync('sleep', ['0.05']);
+    }
+  }
+  try { return fn(); } finally { try { fs.rmdirSync(LOCK); } catch (_) {} }
+}
+
+function load() {
+  try {
+    const j = JSON.parse(fs.readFileSync(ledgerFile(), 'utf8'));
+    if (j.date === today()) {
+      j.spent = j.spent || {};
+      // backfill keys missing from pre-upload-category ledger files (avoid NaN arithmetic)
+      j.spent.roll = j.spent.roll || 0; j.spent.sample = j.spent.sample || 0; j.spent.upload = j.spent.upload || 0;
+      j.spent.osborne = j.spent.osborne || 0; j.spent.backlog = j.spent.backlog || 0;
+      return j;
+    }
+  } catch (e) {}
+  return { date: today(), spent: { roll: 0, sample: 0, upload: 0, osborne: 0, backlog: 0 } };
+}
+function save(j) {
+  const tmp = `${ledgerFile()}.tmp-${process.pid}`;
+  fs.writeFileSync(tmp, JSON.stringify(j, null, 2));
+  fs.renameSync(tmp, ledgerFile());
+}
+
+/**
+ * take(who, n) -> integer granted (0..n). Bounded by both the per-phase cap for
+ * `who` AND the global daily ceiling. Records the grant. Fail-open on any error.
+ */
+function take(who, n) {
+  if (!VALID.has(who)) throw new Error(`budget.take: who must be roll|sample|upload|osborne, got ${who}`);
+  n = Math.max(0, parseInt(n, 10) || 0);
+  if (!n) return 0;
+  try {
+    return withLock(() => {
+      const ph = phase();
+      const j = load();
+      const spentTotal = j.spent.roll + j.spent.sample + j.spent.upload + j.spent.osborne + j.spent.backlog;
+      let remainingGlobal = Math.max(0, BUDGET_TOTAL - spentTotal);
+      // Hard floor: subtract osborne's unspent reserved slice from what every OTHER category may
+      // draw, so roll/sample/upload can't starve the finite Osborne onboard. Osborne itself draws
+      // against the full remaining (it owns the reservation).
+      if (who !== 'osborne') remainingGlobal = Math.max(0, remainingGlobal - osborneReservation(j.spent.osborne));
+      let remainingWho = Math.max(0, ph.caps[who] - j.spent[who]);
+      // end-of-day reclaim: backlog may sweep the global unspent remainder in late slots
+      if (who === 'backlog' && reclaimActive()) remainingWho = remainingGlobal;
+      const grant = Math.min(n, remainingWho, remainingGlobal);
+      if (grant > 0) { j.spent[who] += grant; save(j); }
+      return grant;
+    });
+  } catch (e) {
+    process.stderr.write(`[budget] WARN fail-open (${e.message}) — granting ${n} for ${who}\n`);
+    return n; // never freeze a live pipeline on a ledger fault
+  }
+}
+
+/**
+ * refund(who, n) -> integer actually returned (0..n). The inverse of take():
+ * returns an UNUSED grant to the ledger. Used when a caller took() up front to
+ * size a batch but did not consume the whole grant — e.g. the cadence wrapper
+ * grants 36 variants to size --max, but a Shopify-429 / exit-3 slot creates 0
+ * products (refund 36) or a partial slot creates fewer (refund the remainder).
+ * Floored at j.spent[who] so a double-refund or bogus over-refund can NEVER push
+ * spent below 0 (which would synthesize budget that was never granted). Same
+ * mkdir-mutex as take(). Fail-CLOSED: on a ledger fault it no-ops (leaves the
+ * already-recorded debit in place) rather than fabricating headroom.
+ */
+function refund(who, n) {
+  if (!VALID.has(who)) throw new Error(`budget.refund: who must be roll|sample|upload|osborne, got ${who}`);
+  n = Math.max(0, parseInt(n, 10) || 0);
+  if (!n) return 0;
+  try {
+    return withLock(() => {
+      const j = load();
+      const back = Math.min(n, j.spent[who]);   // never refund more than is actually spent → spent stays >= 0
+      if (back > 0) { j.spent[who] -= back; save(j); }
+      return back;
+    });
+  } catch (e) {
+    process.stderr.write(`[budget] WARN refund fail-closed (${e.message}) — no-op for ${who}\n`);
+    return 0; // a failed refund leaves the debit in place — conservative (never over-grants)
+  }
+}
+
+/**
+ * remaining(who) -> integer headroom for `who` right now (min of per-phase cap
+ * and global ceiling), WITHOUT consuming. Use to gate a loop whose creates may
+ * no-op/skip internally (consume with take() only on a real creation). Fail-open
+ * returns Infinity so a ledger fault never freezes a live pipeline.
+ */
+function remaining(who) {
+  if (!VALID.has(who)) throw new Error(`budget.remaining: who must be roll|sample|upload|osborne, got ${who}`);
+  try {
+    return withLock(() => {
+      const ph = phase();
+      const j = load();
+      const spentTotal = j.spent.roll + j.spent.sample + j.spent.upload + j.spent.osborne + j.spent.backlog;
+      let globalLeft = Math.max(0, BUDGET_TOTAL - spentTotal);
+      if (who !== 'osborne') globalLeft = Math.max(0, globalLeft - osborneReservation(j.spent.osborne));
+      if (who === 'backlog' && reclaimActive()) return globalLeft;
+      return Math.min(Math.max(0, ph.caps[who] - j.spent[who]), globalLeft);
+    });
+  } catch (e) { return Infinity; }
+}
+
+function status() {
+  const ph = phase();
+  const j = load();
+  const spentTotal = j.spent.roll + j.spent.sample + j.spent.upload + j.spent.osborne + j.spent.backlog;
+  const globalLeft = Math.max(0, BUDGET_TOTAL - spentTotal);
+  const osborneReserved = osborneReservation(j.spent.osborne);   // unspent hard floor (auto-zeroes on completion)
+  const othersGlobal = Math.max(0, globalLeft - osborneReserved); // what roll/sample/upload may draw
+  return {
+    date: j.date, budgetTotal: BUDGET_TOTAL,
+    phase: ph.rollPhase ? 'roll-priority' : 'backfill-priority',
+    rollBacklogRemaining: ph.rollBacklogRemaining, buildable: ph.buildable, rollsDone: ph.done,
+    osborneFloor: OSBORNE_FLOOR, osborneReserved,
+    backlogReclaim: { hour: RECLAIM_HOUR, active: reclaimActive() },
+    caps: ph.caps, spent: j.spent, spentTotal,
+    remaining: { roll: Math.min(Math.max(0, ph.caps.roll - j.spent.roll), othersGlobal),
+      sample: Math.min(Math.max(0, ph.caps.sample - j.spent.sample), othersGlobal),
+      upload: Math.min(Math.max(0, ph.caps.upload - j.spent.upload), othersGlobal),
+      backlog: reclaimActive() ? othersGlobal
+        : Math.min(Math.max(0, ph.caps.backlog - j.spent.backlog), othersGlobal),
+      osborne: Math.min(Math.max(0, ph.caps.osborne - j.spent.osborne), globalLeft),
+      global: globalLeft },
+  };
+}
+
+module.exports = { take, refund, remaining, status, phase, BUDGET_TOTAL };
+
+// ── CLI: lets the bash/JS sample-split pipeline integrate without requiring() ──
+if (require.main === module) {
+  const [cmd, a, b] = process.argv.slice(2);
+  if (cmd === 'take') process.stdout.write(String(take(a, b)) + '\n');
+  else if (cmd === 'refund') process.stdout.write(String(refund(a, b)) + '\n');
+  else if (cmd === 'remaining') process.stdout.write(String(remaining(a)) + '\n');
+  else if (cmd === 'phase') process.stdout.write(JSON.stringify(phase(), null, 2) + '\n');
+  else if (cmd === 'status' || !cmd) process.stdout.write(JSON.stringify(status(), null, 2) + '\n');
+  else { process.stderr.write('usage: budget.cjs [status|phase|take <who> <n>|refund <who> <n>|remaining <who>]\n'); process.exit(1); }
+}

← d0cef0c auto-save: 2026-07-26T07:41:30 (2 files) — data/orphan-clean  ·  back to Designerwallcoverings  ·  auto-save: 2026-07-26T09:12:24 (2 files) — scripts/sample-sp ebe40ff →