[object Object]

← back to Zero Price Draft Disarm

TK-12276: read-only measure + dry-run-default disarm/rollback for $0 armed DRAFT variants

f498fce64a2ce5dccb73c3f325fca88ae260b2b1 · 2026-09-25 13:53:24 -0700 · Steve Abrams

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

Files touched

Diff

commit f498fce64a2ce5dccb73c3f325fca88ae260b2b1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 25 13:53:24 2026 -0700

    TK-12276: read-only measure + dry-run-default disarm/rollback for $0 armed DRAFT variants
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01D7LB6rCwpA7ubJTqYzqXEX
---
 .gitignore       |   9 ++++
 data-measure.out |  31 ++++++++++++
 disarm.mjs       | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 measure.mjs      |  91 +++++++++++++++++++++++++++++++++++
 supplement.mjs   |  24 ++++++++++
 5 files changed, 296 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8955433
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/
diff --git a/data-measure.out b/data-measure.out
new file mode 100644
index 0000000..fa19fb7
--- /dev/null
+++ b/data-measure.out
@@ -0,0 +1,31 @@
+{
+  "measured_at": "2026-09-25T20:52:48.679Z",
+  "draft_products_scanned": 21878,
+  "draft_variants_scanned": 30686,
+  "armed_variants": 1395,
+  "armed_products": 335,
+  "armed_by_vendor": {
+    "Christian Fischbacher": 1262,
+    "Phillipe Romano": 79,
+    "Hollywood Acoustical": 14,
+    "Hollywood Wallcoverings": 11,
+    "Fentucci": 7,
+    "Coordonné Europe": 6,
+    "Arte International": 5,
+    "Designer Wallcoverings": 3,
+    "British Walls": 2,
+    "LA Walls": 1,
+    "Versace": 1,
+    "DW Bespoke Studio": 1,
+    "Romo": 1,
+    "Los Angeles Fabrics": 1,
+    "Designer Wallcoverings and Fabrics": 1
+  },
+  "by_reason": {
+    "qty_gt_0": 1146,
+    "policy_continue": 16,
+    "untracked": 249,
+    "qty_2026": 1140
+  },
+  "variants_truncated_note": "variants(first:25) per product"
+}
diff --git a/disarm.mjs b/disarm.mjs
new file mode 100644
index 0000000..a34ce63
--- /dev/null
+++ b/disarm.mjs
@@ -0,0 +1,141 @@
+#!/usr/bin/env node
+// TK-12276 — DISARM $0 NON-sample sellable variants on DRAFT products (option a).
+//
+//   node disarm.mjs                       # DRY-RUN (default): live re-read + plan, NO writes
+//   node disarm.mjs --apply --approved TK-12276 [--limit N] [--vendor "Christian Fischbacher"]
+//                                         # LIVE: policy->DENY, tracked->true, available->0
+//   node disarm.mjs --rollback data/restore-map-<ts>.jsonl --approved TK-12276
+//                                         # UNDO: restore exact prior policy/tracked/qty per location
+//
+// Input: data/armed.json (from measure.mjs). Every target is RE-READ LIVE before acting and is
+// skipped unless it is STILL: product DRAFT, variant price == 0, non-sample, and orderable.
+// Restore-map rows are appended+fsync'd BEFORE the first write for that variant.
+// Never touches price, status, product, or sample variants. Batches of 50 with a 90s gap.
+import fs from 'node:fs';
+import path from 'node:path';
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const DIR = path.dirname(new URL(import.meta.url).pathname);
+const DATA = path.join(DIR, 'data');
+const argv = process.argv.slice(2);
+const flag = f => argv.includes(f);
+const val = (f, d) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : d; };
+const APPLY = flag('--apply');
+const ROLLBACK = val('--rollback', null);
+const LIMIT = Number(val('--limit', '1000000'));
+const VENDOR = val('--vendor', null);
+const BATCH = 50, GAP_MS = 90_000;
+const CAP = 500; // reversible-tier blast-radius cap per invocation
+
+if ((APPLY || ROLLBACK) && val('--approved', '') !== 'TK-12276') {
+  console.error('REFUSED: live mode requires --approved TK-12276 (Steve approval of the pending-approval memo).');
+  process.exit(2);
+}
+
+function token() {
+  if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
+  const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+  const m = env.match(/^SHOPIFY_ADMIN_TOKEN=["']?([^"'\n]+)/m);
+  if (!m) throw new Error('SHOPIFY_ADMIN_TOKEN missing');
+  return m[1];
+}
+const TOK = token();
+async function gql(query, variables) {
+  for (let a = 0; a < 6; a++) {
+    const r = await fetch(`https://${SHOP}/admin/api/${API}/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(2000 * (a + 1)); continue; }
+    if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
+    const j = await r.json();
+    if (j.errors?.some(e => e.extensions?.code === 'THROTTLED')) { await sleep(3000); continue; }
+    if (j.errors) throw new Error(JSON.stringify(j.errors));
+    return j.data;
+  }
+  throw new Error('gql retries exhausted');
+}
+const sleep = ms => new Promise(s => setTimeout(s, ms));
+const isSample = v => /sample|memo/i.test(v.title || '') || /-sample$/i.test(v.sku || '');
+
+const READ = `query($id:ID!){ productVariant(id:$id){ id sku title price inventoryPolicy inventoryQuantity
+  product{ id status vendor }
+  inventoryItem{ id tracked inventoryLevels(first:20){ nodes{ location{ id name }
+    quantities(names:["available","on_hand"]){ name quantity } } } } } }`;
+const M_POLICY = `mutation($pid:ID!,$v:[ProductVariantsBulkInput!]!){ productVariantsBulkUpdate(productId:$pid, variants:$v){ userErrors{ field message } } }`;
+const M_TRACK = `mutation($id:ID!,$in:InventoryItemInput!){ inventoryItemUpdate(id:$id, input:$in){ userErrors{ field message } } }`;
+const M_QTY = `mutation($in:InventorySetQuantitiesInput!){ inventorySetQuantities(input:$in){ userErrors{ field message } } }`;
+
+function snapshot(v) {
+  return { variant_id: v.id, product_id: v.product.id, vendor: v.product.vendor, sku: v.sku,
+    price: v.price, policy: v.inventoryPolicy, tracked: v.inventoryItem.tracked,
+    inventory_item_id: v.inventoryItem.id,
+    levels: v.inventoryItem.inventoryLevels.nodes.map(l => ({ location_id: l.location.id, location: l.location.name,
+      available: l.quantities.find(q => q.name === 'available')?.quantity ?? null })) };
+}
+function stillArmed(v) {
+  if (!v) return 'variant-gone';
+  if (v.product.status !== 'DRAFT') return `product-${v.product.status}`;
+  if (Number(v.price) > 0) return 'price-now-gt-0';
+  if (isSample(v)) return 'is-sample';
+  const orderable = v.inventoryQuantity > 0 || v.inventoryPolicy === 'CONTINUE' || v.inventoryItem.tracked === false;
+  return orderable ? null : 'already-disarmed';
+}
+async function ue(data, key) {
+  const errs = data[key]?.userErrors || [];
+  if (errs.length) throw new Error(`${key}: ${JSON.stringify(errs)}`);
+}
+
+async function disarmOne(s) {
+  await ue(await gql(M_POLICY, { pid: s.product_id, v: [{ id: s.variant_id, inventoryPolicy: 'DENY' }] }), 'productVariantsBulkUpdate');
+  if (s.tracked === false) await ue(await gql(M_TRACK, { id: s.inventory_item_id, in: { tracked: true } }), 'inventoryItemUpdate');
+  const q = s.levels.filter(l => (l.available ?? 0) !== 0).map(l => ({ inventoryItemId: s.inventory_item_id, locationId: l.location_id, quantity: 0 }));
+  if (q.length) await ue(await gql(M_QTY, { in: { name: 'available', reason: 'correction', ignoreCompareQuantity: true,
+    referenceDocumentUri: 'logistics://tk-12276/disarm-zero-price-drafts', quantities: q } }), 'inventorySetQuantities');
+}
+async function restoreOne(s) {
+  const q = s.levels.filter(l => l.available !== null).map(l => ({ inventoryItemId: s.inventory_item_id, locationId: l.location_id, quantity: l.available }));
+  if (q.length) await ue(await gql(M_QTY, { in: { name: 'available', reason: 'correction', ignoreCompareQuantity: true,
+    referenceDocumentUri: 'logistics://tk-12276/rollback', quantities: q } }), 'inventorySetQuantities');
+  if (s.tracked === false) await ue(await gql(M_TRACK, { id: s.inventory_item_id, in: { tracked: false } }), 'inventoryItemUpdate');
+  await ue(await gql(M_POLICY, { pid: s.product_id, v: [{ id: s.variant_id, inventoryPolicy: s.policy }] }), 'productVariantsBulkUpdate');
+}
+
+async function main() {
+  const ts = new Date().toISOString().replace(/[:.]/g, '-');
+  if (ROLLBACK) {
+    const rows = fs.readFileSync(ROLLBACK, 'utf8').trim().split('\n').map(l => JSON.parse(l)).filter(r => r.phase === 'before');
+    let ok = 0, fail = 0;
+    for (const [i, r] of rows.entries()) {
+      try { await restoreOne(r); ok++; } catch (e) { fail++; console.error('restore-fail', r.variant_id, e.message); }
+      if ((i + 1) % BATCH === 0 && i + 1 < rows.length) await sleep(GAP_MS);
+    }
+    console.log(JSON.stringify({ mode: 'rollback', restored: ok, failed: fail }));
+    return;
+  }
+  const { armed } = JSON.parse(fs.readFileSync(path.join(DATA, 'armed.json'), 'utf8'));
+  let targets = armed.filter(a => !VENDOR || a.vendor === VENDOR).slice(0, LIMIT);
+  if (APPLY && targets.length > CAP) {
+    console.error(`NOTE: ${targets.length} targets > reversible-tier cap ${CAP}; this invocation will process the first ${CAP}. Re-run for the rest.`);
+    targets = targets.slice(0, CAP);
+  }
+  const mapPath = path.join(DATA, `restore-map-${ts}.jsonl`);
+  const fd = APPLY ? fs.openSync(mapPath, 'a') : null;
+  const plan = { mode: APPLY ? 'APPLY' : 'DRY-RUN', considered: targets.length, would_disarm: 0, skipped: {}, done: 0, failed: 0, by_vendor: {} };
+  for (const [i, t] of targets.entries()) {
+    const v = (await gql(READ, { id: t.variant_id })).productVariant;
+    const why = stillArmed(v);
+    if (why) { plan.skipped[why] = (plan.skipped[why] || 0) + 1; continue; }
+    const s = snapshot(v);
+    plan.would_disarm++; plan.by_vendor[s.vendor] = (plan.by_vendor[s.vendor] || 0) + 1;
+    if (!APPLY) continue;
+    fs.writeSync(fd, JSON.stringify({ phase: 'before', ...s }) + '\n'); fs.fsyncSync(fd);
+    try { await disarmOne(s); plan.done++; fs.writeSync(fd, JSON.stringify({ phase: 'done', variant_id: s.variant_id }) + '\n'); }
+    catch (e) { plan.failed++; fs.writeSync(fd, JSON.stringify({ phase: 'error', variant_id: s.variant_id, error: e.message }) + '\n'); }
+    if (plan.done && plan.done % BATCH === 0) await sleep(GAP_MS);
+  }
+  if (fd !== null) fs.closeSync(fd);
+  if (APPLY) plan.restore_map = mapPath, plan.undo = `node ${path.join(DIR, 'disarm.mjs')} --rollback ${mapPath} --approved TK-12276`;
+  console.log(JSON.stringify(plan, null, 2));
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/measure.mjs b/measure.mjs
new file mode 100644
index 0000000..dddc4d4
--- /dev/null
+++ b/measure.mjs
@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+// TK-12276 — READ-ONLY re-measure of $0 NON-sample sellable variants on DRAFT products
+// that are "armed" (qty>0, or inventoryPolicy CONTINUE, or untracked) — i.e. would be
+// instantly orderable at $0 the moment the product is set ACTIVE.
+// Paged GraphQL QUERIES only (no mutations, no bulk op). Token: SHOPIFY_ADMIN_TOKEN.
+// Output: data/draft-variants.jsonl (every draft variant) + data/armed.json (the armed set).
+import fs from 'node:fs';
+import path from 'node:path';
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const DIR = path.dirname(new URL(import.meta.url).pathname);
+const DATA = path.join(DIR, 'data');
+fs.mkdirSync(DATA, { recursive: true });
+
+function token() {
+  if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
+  const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+  const m = env.match(/^SHOPIFY_ADMIN_TOKEN=["']?([^"'\n]+)/m);
+  if (!m) throw new Error('SHOPIFY_ADMIN_TOKEN missing');
+  return m[1];
+}
+const TOK = token();
+
+async function gql(query, variables) {
+  for (let attempt = 0; attempt < 6; attempt++) {
+    const r = await fetch(`https://${SHOP}/admin/api/${API}/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 new Promise(s => setTimeout(s, 2000 * (attempt + 1))); continue; }
+    if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
+    const j = await r.json();
+    if (j.errors?.some(e => e.extensions?.code === 'THROTTLED')) { await new Promise(s => setTimeout(s, 3000)); continue; }
+    if (j.errors) throw new Error(JSON.stringify(j.errors));
+    const t = j.extensions?.cost?.throttleStatus;
+    if (t && t.currentlyAvailable < 1500) await new Promise(s => setTimeout(s, 4000));
+    return j.data;
+  }
+  throw new Error('gql retries exhausted');
+}
+
+const Q = `query($after:String){ products(first:40, after:$after, query:"status:draft"){
+  pageInfo{ hasNextPage endCursor }
+  nodes{ id title vendor handle status createdAt tags
+    variants(first:25){ nodes{ id sku title price inventoryPolicy inventoryQuantity
+      inventoryItem{ id tracked } } } } } }`;
+
+const isSample = v => /sample|memo/i.test(v.title || '') || /-sample$/i.test(v.sku || '');
+
+const out = fs.createWriteStream(path.join(DATA, 'draft-variants.jsonl'));
+let after = null, products = 0, variants = 0, page = 0;
+const armed = [];
+const t0 = Date.now();
+do {
+  const d = await gql(Q, { after });
+  const p = d.products;
+  for (const prod of p.nodes) {
+    products++;
+    for (const v of prod.variants.nodes) {
+      variants++;
+      const row = { product_id: prod.id, vendor: prod.vendor, handle: prod.handle, title: prod.title,
+        status: prod.status, created_at: prod.createdAt, variant_id: v.id, sku: v.sku, vtitle: v.title,
+        price: Number(v.price), policy: v.inventoryPolicy, qty: v.inventoryQuantity,
+        inventory_item_id: v.inventoryItem?.id, tracked: v.inventoryItem?.tracked, sample: isSample(v) };
+      out.write(JSON.stringify(row) + '\n');
+      const zero = !(row.price > 0);
+      const orderable = (row.qty > 0) || row.policy === 'CONTINUE' || row.tracked === false;
+      if (zero && !row.sample && orderable) armed.push(row);
+    }
+  }
+  after = p.pageInfo.hasNextPage ? p.pageInfo.endCursor : null;
+  if (++page % 25 === 0) console.error(`page ${page} products ${products} variants ${variants} armed ${armed.length} ${(Date.now()-t0)/1000|0}s`);
+} while (after);
+out.end();
+
+const byVendor = {};
+for (const a of armed) byVendor[a.vendor] = (byVendor[a.vendor] || 0) + 1;
+const armedProducts = new Set(armed.map(a => a.product_id));
+const summary = { measured_at: new Date().toISOString(), draft_products_scanned: products,
+  draft_variants_scanned: variants, armed_variants: armed.length, armed_products: armedProducts.size,
+  armed_by_vendor: Object.fromEntries(Object.entries(byVendor).sort((a, b) => b[1] - a[1])),
+  by_reason: {
+    qty_gt_0: armed.filter(a => a.qty > 0).length,
+    policy_continue: armed.filter(a => a.policy === 'CONTINUE').length,
+    untracked: armed.filter(a => a.tracked === false).length,
+    qty_2026: armed.filter(a => a.qty === 2026).length },
+  variants_truncated_note: 'variants(first:25) per product' };
+fs.writeFileSync(path.join(DATA, 'armed.json'), JSON.stringify({ summary, armed }, null, 1));
+console.log(JSON.stringify(summary, null, 2));
diff --git a/supplement.mjs b/supplement.mjs
new file mode 100644
index 0000000..7cf7c60
--- /dev/null
+++ b/supplement.mjs
@@ -0,0 +1,24 @@
+// TK-12276: re-read products that hit the variants(first:25) cap with first:250 (read-only).
+import fs from 'node:fs';
+const env=fs.readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8');
+const TOK=process.env.SHOPIFY_ADMIN_TOKEN||env.match(/^SHOPIFY_ADMIN_TOKEN=["']?([^"'\n]+)/m)[1];
+const rows=fs.readFileSync('data/draft-variants.jsonl','utf8').trim().split('\n').map(JSON.parse);
+const cnt={};for(const r of rows)cnt[r.product_id]=(cnt[r.product_id]||0)+1;
+const ids=Object.keys(cnt).filter(k=>cnt[k]>=25);
+const isSample=v=>/sample|memo/i.test(v.title||'')||/-sample$/i.test(v.sku||'');
+const A=JSON.parse(fs.readFileSync('data/armed.json','utf8'));
+const have=new Set(A.armed.map(a=>a.variant_id));let added=0;const info=[];
+for(const id of ids){
+ const r=await fetch('https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json',{method:'POST',headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json'},body:JSON.stringify({query:`{product(id:"${id}"){id vendor handle title status createdAt variants(first:250){nodes{id sku title price inventoryPolicy inventoryQuantity inventoryItem{id tracked}}}}}`})});
+ const p=(await r.json()).data.product; info.push([p.vendor,p.variants.nodes.length]);
+ for(const v of p.variants.nodes){ if(have.has(v.id))continue;
+  const row={product_id:p.id,vendor:p.vendor,handle:p.handle,title:p.title,status:p.status,created_at:p.createdAt,variant_id:v.id,sku:v.sku,vtitle:v.title,price:Number(v.price),policy:v.inventoryPolicy,qty:v.inventoryQuantity,inventory_item_id:v.inventoryItem?.id,tracked:v.inventoryItem?.tracked,sample:isSample(v)};
+  if(!(row.price>0)&&!row.sample&&(row.qty>0||row.policy==='CONTINUE'||row.tracked===false)){A.armed.push(row);added++;}}
+}
+const bv={};for(const a of A.armed)bv[a.vendor]=(bv[a.vendor]||0)+1;
+A.summary.armed_variants=A.armed.length;A.summary.armed_products=new Set(A.armed.map(a=>a.product_id)).size;
+A.summary.armed_by_vendor=Object.fromEntries(Object.entries(bv).sort((a,b)=>b[1]-a[1]));
+A.summary.by_reason={qty_gt_0:A.armed.filter(a=>a.qty>0).length,policy_continue:A.armed.filter(a=>a.policy==='CONTINUE').length,untracked:A.armed.filter(a=>a.tracked===false).length,qty_2026:A.armed.filter(a=>a.qty===2026).length};
+A.summary.variants_truncated_note=`variants(first:25) per product; ${ids.length} capped products re-read at first:250 (+${added} armed)`;
+fs.writeFileSync('data/armed.json',JSON.stringify(A,null,1));
+console.log(JSON.stringify({capped:ids.length,info,added,summary:A.summary},null,1));

(oldest)  ·  back to Zero Price Draft Disarm  ·  (newest)