← back to Ticket System
auto-data-snapshot: 2026-09-17T17:25:36 (9 data files) — evidence/TK-11871/TK-11871-verdict.json evidence/TK-11871/restore-map-20260917-1700/RESTORE.md evidence/TK-11871/restore-map-20260917-1700/test_weight_guard.py.ORIG evidence/TK-11871/restore-map-20260917-1700/weight-guard-DesignerWallcoverings.mjs.ORIG evidence/TK-11871/restore-map-20260917-1700/weight-guard.mjs.ORIG
018f10f5480525bfbbfc55c49082d4d54a3e49fa · 2026-09-17 17:25:37 -0700 · auto-commit-fleet
Files touched
A evidence/TK-11871/TK-11871-verdict.jsonA evidence/TK-11871/restore-map-20260917-1700/RESTORE.mdA evidence/TK-11871/restore-map-20260917-1700/test_weight_guard.py.ORIGA evidence/TK-11871/restore-map-20260917-1700/weight-guard-DesignerWallcoverings.mjs.ORIGA evidence/TK-11871/restore-map-20260917-1700/weight-guard.mjs.ORIGA evidence/TK-11871/restore-map-20260917-1700/weight-guard.test.mjs.ORIGA evidence/TK-11871/restore-map-20260917-1700/weight-guard.test.ts.ORIGA evidence/TK-11871/restore-map-20260917-1700/weight-guard.ts.ORIGA evidence/TK-11871/restore-map-20260917-1700/weight_guard.py.ORIG
Diff
commit 018f10f5480525bfbbfc55c49082d4d54a3e49fa
Author: auto-commit-fleet <steve@designerwallcoverings.com>
Date: Thu Sep 17 17:25:37 2026 -0700
auto-data-snapshot: 2026-09-17T17:25:36 (9 data files) — evidence/TK-11871/TK-11871-verdict.json evidence/TK-11871/restore-map-20260917-1700/RESTORE.md evidence/TK-11871/restore-map-20260917-1700/test_weight_guard.py.ORIG evidence/TK-11871/restore-map-20260917-1700/weight-guard-DesignerWallcoverings.mjs.ORIG evidence/TK-11871/restore-map-20260917-1700/weight-guard.mjs.ORIG
---
evidence/TK-11871/TK-11871-verdict.json | 10 +
.../TK-11871/restore-map-20260917-1700/RESTORE.md | 36 +++
.../test_weight_guard.py.ORIG | 253 +++++++++++++++++
.../weight-guard-DesignerWallcoverings.mjs.ORIG | 168 ++++++++++++
.../weight-guard.mjs.ORIG | 168 ++++++++++++
.../weight-guard.test.mjs.ORIG | 206 ++++++++++++++
.../weight-guard.test.ts.ORIG | 96 +++++++
.../restore-map-20260917-1700/weight-guard.ts.ORIG | 74 +++++
.../restore-map-20260917-1700/weight_guard.py.ORIG | 299 +++++++++++++++++++++
9 files changed, 1310 insertions(+)
diff --git a/evidence/TK-11871/TK-11871-verdict.json b/evidence/TK-11871/TK-11871-verdict.json
new file mode 100644
index 00000000..d8cb4230
--- /dev/null
+++ b/evidence/TK-11871/TK-11871-verdict.json
@@ -0,0 +1,10 @@
+{
+ "ticket": "TK-11871",
+ "action": "TYPE_DEFAULT_LB Wallcovering family 3.0 -> 2.0 across guard sync-group (mjs x2, py, ts) + tests aligned (mjs/py/ts) + 2 comment corrections (batch-import-jobs.ts, dwpw-grs-migrate.py)",
+ "class": "go-forward code default; NO Shopify/dw_unified writes; NOT customer-facing (no historical re-stamp)",
+ "rationale": "TK-11803 DTD verdict A + Cody red-team ratified 2.0 = live catalog norm (91.9% of 2,299-sample); TK-11871 is the sanctioned split-out; ticket text: 'align the guard to catalog 2.0' is an explicitly allowed resolution",
+ "blast_radius": "new Wallcovering-type creates/heals only (Morris/Zoffany active importers use vendor scripts at 4lb; cadence 15-18 rows/2x daily)",
+ "tests": {"py": "14/14 OK", "mjs": "52/52 PASS", "jest_weight_guard_ts": "10/10", "jest_batch_import_gate": "36/36", "tsc": "clean", "cadence_syntax": "OK"},
+ "restore_map": "evidence/TK-11871/restore-map-20260917-1700/",
+ "date": "2026-09-17T17:35:00-07:00"
+}
diff --git a/evidence/TK-11871/restore-map-20260917-1700/RESTORE.md b/evidence/TK-11871/restore-map-20260917-1700/RESTORE.md
new file mode 100644
index 00000000..d906e1e1
--- /dev/null
+++ b/evidence/TK-11871/restore-map-20260917-1700/RESTORE.md
@@ -0,0 +1,36 @@
+# TK-11871 Restore Map — TYPE_DEFAULT_LB Wallcovering 3.0 → 2.0 (2026-09-17 ~17:20 PDT)
+
+## What changed
+Wallcovering-family per-type default weight in the weight-guard sync-group:
+3.0 → 2.0 lb (catalog norm; TK-11803 DTD verdict A, 91.9% of ACTIVE Wallcovering
+sellable variants). Revert = set the family back to 3.0.
+
+## Files changed (8)
+| File | Change | Restore |
+|---|---|---|
+| ~/Projects/designerwallcoverings/scripts/lib/weight-guard.mjs | Wallcovering family 3.0→2.0 + TK-11871 comment above export | weight-guard.mjs.ORIG |
+| ~/Projects/Designer-Wallcoverings/shopify/scripts/lib/weight-guard.mjs | same (bit-identical twin) | weight-guard-DesignerWallcoverings.mjs.ORIG |
+| ~/Projects/designerwallcoverings/scripts/lib/weight_guard.py | same (python twin) | weight_guard.py.ORIG |
+| ~/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/lib/weight-guard.ts | same (ts mirror) | weight-guard.ts.ORIG |
+| ~/Projects/designerwallcoverings/scripts/lib/weight-guard.test.mjs | 3 assertions 3→2 (+1 new literal-2.0 case) | weight-guard.test.mjs.ORIG |
+| ~/Projects/designerwallcoverings/scripts/lib/test_weight_guard.py | 2 assertions + 1 derived-weight 3→2 | test_weight_guard.py.ORIG |
+| ~/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/__tests__/weight-guard.test.ts | 4 assertions 3→2 | weight-guard.test.ts.ORIG |
+| ~/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/lib/batch-import-jobs.ts | comment-only ("2.0 lb default (TK-11871)") | not versioned in this dir (uncommitted TK-11786 work present); change is a comment — revert text "2.0 lb Wallcovering default (TK-11871: catalog norm; was 3.0)" → "3.0 lb Wallcovering default" |
+| ~/Projects/designerwallcoverings/scripts/dwpw-grs-migrate.py | comment-only ("2.0 lb — TK-11871 catalog norm, was 3.0") | restore via git checkout (file committed at 4d31a1a/67d7eb3; docstring edit only) |
+
+## Restore commands
+cp <file>.ORIG back over the live file (paths above), or:
+ cd ~/Projects/designerwallcoverings && git checkout -- scripts/lib/weight-guard.mjs scripts/lib/weight_guard.py scripts/lib/weight-guard.test.mjs scripts/lib/test_weight_guard.py scripts/dwpw-grs-migrate.py
+ cd ~/Projects/Designer-Wallcoverings && git checkout -- shopify/scripts/lib/weight-guard.mjs DW-Programming/ImportNewSkufromURL/lib/weight-guard.ts DW-Programming/ImportNewSkufromURL/__tests__/weight-guard.test.ts
+(batch-import-jobs.ts has PRE-EXISTING uncommitted TK-11786 work — do NOT git-checkout it; hand-revert only the one comment line.)
+
+## Scope invariants (verified)
+- Samples untouched: 0.25 lb everywhere (SAMPLE_WEIGHT_LB unchanged).
+- FALLBACK_LB unchanged at 2.0 (now == Wallcovering default, so unknown-type and Wallcovering agree).
+- Non-Wallcovering types unchanged (Fabric 1, Mural 4, Trim 0.5, Acoustic Panel 6, Pillow 1.5, Upholstered Walls 6, Tin 2, Hardware 1, Furniture 15, Memo 0.25).
+- NO Shopify writes. NO dw_unified writes. Local code + tests only.
+
+## Verification at apply time
+- py suite 14/14 OK · mjs suite 52/52 PASS · jest weight-guard 10/10 · jest batch-import-gate 36/36 (pre-existing TK-11786 state) · tsc --noEmit clean · cadence-import.js node --check OK
+- mjs twins shasum-identical after edit (350399246aa8c5150c0cc837daa244cfae38b306)
+- Derived consumers verified: dwpw-grs-migrate._WEIGHT_SELLABLE_LB=2.0, cadence resolveWeightLb→2, sanderson build-payloads→2, batch-import sellableWeightLb→2, tk11471 backfill target→2
diff --git a/evidence/TK-11871/restore-map-20260917-1700/test_weight_guard.py.ORIG b/evidence/TK-11871/restore-map-20260917-1700/test_weight_guard.py.ORIG
new file mode 100644
index 00000000..941bfed5
--- /dev/null
+++ b/evidence/TK-11871/restore-map-20260917-1700/test_weight_guard.py.ORIG
@@ -0,0 +1,253 @@
+#!/usr/bin/env python3
+"""test_weight_guard.py — TK-11471. OFFLINE: zero network, zero DB, zero cost.
+
+ python3 scripts/lib/test_weight_guard.py
+
+CLAUDE.md TK-11431 amendment 3: a check ships with a NEGATIVE test proving it goes
+red on an injected fault, or it does not ship.
+
+Covers the Python guard (lib/weight_guard.py) AND the real publish path in
+dwpw-grs-migrate.py — the job that actually created 80 zero-weight ACTIVE variants
+on 2026-09-11 (all 40 products appear in dwpw-grs-migrate.lastrun.json with
+result=PUBLISHED_NO_TWIN, 37 ensure=create + 3 ensure=update).
+"""
+import importlib.util
+import os
+import tempfile
+import sys
+import unittest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+SCRIPTS = os.path.dirname(HERE)
+sys.path.insert(0, HERE)
+
+import weight_guard as wg # noqa: E402
+
+MJS = os.path.join(HERE, "weight-guard.mjs")
+
+
+def sellable(**kw):
+ v = {"id": "gid://shopify/ProductVariant/1", "sku": "GRS-27580", "title": "Per Yard",
+ "price": "63.00"}
+ v.update(kw)
+ return v
+
+
+def sample(**kw):
+ v = {"id": "gid://shopify/ProductVariant/2", "sku": "GRS-27580-Sample", "title": "Sample",
+ "price": "4.25"}
+ v.update(kw)
+ return v
+
+
+def node(vid, sku, title, lb, iid=None, drop_field=False):
+ inv = {"id": iid or ("gid://shopify/InventoryItem/%s" % vid)}
+ if not drop_field:
+ inv["measurement"] = None if lb is None else {"weight": {"value": lb, "unit": "POUNDS"}}
+ return {"id": "gid://shopify/ProductVariant/%s" % vid, "sku": sku, "title": title,
+ "price": "4.25" if "Sample" in title else "63.00", "inventoryItem": inv}
+
+
+WALLCOVERING = {"product_type": "Wallcovering"}
+
+
+class TestDetector(unittest.TestCase):
+ def test_injected_zero_weight_sellable_is_flagged(self):
+ p = {"productType": "Wallcovering",
+ "variants": [node(1, "GRS-27580", "Per Yard", 0), node(2, "GRS-27580-Sample", "Sample", 0.25)]}
+ self.assertEqual([v["sku"] for v in wg.all_zero_weight_variants(p)], ["GRS-27580"])
+
+ def test_injected_zero_weight_SAMPLE_is_flagged(self):
+ """dw-active-weight-canary FAILs on a zero-weight SAMPLE too (its live run split
+ the offenders 43 sample / 40 sellable). The .mjs zeroWeightBlockers() filters
+ samples out; that bug must not exist here."""
+ p = {"productType": "Wallcovering",
+ "variants": [node(1, "GRS-27580", "Per Yard", 3.0), node(2, "GRS-27580-Sample", "Sample", 0)]}
+ self.assertEqual([v["sku"] for v in wg.all_zero_weight_variants(p)], ["GRS-27580-Sample"])
+
+ def test_missing_weight_field_is_flagged_fails_safe(self):
+ p = {"productType": "Wallcovering", "variants": [sellable(), sample()]}
+ self.assertEqual(len(wg.all_zero_weight_variants(p)), 2)
+ self.assertEqual(len(wg.unmeasured_variants(p)), 2)
+
+ def test_none_and_junk_weight_are_flagged(self):
+ self.assertTrue(wg.has_zero_weight(sellable(weight=None)))
+ self.assertTrue(wg.has_zero_weight(sellable(weight="junk", weight_unit="lb")))
+ self.assertTrue(wg.has_zero_weight(node(1, "GRS-1", "Per Yard", None)))
+
+ def test_units_convert_and_positive_is_not_flagged(self):
+ self.assertAlmostEqual(wg.current_weight_lb(node(1, "x", "Per Yard", 1361, )), 1361) # POUNDS as given
+ self.assertAlmostEqual(wg.current_weight_lb(sellable(weight=1361, weight_unit="g")), 3.0, places=2)
+ self.assertAlmostEqual(wg.current_weight_lb(sellable(weight=1.36, weight_unit="kg")), 2.998, places=2)
+ self.assertAlmostEqual(wg.current_weight_lb(sellable(weight=48, weight_unit="oz")), 3.0, places=3)
+ self.assertFalse(wg.has_zero_weight(sellable(weight=1361, weight_unit="g")))
+ v = {"id": "v", "sku": "x", "title": "Per Yard",
+ "inventoryItem": {"id": "i", "measurement": {"weight": {"value": 1361, "unit": "GRAMS"}}}}
+ self.assertAlmostEqual(wg.current_weight_lb(v), 3.0, places=2)
+ self.assertFalse(wg.has_zero_weight(v))
+
+ def test_all_weighted_returns_empty(self):
+ p = {"productType": "Wallcovering",
+ "variants": [node(1, "GRS-27580", "Per Yard", 3.0), node(2, "GRS-27580-Sample", "Sample", 0.25)]}
+ self.assertEqual(wg.all_zero_weight_variants(p), [])
+
+ def test_resolve_preserves_positive_and_fills_default(self):
+ self.assertAlmostEqual(wg.resolve_weight_lb(sellable(weight=7.5, weight_unit="lb"), WALLCOVERING), 7.5)
+ self.assertEqual(wg.resolve_weight_lb(sellable(), WALLCOVERING), 3.0)
+ self.assertEqual(wg.resolve_weight_lb(sample(), WALLCOVERING), 0.25)
+ self.assertEqual(wg.resolve_weight_lb(sellable(), {"product_type": "Mural"}), 4.0)
+ self.assertEqual(wg.resolve_weight_lb(sellable(), {"product_type": "Nonesuch"}), 2.0)
+
+ @unittest.skipUnless(os.path.exists(MJS), "weight-guard.mjs missing")
+ def test_constants_match_the_mjs_twin(self):
+ with open(MJS) as fh:
+ src = fh.read()
+ self.assertIn("SAMPLE_WEIGHT_LB = 0.25", src)
+ self.assertIn("FALLBACK_LB = 2.0", src)
+ table = src.split("TYPE_DEFAULT_LB = {", 1)[1].split("};", 1)[0]
+ pairs = {}
+ for chunk in table.replace("\n", " ").split(","):
+ if ":" not in chunk:
+ continue
+ k, v = chunk.split(":", 1)
+ try:
+ pairs[k.strip().strip("'\"")] = float(v.strip().rstrip(","))
+ except ValueError:
+ pass
+ self.assertEqual(pairs, wg.TYPE_DEFAULT_LB, "TYPE_DEFAULT_LB drifted from weight-guard.mjs")
+
+
+def load_migrate():
+ os.environ.setdefault("SHOPIFY_FULL_ACCESS_TOKEN", "test-token-not-real")
+ spec = importlib.util.spec_from_file_location("dwpw_grs_migrate",
+ os.path.join(SCRIPTS, "dwpw-grs-migrate.py"))
+ m = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(m)
+ return m
+
+
+class TestMigrateWeightsAreStamped(unittest.TestCase):
+ def test_create_payload_weights_come_from_the_guard(self):
+ m = load_migrate()
+ self.assertEqual(m._WEIGHT_SELLABLE_LB, 3.0) # Wallcovering default
+ self.assertEqual(m._WEIGHT_SAMPLE_LB, 0.25) # sample default
+ self.assertGreater(m._WEIGHT_SELLABLE_LB, 0)
+ self.assertGreater(m._WEIGHT_SAMPLE_LB, 0)
+
+ def test_every_variant_mutation_carries_a_weight(self):
+ """INJECTED-FAULT SHAPE CHECK on the real shipped source: no
+ productVariantsBulk{Create,Update} may send an inventoryItem without a
+ measurement.weight — that is exactly how 80 variants shipped at zero."""
+ with open(os.path.join(SCRIPTS, "dwpw-grs-migrate.py")) as fh:
+ src = fh.read()
+ chunks = [c for c in src.split('"inventoryItem": {')[1:]]
+ self.assertGreaterEqual(len(chunks), 4, "expected >=4 variant inventoryItem payloads")
+ for c in chunks:
+ head = c[:260]
+ self.assertIn("measurement", head,
+ "an inventoryItem variant payload ships with no weight:\n" + head)
+
+
+class TestMigratePublishGate(unittest.TestCase):
+ """Mocked harness over the REAL process() apply path. No network, no Shopify."""
+
+ ROW = {"grs": "GRS-27580", "mfr": "T-1", "title": "Oliva Khaki Grasscloth Wallcovering",
+ "image": "https://example.invalid/x.jpg", "dw_price": "63.00", "cost_yd": "21.00"}
+
+ def _harness(self, heal_ok):
+ m = load_migrate()
+ state = {"weights": {"gid://shopify/InventoryItem/1": 0.0,
+ "gid://shopify/InventoryItem/2": 0.0},
+ "published": False, "archived": False, "mutations": []}
+
+ def fake_gql(query, variables=None):
+ variables = variables or {}
+ if "inventoryItemUpdate" in query:
+ state["mutations"].append((variables["id"], variables["w"]))
+ if not heal_ok:
+ return {"data": {"inventoryItemUpdate":
+ {"userErrors": [{"message": "injected heal failure"}]}}}
+ state["weights"][variables["id"]] = variables["w"]
+ return {"data": {"inventoryItemUpdate": {"userErrors": []}}}
+ # WEIGHT_REQUERY — the response DOES carry the weight field
+ edges = []
+ for iid, lb in state["weights"].items():
+ is_sample = iid.endswith("/2")
+ edges.append({"node": node(iid.rsplit("/", 1)[-1],
+ "GRS-27580-Sample" if is_sample else "GRS-27580",
+ "Sample" if is_sample else "Per Yard",
+ None if lb <= 0 else lb, iid=iid)})
+ return {"data": {"product": {"productType": "Wallcovering",
+ "variants": {"edges": edges}}}}
+
+ def fake_publish(pid, grs, prev_status):
+ state["published"] = True
+
+ m.gql = fake_gql
+ m.find_grs = lambda grs: None
+ m.http_status = lambda url, method="GET": 200
+ m.find_active_dwpw_twin = lambda mfr: []
+ m.create_grs = lambda row, image_ok, res: ("gid://shopify/Product/9", "oliva-khaki")
+ m.verify_read = lambda pid, grs, **kw: {"variants": [sellable(), sample()]}
+ m.verify_image = lambda pid, url, **kw: (True, "READY", ["READY"])
+ m.publish_active = fake_publish
+ m.archive_dwpw = lambda twin, grs: state.__setitem__("archived", True)
+ # HERMETICITY (fixed 2026-09-12): require_no_prior_creation() reads the SHARED, LIVE,
+ # append-only executed-reversible ledger. The fixture SKU GRS-27580 is a REAL sku, so once
+ # the live migration recorded creation receipts for it the guard began raising HISTORY_HOLD
+ # and these 4 tests errored — they passed on 2026-09-11 and failed on 2026-09-12 with ZERO
+ # code change between, purely because production state moved underneath them.
+ #
+ # That is the same defect class this whole test file exists to prevent, one level up: a
+ # check whose verdict is decided by something it does not actually measure. A test that can
+ # silently flip on unrelated live activity is worthless as a signal in BOTH directions —
+ # it can go red on healthy code, and it can be dismissed as "just that flaky one" when it
+ # goes red for a real reason.
+ #
+ # Point the guard at an EMPTY temp ledger. The guard itself is NOT stubbed out — it still
+ # runs for real against a controlled input, so a genuine duplicate-identity regression is
+ # still caught; only the shared live file is swapped for a deterministic one.
+ # (Verified while fixing: GRS-27580 has exactly ONE live product, so the 3 ledger receipts
+ # were historical/archived, not a real customer-facing duplicate.)
+ tmp = tempfile.NamedTemporaryFile("w", suffix=".jsonl", delete=False)
+ tmp.close()
+ self.addCleanup(os.unlink, tmp.name)
+ m.LEDGER = tmp.name
+ return m, state
+
+ def test_zero_weight_product_is_HEALED_then_PUBLISHED(self):
+ m, state = self._harness(heal_ok=True)
+ res = m.process(dict(self.ROW), apply=True)
+ self.assertEqual(res["result"], "PUBLISHED_NO_TWIN", res)
+ self.assertTrue(state["published"])
+ self.assertEqual(sorted(w for _, w in state["mutations"]), [0.25, 3.0])
+
+ def test_product_whose_heal_FAILS_is_NOT_PUBLISHED(self):
+ m, state = self._harness(heal_ok=False)
+ res = m.process(dict(self.ROW), apply=True)
+ self.assertEqual(res["result"], "LEFT_DRAFT_NEEDS_WEIGHT", res)
+ self.assertFalse(state["published"], "a zero-weight product must NEVER be published ACTIVE")
+ self.assertFalse(state["archived"], "the DWPW twin must not be archived on a held GRS")
+ self.assertTrue(any("weight>0" in r for r in res["weight_block"]), res.get("weight_block"))
+
+ def test_gate_fails_closed_when_the_requery_carries_no_weight_field(self):
+ """MEASURE WHAT YOU CLAIM — a response that never contained weight must not
+ be read as a clean pass."""
+ m, state = self._harness(heal_ok=True)
+ m.gql = lambda q, v=None: {"data": {"product": {"productType": "Wallcovering", "variants": {
+ "edges": [{"node": node(1, "GRS-27580", "Per Yard", 0, drop_field=True)}]}}}}
+ res = m.process(dict(self.ROW), apply=True)
+ self.assertEqual(res["result"], "LEFT_DRAFT_NEEDS_WEIGHT", res)
+ self.assertFalse(state["published"])
+
+ def test_gate_is_a_noop_when_weights_are_already_positive(self):
+ m, state = self._harness(heal_ok=True)
+ state["weights"] = {"gid://shopify/InventoryItem/1": 3.0,
+ "gid://shopify/InventoryItem/2": 0.25}
+ res = m.process(dict(self.ROW), apply=True)
+ self.assertEqual(res["result"], "PUBLISHED_NO_TWIN", res)
+ self.assertEqual(state["mutations"], [], "idempotent: no heal writes when already weighted")
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/evidence/TK-11871/restore-map-20260917-1700/weight-guard-DesignerWallcoverings.mjs.ORIG b/evidence/TK-11871/restore-map-20260917-1700/weight-guard-DesignerWallcoverings.mjs.ORIG
new file mode 100644
index 00000000..8d8313d0
--- /dev/null
+++ b/evidence/TK-11871/restore-map-20260917-1700/weight-guard-DesignerWallcoverings.mjs.ORIG
@@ -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 };
+}
diff --git a/evidence/TK-11871/restore-map-20260917-1700/weight-guard.mjs.ORIG b/evidence/TK-11871/restore-map-20260917-1700/weight-guard.mjs.ORIG
new file mode 100644
index 00000000..8d8313d0
--- /dev/null
+++ b/evidence/TK-11871/restore-map-20260917-1700/weight-guard.mjs.ORIG
@@ -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 };
+}
diff --git a/evidence/TK-11871/restore-map-20260917-1700/weight-guard.test.mjs.ORIG b/evidence/TK-11871/restore-map-20260917-1700/weight-guard.test.mjs.ORIG
new file mode 100644
index 00000000..c037c957
--- /dev/null
+++ b/evidence/TK-11871/restore-map-20260917-1700/weight-guard.test.mjs.ORIG
@@ -0,0 +1,206 @@
+#!/usr/bin/env node
+/**
+ * weight-guard.test.mjs — TK-11471. Pure, offline, zero-network, zero-DB.
+ * node scripts/lib/weight-guard.test.mjs
+ *
+ * CLAUDE.md TK-11431 amendment 3: a check ships with a NEGATIVE test proving it goes red on an
+ * injected fault, or it does not ship. Every assertion below that matters injects the fault
+ * (a zero weight, a null measurement, a failed heal) and proves the guard FLAGS it — a
+ * positive-only suite on a detector proves nothing.
+ */
+import {
+ allZeroWeightVariants, zeroWeightBlockers, currentWeightLb, hasZeroWeight,
+ resolveWeightLb, defaultWeightLb, isSampleVariant, healAndVerifyWeights,
+ SAMPLE_WEIGHT_LB, FALLBACK_LB, TYPE_DEFAULT_LB, M_WEIGHT_SET, WEIGHT_REQUERY,
+} from './weight-guard.mjs';
+
+let pass = 0, fail = 0;
+const ok = (name, cond, detail = '') => {
+ if (cond) { pass++; console.log(` ok ${name}`); }
+ else { fail++; console.error(` FAIL ${name}${detail ? ' — ' + detail : ''}`); }
+};
+const eq = (name, got, want) => ok(name, Object.is(got, want), `got ${JSON.stringify(got)} want ${JSON.stringify(want)}`);
+const near = (name, got, want, tol = 1e-4) => ok(name, Math.abs(got - want) < tol, `got ${got} want ~${want}`);
+const section = t => console.log(`\n── ${t}`);
+
+// ── fixture builders ────────────────────────────────────────────────────────────
+const gqlVariant = (sku, value, unit = 'POUNDS', extra = {}) => ({
+ id: `gid://shopify/ProductVariant/${sku}`, sku, title: extra.title ?? 'Roll',
+ price: extra.price ?? '199.00',
+ inventoryItem: { id: `gid://shopify/InventoryItem/${sku}`,
+ measurement: value === null ? null : { weight: value === undefined ? null : { value, unit } } },
+});
+const product = (variants, productType = 'Wallcovering') =>
+ ({ productType, variants: { edges: variants.map(node => ({ node })) } });
+
+// ════════════════════════════════════════════════════════════════════════════════
+section('allZeroWeightVariants — the invariant dw-active-weight-canary enforces');
+
+// NEGATIVE TEST 1: injected zero-weight SELLABLE variant must go red.
+{
+ const p = product([gqlVariant('DWTT-1001', 0), gqlVariant('DWTT-1001-Sample', 0.25, 'POUNDS', { title: 'Sample', price: '4.25' })]);
+ const flagged = allZeroWeightVariants(p);
+ eq('zero-weight SELLABLE flagged', flagged.length, 1);
+ eq(' …and it is the sellable one', flagged[0].sku, 'DWTT-1001');
+}
+
+// NEGATIVE TEST 2: injected zero-weight SAMPLE must go red. THIS IS THE TK-11471 BUG —
+// zeroWeightBlockers() filters samples out and passes, then dw-active-weight-canary turns red
+// (its last live run split the offenders 195 sample / 190 sellable, so samples DO count).
+{
+ const p = product([
+ gqlVariant('DWTT-1002', 3),
+ gqlVariant('DWTT-1002-Sample', 0, 'POUNDS', { title: 'Sample', price: '4.25' }),
+ ]);
+ const flagged = allZeroWeightVariants(p);
+ eq('zero-weight SAMPLE flagged by allZeroWeightVariants', flagged.length, 1);
+ eq(' …and it is the sample', flagged[0].sku, 'DWTT-1002-Sample');
+ // The regression this replaces — proves the OLD gate was blind to exactly this product:
+ eq('deprecated zeroWeightBlockers MISSES it (the bug)', zeroWeightBlockers(p).length, 0);
+}
+
+// NEGATIVE TEST 3: null / missing / absent measurement must FAIL SAFE (flagged, never weighted).
+{
+ const p = product([
+ gqlVariant('null-measurement', null), // inventoryItem.measurement === null
+ gqlVariant('null-weight', undefined), // measurement.weight === null
+ { id: 'v4', sku: 'no-inventoryItem', title: 'Roll', price: '10.00' }, // field absent entirely
+ { id: 'v5', sku: 'explicit-null-weight', weight: null, weight_unit: 'lb' },
+ ]);
+ eq('null/missing/absent measurement all flagged', allZeroWeightVariants(p).length, 4);
+ eq('currentWeightLb(null measurement) === 0', currentWeightLb(gqlVariant('x', null)), 0);
+ ok('hasZeroWeight({}) fails safe', hasZeroWeight({}));
+}
+
+// POSITIVE CONTROL: an all-weighted product must be empty (and the detector must not cry wolf).
+{
+ const p = product([gqlVariant('DWTT-1003', 3), gqlVariant('DWTT-1003-Sample', 0.25, 'POUNDS', { title: 'Sample', price: '4.25' })]);
+ eq('all-weighted product => empty result', allZeroWeightVariants(p).length, 0);
+}
+
+section('unit conversion');
+{
+ near('kilograms converts to lb', currentWeightLb(gqlVariant('kg', 1.36, 'KILOGRAMS')), 2.99828);
+ ok('positive KG weight is NOT flagged', !hasZeroWeight(gqlVariant('kg', 1.36, 'KILOGRAMS')));
+ eq('a 0-value KG weight IS flagged', allZeroWeightVariants(product([gqlVariant('kg0', 0, 'KILOGRAMS')])).length, 1);
+ // TK-11471 fix: GRAMS/OUNCES used to fall through and be read as POUNDS (1360 g -> "1360 lb").
+ near('grams converts to lb', currentWeightLb(gqlVariant('g', 1360, 'GRAMS')), 2.99828);
+ near('ounces converts to lb', currentWeightLb(gqlVariant('oz', 48, 'OUNCES')), 3);
+ near('REST variant.weight lb passthrough', currentWeightLb({ weight: 3, weight_unit: 'lb' }), 3);
+ near('REST variant.grams converts', currentWeightLb({ grams: 1360 }), 2.99828);
+}
+
+section('resolveWeightLb (create-side)');
+{
+ near('preserves an existing positive weight', resolveWeightLb({ weight: 7.5, weight_unit: 'lb' }, { productType: 'Wallcovering' }), 7.5);
+ near('preserves a positive GQL weight', resolveWeightLb(gqlVariant('p', 4.2), { productType: 'Wallcovering' }), 4.2);
+ near('fills the per-type default (Wallcovering=3)', resolveWeightLb({ sku: 'DWX-1', option1: 'Roll' }, { productType: 'Wallcovering' }), TYPE_DEFAULT_LB['Wallcovering']);
+ near('fills the per-type default (Mural=4)', resolveWeightLb({ sku: 'DWX-2', option1: 'Roll' }, { productType: 'Mural' }), TYPE_DEFAULT_LB['Mural']);
+ near('fills the per-type default (Fabric=1)', resolveWeightLb({ sku: 'DWX-3', option1: 'Yard' }, { productType: 'Fabric' }), TYPE_DEFAULT_LB['Fabric']);
+ near('unknown product_type => FALLBACK_LB', resolveWeightLb({ sku: 'DWX-4', option1: 'Roll' }, { productType: 'Nonesuch' }), FALLBACK_LB);
+ near('sample => SAMPLE_WEIGHT_LB', resolveWeightLb({ sku: 'DWX-5-Sample', option1: 'Sample' }, { productType: 'Wallcovering' }), SAMPLE_WEIGHT_LB);
+ near('$4.25 memo detected as sample', resolveWeightLb({ sku: 'DWX-6', option1: 'Memo', price: '4.25' }, { productType: 'Wallcovering' }), SAMPLE_WEIGHT_LB);
+ near('zero weight => default, not 0', resolveWeightLb({ weight: 0, weight_unit: 'lb', sku: 'DWX-7', option1: 'Roll' }, { productType: 'Wallcovering' }), 3);
+ ok('isSampleVariant(-Sample sku)', isSampleVariant({ sku: 'DWX-8-Sample' }));
+ ok('isSampleVariant(Roll) false', !isSampleVariant({ sku: 'DWX-8', title: 'Roll', price: '199.00' }));
+ near('defaultWeightLb honors product_type snake_case too', defaultWeightLb({ sku: 'r', option1: 'Roll' }, { product_type: 'Mural' }), 4);
+}
+
+// ════════════════════════════════════════════════════════════════════════════════
+section('healAndVerifyWeights — SELF-HEAL then VERIFY (offline mock gql)');
+
+/** Mock gql. `healOutcome` decides what the RE-QUERY returns, so we can inject a failed heal. */
+function mockGql({ before, after, failMutation = false, reverifyNull = false, throwOn = null }) {
+ const calls = [];
+ return {
+ calls,
+ gql: async (q, v) => {
+ calls.push({ q: q.includes('inventoryItemUpdate') ? 'MUTATION' : 'QUERY', v });
+ if (throwOn && q.includes(throwOn)) throw new Error('boom');
+ if (q.includes('inventoryItemUpdate')) {
+ return failMutation
+ ? { inventoryItemUpdate: { userErrors: [{ message: 'Access denied for inventoryItemUpdate' }] } }
+ : { inventoryItemUpdate: { userErrors: [] } };
+ }
+ return reverifyNull ? { product: null } : { product: after ?? before };
+ },
+ };
+}
+
+// (i) HAPPY: zero weights are healed, re-verify comes back clean, ok === true.
+{
+ const before = product([gqlVariant('DWTT-2001', 0), gqlVariant('DWTT-2001-Sample', 0, 'POUNDS', { title: 'Sample', price: '4.25' })]);
+ const after = product([gqlVariant('DWTT-2001', 3), gqlVariant('DWTT-2001-Sample', 0.25, 'POUNDS', { title: 'Sample', price: '4.25' })]);
+ const m = mockGql({ before, after });
+ const r = await healAndVerifyWeights(m.gql, 'gid://shopify/Product/1', before);
+ ok('heal succeeds => ok', r.ok === true, JSON.stringify(r));
+ eq(' both variants healed', r.healed.length, 2);
+ near(' sellable healed to the Wallcovering default', r.healed.find(h => h.sku === 'DWTT-2001').lb, 3);
+ near(' sample healed to SAMPLE_WEIGHT_LB', r.healed.find(h => h.sku === 'DWTT-2001-Sample').lb, SAMPLE_WEIGHT_LB);
+ eq(' stillZero empty', r.stillZero.length, 0);
+ eq(' no errors', r.errs.length, 0);
+ eq(' 2 mutations + 1 re-verify', m.calls.length, 3);
+ eq(' the last call is the RE-VERIFY query', m.calls[2].q, 'QUERY');
+}
+
+// (ii) NEGATIVE: the heal SILENTLY FAILS (write 200s, weight still 0) => ok MUST be false.
+{
+ const before = product([gqlVariant('DWTT-2002', 0)]);
+ const m = mockGql({ before, after: before }); // re-query shows it is STILL zero
+ const r = await healAndVerifyWeights(m.gql, 'gid://shopify/Product/2', before);
+ ok('heal silently failed => NOT ok', r.ok === false, JSON.stringify(r));
+ eq(' names the still-zero variant', r.stillZero[0], 'DWTT-2002');
+}
+
+// (iii) NEGATIVE: the mutation returns userErrors (no write_inventory scope) => NOT ok.
+{
+ const before = product([gqlVariant('DWTT-2003', 0)]);
+ const m = mockGql({ before, after: before, failMutation: true });
+ const r = await healAndVerifyWeights(m.gql, 'gid://shopify/Product/3', before);
+ ok('mutation userErrors => NOT ok', r.ok === false);
+ ok(' the error is surfaced', r.errs.some(e => /Access denied/.test(e)), JSON.stringify(r.errs));
+}
+
+// (iv) NEGATIVE: re-verify unreadable (product null / __err) => FAIL CLOSED, never a silent pass.
+{
+ const before = product([gqlVariant('DWTT-2004', 0)]);
+ const m = mockGql({ before, reverifyNull: true });
+ const r = await healAndVerifyWeights(m.gql, 'gid://shopify/Product/4', before);
+ ok('unreadable re-verify => NOT ok (fails closed)', r.ok === false);
+ ok(' says reverify-failed', r.errs.includes('weight:reverify-failed'));
+}
+
+// (v) NEGATIVE: a variant with no inventoryItem id cannot be healed => NOT ok.
+{
+ const before = product([{ id: 'v9', sku: 'DWTT-2005', title: 'Roll', price: '10.00' }]);
+ const m = mockGql({ before, after: before });
+ const r = await healAndVerifyWeights(m.gql, 'gid://shopify/Product/5', before);
+ ok('no inventoryItem id => NOT ok', r.ok === false);
+ ok(' names the unhealable variant', r.errs.some(e => e.startsWith('weight:no-inventory-item:')));
+}
+
+// (vi) NEGATIVE: gql throws mid-heal => NOT ok (no unhandled rejection escapes).
+{
+ const before = product([gqlVariant('DWTT-2006', 0)]);
+ const m = mockGql({ before, after: before, throwOn: 'inventoryItemUpdate' });
+ const r = await healAndVerifyWeights(m.gql, 'gid://shopify/Product/6', before);
+ ok('gql throw => NOT ok', r.ok === false);
+}
+
+// (vii) IDEMPOTENT NO-OP: already-weighted product makes ZERO network calls and returns ok.
+{
+ const before = product([gqlVariant('DWTT-2007', 3), gqlVariant('DWTT-2007-Sample', 0.25, 'POUNDS', { title: 'Sample', price: '4.25' })]);
+ const m = mockGql({ before });
+ const r = await healAndVerifyWeights(m.gql, 'gid://shopify/Product/7', before);
+ ok('already weighted => ok', r.ok === true);
+ eq(' zero gql calls (no-op)', m.calls.length, 0);
+}
+
+section('the shared query/mutation constants actually select what the guard measures');
+ok('WEIGHT_REQUERY selects measurement.weight', /measurement\s*\{\s*weight\s*\{\s*value\s+unit/.test(WEIGHT_REQUERY));
+ok('WEIGHT_REQUERY selects productType', /productType/.test(WEIGHT_REQUERY));
+ok('WEIGHT_REQUERY selects inventoryItem id', /inventoryItem\s*\{\s*id/.test(WEIGHT_REQUERY));
+ok('M_WEIGHT_SET writes POUNDS', /unit:\s*POUNDS/.test(M_WEIGHT_SET));
+
+console.log(`\n${fail === 0 ? 'PASS' : 'FAIL'} — ${pass} passed, ${fail} failed`);
+process.exit(fail === 0 ? 0 : 1);
diff --git a/evidence/TK-11871/restore-map-20260917-1700/weight-guard.test.ts.ORIG b/evidence/TK-11871/restore-map-20260917-1700/weight-guard.test.ts.ORIG
new file mode 100644
index 00000000..0c6254b6
--- /dev/null
+++ b/evidence/TK-11871/restore-map-20260917-1700/weight-guard.test.ts.ORIG
@@ -0,0 +1,96 @@
+// weight-guard.test.ts — TK-11539 negative test (CLAUDE.md TK-11431 amendment 3:
+// "a check ships with a negative test proving it goes red on an injected fault").
+//
+// The guard's job: no variant the shared import engine creates may carry a zero/missing
+// shipping weight. hasPositiveWeight() is the invariant; the tests prove it is RED on the
+// pre-fix (un-guarded) payload and GREEN on the guarded payload, and that the guard resolves
+// sane, positive, per-product-type weights.
+
+import {
+ inventoryItemWithWeight,
+ hasPositiveWeight,
+ sellableWeightLb,
+ sampleWeightLb,
+ SAMPLE_WEIGHT_LB,
+ FALLBACK_LB,
+ TYPE_DEFAULT_LB,
+} from '../lib/weight-guard';
+
+describe('TK-11539 weight guard — weight resolution', () => {
+ it('sellable Wallcovering resolves to the TK-11414 backfill default (3.0 lb)', () => {
+ expect(sellableWeightLb('Wallcovering')).toBe(TYPE_DEFAULT_LB['Wallcovering']);
+ expect(sellableWeightLb('Wallcovering')).toBe(3.0);
+ });
+
+ it('sellable resolution is case-insensitive', () => {
+ expect(sellableWeightLb('wallcovering')).toBe(3.0);
+ expect(sellableWeightLb(' FABRIC ')).toBe(1.0);
+ });
+
+ it('unknown/blank product_type falls back to FALLBACK_LB (never zero)', () => {
+ expect(sellableWeightLb('Nonexistent Type')).toBe(FALLBACK_LB);
+ expect(sellableWeightLb('')).toBe(FALLBACK_LB);
+ expect(sellableWeightLb(undefined)).toBe(FALLBACK_LB);
+ expect(sellableWeightLb(null)).toBe(FALLBACK_LB);
+ expect(FALLBACK_LB).toBeGreaterThan(0);
+ });
+
+ it('sample weight is the approved 0.25 lb', () => {
+ expect(sampleWeightLb()).toBe(SAMPLE_WEIGHT_LB);
+ expect(sampleWeightLb()).toBe(0.25);
+ });
+});
+
+describe('TK-11539 weight guard — inventoryItemWithWeight builder', () => {
+ it('sellable payload keeps base fields AND carries a positive weight in POUNDS', () => {
+ const out = inventoryItemWithWeight(
+ { sku: 'DWXX-1-Roll', tracked: true },
+ { role: 'sellable', productType: 'Wallcovering' }
+ );
+ expect(out.sku).toBe('DWXX-1-Roll');
+ expect(out.tracked).toBe(true);
+ expect(out.measurement.weight.unit).toBe('POUNDS');
+ expect(out.measurement.weight.value).toBe(3.0);
+ expect(hasPositiveWeight(out)).toBe(true);
+ });
+
+ it('sample payload keeps base fields AND carries 0.25 lb', () => {
+ const out = inventoryItemWithWeight(
+ { sku: 'DWXX-1-Sample', tracked: false },
+ { role: 'sample', productType: 'Wallcovering' }
+ );
+ expect(out.sku).toBe('DWXX-1-Sample');
+ expect(out.tracked).toBe(false);
+ expect(out.measurement.weight.value).toBe(0.25);
+ expect(hasPositiveWeight(out)).toBe(true);
+ });
+
+ it('guarantees a positive weight even for an unknown product_type', () => {
+ const out = inventoryItemWithWeight(
+ { sku: 'DWXX-2-Roll', tracked: true },
+ { role: 'sellable', productType: 'Totally Unknown' }
+ );
+ expect(out.measurement.weight.value).toBe(FALLBACK_LB);
+ expect(hasPositiveWeight(out)).toBe(true);
+ });
+});
+
+describe('TK-11539 weight guard — NEGATIVE test (proves the invariant has teeth)', () => {
+ it('REDDENS on the PRE-FIX payload (the exact zero-weight fault this guard removes)', () => {
+ // This is precisely what addSampleVariant wrote before TK-11539: no measurement.
+ const preFixUnguardedPayload = { sku: 'DWXX-1-Roll', tracked: true };
+ expect(hasPositiveWeight(preFixUnguardedPayload)).toBe(false);
+ });
+
+ it('REDDENS on an injected zero / negative / non-finite weight', () => {
+ expect(hasPositiveWeight({ sku: 'x', measurement: { weight: { value: 0, unit: 'POUNDS' } } })).toBe(false);
+ expect(hasPositiveWeight({ sku: 'x', measurement: { weight: { value: -3, unit: 'POUNDS' } } })).toBe(false);
+ expect(hasPositiveWeight({ sku: 'x', measurement: { weight: { value: NaN, unit: 'POUNDS' } } })).toBe(false);
+ expect(hasPositiveWeight({ sku: 'x', measurement: { weight: {} } })).toBe(false);
+ });
+
+ it('GREENS on the guarded payload — closing the loop against the pre-fix fault', () => {
+ const guarded = inventoryItemWithWeight({ sku: 'DWXX-1-Roll', tracked: true }, { role: 'sellable', productType: 'Wallcovering' });
+ expect(hasPositiveWeight(guarded)).toBe(true);
+ });
+});
diff --git a/evidence/TK-11871/restore-map-20260917-1700/weight-guard.ts.ORIG b/evidence/TK-11871/restore-map-20260917-1700/weight-guard.ts.ORIG
new file mode 100644
index 00000000..6f46a611
--- /dev/null
+++ b/evidence/TK-11871/restore-map-20260917-1700/weight-guard.ts.ORIG
@@ -0,0 +1,74 @@
+// weight-guard.ts — TK-11539 (2026-09-12) CREATE-PATH weight guard for the shared
+// ImportNewSkufromURL engine (Carnegie + every vendor routed through /api/create-product).
+//
+// Prevents a recurrence of the TK-11414 zero-weight gap: createOrGetProduct/addSampleVariant
+// created variants with inventoryItem:{sku,tracked} but NEVER set measurement.weight, so every
+// imported product shipped at ZERO weight. Zero weight collapses orders into the lowest
+// freight tier / the free-shipping band and mis-costs DW shipping (Steve's rule: NO product
+// may go ACTIVE with a missing/zero product WEIGHT).
+//
+// Values mirror the canonical backfill primitive:
+// ~/Projects/designerwallcoverings/scripts/lib/weight-guard.mjs
+// (SAMPLE_WEIGHT_LB, FALLBACK_LB, TYPE_DEFAULT_LB). Keep this in sync with that file and the
+// approved TK-11414 backfill. Unit is always POUNDS (Shopify WeightUnit enum).
+
+export const SAMPLE_WEIGHT_LB = 0.25;
+export const FALLBACK_LB = 2.0;
+
+// product_type -> sellable default weight (POUNDS). Mirrors weight-guard.mjs.
+export const TYPE_DEFAULT_LB: Record<string, number> = {
+ '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,
+};
+
+export type WeightUnit = 'POUNDS';
+
+export interface WeightMeasurement {
+ measurement: { weight: { value: number; unit: WeightUnit } };
+}
+
+const norm = (t?: string | null): string => String(t ?? '').trim().toLowerCase();
+
+/** Sellable default weight (lb) for a product_type; unknown/blank -> FALLBACK_LB. Case-insensitive. */
+export function sellableWeightLb(productType?: string | null): number {
+ const key = Object.keys(TYPE_DEFAULT_LB).find(k => norm(k) === norm(productType));
+ const w = key ? TYPE_DEFAULT_LB[key] : FALLBACK_LB;
+ return (Number.isFinite(w) && w > 0) ? w : FALLBACK_LB;
+}
+
+/** Sample-variant weight (lb) — always the approved 0.25 lb. */
+export function sampleWeightLb(): number {
+ return SAMPLE_WEIGHT_LB;
+}
+
+/**
+ * THE CREATE-SIDE GUARD. Merge a positive measurement.weight into a
+ * ProductVariantsBulkInput.inventoryItem input so no variant is ever created zero-weight.
+ * role 'sample' -> SAMPLE_WEIGHT_LB (0.25 lb)
+ * role 'sellable' -> per-productType default (FALLBACK_LB when unknown)
+ * The returned inventoryItem ALWAYS carries weight.value > 0.
+ * Valid on ProductVariantsBulkInput.inventoryItem (InventoryItemInput.measurement) in the
+ * Shopify Admin API 2024-07+.
+ */
+export function inventoryItemWithWeight<T extends object>(
+ base: T,
+ opts: { role: 'sellable' | 'sample'; productType?: string | null }
+): T & WeightMeasurement {
+ const value = opts.role === 'sample' ? sampleWeightLb() : sellableWeightLb(opts.productType);
+ return { ...base, measurement: { weight: { value, unit: 'POUNDS' } } };
+}
+
+/**
+ * Invariant used by the negative test and callable as a runtime assert: a variant's
+ * inventoryItem input must carry a finite, positive measurement.weight. Returns false for
+ * the pre-fix payload (no measurement), which is exactly the fault the guard removes.
+ */
+export function hasPositiveWeight(inventoryItemInput: any): boolean {
+ const v = inventoryItemInput?.measurement?.weight?.value;
+ return typeof v === 'number' && Number.isFinite(v) && v > 0;
+}
diff --git a/evidence/TK-11871/restore-map-20260917-1700/weight_guard.py.ORIG b/evidence/TK-11871/restore-map-20260917-1700/weight_guard.py.ORIG
new file mode 100644
index 00000000..da259a88
--- /dev/null
+++ b/evidence/TK-11871/restore-map-20260917-1700/weight_guard.py.ORIG
@@ -0,0 +1,299 @@
+#!/usr/bin/env python3
+"""weight_guard.py — TK-11471 PYTHON twin of lib/weight-guard.mjs (same directory).
+
+Steve's rule (TK-11414): NO product may go ACTIVE with a missing/zero product WEIGHT
+(zero weight collapses orders into the lowest weight tier / free-shipping band and
+mis-costs DW freight).
+
+Why a Python twin exists at all: the .mjs guard covers the JS onboarders, but
+dwpw-grs-migrate.py — the job that actually created 80 zero-weight ACTIVE variants
+on 2026-09-11 — is PYTHON and cannot import it. A .mjs-only guard is not a guard
+for this repo. Constants are kept BYTE-IDENTICAL to the .mjs so the two
+implementations cannot drift into disagreeing about what a compliant weight is;
+test_weight_guard.py asserts that mechanically against the .mjs source.
+
+Two call sites:
+ 1. create payload: weight = resolve_weight_lb(variant, product) (unit POUNDS)
+ 2. before activate: r = heal_and_verify_weights(gql, gid, product)
+ if not r["ok"]: HOLD as draft (never flip ACTIVE at zero weight)
+
+SAMPLES COUNT. dw-active-weight-canary FAILs on ANY zero-weight ACTIVE variant —
+its last live run split the offenders 43 sample / 40 sellable. The .mjs's
+zeroWeightBlockers() filters samples OUT and is DEPRECATED there; that bug is
+deliberately NOT ported. all_zero_weight_variants() is the only population view here.
+"""
+import math
+
+SAMPLE_WEIGHT_LB = 0.25
+FALLBACK_LB = 2.0
+
+# product_type -> sellable default weight (POUNDS). Identical to the .mjs table.
+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,
+}
+
+NAN = float('nan')
+
+
+def _norm(t):
+ return ('' if t is None else str(t)).strip().lower()
+
+
+def _num(v):
+ """float(v), or NaN when unparseable — mirrors JS Number() -> NaN. NaN is
+ treated as zero-weight downstream, so a junk value FAILS SAFE."""
+ try:
+ f = float(v)
+ except (TypeError, ValueError):
+ return NAN
+ return f
+
+
+def _d(x):
+ return x if isinstance(x, dict) else {}
+
+
+def _gql_weight(variant):
+ """inventoryItem.measurement.weight from a GraphQL node (camel or snake key)."""
+ iv = _d(variant).get('inventoryItem')
+ if iv is None:
+ iv = _d(variant).get('inventory_item')
+ return _d(_d(iv).get('measurement')).get('weight')
+
+
+def is_sample_variant(variant=None):
+ """Sample variant? (importers create `Sample` @ $4.25 + the real unit)."""
+ v = _d(variant)
+ label = _norm(v.get('title') if v.get('title') is not None else v.get('option1'))
+ sku = _norm(v.get('sku'))
+ if 'sample' in label or 'memo' in label:
+ return True
+ if sku.endswith('-sample') or 'sample' in sku:
+ return True
+ p = _num(v.get('price'))
+ return math.isfinite(p) and abs(p - 4.25) < 0.01
+
+
+def weight_field_present(variant=None):
+ """Did the payload we were handed CARRY a weight field at all?
+
+ TK-11431 amendment 1 — an UNMEASURED input is never a PASS. A response that
+ simply never contained weight must not be read as 'weight is zero' by luck,
+ nor as 'fine'. has_zero_weight() flags both, but a caller that needs to
+ distinguish 'measured 0' from 'never measured' asks here.
+ """
+ v = _d(variant)
+ if _d(_gql_weight(v)).get('value') is not None:
+ return True
+ return v.get('grams') is not None or v.get('weight') is not None
+
+
+def current_weight_lb(variant=None):
+ """Current weight in POUNDS, or 0 if missing. Accepts variant.weight (+weight_unit,
+ REST), variant.grams, or inventoryItem.measurement.weight{value,unit} (GraphQL)."""
+ v = _d(variant)
+ g = _d(_gql_weight(v))
+ if g.get('value') is not None:
+ val = _num(g.get('value'))
+ u = _norm(g.get('unit')) # Shopify WeightUnit enum
+ if u == 'kilograms' or u.startswith('kg'):
+ return val * 2.20462
+ if u == 'grams' or u == 'g':
+ return val / 453.59237
+ if u == 'ounces' or u == 'oz':
+ return val / 16
+ return val # POUNDS (unit-less => assume lb)
+ if v.get('grams') is not None:
+ return _num(v.get('grams')) / 453.59237
+ if v.get('weight') is not None:
+ val = _num(v.get('weight'))
+ u = _norm(v.get('weight_unit') or 'lb')
+ if u.startswith('kg'):
+ return val * 2.20462
+ if u == 'g' or u.startswith('gram'):
+ return val / 453.59237
+ if u == 'oz':
+ return val / 16
+ return val # lb
+ return 0.0
+
+
+def has_zero_weight(variant=None):
+ w = current_weight_lb(variant)
+ return (not math.isfinite(w)) or w <= 0
+
+
+def default_weight_lb(variant=None, product=None):
+ """The default weight (lb) to assign a variant that has none."""
+ if is_sample_variant(variant):
+ return SAMPLE_WEIGHT_LB
+ p = _d(product)
+ ptype = p.get('productType') if p.get('productType') is not None else p.get('product_type')
+ return TYPE_DEFAULT_LB.get(ptype, FALLBACK_LB)
+
+
+def resolve_weight_lb(variant=None, product=None):
+ """THE CREATE-SIDE GUARD: keep a real positive weight, else fill the default.
+ Drop-in for the REST variant `weight` field (returns POUNDS; pair with
+ weight_unit='lb')."""
+ w = current_weight_lb(variant)
+ return w if (math.isfinite(w) and w > 0) else default_weight_lb(variant, product)
+
+
+def variants_of(product=None):
+ """Normalize variants out of either a GraphQL connection or a plain REST array."""
+ p = _d(product)
+ edges = _d(p.get('variants')).get('edges')
+ if isinstance(edges, list):
+ return [e.get('node') for e in edges if isinstance(e, dict)]
+ v = p.get('variants')
+ return v if isinstance(v, list) else []
+
+
+def all_zero_weight_variants(product=None):
+ """THE ACTIVATE-SIDE GUARD: EVERY zero-weight variant, SAMPLE INCLUDED.
+ Matches dw-active-weight-canary exactly. Non-empty => DO NOT flip ACTIVE."""
+ return [v for v in variants_of(product) if has_zero_weight(v)]
+
+
+def unmeasured_variants(product=None):
+ """Variants whose payload carried NO weight field at all — the response never
+ measured them. Returned separately so a caller can report NOT-MEASURED rather
+ than claiming a clean zero reading."""
+ return [v for v in variants_of(product) if not weight_field_present(v)]
+
+
+def variant_label(variant=None):
+ v = _d(variant)
+ return v.get('sku') or v.get('title') or v.get('id') or '?'
+
+
+# ---------------------------------------------------------------- activate gate
+# The re-query an activate site MUST run so the guard MEASURES something. A product
+# query that omits inventoryItem{measurement{weight}} makes every variant look
+# zero-weight to current_weight_lb, and one that omits productType silently defaults
+# every heal to FALLBACK_LB. Both fields are required.
+WEIGHT_REQUERY = (
+ "query($id:ID!){ product(id:$id){ productType variants(first:100){edges{node{ "
+ "id sku title price inventoryItem{ id measurement{ weight{ value unit } } } }}} } }"
+)
+
+M_WEIGHT_SET = (
+ "mutation($id:ID!,$w:Float!){ inventoryItemUpdate(id:$id, "
+ "input:{measurement:{weight:{value:$w, unit:POUNDS}}}){ userErrors{message} } }"
+)
+
+
+def response_carries_weight_field(product=None):
+ """Did the re-query response ACTUALLY contain the weight field we asked for?
+
+ A gate that reads a response which never carried weight silently passes
+ everything (or, here, fails everything) for the wrong reason — the false-green
+ class CLAUDE.md warns about. Every variant node must carry an `inventoryItem`
+ key holding a `measurement` key. `measurement.weight = null` is a legitimate
+ 'no weight set' reading and is NOT a shape failure.
+ """
+ vs = variants_of(product)
+ if not vs:
+ return False
+ for v in vs:
+ d = _d(v)
+ iv = d.get('inventoryItem', d.get('inventory_item'))
+ if not isinstance(iv, dict) or 'measurement' not in iv:
+ return False
+ return True
+
+
+def heal_and_verify_weights(gql, product_gid, product=None, requery=None, mutation=None,
+ rest_heal=None):
+ """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 default_weight_lb() in POUNDS
+ 2. the product is RE-READ and re-checked — the mutation's own 200 is not evidence
+ 3. ok is False => caller must HOLD the product as draft and name `weight>0`
+
+ Idempotent and a NO-OP (zero network calls) when every weight is already positive.
+ Fails CLOSED: an unreadable re-query, a response that never carried the weight
+ field, a missing inventoryItem id, or a userError all yield ok=False.
+
+ gql -- the call site's own gql(query, variables) -> parsed JSON
+ rest_heal -- optional fallback heal fn (variant_dict, lb) -> True/False, used when
+ the GraphQL inventoryItemUpdate path is unavailable (e.g. a token
+ without write_inventory). Verification stays GraphQL-side either way.
+ """
+ requery = requery or WEIGHT_REQUERY
+ mutation = mutation or M_WEIGHT_SET
+ errs, healed, still_zero = [], [], []
+ heal_failures = 0
+ p = _d(product)
+ product_type = p.get('productType') if p.get('productType') is not None else p.get('product_type')
+
+ zero = all_zero_weight_variants(p)
+ if not zero:
+ return {"ok": True, "healed": healed, "stillZero": [], "errs": errs} # no-op
+
+ for v in zero:
+ label = variant_label(v)
+ lb = default_weight_lb(v, {"productType": product_type})
+ iid = _d(_d(v).get('inventoryItem') or _d(v).get('inventory_item')).get('id')
+ wrote = False
+ if iid:
+ try:
+ r = gql(mutation, {"id": iid, "w": lb})
+ ue = (((r or {}).get('data') or {}).get('inventoryItemUpdate') or {}).get('userErrors') or []
+ top = (r or {}).get('errors') or []
+ for e in ue:
+ errs.append("weight:%s:%s" % (label, e.get('message')))
+ for e in top:
+ errs.append("weight:%s:%s" % (label, str(e.get('message'))[:80]))
+ wrote = not ue and not top
+ except Exception as e: # noqa: BLE001 - fail closed
+ errs.append("weight:%s:%s" % (label, str(e)[:80]))
+ else:
+ errs.append("weight:no-inventory-item:%s" % label)
+
+ if not wrote and rest_heal is not None:
+ try:
+ wrote = bool(rest_heal(v, lb))
+ except Exception as e: # noqa: BLE001 - fail closed
+ errs.append("weight:rest:%s:%s" % (label, str(e)[:80]))
+
+ if wrote:
+ healed.append({"sku": label, "lb": lb})
+ else:
+ heal_failures += 1
+
+ # RE-VERIFY against the live record. Never trust the write.
+ fresh = None
+ try:
+ resp = gql(requery, {"id": product_gid})
+ if (resp or {}).get('errors'):
+ errs.append("weight:reverify:%s" % str(resp['errors'])[:80])
+ fresh = ((resp or {}).get('data') or {}).get('product')
+ except Exception as e: # noqa: BLE001 - fail closed
+ errs.append("weight:reverify:%s" % str(e)[:80])
+ if not fresh:
+ errs.append("weight:reverify-failed")
+ return {"ok": False, "healed": healed, "stillZero": [], "errs": errs}
+ if not response_carries_weight_field(fresh):
+ # NOT MEASURED. The re-query came back without the field it asked for, so a
+ # clean-looking read proves nothing. Never a PASS.
+ errs.append("weight:reverify-response-carried-no-weight-field")
+ return {"ok": False, "healed": healed, "stillZero": [], "errs": errs}
+
+ still_zero = [variant_label(v) for v in all_zero_weight_variants(fresh)]
+ # FAIL CLOSED on an UNHEALED variant even when the re-verify comes back clean: a
+ # variant we could not write is UNMEASURED with respect to our own action, and 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": (not still_zero) and heal_failures == 0,
+ "healed": healed, "stillZero": still_zero, "errs": errs}
← fefe129c ticket-runner: capture rc before date cmdsub — failures no l
·
back to Ticket System
·
tk: evidence guard on done (open gated memo / no logged work dee8eacd →