[object Object]

← back to Fentucci Naturals

TK-11471: wire the weight guard into the Fentucci create + activate paths

9874a310923845af0d2fdf2807d9fb444991c6bf · 2026-09-11 11:35:05 -0700 · Steve Abrams

CREATE (build-drafts.py, create-drafts.py): every variant now carries
weight + weight_unit "lb" — Sample 0.25, Per Yard 3.0 (Wallcovering default).
The $0.00 quote-only Per Yard variant still ships, so it still needs a weight.
Both files assert weight>0 before writing/POSTing. The guard is applied again
at the POST site because data/shopify-drafts.jsonl predates this change — a
stale payload must not be able to create a zero-weight variant. Price,
inventory_policy deny, inventory_management, tags and the settlement exclusion
are untouched (the TK-11357 quote-only posture is a separate load-bearing guard).

ACTIVATE (go-live.py): new step 3d weight_gate() runs before publish + before
status=active. It re-reads weights through a query that ACTUALLY returns them
(inventoryItem{measurement{weight{value unit}}}), writes the approved default
onto any zero/unset variant, RE-VERIFIES, and on any variant still zero returns
the existing ("held", [...]) outcome with a `weight>0` reason so it lands in
data/golive-held.jsonl like every other gate. Idempotent; a no-op beyond one
read when weights are already positive. Heal path is inventoryItemUpdate with a
REST variant-update fallback for a token without write_inventory.

go-live.py / cadence-golive.sh / resume.sh now prefer SHOPIFY_FULL_ACCESS_TOKEN
over the narrow SHOPIFY_ADMIN_TOKEN: the weight heal and the pre-existing
inventory_levels writes both need write_inventory (dw-golive-token-guard-canary).

Offline replay over the real 504-row drafts file: 1008 zero-weight variants
before the guard, 0 after. No Shopify writes, no pipeline run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJHxAzaEMMxado57mFjiCk

Files touched

Diff

commit 9874a310923845af0d2fdf2807d9fb444991c6bf
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 11 11:35:05 2026 -0700

    TK-11471: wire the weight guard into the Fentucci create + activate paths
    
    CREATE (build-drafts.py, create-drafts.py): every variant now carries
    weight + weight_unit "lb" — Sample 0.25, Per Yard 3.0 (Wallcovering default).
    The $0.00 quote-only Per Yard variant still ships, so it still needs a weight.
    Both files assert weight>0 before writing/POSTing. The guard is applied again
    at the POST site because data/shopify-drafts.jsonl predates this change — a
    stale payload must not be able to create a zero-weight variant. Price,
    inventory_policy deny, inventory_management, tags and the settlement exclusion
    are untouched (the TK-11357 quote-only posture is a separate load-bearing guard).
    
    ACTIVATE (go-live.py): new step 3d weight_gate() runs before publish + before
    status=active. It re-reads weights through a query that ACTUALLY returns them
    (inventoryItem{measurement{weight{value unit}}}), writes the approved default
    onto any zero/unset variant, RE-VERIFIES, and on any variant still zero returns
    the existing ("held", [...]) outcome with a `weight>0` reason so it lands in
    data/golive-held.jsonl like every other gate. Idempotent; a no-op beyond one
    read when weights are already positive. Heal path is inventoryItemUpdate with a
    REST variant-update fallback for a token without write_inventory.
    
    go-live.py / cadence-golive.sh / resume.sh now prefer SHOPIFY_FULL_ACCESS_TOKEN
    over the narrow SHOPIFY_ADMIN_TOKEN: the weight heal and the pre-existing
    inventory_levels writes both need write_inventory (dw-golive-token-guard-canary).
    
    Offline replay over the real 504-row drafts file: 1008 zero-weight variants
    before the guard, 0 after. No Shopify writes, no pipeline run.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01FJHxAzaEMMxado57mFjiCk
---
 scripts/build-drafts.py   | 39 ++++++++++++++++++-------
 scripts/cadence-golive.sh |  5 ++++
 scripts/create-drafts.py  | 11 +++++++
 scripts/go-live.py        | 73 ++++++++++++++++++++++++++++++++++++++++++++---
 scripts/resume.sh         |  5 ++++
 5 files changed, 119 insertions(+), 14 deletions(-)

