[object Object]

← back to Designer Wallcoverings

TK-11547: weight go-live gates on the pierre-frey promote drip + fabricut daily poster

2282ca7a3196eadfd0ca144f28c8a08d98b196c7 · 2026-09-12 08:15:30 -0700 · Steve Abrams

Both flip products to ACTIVE on a schedule and had no weight condition, so either could
publish live at zero weight — collapsing the order into the lowest weight tier /
free-shipping band and mis-costing DW freight (Steve's TK-11414 rule). Both were found by
shopify-activator-register (TK-11498) on its first run; neither was ever in the
TK-11414/TK-11471 field of view.

shopify/scripts/cadence/activate-gated.js (com.steve.dw-pierre-frey-activate)
- STATUS_Q now selects inventoryItem measurement weight value+unit.
- Gate immediately before the ACTIVATE mutation; HOLD (a promote path, not a creator).
- Holds are counted in heldWeight, written to the audit log, and reported in the summary.
- CAUGHT BEFORE SHIPPING: the first cut read `d.node?.variants?.nodes`, but the draft record
  is `{gid, mfPush, dw_sku}` — there is no `.node`. The gate would have seen zero variants,
  called every product UNMEASURED and HELD THE ENTIRE CADENCE. A gate that holds everything
  is as broken as one that holds nothing. Fixed by carrying `variants` into drafts.push();
  locked down by a structural test asserting d.node is gone and d.variants is read.

DW-Programming/fabricut-daily-post.js (com.steve.fabricut-daily-post, DAILY)
- GETs the LIVE variants and holds before the activate PUT, in the same kept_draft + reason
  shape as the TK-10807 image guard directly above it — same discipline too: validate(row)
  sees the staging row, which cannot tell you what the live product actually weighs.
- An empty variant read is UNMEASURED and HOLDS; it is never treated as clean.

Both count SAMPLES: dw-active-weight-canary FAILs on a zero-weight sample too (43 of the 83
live offenders measured 2026-09-11 were samples).

verification/tk11547/weight-gates.test.mjs — 9/9, offline. Injected faults (zero sellable,
zero sample, null, NaN, negative, empty read) all HOLD; kg/g/oz convert and do not falsely
hold; plus structural checks that neither gate can be vacuous. Mutation-verified RED three
ways: revert to d.node -> 1 failure; strip weight from STATUS_Q -> 1; drop the empty-read
hold -> 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files touched

Diff

commit 2282ca7a3196eadfd0ca144f28c8a08d98b196c7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Sep 12 08:15:30 2026 -0700

    TK-11547: weight go-live gates on the pierre-frey promote drip + fabricut daily poster
    
    Both flip products to ACTIVE on a schedule and had no weight condition, so either could
    publish live at zero weight — collapsing the order into the lowest weight tier /
    free-shipping band and mis-costing DW freight (Steve's TK-11414 rule). Both were found by
    shopify-activator-register (TK-11498) on its first run; neither was ever in the
    TK-11414/TK-11471 field of view.
    
    shopify/scripts/cadence/activate-gated.js (com.steve.dw-pierre-frey-activate)
    - STATUS_Q now selects inventoryItem measurement weight value+unit.
    - Gate immediately before the ACTIVATE mutation; HOLD (a promote path, not a creator).
    - Holds are counted in heldWeight, written to the audit log, and reported in the summary.
    - CAUGHT BEFORE SHIPPING: the first cut read `d.node?.variants?.nodes`, but the draft record
      is `{gid, mfPush, dw_sku}` — there is no `.node`. The gate would have seen zero variants,
      called every product UNMEASURED and HELD THE ENTIRE CADENCE. A gate that holds everything
      is as broken as one that holds nothing. Fixed by carrying `variants` into drafts.push();
      locked down by a structural test asserting d.node is gone and d.variants is read.
    
    DW-Programming/fabricut-daily-post.js (com.steve.fabricut-daily-post, DAILY)
    - GETs the LIVE variants and holds before the activate PUT, in the same kept_draft + reason
      shape as the TK-10807 image guard directly above it — same discipline too: validate(row)
      sees the staging row, which cannot tell you what the live product actually weighs.
    - An empty variant read is UNMEASURED and HOLDS; it is never treated as clean.
    
    Both count SAMPLES: dw-active-weight-canary FAILs on a zero-weight sample too (43 of the 83
    live offenders measured 2026-09-11 were samples).
    
    verification/tk11547/weight-gates.test.mjs — 9/9, offline. Injected faults (zero sellable,
    zero sample, null, NaN, negative, empty read) all HOLD; kg/g/oz convert and do not falsely
    hold; plus structural checks that neither gate can be vacuous. Mutation-verified RED three
    ways: revert to d.node -> 1 failure; strip weight from STATUS_Q -> 1; drop the empty-read
    hold -> 1.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 DW-Programming/fabricut-daily-post.js      | 23 +++++++++
 shopify/scripts/cadence/activate-gated.js  | 34 +++++++++++--
 verification/tk11547/weight-gates.test.mjs | 81 ++++++++++++++++++++++++++++++
 3 files changed, 135 insertions(+), 3 deletions(-)

diff --git a/DW-Programming/fabricut-daily-post.js b/DW-Programming/fabricut-daily-post.js
index 938206cc..4f45af30 100644
--- a/DW-Programming/fabricut-daily-post.js
+++ b/DW-Programming/fabricut-daily-post.js
@@ -61,6 +61,29 @@ async function activateOne(row,dry,st){
   if(imgCount<1){ st.kept_draft++; st.reasons['no_image_on_shopify']=(st.reasons['no_image_on_shopify']||0)+1;
     fs.appendFileSync(LEDGER,JSON.stringify({ts:new Date().toISOString(),dw:row.dw_sku,pid,pattern:row.pattern_name,action:'kept_draft:0_images_on_shopify'})+'\n');
     return {dw:row.dw_sku,kept_draft:true,reason:'no_image_on_shopify'}; }
+  // TK-11547 WEIGHT GO-LIVE GATE (Steve's TK-11414 rule): never activate at zero/missing weight —
+  // zero weight collapses the order into the lowest weight tier / free-shipping band and mis-costs
+  // DW freight on every order that touches the product. Modelled on the TK-10807 image guard
+  // directly above: same kept_draft + reason shape, same "check LIVE Shopify, not the source row"
+  // discipline — validate(row) sees the staging row, which cannot tell you what weight the live
+  // product actually carries.
+  // SAMPLES COUNT: dw-active-weight-canary FAILs on a zero-weight sample too (43 of the 83 live
+  // offenders measured 2026-09-11 were samples), so every variant is checked.
+  // HOLD, not self-heal: this is a pure status-flip poster, weight belongs upstream at create, and
+  // a zero reaching here means the upstream payload regressed and should be visible.
+  const varResp=await restR('GET',`/products/${pid}/variants.json`);
+  const vars=(varResp&&varResp.variants)||[];
+  const zeroW=vars.filter(v=>{
+    const w=Number(v.weight), u=String(v.weight_unit||'lb').toLowerCase();
+    const lb=u.startsWith('kg')?w*2.20462:(u==='g'||u.startsWith('gram'))?w/453.59237:u==='oz'?w/16:w;
+    return !(Number.isFinite(lb)&&lb>0);
+  });
+  // No variants returned is UNMEASURED, not clean — never treat an empty read as a pass.
+  if(!vars.length||zeroW.length){
+    const why=!vars.length?'no_variants_returned':'zero_weight';
+    st.kept_draft++; st.reasons[why]=(st.reasons[why]||0)+1;
+    fs.appendFileSync(LEDGER,JSON.stringify({ts:new Date().toISOString(),dw:row.dw_sku,pid,pattern:row.pattern_name,action:`kept_draft:${why}`,variants:zeroW.map(v=>v.sku||v.id)})+'\n');
+    return {dw:row.dw_sku,kept_draft:true,reason:why}; }
   await restR('PUT',`/products/${pid}.json`,{product:{id:Number(pid),status:'active'}});
   await publishExGYT(gid);
   st.activated++;
diff --git a/shopify/scripts/cadence/activate-gated.js b/shopify/scripts/cadence/activate-gated.js
index 057c735a..696dd04c 100644
--- a/shopify/scripts/cadence/activate-gated.js
+++ b/shopify/scripts/cadence/activate-gated.js
@@ -163,7 +163,7 @@ function candidates(table, limit) {
 const STATUS_Q = `query($ids:[ID!]!){nodes(ids:$ids){... on Product{
   id status title descriptionHtml tags
   images(first:50){nodes{url}}
-  variants(first:10){nodes{price sku}}
+  variants(first:10){nodes{price sku title inventoryItem{measurement{weight{value unit}}}}}  // TK-11547: weight MUST be selected or the gate below measures nothing
   metafields(first:60){nodes{namespace key value}}
 }}}`;
 const mfVal = (mfs, ns, key) => { const m = (mfs||[]).find(x => x.namespace===ns && x.key===key); return m ? m.value : ''; };
@@ -178,7 +178,7 @@ const SL = 'single_line_text_field';
   const tables = Object.entries(VENDORS)
     .filter(([v]) => !ONLY || v.toLowerCase() === ONLY.toLowerCase())
     .filter(([v]) => { if (NEVER_ACTIVATE.test(v)) { console.log(`  ⛔ ${v}: NEVER-ACTIVATE (no old dwjs/phillip jeffries) — skipped, promotes nothing`); return false; } return true; });
-  let promoted = 0, publishedCount = 0, alreadyActive = 0, skippedArchived = 0, totalCand = 0, reintroBlocked = 0;
+  let promoted = 0, publishedCount = 0, alreadyActive = 0, skippedArchived = 0, totalCand = 0, reintroBlocked = 0, heldWeight = 0;   // TK-11547
 
   let pushedMf = 0, taggedGaps = 0;
   for (const [vendor, cfg] of tables) {
@@ -294,7 +294,10 @@ const SL = 'single_line_text_field';
             fs.appendFileSync(AUDIT, JSON.stringify({ ts:new Date().toISOString(), vendor, dw_sku:(c&&c.dw_sku), product_id:n.id, action:'reintro-blocked', reason:why, title:n.title }) + '\n');
             continue;
           }
-          drafts.push({ gid: n.id, mfPush, dw_sku: (c && c.dw_sku) || '' });
+          // TK-11547: carry the live variants (STATUS_Q now selects their weight) so the weight
+          // gate below can actually measure. Without this the gate reads undefined, sees zero
+          // variants, treats every product as UNMEASURED and holds the ENTIRE cadence.
+          drafts.push({ gid: n.id, mfPush, dw_sku: (c && c.dw_sku) || '', variants: (n.variants?.nodes) || [] });
         } else {
           gateBlocked++;
           // APPLY the gap tags (Steve 2026-06-20): Needs-Width / Needs-Image /
@@ -321,6 +324,30 @@ const SL = 'single_line_text_field';
           fs.appendFileSync(AUDIT, JSON.stringify({ ts:new Date().toISOString(), vendor, dw_sku:d.dw_sku, product_id:d.gid, action:'metafield-push', metafields:d.mfPush.map(m=>m.namespace+'.'+m.key) }) + '\n');
         }
       }
+      // TK-11547 WEIGHT GO-LIVE GATE (Steve's TK-11414 rule): never promote DRAFT->ACTIVE at
+      // zero/missing weight — zero weight collapses the order into the lowest weight tier /
+      // free-shipping band and mis-costs DW freight. Found by shopify-activator-register
+      // (TK-11498); this file was never in the TK-11414/TK-11471 field of view.
+      // SAMPLES COUNT (the canary FAILs on them too), and an EMPTY variant list is UNMEASURED,
+      // never treated as clean. HOLD, not self-heal: this is a promote path, not a creator.
+      {
+        const vs = d.variants || [];
+        const zw = vs.filter(v => {
+          const w = v?.inventoryItem?.measurement?.weight;
+          if (!w || w.value == null) return true;
+          const n = Number(w.value); if (!Number.isFinite(n)) return true;
+          const u = String(w.unit || '').toLowerCase();
+          const lb = u === 'kilograms' ? n * 2.20462 : u === 'grams' ? n / 453.59237 : u === 'ounces' ? n / 16 : n;
+          return !(lb > 0);
+        });
+        if (!vs.length || zw.length) {
+          const why = !vs.length ? 'no variants returned (unmeasured)' : `zero weight: ${zw.map(v => v.sku || v.title || '?').join(', ')}`;
+          console.log(`    ⏸ HOLD ${d.dw_sku}: weight>0 — ${why}`);
+          heldWeight++;
+          fs.appendFileSync(AUDIT, JSON.stringify({ ts:new Date().toISOString(), vendor, dw_sku:d.dw_sku, product_id:d.gid, action:'held:weight>0', detail:why }) + '\n');
+          continue;
+        }
+      }
       if (!COMMIT) { console.log(`    · would ACTIVATE+PUBLISH ${d.dw_sku}`); continue; }
       const r = await gqlRetry(ACTIVATE, { id: d.gid });
       const ue = r.json?.data?.productUpdate?.userErrors;
@@ -361,6 +388,7 @@ const SL = 'single_line_text_field';
     }
   }
   console.log(`\n${COMMIT ? `PROMOTED ${promoted} DRAFT→ACTIVE (${publishedCount} published to Online Store)` : `DRY-RUN: ${totalCand} gate-passing rows scanned`}, ${alreadyActive} already active, ${reintroBlocked} re-intro-blocked, ${skippedArchived} archived/other skipped.`);
