[object Object]

← back to Designerwallcoverings

Guard manufacturer rollback against observed value drift

5c8221185b9aa2982c704f9f38497151a99e25e5 · 2026-09-04 14:45:50 -0700 · Steve Abrams

Files touched

Diff

commit 5c8221185b9aa2982c704f9f38497151a99e25e5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 4 14:45:50 2026 -0700

    Guard manufacturer rollback against observed value drift
---
 scripts/mfr-backfill-tk10162/rollback.mjs          | 110 ++++++++++++++-------
 .../mfr-backfill-tk10162/rollback.offline.test.mjs |  38 +++++++
 verification/e2e-proof.json                        |  87 ++++++++++++++++
 3 files changed, 200 insertions(+), 35 deletions(-)

diff --git a/scripts/mfr-backfill-tk10162/rollback.mjs b/scripts/mfr-backfill-tk10162/rollback.mjs
index 17bfc4b..99014ef 100644
--- a/scripts/mfr-backfill-tk10162/rollback.mjs
+++ b/scripts/mfr-backfill-tk10162/rollback.mjs
@@ -1,50 +1,90 @@
 #!/usr/bin/env node
 /**
- * TK-10162 — rollback for backfill-mfr.mjs.
- * Reads a restore-map-<vendor>-<ts>.jsonl produced by an --apply run and reverses it:
- * every row we WROTE (prev:null) -> delete the custom.* AND dwc.* manufacturer_sku
- * metafields we created (returns the product to its pre-backfill state). A row with a
- * non-null prev is restored to that prior value instead of deleted.
- *
- * DRY-RUN by default. --apply required to reverse.
- * Usage: node rollback.mjs out/restore-map-pj-<ts>.jsonl [--apply]
+ * TK-10162 rollback. LOCAL validation does not authorize live execution.
+ * Restore-map rows must include id, wrote, and prev (null or string).
+ * Both live manufacturer_sku values must equal wrote before delete/restore.
+ * This read-before-write check is NOT atomic CAS: an intervening writer can
+ * still race either mutation, especially delete. Live execution remains gated.
+ * DRY-RUN by default, with no network. Usage: rollback.mjs <map.jsonl> [--apply]
  */
 import fs from 'node:fs';
 const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
 const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
 const API = '2024-10';
-const file = process.argv[2];
-const APPLY = process.argv.includes('--apply');
-if (!file || !fs.existsSync(file)) { console.error('usage: node rollback.mjs <restore-map.jsonl> [--apply]'); process.exit(1); }
+const [file, ...flags] = process.argv.slice(2);
+const APPLY = flags.includes('--apply');
 const sleep = ms => new Promise(r => setTimeout(r, ms));
+const Q_READ = `query($id:ID!){product(id:$id){custom:metafield(namespace:"custom",key:"manufacturer_sku"){value} dwc:metafield(namespace:"dwc",key:"manufacturer_sku"){value}}}`;
+const M_DEL = `mutation($mf:[MetafieldIdentifierInput!]!){metafieldsDelete(metafields:$mf){userErrors{message}}}`;
+const M_SET = `mutation($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){userErrors{message}}}`;
 async function gql(query, variables) {
   const res = 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, variables }) });
-  return res.json();
+  if (!res.ok) throw new Error(`HTTP ${res.status}`);
+  const j = await res.json();
+  if (!j || typeof j !== 'object' || (j.errors !== undefined && (!Array.isArray(j.errors) || j.errors.length))) {
+    throw new Error('GraphQL errors or malformed response');
+  }
+  if (!j.data || typeof j.data !== 'object') throw new Error('Missing GraphQL data');
+  return j.data;
 }
