[object Object]

← back to Designerwallcoverings

TK-11471: weight gate on the JD rolling publish (the 4th unguarded activate site)

ea8f8715f3786ff98ffcfc0e178346fad4f066f9 · 2026-09-11 11:44:34 -0700 · Steve Abrams

publish-jd-batch.mjs is a RECURRING rolling daily publish for the live "Los Angeles
Fabrics" line (DTD verdict B, 2026-06-18, human-gated), not a spent one-off. It already
ran a 5-condition QA gate before flipping DRAFT -> ACTIVE — image, metafields, >=2
variants, every price > 0, title/vendor — but had no weight condition, so it could
publish a product live at zero weight, which collapses an order into the lowest weight
tier / free-shipping band and mis-costs DW freight (Steve's TK-11414 rule).

- PRODUCT_QA now SELECTS inventoryItem measurement weight value+unit (plus sku/title and
  productType). Without this the gate would read a response that never carried weight,
  measure nothing, and pass 100% — the false-green class; none of the 9 sibling go-live
  queries selected it either.
- judge() gains a 6th condition via the shared allZeroWeightVariants(), which counts
  SAMPLES: dw-active-weight-canary FAILs on a zero-weight sample too (43 of the 83 live
  offenders measured 2026-09-11 were samples).
- HOLD, not self-heal, unlike the onboarder go-lives: this script is a pure QA gate that
  only ever flips status, so quarantining is in keeping with its design. Weight is set
  upstream at create; a zero here means the upstream payload regressed and should be seen.

verification/tk11471/jd-publish-gate.test.mjs — 11/11 PASS, offline. Every case is an
injected fault that must go red (zero sellable, zero SAMPLE, null measurement, negative,
both), plus GRAMS/KILOGRAMS conversion cases so the gate is not trigger-happy, plus three
structural checks that the gate cannot be vacuous: the query really selects weight, the
call really sits in judge(), and it precedes the ACTIVATE CALL SITE rather than merely the
mutation constant's declaration (that exact vacuous comparison was a real false green
caught earlier in this ticket).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJHxAzaEMMxado57mFjiCk

Files touched

Diff

commit ea8f8715f3786ff98ffcfc0e178346fad4f066f9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 11 11:44:34 2026 -0700

    TK-11471: weight gate on the JD rolling publish (the 4th unguarded activate site)
    
    publish-jd-batch.mjs is a RECURRING rolling daily publish for the live "Los Angeles
    Fabrics" line (DTD verdict B, 2026-06-18, human-gated), not a spent one-off. It already
    ran a 5-condition QA gate before flipping DRAFT -> ACTIVE — image, metafields, >=2
    variants, every price > 0, title/vendor — but had no weight condition, so it could
    publish a product live at zero weight, which collapses an order into the lowest weight
    tier / free-shipping band and mis-costs DW freight (Steve's TK-11414 rule).
    
    - PRODUCT_QA now SELECTS inventoryItem measurement weight value+unit (plus sku/title and
      productType). Without this the gate would read a response that never carried weight,
      measure nothing, and pass 100% — the false-green class; none of the 9 sibling go-live
      queries selected it either.
    - judge() gains a 6th condition via the shared allZeroWeightVariants(), which counts
      SAMPLES: dw-active-weight-canary FAILs on a zero-weight sample too (43 of the 83 live
      offenders measured 2026-09-11 were samples).
    - HOLD, not self-heal, unlike the onboarder go-lives: this script is a pure QA gate that
      only ever flips status, so quarantining is in keeping with its design. Weight is set
      upstream at create; a zero here means the upstream payload regressed and should be seen.
    
    verification/tk11471/jd-publish-gate.test.mjs — 11/11 PASS, offline. Every case is an
    injected fault that must go red (zero sellable, zero SAMPLE, null measurement, negative,
    both), plus GRAMS/KILOGRAMS conversion cases so the gate is not trigger-happy, plus three
    structural checks that the gate cannot be vacuous: the query really selects weight, the
    call really sits in judge(), and it precedes the ACTIVATE CALL SITE rather than merely the
    mutation constant's declaration (that exact vacuous comparison was a real false green
    caught earlier in this ticket).
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01FJHxAzaEMMxado57mFjiCk
---
 scripts/justin-david-pricing/publish-jd-batch.mjs | 14 ++++-
 verification/tk11471/jd-publish-gate.test.mjs     | 74 +++++++++++++++++++++++
 2 files changed, 86 insertions(+), 2 deletions(-)

