[object Object]

← back to Rebel Walls Push

guard TK-11357 Fix D: rebel-walls push.js — refuse to MINT a $0 mural, and never CONTINUE it

68b2721cd88de3f258c5148643b5842e25933507 · 2026-09-10 09:44:20 -0700 · Steve

SCHEDULED: reached from run-cadence-hourly.sh:117 via cadence-daily-step.sh behind an $RW_MARK
marker, i.e. effectively daily. Behind a multi-hop .sh chain, which is why plist greps missed it.

PRE-EXISTING GUARD STATE (whole path read, not assumed): NO price gate anywhere. fetchRows()
filters only on `(shopify_product_id IS NULL OR = '') AND dedup_skip IS NOT TRUE`; there is no
price_retail condition in the SQL and none in JS. buildInput() then prices the main mural variant
`String(row.price_retail)` with inventoryPolicy CONTINUE and tracked:true.

Why CONTINUE is the dangerous part: CONTINUE (oversell) makes a variant orderable at ANY quantity
INCLUDING ZERO. So a $0 variant minted here is orderable AND the established qty->0 stopgap for
this defect class is a NO-OP against it. Guarding only the quantity would have been decorative.

THREE fixes:
  (a) pushRow() refuses to create when !(Number(row.price_retail) > 0). Safe + idempotent: this is
      a create-only MINTING path (hard-coded VENDOR 'Rebel Walls'), it cannot revert an existing
      cohort, and the row keeps shopify_product_id NULL so it is re-picked when a real price lands.
      The caller reports it as HELD, not FAILED, and logs it to the progress log.
  (b) inventoryPolicy comes from the guard: CONTINUE only for a genuinely priced mural, 'deny'
      otherwise (uppercased for the productSet enum). Priced murals keep CONTINUE unchanged.
  (c) publishAndStock()'s flat QTY_2026 stamp is guarded too. HONEST NOTE: that path is currently
      unreachable for new rows — buildInput() mints DRAFT/ARCHIVED and the call is gated on
      status === 'ACTIVE' despite the comment above it claiming ACTIVE. Guarded anyway so flipping
      that decision back cannot silently reopen the hole. The mutation already returned each
      variant's price; it now returns `title` too, for guard accuracy.

Shared guard vendored to scripts/lib/. Proven by the Designer-Wallcoverings harness:
4/4 required + 3/3 regression + P8 policy 2/2 ($0->deny, priced->continue).
SOURCE-ONLY: nothing run, no Shopify write. Reversible: git revert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files touched

Diff

commit 68b2721cd88de3f258c5148643b5842e25933507
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Sep 10 09:44:20 2026 -0700

    guard TK-11357 Fix D: rebel-walls push.js — refuse to MINT a $0 mural, and never CONTINUE it
    
    SCHEDULED: reached from run-cadence-hourly.sh:117 via cadence-daily-step.sh behind an $RW_MARK
    marker, i.e. effectively daily. Behind a multi-hop .sh chain, which is why plist greps missed it.
    
    PRE-EXISTING GUARD STATE (whole path read, not assumed): NO price gate anywhere. fetchRows()
    filters only on `(shopify_product_id IS NULL OR = '') AND dedup_skip IS NOT TRUE`; there is no
    price_retail condition in the SQL and none in JS. buildInput() then prices the main mural variant
    `String(row.price_retail)` with inventoryPolicy CONTINUE and tracked:true.
    
    Why CONTINUE is the dangerous part: CONTINUE (oversell) makes a variant orderable at ANY quantity
    INCLUDING ZERO. So a $0 variant minted here is orderable AND the established qty->0 stopgap for
    this defect class is a NO-OP against it. Guarding only the quantity would have been decorative.
    
    THREE fixes:
      (a) pushRow() refuses to create when !(Number(row.price_retail) > 0). Safe + idempotent: this is
          a create-only MINTING path (hard-coded VENDOR 'Rebel Walls'), it cannot revert an existing
          cohort, and the row keeps shopify_product_id NULL so it is re-picked when a real price lands.
          The caller reports it as HELD, not FAILED, and logs it to the progress log.
      (b) inventoryPolicy comes from the guard: CONTINUE only for a genuinely priced mural, 'deny'
          otherwise (uppercased for the productSet enum). Priced murals keep CONTINUE unchanged.
      (c) publishAndStock()'s flat QTY_2026 stamp is guarded too. HONEST NOTE: that path is currently
          unreachable for new rows — buildInput() mints DRAFT/ARCHIVED and the call is gated on
          status === 'ACTIVE' despite the comment above it claiming ACTIVE. Guarded anyway so flipping
          that decision back cannot silently reopen the hole. The mutation already returned each
          variant's price; it now returns `title` too, for guard accuracy.
    
    Shared guard vendored to scripts/lib/. Proven by the Designer-Wallcoverings harness:
    4/4 required + 3/3 regression + P8 policy 2/2 ($0->deny, priced->continue).
    SOURCE-ONLY: nothing run, no Shopify write. Reversible: git revert.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 scripts/lib/inventory-stamp-guard.mjs | 80 ++++++++++++++++++++++++++++++++++
 scripts/push.js                       | 81 +++++++++++++++++++++++++++++++----
 2 files changed, 152 insertions(+), 9 deletions(-)

