[object Object]

← back to Dw Rotation Activator

TK-11471: weight go-live gate on the rotation activator (highest blast radius in the fleet)

9f91f6ce4533fbea14036c38f79cea1cabbda689 · 2026-09-11 11:55:26 -0700 · Steve Abrams

This is the biggest unguarded path found in the TK-11414 lineage, and it was missed by
every prior sweep — including mine. com.steve.dw-rotation-activator fires HOURLY at :25
and rotate-activate.js is a GLOBAL DRAFT->ACTIVE funnel: lib/rotation-order.js queries
`WHERE status = 'DRAFT'` across the whole shopify_products catalog, not scoped to any
vendor, capped at 500/day, against a queue of ~1,396. `grep -rn weight` over the entire
repo returned ZERO hits. Its documented "5-field gate" (sample variant, sellable variant,
price>0, description, >=2 tags, width) simply had no weight condition.

It is currently activating ~0/run only because an unrelated mfr-provenance gate is
blocking 1,393 of the queue. That is not safety — it is a coincidence, and clearing that
backlog is the drain's explicit purpose. The moment it clears, this flips thousands of
products ACTIVE with no weight check, independent of every creator this ticket fixed.

- lib/weight-gate.js: self-contained (same decoupling rationale as this repo's
  private-label leak guard, DTD verdict B 2026-07-21 — a ~/Projects app does not
  cross-import another app's tree). Constants identical to the canonical
  designerwallcoverings/scripts/lib/weight-guard.mjs and lib/weight_guard.py.
  SAMPLES COUNT (43 of the 83 live offenders were samples); the .mjs's sample-filtering
  zeroWeightBlockers() bug is deliberately not reproduced.
  FAIL-SAFE: a response that does not carry the weight FIELD is a failure, not a pass —
  a future query regression that drops the field cannot silently re-open the hole behind
  a green gate.
- rotate-activate.js: STATUS_Q now selects inventoryItem measurement weight value+unit
  (without this the gate would measure nothing and pass 100%), productType added, and
  weight.ok ANDed into `passes` alongside gate/extra/leak/mfr. HOLDs rather than
  self-heals, matching every other gate here: this script activates, it is not a creator.

test/weight-gate.test.js — 16/16 PASS, offline. Includes 4 structural checks that the
gate cannot be vacuous (query really selects weight; weight.ok really reaches `passes`;
gate precedes the ACTIVATE call site; constants have not drifted from the canonical .mjs)
and unit-conversion cases so it is not trigger-happy. Mutation-verified RED on scratch
copies: strip weight from STATUS_Q -> 1 failure; drop weight.ok from `passes` -> 1
failure; reintroduce the sample-filtering bug -> 2 failures.

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

Files touched

Diff

commit 9f91f6ce4533fbea14036c38f79cea1cabbda689
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 11 11:55:26 2026 -0700

    TK-11471: weight go-live gate on the rotation activator (highest blast radius in the fleet)
    
    This is the biggest unguarded path found in the TK-11414 lineage, and it was missed by
    every prior sweep — including mine. com.steve.dw-rotation-activator fires HOURLY at :25
    and rotate-activate.js is a GLOBAL DRAFT->ACTIVE funnel: lib/rotation-order.js queries
    `WHERE status = 'DRAFT'` across the whole shopify_products catalog, not scoped to any
    vendor, capped at 500/day, against a queue of ~1,396. `grep -rn weight` over the entire
    repo returned ZERO hits. Its documented "5-field gate" (sample variant, sellable variant,
    price>0, description, >=2 tags, width) simply had no weight condition.
    
    It is currently activating ~0/run only because an unrelated mfr-provenance gate is
    blocking 1,393 of the queue. That is not safety — it is a coincidence, and clearing that
    backlog is the drain's explicit purpose. The moment it clears, this flips thousands of
    products ACTIVE with no weight check, independent of every creator this ticket fixed.
    
    - lib/weight-gate.js: self-contained (same decoupling rationale as this repo's
      private-label leak guard, DTD verdict B 2026-07-21 — a ~/Projects app does not
      cross-import another app's tree). Constants identical to the canonical
      designerwallcoverings/scripts/lib/weight-guard.mjs and lib/weight_guard.py.
      SAMPLES COUNT (43 of the 83 live offenders were samples); the .mjs's sample-filtering
      zeroWeightBlockers() bug is deliberately not reproduced.
      FAIL-SAFE: a response that does not carry the weight FIELD is a failure, not a pass —
      a future query regression that drops the field cannot silently re-open the hole behind
      a green gate.
    - rotate-activate.js: STATUS_Q now selects inventoryItem measurement weight value+unit
      (without this the gate would measure nothing and pass 100%), productType added, and
      weight.ok ANDed into `passes` alongside gate/extra/leak/mfr. HOLDs rather than
      self-heals, matching every other gate here: this script activates, it is not a creator.
    
    test/weight-gate.test.js — 16/16 PASS, offline. Includes 4 structural checks that the
    gate cannot be vacuous (query really selects weight; weight.ok really reaches `passes`;
    gate precedes the ACTIVATE call site; constants have not drifted from the canonical .mjs)
    and unit-conversion cases so it is not trigger-happy. Mutation-verified RED on scratch
    copies: strip weight from STATUS_Q -> 1 failure; drop weight.ok from `passes` -> 1
    failure; reintroduce the sample-filtering bug -> 2 failures.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01FJHxAzaEMMxado57mFjiCk
---
 lib/weight-gate.js       | 91 ++++++++++++++++++++++++++++++++++++++++++++++++
 rotate-activate.js       | 21 ++++++++---
 test/weight-gate.test.js | 83 +++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 191 insertions(+), 4 deletions(-)

diff --git a/lib/weight-gate.js b/lib/weight-gate.js
new file mode 100644
index 0000000..13d09b8
--- /dev/null
+++ b/lib/weight-gate.js
@@ -0,0 +1,91 @@
+// weight-gate.js — TK-11471 (enforcing Steve's TK-11414 rule) for dw-rotation-activator.
+//
+// NO product may go DRAFT->ACTIVE with a missing/zero product WEIGHT. Zero weight collapses an
+// order into the lowest weight tier / free-shipping band and mis-costs DW freight on every order
+// that touches the product.
+//
+// WHY THIS FILE EXISTS SEPARATELY rather than importing ~/Projects/designerwallcoverings/scripts/
+// lib/weight-guard.mjs: same reasoning as this repo's private-label leak guard (DTD verdict B,
+// 2026-07-21) — a ~/Projects app does not cross-import another app's tree. Constants are kept
+// IDENTICAL to that module and to lib/weight_guard.py; weight-gate.test.js asserts that
+// mechanically against the .mjs source so the two cannot drift into disagreeing about what a
+// compliant weight is.
+//
+// SAMPLES COUNT. dw-active-weight-canary FAILs on ANY zero-weight ACTIVE variant — its live run
+// on 2026-09-11 split the offenders 43 sample / 40 sellable. The .mjs's zeroWeightBlockers()
+// filters samples OUT and is deprecated there; that bug is deliberately not reproduced here.
+//
+// FAIL-SAFE: returns {ok:false} on anything it cannot positively confirm. It can only BLOCK an
+// activation, never cause one. In particular, a response that does not carry the weight FIELD is
+// a failure, not a pass — otherwise a query regression that drops the field would silently
+// re-open the hole with a green gate, which is the exact false-green class this ticket exists for.
+
+const SAMPLE_WEIGHT_LB = 0.25;
+const FALLBACK_LB = 2.0;
+const TYPE_DEFAULT_LB = {
+  'Wallcovering': 3.0, 'Wallcoverings': 3.0, 'Wallpaper': 3.0,
+  'Metallic Wallcovering': 3.0, 'Commercial Wallcovering': 3.0,
+  'Mural': 4.0,
+  'Fabric': 1.0, 'Commercial Fabric': 1.0, 'Commercial Drapery': 1.0,
+  'Trim': 0.5, 'Acoustic Panel': 6.0, 'Pillow': 1.5,
+  'Upholstered Walls/Panels': 6.0, 'Tin Ceiling Tile': 2.0,
+  'Hardware': 1.0, 'Furniture': 15.0, 'Memo Sample': 0.25,
+};
+
+const norm = (t) => String(t ?? '').trim().toLowerCase();
+
+function isSampleVariant(v = {}) {
+  const label = norm(v.title ?? v.option1 ?? '');
+  const sku = norm(v.sku);
+  if (label.includes('sample') || label.includes('memo')) return true;
+  if (sku.endsWith('-sample') || sku.includes('sample')) return true;
+  const p = Number(v.price);
+  return Number.isFinite(p) && Math.abs(p - 4.25) < 0.01;
+}
+
+// Did the response actually CARRY the weight field? Distinguishes "measured zero" from
+// "never asked / field absent" — an unmeasured input is never a pass (CLAUDE.md TK-11431 am.1).
+function weightFieldPresent(v = {}) {
+  const m = v?.inventoryItem?.measurement;
+  return !!(m && Object.prototype.hasOwnProperty.call(m, 'weight'));
+}
+
+function currentWeightLb(v = {}) {
+  const w = v?.inventoryItem?.measurement?.weight;
+  if (!w || w.value == null) return 0;
+  const val = Number(w.value);
+  if (!Number.isFinite(val)) return 0;
+  switch (norm(w.unit)) {
+    case 'kilograms': return val * 2.20462;
+    case 'grams':     return val / 453.59237;
+    case 'ounces':    return val / 16;
+    default:          return val; // POUNDS
+  }
+}
+
+function defaultWeightLb(v = {}, productType) {
+  if (isSampleVariant(v)) return SAMPLE_WEIGHT_LB;
+  return TYPE_DEFAULT_LB[productType] ?? FALLBACK_LB;
+}
+
+/**
+ * The gate. `n` is the LIVE product node from STATUS_Q.
+ * @returns {{ok: boolean, reasons: string[]}}
+ */
+function weightGuard(n = {}) {
+  const variants = n?.variants?.nodes || [];
+  if (!variants.length) return { ok: false, reasons: ['weight: no variants on the live node — cannot assert weight>0'] };
+
+  const unmeasured = variants.filter((v) => !weightFieldPresent(v));
+  if (unmeasured.length) {
+    return { ok: false, reasons: [`weight: ${unmeasured.length}/${variants.length} variant(s) came back WITHOUT the weight field — the query is not measuring weight; refusing to pass on unmeasured data`] };
+  }
+  const zero = variants.filter((v) => !(currentWeightLb(v) > 0));
+  if (zero.length) {
+    return { ok: false, reasons: [`weight>0: ${zero.length}/${variants.length} variant(s) at zero/missing weight (${zero.map((v) => v.sku || v.title || '?').join(', ')})`] };
+  }
+  return { ok: true, reasons: [] };
+}
+
+module.exports = { weightGuard, currentWeightLb, isSampleVariant, weightFieldPresent,
+                   defaultWeightLb, SAMPLE_WEIGHT_LB, FALLBACK_LB, TYPE_DEFAULT_LB };
diff --git a/rotate-activate.js b/rotate-activate.js
index 39aaf77..e65ab42 100644
--- a/rotate-activate.js
+++ b/rotate-activate.js
@@ -54,6 +54,7 @@ const { confirmedPublishMutation, confirmedOnlineStore } = require('./lib/public
 const { countHourlyActivations } = require('./lib/hourly-activation-count.js');
 const { activationAllowance, assertDailyCatchup } = require('./lib/activation-allowance.js');
 const { reusedMfrSet, stagingColorFor } = require('./lib/mfr-gate-resolve.js');
+const { weightGuard } = require('./lib/weight-gate.js'); // GUARD TK-11471 (weight go-live)
 // Canonical showroom-vendor primitive (list + logic live in fix-live-board/config). Showroom-only
 // vendors are addressable-not-discoverable: never rotate them into the New Arrivals discoverability
 // flow (activate + 'New Arrival' tag + browse-channel publish). Never hardcode a vendor — edit
@@ -180,9 +181,9 @@ function loadQueue() {
 }
 
 const STATUS_Q = `query($ids:[ID!]!){nodes(ids:$ids){... on Product{
-  id status title vendor descriptionHtml tags
+  id status title vendor descriptionHtml tags productType
   images(first:50){nodes{url}}
-  variants(first:20){nodes{price sku title} pageInfo{hasNextPage endCursor}}
+  variants(first:20){nodes{price sku title inventoryItem{id measurement{weight{value unit}}}} pageInfo{hasNextPage endCursor}}  // TK-11471: weight MUST be selected or the gate below measures nothing
   metafields(first:80){nodes{namespace key value} pageInfo{hasNextPage endCursor}}
 }}}`;
 const mfVal = (mfs, ns, key) => { const m = (mfs || []).find((x) => x.namespace === ns && x.key === key); return m ? m.value : ''; };
@@ -346,6 +347,17 @@ function leakGuard(title, vendor) {
       const gate = gateFromLive(n, q && q.dw_sku, q && q.vendor);
       const extra = fiveFieldExtra(n);
       const leak = leakGuard(n.title, q && q.vendor);
+      // WEIGHT GO-LIVE GATE (TK-11471, enforcing Steve's TK-11414 rule). A product must NEVER
+      // go DRAFT->ACTIVE at zero/missing weight: zero weight collapses an order into the lowest
+      // weight tier / free-shipping band and mis-costs DW freight on every order touching it.
+      // This activator is the highest-blast-radius path in the fleet — hourly, catalog-wide
+      // (WHERE status='DRAFT', not vendor-scoped), 500/day cap — and had NO weight check at all.
+      // 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.
+      // Fail-safe by construction: it can only BLOCK an activation, never cause one. It HOLDS
+      // rather than self-heals, matching every other gate here — this script activates, it is
+      // not a creator, and the weight belongs to whichever importer made the draft.
+      const weight = weightGuard(n);
       // MFR-PROVENANCE gate (TK-11063). Resolve the live mfr metafield, its cross-vendor
       // reuse flag, and its staging color name, then run the pure gate. Staging lookup is
       // only worth doing when the code is otherwise present + not reused (the fabricated
@@ -355,13 +367,14 @@ function leakGuard(title, vendor) {
       const stagingColor = (mfrCode && !reused) ? stagingColorFor(q && q.vendor, mfrCode) : null;
       const mfr = mfrGate({ vendor: q && q.vendor, mfr: mfrCode,
         reusedAcrossVendors: reused, stagingColorName: stagingColor });
-      const passes = gate.ok && extra.ok && leak.ok && mfr.ok;
+      const passes = gate.ok && extra.ok && leak.ok && mfr.ok && weight.ok;
       if (!mfr.ok) mfrBlocked++;
       const rec = { ts: new Date().toISOString(), shopify_id: n.id, vendor: q && q.vendor,
         dw_sku: q && q.dw_sku, mat_tier: q && q.mat_tier, rr: q && q.rr,
         title: n.title, passes, mfr_code: mfrCode, width_source: widthFromLive(n).source,
         reasons: [...(gate.ok ? [] : gate.reasons), ...(extra.ok ? [] : extra.reasons),
-                  ...(leak.ok ? [] : [leak.reason]), ...(mfr.ok ? [] : mfr.reasons)] };
+                  ...(leak.ok ? [] : [leak.reason]), ...(mfr.ok ? [] : mfr.reasons),
+                  ...(weight.ok ? [] : weight.reasons)] };
 
       if (!passes) {
         skipped++;
diff --git a/test/weight-gate.test.js b/test/weight-gate.test.js
new file mode 100644
index 0000000..02cb7eb
--- /dev/null
+++ b/test/weight-gate.test.js
@@ -0,0 +1,83 @@
+// weight-gate.test.js — TK-11471 NEGATIVE TEST for the weight gate on the highest-blast-radius
+// activation path in the fleet (hourly, catalog-wide DRAFT->ACTIVE, 500/day cap).
+// A positive-only test on a gate proves nothing (CLAUDE.md TK-11431 amendment 3).
+// Offline: zero network, zero DB, zero Shopify.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { weightGuard, currentWeightLb, TYPE_DEFAULT_LB, SAMPLE_WEIGHT_LB } = require('../lib/weight-gate.js');
+
+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++; } };
+
+const V = (sku, lb, unit = 'POUNDS') => ({ sku, title: sku, price: '10.00',
+  inventoryItem: { id: 'gid://shopify/InventoryItem/1', measurement: { weight: lb == null ? null : { value: lb, unit } } } });
+const NOFIELD = (sku) => ({ sku, title: sku, price: '10.00', inventoryItem: { id: 'x', measurement: {} } });
+const P = (variants, productType = 'Wallcovering') => ({ productType, variants: { nodes: variants } });
+
+// ── STRUCTURAL: the gate cannot be vacuous ────────────────────────────────────────────────────
+const SRC = fs.readFileSync(path.join(__dirname, '..', 'rotate-activate.js'), 'utf8');
+t('S1 STATUS_Q actually SELECTS weight (else the gate measures nothing and passes 100%)', () => {
+  const q = SRC.slice(SRC.indexOf('const STATUS_Q'), SRC.indexOf('const mfVal'));
+  assert.ok(/inventoryItem\s*\{\s*id\s+measurement\s*\{\s*weight\s*\{\s*value\s+unit/.test(q),
+    'STATUS_Q does not select inventoryItem measurement weight value+unit');
+});
+t('S2 weight gate is ANDed into `passes` (not computed and ignored)', () => {
+  assert.ok(/const passes = .*weight\.ok/.test(SRC), 'weight.ok is not part of the passes expression');
+});
+t('S3 gate is evaluated BEFORE the ACTIVATE mutation call site', () => {
+  assert.ok(SRC.indexOf('weightGuard(n)') < SRC.indexOf('gqlRetry(ACTIVATE'),
+    'weightGuard does not precede the ACTIVATE call');
+});
+t('S4 constants have not drifted from the canonical weight-guard.mjs', () => {
+  const mjs = path.join(process.env.HOME, 'Projects/designerwallcoverings/scripts/lib/weight-guard.mjs');
+  if (!fs.existsSync(mjs)) { console.log('    (canonical .mjs not present — skipping drift check)'); return; }
+  const src = fs.readFileSync(mjs, 'utf8');
+  assert.ok(src.includes('SAMPLE_WEIGHT_LB = 0.25'), 'canonical sample weight changed');
+  for (const [k, v] of Object.entries(TYPE_DEFAULT_LB)) {
+    const re = new RegExp(`'${k.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&')}':\\s*${v}`);
+    assert.ok(re.test(src), `TYPE_DEFAULT_LB drift: '${k}' not ${v} in the canonical .mjs`);
+  }
+  assert.strictEqual(SAMPLE_WEIGHT_LB, 0.25);
+});
+
+// ── INJECTED FAULTS: each MUST block ──────────────────────────────────────────────────────────
+t('zero-weight SELLABLE blocks activation', () => {
+  assert.strictEqual(weightGuard(P([V('DW-1', 0), V('DW-1-Sample', 0.25)])).ok, false);
+});
+t('zero-weight SAMPLE blocks activation (samples count — the canary FAILs on them)', () => {
+  assert.strictEqual(weightGuard(P([V('DW-1', 3), V('DW-1-Sample', 0)])).ok, false);
+});
+t('NULL weight blocks', () => { assert.strictEqual(weightGuard(P([V('DW-1', null)])).ok, false); });
+t('negative weight blocks', () => { assert.strictEqual(weightGuard(P([V('DW-1', -5)])).ok, false); });
+t('NaN weight blocks', () => { assert.strictEqual(weightGuard(P([V('DW-1', 'abc')])).ok, false); });
+t('MISSING weight FIELD blocks (query regression must not silently re-open the hole)', () => {
+  const r = weightGuard(P([NOFIELD('DW-1')]));
+  assert.strictEqual(r.ok, false);
+  assert.ok(/WITHOUT the weight field/.test(r.reasons[0]), 'did not name the unmeasured cause');
+});
+t('NO variants blocks (cannot assert weight>0 on nothing)', () => {
+  assert.strictEqual(weightGuard(P([])).ok, false);
+});
+t('reason names the offending SKUs', () => {
+  const r = weightGuard(P([V('DW-1', 0), V('DW-1-Sample', 0)]));
+  assert.ok(r.reasons[0].includes('DW-1') && r.reasons[0].includes('DW-1-Sample'));
+});
+
+// ── NOT trigger-happy: a noisy gate dies the same death as a false green ──────────────────────
+t('properly weighted product PASSES', () => {
+  assert.strictEqual(weightGuard(P([V('DW-1', 3), V('DW-1-Sample', 0.25)])).ok, true);
+});
+t('GRAMS converts and does NOT falsely block', () => {
+  assert.strictEqual(weightGuard(P([V('DW-1', 1360, 'GRAMS')])).ok, true);
+  assert.ok(Math.abs(currentWeightLb(V('DW-1', 1360, 'GRAMS')) - 2.9982) < 0.01);
+});
+t('KILOGRAMS converts and does NOT falsely block', () => {
+  assert.strictEqual(weightGuard(P([V('DW-1', 1.36, 'KILOGRAMS')])).ok, true);
+});
+t('OUNCES converts and does NOT falsely block', () => {
+  assert.strictEqual(weightGuard(P([V('DW-1', 4, 'OUNCES')])).ok, true);
+});
+
+console.log(`\n${fail ? 'TESTS FAILED' : 'ALL TESTS PASS'} — ${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);

← 1e149c4 fix: remove a NUL byte I introduced in 7e0039b's cache key  ·  back to Dw Rotation Activator  ·  leakGuard: mirror mdc/mdcwall/mdc wall/as creation denylist ce4a6ca →