[object Object]

← back to Dw Unbuyable Recovery Pilot

TK-10978 RebelWalls: read-only recon + reversible archive executor (scope-inversion evidence; nothing fired)

ae3731db9e58a68ca2f21830ff8a1171da5fc149 · 2026-09-02 11:59:14 -0700 · steve

454/494 unbuyable RS-orphans are stale duplicates of already-buyable R-twins; archive (not make-buyable). Dry-run proven on 5.

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

Files touched

Diff

commit ae3731db9e58a68ca2f21830ff8a1171da5fc149
Author: steve <steve@designerwallcoverings.com>
Date:   Wed Sep 2 11:59:14 2026 -0700

    TK-10978 RebelWalls: read-only recon + reversible archive executor (scope-inversion evidence; nothing fired)
    
    454/494 unbuyable RS-orphans are stale duplicates of already-buyable R-twins; archive (not make-buyable). Dry-run proven on 5.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 tk10978-rebelwalls/archive-duplicates.mjs | 94 +++++++++++++++++++++++++++++++
 tk10978-rebelwalls/rescrape-recon.mjs     | 76 +++++++++++++++++++++++++
 2 files changed, 170 insertions(+)

diff --git a/tk10978-rebelwalls/archive-duplicates.mjs b/tk10978-rebelwalls/archive-duplicates.mjs
new file mode 100644
index 0000000..d4b4790
--- /dev/null
+++ b/tk10978-rebelwalls/archive-duplicates.mjs
@@ -0,0 +1,94 @@
+#!/usr/bin/env node
+// TK-10978 — Rebel Walls stale RS-orphan ARCHIVE (customer-facing Shopify write).
+//
+// FINDING (verified end-to-end, 2026-09-02): the store re-imported Rebel Walls
+// under a new R-series and made THOSE buyable, orphaning the old RS-series as
+// ACTIVE-but-unbuyable (sample-only). 454 RS-orphans have a buyable R-twin at
+// IDENTICAL mfr digits (RS15372<->R15372, confirmed: R15372 is ACTIVE with a live
+// per-m2 variant + $4.25 sample). Those RS-orphans are STALE DUPLICATES.
+//
+// CORRECT remediation = ARCHIVE the RS-orphan (its buyable R-twin already serves
+// customers), NOT make it buyable (that would create 454 customer-facing dupes).
+//
+// VERIFY-BEFORE-ACT (per product, live, at write time — never trust the CSV):
+//   1. GET the RS-orphan; must be status ACTIVE.
+//   2. RS-orphan must be genuinely unbuyable: NO non-Sample variant
+//      (every variant option1 ILIKE 'Sample' / '%-Sample' sku) — never archive
+//      a product that has a real sellable variant.
+//   3. GET the R-twin (from CSV twin_pid); must be status ACTIVE and HAVE a
+//      buyable non-Sample variant. If the twin isn't genuinely buyable -> SKIP
+//      (do not orphan the design).
+//   4. RS-orphan digits == R-twin digits (re-derive, don't trust the row).
+// Any guard fail -> SKIP + log; never an ambiguous archive.
+//
+// Reversibility FIRST: prestate.jsonl {pid, prior_status} written BEFORE the write.
+//   undo = set status back to prior_status (active). Ledgered per product.
+// Action: productUpdate status -> 'archived' (default; --status=draft alternative).
+//
+// Usage: node archive-duplicates.mjs [--limit=N] [--only=<pid>] [--status=archived|draft] [--live]
+//   default = DRY-RUN (no writes). --live requires Steve's explicit go.
+import { readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { execSync } from 'child_process';
+
+const TOKEN = execSync(`grep -E '^SHOPIFY_ADMIN_TOKEN=' ${process.env.HOME}/Projects/secrets-manager/.env|cut -d= -f2-`).toString().trim();
+const DOMAIN = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const REST = `https://${DOMAIN}/admin/api/${API}`;
+const hdr = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
+const LIVE = process.argv.includes('--live');
+const ONLY = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1];
+const LIMIT = Number((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || 0);
+const STATUS = (process.argv.find(a => a.startsWith('--status=')) || '').split('=')[1] || 'archived';
+
+const dataDir = new URL('./data', import.meta.url).pathname; mkdirSync(dataDir, { recursive: true });
+const PRESTATE = `${dataDir}/archive-prestate.jsonl`;
+const SKIPS = `${dataDir}/archive-skips.json`;
+const LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;
+
+const csv = readFileSync(`${dataDir}/archive-454-duplicates.csv`, 'utf8').trim().split('\n');
+csv.shift(); // header
+let rows = csv.map(l => { const m = l.match(/^(\d+),(".*?"|[^,]*),([^,]*),([^,]*),(\d+)$/); return m ? { pid:m[1], title:m[2].replace(/^"|"$/g,''), rs_sku:m[3], twin_sku:m[4], twin_pid:m[5] } : null; }).filter(Boolean);
+if (ONLY) rows = rows.filter(r => r.pid === ONLY);
+if (LIMIT > 0) rows = rows.slice(0, LIMIT);
+
+const digits = s => (String(s||'').match(/\d+/)||[''])[0];
+const isSample = v => /sample/i.test(v.option1||'') || /-sample$/i.test(v.sku||'');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+async function api(path, opts = {}) {
+  for (let a=0;a<6;a++){ const res=await fetch(`${REST}${path}`,{headers:hdr,...opts});
+    if(res.status===429){await sleep(2500);continue;} const t=await res.text(); let j; try{j=JSON.parse(t);}catch{j={_raw:t};}
+    return {status:res.status,json:j}; } return {status:429,json:{}};
+}
+
+let acted=0, skipped=0, failed=0; const skips=[];
+for (const r of rows) {
+  // GUARD 1/2 — RS-orphan live
+  const o = await api(`/products/${r.pid}.json`);
+  if (o.status!==200 || !o.json.product) { skips.push({pid:r.pid,reason:`fetch ${o.status}`,title:r.title}); skipped++; continue; }
+  const op = o.json.product;
+  if ((op.status||'').toLowerCase()!=='active') { skips.push({pid:r.pid,reason:`orphan status=${op.status}`,title:r.title}); skipped++; continue; }
+  const oNonSample = (op.variants||[]).filter(v=>!isSample(v));
+  if (oNonSample.length>0) { skips.push({pid:r.pid,reason:`orphan HAS sellable variant (${oNonSample.map(v=>v.option1).join('|')}) — not a pure orphan`,title:r.title}); skipped++; continue; }
+  // GUARD 3/4 — R-twin genuinely buyable
+  const t = await api(`/products/${r.twin_pid}.json`);
+  if (t.status!==200 || !t.json.product) { skips.push({pid:r.pid,reason:`twin fetch ${t.status}`,title:r.title}); skipped++; continue; }
+  const tp = t.json.product;
+  if ((tp.status||'').toLowerCase()!=='active') { skips.push({pid:r.pid,reason:`twin status=${tp.status}`,title:r.title}); skipped++; continue; }
+  if ((tp.variants||[]).filter(v=>!isSample(v)).length===0) { skips.push({pid:r.pid,reason:`twin NOT buyable (no sellable variant)`,title:r.title}); skipped++; continue; }
+  if (digits(op.variants?.[0]?.sku) && digits(r.rs_sku)!==digits(r.twin_sku)) { skips.push({pid:r.pid,reason:`digit mismatch rs=${r.rs_sku} twin=${r.twin_sku}`,title:r.title}); skipped++; continue; }
+
+  if (!LIVE) { console.log(`DRY archive ${r.pid} "${op.title}" (dup of buyable ${r.twin_sku} ${r.twin_pid}) -> ${STATUS}`); acted++; continue; }
+
+  // reversibility FIRST
+  appendFileSync(PRESTATE, JSON.stringify({ ts:new Date().toISOString(), pid:r.pid, title:op.title, prior_status:op.status, twin_pid:r.twin_pid, twin_sku:r.twin_sku })+'\n');
+  const up = await api(`/products/${r.pid}.json`, { method:'PUT', body: JSON.stringify({ product: { id: Number(r.pid), status: STATUS } }) });
+  if (up.status!==200) { skips.push({pid:r.pid,reason:`archive ${up.status}`,detail:JSON.stringify(up.json).slice(0,160),title:op.title}); failed++; continue; }
+  const undo = `curl -s -X PUT "${REST}/products/${r.pid}.json" -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN" -H 'Content-Type: application/json' -d '{"product":{"id":${r.pid},"status":"${op.status}"}}'`;
+  appendFileSync(LEDGER, JSON.stringify({ ts:new Date().toISOString(), agent:'run-now-rebelwalls', ticket:'TK-10978',
+    action:`archive stale RS-orphan ${r.pid} "${op.title}" (${r.rs_sku}) -> ${STATUS}; dup of ACTIVE+buyable twin ${r.twin_sku} ${r.twin_pid}`,
+    product_id:r.pid, prior_status:op.status, blast_radius:1, undo_cmd:undo,
+    verify:`curl -s "${REST}/products/${r.pid}.json" -H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN"|jq '.product.status'` })+'\n');
+  acted++; console.log(`ARCHIVED ${r.pid} "${op.title}" -> ${STATUS} (twin ${r.twin_sku} buyable)`);
+  await sleep(600);
+}
+writeFileSync(SKIPS, JSON.stringify(skips,null,2));
+console.log(`\n${LIVE?'LIVE':'DRY'} — acted ${acted}, skipped ${skipped}, failed ${failed} (of ${rows.length}); status target=${STATUS}`);
diff --git a/tk10978-rebelwalls/rescrape-recon.mjs b/tk10978-rebelwalls/rescrape-recon.mjs
new file mode 100644
index 0000000..8df3d02
--- /dev/null
+++ b/tk10978-rebelwalls/rescrape-recon.mjs
@@ -0,0 +1,76 @@
+#!/usr/bin/env node
+// TK-10978 — Rebel Walls RS-494 corrected re-scrape RECON (READ-ONLY, $0, feed-first).
+// Fetches the vendor's TRUE per-m2 price from JSON-LD, keyed RS<n> -> vendor R<n>.
+// SAFETY NET: only accept a price when JSON-LD sku DIGITS == our RS digits (rejects wrong-slug hits).
+// Writes ONLY local artifacts (data/rescrape-recon.csv + data/rescrape-summary.json).
+// NO writes to Shopify or dw_unified. Gentle: 1 request / 1.5s, single-threaded (skill's stated rate).
+import { writeFileSync, mkdirSync } from 'node:fs';
+import { execSync } from 'node:child_process';
+const HERE = new URL('.', import.meta.url).pathname;
+mkdirSync(`${HERE}data`, { recursive: true });
+
+const SQL = `BEGIN READ ONLY;
+SELECT json_agg(t) FROM (
+  SELECT regexp_replace(shopify_id,'.*/','') AS pid, title, variant_sku AS sample_sku, mfr_sku
+  FROM shopify_products
+  WHERE vendor='Rebel Walls' AND status='ACTIVE'
+    AND NOT coalesce(has_product_variant,false) AND variant_sku ILIKE '%-Sample'
+) t;
+ROLLBACK;`;
+const out = execSync('psql -h /tmp -d dw_unified -tA -v ON_ERROR_STOP=1', { input: SQL, encoding: 'utf8', maxBuffer: 64*1024*1024 });
+const rows = JSON.parse(out.slice(out.indexOf('['), out.lastIndexOf(']')+1) || '[]');
+
+const slugify = t => t.replace(/ \| Rebel Walls\s*$/i,'').toLowerCase()
+  .replace(/['’]/g,'').replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'');
+const digits = s => (String(s||'').match(/\d+/)||[''])[0];
+const sh = s => `"${String(s??'').replace(/"/g,'""')}"`;
+
+const results = [];
+for (let i=0;i<rows.length;i++){
+  const r = rows[i];
+  const slug = slugify(r.title);
+  const rsDig = digits(r.mfr_sku);
+  let http='ERR', ldsku='', price='', avail='', cls='needs_scraper';
+  try {
+    const html = execSync(`curl -s -m 25 -w '\\n<<HTTP:%{http_code}>>' -A 'Mozilla/5.0 (compatible; DW-catalog/1.0)' "https://rebelwalls.com/${slug}"`, {encoding:'utf8', maxBuffer:16*1024*1024});
+    http = (html.match(/<<HTTP:(\d+)>>/)||[])[1] || '000';
+    if (http==='200'){
+      const m = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/);
+      if (m){
+        try {
+          const d = JSON.parse(m[1]);
+          const it = (Array.isArray(d)?d:[d]).find(x=>x&&x['@type']==='Product');
+          if (it){ ldsku=it.sku||''; const off=it.offers||{}; price=off.price??''; avail=String(off.availability||'').split('/').pop(); }
+        } catch {}
+      }
+      if (!ldsku) cls='slug_unresolved';
+      else if (digits(ldsku)!==rsDig) cls='sku_mismatch';        // wrong product -> reject
+      else if (avail && avail!=='InStock') cls='oos_defer';
+      else if (Number(price)>10 && Number(price)<10000) cls='recoverable';
+      else cls='no_price';
+    } else if (http==='410') cls='discontinued_410';
+    else if (http==='404') cls='slug_404';
+    else cls='http_'+http;
+  } catch(e){ cls='fetch_error'; }
+  results.push({ pid:r.pid, title:r.title, rs_sku:r.mfr_sku, r_map:'R'+rsDig, slug, http, ldsku, per_sqm:price, avail, cls });
+  if ((i+1)%25===0) process.stderr.write(`  ...${i+1}/${rows.length}\n`);
+  execSync('sleep 1.5');
+}
+
+const cols=['pid','title','rs_sku','r_map','slug','http','ldsku','per_sqm','avail','cls'];
+writeFileSync(`${HERE}data/rescrape-recon.csv`, [cols.join(',')].concat(results.map(x=>cols.map(c=>sh(x[c])).join(','))).join('\n'));
+const by = results.reduce((a,x)=>{a[x.cls]=(a[x.cls]||0)+1;return a;},{});
+const recov = results.filter(x=>x.cls==='recoverable');
+const prices = recov.map(x=>Number(x.per_sqm));
+const summary = {
+  ticket:'TK-10978', mode:'READ-ONLY recon — NO writes', generated_at:new Date().toISOString(),
+  total:results.length, by_class:by,
+  recoverable:recov.length,
+  per_sqm_min: prices.length?Math.min(...prices):null,
+  per_sqm_max: prices.length?Math.max(...prices):null,
+  per_sqm_distinct: [...new Set(prices)].sort((a,b)=>a-b),
+  defer_discontinued_or_unresolved: results.length - recov.length,
+  note:'recoverable = live 200 + JSON-LD sku digits == RS digits + InStock + price in range. Everything else defers to the scraper or is vendor-discontinued.',
+};
+writeFileSync(`${HERE}data/rescrape-summary.json`, JSON.stringify(summary,null,2));
+console.log(JSON.stringify(summary,null,2));

← 548c7af TK-10978 pre-approval audit: Coordonné 136 dry-run broken (d  ·  back to Dw Unbuyable Recovery Pilot  ·  TK-10978 RebelWalls: focused 39-no-twin re-scrape — 31/39 ve 4101e6d →