[object Object]

← back to Designerwallcoverings

TK-11076: Rebel Walls AI-palette retag remediate+rollback (canary-safe, per-item reversible)

a6fd514fbaad9cfba8c968c9b231e366ac0d8f70 · 2026-09-01 12:44:05 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit a6fd514fbaad9cfba8c968c9b231e366ac0d8f70
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 1 12:44:05 2026 -0700

    TK-11076: Rebel Walls AI-palette retag remediate+rollback (canary-safe, per-item reversible)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/rebelwalls-onboard/tk11076-remediate.mjs | 271 +++++++++++++++++++++++
 scripts/rebelwalls-onboard/tk11076-rollback.mjs  |  51 +++++
 2 files changed, 322 insertions(+)

diff --git a/scripts/rebelwalls-onboard/tk11076-remediate.mjs b/scripts/rebelwalls-onboard/tk11076-remediate.mjs
new file mode 100644
index 0000000..fa08edc
--- /dev/null
+++ b/scripts/rebelwalls-onboard/tk11076-remediate.mjs
@@ -0,0 +1,271 @@
+#!/usr/bin/env node
+/**
+ * TK-11076 — batch-correct LIVE Rebel Walls products (~390 affected of 1376 live).
+ *  ANCHOR: rebelwalls_catalog.color_name is the AUTHORITATIVE real colorway (Mac2-canonical).
+ *  Per live product, joined by shopify_product_id (clean 1:1):
+ *   (1) set the ONE correct color:<color_name> facet (+ canonical bare colorway tag);
+ *   (2) strip AI-palette STRAY color-word tags = tags that are in THIS product's OWN
+ *       ai_colors[].name + ai_background_color AND are NOT the real colorway. Source-field
+ *       driven, per-product — NOT a global color denylist. Also strip any existing color:
+ *       facet (rebuilt from the anchor);
+ *   (3) PRESERVE the real color facet, a legit bare color tag that IS the product's colorway,
+ *       and ALL non-color tags (collection/material/vendor/display_variant/animal/style/etc).
+ *  NO SKU / price / status / channel / image writes.
+ *
+ *  PG-first: nothing to change in rebelwalls_catalog for the tag fix (color_name is already the
+ *  authoritative anchor we read). We record the old tag array in this vendor's OWN rollback file,
+ *  then write Shopify (authoritative). Reversible + ledgered. If a product's color_name is empty
+ *  it is SKIPPED (never guessed).
+ *
+ *   node tk11076-remediate.mjs                       # DRY-RUN report only (no writes)
+ *   node tk11076-remediate.mjs --apply --canary=6    # canary N diverse products, before/after diff
+ *   node tk11076-remediate.mjs --apply --only=h1,h2  # apply to specific handles
+ *   node tk11076-remediate.mjs --apply               # full batch (90s gap every 25)
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execSync } from 'node:child_process';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const DB = 'postgresql:///dw_unified?host=/tmp';
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = process.env.SHOPIFY_API_VERSION || '2024-10';
+const APPLY = process.argv.includes('--apply');
+const CANARY = parseInt((process.argv.find(a => a.startsWith('--canary=')) || '').split('=')[1] || '0', 10);
+const ONLY = ((process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1] || '').split(',').filter(Boolean);
+const ROLLBACK = path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible/TK-11076-Rebel_Walls-rollback.json');
+const LEDGER_LOG = path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible');
+const DIFF_OUT = path.join(HERE, 'out', 'tk11076-canary-diff.json');
+const SKIPS_OUT = path.join(HERE, 'out', 'tk11076-skips.json');
+if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const decode = s => String(s || '').replace(/&amp;/g, '&').replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/&#38;/g, '&');
+const titleCase = s => decode(s).replace(/\b\w/g, c => c.toUpperCase()).trim();
+// normalize a tag for comparison: decode entities, lowercase, collapse slash-spacing + whitespace, unify &
+const norm = s => decode(s).toLowerCase().replace(/\s*&\s*/g, ' & ').replace(/\s*\/\s*/g, '/').replace(/\s+/g, ' ').trim();
+
+// Color lexicon — a tag is "color-ish" only if EVERY space/slash/&-joined token is a known
+// color word or modifier. Used ONLY as a guard so we never mistake a non-color tag for a stray.
+// The actual STRIP decision is palette-membership (this product's own AI palette), not this list.
+const COLOR_MODIFIERS = new Set(['soft','pale','dark','light','deep','bright','warm','cool','dusty',
+  'metallic','metalic','antique','burnished','muted','rich','pastel','vintage','dark','&','and']);
+const COLOR_WORDS = new Set([
+  'white','black','grey','gray','ivory','cream','beige','stone','dove','taupe','greige','oatmeal',
+  'alabaster','bone','eggshell','linen','sand','almond','pearl','chalk','parchment','champagne','ecru',
+  'silver','gold','gilver','bronze','copper','pewter','platinum','mica','graphite','charcoal','slate',
+  'teal','aqua','turquoise','jade','celadon','sage','mint','emerald','forest','olive','chartreuse','lime','green',
+  'blue','navy','indigo','cobalt','sapphire','cornflower','sky','denim','powder','peacock','azure','petrol',
+  'red','crimson','scarlet','burgundy','wine','russet','terracotta','rust','brick','coral','salmon','blush',
+  'pink','rose','fuchsia','magenta','lavender','lilac','purple','plum','amethyst','mauve','violet',
+  'yellow','ochre','saffron','mustard','honey','amber','peach','apricot','tan','walnut','espresso','coffee',
+  'brown','chocolate','khaki','multi','natural','neutral','midnight','seafoam','ash','rainbow','mushroom']);
+function isColorTag(tag) {
+  const toks = norm(tag).split(/[\s/]+/).filter(Boolean);
+  if (!toks.length) return false;
+  return toks.every(t => COLOR_WORDS.has(t) || COLOR_MODIFIERS.has(t));
+}
+
+async function gql(query, variables) {
+  for (let a = 0; a < 5; a++) {
+    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 }), signal: AbortSignal.timeout(45000) });
+    const j = await res.json().catch(() => ({}));
+    if (j.errors) { if (a === 4) return { errors: j.errors }; await sleep(1200 * (a + 1)); continue; }
+    await sleep(350); return j.data;
+  }
+}
+
+// pull ALL live Rebel Walls products
+async function fetchLive() {
+  const out = []; let cursor = null;
+  const q = `query($cursor:String){
+    products(first:100, query:"vendor:'Rebel Walls'", after:$cursor){
+      pageInfo{ hasNextPage endCursor }
+      nodes{ id handle status title tags
+        featuredImage{ url }
+        variants(first:5){ nodes{ sku price } } }
+    } }`;
+  for (let guard = 0; guard < 60; guard++) {
+    const d = await gql(q, { cursor });
+    if (!d || d.errors) { console.error('fetchLive gql error', JSON.stringify(d && d.errors)); break; }
+    for (const n of d.products.nodes) out.push(n);
+    if (!d.products.pageInfo.hasNextPage) break;
+    cursor = d.products.pageInfo.endCursor;
+  }
+  return out;
+}
+
+// map numeric shopify_product_id → { colorway, palette:Set(titleCase) } from rebelwalls_catalog
+function anchorMap() {
+  const raw = execSync(`psql "${DB}" -tAc "select coalesce(json_agg(json_build_object('pid',shopify_product_id,'color',color_name,'bg',ai_background_color,'colors',ai_colors))::text,'[]') from rebelwalls_catalog where shopify_product_id is not null"`,
+    { encoding: 'utf8', maxBuffer: 128 * 1024 * 1024 }).trim();
+  const m = new Map();
+  for (const r of JSON.parse(raw || '[]')) {
+    const palette = new Set(
+      [...(Array.isArray(r.colors) ? r.colors.map(c => c && (c.name || c.color)) : []), r.bg]
+        .filter(Boolean).map(x => titleCase(x)));
+    m.set(String(r.pid), { colorway: r.color ? decode(r.color).trim() : '', palette });
+  }
+  return m;
+}
+
+function pidOf(gid) { return String(gid || '').split('/').pop(); }
+
+async function run() {
+  const anchor = anchorMap();
+  let live = await fetchLive();
+  console.log(`live Rebel Walls products: ${live.length}`);
+  if (ONLY.length) live = live.filter(p => ONLY.includes(p.handle));
+
+  const plans = [], skipped = [], noAnchor = [];
+  for (const p of live) {
+    const a = anchor.get(pidOf(p.id));
+    if (!a) { noAnchor.push({ handle: p.handle, pid: pidOf(p.id), title: p.title, reason: 'no rebelwalls_catalog row for this shopify_product_id' }); continue; }
+    const colorway = a.colorway;
+    if (!colorway) { skipped.push({ handle: p.handle, pid: pidOf(p.id), title: p.title, reason: 'empty color_name in anchor — not confidently resolvable, skip-not-guess' }); continue; }
+
+    const bareColorway = titleCase(colorway);            // canonical colorway tag form
+    const facet = 'color:' + bareColorway;               // the ONE correct facet
+    const palette = a.palette || new Set();              // THIS product's own AI palette (titleCase)
+
+    const newTags = [];
+    const strippedFacet = [], strippedPalette = [];
+    for (const t of p.tags) {
+      // (2a) drop ALL existing color: facets (rebuild the one correct facet from anchor)
+      if (/^color:/i.test(t)) { strippedFacet.push(t); continue; }
+      // KEEP a bare tag that IS the real colorway (dedup — canonical re-added below)
+      if (norm(t) === norm(bareColorway)) continue;
+      // (2b) STRIP palette-sourced strays: tag is in THIS product's OWN AI palette AND is a color tag
+      //      AND is not the real colorway. palette membership is the noise signal; isColorTag guards
+      //      against nuking a non-color tag that happens to collide with a palette word.
+      if (palette.has(titleCase(t)) && isColorTag(t)) { strippedPalette.push(t); continue; }
+      newTags.push(t);                                   // (3) PRESERVE every other tag as-is
+    }
+    // (1) add the ONE correct facet + canonical bare colorway
+    if (!newTags.includes(facet)) newTags.push(facet);
+    if (!newTags.includes(bareColorway)) newTags.push(bareColorway);
+
+    const changed = JSON.stringify([...p.tags].sort()) !== JSON.stringify([...newTags].sort());
+    if (!changed) continue;
+    plans.push({ id: p.id, handle: p.handle, status: p.status, pid: pidOf(p.id), title: p.title,
+      colorway: bareColorway, facet, image: p.featuredImage && p.featuredImage.url,
+      variants: p.variants.nodes, oldTags: p.tags, newTags, strippedFacet, strippedPalette });
+  }
+
+  console.log(`plans: ${plans.length} products need correction · skipped(empty color_name): ${skipped.length} · no-anchor: ${noAnchor.length}`);
+  fs.writeFileSync(SKIPS_OUT, JSON.stringify({ skipped, noAnchor }, null, 2));
+
+  // ---- CANARY (before/after diff, JUDGE safety) ----
+  if (CANARY && !ONLY.length) {
+    // pick diverse: spread across the plan (every Nth) so we sample varied colorways/palettes
+    const step = Math.max(1, Math.floor(plans.length / CANARY));
+    const picks = [];
+    for (let i = 0; i < plans.length && picks.length < CANARY; i += step) picks.push(plans[i]);
+    while (picks.length < Math.min(CANARY, plans.length)) picks.push(plans[picks.length]);
+    const diff = picks.map(pl => {
+      const add = pl.newTags.filter(t => !pl.oldTags.includes(t));
+      const rem = pl.oldTags.filter(t => !pl.newTags.includes(t));
+      // SAFETY JUDGE: a removed tag is a LEGIT-tag loss ONLY if it is NOT a color: facet, NOT a
+      // palette-sourced stray, AND not merely the bare colorway being re-cased to canonical form
+      // (e.g. "white" removed but "White" + "color:White" added — the colorway is preserved).
+      const legitLost = rem.filter(t => !/^color:/i.test(t) && !pl.strippedPalette.includes(t) && norm(t) !== norm(pl.colorway));
+      return { handle: pl.handle, pid: pl.pid, title: pl.title, colorway: pl.colorway,
+        before: pl.oldTags, after: pl.newTags, added: add, removed: rem,
+        stripped_palette: pl.strippedPalette, stripped_facet: pl.strippedFacet,
+        LEGIT_TAG_LOST: legitLost };
+    });
+    fs.writeFileSync(DIFF_OUT, JSON.stringify(diff, null, 2));
+    console.log(`\n===== CANARY (${diff.length} products) — before/after tag diff =====`);
+    let unsafe = 0;
+    for (const d of diff) {
+      console.log(`\n[${d.pid}] ${d.handle}  "${d.title}"  → color:${d.colorway}`);
+      console.log(`   BEFORE: ${d.before.join(' | ')}`);
+      console.log(`   AFTER : ${d.after.join(' | ')}`);
+      console.log(`   +[${d.added.join(', ')}]  -[${d.removed.join(', ')}]`);
+      if (d.LEGIT_TAG_LOST.length) { console.log(`   ⛔ LEGIT TAG LOST: ${JSON.stringify(d.LEGIT_TAG_LOST)}`); unsafe++; }
+    }
+    console.log(`\ncanary diff → ${DIFF_OUT}`);
+    if (unsafe) { console.log(`\n⛔ CANARY UNSAFE: ${unsafe} product(s) would lose a legit non-color/non-palette tag. STOP — do NOT run the batch.`); process.exit(2); }
+    console.log(`\n✅ CANARY SAFE: every removed tag is either a color: facet or a palette-sourced stray. No legit tag lost.`);
+    if (!APPLY) return;  // canary is report-only unless caller explicitly wants to proceed
+  }
+
+  if (!APPLY) {
+    const sample = plans.slice(0, 8);
+    for (const pl of sample) {
+      const add = pl.newTags.filter(t => !pl.oldTags.includes(t));
+      const rem = pl.oldTags.filter(t => !pl.newTags.includes(t));
+      console.log(`\n[${pl.status}] ${pl.handle} "${pl.title}" → ${pl.facet}`);
+      console.log(`   +[${add.join(', ')}]  -[${rem.join(', ')}]`);
+    }
+    console.log(`\nDRY-RUN. ${plans.length} would change. Re-run with --apply (or --apply --canary=6 first).`);
+    return;
+  }
+
+  // ---- APPLY (full batch) ----
+  let toApply = plans;
+  const rollback = fs.existsSync(ROLLBACK) ? JSON.parse(fs.readFileSync(ROLLBACK, 'utf8')) : {};
+  let done = 0, pass = 0, fail = 0, facetsSet = 0, straysStripped = 0, batchCount = 0;
+  for (const pl of toApply) {
+    // hard safety re-check per item: never remove a legit non-color/non-palette tag (colorway
+    // re-casing to canonical form is not a loss — the colorway stays in newTags).
+    const legitLost = pl.oldTags.filter(t => !pl.newTags.includes(t) && !/^color:/i.test(t) && !pl.strippedPalette.includes(t) && norm(t) !== norm(pl.colorway));
+    if (legitLost.length) { console.error(`   ⛔ SKIP ${pl.handle}: would lose legit tag ${JSON.stringify(legitLost)}`); fail++; continue; }
+
+    // record rollback BEFORE the write (own vendor file)
+    rollback[pl.handle] = { id: pl.id, pid: pl.pid, ts: new Date().toISOString(),
+      old_tags: pl.oldTags, new_tags: pl.newTags, colorway: pl.colorway,
+      stripped_palette: pl.strippedPalette, stripped_facet: pl.strippedFacet };
+    fs.writeFileSync(ROLLBACK, JSON.stringify(rollback, null, 2));
+
+    const d = await gql(`mutation($input:ProductInput!){ productUpdate(input:$input){ product{ id } userErrors{ field message } } }`,
+      { input: { id: pl.id, tags: pl.newTags } });
+    const ue = d && d.productUpdate && d.productUpdate.userErrors;
+    if (ue && ue.length) { console.error(`   userErrors ${pl.handle}: ${JSON.stringify(ue)}`); fail++; continue; }
+
+    // per-item VERIFY: re-GET tags + featuredImage + variants (confirm no image/price/status change)
+    const v = await gql(`query($id:ID!){ product(id:$id){ status tags featuredImage{ url } variants(first:5){ nodes{ sku price } } } }`, { id: pl.id });
+    const P = (v && v.product) || {};
+    const liveTags = P.tags || [];
+    const facetOk = liveTags.includes(pl.facet);
+    const onlyOneFacet = liveTags.filter(t => /^color:/i.test(t)).length === 1;
+    const paletteGone = !pl.strippedPalette.some(t => liveTags.includes(t) && titleCase(t) !== pl.colorway);
+    const legitIntact = pl.oldTags.every(t => (/^color:/i.test(t) || pl.strippedPalette.includes(t) || norm(t) === norm(pl.colorway)) || liveTags.includes(t));
+    const imgOk = (P.featuredImage && P.featuredImage.url || null) === (pl.image || null);
+    const varsOk = JSON.stringify((P.variants && P.variants.nodes || []).map(x => [x.sku, x.price])) ===
+                   JSON.stringify((pl.variants || []).map(x => [x.sku, x.price]));
+    const statusOk = P.status === pl.status;
+    const verifyOk = facetOk && onlyOneFacet && paletteGone && legitIntact && imgOk && varsOk && statusOk;
+    if (verifyOk) { pass++; facetsSet++; straysStripped += pl.strippedPalette.length; }
+    else { fail++; console.error(`   VERIFY FAIL ${pl.handle}: facetOk=${facetOk} onlyOneFacet=${onlyOneFacet} paletteGone=${paletteGone} legitIntact=${legitIntact} imgOk=${imgOk} varsOk=${varsOk} statusOk=${statusOk}`); }
+
+    done++;
+    console.log(`[${done}/${toApply.length}] ${pl.handle} ✓ ${verifyOk ? 'verified' : 'VERIFY-FAIL'} (color:${pl.colorway}, -${pl.strippedPalette.length} stray)`);
+    try {
+      execSync(`node "${path.join(LEDGER_LOG, 'log-exec.mjs')}" ` +
+        `--agent vp-dw-commerce --ticket TK-11076 ` +
+        `--action "Rebel Walls retag ${pl.handle}: ${pl.facet}, strip AI-palette strays" ` +
+        `--blast 1 ` +
+        `--undo "cd ${HERE} && node tk11076-rollback.mjs --only=${pl.handle} --apply" ` +
+        `--verify "GET product ${pl.pid} — color facet correct, palette strays gone, non-color tags intact, no image/price/status change"`,
+        { stdio: 'ignore' });
+    } catch { /* ledger best-effort */ }
+
+    // CIRCUIT BREAKER (codex-recommended): halt if verify-fail rate spikes after a warmup.
+    if (done >= 20 && fail / done > 0.15) {
+      console.error(`\n⛔ CIRCUIT BREAKER: verify-fail rate ${(100 * fail / done).toFixed(0)}% (${fail}/${done}) > 15%. Halting batch. Rollback: ${ROLLBACK}`);
+      break;
+    }
+
+    batchCount++;
+    if (batchCount % 25 === 0 && done < toApply.length) { console.log('   …90s inter-batch gap…'); await sleep(90000); }
+    else await sleep(600);
+  }
+  console.log(`\nDONE: ${done} products · verify-pass ${pass} · verify-fail ${fail} · facets-set ${facetsSet} · strays-stripped ${straysStripped}`);
+  console.log(`rollback map: ${ROLLBACK}`);
+  fs.writeFileSync(path.join(HERE, 'out', 'tk11076-summary.json'), JSON.stringify({ done, pass, fail, facetsSet, straysStripped, skipped: skipped.length, noAnchor: noAnchor.length }, null, 2));
+}
+run();
diff --git a/scripts/rebelwalls-onboard/tk11076-rollback.mjs b/scripts/rebelwalls-onboard/tk11076-rollback.mjs
new file mode 100644
index 0000000..2d49c04
--- /dev/null
+++ b/scripts/rebelwalls-onboard/tk11076-rollback.mjs
@@ -0,0 +1,51 @@
+#!/usr/bin/env node
+/**
+ * TK-11076 — Rebel Walls tag-retag ROLLBACK. Restores each product's old_tags from this
+ * vendor's OWN rollback file. Reversible, per-handle.
+ *
+ *   node tk11076-rollback.mjs                       # DRY-RUN: list what would revert
+ *   node tk11076-rollback.mjs --only=h1,h2 --apply  # revert specific handles
+ *   node tk11076-rollback.mjs --apply               # revert ALL recorded handles
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = process.env.SHOPIFY_API_VERSION || '2024-10';
+const APPLY = process.argv.includes('--apply');
+const ONLY = ((process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1] || '').split(',').filter(Boolean);
+const ROLLBACK = path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible/TK-11076-Rebel_Walls-rollback.json');
+if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function gql(query, variables) {
+  for (let a = 0; a < 5; a++) {
+    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 }), signal: AbortSignal.timeout(45000) });
+    const j = await res.json().catch(() => ({}));
+    if (j.errors) { if (a === 4) return { errors: j.errors }; await sleep(1200 * (a + 1)); continue; }
+    await sleep(350); return j.data;
+  }
+}
+
+async function main() {
+  if (!fs.existsSync(ROLLBACK)) { console.error(`no rollback file at ${ROLLBACK}`); process.exit(1); }
+  const map = JSON.parse(fs.readFileSync(ROLLBACK, 'utf8'));
+  let handles = Object.keys(map);
+  if (ONLY.length) handles = handles.filter(h => ONLY.includes(h));
+  console.log(`rollback candidates: ${handles.length}`);
+  if (!APPLY) { for (const h of handles) console.log(`  ${h} → restore ${map[h].old_tags.length} old tags`); console.log('\nDRY-RUN. Re-run with --apply.'); return; }
+  let done = 0, fail = 0;
+  for (const h of handles) {
+    const r = map[h];
+    const d = await gql(`mutation($input:ProductInput!){ productUpdate(input:$input){ product{ id } userErrors{ field message } } }`,
+      { input: { id: r.id, tags: r.old_tags } });
+    const ue = d && d.productUpdate && d.productUpdate.userErrors;
+    if (ue && ue.length) { console.error(`  userErrors ${h}: ${JSON.stringify(ue)}`); fail++; continue; }
+    done++; console.log(`[${done}] ${h} reverted to ${r.old_tags.length} old tags`);
+    await sleep(600);
+  }
+  console.log(`\nROLLBACK DONE: ${done} reverted · ${fail} failed`);
+}
+main();

← 8e552b1 TK-11076: Kravet colorway retag scripts + canary halt (ancho  ·  back to Designerwallcoverings  ·  TK-11076 Anna French colorway retag: remediate+rollback+cana 9b0e128 →