[object Object]

← back to Designerwallcoverings

TK-11076: harden the undo the gated memo rests on

102998b9aa6d8f68a6c7a9d73e7ef05e68dee6d5 · 2026-09-13 16:11:55 -0700 · Steve Abrams

The reversibility guarantee in the Hollywood retag memo was three ways unsound.

1. tk11076-rollback.mjs iterated top-level bookkeeping keys as products. The
   Hollywood dry-run reported '6 products' before a single write had ever run
   (ticket/vendor/status/ts/note/products), and under --apply would have issued
   6 productUpdate mutations with id:undefined. 5 of 50 rollback files carry
   22 such keys. Entries are now accepted by shape (object with a Product gid)
   and the rejected keys are reported, not silently dropped.
2. userErrors were discarded and 'rollback applied.' printed unconditionally --
   a failed undo reported success. Every revert is now verified by read-back and
   the run exits non-zero if anything did not come back.
3. A --only handle with no rollback record was a silent no-op. Now a loud
   failure: an unmeasured input is never a pass.

Forward script: re-stamp the rollback file's bookkeeping on every apply. A stale
'zero writes / nothing to roll back' note surviving beneath appended entries is
what made this ticket's own premise wrong for 12 days -- Malibu read as pilot-only
while 674 real undo records sat under the note. Legacy top-level keys move to
_meta.prior_bookkeeping (preserved, with a .bak) so the file cannot claim one
thing while holding another. Phillip Jeffries is in that misread state today:
its note says 'NO products were modified' over 1,046 real records.

Ships a negative test (--self-test) proving the guard reddens on an injected
fault and greens when restored. No Shopify write fired; the 282-product retag
stays gated.

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

Files touched

Diff

commit 102998b9aa6d8f68a6c7a9d73e7ef05e68dee6d5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 13 16:11:55 2026 -0700

    TK-11076: harden the undo the gated memo rests on
    
    The reversibility guarantee in the Hollywood retag memo was three ways unsound.
    
    1. tk11076-rollback.mjs iterated top-level bookkeeping keys as products. The
       Hollywood dry-run reported '6 products' before a single write had ever run
       (ticket/vendor/status/ts/note/products), and under --apply would have issued
       6 productUpdate mutations with id:undefined. 5 of 50 rollback files carry
       22 such keys. Entries are now accepted by shape (object with a Product gid)
       and the rejected keys are reported, not silently dropped.
    2. userErrors were discarded and 'rollback applied.' printed unconditionally --
       a failed undo reported success. Every revert is now verified by read-back and
       the run exits non-zero if anything did not come back.
    3. A --only handle with no rollback record was a silent no-op. Now a loud
       failure: an unmeasured input is never a pass.
    
    Forward script: re-stamp the rollback file's bookkeeping on every apply. A stale
    'zero writes / nothing to roll back' note surviving beneath appended entries is
    what made this ticket's own premise wrong for 12 days -- Malibu read as pilot-only
    while 674 real undo records sat under the note. Legacy top-level keys move to
    _meta.prior_bookkeeping (preserved, with a .bak) so the file cannot claim one
    thing while holding another. Phillip Jeffries is in that misread state today:
    its note says 'NO products were modified' over 1,046 real records.
    
    Ships a negative test (--self-test) proving the guard reddens on an injected
    fault and greens when restored. No Shopify write fired; the 282-product retag
    stays gated.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01P6fLv15vwEHvH3kFfhMXPt
---
 .../tk11076-colorway-retag.mjs                     | 27 +++++++
 scripts/colorway-title-anchor/tk11076-rollback.mjs | 94 ++++++++++++++++++++--
 2 files changed, 114 insertions(+), 7 deletions(-)