-const rows = fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
-console.log(`rollback: ${rows.length} rows from ${file} · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
-const M_DEL = `mutation($mf:[MetafieldIdentifierInput!]!){metafieldsDelete(metafields:$mf){userErrors{message}}}`;
-const M_SET = `mutation($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){userErrors{message}}}`;
-if (!APPLY) { console.log('DRY-RUN. would delete/restore custom+dwc manufacturer_sku on each. Re-run with --apply.'); process.exit(0); }
-let done = 0;
-for (const r of rows) {
-  if (r.prev == null) {
-    const mf = [
-      { ownerId: r.id, namespace: 'custom', key: 'manufacturer_sku' },
-      { ownerId: r.id, namespace: 'dwc',    key: 'manufacturer_sku' },
-    ];
-    const j = await gql(M_DEL, { mf });
-    const ue = j.data?.metafieldsDelete?.userErrors || [];
-    if (ue.length) console.error(`  ${r.dwsku}:`, ue.slice(0, 2));
-  } else {
-    const mf = [
-      { ownerId: r.id, namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field', value: r.prev },
-      { ownerId: r.id, namespace: 'dwc',    key: 'manufacturer_sku', type: 'single_line_text_field', value: r.prev },
-    ];
-    await gql(M_SET, { mf });
+function loadRows() {
+  if (!file || flags.some(f => f !== '--apply') || flags.length > 1) {
+    throw new Error('usage: rollback.mjs <restore-map.jsonl> [--apply] (no force bypass)');
+  }
+  const rows = fs.readFileSync(file, 'utf8').split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
+  const seen = new Set();
+  for (const [i, r] of rows.entries()) {
+    if (!r || typeof r !== 'object' || Array.isArray(r) ||
+        typeof r.id !== 'string' || !/^gid:\/\/shopify\/Product\/[1-9]\d*$/.test(r.id) ||
+        typeof r.wrote !== 'string' || !r.wrote.trim() ||
+        !Object.hasOwn(r, 'prev') || (r.prev !== null && typeof r.prev !== 'string')) {
+      throw new Error(`Invalid restore map row ${i + 1}: requires product id, nonempty wrote, explicit null/string prev`);
+    }
+    if (seen.has(r.id)) throw new Error(`Duplicate product id at row ${i + 1}`);
+    seen.add(r.id);
+  }
+  return rows;
+}
+async function main() {
+  const rows = loadRows(); // Validate the entire map before any network or mutation.
+  console.log(`rollback: ${rows.length} rows from ${file} · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
+  if (!APPLY) {
+    console.log('DRY-RUN: would precheck both live values before delete/restore. No network requests.');
+    return;
+  }
+  let succeeded = 0;
+  const drift = [], errors = [];
+  for (const [i, r] of rows.entries()) {
+    try {
+      const data = await gql(Q_READ, { id: r.id });
+      const p = data.product;
+      if (!p || !['custom', 'dwc'].every(ns => typeof p[ns]?.value === 'string' && p[ns].value === r.wrote)) {
+        drift.push(r.id);
+        console.error(`DRIFT ${r.id}: missing, malformed, or changed custom/dwc value; no mutation`);
+        continue;
+      }
+      const mf = ['custom', 'dwc'].map(namespace => ({
+        ownerId: r.id, namespace, key: 'manufacturer_sku',
+        ...(r.prev === null ? {} : { type: 'single_line_text_field', value: r.prev }),
+      }));
+      const name = r.prev === null ? 'metafieldsDelete' : 'metafieldsSet';
+      const result = (await gql(r.prev === null ? M_DEL : M_SET, { mf }))[name];
+      if (!result || !Array.isArray(result.userErrors)) throw new Error('Missing/malformed mutation result');
+      if (result.userErrors.length) throw new Error(`Mutation rejected (${result.userErrors.length} userErrors)`);
+      succeeded++;
+    } catch (err) {
+      errors.push(r.id);
+      console.error(`ERROR ${r.id}: ${err.message}; not counted successful (mutation outcome may require reconciliation)`);
+    } finally {
+      if ((i + 1) % 25 === 0) await sleep(500);
+    }
+  }
+  console.log(`ROLLBACK SUMMARY: succeeded=${succeeded}/${rows.length} drift=${drift.length} errors=${errors.length}`);
+  if (drift.length || errors.length) {
+    console.error(JSON.stringify({ drift, errors }));
+    process.exitCode = 1;
   }
