[object Object]

← back to Designerwallcoverings

TK-11644: description_tag mass-overwrite — sweep, proof, and gated executor

1d0b9dd679110746bcc57719fd70315b3939cbad · 2026-09-13 16:34:03 -0700 · Steve Abrams

The 2026-04-05T23:20-23:26Z job that corrupted title_tag (TK-11564) also overwrote
33,198 ACTIVE meta descriptions with generic store boilerplate. Different corruption
shape than title_tag (genericization, not wrong-identity), which is why identity-based
checks read it as clean.

Proof: metafield.createdAt partition -> created-by-job=0, overwrote-pre-existing=33,198
(100%), createdAt spanning 2020-04..2026-02. That spread also proves the test is valid
rather than void: a delete+recreate job would have stamped every createdAt in-window.
Same-vendor control: Coordonne rows the job missed are 97.5% product-specific; the 1,019
it touched are 0.2%.

Fallback gate measured BEFORE proposing the fix: 99.9% of affected rows have a usable
body_html; pattern-specificity goes 3.8% -> 30.2%. The 41 unsafe rows are excluded.

  tk11644-desctag-sweep.mjs        catalog-wide window sweep (read-only)
  tk11644-createdat-partition.mjs  backfill-vs-overwrite proof (read-only)
  tk11644-fallback-quality.mjs     body_html fallback safety gate (read-only)
  tk11644-desctag-fix.mjs          phased executor, DRY-RUN BY DEFAULT, race-safe

No Shopify write fired. Remediation drafted to pending-approval for Steve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Ch7HhNSTbzTTBS4rKmk5j

Files touched

Diff

commit 1d0b9dd679110746bcc57719fd70315b3939cbad
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 13 16:34:03 2026 -0700

    TK-11644: description_tag mass-overwrite — sweep, proof, and gated executor
    
    The 2026-04-05T23:20-23:26Z job that corrupted title_tag (TK-11564) also overwrote
    33,198 ACTIVE meta descriptions with generic store boilerplate. Different corruption
    shape than title_tag (genericization, not wrong-identity), which is why identity-based
    checks read it as clean.
    
    Proof: metafield.createdAt partition -> created-by-job=0, overwrote-pre-existing=33,198
    (100%), createdAt spanning 2020-04..2026-02. That spread also proves the test is valid
    rather than void: a delete+recreate job would have stamped every createdAt in-window.
    Same-vendor control: Coordonne rows the job missed are 97.5% product-specific; the 1,019
    it touched are 0.2%.
    
    Fallback gate measured BEFORE proposing the fix: 99.9% of affected rows have a usable
    body_html; pattern-specificity goes 3.8% -> 30.2%. The 41 unsafe rows are excluded.
    
      tk11644-desctag-sweep.mjs        catalog-wide window sweep (read-only)
      tk11644-createdat-partition.mjs  backfill-vs-overwrite proof (read-only)
      tk11644-fallback-quality.mjs     body_html fallback safety gate (read-only)
      tk11644-desctag-fix.mjs          phased executor, DRY-RUN BY DEFAULT, race-safe
    
    No Shopify write fired. Remediation drafted to pending-approval for Steve.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_019Ch7HhNSTbzTTBS4rKmk5j
---
 scripts/tk11644-createdat-partition.mjs |  53 +++++++++++++++++
 scripts/tk11644-desctag-fix.mjs         |  77 ++++++++++++++++++++++++
 scripts/tk11644-desctag-sweep.mjs       | 102 ++++++++++++++++++++++++++++++++
 scripts/tk11644-fallback-quality.mjs    |  46 ++++++++++++++
 4 files changed, 278 insertions(+)

