[object Object]

← back to Designerwallcoverings

TK-11073 Wave2: Stout LIVE remediation — 180 products retagged

c3c3b079934fe2719a2eaf597b787dfd30f91bda · 2026-09-01 12:17:54 -0700 · Steve Abrams

Added color:<real colorway> facet (from custom.color, authoritative) to all 180 live Stout
products (none had a facet) and stripped AI-palette bare-tag strays (source: stout_catalog.ai_colors
per mfr_sku — source-field driven), preserving the real colorway + all non-color tags. NO image
writes. 176/176 batch + 4 canary verified via per-item re-GET (180 total, 0 fails). Reversible via
tk11073-rollback.mjs, ledgered, 90s inter-batch gaps.

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

Files touched

Diff

commit c3c3b079934fe2719a2eaf597b787dfd30f91bda
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 1 12:17:54 2026 -0700

    TK-11073 Wave2: Stout LIVE remediation — 180 products retagged
    
    Added color:<real colorway> facet (from custom.color, authoritative) to all 180 live Stout
    products (none had a facet) and stripped AI-palette bare-tag strays (source: stout_catalog.ai_colors
    per mfr_sku — source-field driven), preserving the real colorway + all non-color tags. NO image
    writes. 176/176 batch + 4 canary verified via per-item re-GET (180 total, 0 fails). Reversible via
    tk11073-rollback.mjs, ledgered, 90s inter-batch gaps.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/stout-onboard/tk11073-remediate.mjs | 175 ++++++++++++++++++++++++++++
 scripts/stout-onboard/tk11073-rollback.mjs  |  50 ++++++++
 2 files changed, 225 insertions(+)

