[object Object]

← back to Designer Wallcoverings

fix: add weight handling to cadence-import.js create+activate paths (TK-11495)

8722ce1e7de7ad9839cc9905d9daf88bb9dcbe9c · 2026-09-12 04:06:11 -0700 · steve@designerwallcoverings.com

- Require weight-guard.mjs (copied from designerwallcoverings/scripts/lib) at top
- buildInput(): resolveWeightLb() on both roll+sample variants → inventoryItem.measurement.weight
- buildInput(): custom.weight_source=DEFAULTED metafield (TK-11496 provenance)
- buildInput(): willActivate log shows actual lb values + DEFAULTED note
- buildSampleOnlyInput(): SAMPLE_WEIGHT_LB (0.25lb) on sample variant measurement
- buildSampleOnlyInput(): custom.weight_source=DEFAULTED metafield (TK-11496)

Pattern: mirrors stout-onboard/build-payloads.mjs + sanderson-onboard weight-guard usage.
No product created by cadence-import can now reach Shopify at zero weight.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files touched

Diff

commit 8722ce1e7de7ad9839cc9905d9daf88bb9dcbe9c
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Sat Sep 12 04:06:11 2026 -0700

    fix: add weight handling to cadence-import.js create+activate paths (TK-11495)
    
    - Require weight-guard.mjs (copied from designerwallcoverings/scripts/lib) at top
    - buildInput(): resolveWeightLb() on both roll+sample variants → inventoryItem.measurement.weight
    - buildInput(): custom.weight_source=DEFAULTED metafield (TK-11496 provenance)
    - buildInput(): willActivate log shows actual lb values + DEFAULTED note
    - buildSampleOnlyInput(): SAMPLE_WEIGHT_LB (0.25lb) on sample variant measurement
    - buildSampleOnlyInput(): custom.weight_source=DEFAULTED metafield (TK-11496)
    
    Pattern: mirrors stout-onboard/build-payloads.mjs + sanderson-onboard weight-guard usage.
    No product created by cadence-import can now reach Shopify at zero weight.
    
    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 shopify/scripts/cadence/cadence-import.js |  20 +++-
 shopify/scripts/lib/weight-guard.mjs      | 168 ++++++++++++++++++++++++++++++
 2 files changed, 185 insertions(+), 3 deletions(-)

diff --git a/shopify/scripts/cadence/cadence-import.js b/shopify/scripts/cadence/cadence-import.js
index d06555e8..6ddd63db 100644
--- a/shopify/scripts/cadence/cadence-import.js
+++ b/shopify/scripts/cadence/cadence-import.js
@@ -32,6 +32,7 @@ const VENDORS = require('./vendors.js');
 const { validateBeforeActivate } = require('../lib/validate-before-activate.js'); // single activation gate (Steve 2026-06-20)
 const runLog = require('../lib/scraper-run-log.js'); // TK-10374: per-run ground-truth for scraper liveness (writes scraper_run_log)
 const { safeStampQuantity } = require('../lib/inventory-stamp-guard.mjs'); // TK-10965: never stock a $0/quote-only sellable variant
+const { resolveWeightLb, isSampleVariant, defaultWeightLb, SAMPLE_WEIGHT_LB } = require('../lib/weight-guard.mjs'); // TK-11495: weight on CREATE+ACTIVATE paths (zero-weight = free-shipping mis-cost)
 // Canonical showroom-vendor primitive (list + logic live in fix-live-board/config). Showroom-only
 // vendors are addressable-not-discoverable: never add the New Arrivals / Trending discoverability
 // tags or auto-membership. Never hardcode a vendor — edit showroom-vendors.json. TK-11186.
