[object Object]

← back to Designerwallcoverings

TK-11076: Cole & Son color-facet remediation + rollback scripts

7c24b65e4cfda308d76daeacb366ca62a398f3e8 · 2026-09-04 12:42:38 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STupyqBoLfTPHaTr1Gi42b

Files touched

Diff

commit 7c24b65e4cfda308d76daeacb366ca62a398f3e8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 4 12:42:38 2026 -0700

    TK-11076: Cole & Son color-facet remediation + rollback scripts
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01STupyqBoLfTPHaTr1Gi42b
---
 scripts/coleson-onboard/tk11076-remediate.mjs | 215 ++++++++++++++++++++++++++
 scripts/coleson-onboard/tk11076-rollback.mjs  |  50 ++++++
 2 files changed, 265 insertions(+)

diff --git a/scripts/coleson-onboard/tk11076-remediate.mjs b/scripts/coleson-onboard/tk11076-remediate.mjs
new file mode 100644
index 0000000..3fd9182
--- /dev/null
+++ b/scripts/coleson-onboard/tk11076-remediate.mjs
@@ -0,0 +1,215 @@
+#!/usr/bin/env node
+/**
+ * TK-11076 — batch-correct LIVE Cole & Son products (color facet + AI-palette stray strip).
+ *
+ *  ANCHOR: coleson_catalog.color_name is the authoritative real colorway (Mac2-canonical, /tmp socket).
+ *          Join LIVE product -> catalog by mfr_sku (custom.manufacturer_sku metafield, else variant SKU).
+ *
+ *  PER PRODUCT:
+ *   (1) SET the correct color:<real colorway> facet (from catalog color_name for THIS mfr_sku).
+ *   (2) STRIP AI-palette STRAY color-word tags = bare tags present in THIS product's OWN ai_colors[].name
+ *       + ai_background_color (source-field driven, per-product, NOT a global denylist), UNLESS the tag
+ *       IS the real colorway (never strip the real colorway).
+ *       ALSO strip any WRONG color:<x> facet (x != real colorway).
+ *   (3) PRESERVE: the real color facet + real colorway bare tag, and EVERY non-color tag
+ *       (collection/material/vendor/room/style/New Arrival/Trending/display_variant/etc).
+ *
+ *  A product that CANNOT be anchored (no catalog row for its mfr_sku, or no real color_name) is SKIPPED
+ *  — never guessed. No image/price/status/SKU/sales-channel writes.
+ *
+ *  PG-FIRST: coleson_catalog.color_name is already the authoritative colorway (nothing to write there for
+ *  the facet fix — it is the SOURCE). We record old tags in this vendor's OWN rollback file, then write
+ *  Shopify (authoritative). Reversible + ledgered + per-item re-GET verified.
+ *
+ *   node tk11076-remediate.mjs                    # DRY-RUN report only
+ *   node tk11076-remediate.mjs --apply --limit=6  # canary (first N of the plan)
+ *   node tk11076-remediate.mjs --apply --only=h1,h2
+ *   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 LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').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-Cole_Son-rollback.json');
+const LEDGER_LOG = path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible');
+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, '"');
+const titleCase = s => decode(s).replace(/\b\w/g, c => c.toUpperCase()).trim();
+const norm = s => titleCase(s).toLowerCase().replace(/\s*\/\s*/g, '/').replace(/\s*&\s*/g, ' & ').replace(/\s+/g, ' ').trim();
+const bad = s => !s || /^\s*(unknown|n\/?a|null|none|-)?\s*$/i.test(String(s));
+
+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;
+  }
+}
+
+// mfr_sku(upper) -> { colorway (real color_name), palette:Set<titleCase names from ai_colors + ai_background_color> }
+function catalogMap() {
+  const raw = execSync(`psql "${DB}" -tAc "select coalesce(json_agg(json_build_object('sku',mfr_sku,'cw',color_name,'colors',ai_colors,'bg',ai_background_color))::text,'[]') from coleson_catalog"`,
+    { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
+  const m = new Map();
+  for (const r of JSON.parse(raw || '[]')) {
+    if (!r.sku) continue;
+    const palette = new Set();
+    for (const c of (Array.isArray(r.colors) ? r.colors : [])) { const n = c && (c.name || c.color); if (n) palette.add(titleCase(n)); }
+    if (r.bg) palette.add(titleCase(r.bg));
+    // ANCHOR TRUST GATE (TK-11076 canary finding): coleson_catalog.color_name has TWO generations —
+    // UPPERCASE/mixed-case values are the authoritative SCRAPED designer colorways (BUFF & GOLD,
+    // PRINT ROOM BLUE, Hyacinth on White…), but ALL-LOWERCASE values (beige, gray, ivory, teal…) are
+    // AI-enrichment LEAKAGE that demonstrably mismatch the product's real colorway (15/15 sampled were
+    // wrong, e.g. catalog 'beige' vs real 'Olive & Gold'). Trust ONLY non-all-lowercase color_name;
+    // all-lowercase -> null -> the product is SKIPPED (never guess a colorway).
+    const cwRaw = bad(r.cw) ? null : decode(r.cw).trim();
+    const trustworthy = cwRaw && cwRaw !== cwRaw.toLowerCase();
+    m.set(String(r.sku).toUpperCase(), { colorway: trustworthy ? cwRaw : null, palette });
+  }
+  return m;
+}
+
+async function fetchLive() {
+  const out = []; let cursor = null;
+  const q = `query($cursor:String){
+    products(first:100, query:"vendor:'Cole & Son'", after:$cursor){
+      pageInfo{ hasNextPage endCursor }
+      nodes{ id handle status title tags
+        mfr: metafield(namespace:"custom", key:"manufacturer_sku"){ value }
+        variants(first:5){ nodes{ sku } } }
+    } }`;
+  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;
+}
+
+// resolve mfr_sku from a live product: metafield first, else variant SKU (strip DW prefix + suffix)
+function resolveMfr(p) {
+  const mf = p.mfr && p.mfr.value && p.mfr.value.trim();
+  if (mf) return mf.toUpperCase();
+  for (const v of p.variants.nodes) {
+    const s = (v.sku || '').trim();
+    if (!s) continue;
+    // DWKK-133740-Sample / DWKK-133740-Sold Per Bolt (...) -> not a mfr sku; skip DW-prefixed
+    if (/^DW[A-Z]{1,3}-/i.test(s)) continue;
+    return s.toUpperCase();
+  }
+  return null;
+}
+
+async function main() {
+  const catByMfr = catalogMap();
+  let live = await fetchLive();
+  console.log(`live Cole & Son products: ${live.length}`);
+  if (ONLY.length) live = live.filter(p => ONLY.includes(p.handle));
+
+  const plans = [], skippedNoAnchor = [], skippedNoColorway = [];
+  for (const p of live) {
+    const mfrU = resolveMfr(p);
+    const cat = mfrU ? catByMfr.get(mfrU) : null;
+    if (!cat) { if (p.tags.some(t => /^color:/i.test(t)) || true) skippedNoAnchor.push({ handle: p.handle, mfr: mfrU, title: p.title }); continue; }
+    const colorway = cat.colorway;                              // authoritative real colorway
+    if (!colorway) { skippedNoColorway.push({ handle: p.handle, mfr: mfrU, title: p.title }); continue; }
+    const bareColorway = titleCase(colorway);
+    const palette = cat.palette;                                // THIS product's OWN AI palette (source-field)
+
+    const newTags = [];
+    const strippedFacet = [], strippedPalette = [];
+    for (const t of p.tags) {
+      if (/^color:/i.test(t)) { strippedFacet.push(t); continue; }                       // rebuild facet below
+      // strip a bare palette word UNLESS it IS the real colorway (never strip the real colorway)
+      if (palette.has(titleCase(t)) && norm(t) !== norm(bareColorway)) { strippedPalette.push(t); continue; }
+      if (norm(t) === norm(bareColorway)) continue;                                        // dedup, re-added canonical
+      newTags.push(t);                                                                     // PRESERVE non-color tags
+    }
+    const facet = 'color:' + bareColorway;
+    if (!newTags.includes(facet)) newTags.push(facet);
+    if (!newTags.some(t => norm(t) === norm(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, mfr: mfrU, title: p.title, colorway,
+      oldTags: p.tags, newTags, strippedFacet, strippedPalette });
+  }
+
+  const addCt = plans.length;
+  console.log(`plans: ${plans.length} anchored products need correction (all get color: facet set)`);
+  console.log(`skipped-NO-ANCHOR (no coleson_catalog row for mfr_sku — untouched, never guessed): ${skippedNoAnchor.length}`);
+  console.log(`skipped-NO-COLORWAY (catalog row but blank color_name — untouched): ${skippedNoColorway.length}`);
+  fs.writeFileSync(path.join(HERE, 'out', 'tk11076-skips.json'),
+    JSON.stringify({ skippedNoAnchor, skippedNoColorway }, null, 2));
+
+  let toApply = plans;
+  if (LIMIT) toApply = plans.slice(0, LIMIT);
+  for (const pl of toApply) {
+    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.mfr})  "${pl.title}" -> color:${pl.colorway}`);
+    console.log(`   +[${add.join(', ')}]  -[${rem.join(', ')}]`);
+  }
+
+  if (!APPLY) { console.log(`\nDRY-RUN. ${plans.length} would change (applying ${toApply.length}). Re-run with --apply.`); return; }
+
+  // ---- APPLY ----
+  const rollback = fs.existsSync(ROLLBACK) ? JSON.parse(fs.readFileSync(ROLLBACK, 'utf8')) : {};
+  let done = 0, pass = 0, fail = 0, batchCount = 0;
+  for (const pl of toApply) {
+    rollback[pl.handle] = { id: pl.id, mfr: pl.mfr, ts: new Date().toISOString(), old_tags: pl.oldTags, new_tags: pl.newTags };
+    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
+    const v = await gql(`query($id:ID!){ product(id:$id){ tags } }`, { id: pl.id });
+    const liveTags = (v && v.product && v.product.tags) || [];
+    const keepColorway = titleCase(pl.colorway);
+    const facetOk = liveTags.includes('color:' + keepColorway);
+    const noWrongFacet = !liveTags.some(t => /^color:/i.test(t) && norm(t) !== norm('color:' + pl.colorway));
+    const paletteGone = !pl.strippedPalette.some(t => liveTags.includes(t) && titleCase(t) !== keepColorway);
+    // no legit/non-color tag lost: every oldTag that is NOT a color: facet and NOT a stripped palette word must survive
+    const stripSet = new Set([...pl.strippedFacet, ...pl.strippedPalette]);
+    const preservedOk = pl.oldTags.every(t => stripSet.has(t) || liveTags.includes(t) || norm(t) === norm(keepColorway));
+    const verifyOk = facetOk && noWrongFacet && paletteGone && preservedOk;
+    if (verifyOk) pass++; else { fail++; console.error(`   VERIFY FAIL ${pl.handle}: facetOk=${facetOk} noWrongFacet=${noWrongFacet} paletteGone=${paletteGone} preservedOk=${preservedOk}`); }
+
+    done++;
+    console.log(`[${done}/${toApply.length}] ${pl.handle} ${verifyOk ? '✓ verified' : '✗ VERIFY-FAIL'}`);
+    try {
+      execSync(`node "${path.join(LEDGER_LOG, 'log-exec.mjs')}" ` +
+        `--agent vp-dw-commerce --ticket TK-11076 ` +
+        `--action "Cole & Son retag ${pl.handle}: color:${pl.colorway}, strip AI-palette strays" ` +
+        `--blast 1 ` +
+        `--undo "cd ${HERE} && node tk11076-rollback.mjs --only=${pl.handle} --apply" ` +
+        `--verify "GET product ${pl.id} — color facet correct, palette strays gone, non-color tags intact"`,
+        { stdio: 'ignore' });
+    } catch { /* ledger best-effort */ }
+
+    batchCount++;
+    if (batchCount % 25 === 0 && done < toApply.length) { console.log('   …90s inter-batch gap…'); await sleep(90000); }
+    else await sleep(700);
+  }
+  console.log(`\nDONE: ${done} products · verify-pass ${pass} · verify-fail ${fail}`);
+  console.log(`rollback file: ${ROLLBACK}`);
+}
+main();
diff --git a/scripts/coleson-onboard/tk11076-rollback.mjs b/scripts/coleson-onboard/tk11076-rollback.mjs
new file mode 100644
index 0000000..5d1504c
--- /dev/null
+++ b/scripts/coleson-onboard/tk11076-rollback.mjs
@@ -0,0 +1,50 @@
+#!/usr/bin/env node
+/**
+ * TK-11076 Cole & Son rollback — restore products to pre-remediation tags from
+ * ~/.claude/yolo-queue/executed-reversible/TK-11076-Cole_Son-rollback.json (this vendor's OWN file).
+ * Tags-only (retag-only; no image/price/status/PG writes to reverse).
+ *
+ *   node tk11076-rollback.mjs                    # DRY-RUN, all recorded handles
+ *   node tk11076-rollback.mjs --only=h1,h2       # DRY-RUN specific handles
+ *   node tk11076-rollback.mjs --apply            # RESTORE all
+ *   node tk11076-rollback.mjs --only=h1 --apply  # RESTORE one
+ */
+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-Cole_Son-rollback.json');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+
+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() {
+  const map = JSON.parse(fs.readFileSync(ROLLBACK, 'utf8'));
+  const handles = ONLY.length ? ONLY : Object.keys(map);
+  for (const h of handles) {
+    const r = map[h];
+    if (!r) { console.log(`no rollback record for ${h}`); continue; }
+    console.log(`[${h}] restore tags(${r.old_tags.length})`);
+    if (!APPLY) continue;
+    const d = await gql(`mutation($input:ProductInput!){ productUpdate(input:$input){ userErrors{ message } } }`,
+      { input: { id: r.id, tags: r.old_tags } });
+    const ue = d && d.productUpdate && d.productUpdate.userErrors;
+    if (ue && ue.length) console.error(`   errors: ${JSON.stringify(ue)}`); else console.log('   restored ✓');
+  }
+  if (!APPLY) console.log('\nDRY-RUN. Re-run with --apply to restore.');
+}
+main();

← 9412d37 auto-data-snapshot: 2026-09-04T12:10:54 (1 data files) — dat  ·  back to Designerwallcoverings  ·  TK-11076: colorway-title-anchor PL-exclusion + Malibu run sc bfae207 →