← 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
M scripts/justin-david-pricing/publish-jd-batch.mjsA verification/tk11471/jd-publish-gate.test.mjs
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 →