[object Object]

← back to Designerwallcoverings

TK-11404: productSet position-1 reorder prototype + FINDINGS (mechanism proven ineffective)

53200691715db9e89bab6a8413d1e89b9d58ec3c · 2026-09-10 14:09:03 -0700 · Steve Abrams

--test run (synthetic DRAFT, self-deleted) proves productSet option-value
reorder is accepted (userErrors []) but does NOT move REST variant position;
settle-probe confirms same at +5s for both productSet and
productVariantsBulkReorder. No non-destructive API re-seats the sellable
variant at REST position 1 for the [Sample, Roll] shape. Combined with the
prior live-storefront premise-disproven finding, the gated --pid apply is
recommended BLOCK. Prototype --pid/--rollback left gated, not run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018en9DwRg3hJwbpy5dL7xGT

Files touched

Diff

commit 53200691715db9e89bab6a8413d1e89b9d58ec3c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 14:09:03 2026 -0700

    TK-11404: productSet position-1 reorder prototype + FINDINGS (mechanism proven ineffective)
    
    --test run (synthetic DRAFT, self-deleted) proves productSet option-value
    reorder is accepted (userErrors []) but does NOT move REST variant position;
    settle-probe confirms same at +5s for both productSet and
    productVariantsBulkReorder. No non-destructive API re-seats the sellable
    variant at REST position 1 for the [Sample, Roll] shape. Combined with the
    prior live-storefront premise-disproven finding, the gated --pid apply is
    recommended BLOCK. Prototype --pid/--rollback left gated, not run.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_018en9DwRg3hJwbpy5dL7xGT
---
 scripts/tk11404-productset-FINDINGS.md   |  76 ++++++++++++++++
 scripts/tk11404-productset-prototype.mjs | 146 +++++++++++++++++++++++++++++++
 2 files changed, 222 insertions(+)