-  if (++done % 25 === 0) { console.log(`  ...${done}/${rows.length}`); await sleep(500); }
 }
-console.log(`DONE rollback: ${done}/${rows.length}`);
+main().catch(err => { console.error(`ROLLBACK FAILED: ${err.message}`); process.exitCode = 1; });
diff --git a/scripts/mfr-backfill-tk10162/rollback.offline.test.mjs b/scripts/mfr-backfill-tk10162/rollback.offline.test.mjs
new file mode 100644
index 0000000..fe12d0c
--- /dev/null
+++ b/scripts/mfr-backfill-tk10162/rollback.offline.test.mjs
@@ -0,0 +1,38 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rollback-proof-'));
+const script = fileURLToPath(new URL('./rollback.mjs', import.meta.url));
+const preload = path.join(dir, 'mock.mjs');
+fs.writeFileSync(preload, `import fs from 'node:fs';
+const responses=JSON.parse(process.env.MOCK_RESPONSES); let n=0;
+globalThis.fetch=async(url, opts)=>{
+ fs.appendFileSync(process.env.MOCK_CALLS,JSON.stringify(JSON.parse(opts.body))+'\\n');
+ if(n>=responses.length) throw new Error('Unexpected network request (blocked)');
+ const r=responses[n++]; if(r.throw) throw new Error(r.throw);
+ return {ok:r.status===undefined||r.status===200,status:r.status||200,json:async()=>{if(r.badJson)throw new Error('bad json'); return r.body;}};
+};`);
+const row = {id:'gid://shopify/Product/123',dwsku:'TEST-ONLY',prev:null,wrote:'NEW'};
+const read = (a='NEW',b='NEW') => ({body:{data:{product:{custom:a===null?null:{value:a},dwc:b===null?null:{value:b}}}}});
+const success = name=>({body:{data:{[name]:{userErrors:[]}}}});
+let serial=0;
+function run(rows=[row],responses=[],apply=true,extra=[]) {
+ const prefix=path.join(dir,String(++serial)); const file=prefix+'.jsonl', calls=prefix+'.calls';
+ fs.writeFileSync(file,rows.map(x=>JSON.stringify(x)).join('\n'));
+ const p=spawnSync(process.execPath,['--import',preload,script,file,...(apply?['--apply']:[]),...extra],{encoding:'utf8',env:{PATH:process.env.PATH,DTD_ZERO_COST:'1',MOCK_RESPONSES:JSON.stringify(responses),MOCK_CALLS:calls}});
+ return {...p,calls:fs.existsSync(calls)?fs.readFileSync(calls,'utf8').trim().split('\n').map(JSON.parse):[]};
+}
+test('dry run has no network',()=>{const r=run([row],[],false); assert.equal(r.status,0); assert.equal(r.calls.length,0);});
+// Override default above explicitly: apply tests are always intercepted before script import.
+test('observed drift prevents all writes',()=>{const r=run([row],[read('OTHER')]);assert.notEqual(r.status,0);assert.equal(r.calls.filter(x=>x.query.startsWith('mutation')).length,0);});
+test('delete and restore payloads follow successful two-namespace read',()=>{for(const prev of [null,'OLD']){const name=prev===null?'metafieldsDelete':'metafieldsSet';const r=run([{...row,prev}],[read(),success(name)]);assert.equal(r.status,0,r.stderr);assert.equal(r.calls.length,2);assert.deepEqual(r.calls[0].variables,{id:row.id});assert.deepEqual(r.calls[1].variables.mf,['custom','dwc'].map(namespace=>({ownerId:row.id,namespace,key:'manufacturer_sku',...(prev===null?{}:{type:'single_line_text_field',value:prev})})));assert.match(r.stdout,/succeeded=1/);}});
+test('missing and malformed live values reject',()=>{for(const response of [read(null),read('NEW',null),read('NEW','OTHER'),read(123),{body:{data:{product:null}}},{body:{data:{product:{}}}}]){const r=run([row],[response]);assert.notEqual(r.status,0);assert.equal(r.calls.length,1);}});
+test('read errors fail closed',()=>{for(const response of [{throw:'transport'},{status:429,body:{}},{badJson:true},{body:{errors:[{message:'denied'}]}},{body:{}}]){const r=run([row],[response]);assert.notEqual(r.status,0);assert.equal(r.calls.length,1);}});
+test('mutation failures are not counted successful',()=>{for(const prev of [null,'OLD'])for(const response of [{throw:'transport'},{status:500,body:{}},{badJson:true},{body:{errors:[{message:'denied'}]}},{body:{}},{body:{data:{[prev===null?'metafieldsDelete':'metafieldsSet']:{userErrors:[{message:'rejected'}]}}}}]){const r=run([{...row,prev}],[read(),response]);assert.notEqual(r.status,0);assert.match(r.stdout,/succeeded=0/);}});
+test('invalid map and unsupported force reject before network',()=>{for(const rows of [[{...row,wrote:undefined}],[{...row,prev:undefined}],[{...row,prev:42}],[row,row],[{...row,id:'bad'}],[{...row,wrote:''}],[null]]){const r=run(rows);assert.notEqual(r.status,0);assert.equal(r.calls.length,0);}const r=run([row],[],true,['--force']);assert.notEqual(r.status,0);assert.equal(r.calls.length,0);});
+test('one drift does not hide later success and exit remains nonzero',()=>{const r=run([row,{...row,id:'gid://shopify/Product/124'}],[read('OTHER'),read(),success('metafieldsDelete')]);assert.notEqual(r.status,0);assert.equal(r.calls.length,3);assert.match(r.stdout,/succeeded=1/);assert.match(r.stdout,/drift=1/);});
+console.log('Retained offline fixtures:',dir);
diff --git a/verification/e2e-proof.json b/verification/e2e-proof.json
new file mode 100644
index 0000000..9af31eb
--- /dev/null
+++ b/verification/e2e-proof.json
@@ -0,0 +1,87 @@
+{
+  "intent": "Fail closed on observed rollback drift, invalid maps, read errors and unsuccessful mutations; preserve dry-run zero network",
+  "risk_tier": "R3 code boundary, exercised locally with isolated offline CLI fixtures; live R4 execution remains gated",
+  "environment": "Node subprocess with fetch mock loaded before script; minimal child environment excludes provider secrets; unexpected fetch throws",
+  "build_identity": {
+    "baseline_commit": "37330673905a5c8bdf5d42a0274183564bc8dead",
+    "verified_source_sha256": {
+      "scripts/mfr-backfill-tk10162/rollback.mjs": "68e5034299fcc5c7284461e8a55f2602de7edc590b0bef2c32a44897658db5e6",
+      "scripts/mfr-backfill-tk10162/rollback.offline.test.mjs": "1e5c09dacc099ff105b853e7116256f9a4a2f4412c45f903b8ed8549779c2276"
+    }
+  },
+  "timestamp": "2026-09-04T21:45:43.935952+00:00",
+  "ticket": "TK-11242",
+  "correlation": "M-01968 / cycle2133 / increment2",
+  "preconditions": "Filesystem guard verified ZERO_COST_REQUIRED; worktree clean before changes; DTD final KEEP read; parent ownership packet",
+  "commands": [
+    "node --test --test-name-pattern=\"observed drift\" scripts/mfr-backfill-tk10162/rollback.offline.test.mjs (baseline expected FAIL)",
+    "node --test scripts/mfr-backfill-tk10162/rollback.offline.test.mjs (8/8 PASS)",
+    "git diff --check (PASS)",
+    "node /Users/macstudio3/.claude/yolo-queue/evidence/TK-11264/cycle2133/parent-regression.cjs /Users/macstudio3/.claude/yolo-queue/evidence/TK-11264/cycle2133/parent-regression-after.json"
+  ],
+  "checks": [
+    {
+      "verdict": "PASS",
+      "boundary": "Both namespaces must equal wrote before either mutation"
+    },
+    {
+      "verdict": "PASS",
+      "boundary": "Delete and restore exact mutation payload"
+    },
+    {
+      "verdict": "PASS",
+      "boundary": "Missing/malformed or mismatched live values produce zero mutations"
+    },
+    {
+      "verdict": "PASS",
+      "boundary": "Transport/HTTP/GraphQL/JSON read errors fail closed"
+    },
+    {
+      "verdict": "PASS",
+      "boundary": "Mutation transport/HTTP/GraphQL/userErrors/malformed results not counted successful"
+    },
+    {
+      "verdict": "PASS",
+      "boundary": "Entire map invalid rows/duplicates/no-force validation before network"
+    },
+    {
+      "verdict": "PASS",
+      "boundary": "Dry-run performs zero network calls"
+    },
+    {
+      "verdict": "PASS",
+      "boundary": "Mixed drift and success preserve nonzero exit"
+    }
+  ],
+  "artifacts": [
+    "/Users/macstudio3/.claude/yolo-queue/evidence/TK-11264/cycle2133/baseline-reproduction.json",
+    "/Users/macstudio3/.claude/yolo-queue/evidence/TK-11264/cycle2133/parent-regression-after.json",
+    "/Users/macstudio3/.claude/yolo-queue/evidence/TK-11264/cycle2133/parent-offline-tests.txt",
+    "/Users/macstudio3/.claude/yolo-queue/evidence/TK-11264/cycle2133/parent-acceptance.json",
+    "/Users/macstudio3/.claude/yolo-queue/evidence/TK-11264/cycle2133/final-parent-regression.json",
+    "/Users/macstudio3/.claude/yolo-queue/evidence/TK-11264/cycle2133/final-offline-tests.txt",
+    "/Users/macstudio3/.claude/yolo-queue/evidence/TK-11264/cycle2133/cody.md",
+    "/private/tmp/dtd-cycle2133-final.RhXWIi/codex-debate.txt"
+  ],
+  "hashes": {
+    "scripts/mfr-backfill-tk10162/rollback.mjs": "68e5034299fcc5c7284461e8a55f2602de7edc590b0bef2c32a44897658db5e6",
+    "scripts/mfr-backfill-tk10162/rollback.offline.test.mjs": "1e5c09dacc099ff105b853e7116256f9a4a2f4412c45f903b8ed8549779c2276"
+  },
+  "cleanup": "Fixtures retained in OS temporary directory; no real records created or modified",
+  "remaining_gates": "No live use approved or performed. Read-before-write is not atomic CAS; concurrent change can still race mutation. Mutation transport failure may require reconciliation; no live service certification.",
+  "cost": 0,
+  "parent_acceptance": {
+    "independent_cli_cases": "5/5 PASS; baseline4 FAIL",
+    "owner_suite_rerun": "8/8 PASS",
+    "diff_check": "PASS",
+    "status": "PASS: scoped local implementation accepted after Cody and final DTD"
+  },
+  "scope": "Local CLI safety improvement only. No live writes, no production certification. CTA/screenrecord not applicable: no web/UI surface; offline CLI producer/network-mock/exit-code boundary exercised.",
+  "reviews": {
+    "Cody": "SHIP IT5/5; no reproduced blocker",
+    "final_DTD": "SHIP2/2 valid Codex/Qwen, availability2/6; mandatory Codex debate KEEP",
+    "confidence": "medium",
+    "dissent": "none among valid voters; adversarial warning about non-atomicity retained"
+  },
+  "verdict": "PASS \u2014 local increment only"
+}

← 3733067 TK-11061: reprice ~457 real-roll variants with leaked $4.25  ·  back to Designerwallcoverings  ·  auto-data-snapshot: 2026-09-04T18:04:25 (1 data files) — dat 693dfa7 →