diff --git a/scripts/justin-david-pricing/publish-jd-batch.mjs b/scripts/justin-david-pricing/publish-jd-batch.mjs
index d61b00b..96f197a 100644
--- a/scripts/justin-david-pricing/publish-jd-batch.mjs
+++ b/scripts/justin-david-pricing/publish-jd-batch.mjs
@@ -35,6 +35,7 @@
  *   node publish-jd-batch.mjs --execute --i-am-steve   # LIVE publish (Steve-gated)
  */
 import fs from 'node:fs';
+import { allZeroWeightVariants } from '../lib/weight-guard.mjs'; // GUARD TK-11471 (weight go-live)
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 
@@ -73,10 +74,10 @@ async function gql(q, v) {
 }
 
 const PRODUCT_QA = `query($id:ID!){ product(id:$id){
-  id status title vendor
+  id status title vendor productType
   media(first:1){ nodes{ id } }
   metafields(first:3){ nodes{ id } }
-  variants(first:5){ nodes{ price } } } }`;
+  variants(first:5){ nodes{ sku title price inventoryItem{ id measurement{ weight{ value unit } } } } } } }`;
 const PRODUCT_ACTIVATE = `mutation($id:ID!){ productUpdate(input:{id:$id, status:ACTIVE}){
   product{ id status } userErrors{ field message } } }`;
 
@@ -92,6 +93,15 @@ function judge(p) {
   if (vs.some(v => !(parseFloat(v.price) > 0))) reasons.push('a variant price is 0/blank');
   if (!p.title) reasons.push('no title');
   if (p.vendor !== VENDOR) reasons.push(`vendor "${p.vendor}" != "${VENDOR}"`);
+  // TK-11471 (TK-11414 rule): never publish a product ACTIVE at zero/missing weight — zero weight
+  // collapses an order into the lowest weight tier / free-shipping band and mis-costs DW freight.
+  // 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 this checks EVERY variant, not just sellables.
+  // This is a HOLD, not a self-heal: publish-jd-batch is a pure QA gate that only ever flips status
+  // (DTD verdict B 2026-06-18, human-gated), so quarantining is in keeping — the weight is set
+  // upstream at create, and a zero here means the upstream payload regressed and should be seen.
+  const zw = allZeroWeightVariants({ variants: vs, productType: p.productType });
+  if (zw.length) reasons.push(`zero/missing weight on ${zw.length} variant(s): ${zw.map(v => v.sku || v.title || '?').join(', ')}`);
   return { verdict: reasons.length ? 'fail' : 'publish', reasons, product: p };
 }
 
diff --git a/verification/tk11471/jd-publish-gate.test.mjs b/verification/tk11471/jd-publish-gate.test.mjs
new file mode 100644
index 0000000..70df237
--- /dev/null
+++ b/verification/tk11471/jd-publish-gate.test.mjs
@@ -0,0 +1,74 @@
+// jd-publish-gate.test.mjs — TK-11471 negative test for the weight condition added to
+// scripts/justin-david-pricing/publish-jd-batch.mjs (the recurring rolling daily publish for the
+// live "Los Angeles Fabrics" line). Offline: zero network, zero DB, zero Shopify.
+//
+// A positive-only test on a gate proves nothing (CLAUDE.md TK-11431 amendment 3), so every case
+// here is an injected fault that must come back RED — plus the two structural checks that catch
+// the ways this gate could silently pass everything:
+//   (S1) the QA query must actually SELECT weight. A gate reading a response that never carried
+//        weight measures nothing and passes 100%. None of the 9 go-live queries selected it.
+//   (S2) the gate must sit in judge(), i.e. on the path that decides 'publish'.
+import { allZeroWeightVariants } from '../../scripts/lib/weight-guard.mjs';
+import fs from 'node:fs';
+import assert from 'node:assert';
+
+const SRC = fs.readFileSync(new URL('../../scripts/justin-david-pricing/publish-jd-batch.mjs', import.meta.url), 'utf8');
+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++; } };
+
+// judge()'s weight condition, replicated exactly as wired (same call, same shape).
+const weightReasons = (p) => {
+  const vs = p.variants?.nodes || [];
+  const zw = allZeroWeightVariants({ variants: vs, productType: p.productType });
+  return zw.length ? [`zero/missing weight on ${zw.length} variant(s)`] : [];
+};
+const V = (sku, lb, unit = 'POUNDS') => ({ sku, title: sku, price: '10.00',
+  inventoryItem: { id: 'gid://shopify/InventoryItem/1', measurement: lb == null ? null : { weight: { value: lb, unit } } } });
+
+// ── S1/S2: structural — the gate cannot be vacuous ────────────────────────────────────────────
+t('S1 QA query SELECTS weight (else the gate measures nothing and passes 100%)', () => {
+  const q = SRC.slice(SRC.indexOf('const PRODUCT_QA'), SRC.indexOf('const PRODUCT_ACTIVATE'));
+  assert.ok(/measurement\s*\{\s*weight\s*\{\s*value\s+unit/.test(q), 'PRODUCT_QA does not select inventoryItem measurement weight value+unit');
+});
+t('S2 weight gate lives inside judge(), before the publish verdict', () => {
+  const j = SRC.slice(SRC.indexOf('function judge('), SRC.indexOf("return { verdict: reasons.length"));
+  assert.ok(j.includes('allZeroWeightVariants'), 'judge() does not call allZeroWeightVariants');
+});
+t('S2b gate runs BEFORE the ACTIVATE call site (not merely before the constant)', () => {
+  // compare against the CALL, not the mutation's declaration near the top of file — comparing
+  // against the declaration is vacuously true and was a real false green in this ticket.
+  assert.ok(SRC.indexOf('allZeroWeightVariants(') < SRC.indexOf('gql(PRODUCT_ACTIVATE'),
+    'weight gate does not precede the productUpdate ACTIVATE call');
+});
+
+// ── injected faults: each MUST go red ─────────────────────────────────────────────────────────
+t('zero-weight SELLABLE variant => RED', () => {
+  assert.strictEqual(weightReasons({ productType: 'Fabric', variants: { nodes: [V('DWLA-1', 0), V('DWLA-1-Sample', 0.25)] } }).length, 1);
+});
+t('zero-weight SAMPLE variant => RED (samples count; the canary FAILs on them too)', () => {
+  assert.strictEqual(weightReasons({ productType: 'Fabric', variants: { nodes: [V('DWLA-1', 1), V('DWLA-1-Sample', 0)] } }).length, 1);
+});
+t('NULL measurement (never set) => RED, not silently OK', () => {
+  assert.strictEqual(weightReasons({ productType: 'Fabric', variants: { nodes: [V('DWLA-1', null)] } }).length, 1);
+});
+t('negative weight => RED', () => {
+  assert.strictEqual(weightReasons({ productType: 'Fabric', variants: { nodes: [V('DWLA-1', -1)] } }).length, 1);
+});
+t('BOTH variants zero => RED and names both', () => {
+  const r = allZeroWeightVariants({ variants: [V('DWLA-1', 0), V('DWLA-1-Sample', 0)], productType: 'Fabric' });
+  assert.strictEqual(r.length, 2);
+});
+
+// ── the gate must not be trigger-happy either: a noisy detector dies the same death ────────────
+t('both variants properly weighted => GREEN', () => {
+  assert.strictEqual(weightReasons({ productType: 'Fabric', variants: { nodes: [V('DWLA-1', 1), V('DWLA-1-Sample', 0.25)] } }).length, 0);
+});
+t('weight expressed in GRAMS converts and is NOT falsely flagged', () => {
+  assert.strictEqual(weightReasons({ productType: 'Fabric', variants: { nodes: [V('DWLA-1', 1360, 'GRAMS')] } }).length, 0);
+});
+t('weight expressed in KILOGRAMS converts and is NOT falsely flagged', () => {
+  assert.strictEqual(weightReasons({ productType: 'Fabric', variants: { nodes: [V('DWLA-1', 1.36, 'KILOGRAMS')] } }).length, 0);
+});
+
+console.log(`\n${fail ? 'TESTS FAILED' : 'ALL TESTS PASS'} — ${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);

← 4d31a1a TK-11471: weight gate on the DWPW->GRS migration (the actual  ·  back to Designerwallcoverings  ·  TK-11471: inert executor so the gated backfill memo is actua 0876e0f →