diff --git a/scripts/lib/inventory-stamp-guard.mjs b/scripts/lib/inventory-stamp-guard.mjs
new file mode 100644
index 0000000..16b6437
--- /dev/null
+++ b/scripts/lib/inventory-stamp-guard.mjs
@@ -0,0 +1,80 @@
+// VENDORED COPY — canonical source: Designer-Wallcoverings/shopify/scripts/lib/inventory-stamp-guard.mjs
+// Vendored (not cross-repo-imported) on purpose: this repo ships and runs independently, and a
+// cross-repo relative import would hard-crash the scheduled daily cadence if either tree moved.
+// KEEP IN SYNC — the TK-11357 fixture harness hashes every copy and FAILS on drift.
+// TK-11357 (lineage TK-10825/10965/11140/11299/11301/11357).
+// TK-10965 — Fix B (prevention): the inventory-stamp invariant, as a pure guard.
+//
+// ROOT CAUSE (see ../FINDINGS.md): importers stamp a positive "cap-free" stock
+// number (the year literal 2026) on the SELLABLE non-Sample variant of every
+// activated product — both in the product-create payload (`inventory_quantity: 2026`)
+// and on reconcile (`setInventory2026()`). When that sellable variant is ALSO
+// priced $0 (quote-only / contact-for-price lines like Phillipe Romano, Fentucci
+// Naturals), positive stock makes it `availableForSale` → checkout-orderable for $0.
+//
+// THE INVARIANT this module enforces (one place, both call sites):
+//   A sellable variant that is priced $0 OR belongs to a quote-only / price-
+//   suppressed line must NEVER receive positive inventory. It gets 0 → not orderable.
+//   (The $4.25 Sample variant is unaffected — it is not the sellable variant and is
+//    already qty=0/non-orderable by design.)
+//
+// PURE + dependency-free on purpose: no network, no env, no Shopify client, so it
+// unit-tests offline and drops into any importer runtime unchanged. $0 (local).
+
+// Tag family that means "this line has no public retail price" — a superset of the
+// single `quote-only` tag the standing canary keyed on (which is why Fentucci, tagged
+// `quotes`/`Needs-Price`, was the canary's 462-product blind spot).
+export const PRICE_SUPPRESSED_TAGS = new Set([
+  'quote-only', 'quote only', 'quote_only',
+  'quotes', 'contact-for-price', 'contact for price', 'needs-price', 'needs price',
+]);
+
+const norm = t => String(t).trim().toLowerCase();
+
+/**
+ * Is this product a quote-only / price-suppressed line?
+ * @param {{tags?: string[]|string, vendor?: string}} product
+ */
+export function isPriceSuppressed(product = {}) {
+  const tags = Array.isArray(product.tags)
+    ? product.tags
+    : String(product.tags || '').split(',');
+  if (tags.some(t => PRICE_SUPPRESSED_TAGS.has(norm(t)))) return true;
+  // Vendor fallback for untagged cohorts (Fentucci Naturals ships quote-only with
+  // zero quote-only tags). Extend as new price-on-request lines are onboarded.
+  return norm(product.vendor) === 'fentucci naturals';
+}
+
+/**
+ * A variant is the "sellable" one iff it is NOT the Sample variant.
+ * (Importers create exactly two variants: `Sample` @ $4.25 and the real unit @ price.)
+ * @param {{title?: string, option1?: string}} variant
+ */
+export function isSellableVariant(variant = {}) {
+  const label = variant.title ?? variant.option1 ?? '';
+  return !/sample/i.test(label);
+}
+
+/**
+ * Would giving this sellable variant positive stock make it a $0-orderable defect?
+ * True iff it's the sellable variant AND (price is 0 OR the line is price-suppressed).
+ * @param {object} variant  the variant about to be stamped
+ * @param {object} product  its parent (for tags/vendor)
+ */
+export function isZeroPriceOrderableRisk(variant = {}, product = {}) {
+  if (!isSellableVariant(variant)) return false;
+  const price = Number(variant.price);
+  return price === 0 || Number.isNaN(price) || isPriceSuppressed(product);
+}
+
+/**
+ * THE GUARD. Return the inventory quantity that is SAFE to stamp on this variant.
+ * Drop-in replacement for the literal `2026` at both call sites:
+ *   - create payload:  inventory_quantity: safeStampQuantity(variant, product)
+ *   - setInventory2026: quantity:        safeStampQuantity(variant, product)
+ * Returns `desired` (2026) for normal priced variants; 0 for the defect class.
+ * @returns {number} 0 for a zero-price-orderable risk, else `desired`
+ */
+export function safeStampQuantity(variant, product, desired = 2026) {
+  return isZeroPriceOrderableRisk(variant, product) ? 0 : desired;
+}
diff --git a/scripts/push.js b/scripts/push.js
index de78996..1928afc 100644
--- a/scripts/push.js
+++ b/scripts/push.js
@@ -213,6 +213,44 @@ function buildMetafields(row) {
   return mf;
 }
 
