← 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
M scripts/build-drafts.pyM scripts/cadence-golive.shM scripts/create-drafts.pyM scripts/go-live.pyM scripts/resume.sh
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 →