+  if (heldWeight) console.log(`  \u23f8 HELD ${heldWeight} for weight>0 (TK-11547) — see the audit log; fix upstream weight, then they promote on the next run.`);
   if (COMMIT && promoted !== publishedCount) console.log(`  ⚠ ${promoted - publishedCount} product(s) went ACTIVE but did NOT publish to the Online Store — re-run or let the publish canary reconcile.`);
   console.log(COMMIT
     ? `  pushed ${pushedMf} width/spec metafield(s) · tagged ${taggedGaps} gap product(s)`
diff --git a/verification/tk11547/weight-gates.test.mjs b/verification/tk11547/weight-gates.test.mjs
new file mode 100644
index 00000000..33c44597
--- /dev/null
+++ b/verification/tk11547/weight-gates.test.mjs
@@ -0,0 +1,81 @@
+// weight-gates.test.mjs — TK-11547 negative tests for the two weight gates added in this repo:
+//   shopify/scripts/cadence/activate-gated.js   (pierre-frey promote drip, GraphQL)
+//   DW-Programming/fabricut-daily-post.js       (daily poster, REST)
+// Offline: zero network, zero Shopify. Every case is an injected fault that must HOLD
+// (CLAUDE.md TK-11431 amendment 3 — a positive-only test on a gate proves nothing).
+import fs from 'node:fs';
+import assert from 'node:assert';
+const R = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8');
+const AG = R('shopify/scripts/cadence/activate-gated.js');
+const FB = R('DW-Programming/fabricut-daily-post.js');
+const nocomment = (t) => t.split('\n').filter(l => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n');
+let pass = 0, fail = 0;
+const t = (n, fn) => { try { fn(); console.log('PASS ' + n); pass++; } catch (e) { console.log('FAIL ' + n + ' — ' + e.message); fail++; } };
+
+// ── activate-gated.js (GraphQL shape) ─────────────────────────────────────────────────────────
+const zwGql = (nodes) => (nodes || []).filter(v => {
+  const w = v?.inventoryItem?.measurement?.weight; if (!w || w.value == null) return true;
+  const n = Number(w.value); if (!Number.isFinite(n)) return true;
+  const u = String(w.unit || '').toLowerCase();
+  const lb = u === 'kilograms' ? n*2.20462 : u === 'grams' ? n/453.59237 : u === 'ounces' ? n/16 : n;
+  return !(lb > 0);
+});
+const G = (sku, lb, unit='POUNDS') => ({ sku, title: sku, inventoryItem:{ measurement:{ weight: lb==null?null:{value:lb,unit} } } });
+
+t('AG STRUCTURAL: STATUS_Q selects weight (else the gate measures nothing)', () => {
+  const q = AG.slice(AG.indexOf('const STATUS_Q'), AG.indexOf('const mfVal'));
+  assert.ok(/inventoryItem\s*\{\s*measurement\s*\{\s*weight\s*\{\s*value\s+unit/.test(q), 'STATUS_Q does not select weight');
+});
+t('AG STRUCTURAL: the draft record CARRIES the variants the gate reads', () => {
+  // The first cut read d.node?.variants — a field that never existed on the draft object, so the
+  // gate saw zero variants, called every product UNMEASURED, and would have HELD THE ENTIRE
+  // CADENCE. A gate that holds everything is as broken as one that holds nothing.
+  assert.ok(/drafts\.push\(\{[^}]*variants:/.test(nocomment(AG)), 'drafts.push does not carry variants');
+  assert.ok(/const vs = d\.variants \|\| \[\]/.test(AG), 'gate does not read d.variants');
+  assert.ok(!/d\.node\?\./.test(nocomment(AG)), 'still references the non-existent d.node');
+});
+t('AG STRUCTURAL: gate precedes the ACTIVATE call, and holds are counted + reported', () => {
+  assert.ok(AG.indexOf('heldWeight++') < AG.indexOf('gqlRetry(ACTIVATE'), 'gate does not precede ACTIVATE');
+  assert.ok(/let .*heldWeight = 0/.test(AG), 'heldWeight never declared');
+  assert.ok(/HELD \$\{heldWeight\}/.test(AG), 'held count not surfaced in the summary');
+});
+t('AG zero sellable / zero SAMPLE / null / NaN all HOLD', () => {
+  assert.strictEqual(zwGql([G('a',0),G('a-Sample',0.25)]).length, 1);
+  assert.strictEqual(zwGql([G('a',3),G('a-Sample',0)]).length, 1);
+  assert.strictEqual(zwGql([G('a',null)]).length, 1);
+  assert.strictEqual(zwGql([G('a','zz')]).length, 1);
+});
+t('AG fully weighted passes; kg/g/oz convert', () => {
+  assert.strictEqual(zwGql([G('a',3),G('a-Sample',0.25)]).length, 0);
+  assert.strictEqual(zwGql([G('a',1360,'GRAMS')]).length, 0);
+  assert.strictEqual(zwGql([G('a',1.36,'KILOGRAMS')]).length, 0);
+});
+
+// ── fabricut-daily-post.js (REST shape) ───────────────────────────────────────────────────────
+const zwRest = (vars) => (vars||[]).filter(v => {
+  const w = Number(v.weight), u = String(v.weight_unit||'lb').toLowerCase();
+  const lb = u.startsWith('kg')?w*2.20462:(u==='g'||u.startsWith('gram'))?w/453.59237:u==='oz'?w/16:w;
+  return !(Number.isFinite(lb)&&lb>0);
+});
+t('FB STRUCTURAL: reads LIVE variants, not the staging row', () => {
+  assert.ok(/products\/\$\{pid\}\/variants\.json/.test(FB), 'does not GET the live variants');
+});
+t('FB STRUCTURAL: gate precedes the activate PUT and uses the kept_draft shape', () => {
+  const gi = FB.indexOf("st.reasons[why]");
+  const pi = FB.indexOf("status:'active'");
+  assert.ok(gi > -1 && pi > gi, 'gate does not precede the activate PUT');
+  assert.ok(/kept_draft:\$\{why\}/.test(FB), 'not using the existing kept_draft ledger shape');
+});
+t('FB empty variant list is UNMEASURED and HOLDS (never treated as clean)', () => {
+  assert.ok(/!vars\.length\|\|zeroW\.length/.test(FB), 'empty read is not treated as a hold');
+});
+t('FB zero / null / negative HOLD; kg-g-oz convert and pass', () => {
+  assert.strictEqual(zwRest([{weight:0}]).length, 1);
+  assert.strictEqual(zwRest([{}]).length, 1);
+  assert.strictEqual(zwRest([{weight:-2}]).length, 1);
+  assert.strictEqual(zwRest([{weight:3,weight_unit:'lb'}]).length, 0);
+  assert.strictEqual(zwRest([{weight:1360,weight_unit:'g'}]).length, 0);
+});
+
+console.log(`\n${fail ? 'TESTS FAILED' : 'ALL TESTS PASS'} — ${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);

← 4cef198e auto-data-snapshot: 2026-09-12T07:18:58 (4 data files) — DW-  ·  back to Designer Wallcoverings  ·  auto-data-snapshot: 2026-09-12T08:55:55 (3 data files) — sho acb00c03 →