[object Object]

← back to Carnegie Reprice

TK-11547: weight guard on the carnegie rollback un-archive (heal, never block)

081afeac27ffca3a98b26fc41c8c25b800c2df66 · 2026-09-12 08:13:06 -0700 · Steve Abrams

archive-redirect.mjs's rollback path PUTs status:'active' to un-archive products
(com.steve.carnegie-rollout). Despite the filename it is a real activator, and it had no
weight handling — so a restored product could go live at zero weight, collapsing orders into
the lowest weight tier / free-shipping band (Steve's TK-11414 rule).

Found by shopify-activator-register (TK-11498) on its first run; never in the
TK-11414/TK-11471 field of view.

DELIBERATELY DIFFERENT from every other TK-11547 site, which HOLD. This is an UNDO path: it
restores products that were intentionally archived. A gate that can block a rollback is worse
than the defect it prevents — it strands you mid-incident with no way back. So this never
refuses to un-archive:

  1. read each variant's current weight
  2. fill the TK-11414-approved default on any zero (sample 0.25 lb, sellable 3.0 lb)
  3. RE-VERIFY — the write's own 200 is not evidence the weight is set
  4. un-archive REGARDLESS, and if any variant is still zero, say so LOUDLY on stderr with the
     ids, leaving dw-active-weight-canary (daily) as the backstop

Silence is the only outcome ruled out. The heal goes through the REST variant field because
this script authenticates with the narrow SHOPIFY_ADMIN_TOKEN, which lacks write_inventory —
the GraphQL inventoryItemUpdate path would 403.

test-weight-guard.mjs — 10/10, offline. Unit cases for zero/null/negative/NaN and kg/g/oz
conversion, plus five structural checks: it re-verifies after writing, it un-archives even
when the heal fails, a failed heal is loud rather than silent, the heal uses the REST path the
narrow token can actually call, and the sample/sellable default split is applied.

(One test caught itself: the "no GraphQL path" assertion initially matched the word
inventoryItemUpdate inside this guard's own explanatory comment and failed on correct code.
Now strips comments before asserting — prose is not behaviour.)

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

Files touched

Diff

commit 081afeac27ffca3a98b26fc41c8c25b800c2df66
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Sep 12 08:13:06 2026 -0700

    TK-11547: weight guard on the carnegie rollback un-archive (heal, never block)
    
    archive-redirect.mjs's rollback path PUTs status:'active' to un-archive products
    (com.steve.carnegie-rollout). Despite the filename it is a real activator, and it had no
    weight handling — so a restored product could go live at zero weight, collapsing orders into
    the lowest weight tier / free-shipping band (Steve's TK-11414 rule).
    
    Found by shopify-activator-register (TK-11498) on its first run; never in the
    TK-11414/TK-11471 field of view.
    
    DELIBERATELY DIFFERENT from every other TK-11547 site, which HOLD. This is an UNDO path: it
    restores products that were intentionally archived. A gate that can block a rollback is worse
    than the defect it prevents — it strands you mid-incident with no way back. So this never
    refuses to un-archive:
    
      1. read each variant's current weight
      2. fill the TK-11414-approved default on any zero (sample 0.25 lb, sellable 3.0 lb)
      3. RE-VERIFY — the write's own 200 is not evidence the weight is set
      4. un-archive REGARDLESS, and if any variant is still zero, say so LOUDLY on stderr with the
         ids, leaving dw-active-weight-canary (daily) as the backstop
    
    Silence is the only outcome ruled out. The heal goes through the REST variant field because
    this script authenticates with the narrow SHOPIFY_ADMIN_TOKEN, which lacks write_inventory —
    the GraphQL inventoryItemUpdate path would 403.
    
    test-weight-guard.mjs — 10/10, offline. Unit cases for zero/null/negative/NaN and kg/g/oz
    conversion, plus five structural checks: it re-verifies after writing, it un-archives even
    when the heal fails, a failed heal is loud rather than silent, the heal uses the REST path the
    narrow token can actually call, and the sample/sellable default split is applied.
    
    (One test caught itself: the "no GraphQL path" assertion initially matched the word
    inventoryItemUpdate inside this guard's own explanatory comment and failed on correct code.
    Now strips comments before asserting — prose is not behaviour.)
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 archive-redirect.mjs  | 40 ++++++++++++++++++++++++++++++++++++-
 test-weight-guard.mjs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 94 insertions(+), 1 deletion(-)

diff --git a/archive-redirect.mjs b/archive-redirect.mjs
index 6665e59..90cfe21 100644
--- a/archive-redirect.mjs
+++ b/archive-redirect.mjs
@@ -52,7 +52,45 @@ if(MODE==='rollback'){
   // TK-10792 note: rollback re-activates OLD multi-variant products that were intentionally
   // archived. These are original Carnegie pattern pages with real vendor mfr_skus (pre-split),
   // NOT new DWAG-* products. mfr_sku gate intentionally does not apply to this undo path.
-  for(const a of map.archived){ await shop(`/products/${a.old_id}.json`,{method:'PUT',body:JSON.stringify({product:{id:a.old_id,status:'active'}})}); await sleep(550); }
+  // TK-11547 WEIGHT GO-LIVE GUARD (Steve's TK-11414 rule: never ACTIVE at zero/missing weight —
+  // zero weight collapses an order into the lowest weight tier / free-shipping band and mis-costs
+  // freight). Found by shopify-activator-register (TK-11498); this file was never in the
+  // TK-11414/TK-11471 field of view.
+  //
+  // HEAL-THEN-PROCEED-LOUDLY, deliberately NOT hold. Every other site gated under TK-11547 HOLDS,
+  // but this is an UNDO path: it restores products that were intentionally archived. A gate that
+  // can block a rollback is worse than the defect it prevents — it strands you mid-incident with
+  // no way back. So this never refuses to un-archive. It fills the approved default first, and if
+  // that fails it un-archives ANYWAY and says so loudly, leaving dw-active-weight-canary (daily)
+  // as the backstop. Silence is the only outcome ruled out.
+  //
+  // Heal goes through the REST variant field because this script authenticates with the NARROW
+  // SHOPIFY_ADMIN_TOKEN, which lacks write_inventory (the GraphQL inventoryItemUpdate path 403s).
+  const W_SAMPLE_LB = 0.25, W_DEFAULT_LB = 3.0;   // TK-11414-approved defaults
+  const isSample = v => /sample|memo/i.test(String(v.title||v.option1||'')) || /-sample$/i.test(String(v.sku||''))
+                        || Math.abs(Number(v.price)-4.25) < 0.01;
+  let healed=0, unhealed=[];
+  for(const a of map.archived){
+    try{
+      const pr = await (await shop(`/products/${a.old_id}.json`)).json();
+      for(const v of (pr.product?.variants||[])){
+        const w = Number(v.weight);
+        const 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;
+        if(Number.isFinite(lb) && lb > 0) continue;
+        const target = isSample(v) ? W_SAMPLE_LB : W_DEFAULT_LB;
+        await shop(`/variants/${v.id}.json`,{method:'PUT',body:JSON.stringify({variant:{id:v.id,weight:target,weight_unit:'lb'}})});
+        await sleep(400);
+        // RE-VERIFY: the mutation's own 200 is not evidence the weight is set.
+        const re = await (await shop(`/variants/${v.id}.json`)).json();
+        const rw = Number(re.variant?.weight);
+        if(Number.isFinite(rw) && rw > 0) healed++; else unhealed.push(`${a.old_id}/${v.sku||v.id}`);
+      }
+    }catch(e){ unhealed.push(`${a.old_id}(read/heal error: ${e.message})`); }
+    await shop(`/products/${a.old_id}.json`,{method:'PUT',body:JSON.stringify({product:{id:a.old_id,status:'active'}})}); await sleep(550);
+  }
+  if(healed) console.log(`[rollback] TK-11547: healed ${healed} zero-weight variant(s) to the approved default before un-archiving`);
+  if(unhealed.length) console.error(`[rollback] ⚠️  TK-11547: ${unhealed.length} variant(s) are LIVE AT ZERO WEIGHT — un-archived anyway (an undo must never be blocked). dw-active-weight-canary will flag these within 24h; fix with scripts/tk11471-weight-backfill.py. ${unhealed.slice(0,15).join(', ')}`);
   for(const r of map.redirects){ if(r.redirect_id){ try{await shop(`/redirects/${r.redirect_id}.json`,{method:'DELETE'});}catch{} await sleep(400);} }
   console.log(`[rollback] done`); process.exit(0);
 }
diff --git a/test-weight-guard.mjs b/test-weight-guard.mjs
new file mode 100644
index 0000000..b0c0048
--- /dev/null
+++ b/test-weight-guard.mjs
@@ -0,0 +1,55 @@
+// test-weight-guard.mjs — TK-11547 test for the rollback weight guard in archive-redirect.mjs.
+// Offline. The design here is deliberately DIFFERENT from every other TK-11547 site (heal-then-
+// proceed-loudly rather than HOLD, because blocking an undo is worse than the defect), so the
+// tests assert BOTH halves: that it heals, and that it still un-archives when healing fails.
+import fs from 'node:fs';
+import assert from 'node:assert';
+const SRC = fs.readFileSync(new URL('./archive-redirect.mjs', import.meta.url), 'utf8');
+const BLOCK = SRC.slice(SRC.indexOf('TK-11547 WEIGHT GO-LIVE GUARD'), SRC.indexOf('for(const r of map.redirects)'));
+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++; } };
+
+// the zero-detection predicate, lifted verbatim
+const zero = (v) => { const w = Number(v.weight); const 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('detects zero weight', () => assert.strictEqual(zero({ weight: 0 }), true));
+t('detects null/absent weight', () => { assert.strictEqual(zero({}), true); assert.strictEqual(zero({ weight: null }), true); });
+t('detects negative / NaN', () => { assert.strictEqual(zero({ weight: -1 }), true); assert.strictEqual(zero({ weight: 'x' }), true); });
+t('accepts a positive lb weight', () => assert.strictEqual(zero({ weight: 3, weight_unit: 'lb' }), false));
+t('kg / g / oz convert and are not falsely flagged', () => {
+  assert.strictEqual(zero({ weight: 1.36, weight_unit: 'kg' }), false);
+  assert.strictEqual(zero({ weight: 1360, weight_unit: 'g' }), false);
+  assert.strictEqual(zero({ weight: 4, weight_unit: 'oz' }), false);
+});
+
+t('STRUCTURAL: the guard RE-VERIFIES after writing (a 200 is not evidence)', () => {
+  assert.ok(/RE-VERIFY/.test(BLOCK) && /re\.variant\?\.weight/.test(BLOCK), 'no re-read after the heal write');
+});
+t('STRUCTURAL: it un-archives EVEN WHEN the heal fails — an undo is never blocked', () => {
+  const healIdx = BLOCK.indexOf('unhealed.push');
+  const unarchIdx = BLOCK.indexOf("status:'active'");
+  assert.ok(healIdx > -1 && unarchIdx > healIdx, 'the un-archive does not follow the failed-heal path');
+  assert.ok(!/continue;\s*\}\s*$/m.test(BLOCK.slice(healIdx, unarchIdx)), 'a failed heal appears to skip the un-archive');
+});
+t('STRUCTURAL: a failed heal is LOUD (console.error), never silent', () => {
+  assert.ok(/console\.error\(/.test(BLOCK) && /LIVE AT ZERO WEIGHT/.test(BLOCK), 'unhealed variants are not surfaced loudly');
+});
+t('STRUCTURAL: heal uses the REST variant field (narrow token lacks write_inventory)', () => {
+  assert.ok(/\/variants\/\$\{v\.id\}\.json/.test(BLOCK), 'not using the REST variant path');
+  // Strip comment lines before asserting absence. The first cut matched the word
+  // `inventoryItemUpdate` inside this guard's OWN explanatory comment (which says that path 403s
+  // on the narrow token) and failed on correct code — an assertion that reads prose as if it were
+  // behaviour. Worth locking down: it is the same imprecision that made an earlier structural
+  // check vacuously true by comparing against a constant's declaration instead of its call site.
+  const CODE = BLOCK.split('\n').filter(l => !/^\s*\/\//.test(l)).join('\n');
+  assert.ok(!/inventoryItemUpdate/.test(CODE), 'uses a GraphQL path the narrow token cannot call');
+});
+t('STRUCTURAL: samples get the sample default, sellables the type default', () => {
+  assert.ok(/W_SAMPLE_LB = 0\.25/.test(BLOCK) && /W_DEFAULT_LB = 3\.0/.test(BLOCK), 'approved defaults missing');
+  assert.ok(/isSample\(v\) \? W_SAMPLE_LB : W_DEFAULT_LB/.test(BLOCK), 'sample/sellable split not applied');
+});
+
+console.log(`\n${fail ? 'TESTS FAILED' : 'ALL TESTS PASS'} — ${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);

← 1634913 auto-data-snapshot: 2026-09-11T10:08:53 (3 data files) — rep  ·  back to Carnegie Reprice  ·  carnegie phase2: retry metafield writes, record failures, gu 5f484b7 →