diff --git a/scripts/stout-onboard/tk11073-remediate.mjs b/scripts/stout-onboard/tk11073-remediate.mjs
new file mode 100644
index 0000000..3cc3b29
--- /dev/null
+++ b/scripts/stout-onboard/tk11073-remediate.mjs
@@ -0,0 +1,175 @@
+#!/usr/bin/env node
+/**
+ * TK-11073 Wave 2 — batch-correct LIVE Stout Textiles products.
+ *  • ADD color:<real colorway> facet (colorway = custom.color metafield — authoritative, no ambiguity).
+ *  • STRIP bare AI-palette color-word tags (source: THIS product's OWN color_details / color_N_name
+ *    metafields — deterministic, per-product, NOT a global denylist). NEVER strip the real colorway.
+ *  • PRESERVE every non-color tag + the real color_name bare tag. NO image writes.
+ *
+ * Reversible (TK-11073-rollback-map.json) · ledgered · per-item re-GET verified.
+ *
+ *   node tk11073-remediate.mjs                    # DRY-RUN report
+ *   node tk11073-remediate.mjs --apply --limit=4  # canary
+ *   node tk11073-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-11073-rollback-map.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+/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) → Set of AI palette color-word names from stout_catalog.ai_colors (the definitive
+// AI color-swatch output that generated the live bare-tag pollution). Source-field driven.
+function paletteMap() {
+  const raw = execSync(`psql "${DB}" -tAc "select coalesce(json_agg(json_build_object('sku',mfr_sku,'colors',ai_colors))::text,'[]') from stout_catalog"`,
+    { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
+  const m = new Map();
+  for (const r of JSON.parse(raw || '[]')) {
+    if (!r.sku) continue;
+    const s = new Set();
+    for (const c of (Array.isArray(r.colors) ? r.colors : [])) { const n = c && (c.name || c.color); if (n) s.add(titleCase(n)); }
+    m.set(String(r.sku).toUpperCase(), s);
+  }
+  return m;
+}
+
+async function fetchLive() {
+  const out = []; let cursor = null;
+  const q = `query($cursor:String){
+    products(first:100, query:"vendor:'Stout Textiles'", after:$cursor){
+      pageInfo{ hasNextPage endCursor }
+      nodes{ id handle status title tags
+        mfr: metafield(namespace:"custom", key:"manufacturer_sku"){ value }
+        col: metafield(namespace:"custom", key:"color"){ value }
+        variants(first:3){ nodes{ sku } } }
+    } }`;
+  for (let guard = 0; guard < 30; 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;
+}
+
+async function main() {
+  const palByMfr = paletteMap();
+  let live = await fetchLive();
+  console.log(`live Stout products: ${live.length}`);
+  if (ONLY.length) live = live.filter(p => ONLY.includes(p.handle));
+
+  const plans = [], skippedAdd = [];
+  for (const p of live) {
+    const mfr = (p.mfr && p.mfr.value) || (p.variants.nodes.map(v => v.sku).find(Boolean) || '').replace(/^DWST-\d+.*/i, '');
+    const mfrU = String(mfr).toUpperCase();
+    const colorway = p.col && !bad(p.col.value) ? decode(p.col.value).trim() : null;   // authoritative real colorway
+    const bareColorway = colorway ? titleCase(colorway) : null;
+
+    // palette strip source = stout_catalog.ai_colors[].name for THIS mfr_sku (source-field driven).
+    const palette = palByMfr.get(mfrU) || new Set();
+
+    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's the real colorway (never strip the real colorway)
+      if (palette.has(titleCase(t)) && (!bareColorway || norm(t) !== norm(bareColorway))) { strippedPalette.push(t); continue; }
+      if (bareColorway && norm(t) === norm(bareColorway)) continue;                    // dedup, re-added canonical below
+      newTags.push(t);                                                                 // PRESERVE non-color tags
+    }
+    if (colorway) {
+      const facet = 'color:' + titleCase(colorway);
+      if (!newTags.includes(facet)) newTags.push(facet);
+      if (!newTags.includes(bareColorway)) newTags.push(bareColorway);
+    } else if (strippedFacet.length) {
+      skippedAdd.push({ handle: p.handle, reason: 'no custom.color — facet stripped, none re-added' });
+    }
+    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, title: p.title, colorway,
+      oldTags: p.tags, newTags, strippedFacet, strippedPalette });
+  }
+
+  const addCt = plans.filter(pl => pl.colorway).length;
+  console.log(`plans: ${plans.length} products need correction (${addCt} get color: facet added; ${plans.length - addCt} strip-only) · skipped-add: ${skippedAdd.length}`);
+  fs.writeFileSync(path.join(HERE, 'out', 'tk11073-skips.json'), JSON.stringify(skippedAdd, null, 2));
+
+  let toApply = plans;
+  if (LIMIT) toApply = plans.slice(0, LIMIT);
+  for (const pl of (LIMIT ? toApply : plans).slice(0, 8)) {
+    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}" → color:${pl.colorway || '(none)'}`);
+    console.log(`   +[${add.join(', ')}]  -[${rem.join(', ')}]`);
+  }
+
+  if (!APPLY) { console.log(`\nDRY-RUN. ${plans.length} would change (applying ${toApply.length}). Re-run with --apply.`); return; }
+
+  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, 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; }
+
+    const v = await gql(`query($id:ID!){ product(id:$id){ tags } }`, { id: pl.id });
+    const liveTags = (v && v.product && v.product.tags) || [];
+    const keepColorway = pl.colorway ? titleCase(pl.colorway) : null;
+    const facetOk = !pl.colorway || liveTags.includes('color:' + titleCase(pl.colorway));
+    const noWrongFacet = !liveTags.some(t => /^color:/i.test(t) && (!pl.colorway || norm(t) !== norm('color:' + pl.colorway)));
+    const paletteGone = !pl.strippedPalette.some(t => liveTags.includes(t) && titleCase(t) !== keepColorway);
+    const verifyOk = facetOk && noWrongFacet && paletteGone;
+    if (verifyOk) pass++; else { fail++; console.error(`   VERIFY FAIL ${pl.handle}: facetOk=${facetOk} noWrongFacet=${noWrongFacet} paletteGone=${paletteGone}`); }
+
+    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-11073 ` +
+        `--action "Stout retag ${pl.handle}: color:${pl.colorway || 'none'}, strip AI-palette" ` +
+        `--blast 1 ` +
+        `--undo "cd ${HERE} && node tk11073-rollback.mjs --only=${pl.handle} --apply" ` +
+        `--verify "GET product ${pl.id} — color facet correct, palette strays gone, non-color tags intact"`,
+        { stdio: 'ignore' });
+    } catch {}
+
+    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}`);
+  console.log(`rollback map: ${ROLLBACK}`);
+}
+main();
diff --git a/scripts/stout-onboard/tk11073-rollback.mjs b/scripts/stout-onboard/tk11073-rollback.mjs
new file mode 100644
index 0000000..5ababd5
--- /dev/null
+++ b/scripts/stout-onboard/tk11073-rollback.mjs
@@ -0,0 +1,50 @@
+#!/usr/bin/env node
+/**
+ * TK-11073 rollback — restore products to pre-remediation tags from
+ * ~/.claude/yolo-queue/executed-reversible/TK-11073-rollback-map.json.
+ * Tags-only (Waves 1-2 & 4 are retag-only; no image/PG writes to reverse).
+ *
+ *   node tk11073-rollback.mjs                    # DRY-RUN, all recorded handles
+ *   node tk11073-rollback.mjs --only=h1,h2       # DRY-RUN specific handles
+ *   node tk11073-rollback.mjs --apply            # RESTORE all
+ *   node tk11073-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-11073-rollback-map.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();

← 76fe1a2 auto-data-snapshot: 2026-09-01T12:16:45 (5 data files) — dat  ·  back to Designerwallcoverings  ·  TK-11076: PJ retag halted at canary gate (stopped-canary-uns 672d319 →