← 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
M scripts/colorway-title-anchor/tk11076-colorway-retag.mjsM scripts/colorway-title-anchor/tk11076-rollback.mjs
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 →