[object Object]

← back to Designerwallcoverings

TK-11483: phased reversible archive executor for 147 Fentucci mint-dupes

d025ccb08984485983e657909322ff26e5dc2230 · 2026-09-11 14:19:19 -0700 · Steve Abrams

Keeper rule (over-determined, 93/93 unambiguous groups, 0 rule-disagreements):
keep the non-migrate DWFE original per mfr_sku group; archive the dwpw-grs-daily
mint dupes. ARCHIVE-only, per-product precondition guard, idempotent, ledgered.
54 ambiguous groups + 139 no-mfr NOT_MEASURED carved out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCU4vM8cCDiLe6Gw8oBb1w

Files touched

Diff

commit d025ccb08984485983e657909322ff26e5dc2230
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 11 14:19:19 2026 -0700

    TK-11483: phased reversible archive executor for 147 Fentucci mint-dupes
    
    Keeper rule (over-determined, 93/93 unambiguous groups, 0 rule-disagreements):
    keep the non-migrate DWFE original per mfr_sku group; archive the dwpw-grs-daily
    mint dupes. ARCHIVE-only, per-product precondition guard, idempotent, ledgered.
    54 ambiguous groups + 139 no-mfr NOT_MEASURED carved out.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01DCU4vM8cCDiLe6Gw8oBb1w
---
 scripts/tk11483-archive-dupes.py | 191 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 191 insertions(+)

