← back to Sister Parish Onboarding
guard TK-11357 Fix D: sister-parish push_shopify.js — don't mint/stock a $0 variant as CONTINUE
3e2c3fc6afb961cc26c3dce68f5b660fddb207d6 · 2026-09-10 09:44:39 -0700 · Steve
Found by widening the writer enumeration to REST inventory_levels endpoints (my first sweep was
GraphQL-mutation-only, the same blind spot this lineage has hit before).
PRE-EXISTING GUARD STATE: none. buildProductPayload() prices every variant `v.dw_retail.toFixed(2)`
straight from the input feed with no price check, sets inventory_policy 'continue', and the caller
then stamps a flat 2026 on every created variant via /inventory_levels/set.json. A 0/absent
vendor_retail (dw_retail = vendor_retail / 0.85) therefore mints a $0 variant that is CONTINUE --
orderable at ANY quantity INCLUDING ZERO, so the qty->0 remedy cannot fix it.
FIX: inventory_policy comes from the guard ('deny' when the safe quantity is 0, caller's 'continue'
otherwise), and the stock loop stamps the guard's per-variant safe quantity computed from the price
Shopify actually landed -- skipping $0 variants with a logged refusal instead of stocking them.
Priced variants keep 'continue' + 2026 exactly as before (Steve's 2026-06-20 rule intact).
Shared guard vendored to scripts/lib/. Proven: 4/4 + 3/3 + P8 policy 2/2.
SOURCE-ONLY. Reversible: git revert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Files touched
A scripts/lib/inventory-stamp-guard.mjsM scripts/push_shopify.js
Diff
commit 3e2c3fc6afb961cc26c3dce68f5b660fddb207d6
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Sep 10 09:44:39 2026 -0700
guard TK-11357 Fix D: sister-parish push_shopify.js — don't mint/stock a $0 variant as CONTINUE
Found by widening the writer enumeration to REST inventory_levels endpoints (my first sweep was
GraphQL-mutation-only, the same blind spot this lineage has hit before).
PRE-EXISTING GUARD STATE: none. buildProductPayload() prices every variant `v.dw_retail.toFixed(2)`
straight from the input feed with no price check, sets inventory_policy 'continue', and the caller
then stamps a flat 2026 on every created variant via /inventory_levels/set.json. A 0/absent
vendor_retail (dw_retail = vendor_retail / 0.85) therefore mints a $0 variant that is CONTINUE --
orderable at ANY quantity INCLUDING ZERO, so the qty->0 remedy cannot fix it.
FIX: inventory_policy comes from the guard ('deny' when the safe quantity is 0, caller's 'continue'
otherwise), and the stock loop stamps the guard's per-variant safe quantity computed from the price
Shopify actually landed -- skipping $0 variants with a logged refusal instead of stocking them.
Priced variants keep 'continue' + 2026 exactly as before (Steve's 2026-06-20 rule intact).
Shared guard vendored to scripts/lib/. Proven: 4/4 + 3/3 + P8 policy 2/2.
SOURCE-ONLY. Reversible: git revert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
scripts/lib/inventory-stamp-guard.mjs | 78 +++++++++++++++++++++++++++++++++++
scripts/push_shopify.js | 51 ++++++++++++++++++++---
2 files changed, 124 insertions(+), 5 deletions(-)
diff --git a/scripts/lib/inventory-stamp-guard.mjs b/scripts/lib/inventory-stamp-guard.mjs
new file mode 100644
index 0000000..195b65f
--- /dev/null
+++ b/scripts/lib/inventory-stamp-guard.mjs
@@ -0,0 +1,78 @@
+// VENDORED COPY — canonical source: Designer-Wallcoverings/shopify/scripts/lib/inventory-stamp-guard.mjs
+// Vendored (not cross-repo-imported): this repo runs independently; a cross-repo relative import
+// would hard-crash if either tree moved. KEEP IN SYNC — the TK-11357 harness hashes every copy.
+// 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_shopify.js b/scripts/push_shopify.js
index 0984d6a..db3f00f 100644
--- a/scripts/push_shopify.js
+++ b/scripts/push_shopify.js
@@ -60,6 +60,39 @@ async function shopify(method, pathSuffix, body) {
return json;
}
+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 ────────────────────────────────────────────
+
function buildProductPayload(p) {
// Option axes — collapse to (Pattern, Material, Size) like the source.
const allOpts = p.variants.map(v => ({ o1: v.option1, o2: v.option2, o3: v.option3 }));
@@ -77,7 +110,10 @@ function buildProductPayload(p) {
price: v.dw_retail.toFixed(2),
compare_at_price: v.vendor_retail.toFixed(2),
inventory_management: 'shopify',
- inventory_policy: 'continue',
+ // GUARD TK-11357: dw_retail comes straight from the input feed with no price check, so a
+ // 0/absent vendor_retail minted a $0 variant with policy CONTINUE — orderable at ANY
+ // quantity INCLUDING ZERO, which defeats the qty->0 remedy for this defect class.
+ inventory_policy: safePolicy({ vendor: 'Sister Parish', tags: [] }, { title: v.option3 || v.option1, price: v.dw_retail }, 'continue', 2026),
requires_shipping: true,
taxable: true,
weight: (v.grams || 0) / 1000,
@@ -134,6 +170,8 @@ async function setVariantInventory(variantId, qty) {
await shopify('POST', '/inventory_levels/connect.json', { location_id: locationId, inventory_item_id: inventoryItemId });
} catch (e) { /* already connected — ignore */ }
// 4) Set qty
+ // GUARD TK-11357: never stamp positive stock without a price — callers must pass the guard's
+ // safe quantity (a $0 variant reaching this with a positive qty is the whole defect class).
await shopify('POST', '/inventory_levels/set.json', {
location_id: locationId, inventory_item_id: inventoryItemId, available: qty
});
@@ -160,13 +198,16 @@ async function setVariantInventory(variantId, qty) {
created.push({ sp_id: p.sp_product_id, sf_id: prod.id, handle: prod.handle, variants: prod.variants.length });
console.log(`${stamp} ✓ ${p.title} → SF #${prod.id} (${prod.variants.length} variants, ${prod.images.length} images)`);
- // Set inventory qty=2026 on every variant (per standing rule)
- for (const v of prod.variants) {
+ // Set inventory qty=2026 on every PRICED variant (standing rule), per GUARD TK-11357:
+ // stock from the price Shopify actually landed, never a flat 2026 — a $0 variant gets 0.
+ for (const q of safeQuantities({ vendor: 'Sister Parish', tags: [] },
+ prod.variants.map(v => ({ title: v.option3 || v.option1, price: v.price, inventoryItem: { id: v.id } })), null, 2026)) {
+ if (q.quantity === 0) { console.log(` · ⛔ variant ${q.inventoryItemId}: $0 — NOT stocked (TK-11357 guard)`); continue; }
try {
- await setVariantInventory(v.id, 2026);
+ await setVariantInventory(q.inventoryItemId, q.quantity);
await sleep(RATE_DELAY_MS);
} catch (invErr) {
- console.log(` · variant ${v.id} inventory set failed: ${invErr.message.slice(0,140)}`);
+ console.log(` · variant ${q.inventoryItemId} inventory set failed: ${invErr.message.slice(0,140)}`);
}
}
} catch (e) {
← 22f4fcc importer: map global.width metafield (Carnegie-class fix)
·
back to Sister Parish Onboarding
·
governance(TK-11370): make live-by-default Shopify writers d 4322d1d →