@@ -548,6 +549,7 @@ function buildSampleOnlyInput(vendor, cfg, row, activate) {
   if (row.pattern_repeat){ m('global','repeat',row.pattern_repeat,SL); }
   if (row.collection) m('custom','collection_name',row.collection,SL);
   m('global','unit_of_measure','Sample Only',SL);   // no priced roll yet — sample-only listing
+  m('custom','weight_source','DEFAULTED',SL); // TK-11496: DEFAULTED provenance (no vendor weight in catalog)
 
   // Activation gate — sample-only has a SINGLE Sample variant (no roll). The gate treats
   // unitOfMeasure as not-vendor-provided (sample-only has no roll UOM) so it isn't required.
@@ -582,7 +584,8 @@ function buildSampleOnlyInput(vendor, cfg, row, activate) {
     metafields: mf,
     productOptions: [{ name:'Title', position:1, values:[{name:'Sample'}] }],
     variants: [
-      { optionValues:[{optionName:'Title',name:'Sample'}], price:SAMPLE_PRICE, sku:`${row.dw_sku}-Sample`, inventoryItem:{sku:`${row.dw_sku}-Sample`,tracked:false}, inventoryPolicy:'CONTINUE', taxable:true },
+      // TK-11495: weight added so sample-only products are never created at zero weight. DEFAULTED provenance (TK-11496).
+      { optionValues:[{optionName:'Title',name:'Sample'}], price:SAMPLE_PRICE, sku:`${row.dw_sku}-Sample`, inventoryItem:{sku:`${row.dw_sku}-Sample`,tracked:false,measurement:{weight:{value:SAMPLE_WEIGHT_LB,unit:'POUNDS'}}}, inventoryPolicy:'CONTINUE', taxable:true },
     ],
   };
   // Attach ALL vendor images (full-page-scrape rule), primary first, de-duped.
@@ -682,6 +685,9 @@ function buildInput(vendor, cfg, row, retail, activate) {
   m('custom','cost',row.cost.toFixed(2),ND);                 // REAL cost
   m('custom','price_updated_at',TODAY,'date');                // 30-day refresh audit
   m('global','unit_of_measure',`Priced Per ${soldBy}`,SL);
+  // TK-11495+TK-11496 WEIGHT: stamp provenance so DEFAULTED is distinguishable from vendor-measured.
+  // Cadence catalog rows never carry a vendor weight — always DEFAULTED (per-product-type default).
+  m('custom','weight_source','DEFAULTED',SL); // TK-11496: dw-active-weight-canary/rollup can tell source
   // ---- Double-roll MOQ for double-roll lines (gated, inert until DW_DOUBLE_ROLL_MOQ) ----
   // theme DesignerWallcoverings-product.liquid defaults prods_quantity_order_min=1; any roll product
   // MISSING global.v_prods_quantity_order_min falls back to single-roll-orderable. Stamp 2/2 so the
@@ -712,6 +718,11 @@ function buildInput(vendor, cfg, row, retail, activate) {
   });
   const ready = gate.ok;
   const willActivate = !!(activate && ready);
+  // TK-11495 WEIGHT ACTIVATE CHECK: compute variant weights before building input.
+  // Cadence rows have no vendor-supplied weight → always DEFAULTED (product-type default lb).
+  const _sellWt = resolveWeightLb({ sku: row.dw_sku, option1: variantLabel }, { productType });
+  const _sampleWt = SAMPLE_WEIGHT_LB; // 0.25 lb fixed
+  if (willActivate) console.log(`  [weight] ${row.dw_sku}: roll=${_sellWt.toFixed(2)}lb sample=${_sampleWt.toFixed(2)}lb (DEFAULTED — no vendor weight in catalog)`);
   const input = {
     title, handle, vendor, productType,
     status: willActivate ? 'ACTIVE' : 'DRAFT',  // DRAFT unless --activate AND the full gate passes
@@ -730,8 +741,11 @@ function buildInput(vendor, cfg, row, retail, activate) {
     metafields: mf,
     productOptions: [{ name:'Title', position:1, values:[{name:variantLabel},{name:'Sample'}] }],
     variants: [
-      { optionValues:[{optionName:'Title',name:variantLabel}], price:String(retail), sku:row.dw_sku, inventoryItem:{sku:row.dw_sku,tracked:true,cost:row.cost.toFixed(2)}, inventoryPolicy:'CONTINUE', taxable:true },
-      { optionValues:[{optionName:'Title',name:'Sample'}], price:SAMPLE_PRICE, sku:`${row.dw_sku}-Sample`, inventoryItem:{sku:`${row.dw_sku}-Sample`,tracked:true}, inventoryPolicy:'CONTINUE', taxable:true },
+      // TK-11495: weight added to inventoryItem.measurement so products are never created at zero weight.
+      // resolveWeightLb always returns > 0 (per-type default when vendor has no weight). DEFAULTED provenance
+      // stamped in custom.weight_source metafield above (TK-11496).
+      { optionValues:[{optionName:'Title',name:variantLabel}], price:String(retail), sku:row.dw_sku, inventoryItem:{sku:row.dw_sku,tracked:true,cost:row.cost.toFixed(2),measurement:{weight:{value:_sellWt,unit:'POUNDS'}}}, inventoryPolicy:'CONTINUE', taxable:true },
+      { optionValues:[{optionName:'Title',name:'Sample'}], price:SAMPLE_PRICE, sku:`${row.dw_sku}-Sample`, inventoryItem:{sku:`${row.dw_sku}-Sample`,tracked:true,measurement:{weight:{value:_sampleWt,unit:'POUNDS'}}}, inventoryPolicy:'CONTINUE', taxable:true },
     ],
   };
   // Attach ALL vendor images (full-page-scrape rule), primary first, de-duped.
diff --git a/shopify/scripts/lib/weight-guard.mjs b/shopify/scripts/lib/weight-guard.mjs
new file mode 100644
index 00000000..8d8313d0
--- /dev/null
+++ b/shopify/scripts/lib/weight-guard.mjs
@@ -0,0 +1,168 @@
+// weight-guard.mjs — TK-11414 (2026-09-10) prevention primitive.
+// Steve's rule: NO product may go ACTIVE with a missing/zero product WEIGHT
+// (zero-weight collapses orders into the lowest weight tier / free band and
+// mis-costs DW freight). Mirrors the inventory-stamp-guard.mjs pattern: a pure,
+// side-effect-free module onboarders import at two call sites —
+//   1. create payload:  weight: resolveWeightLb(variant, product)  (unit POUNDS)
+//   2. before activate:  const r = await healAndVerifyWeights(gql, gid, product);
+//                       if (!r.ok) HOLD as draft (never flip ACTIVE at zero weight)
+//
+// TK-11471 (2026-09-11) — the gate and the canary DISAGREED about the invariant:
+// dw-active-weight-canary FAILs on ANY zero-weight ACTIVE variant (its last live run split the
+// offenders 195 sample / 190 sellable — i.e. SAMPLES COUNT), but zeroWeightBlockers() filtered
+// samples OUT. A product with a zero-weight SAMPLE therefore passed the gate and then turned the
+// canary red. allZeroWeightVariants() is the gate that matches the canary; zeroWeightBlockers()
+// is kept only for back-compat and is DEPRECATED.
+//
+// Defaults come straight from the approved TK-11414 backfill (samples 0.25 lb,
+// sellable per-product-type). Keep these in sync with that backfill.
+
+export const SAMPLE_WEIGHT_LB = 0.25;
+export const FALLBACK_LB = 2.0;
+
+// product_type -> sellable default weight (POUNDS)
+export const TYPE_DEFAULT_LB = {
+  'Wallcovering': 3.0, 'Wallcoverings': 3.0, 'Wallpaper': 3.0,
+  'Metallic Wallcovering': 3.0, 'Commercial Wallcovering': 3.0,
+  'Mural': 4.0,
+  'Fabric': 1.0, 'Commercial Fabric': 1.0, 'Commercial Drapery': 1.0,
+  'Trim': 0.5, 'Acoustic Panel': 6.0, 'Pillow': 1.5,
+  'Upholstered Walls/Panels': 6.0, 'Tin Ceiling Tile': 2.0,
+  'Hardware': 1.0, 'Furniture': 15.0, 'Memo Sample': 0.25,
+};
+
+const norm = t => String(t ?? '').trim().toLowerCase();
+
+/** Sample variant? (importers create `Sample` @ $4.25 + the real unit). */
+export function isSampleVariant(variant = {}) {
+  const label = norm(variant.title ?? variant.option1 ?? '');
+  const sku = norm(variant.sku);
+  if (label.includes('sample') || label.includes('memo')) return true;
+  if (sku.endsWith('-sample') || sku.includes('sample')) return true;
+  const p = Number(variant.price);
+  return Number.isFinite(p) && Math.abs(p - 4.25) < 0.01;
+}
+
+/** Current weight in lb, or 0 if missing/unparseable. Accepts variant.weight (REST),
+ *  variant.grams, or inventoryItem.measurement.weight.value (GraphQL). */
+export function currentWeightLb(variant = {}) {
+  const gql = variant?.inventoryItem?.measurement?.weight;
+  if (gql && gql.value != null) {
+    const v = Number(gql.value);
+    const u = norm(gql.unit);                       // Shopify WeightUnit enum
+    if (u === 'kilograms' || u.startsWith('kg')) return v * 2.20462;
+    if (u === 'grams' || u === 'g') return v / 453.59237;   // TK-11471: was read as POUNDS
+    if (u === 'ounces' || u === 'oz') return v / 16;        // TK-11471: was read as POUNDS
+    return v;                                       // POUNDS (and unit-less ⇒ assume lb)
+  }
+  if (variant.grams != null) return Number(variant.grams) / 453.59237;
+  if (variant.weight != null) {
+    const v = Number(variant.weight);
+    const u = norm(variant.weight_unit || 'lb');
+    if (u.startsWith('kg')) return v * 2.20462;
+    if (u === 'g' || u.startsWith('gram')) return v / 453.59237;
+    if (u === 'oz') return v / 16;
+    return v; // lb
+  }
+  return 0;
+}
+
+export function hasZeroWeight(variant = {}) {
+  const w = currentWeightLb(variant);
+  return !Number.isFinite(w) || w <= 0;
+}
+
+/** The default weight (lb) to assign a variant that has none. */
+export function defaultWeightLb(variant = {}, product = {}) {
+  if (isSampleVariant(variant)) return SAMPLE_WEIGHT_LB;
+  return TYPE_DEFAULT_LB[product.productType || product.product_type] ?? FALLBACK_LB;
+}
+
+/** THE CREATE-SIDE GUARD: keep a real positive weight, else fill the default.
+ *  Drop-in for the weight field in a create/productSet payload (returns lb). */
+export function resolveWeightLb(variant = {}, product = {}) {
+  const w = currentWeightLb(variant);
+  return (Number.isFinite(w) && w > 0) ? w : defaultWeightLb(variant, product);
+}
+
+/** Normalize a product's variants out of either a GraphQL connection or a plain array. */
+export function variantsOf(product = {}) {
+  return product.variants?.edges?.map(e => e.node) ?? product.variants ?? [];
+}
+
+/** @deprecated TK-11471 — SELLABLE-ONLY view; it filters samples OUT, so it does NOT match the
+ *  invariant dw-active-weight-canary enforces (that canary fails on ANY zero-weight ACTIVE
+ *  variant, samples included). Kept for back-compat with existing call sites.
+ *  Use allZeroWeightVariants() for any new gate. */
+export function zeroWeightBlockers(product = {}) {
+  return variantsOf(product).filter(v => !isSampleVariant(v) && hasZeroWeight(v));
+}
+
+/** THE ACTIVATE-SIDE GUARD (TK-11471): EVERY zero-weight variant, sample included.
+ *  Matches dw-active-weight-canary exactly. Non-empty ⇒ DO NOT flip the product ACTIVE. */
+export function allZeroWeightVariants(product = {}) {
+  return variantsOf(product).filter(v => hasZeroWeight(v));
+}
+
+/** The re-query a go-live site must run so the guard MEASURES something. A product query that
+ *  omits inventoryItem{measurement{weight}} makes every variant look zero-weight to
+ *  currentWeightLb — and a query that omits productType silently defaults every heal to
+ *  FALLBACK_LB. Both fields are required. */
+export const WEIGHT_REQUERY = `query($id:ID!){ product(id:$id){ productType variants(first:100){edges{node{ id sku title price inventoryItem{ id measurement{ weight{ value unit } } } }}} } }`;
+
+export const M_WEIGHT_SET = `mutation($id:ID!,$w:Float!){ inventoryItemUpdate(id:$id, input:{measurement:{weight:{value:$w, unit:POUNDS}}}){ userErrors{message} } }`;
+
+/**
+ * SELF-HEAL then VERIFY, the pattern Steve approved in sanderson-onboard/scripts/create_sdg.mjs
+ * (8d09eed) — stranding product is worse than assigning the already-approved default, but a heal
+ * that silently fails must NEVER activate.
+ *
+ *   1. every zero-weight variant (sample included) is written defaultWeightLb() in POUNDS
+ *   2. the product is RE-READ and re-checked — the mutation's own 200 is not evidence
+ *   3. ok === false  ⇒ caller must HOLD the product as draft and name `weight>0`
+ *
+ * Idempotent + no-op when all weights are already positive (zero network calls in that case).
+ * Fails CLOSED: an unreadable re-query, a missing inventoryItem id, or a userError all yield
+ * ok:false rather than a silent pass.
+ *
+ * @param {(q:string,v:object)=>Promise<any>} gql  the call site's own gql(query, variables)
+ * @param {string} productGid                      gid://shopify/Product/<id>
+ * @param {object} product                         the already-fetched product (weights + productType)
+ */
+export async function healAndVerifyWeights(gql, productGid, product = {}, opts = {}) {
+  const requery = opts.requery || WEIGHT_REQUERY;
+  const mutation = opts.mutation || M_WEIGHT_SET;
+  const errs = [], healed = [];
+  let healFailures = 0;                 // TK-11471: an UNHEALED variant is never a PASS
+  const productType = product.productType || product.product_type;
+
+  const zero = allZeroWeightVariants(product);
+  if (!zero.length) return { ok: true, healed, stillZero: [], errs };   // no-op
+
+  for (const v of zero) {
+    const iid = v?.inventoryItem?.id;
+    const label = v.sku || v.title || v.id || '?';
+    if (!iid) { healFailures++; errs.push(`weight:no-inventory-item:${label}`); continue; }
+    const lb = defaultWeightLb(v, { productType });
+    let r;
+    try { r = await gql(mutation, { id: iid, w: lb }); }
+    catch (e) { healFailures++; errs.push(`weight:${label}:${String(e && e.message || e).slice(0, 80)}`); continue; }
+    const ue = r?.inventoryItemUpdate?.userErrors || [];
+    if (ue.length) healFailures++;
+    ue.forEach(e => errs.push(`weight:${label}:${e.message}`));
+    healed.push({ sku: label, inventoryItemId: iid, lb });
+  }
+
+  // RE-VERIFY against the live record. Never trust the write.
+  let fresh;
+  try { fresh = (await gql(requery, { id: productGid }))?.product; }
+  catch (e) { errs.push(`weight:reverify:${String(e && e.message || e).slice(0, 80)}`); }
+  if (!fresh) { errs.push('weight:reverify-failed'); return { ok: false, healed, stillZero: [], errs }; }
+
+  const stillZero = allZeroWeightVariants(fresh).map(v => v.sku || v.title || v.id || '?');
+  // FAIL CLOSED on an UNHEALED variant even when the re-verify comes back clean. A variant we
+  // could not write (no inventoryItem id, a throw, a userError) is UNMEASURED with respect to our
+  // own action; a clean re-verify that happens to disagree is not licence to activate. Holding is
+  // reversible and the next run is a no-op, so the conservative branch costs nothing.
+  return { ok: stillZero.length === 0 && healFailures === 0, healed, stillZero, errs };
+}

← 1c52930d auto-data-snapshot: 2026-09-12T03:53:49 (3 data files) — sho  ·  back to Designer Wallcoverings  ·  auto-data-snapshot: 2026-09-12T05:03:08 (3 data files) — sho 98445329 →