diff --git a/scripts/tk11483-archive-dupes.py b/scripts/tk11483-archive-dupes.py
new file mode 100644
index 0000000..7230910
--- /dev/null
+++ b/scripts/tk11483-archive-dupes.py
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+"""TK-11483 — archive the Fentucci runaway-mint duplicates (keeper rule).
+
+Keeper rule (over-determined, 93/93 unambiguous groups, 0 rule-disagreements):
+  KEEP the one non-migrate original per mfr_sku group (all carry a DWFE sellable
+  SKU); ARCHIVE every copy minted by dwpw-grs-daily (created_by_dwpw_grs_migrate).
+  Identity = the dwc.manufacturer_sku metafield (the group key), never handle/title.
+
+Scope = the 147 archive_candidates ONLY (71 DRAFT + 76 ACTIVE). The 54 ambiguous
+groups (145 excess) and the 139 no-mfr NOT_MEASURED products are carved out and
+NEVER touched by this script.
+
+SAFE BY DESIGN:
+  * ARCHIVE only (status -> ARCHIVED). NO deletes.
+  * Per-product precondition: current live status MUST equal the recorded
+    old_status. If it drifted (someone changed it), the product is SKIPPED and
+    reported — never blindly archived.
+  * Already-ARCHIVED  -> counted done, skipped (idempotent, re-run safe).
+  * Every archive appends an executed-reversible ledger line with the exact undo
+    command (productUpdate status back to old_status).
+
+PHASES (run in order):
+  --dry                 default; zero writes, prints the plan.
+  --phase draft         archive the 71 DRAFT candidates (undo rehearsal set).
+  --reversal-test       un-archive ONE just-archived DRAFT, verify it restored,
+                        then re-archive it — proves the restore map works.
+  --phase active        archive the 76 ACTIVE candidates (customer-facing).
+
+Token: SHOPIFY_FULL_ACCESS_TOKEN (env or secrets .env) — write_products scope.
+"""
+import argparse, datetime, json, os, sys, time, urllib.error, urllib.request
+
+DOMAIN = "designer-laboratory-sandbox.myshopify.com"
+API = "2024-10"
+SECRETS = "/Users/macstudio3/Projects/secrets-manager/.env"
+ASSETS = os.path.expanduser("~/.claude/yolo-queue/pending-approval/assets")
+ARCHIVE_LIST = os.path.join(ASSETS, "TK-11483-archive-list.json")
+LEDGER = os.path.expanduser("~/.claude/yolo-queue/executed-reversible/ledger.jsonl")
+TICKET = "TK-11483"
+
+
+def load_token():
+    tk = os.environ.get("SHOPIFY_FULL_ACCESS_TOKEN")
+    if tk:
+        return tk.strip()
+    for line in open(SECRETS):
+        if line.startswith("SHOPIFY_FULL_ACCESS_TOKEN="):
+            return line.split("=", 1)[1].strip()
+    raise RuntimeError("SHOPIFY_FULL_ACCESS_TOKEN not found (env or secrets .env)")
+
+
+TOKEN = load_token()
+
+
+def gql(query, variables=None, _tries=0):
+    body = json.dumps({"query": query, "variables": variables or {}}).encode()
+    req = urllib.request.Request(
+        f"https://{DOMAIN}/admin/api/{API}/graphql.json", data=body,
+        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
+        method="POST")
+    try:
+        with urllib.request.urlopen(req, timeout=60) as r:
+            out = json.loads(r.read())
+    except urllib.error.HTTPError as e:
+        if e.code in (429, 502, 503) and _tries < 5:
+            time.sleep(2 * (_tries + 1)); return gql(query, variables, _tries + 1)
+        raise
+    except urllib.error.URLError:
+        if _tries < 5:
+            time.sleep(2 * (_tries + 1)); return gql(query, variables, _tries + 1)
+        raise
+    if out.get("errors"):
+        codes = [(e.get("extensions") or {}).get("code") for e in out["errors"]]
+        if "THROTTLED" in codes and _tries < 5:
+            time.sleep(2 * (_tries + 1)); return gql(query, variables, _tries + 1)
+    return out
+
+
+Q_STATUS = "query($id:ID!){ product(id:$id){ id status } }"
+M_SET = ("mutation($id:ID!,$s:ProductStatus!){ productUpdate(input:{id:$id,status:$s})"
+         "{ product{ id status } userErrors{ field message } } }")
+
+
+def live_status(pid):
+    d = gql(Q_STATUS, {"id": pid})
+    p = (d.get("data") or {}).get("product")
+    return p["status"] if p else None
+
+
+def set_status(pid, status):
+    d = gql(M_SET, {"id": pid, "s": status})
+    pu = (d.get("data") or {}).get("productUpdate") or {}
+    errs = pu.get("userErrors") or []
+    if errs:
+        return False, errs
+    return True, (pu.get("product") or {}).get("status")
+
+
+def ledger(action, pid, handle, mfr, old_status, new_status):
+    rec = {"ts": datetime.datetime.utcnow().isoformat() + "Z", "agent": "win-11483",
+           "ticket": TICKET, "action": action, "product_id": pid, "handle": handle,
+           "mfr_sku": mfr, "old_status": old_status, "new_status": new_status,
+           "blast_radius": 1,
+           "undo_cmd": f"SHOPIFY status {pid} -> {old_status} (productUpdate)",
+           "verify": f"gql product({pid}).status == {new_status}"}
+    os.makedirs(os.path.dirname(LEDGER), exist_ok=True)
+    with open(LEDGER, "a") as f:
+        f.write(json.dumps(rec) + "\n")
+
+
+def archive_one(c, dry):
+    pid, handle, mfr, old = c["product_id"], c["handle"], c["mfr_sku"], c["old_status"]
+    cur = live_status(pid)
+    if cur is None:
+        return "missing", f"{handle}: product not found"
+    if cur == "ARCHIVED":
+        return "already", f"{handle}: already ARCHIVED"
+    if cur != old:
+        return "drift", f"{handle}: live={cur} != recorded old={old}  -> SKIP (drift)"
+    if dry:
+        return "would", f"{handle} ({mfr}) {old} -> ARCHIVED"
+    ok, res = set_status(pid, "ARCHIVED")
+    if not ok:
+        return "error", f"{handle}: userErrors {res}"
+    rb = live_status(pid)
+    if rb != "ARCHIVED":
+        return "error", f"{handle}: readback {rb} != ARCHIVED"
+    ledger("archive_dupe", pid, handle, mfr, old, "ARCHIVED")
+    return "archived", f"{handle} ({mfr}) {old} -> ARCHIVED  [ledgered]"
+
+
+def run_phase(cands, status_filter, dry):
+    sub = [c for c in cands if c["old_status"] == status_filter]
+    print(f"== phase {status_filter}: {len(sub)} candidates (dry={dry}) ==")
+    tally = {}
+    for c in sub:
+        k, msg = archive_one(c, dry)
+        tally[k] = tally.get(k, 0) + 1
+        print(f"  [{k:8}] {msg}")
+    print("  --", dict(tally))
+    return tally
+
+
+def reversal_test(cands):
+    # pick the first DRAFT candidate that is now ARCHIVED, un-archive -> verify -> re-archive
+    for c in cands:
+        if c["old_status"] != "DRAFT":
+            continue
+        pid, handle, old = c["product_id"], c["handle"], c["old_status"]
+        if live_status(pid) != "ARCHIVED":
+            continue
+        print(f"== reversal test on {handle} ({pid}) ==")
+        ok, res = set_status(pid, old)
+        if not ok:
+            print(f"  FAIL un-archive: {res}"); return False
+        if live_status(pid) != old:
+            print(f"  FAIL: did not restore to {old}"); return False
+        print(f"  OK restored to {old}")
+        ledger("reversal_test_restore", pid, handle, c["mfr_sku"], "ARCHIVED", old)
+        ok, res = set_status(pid, "ARCHIVED")
+        if not ok or live_status(pid) != "ARCHIVED":
+            print(f"  FAIL re-archive: {res}"); return False
+        ledger("reversal_test_rearchive", pid, handle, c["mfr_sku"], old, "ARCHIVED")
+        print("  OK re-archived. RESTORE MAP PROVEN.")
+        return True
+    print("no ARCHIVED DRAFT candidate available for reversal test (run --phase draft first)")
+    return False
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--phase", choices=["draft", "active"])
+    ap.add_argument("--reversal-test", action="store_true")
+    ap.add_argument("--dry", action="store_true")
+    a = ap.parse_args()
+    cands = json.load(open(ARCHIVE_LIST))
+    assert len(cands) == 147, f"expected 147 candidates, got {len(cands)}"
+    dry = a.dry or not (a.phase or a.reversal_test)
+    if a.reversal_test:
+        sys.exit(0 if reversal_test(cands) else 1)
+    if a.phase == "draft":
+        run_phase(cands, "DRAFT", dry)
+    elif a.phase == "active":
+        run_phase(cands, "ACTIVE", dry)
+    else:
+        run_phase(cands, "DRAFT", True)
+        run_phase(cands, "ACTIVE", True)
+
+
+if __name__ == "__main__":
+    main()

← 4911595 auto-data-snapshot: 2026-09-11T13:57:49 (2 data files) — ver  ·  back to Designerwallcoverings  ·  TK-11461 APPLIED + verified: 76 foreign gallery images remov 83d3cd4 →