[object Object]

← back to Designerwallcoverings

TK-11404: add GATED position-1 sample-reorder fix + rollback (live-verified 2,586 leaks, not run)

509210daf6e87ff2a2a0760dc4a670e28252ec5c · 2026-09-10 12:57:01 -0700 · Steve Abrams

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

Files touched

Diff

commit 509210daf6e87ff2a2a0760dc4a670e28252ec5c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 12:57:01 2026 -0700

    TK-11404: add GATED position-1 sample-reorder fix + rollback (live-verified 2,586 leaks, not run)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/tk11404-fix-position1-sample.mjs | 98 ++++++++++++++++++++++++++++++++
 scripts/tk11404-undo.mjs                 | 37 ++++++++++++
 2 files changed, 135 insertions(+)

diff --git a/scripts/tk11404-fix-position1-sample.mjs b/scripts/tk11404-fix-position1-sample.mjs
new file mode 100644
index 0000000..2253ce3
--- /dev/null
+++ b/scripts/tk11404-fix-position1-sample.mjs
@@ -0,0 +1,98 @@
+#!/usr/bin/env node
+// TK-11404 — fix the SAMPLE-at-position-1 leak fleet-wide.
+// A bare /products/<handle> URL (no ?variant=) renders the position-1 variant's price.
+// For 2594+ ACTIVE products the position-1 variant is the SAMPLE, so the landing page
+// shows the sample memo price instead of the real sellable price (the Zoffany $422→$4.25 class).
+//
+// Precedent: TK-10895 (scripts/tk10895-fix-position1.mjs). Correct API type is
+// ProductVariantPositionInput (NOT VariantPositionInput — that type does not exist and a
+// `?.` chain over its null data silently swallowed every reorder in the original go-live).
+//
+// CRITICAL DIFFERENCE FROM TK-10895: sellable qualification is by SAMPLE IDENTITY, not a
+// >$50 price threshold. wider-sample-bug.json proved samples can carry a HIGH price
+// (e.g. Thibaut DWTT-70740-SAMPLE @ $161.27), so a price threshold would mis-classify
+// those samples as "already OK". A variant is the SAMPLE iff its SKU ends -SAMPLE (case-insensitive)
+// or its title is exactly "Sample". Promote the FIRST non-sample variant to position 1.
+//
+// DRY-RUN by default; --apply writes. Reversible: every reorder logs before/after + undo to the ledger.
+// GATED: blast radius 2594+ >> 500 and customer-facing → requires Steve approval before --apply.
+
+import fs from 'fs';
+const SHOP='designer-laboratory-sandbox.myshopify.com', VER='2024-10';
+const TOK=fs.readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8')
+  .match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m)[1].trim().replace(/^["']|["']$/g,'');
+const APPLY=process.argv.includes('--apply');
+const LIMIT=(()=>{const i=process.argv.indexOf('--limit');return i>-1?+process.argv[i+1]:0;})();
+// pid list produced by the read-only audit (scripts/tk11404-audit output):
+const PIDS=(()=>{const i=process.argv.indexOf('--pids');return i>-1?process.argv[i+1]
+  :process.env.HOME+'/.claude/yolo-queue/evidence/TK-11404/leak-pids.txt';})();
+const MAP=process.env.HOME+'/.claude/yolo-queue/executed-reversible/TK-11404-position-fix.jsonl';
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+const isSample=v=>/-SAMPLE$/i.test(v.sku||'')||/^\s*sample\s*$/i.test(v.title||'');
+
+async function api(path,opts={},tries=6){
+  for(let a=1;a<=tries;a++){
+    try{const r=await fetch(`https://${SHOP}/admin/api/${VER}${path}`,{...opts,
+      headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json',...(opts.headers||{})}});
+      if(r.status===429||r.status>=500){await sleep(1500*a);continue;}
+      const t=await r.text(); try{return{status:r.status,body:JSON.parse(t)};}catch{await sleep(1200*a);}
+    }catch{await sleep(1200*a);} }
+  return {status:0,body:null};
+}
+// returns FULL envelope so query-level errors cannot be swallowed (the TK-10895 lesson).
+async function gql(query,variables,tries=6){
+  for(let a=1;a<=tries;a++){
+    try{const r=await fetch(`https://${SHOP}/admin/api/${VER}/graphql.json`,{method:'POST',
+      headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json'},
+      body:JSON.stringify({query,variables})});
+      if(r.status===429||r.status>=500){await sleep(1500*a);continue;}
+      return await r.json();
+    }catch{await sleep(1200*a);} }
+  return null;
+}
+const REORDER=`mutation r($productId: ID!, $positions: [ProductVariantPositionInput!]!) {
+  productVariantsBulkReorder(productId: $productId, positions: $positions) { userErrors { field message } } }`;
+
+const pids=fs.readFileSync(PIDS,'utf8').split('\n').map(s=>s.trim()).filter(Boolean);
+const work=LIMIT?pids.slice(0,LIMIT):pids;
+console.log(`${APPLY?'*** APPLY — LIVE CUSTOMER-FACING WRITES ***':'DRY-RUN'} — ${work.length} products\n`);
+let fixed=0,ok=0,skip=0,hard=0;
+
+for(const [i,pid] of work.entries()){
+  const tag=`[${i+1}/${work.length}] pid=${pid}`;
+  const g=await api(`/products/${pid}.json`);
+  if(g.status!==200||!g.body?.product){console.log(`${tag} FAIL GET ${g.status}`);hard++;continue;}
+  const p=g.body.product, vs=[...p.variants].sort((a,b)=>a.position-b.position);
+  if(vs.length<2){console.log(`${tag} skip — ${vs.length} variant`);skip++;continue;}
+  const sellable=vs.filter(v=>!isSample(v));
+  if(!sellable.length){console.log(`${tag} SKIP — no non-sample variant to promote`);skip++;continue;}
+  if(!isSample(vs[0])){console.log(`${tag} already OK — pos1 "${vs[0].title}" sku=${vs[0].sku}`);ok++;continue;}
+  const target=sellable[0];
+  if(!APPLY){console.log(`${tag} would promote "${target.title}" $${target.price} sku=${target.sku} to pos1 (currently SAMPLE "${vs[0].title}" $${vs[0].price})`);fixed++;continue;}
+
+  const ordered=[target.id,...vs.filter(v=>v.id!==target.id).map(v=>v.id)];
+  const res=await gql(REORDER,{productId:`gid://shopify/Product/${pid}`,
+    positions:ordered.map((id,idx)=>({id:`gid://shopify/ProductVariant/${id}`,position:idx+1}))});
+  if(!res){console.log(`${tag} HARD FAIL — no response`);hard++;continue;}
+  if(res.errors){console.log(`${tag} HARD FAIL — GraphQL query error: ${JSON.stringify(res.errors).slice(0,200)}`);hard++;continue;}
+  const ue=res.data?.productVariantsBulkReorder?.userErrors;
+  if(ue===undefined){console.log(`${tag} HARD FAIL — no payload: ${JSON.stringify(res).slice(0,200)}`);hard++;continue;}
+  if(ue.length){console.log(`${tag} HARD FAIL — userErrors ${JSON.stringify(ue)}`);hard++;continue;}
+
+  // VERIFY AFTER — do not trust the mutation's own success report
+  await sleep(400);
+  const v2=await api(`/products/${pid}.json`);
+  const nv=[...(v2.body?.product?.variants||[])].sort((a,b)=>a.position-b.position);
+  if(!nv.length||isSample(nv[0])){
+    console.log(`${tag} HARD FAIL — after reorder pos1 is still a SAMPLE "${nv[0]?.title}"`);hard++;continue;}
+  fs.appendFileSync(MAP,JSON.stringify({ts:new Date().toISOString(),ticket:'TK-11404',
+    action:'position1-sample-fix',product_id:pid,handle:p.handle,
+    before_pos1:{id:vs[0].id,price:vs[0].price,title:vs[0].title,sku:vs[0].sku},
+    after_pos1:{id:nv[0].id,price:nv[0].price,title:nv[0].title,sku:nv[0].sku},
+    original_order:vs.map(v=>v.id),
+    undo:`productVariantsBulkReorder productId=gid://shopify/Product/${pid} back to original_order`})+'\n');
+  fixed++; console.log(`${tag} ✓ pos1 now "${nv[0].title}" $${nv[0].price}  (${p.handle})`);
+  await sleep(350);
+}
+console.log(`\nDONE  fixed=${fixed} alreadyOK=${ok} skipped=${skip} HARDFAIL=${hard}`);
+if(hard) { console.log('*** HARD FAILURES PRESENT — do not report success ***'); process.exitCode=1; }
diff --git a/scripts/tk11404-undo.mjs b/scripts/tk11404-undo.mjs
new file mode 100644
index 0000000..3b0cf7f
--- /dev/null
+++ b/scripts/tk11404-undo.mjs
@@ -0,0 +1,37 @@
+#!/usr/bin/env node
+// TK-11404 — ROLLBACK. Reverses each applied position-1 fix using the reversible ledger
+// (~/.claude/yolo-queue/executed-reversible/TK-11404-position-fix.jsonl), which records
+// original_order (the exact variant id order before the fix). Restores that order.
+// DRY-RUN by default; --apply writes. Verifies AFTER that pos1 is the original sample again.
+import fs from 'fs';
+const SHOP='designer-laboratory-sandbox.myshopify.com', VER='2024-10';
+const TOK=fs.readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8')
+  .match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m)[1].trim().replace(/^["']|["']$/g,'');
+const APPLY=process.argv.includes('--apply');
+const LEDGER=process.env.HOME+'/.claude/yolo-queue/executed-reversible/TK-11404-position-fix.jsonl';
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+async function gql(query,variables,tries=6){
+  for(let a=1;a<=tries;a++){ try{const r=await fetch(`https://${SHOP}/admin/api/${VER}/graphql.json`,{method:'POST',
+    headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json'},body:JSON.stringify({query,variables})});
+    if(r.status===429||r.status>=500){await sleep(1500*a);continue;} return await r.json();}catch{await sleep(1200*a);} }
+  return null;
+}
+const REORDER=`mutation r($productId: ID!, $positions: [ProductVariantPositionInput!]!) {
+  productVariantsBulkReorder(productId: $productId, positions: $positions) { userErrors { field message } } }`;
+if(!fs.existsSync(LEDGER)){console.log('no ledger — nothing applied yet:',LEDGER);process.exit(0);}
+const rows=fs.readFileSync(LEDGER,'utf8').split('\n').filter(Boolean).map(l=>JSON.parse(l));
+console.log(`${APPLY?'*** APPLY — LIVE ROLLBACK ***':'DRY-RUN'} — ${rows.length} ledger rows\n`);
+let done=0,hard=0;
+for(const [i,row] of rows.entries()){
+  const pid=row.product_id, order=row.original_order;
+  const tag=`[${i+1}/${rows.length}] pid=${pid}`;
+  if(!order?.length){console.log(`${tag} SKIP — no original_order in ledger row`);continue;}
+  if(!APPLY){console.log(`${tag} would restore order [${order.join(',')}] (${row.handle})`);done++;continue;}
+  const res=await gql(REORDER,{productId:`gid://shopify/Product/${pid}`,
+    positions:order.map((id,idx)=>({id:`gid://shopify/ProductVariant/${id}`,position:idx+1}))});
+  const ue=res?.data?.productVariantsBulkReorder?.userErrors;
+  if(!res||res.errors||ue===undefined||ue.length){console.log(`${tag} HARD FAIL ${JSON.stringify(res?.errors||ue||res).slice(0,180)}`);hard++;continue;}
+  done++; console.log(`${tag} ✓ restored (${row.handle})`); await sleep(350);
+}
+console.log(`\nROLLBACK DONE  restored=${done} HARDFAIL=${hard}`);
+if(hard) process.exitCode=1;

← 93a10f1 auto-data-snapshot: 2026-09-10T12:25:41 (6 data files) — pac  ·  back to Designerwallcoverings  ·  TK-11357: track zero-price investigation tooling (process-sn 9f80218 →