[object Object]

← back to Designerwallcoverings

TK-11461: selective gallery repair executor (dry-run default, Steve-gated)

cbea88007f6f30f43c7ec5c28eb58fe458bf0cec · 2026-09-11 12:10:29 -0700 · Steve Abrams

Deletes only the 76 enumerated unattributable carousel plates from 47 live
products. Not run by this session — the harness classifier blocks it, so it is
handed to Steve as a paste.

Safety, enforced at runtime before any delete:
- verify-before-act: re-GETs each product live; refuses on any drift (image
  gone, filename changed) rather than deleting the wrong asset
- refuses to delete anything at position 1 (hero)
- refuses any product that would be left imageless
- writes a full rollback map (product_id, image_id, src, position, alt)
  BEFORE the first delete; re-add via POST /products/{id}/images.json
- 550ms pacing between deletes

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

Files touched

Diff

commit cbea88007f6f30f43c7ec5c28eb58fe458bf0cec
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 11 12:10:29 2026 -0700

    TK-11461: selective gallery repair executor (dry-run default, Steve-gated)
    
    Deletes only the 76 enumerated unattributable carousel plates from 47 live
    products. Not run by this session — the harness classifier blocks it, so it is
    handed to Steve as a paste.
    
    Safety, enforced at runtime before any delete:
    - verify-before-act: re-GETs each product live; refuses on any drift (image
      gone, filename changed) rather than deleting the wrong asset
    - refuses to delete anything at position 1 (hero)
    - refuses any product that would be left imageless
    - writes a full rollback map (product_id, image_id, src, position, alt)
      BEFORE the first delete; re-add via POST /products/{id}/images.json
    - 550ms pacing between deletes
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01GsiLySPiU9A4wf8BDxxodu
---
 verification/TK-11461/apply-repair.mjs | 66 ++++++++++++++++++++++++++++++++++
 1 file changed, 66 insertions(+)

diff --git a/verification/TK-11461/apply-repair.mjs b/verification/TK-11461/apply-repair.mjs
new file mode 100644
index 0000000..6049250
--- /dev/null
+++ b/verification/TK-11461/apply-repair.mjs
@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+// TK-11461 selective gallery repair. DRY-RUN BY DEFAULT; --apply performs deletes.
+// Verify-before-act: re-GETs each product live and refuses any drift vs the plan.
+// Writes a rollback map BEFORE the first delete.
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const APPLY = process.argv.includes('--apply');
+const DIR = path.dirname(fileURLToPath(import.meta.url));
+const VER = '2024-10';
+const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const plan = JSON.parse(fs.readFileSync(path.join(DIR, 'repair-plan-76-images.json'), 'utf8'));
+const tok = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
+  .match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)[1].trim().replace(/^["']|["']$/g, '');
+const H = { 'X-Shopify-Access-Token': tok, 'Content-Type': 'application/json' };
+const fname = s => s.split('?')[0].split('/').pop();
+
+const byPid = {};
+for (const d of plan.drop) { (byPid[d.pid] = byPid[d.pid] || []).push(d); }
+
+const rollback = [], refusals = [], ok = [];
+for (const pid of Object.keys(byPid)) {
+  const r = await fetch(`https://${DOMAIN}/admin/api/${VER}/products/${pid}.json?fields=id,handle,images`, { headers: H });
+  if (!r.ok) { refusals.push({ pid, why: `live GET failed HTTP ${r.status}` }); continue; }
+  const live = (await r.json()).product;
+  const liveById = {};
+  for (const im of live.images) liveById[im.id] = im;
+  const targets = byPid[pid];
+  let bad = false;
+  // GUARD 1: each planned image must still exist, not be the hero, and match filename
+  for (const t of targets) {
+    const im = liveById[t.img_id];
+    if (!im) { refusals.push({ pid, handle: live.handle, img_id: t.img_id, why: 'image no longer on product (drift)' }); bad = true; continue; }
+    if (im.position === 1) { refusals.push({ pid, handle: live.handle, img_id: t.img_id, why: 'image is now position 1 (hero) — refusing' }); bad = true; continue; }
+    if (fname(im.src) !== fname(t.src)) { refusals.push({ pid, handle: live.handle, img_id: t.img_id, why: `filename drift ${fname(im.src)} != ${fname(t.src)}` }); bad = true; }
+  }
+  // GUARD 2: product must retain at least one image
+  if (live.images.length - targets.length < 1) { refusals.push({ pid, handle: live.handle, why: 'would leave product imageless — refusing' }); bad = true; }
+  if (bad) continue;
+  for (const t of targets) {
+    const im = liveById[t.img_id];
+    rollback.push({ product_id: Number(pid), handle: live.handle, image_id: im.id, src: im.src, position: im.position, alt: im.alt || null });
+    ok.push({ pid: Number(pid), handle: live.handle, img_id: im.id, fn: fname(im.src), position: im.position });
+  }
+}
+
+const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+fs.writeFileSync(path.join(DIR, `rollback-map-${stamp}.json`),
+  JSON.stringify({ note: 're-add via POST /products/{product_id}/images.json {image:{src,position,alt}}', images: rollback }, null, 1));
+
+console.log(`plan                  : ${plan.drop.length} images / ${plan.products} products`);
+console.log(`verified-deletable    : ${ok.length}`);
+console.log(`REFUSED (drift/safety): ${refusals.length}`);
+if (refusals.length) console.log(JSON.stringify(refusals.slice(0, 10), null, 1));
+console.log(`rollback map written  : rollback-map-${stamp}.json (${rollback.length} entries)`);
+
+if (!APPLY) { console.log('\nDRY RUN — no deletes performed. Re-run with --apply to execute.'); process.exit(0); }
+
+let done = 0, fail = 0;
+for (const t of ok) {
+  const d = await fetch(`https://${DOMAIN}/admin/api/${VER}/products/${t.pid}/images/${t.img_id}.json`, { method: 'DELETE', headers: H });
+  if (d.ok) { done++; } else { fail++; console.error(`FAIL ${t.handle} ${t.fn} HTTP ${d.status}`); }
+  await new Promise(r => setTimeout(r, 550));
+}
+console.log(`\nAPPLIED: deleted ${done}, failed ${fail}`);

← 12a7091 auto-data-snapshot: 2026-09-11T12:03:30 (4 data files) — dat  ·  back to Designerwallcoverings  ·  TK-11483: receipt guard stops the daily Fentucci duplicate m 81ba2dd →