diff --git a/scripts/build-drafts.py b/scripts/build-drafts.py
index 7766ac2..7254c50 100644
--- a/scripts/build-drafts.py
+++ b/scripts/build-drafts.py
@@ -9,6 +9,10 @@ $0.00 + quotes/Needs-Price tags, Sample $4.25 sku {DW_SKU}-Sample).
 Title = "<Pattern> <Color> | Fentucci Naturals" (family convention; the word
 "Wallpaper" is banned house-wide — "Wallcovering" only).
 
+Every variant carries a real product WEIGHT (TK-11414 hard rule; TK-11471 wiring):
+Sample 0.25 lb, Per Yard the per-product-type default (Wallcovering = 3.0 lb).
+A $0.00 quote-only yard variant still SHIPS, so it still needs a weight.
+
 Rows with settlement_status != 'clear' are EXCLUDED and listed on stderr.
 FILES ONLY — no API calls, no shopify_products writes.
 """
@@ -16,8 +20,11 @@ import json, os, re, subprocess, sys
 
 HERE = os.path.dirname(os.path.abspath(__file__))
 ROOT = os.path.dirname(HERE)
+sys.path.insert(0, os.path.join(HERE, "lib"))
+from weight_guard import resolve_weight_lb  # noqa: E402  (TK-11471 weight gate)
 OUT = os.path.join(ROOT, "data", "shopify-drafts.jsonl")
 CONN = "host=/tmp dbname=dw_unified"
+PRODUCT_TYPE = "Wallcovering"   # drives the TK-11414 per-type default weight
 
 def psql(sql):
     return subprocess.run(["psql", CONN, "-Atc", sql], capture_output=True, text=True, check=True).stdout.strip()
@@ -45,6 +52,23 @@ with open(OUT, "w") as f:
             f"<tr><td><strong>Type</strong></td><td>Sidewall</td></tr>"
             f"<tr><td><strong>Pricing</strong></td><td>By quote — order a memo sample to see the material in hand</td></tr>"
             f"</table>")
+        # TK-11414/TK-11471 WEIGHT GATE (create side). Shopify REST takes weight +
+        # weight_unit on the variant object. A missing/zero weight collapses the order
+        # into the lowest weight tier / free-shipping band and mis-costs DW freight, so
+        # EVERY variant carries one — the $0.00 quote-only Per Yard variant included,
+        # because a quote-only item still ships.
+        variants = [
+            {"sku": f"{r['dw_sku']}-Sample", "price": "4.25", "option1": "Sample",
+             "taxable": True, "requires_shipping": True, "inventory_management": None},
+            # inventory_policy deny: hard stop on the $0.00 quote-only yard
+            # variant ever being purchasable if a tag edit strips `quotes`
+            {"sku": r["dw_sku"], "price": "0.00", "option1": "Per Yard",
+             "taxable": True, "requires_shipping": True, "inventory_management": None,
+             "inventory_policy": "deny"},
+        ]
+        for v in variants:
+            v["weight"] = resolve_weight_lb(v, {"product_type": PRODUCT_TYPE})
+            v["weight_unit"] = "lb"
         rec = {
             "mfr_sku": r["mfr_sku"],
             "sku": r["dw_sku"],
@@ -53,20 +77,12 @@ with open(OUT, "w") as f:
                 "title": title,
                 "handle": f"{slug(r['pattern_name'])}-{slug(r['color_name'])}-fentucci-naturals",
                 "vendor": "Fentucci Naturals",
-                "product_type": "Wallcovering",
+                "product_type": PRODUCT_TYPE,
                 "status": "draft",
                 "tags": ", ".join(r["tags"]),
                 "body_html": body,
                 "options": [{"name": "Size"}],
-                "variants": [
-                    {"sku": f"{r['dw_sku']}-Sample", "price": "4.25", "option1": "Sample",
-                     "taxable": True, "requires_shipping": True, "inventory_management": None},
-                    # inventory_policy deny: hard stop on the $0.00 quote-only yard
-                    # variant ever being purchasable if a tag edit strips `quotes`
-                    {"sku": r["dw_sku"], "price": "0.00", "option1": "Per Yard",
-                     "taxable": True, "requires_shipping": True, "inventory_management": None,
-                     "inventory_policy": "deny"},
-                ],
+                "variants": variants,
             },
             "metafields": [
                 {"namespace": "custom", "key": "manufacturer_sku", "type": "single_line_text_field", "value": r["mfr_sku"]},
@@ -83,6 +99,9 @@ with open(OUT, "w") as f:
         }
         # 5-field + image gate: refuse to write an incomplete draft
         assert r["description"] and len(r["tags"]) >= 2 and os.path.exists(rec["local_image_path"]), r["dw_sku"]
+        # weight gate: refuse to write a payload that would create a zero-weight variant
+        assert all(float(v["weight"]) > 0 and v["weight_unit"] == "lb"
+                   for v in rec["product"]["variants"]), f"zero-weight variant: {r['dw_sku']}"
         f.write(json.dumps(rec, ensure_ascii=False) + "\n")
         written += 1
 
diff --git a/scripts/cadence-golive.sh b/scripts/cadence-golive.sh
index baafbf6..39db730 100755
--- a/scripts/cadence-golive.sh
+++ b/scripts/cadence-golive.sh
@@ -12,6 +12,11 @@ mkdir -p "$ROOT/logs"
 # grep-export, never `source` — an unquoted value in the secrets .env aborts set -a sourcing
 SHOPIFY_ADMIN_TOKEN=$(grep '^SHOPIFY_ADMIN_TOKEN=' "$HOME/Projects/secrets-manager/.env" | cut -d= -f2- | tr -d '"')
 export SHOPIFY_ADMIN_TOKEN
+# TK-11471: go-live.py prefers the FULL-access token — the weight heal
+# (inventoryItemUpdate) and inventory_levels writes need write_inventory,
+# which the narrow SHOPIFY_ADMIN_TOKEN does not carry.
+SHOPIFY_FULL_ACCESS_TOKEN=$(grep '^SHOPIFY_FULL_ACCESS_TOKEN=' "$HOME/Projects/secrets-manager/.env" | cut -d= -f2- | tr -d '"')
+export SHOPIFY_FULL_ACCESS_TOKEN
 {
   echo "=== fentucci go-live tranche $(date '+%Y-%m-%d %H:%M:%S') ==="
   cd "$ROOT" && python3 scripts/go-live.py --apply --limit 40
diff --git a/scripts/create-drafts.py b/scripts/create-drafts.py
index 654ce69..718d53f 100644
--- a/scripts/create-drafts.py
+++ b/scripts/create-drafts.py
@@ -15,6 +15,8 @@ import argparse, base64, json, os, sys, time, urllib.request
 
 HERE = os.path.dirname(os.path.abspath(__file__))
 ROOT = os.path.dirname(HERE)
+sys.path.insert(0, os.path.join(HERE, "lib"))
+from weight_guard import resolve_weight_lb  # noqa: E402  (TK-11471 weight gate)
 DRAFTS = os.path.join(ROOT, "data", "shopify-drafts.jsonl")
 CREATED = os.path.join(ROOT, "data", "created.jsonl")
 SHOP = "designer-laboratory-sandbox.myshopify.com"
@@ -67,6 +69,15 @@ def main():
             payload["status"] = "draft"
             assert payload["status"] == "draft" and payload["status"] != "active", \
                 f"REFUSED: create-drafts.py can never create an ACTIVE product ({r['sku']})"
+            # TK-11414/TK-11471 WEIGHT GATE at the POST site. build-drafts.py already
+            # stamps weights, but data/shopify-drafts.jsonl can predate that change —
+            # a stale payload must not be able to create a zero-weight variant. Shopify
+            # REST takes weight + weight_unit on the variant object.
+            for v in payload.get("variants", []):
+                v["weight"] = resolve_weight_lb(v, {"product_type": payload.get("product_type")})
+                v["weight_unit"] = "lb"
+            assert payload.get("variants") and all(float(v["weight"]) > 0 for v in payload["variants"]), \
+                f"REFUSED: zero-weight variant in create payload ({r['sku']})"
             payload["images"] = [{"attachment": img, "filename": os.path.basename(r["local_image_path"])}]
             resp = api("products.json", "POST", {"product": payload})
             pid = resp["product"]["id"]
diff --git a/scripts/go-live.py b/scripts/go-live.py
index a6ef0b4..e56bede 100644
--- a/scripts/go-live.py
+++ b/scripts/go-live.py
@@ -14,10 +14,16 @@ Per product (data/created.jsonl):
      orderable variant is a free-checkout loss vector), keep ONLY {SKU}-Sample
      @ $4.25 as the orderable item: inventory tracked, policy=DENY, on_hand=2026
      at Ventura Blvd. Add tag 'contact-for-price'.
-  4. Publish to the 12 DW sales channels — Google & YouTube EXCLUDED
+  4. WEIGHT GATE (TK-11414 hard rule, wired TK-11471): re-read every surviving
+     variant's weight through a query that ACTUALLY returns it, write the approved
+     default (sample 0.25 lb / Wallcovering 3.0 lb) onto any that are zero or unset,
+     RE-VERIFY, and if any variant is STILL zero-weight -> record HELD ('weight>0')
+     and do NOT activate. Zero weight collapses the order into the lowest weight
+     tier / free-shipping band and mis-costs DW freight.
+  5. Publish to the 12 DW sales channels — Google & YouTube EXCLUDED
      (the $4.25 sample would trip GMC price disapproval).
-  5. status = ACTIVE.
-  6. PG-first write-back: tokiwa_catalog.status='active' (on_shopify already true).
+  6. status = ACTIVE.
+  7. PG-first write-back: tokiwa_catalog.status='active' (on_shopify already true).
 
 Idempotent: skips products already ACTIVE or in data/golive-done.jsonl.
 Trickle: --limit N per run. NO same-day bulk-dump.
@@ -29,6 +35,9 @@ Trickle: --limit N per run. NO same-day bulk-dump.
 import argparse, json, os, subprocess, sys, time, urllib.request, urllib.error
 
 HERE = os.path.dirname(os.path.abspath(__file__)); ROOT = os.path.dirname(HERE)
+sys.path.insert(0, os.path.join(HERE, "lib"))
+from weight_guard import (  # noqa: E402  (TK-11471 weight gate)
+    WEIGHT_REQUERY, heal_and_verify_weights, response_carries_weight_field)
 CREATED = os.path.join(ROOT, "data", "created.jsonl")
 DONE = os.path.join(ROOT, "data", "golive-done.jsonl")
 HELD = os.path.join(ROOT, "data", "golive-held.jsonl")
@@ -38,7 +47,11 @@ TARGET_QTY = 2026
 # Google & YouTube (29646651457) EXCLUDED per Steve's rule + GMC $4.25-leak guard.
 PUBLICATIONS = [22208643184, 22497296496, 29739483201, 29776969793, 37904089153,
     43657658419, 44234276915, 44317474867, 44317507635, 71898464307, 115856375859, 140027723827]
-TOKEN = os.environ.get("SHOPIFY_ADMIN_TOKEN") or sys.exit("SHOPIFY_ADMIN_TOKEN not set — source ~/Projects/secrets-manager/.env")
+# Prefer the FULL-access token: the weight heal (inventoryItemUpdate) and the
+# inventory_levels writes below both need write_inventory, which the narrow
+# SHOPIFY_ADMIN_TOKEN does not carry (dw-golive-token-guard-canary).
+TOKEN = (os.environ.get("SHOPIFY_FULL_ACCESS_TOKEN") or os.environ.get("SHOPIFY_ADMIN_TOKEN")
+         or sys.exit("no Shopify token set — source ~/Projects/secrets-manager/.env"))
 
 
 def rest(path, method="GET", body=None):
@@ -92,6 +105,52 @@ def validate_lite(p):
     return reasons
 
 
+def weight_gate(pid, gid_product):
+    """TK-11414 hard rule: NO product goes ACTIVE with a missing/zero variant weight.
+
+    SELF-HEAL then VERIFY (the pattern Steve approved in sanderson-onboard
+    create_sdg.mjs 8d09eed) — stranding product is worse than assigning the
+    already-approved default, but a heal that silently fails must never activate.
+
+    Returns [] to proceed, or a list of `weight>0` hold reasons. Idempotent and a
+    no-op (zero extra calls beyond the one re-read) when weights are already positive.
+
+    The re-read runs WEIGHT_REQUERY, which explicitly asks for
+    inventoryItem{measurement{weight{value unit}}} — and the response is checked for
+    that field before any verdict, so a response that never carried weight can never
+    be mistaken for a clean reading (fails CLOSED, never a false green).
+    """
+    try:
+        resp = graphql(WEIGHT_REQUERY, {"id": gid_product})
+    except Exception as e:                                   # noqa: BLE001 - fail closed
+        return ["weight>0: weight re-read failed: %s" % str(e)[:80]]
+    if (resp or {}).get("errors"):
+        return ["weight>0: weight re-read errored: %s" % str(resp["errors"])[:100]]
+    fresh = ((resp or {}).get("data") or {}).get("product")
+    if not fresh:
+        return ["weight>0: product not readable for weights"]
+    if not response_carries_weight_field(fresh):
+        return ["weight>0: NOT MEASURED — re-read response carried no weight field"]
+
+    def rest_heal(variant, lb):
+        """Fallback when the GraphQL inventoryItemUpdate path is unavailable (a token
+        without write_inventory). REST variant update needs only write_products."""
+        vid = str(variant.get("id") or "").rsplit("/", 1)[-1]
+        if not vid.isdigit():
+            return False
+        r = rest("variants/%s.json" % vid, "PUT",
+                 {"variant": {"id": int(vid), "weight": lb, "weight_unit": "lb"}})
+        return bool((r or {}).get("variant"))
+
+    res = heal_and_verify_weights(graphql, gid_product, fresh, rest_heal=rest_heal)
+    if res["ok"]:
+        return []
+    reasons = ["weight>0: still zero after heal: %s" % ", ".join(res["stillZero"])] if res["stillZero"] \
+        else ["weight>0: heal could not be confirmed"]
+    reasons += ["weight>0: " + e for e in res["errs"][:3]]
+    return reasons
+
+
 def go_live_one(pid, sku):
     gidP = f"gid://shopify/Product/{pid}"
     p = rest(f"products/{pid}.json").get("product")
@@ -133,6 +192,12 @@ def go_live_one(pid, sku):
         try: rest(f"products/{pid}.json", "PUT", {"product": {"id": pid, "tags": ", ".join(tags)}})
         except urllib.error.HTTPError as e: errs.append(f"tag:{e.code}")
 
+    # 3d) WEIGHT GATE — heal then verify. A product that is STILL zero-weight is
+    # HELD as a draft; it is never published and never flipped ACTIVE.
+    wreasons = weight_gate(pid, gidP)
+    if wreasons:
+        return ("held", wreasons)
+
     # 4) publish to 12 channels (Google excluded)
     r = graphql("mutation($id:ID!,$pubs:[PublicationInput!]!){ publishablePublish(id:$id, input:$pubs){ userErrors{message} } }",
         {"id": gidP, "pubs": [{"publicationId": f"gid://shopify/Publication/{n}"} for n in PUBLICATIONS]})
diff --git a/scripts/resume.sh b/scripts/resume.sh
index abd7163..faac912 100755
--- a/scripts/resume.sh
+++ b/scripts/resume.sh
@@ -14,6 +14,11 @@ LABEL="com.steve.fentucci-onboard-resume"
 # grep-export (never `source` — an unquoted value in the secrets .env aborts set -a)
 SHOPIFY_ADMIN_TOKEN=$(grep '^SHOPIFY_ADMIN_TOKEN=' "$HOME/Projects/secrets-manager/.env" | cut -d= -f2- | tr -d '"')
 export SHOPIFY_ADMIN_TOKEN
+# TK-11471: go-live.py prefers the FULL-access token — the weight heal
+# (inventoryItemUpdate) and inventory_levels writes need write_inventory,
+# which the narrow SHOPIFY_ADMIN_TOKEN does not carry.
+SHOPIFY_FULL_ACCESS_TOKEN=$(grep '^SHOPIFY_FULL_ACCESS_TOKEN=' "$HOME/Projects/secrets-manager/.env" | cut -d= -f2- | tr -d '"')
+export SHOPIFY_FULL_ACCESS_TOKEN
 ts() { date '+%Y-%m-%dT%H:%M:%S'; }
 {
   echo "===== $(ts) fentucci go-live tick (limit ${GOLIVE_LIMIT}) ====="

← 81a477a TK-11471: vendor a Python weight guard + its negative tests  ·  back to Fentucci Naturals  ·  chore: v1.0.6 (TK-11471 session close — weight guard on the 09562bf →