← 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
A scripts/tk11404-fix-position1-sample.mjsA scripts/tk11404-undo.mjs
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 →