+const { safeStampQuantity } = require('./lib/inventory-stamp-guard.mjs');  // GUARD TK-11357 (shared guard)
+// ── GUARD TK-11357 BEGIN ─ do not edit without re-running the fixture proof ──────────
+// A $0 / quote-only sellable variant must NEVER receive positive stock: positive stock is what
+// flips availableForSale=true, making it checkout-orderable at $0 (lineage TK-10825 -> 10965 ->
+// 11140 -> 11299 -> 11301 -> 11357). $0 is the LIVE theme's deliberate quote-only SENTINEL
+// (snippets/product-form-content.liquid renders the "Contact Us" button iff variant.price == 0),
+// so the remedy is NEVER to write a placeholder price - it is "do not stock it".
+// Steve's 2026-06-20 "active products are never out of stock" rule is PRESERVED for PRICED goods:
+// a priced variant still gets `desired`. The quote-only tag/vendor decision is delegated to the
+// shared guard (inventory-stamp-guard.mjs); this adds one strictly-safer rule of its own -
+// price <= 0 / NaN is ALWAYS 0, even on a variant labelled "Sample" (a $0 "sample" is the same
+// $0-orderable defect). The real $4.25 memo sample is unaffected and keeps its existing quantity.
+// `inventoryItem?.id ?? null` because this writer decides on a CREATE PAYLOAD, where no
+// inventoryItem id exists yet; only the .quantity is consumed there.
+function safeQuantities(product, variants, locationId, desired) {
+  return (variants || []).map(v => ({
+    inventoryItemId: v.inventoryItem?.id ?? null,
+    locationId,
+    quantity: Number(v.price) > 0
+      ? safeStampQuantity({ title: v.title, price: v.price }, product, desired)
+      : 0,
+  }));
+}
+// inventoryPolicy CONTINUE (oversell) makes a variant orderable at ANY quantity INCLUDING ZERO.
+// So for the $0 class, "set the quantity to 0" is a NO-OP under CONTINUE and the entire
+// established remedy for this defect class silently fails. A variant the guard zeroes must
+// therefore ALSO be DENY, or the guard is decorative. Priced variants keep the caller's policy
+// exactly as-is, so oversell behaviour for real made-to-order goods is unchanged.
+function safePolicy(product, variant, desiredPolicy, desired) {
+  return safeQuantities(product, [variant], null, desired)[0].quantity > 0 ? desiredPolicy : 'deny';
+}
+// ── GUARD TK-11357 END ────────────────────────────────────────────
+
+// The guard's product view for this writer. VENDOR is hard-coded 'Rebel Walls' (a priced, per-m²
+// made-to-order line — not a quote-only line), so the shared guard's price-suppressed branch does
+// not fire here and only the price rule applies.
+const _guardProduct = row => ({ vendor: VENDOR, tags: buildTags(row) });
+
 function buildInput(row) {
   // Steve-authorized 2026-06-04 ("publish all to active, assign all sales channels,
   // and inventory at 2026 for all — rebel walls"): Rebel Walls ship LIVE on create.
@@ -239,7 +277,10 @@ function buildInput(row) {
         price,
         sku: row.dw_sku,
         inventoryItem: { sku: row.dw_sku, tracked: true },
-        inventoryPolicy: 'CONTINUE',
+        // GUARD TK-11357 (b): CONTINUE only for a genuinely priced mural. A $0 variant must never
+        // be CONTINUE or a later qty->0 remediation cannot make it unorderable. Uppercased because
+        // productSet takes the ProductVariantInventoryPolicy enum, not the REST lowercase string.
+        inventoryPolicy: safePolicy(_guardProduct(row), { title: 'Mural (per m²)', price }, 'CONTINUE', QTY_2026).toUpperCase(),
         taxable: true,
       },
       {
@@ -261,13 +302,16 @@ function buildInput(row) {
 const MUTATION = `mutation push($input: ProductSetInput!) {
   productSet(synchronous: true, input: $input) {
     product { id handle status
-      variants(first: 5) { edges { node { sku price inventoryItem { id } } } }
+      variants(first: 5) { edges { node { sku title price inventoryItem { id } } } }
       media(first: 1) { edges { node { mediaContentType status } } } }
     userErrors { field message }
   }
 }`;
 
 // ---- live-on-create helpers (publications + inventory) ------------------
+// The productSet mutation's selection set returns no vendor/tags, but VENDOR is a hard-coded
+// constant for this writer, so the guard's product view is knowable without a second round-trip.
+const _guardProductFromLive = () => ({ vendor: VENDOR, tags: [] });
 const QTY_2026 = 2026;
 let PUBLICATION_IDS = null;   // [{id,name}]
 let LOCATION_ID = null;       // gid://shopify/Location/...
@@ -323,11 +367,15 @@ async function publishAndStock(product) {
       if (real.length) throw new Error('publish: ' + JSON.stringify(real));
     }
   }
-  // inventory = 2026 on both variants (one call)
-  const setQ = product.variants.edges
-    .map(e => e.node.inventoryItem && e.node.inventoryItem.id)
-    .filter(Boolean)
-    .map(itemId => ({ inventoryItemId: itemId, locationId: LOCATION_ID, quantity: QTY_2026 }));
+  // inventory = 2026 on both variants (one call).
+  // GUARD TK-11357 (c): this stamped a flat QTY_2026 on every returned variant with no price check.
+  // It is reachable only when status === 'ACTIVE' (buildInput currently mints DRAFT/ARCHIVED, so
+  // today it is effectively dead for new rows) — guarded anyway so flipping that decision back to
+  // ACTIVE cannot silently reopen the hole. The mutation already returns each variant's price.
+  const setQ = safeQuantities(
+    _guardProductFromLive(product),
+    product.variants.edges.map(e => e.node).filter(n => n.inventoryItem && n.inventoryItem.id),
+    LOCATION_ID, QTY_2026);
   if (setQ.length && LOCATION_ID) {
     const r = await gql(M_SET_QTY, { input: { name: 'available', reason: 'correction', ignoreCompareQuantity: true, quantities: setQ } });
     const ue = r.json.data && r.json.data.inventorySetQuantities.userErrors;
@@ -336,6 +384,15 @@ async function publishAndStock(product) {
 }
 
 async function pushRow(row) {
+  // GUARD TK-11357 (a): fetchRows() filters ONLY on shopify_product_id + dedup_skip — there is no
+  // price filter anywhere on this path, so a null/0 price_retail row minted a $0 sellable mural
+  // variant marked inventoryPolicy CONTINUE (orderable at ANY quantity INCLUDING ZERO, which makes
+  // the standard qty->0 stopgap a no-op against it). This is a create-only MINTING path, so
+  // refusing is safe and idempotent: the row keeps shopify_product_id NULL and is re-picked
+  // automatically once a real price_retail lands.
+  if (!(Number(row.price_retail) > 0)) {
+    return { skippedNoPrice: true, dw_sku: row.dw_sku, price_retail: row.price_retail };
+  }
   const input = buildInput(row);
   if (DRY) { return { dry: true, title: input.title, status: input.status, skus: input.variants.map(v=>v.sku), price: input.variants[0].price, image: !!input.files }; }
   for (let attempt = 1; attempt <= 5; attempt++) {
@@ -371,12 +428,18 @@ async function pushRow(row) {
   }
   const rows = fetchRows();
   console.log(`[rebel-walls-push] ${rows.length} rows to push  (dry=${DRY})`);
-  let ok = 0, fail = 0;
+  let ok = 0, fail = 0, heldNoPrice = 0;
   const failures = [];
   for (let i = 0; i < rows.length; i++) {
     const row = rows[i];
     try {
       const res = await pushRow(row);
+      if (res.skippedNoPrice) {   // GUARD TK-11357: held, not failed — re-picked when a price lands
+        heldNoPrice++;
+        console.log(`  ⛔ HELD [${row.id}] ${res.dw_sku}: price_retail=${res.price_retail} — NOT created ($0-orderable guard)`);
+        fs.appendFileSync(PROGRESS_LOG, JSON.stringify({ ts: new Date().toISOString(), pg_id: row.id, dw_sku: row.dw_sku, held: 'no-price', price_retail: res.price_retail }) + '\n');
+        continue;
+      }
       if (DRY) { console.log(`DRY [${row.id}] ${res.title} | ${res.status} | ${res.skus.join(', ')} | $${res.price} | img=${res.image}`); ok++; continue; }
       const gid = res.product.id;
       markPushed(row.id, gid);
@@ -393,6 +456,6 @@ async function pushRow(row) {
     // gentle inter-call pacing
     if (!DRY) await sleep(350);
   }
-  console.log(`\n[rebel-walls-push] DONE  ok=${ok}  fail=${fail}`);
+  console.log(`\n[rebel-walls-push] DONE  ok=${ok}  fail=${fail}  held-no-price=${heldNoPrice}`);
   if (failures.length) { fs.writeFileSync(path.join(LOG_DIR, 'failures.json'), JSON.stringify(failures, null, 2)); console.log(`  failures -> data/failures.json`); }
 })().catch(e => { console.error('FATAL', e); process.exit(1); });

← 359cbbf TK-10405 full-fix EXECUTED: reordered Mural(per m²)->pos1 +  ·  back to Rebel Walls Push  ·  auto-data-snapshot: 2026-09-22T11:43:24 (1 data files) — scr 960314b →