diff --git a/scripts/tk11404-productset-FINDINGS.md b/scripts/tk11404-productset-FINDINGS.md
new file mode 100644
index 0000000..574a3ae
--- /dev/null
+++ b/scripts/tk11404-productset-FINDINGS.md
@@ -0,0 +1,76 @@
+# TK-11404 — productSet position-1 reorder prototype: FINDINGS
+
+**Agent:** vp-dw-commerce · **Date:** 2026-09-10 · **Mode run:** `--test` only (zero real-catalog risk)
+**Prototype:** `scripts/tk11404-productset-prototype.mjs`
+
+## What the prototype tests
+
+DTD verdict **C** (unanimous 4/4, 2026-09-10 20:30Z) chose `productSet` to reorder the single
+"Size" option's **value order** to `[sellable…, Sample]`, on the theory that REST variant
+`position` is derived from option-value order, so forcing `Sample` last would push the sellable
+variant to REST position 1 (the position the bare `/products/<handle>` landing page reads).
+
+The prototype proves the payload against a **synthetic DRAFT product** it creates, reorders,
+verifies, and **deletes** — so the mechanism is validated with zero real-catalog exposure before
+any live `--pid` write.
+
+## Result: the mechanism does NOT work
+
+`--test` run (synthetic `[Sample $4.25, Sold Per Roll $100]`, self-deleted):
+
+```
+[BEFORE] REST pos1="Sample" $4.25 sku=…-sample | sample id preserved
+reorder input option values -> ["Sold Per Roll","Sample"]
+productSet userErrors: []            # mutation ACCEPTED
+[AFTER]  REST pos1="Sample" $4.25    # REST position UNCHANGED
+RESULT: pos1 now sellable? false     # <-- the fix did not take
+```
+
+A follow-up **settle-probe** (also synthetic DRAFT, self-deleted) ruled out eventual-consistency
+and cross-checked the alternate API, re-verifying at **+1s and +5s**:
+
+```
+productSet reorder [Roll, Sample]   -> userErrors []  -> REST: 1:Sample 2:Roll  (both +1s and +5s)
+productVariantsBulkReorder [→1,→2]  -> userErrors []  -> REST: 1:Sample 2:Roll  (both +1s and +5s)
+```
+
+**Both** `productSet` (option-value reorder) **and** `productVariantsBulkReorder` (direct position
+set) return `userErrors: []` yet leave REST variant `position` unchanged — they are **no-ops on
+REST position** for the `[Sample, Sold Per Roll]` single-option shape. This is not a timing
+artifact (confirmed at +5s), and it confirms the earlier live-apply observations logged on the
+ticket (20:24Z: bulkReorder changed only 2/32; 20:40Z: productSet does not change REST position).
+
+The **identity-preservation half works**: the sample variant `id`, `-sample` SKU, `$4.25` price,
+`inventoryPolicy`, weight, and inventory qty are all preserved across the mutation. Only the
+reorder EFFECT fails.
+
+## Why (root cause)
+
+REST variant `position` for a single-option product is bound to the option's **value creation
+order**, not the value list order passed at update time. When the value list is passed with
+existing `id`s, Shopify matches by id and preserves the stored order — the reordered list is
+accepted but ignored for positioning. The only mechanism that reliably re-seats the sellable
+variant at position 1 is to **delete and recreate the sample variant so it is created last**,
+which changes the sample variant's `id` and `-sample` SKU — a direct violation of the
+TK-11404 constraint "do NOT touch the sample variant id / -sample SKU."
+
+## Recommendation: BLOCK the live `--pid` apply
+
+1. **Mechanism ineffective** — proven here: no tested non-destructive API moves REST position 1.
+   A mass `--pid` run would be a no-op that burns write budget and ledger noise for ~2,586 products.
+2. **Premise already disproven** — the live-storefront check (ticket 20:40Z) showed the bare
+   `/products/<handle>` URL renders the **sellable** price, because Shopify's
+   `selected_or_first_available_variant` skips the *unavailable* Sample and auto-appends
+   `?variant=<sellable>`. There is **no customer-facing bare-URL leak**. The only real leak was the
+   GMC **offer link** pinning `?variant=<sample>`, already fixed under **TK-11394**.
+
+Net: the position-1 reorder is both **unnecessary** and **not achievable non-destructively**.
+The gated apply memo (`~/.claude/yolo-queue/pending-approval/`) recommends **BLOCK**.
+
+## Prototype interface (unchanged, gated)
+
+- `--test` — synthetic DRAFT create → reorder → verify → delete (what was run here; safe)
+- `--pid <id>` — snapshot a REAL product, reorder, verify (rollback payload saved) — **GATED, not run**
+- `--rollback <id>` — restore a real product's option-value order from its snapshot
+
+Rollback path exists and is wired (`SNAPDIR` snapshots + `--rollback`), but is moot given BLOCK.
diff --git a/scripts/tk11404-productset-prototype.mjs b/scripts/tk11404-productset-prototype.mjs
new file mode 100644
index 0000000..419bb60
--- /dev/null
+++ b/scripts/tk11404-productset-prototype.mjs
@@ -0,0 +1,146 @@
+#!/usr/bin/env node
+// TK-11404 — PROTOTYPE the productSet option-value reorder fix (DTD verdict C).
+// Goal: force the sellable variant to REST position 1 by reordering the single "Size"
+// option's VALUE ORDER to [sellable..., Sample], WITHOUT changing the sample variant's
+// id / -sample SKU / price / inventoryPolicy / weight / inventory quantity.
+//
+// productSet has FULL-REPLACEMENT semantics: options/variants omitted from input are
+// DELETED, and weight lives on inventoryItem. So the payload passes the COMPLETE current
+// option-value set (only reordered) AND the COMPLETE variant list (id + optionValues +
+// price + sku + inventoryPolicy + inventoryItem.measurement.weight), and NO
+// inventoryQuantities (stock is left untouched; we verify it after).
+//
+// Modes:
+//   --test            create a DRAFT synthetic product [Sample, Roll], reorder, verify, DELETE it (zero real-catalog risk)
+//   --pid <id>        snapshot a REAL product, reorder, verify (rollback payload saved)
+//   --rollback <id>   restore a real product's option-value order from its snapshot
+//   (no --apply flag: this is the gated prototype; it writes when a mode is given)
+
+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 SNAPDIR=process.env.HOME+'/.claude/yolo-queue/evidence/TK-11404/snapshots';
+fs.mkdirSync(SNAPDIR,{recursive:true});
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+const isSampleName=s=>/^\s*sample\s*$/i.test(s||'');
+const isSampleSku=s=>/-SAMPLE$/i.test(s||'');
+
+async function gql(query,variables){
+  for(let a=1;a<=6;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;
+}
+async function rest(path){ const r=await fetch(`https://${SHOP}/admin/api/${VER}${path}`,{headers:{'X-Shopify-Access-Token':TOK}}); return {status:r.status,body:await r.json().catch(()=>null)}; }
+
+const PRODUCT_Q=`query($id:ID!){ product(id:$id){ id title status
+  options{ id name position optionValues{ id name } }
+  variants(first:100){ edges{ node{ id sku title price inventoryPolicy
+    selectedOptions{ name value }
+    inventoryItem{ id tracked measurement{ weight{ value unit } } } } } } } }`;
+
+async function snapshot(gid){
+  const d=await gql(PRODUCT_Q,{id:gid});
+  const p=d?.data?.product; if(!p) throw new Error('no product '+JSON.stringify(d).slice(0,200));
+  p._variants=p.variants.edges.map(e=>e.node);
+  return p;
+}
+function buildReorderInput(p){
+  // reorder the (single) Size option's values: all non-sample first (keep relative order), sample last
+  const opt=p.options[0];
+  const nonSample=opt.optionValues.filter(v=>!isSampleName(v.name));
+  const sample=opt.optionValues.filter(v=>isSampleName(v.name));
+  const orderedValues=[...nonSample,...sample].map(v=>({id:v.id,name:v.name}));
+  const productOptions=[{id:opt.id,name:opt.name,values:orderedValues}];
+  const variants=p._variants.map(v=>({
+    id:v.id, price:v.price, sku:v.sku, inventoryPolicy:v.inventoryPolicy,
+    optionValues:v.selectedOptions.map(so=>({optionName:so.name,name:so.value})),
+    inventoryItem:{ tracked:v.inventoryItem?.tracked,
+      ...(v.inventoryItem?.measurement?.weight?{measurement:{weight:{value:v.inventoryItem.measurement.weight.value,unit:v.inventoryItem.measurement.weight.unit}}}:{}) },
+  }));
+  return { id:p.id, productOptions, variants };
+}
+const PRODUCTSET=`mutation($input:ProductSetInput!){ productSet(input:$input, synchronous:true){
+  product{ id } userErrors{ field message code } } }`;
+
+async function verify(gid,label){
+  // REST position (authoritative for landing page) + sample-variant preservation
+  const pid=gid.split('/').pop();
+  const r=await rest(`/products/${pid}.json`); const vs=[...(r.body?.product?.variants||[])].sort((a,b)=>a.position-b.position);
+  const pos1=vs[0]; const sampleV=vs.find(v=>isSampleSku(v.sku)||isSampleName(v.title));
+  console.log(`  [${label}] REST pos1="${pos1?.title}" $${pos1?.price} sku=${pos1?.sku} | sample: id=${sampleV?.id} sku=${sampleV?.sku} $${sampleV?.price} pol=${sampleV?.inventory_policy} wt=${sampleV?.weight}${sampleV?.weight_unit} qty=${sampleV?.inventory_quantity}`);
+  return {pos1IsSellable: pos1 && !(isSampleSku(pos1.sku)||isSampleName(pos1.title)), sampleV, all:vs};
+}
+
+const argv=process.argv;
+const mode=argv.includes('--test')?'test':argv.includes('--pid')?'pid':argv.includes('--rollback')?'rollback':null;
+if(!mode){console.log('specify --test | --pid <id> | --rollback <id>');process.exit(1);}
+
+if(mode==='test'){
+  // 1) create DRAFT synthetic product with [Sample, Roll] value order (Sample forced to pos1)
+  const CREATE=`mutation($input:ProductSetInput!){ productSet(input:$input, synchronous:true){
+    product{ id options{ optionValues{ id name } } variants(first:10){ edges{ node{ id sku title price } } } } userErrors{ field message } } }`;
+  const cin={ title:'ZZ TK-11404 TEST DELETE ME', status:'DRAFT',
+    productOptions:[{name:'Size', values:[{name:'Sample'},{name:'Sold Per Roll'}]}],
+    variants:[
+      {sku:'TK11404-TEST-sample', price:'4.25', inventoryPolicy:'CONTINUE', optionValues:[{optionName:'Size',name:'Sample'}], inventoryItem:{tracked:true, measurement:{weight:{value:0.5,unit:'POUNDS'}}}},
+      {sku:'TK11404-TEST', price:'100.00', inventoryPolicy:'CONTINUE', optionValues:[{optionName:'Size',name:'Sold Per Roll'}], inventoryItem:{tracked:false, measurement:{weight:{value:3,unit:'POUNDS'}}}},
+    ]};
+  const c=await gql(CREATE,{input:cin});
+  const err=c?.data?.productSet?.userErrors; if(!c?.data?.productSet?.product||err?.length){console.log('CREATE FAIL',JSON.stringify(c?.errors||err||c).slice(0,400));process.exit(1);}
+  const gid=c.data.productSet.product.id; console.log('created test',gid);
+  await sleep(600); const before=await verify(gid,'BEFORE');
+  // 2) reorder via productSet
+  const snap=await snapshot(gid); const input=buildReorderInput(snap);
+  console.log('  reorder input option values ->', JSON.stringify(input.productOptions[0].values.map(v=>v.name)));
+  const res=await gql(PRODUCTSET,{input});
+  const ue=res?.data?.productSet?.userErrors; if(res?.errors||ue===undefined||ue?.length){console.log('PRODUCTSET FAIL',JSON.stringify(res?.errors||ue||res).slice(0,400));}
+  await sleep(800); const after=await verify(gid,'AFTER');
+  console.log(`  RESULT: pos1 now sellable? ${after.pos1IsSellable}  sample id preserved? ${before.sampleV?.id===after.sampleV?.id}`);
+  // 3) delete the test product
+  const DEL=`mutation($id:ID!){ productDelete(input:{id:$id}){ deletedProductId userErrors{ message } } }`;
+  const del=await gql(DEL,{id:gid}); console.log('  deleted test product:', del?.data?.productDelete?.deletedProductId||JSON.stringify(del?.errors||del).slice(0,200));
+  process.exit(after.pos1IsSellable && before.sampleV?.id===after.sampleV?.id ? 0 : 1);
+}
+
+if(mode==='pid'){
+  const pid=argv[argv.indexOf('--pid')+1]; const gid=`gid://shopify/Product/${pid}`;
+  const snap=await snapshot(gid);
+  fs.writeFileSync(`${SNAPDIR}/${pid}.json`,JSON.stringify(snap,null,1));
+  console.log(`snapshot saved ${SNAPDIR}/${pid}.json — "${snap.title}"`);
+  const before=await verify(gid,'BEFORE');
+  const input=buildReorderInput(snap);
+  console.log('  reorder option values ->', JSON.stringify(input.productOptions[0].values.map(v=>v.name)));
+  const res=await gql(PRODUCTSET,{input});
+  const ue=res?.data?.productSet?.userErrors; if(res?.errors||ue===undefined||ue?.length){console.log('PRODUCTSET FAIL',JSON.stringify(res?.errors||ue||res).slice(0,400));process.exit(1);}
+  await sleep(900); const after=await verify(gid,'AFTER');
+  const okPos=after.pos1IsSellable;
+  const okSampleId=before.sampleV?.id===after.sampleV?.id;
+  const okSampleSku=before.sampleV?.sku===after.sampleV?.sku;
+  const okSamplePrice=before.sampleV?.price===after.sampleV?.price;
+  const okPol=before.sampleV?.inventory_policy===after.sampleV?.inventory_policy;
+  const okWt=String(before.sampleV?.weight)===String(after.sampleV?.weight);
+  const okQty=before.sampleV?.inventory_quantity===after.sampleV?.inventory_quantity;
+  console.log(`  RESULT pid=${pid}: pos1_sellable=${okPos} sampleId=${okSampleId} sku=${okSampleSku} price=${okSamplePrice} pol=${okPol} weight=${okWt} qty=${okQty}`);
+  const ledger=process.env.HOME+'/.claude/yolo-queue/executed-reversible/TK-11404-productset.jsonl';
+  fs.appendFileSync(ledger,JSON.stringify({ts:new Date().toISOString(),ticket:'TK-11404',action:'productset-reorder-option-values',product_id:pid,handle:snap.title,
+    original_option_values:snap.options[0].optionValues.map(v=>v.name), snapshot:`${SNAPDIR}/${pid}.json`,
+    undo:`node scripts/tk11404-productset-prototype.mjs --rollback ${pid}`})+'\n');
+  const allOk=okPos&&okSampleId&&okSampleSku&&okSamplePrice&&okPol&&okWt&&okQty;
+  console.log(allOk?'  ✓ ALL PRESERVED + FIXED':'  *** MISMATCH — INSPECT / ROLLBACK ***');
+  process.exit(allOk?0:1);
+}
+
+if(mode==='rollback'){
+  const pid=argv[argv.indexOf('--rollback')+1]; const snap=JSON.parse(fs.readFileSync(`${SNAPDIR}/${pid}.json`,'utf8'));
+  // restore original option-value order
+  const opt=snap.options[0];
+  const input={ id:snap.id, productOptions:[{id:opt.id,name:opt.name,values:opt.optionValues.map(v=>({id:v.id,name:v.name}))}],
+    variants:snap._variants.map(v=>({id:v.id,price:v.price,sku:v.sku,inventoryPolicy:v.inventoryPolicy,
+      optionValues:v.selectedOptions.map(so=>({optionName:so.name,name:so.value})),
+      inventoryItem:{tracked:v.inventoryItem?.tracked,...(v.inventoryItem?.measurement?.weight?{measurement:{weight:{value:v.inventoryItem.measurement.weight.value,unit:v.inventoryItem.measurement.weight.unit}}}:{})}}))};
+  const res=await gql(PRODUCTSET,{input}); const ue=res?.data?.productSet?.userErrors;
+  console.log('rollback', (res?.errors||ue?.length)?('FAIL '+JSON.stringify(res?.errors||ue).slice(0,300)):'OK');
+  await sleep(800); await verify(snap.id,'AFTER-ROLLBACK');
+}

← 2be0da4 TK-11414: wire weight defaults into 8 more onboarder create-  ·  back to Designerwallcoverings  ·  TK-10895: guards accept recorded adjudications, never implic 27999a6 →