← 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
A scripts/coleson-onboard/tk11076-remediate.mjsA scripts/coleson-onboard/tk11076-rollback.mjs
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(/&/g, '&').replace(/'/g, "'").replace(/"/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 →