diff --git a/scripts/tk11644-createdat-partition.mjs b/scripts/tk11644-createdat-partition.mjs
new file mode 100644
index 0000000..caee599
--- /dev/null
+++ b/scripts/tk11644-createdat-partition.mjs
@@ -0,0 +1,53 @@
+// TK-11644 READ-ONLY decisive partition: did the 2026-04-05 job CREATE each description_tag
+// (additive backfill — nothing destroyed) or OVERWRITE a pre-existing one (real destruction)?
+// metafield.createdAt is preserved by an upsert (metafieldsSet) but NOT by delete+recreate,
+// so we also report the createdAt distribution to prove the test isn't void.
+// No writes. $0.
+import { gql } from './lib/shopify.mjs';
+import fs from 'node:fs';
+const Q=`{ products { edges { node { id handle vendor title status
+  metafield(namespace:"global",key:"description_tag"){ value createdAt updatedAt } } } } }`;
+const RAW='assets/TK-11644-createdat-raw.jsonl';
+let jsonl;
+if(fs.existsSync(RAW)&&process.env.USE_CACHE==='1'){ jsonl=fs.readFileSync(RAW,'utf8'); }
+else{
+  const m=await gql(`mutation { bulkOperationRunQuery(query: ${JSON.stringify(Q)}) { bulkOperation{id status} userErrors{message} } }`);
+  if(m.bulkOperationRunQuery.userErrors.length){console.error(m.bulkOperationRunQuery.userErrors);process.exit(1);}
+  for(;;){ const d=await gql(`{ currentBulkOperation(type:QUERY){ status objectCount url } }`);
+    const b=d.currentBulkOperation; process.stderr.write(`\r  bulk ${b.status} objs=${b.objectCount}   `);
+    if(b.status==='COMPLETED'){process.stderr.write('\n'); jsonl=await (await fetch(b.url)).text(); break;}
+    if(['FAILED','CANCELED'].includes(b.status)){console.error(b);process.exit(1);}
+    await new Promise(r=>setTimeout(r,3000)); }
+  fs.writeFileSync(RAW,jsonl);
+}
+const LO=new Date('2026-04-05T23:20:00Z').getTime(), HI=new Date('2026-04-05T23:27:00Z').getTime();
+const BOIL="Our professional sales staff at Designer Wallcoverings";
+let created=0, overwrote=0, createdAfter=0; const cHist={}; const ow=[];
+const spec=(t,d)=>{const p=t.split(' | ')[0].split(' - ')[0].trim().toLowerCase(); return p.length>=6 && d.toLowerCase().includes(p);};
+let owBoil=0, crBoil=0;
+for(const l of jsonl.split('\n')){ if(!l)continue; const n=JSON.parse(l);
+  if(!n.id?.includes('/Product/')||n.status!=='ACTIVE') continue;
+  const mf=n.metafield; if(!mf?.updatedAt) continue;
+  const u=new Date(mf.updatedAt).getTime(); if(u<LO||u>HI) continue;   // in-window ACTIVE only
+  const c=new Date(mf.createdAt).getTime();
+  cHist[mf.createdAt.slice(0,7)]=(cHist[mf.createdAt.slice(0,7)]||0)+1;
+  const v=(mf.value||'').trim();
+  if(c>=LO&&c<=HI){ created++; if(v.startsWith(BOIL))crBoil++; }
+  else if(c<LO){ overwrote++; if(v.startsWith(BOIL))owBoil++;
+    ow.push({handle:n.handle,vendor:(n.vendor||'').trim(),title:n.title,createdAt:mf.createdAt,updatedAt:mf.updatedAt,value:v}); }
+  else createdAfter++;
+}
+const tot=created+overwrote+createdAfter;
+console.log(JSON.stringify({
+  in_window_ACTIVE: tot,
+  CREATED_by_job_additive_backfill: created,
+  OVERWROTE_preexisting_value: overwrote,
+  created_after_window_anomaly: createdAfter,
+  pct_overwrite: +(100*overwrote/tot).toFixed(1),
+  boilerplate_among_OVERWRITTEN: owBoil,
+  boilerplate_among_CREATED: crBoil,
+  createdAt_month_histogram: Object.entries(cHist).sort(),
+  test_validity_note: created===tot ? 'VOID - all createdAt in-window, job likely delete+recreate' : 'VALID - createdAt varies, upsert preserved provenance',
+},null,2));
+fs.writeFileSync('assets/TK-11644-overwritten.json',JSON.stringify(ow,null,2));
+console.log('overwritten rows ->','assets/TK-11644-overwritten.json','count',ow.length);
diff --git a/scripts/tk11644-desctag-fix.mjs b/scripts/tk11644-desctag-fix.mjs
new file mode 100644
index 0000000..fcb84d3
--- /dev/null
+++ b/scripts/tk11644-desctag-fix.mjs
@@ -0,0 +1,77 @@
+// TK-11644 phased description_tag corruption FIX.
+// The 2026-04-05T23:20-23:26Z bulk job OVERWROTE 33,198 pre-existing ACTIVE meta descriptions
+// (proven: metafield.createdAt predates the job on 100% of them), replacing 19,053 with one
+// identical store-generic blurb. Fix = DELETE global.description_tag so Shopify falls back to
+// the product's own body_html excerpt (measured usable on 99.9%, ~8x more product-specific).
+//
+// DRY-RUN BY DEFAULT. --apply is the GATED live customer-facing write (Steve go only).
+// Race-safe: before each delete it LIVE-verifies (a) the metafield still holds the exact
+// corrupted value from the sweep — so a manual fix made since is never clobbered, and
+// (b) the body_html fallback is still usable — so we never expose an empty description.
+import { gql } from './lib/shopify.mjs';
+import fs from 'node:fs';
+
+const args=process.argv.slice(2);
+const TIER=(args.find(a=>a.startsWith('--tier='))||'--tier=A').split('=')[1];  // A|B|AB
+const APPLY=args.includes('--apply');
+const LIMIT=parseInt((args.find(a=>a.startsWith('--limit='))||'--limit=0').split('=')[1],10);
+
+const load=t=>JSON.parse(fs.readFileSync(`assets/TK-11644-tier-${t}.json`,'utf8')).map(r=>({...r,tier:t}));
+let set = TIER==='AB' ? [...load('A'),...load('B')] : load(TIER);
+if(LIMIT>0) set=set.slice(0,LIMIT);
+console.error(`Tier=${TIER} candidates=${set.length} mode=${APPLY?'APPLY (LIVE WRITE)':'DRY-RUN'}`);
+
+// resolve gids + live state in batches of 100 (handle -> product)
+const verified=[], changed=[], badFallback=[], missing=[];
+for(let i=0;i<set.length;i+=100){
+  const chunk=set.slice(i,i+100);
+  const parts=chunk.map((r,j)=>`p${j}: productByHandle(handle:${JSON.stringify(r.handle)}){ id title status descriptionHtml metafield(namespace:"global",key:"description_tag"){ id value } }`);
+  let d; try{ d=await gql(`{ ${parts.join('\n')} }`); }catch(e){ console.error('batch error',e.message); continue; }
+  chunk.forEach((r,j)=>{
+    const n=d[`p${j}`];
+    if(!n){ missing.push(r.handle); return; }
+    const cur=(n.metafield?.value||'').trim();
+    if(!cur || cur!==r.value.trim()){ changed.push({handle:r.handle,now:cur.slice(0,60)||'(deleted)'}); return; }
+    const body=(n.descriptionHtml||'').replace(/<[^>]+>/g,' ').replace(/&[a-z#0-9]+;/gi,' ').replace(/\s+/g,' ').trim();
+    if(body.length<60){ badFallback.push({handle:r.handle,body_len:body.length}); return; }
+    verified.push({gid:n.id,pid:n.id.split('/').pop(),handle:r.handle,vendor:r.vendor,tier:r.tier,
+      old_value:cur,fallback:body.slice(0,160)});
+  });
+  process.stderr.write(`\r  verified ${verified.length}  changed-since-sweep ${changed.length}  bad-fallback ${badFallback.length}  missing ${missing.length}   `);
+}
+process.stderr.write('\n');
+
+const mpath=`assets/TK-11644-fix-tier${TIER}-manifest.json`;
+fs.writeFileSync(mpath,JSON.stringify({tier:TIER,generated_at:new Date().toISOString(),
+  will_delete:verified.length,skipped_changed:changed.length,skipped_bad_fallback:badFallback.length,
+  skipped_missing:missing.length,rows:verified,changed,badFallback,missing},null,1));
+
+console.log(`\nWILL DELETE description_tag on: ${verified.length}`);
+console.log(`SKIP (changed since sweep):     ${changed.length}   (never clobber a manual fix)`);
+console.log(`SKIP (body fallback <60 chars): ${badFallback.length}   (would expose an empty description)`);
+console.log(`SKIP (product not found):       ${missing.length}`);
+console.log('\nSample before -> after (what Google will show instead):');
+for(const v of verified.slice(0,5)) console.log(`  [${v.tier}] ${v.handle}\n      was: ${JSON.stringify(v.old_value.slice(0,78))}\n      ->:  ${JSON.stringify(v.fallback.slice(0,78))}`);
+console.log('\nmanifest + rollback map ->',mpath);
+if(!APPLY){ console.log('\nDRY-RUN. No write fired. Re-run with --apply (GATED — Steve go only).'); process.exit(0); }
+
+// ---- LIVE WRITE PATH (gated) ----
+console.error('APPLYING: deleting description_tag on',verified.length,'products ...');
+const ledger=process.env.HOME+'/.claude/yolo-queue/executed-reversible/ledger.jsonl';
+let ok=0,err=0;
+for(let i=0;i<verified.length;i+=25){
+  const chunk=verified.slice(i,i+25);
+  const mf=chunk.map(v=>({ownerId:v.gid,namespace:'global',key:'description_tag'}));
+  const d=await gql(`mutation($mf:[MetafieldIdentifierInput!]!){ metafieldsDelete(metafields:$mf){ deletedMetafields{ ownerId } userErrors{ field message } } }`,{mf});
+  const ue=d.metafieldsDelete.userErrors;
+  if(ue.length){ console.error('userErrors',ue); err+=chunk.length; continue; }
+  ok+=d.metafieldsDelete.deletedMetafields.length;
+  for(const v of chunk) fs.appendFileSync(ledger,JSON.stringify({ts:new Date().toISOString(),
+    agent:'claude-run-11644',ticket:'TK-11644',action:`delete global.description_tag (tier ${v.tier})`,
+    pid:v.pid,handle:v.handle,blast_radius:1,
+    undo_cmd:`re-set global.description_tag=${JSON.stringify(v.old_value)} on product ${v.pid}`,
+    old_value:v.old_value,verify:`GET product ${v.pid} description_tag == null`})+'\n');
+  process.stderr.write(`\r  deleted ${ok}/${verified.length}  err ${err}   `);
+}
+process.stderr.write('\n');
+console.log(`APPLY done. deleted=${ok} err=${err}. Rollback map ${mpath} + executed-reversible ledger.`);
diff --git a/scripts/tk11644-desctag-sweep.mjs b/scripts/tk11644-desctag-sweep.mjs
new file mode 100644
index 0000000..8fdd718
--- /dev/null
+++ b/scripts/tk11644-desctag-sweep.mjs
@@ -0,0 +1,102 @@
+// TK-11644 READ-ONLY blast-radius sweep: find every product whose global.description_tag
+// (SEO meta description) metafield was written by the SAME 2026-04-05 16:20-16:26 PT bad
+// bulk SEO job that corrupted global.title_tag (TK-11558 -> TK-11564, now remediated).
+//
+// AUTHORITATIVE SIGNAL = metafield updatedAt inside the job window. This is the exhaustive
+// method TK-11564's worker-w01 flagged as the missing backstop: it catches ANY value the job
+// wrote regardless of phrasing, instead of guessing text signatures first.
+// No writes. $0 read-only. Caches the raw bulk JSONL so re-analysis needs no re-export.
+import { gql } from './lib/shopify.mjs';
+import fs from 'node:fs';
+
+const WIN_LO = new Date('2026-04-05T16:20:00-07:00').getTime();
+const WIN_HI = new Date('2026-04-05T16:27:00-07:00').getTime(); // padded 1min past last-seen 16:25:32
+const OUT = 'assets/TK-11644-desctag-sweep';
+const RAW = `${OUT}-raw.jsonl`;
+
+const BULK_QUERY = `{
+  products {
+    edges { node {
+      id handle vendor title status
+      metafield(namespace:"global", key:"description_tag") { value updatedAt }
+    } }
+  }
+}`;
+
+async function startBulk() {
+  const q = `mutation { bulkOperationRunQuery(query: ${JSON.stringify(BULK_QUERY)}) {
+    bulkOperation { id status } userErrors { field message } } }`;
+  const d = await gql(q);
+  const ue = d.bulkOperationRunQuery.userErrors;
+  if (ue.length) { console.error('userErrors', ue); process.exit(1); }
+  return d.bulkOperationRunQuery.bulkOperation.id;
+}
+async function poll() {
+  const q = `{ currentBulkOperation(type: QUERY) { id status errorCode objectCount url } }`;
+  for (;;) {
+    const d = await gql(q);
+    const b = d.currentBulkOperation;
+    process.stderr.write(`\r  bulk ${b.status} objs=${b.objectCount}   `);
+    if (b.status === 'COMPLETED') { process.stderr.write('\n'); return b; }
+    if (['FAILED','CANCELED'].includes(b.status)) { console.error('\nbulk', b); process.exit(1); }
+    await new Promise(r => setTimeout(r, 3000));
+  }
+}
+
+let jsonl;
+if (process.env.USE_CACHE === '1' && fs.existsSync(RAW)) {
+  console.error('using cached raw export', RAW);
+  jsonl = fs.readFileSync(RAW, 'utf8');
+} else {
+  console.error('starting bulk export of all products + global.description_tag ...');
+  await startBulk();
+  const done = await poll();
+  if (!done.url) { console.log('No results url (0 objects).'); process.exit(0); }
+  console.error('downloading', done.objectCount, 'objects ...');
+  jsonl = await (await fetch(done.url)).text();
+  fs.mkdirSync('assets', { recursive: true });
+  fs.writeFileSync(RAW, jsonl);
+  console.error('cached raw ->', RAW);
+}
+
+const lines = jsonl.split('\n').filter(Boolean).map(JSON.parse);
+
+let totalProducts = 0, withTag = 0, inWindow = 0;
+const hits = [];
+const updHist = {};
+for (const n of lines) {
+  if (!n.id || !n.id.includes('/Product/')) continue;
+  totalProducts++;
+  const mf = n.metafield;
+  if (!mf || !mf.updatedAt) continue;
+  withTag++;
+  const day = mf.updatedAt.slice(0,10);
+  updHist[day] = (updHist[day]||0)+1;
+  const t = new Date(mf.updatedAt).getTime();
+  if (t < WIN_LO || t > WIN_HI) continue;
+  inWindow++;
+  hits.push({
+    handle: n.handle, pid: n.id.split('/').pop(), vendor: n.vendor || '',
+    status: n.status, real_title: (n.title||'').trim(),
+    desc_tag: (mf.value||'').trim(), desc_updated_at: mf.updatedAt,
+  });
+}
+
+hits.sort((a,b) => new Date(a.desc_updated_at) - new Date(b.desc_updated_at));
+fs.writeFileSync(`${OUT}-window.json`, JSON.stringify(hits, null, 2));
+
+const byVendor = {}, byStatus = {};
+for (const h of hits) {
+  byVendor[h.vendor||'(none)'] = (byVendor[h.vendor||'(none)']||0)+1;
+  byStatus[h.status] = (byStatus[h.status]||0)+1;
+}
+console.log(JSON.stringify({
+  total_products: totalProducts,
+  with_description_tag: withTag,
+  in_bad_job_window: inWindow,
+  window: ['2026-04-05T16:20:00-07:00','2026-04-05T16:27:00-07:00'],
+  by_status: byStatus,
+  top_vendors: Object.entries(byVendor).sort((a,b)=>b[1]-a[1]).slice(0,25),
+  updatedAt_day_histogram_top: Object.entries(updHist).sort((a,b)=>b[1]-a[1]).slice(0,12),
+  out: `${OUT}-window.json`,
+}, null, 2));
diff --git a/scripts/tk11644-fallback-quality.mjs b/scripts/tk11644-fallback-quality.mjs
new file mode 100644
index 0000000..9cf459a
--- /dev/null
+++ b/scripts/tk11644-fallback-quality.mjs
@@ -0,0 +1,46 @@
+// TK-11644 READ-ONLY fallback-quality canary for the proposed remediation.
+// The fix for a genericized description_tag is to DELETE it so Shopify falls back to a
+// body_html excerpt. That is only a win if body_html is PRESENT and PRODUCT-SPECIFIC.
+// This measures exactly that across the affected ACTIVE population before anything is proposed.
+// No writes. $0.
+import { gql } from './lib/shopify.mjs';
+import fs from 'node:fs';
+const Q=`{ products { edges { node { id handle status descriptionHtml } } } }`;
+const RAW='assets/TK-11644-body-raw.jsonl';
+let jsonl;
+if (fs.existsSync(RAW) && process.env.USE_CACHE==='1') { jsonl=fs.readFileSync(RAW,'utf8'); }
+else {
+  const m=await gql(`mutation { bulkOperationRunQuery(query: ${JSON.stringify(Q)}) { bulkOperation{id status} userErrors{message} } }`);
+  if(m.bulkOperationRunQuery.userErrors.length){console.error(m.bulkOperationRunQuery.userErrors);process.exit(1);}
+  for(;;){ const d=await gql(`{ currentBulkOperation(type:QUERY){ status objectCount url errorCode } }`);
+    const b=d.currentBulkOperation; process.stderr.write(`\r  bulk ${b.status} objs=${b.objectCount}   `);
+    if(b.status==='COMPLETED'){ process.stderr.write('\n'); jsonl=await (await fetch(b.url)).text(); break; }
+    if(['FAILED','CANCELED'].includes(b.status)){ console.error(b); process.exit(1); }
+    await new Promise(r=>setTimeout(r,3000)); }
+  fs.writeFileSync(RAW,jsonl);
+}
+const body=new Map();
+for(const l of jsonl.split('\n')){ if(!l)continue; const n=JSON.parse(l); if(!n.id?.includes('/Product/'))continue;
+  body.set(n.handle,(n.descriptionHtml||'').replace(/<[^>]+>/g,' ').replace(/&[a-z#0-9]+;/gi,' ').replace(/\s+/g,' ').trim()); }
+const win=JSON.parse(fs.readFileSync('assets/TK-11644-desctag-sweep-window.json','utf8')).filter(r=>r.status==='ACTIVE');
+let empty=0,tooShort=0,good=0,specific=0,missing=0;
+const bad=[];
+for(const r of win){
+  if(!body.has(r.handle)){missing++;continue;}
+  const b=body.get(r.handle);
+  if(!b){empty++; bad.push({handle:r.handle,vendor:r.vendor,why:'empty body'}); continue;}
+  if(b.length<60){tooShort++; bad.push({handle:r.handle,vendor:r.vendor,why:'body <60 chars',body:b}); continue;}
+  good++;
+  const p=r.real_title.split(' | ')[0].split(' - ')[0].trim().toLowerCase();
+  if(p.length>=6 && b.toLowerCase().includes(p)) specific++;
+}
+const n=win.length;
+console.log(JSON.stringify({
+  affected_active: n, body_missing_from_export: missing,
+  body_EMPTY: empty, body_TOO_SHORT_lt60: tooShort,
+  body_USABLE: good, pct_usable: +(100*good/n).toFixed(1),
+  body_NAMES_OWN_PATTERN: specific, pct_specific: +(100*specific/n).toFixed(1),
+  verdict_note: 'delete-to-fallback is only safe for rows with a usable body',
+},null,2));
+fs.writeFileSync('assets/TK-11644-fallback-unsafe.json',JSON.stringify(bad,null,2));
+console.log('unsafe rows ->','assets/TK-11644-fallback-unsafe.json','count',bad.length);

← 7b7ff51 auto-data-snapshot: 2026-09-13T16:30:12 (10 data files) — .g  ·  back to Designerwallcoverings  ·  TK-11574: fix classifier substring bug + make the job window 7aa2e4e →