diff --git a/scripts/colorway-title-anchor/tk11076-colorway-retag.mjs b/scripts/colorway-title-anchor/tk11076-colorway-retag.mjs
index 58095d9..f835823 100644
--- a/scripts/colorway-title-anchor/tk11076-colorway-retag.mjs
+++ b/scripts/colorway-title-anchor/tk11076-colorway-retag.mjs
@@ -273,10 +273,37 @@ async function run() {
   if (!VENDOR) { console.error('refuse --apply without a single --vendor (per-vendor rollback isolation)'); process.exit(1); }
   const ROLLBACK = path.join(process.env.HOME, `.claude/yolo-queue/executed-reversible/TK-11076-${VENDOR.replace(/\W+/g, '_')}-rollback.json`);
   const rollback = fs.existsSync(ROLLBACK) ? JSON.parse(fs.readFileSync(ROLLBACK, 'utf8')) : {};
+
+  // TK-11076 (2026-09-13): a stale "zero writes / nothing to roll back" note surviving beneath
+  // appended entries is what made this ticket's own premise wrong for 12 days — Malibu was read
+  // as pilot-only while 674 real undo records sat underneath the note. The note predicted the
+  // append and was never refreshed by it. Move any legacy top-level bookkeeping under _meta.prior
+  // (preserved, not destroyed) and re-stamp an accurate _meta on every write, so the file can
+  // never claim one thing while holding another.
+  const LEGACY = ['ticket', 'vendor', 'status', 'ts', 'note', 'products', 'generated_at',
+    'canary_diff', 'reasons_unsafe', 'entries', '_ticket', '_vendor', '_status', '_note', '_created'];
+  const prior = {};
+  for (const k of LEGACY) if (k in rollback && !(rollback[k] && rollback[k].id)) { prior[k] = rollback[k]; delete rollback[k]; }
+  if (Object.keys(prior).length) fs.writeFileSync(ROLLBACK + '.pre-tk11076-restamp.bak', JSON.stringify(prior, null, 2));
+  const stamp = () => {
+    const n = Object.keys(rollback).filter(k => rollback[k] && rollback[k].id).length;
+    rollback._meta = {
+      ticket: 'TK-11076', vendor: VENDOR, status: n ? 'writes-applied' : 'no-writes-yet', entries: n,
+      last_apply_run: new Date().toISOString(),
+      note: `${n} per-handle undo record(s) are stored in this file. COUNT THE ENTRIES, NOT THIS NOTE. ` +
+            `Undo: node tk11076-rollback.mjs --vendor="${VENDOR}" [--only=<handle>] --apply`,
+      ...(Object.keys(prior).length ? { prior_bookkeeping: prior } : {}),
+    };
+  };
+
+  stamp();
+  fs.writeFileSync(ROLLBACK, JSON.stringify(rollback, null, 2)); // persist the re-stamp even on a zero-plan run
+
   let done = 0, pass = 0, fail = 0, batch = 0;
   for (const pl of plans) {
     rollback[pl.handle] = { id: pl.id, ts: new Date().toISOString(), old_tags: pl.oldTags, new_tags: pl.newTags,
       old_metafield: pl.oldColorwayMeta, new_metafield: pl.newColorwayMeta };
+    stamp();
     fs.writeFileSync(ROLLBACK, JSON.stringify(rollback, null, 2));
 
     const d = await gql(`mutation($input:ProductInput!){ productUpdate(input:$input){ product{ id }
diff --git a/scripts/colorway-title-anchor/tk11076-rollback.mjs b/scripts/colorway-title-anchor/tk11076-rollback.mjs
index e2d531b..602ff99 100644
--- a/scripts/colorway-title-anchor/tk11076-rollback.mjs
+++ b/scripts/colorway-title-anchor/tk11076-rollback.mjs
@@ -5,6 +5,17 @@
  *   node tk11076-rollback.mjs --vendor="Cole & Son"                 # DRY-RUN (list what would revert)
  *   node tk11076-rollback.mjs --vendor="Cole & Son" --apply         # revert ALL for that vendor
  *   node tk11076-rollback.mjs --vendor="Cole & Son" --only=h1,h2 --apply
+ *   node tk11076-rollback.mjs --self-test                           # negative test, no network/token
+ *
+ * TK-11076 (2026-09-13) hardening — this is the undo the gated memo rests on, so it must not
+ * report success it did not achieve:
+ *   - rollback files carry top-level metadata keys (ticket/vendor/status/note/_meta/entries).
+ *     They were being iterated as phantom "products" and mutated with id:undefined. Now rejected
+ *     by shape and reported, not silently dropped.
+ *   - a --only handle with no rollback record used to be a silent no-op. Now a loud failure:
+ *     an unmeasured input is never a pass.
+ *   - userErrors were discarded and "rollback applied." printed unconditionally. Now every revert
+ *     is verified by read-back and the run exits non-zero if any item did not come back.
  */
 import fs from 'node:fs';
 import path from 'node:path';
@@ -12,22 +23,91 @@ const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopif
 const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
 const API = process.env.SHOPIFY_API_VERSION || '2024-10';
 const APPLY = process.argv.includes('--apply');
+const SELFTEST = process.argv.includes('--self-test');
 const arg = k => (process.argv.find(a => a.startsWith(`--${k}=`)) || '').split('=').slice(1).join('=');
+const norm = s => String(s == null ? '' : s).trim().toLowerCase();
+
+// A genuine undo record is an object carrying the Shopify product gid we must write back to.
+// Everything else in the file is bookkeeping and must never reach productUpdate.
+const isProductEntry = ([, r]) =>
+  !!r && typeof r === 'object' && !Array.isArray(r) &&
+  typeof r.id === 'string' && r.id.startsWith('gid://shopify/Product/');
+
+function classify(map, only) {
+  const all = Object.entries(map);
+  const products = all.filter(isProductEntry);
+  const skipped = all.filter(e => !isProductEntry(e)).map(([h]) => h);
+  const have = new Set(products.map(([h]) => h));
+  const missing = only.filter(h => !have.has(h));
+  const entries = products.filter(([h]) => !only.length || only.includes(h));
+  return { entries, skipped, missing };
+}
+
+if (SELFTEST) {
+  // Negative test: prove the classifier rejects an injected fault instead of mutating on it.
+  const good = 'gid://shopify/Product/1';
+  const BOOKKEEPING = ['ticket', 'vendor', 'status', 'note', '_meta', 'entries', 'products'];
+  const fixture = {
+    ticket: 'TK-11076', vendor: 'X', status: 'stopped-canary-unsafe', note: 'zero writes',
+    _meta: { note: 'n' }, entries: {}, products: {},
+    'real-handle': { id: good, old_tags: ['a'], old_metafield: 'Oyster' },
+    'no-id-handle': { old_tags: ['b'], old_metafield: 'Pewter' },
+  };
+  const a = classify(fixture, []);
+  const b = classify(fixture, ['real-handle', 'never-written-handle']);
+  const checks = [
+    ['only the real record is mutable', a.entries.length === 1 && a.entries[0][0] === 'real-handle'],
+    ['every bookkeeping key rejected', BOOKKEEPING.every(k => a.skipped.includes(k))],
+    ['nothing rejected except bookkeeping + the malformed entry',
+      a.skipped.length === BOOKKEEPING.length + 1],
+    ['an entry with no gid is rejected', a.skipped.includes('no-id-handle')],
+    ['--only restricts to the named record', b.entries.length === 1],
+    ['--only flags a handle with no record', b.missing.length === 1 && b.missing[0] === 'never-written-handle'],
+  ];
+  let bad = 0;
+  for (const [name, ok] of checks) { console.log(`  ${ok ? 'PASS' : 'FAIL'}  ${name}`); if (!ok) bad++; }
+  console.log(bad ? `\nSELF-TEST FAILED (${bad})` : '\nSELF-TEST PASSED');
+  process.exit(bad ? 1 : 0);
+}
+
 const VENDOR = arg('vendor');
-const ONLY = (arg('only') || '').split(',').filter(Boolean);
-if (!TOKEN || !VENDOR) { console.error('need SHOPIFY_ADMIN_TOKEN + --vendor="X"'); process.exit(1); }
+const ONLY = (arg('only') || '').split(',').map(s => s.trim()).filter(Boolean);
+if (!TOKEN || !VENDOR) { console.error('need SHOPIFY_ADMIN_TOKEN + --vendor="X"  (or --self-test)'); process.exit(1); }
 const ROLLBACK = path.join(process.env.HOME, `.claude/yolo-queue/executed-reversible/TK-11076-${VENDOR.replace(/\W+/g, '_')}-rollback.json`);
 if (!fs.existsSync(ROLLBACK)) { console.error('no rollback file:', ROLLBACK); process.exit(1); }
 const map = JSON.parse(fs.readFileSync(ROLLBACK, 'utf8'));
 const sleep = ms => new Promise(r => setTimeout(r, ms));
-async function gql(q, v) { const r = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) }); return (await r.json()).data; }
-const entries = Object.entries(map).filter(([h]) => !ONLY.length || ONLY.includes(h));
+async function gql(q, v) {
+  const r = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) });
+  if (!r.ok) return { __http: r.status };
+  return (await r.json()).data || { __nodata: true };
+}
+
+const { entries, skipped, missing } = classify(map, ONLY);
 console.log(`${APPLY ? 'APPLY' : 'DRY-RUN'} rollback ${VENDOR}: ${entries.length} products`);
+if (skipped.length) console.log(`  ignoring ${skipped.length} non-product key(s): ${skipped.join(', ')}`);
+if (missing.length) console.error(`  WARN: ${missing.length} requested handle(s) have NO rollback record (nothing to revert): ${missing.join(', ')}`);
+
+let ok = 0, fail = 0;
 for (const [h, r] of entries) {
   console.log(`  ${h}: tags -> [${(r.old_tags || []).join(', ')}]  real_color_name -> "${r.old_metafield || '(empty)'}"`);
   if (!APPLY) continue;
-  await gql(`mutation($i:ProductInput!){ productUpdate(input:$i){ userErrors{ message } } }`,
-    { i: { id: r.id, tags: r.old_tags || [], metafields: [{ namespace: 'custom', key: 'real_color_name', type: 'single_line_text_field', value: r.old_metafield || '' }] } });
+  const want = { tags: r.old_tags || [], meta: r.old_metafield || '' };
+  const d = await gql(`mutation($i:ProductInput!){ productUpdate(input:$i){ userErrors{ field message } } }`,
+    { i: { id: r.id, tags: want.tags, metafields: [{ namespace: 'custom', key: 'real_color_name', type: 'single_line_text_field', value: want.meta }] } });
+  const ue = (d && d.productUpdate && d.productUpdate.userErrors) || [];
+  if (d.__http || d.__nodata || ue.length) { fail++; console.error(`   REVERT FAIL ${h}: ${d.__http ? 'HTTP ' + d.__http : d.__nodata ? 'no data' : JSON.stringify(ue)}`); await sleep(500); continue; }
+  // Verify by read-back — a mutation that returned clean is not proof the state came back.
+  const v = await gql(`query($id:ID!){ product(id:$id){ tags m:metafield(namespace:"custom",key:"real_color_name"){ value } } }`, { id: r.id });
+  const lt = (v && v.product && v.product.tags) || [];
+  const tagsOk = want.tags.length === lt.length && want.tags.every(t => lt.some(x => norm(x) === norm(t)));
+  const metaOk = norm((v && v.product && v.product.m && v.product.m.value) || '') === norm(want.meta);
+  if (tagsOk && metaOk) { ok++; console.log(`   ✓ reverted + verified`); }
+  else { fail++; console.error(`   VERIFY FAIL ${h}: tagsOk=${tagsOk} metaOk=${metaOk}`); }
   await sleep(500);
 }
-console.log(APPLY ? 'rollback applied.' : 'DRY-RUN — re-run with --apply to revert.');
+
+if (!APPLY) { console.log('DRY-RUN — re-run with --apply to revert.'); process.exit(missing.length ? 1 : 0); }
+console.log(`\nrollback ${VENDOR}: attempted ${entries.length} · reverted+verified ${ok} · FAILED ${fail} · ignored-non-product ${skipped.length} · missing-record ${missing.length}`);
+if (fail || missing.length) { console.error('ROLLBACK INCOMPLETE — do not treat this as reverted.'); process.exit(1); }
+console.log('rollback applied.');

← 2fbec90 TK-11564: phased title_tag delete executor (dry-default, rac  ·  back to Designerwallcoverings  ·  TK-11635: Fentucci $0 Per-Yard variant sweep + dry-default g cce7427 →