← 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
A scripts/lib/inventory-stamp-guard.mjsM scripts/push.js
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 →