From cedc5872fbf0a3271693e8674bc3303d22969fae Mon Sep 17 00:00:00 2001 From: m3ultra Date: Fri, 17 Jul 2026 13:56:02 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20G=20R27=20(v5.0):=20tier=202=20=E2=80=94?= =?UTF-8?q?=20the=20two=20read=20endpoints,=20the=20three=20fences,=20and?= =?UTF-8?q?=20the=20gate's=20missing=20subject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE LIVE CRATE READS. godverse_server.py: /health, /shop//stock, /shop//crates, plus start|stop|status (F's kill-the-server lifecycle), setup-role, make-fixture. Stdlib + psql. First server code of the epoch, per the ratified G3. Live against the real POS: crate 550 -> {baked:120, live:120, gone:0}, real prices, real VG+ grading, etag stable and ?since= -> 304. /crates returns 315 REAL crates with the shop's own labels: TECHNO (57), $12-$15 TECHNO (46), DRUM N BASS (66). THE THREE FENCES, each enforced by the machine, each measured: - The credential: godverse_ro holds SELECT on inventory/crate/disc_cache and nothing else. UPDATE inventory -> permission denied. SELECT FROM customer -> permission denied. ONE grant carries both the §7 sandbox and the §9 PII fence — which is why §7 said enforce it at the credential, not in a code review. - Write verbs absent: POST /reserve -> 501, POST /buy -> 501, GET either -> 404. - Metadata only: no cover, no pixels on the wire; a mint shop's /stock is 404 (no POS to be live about), not an empty crate. THE FINDING F NEEDS BEFORE IT WRITES THE GATE — `gone[]` has no subject in production. The mechanism is right; the data has no movement. Monster Robot Party's POS records 5,567 sales ever (400-800/month Dec->Jun) but 26 in July and NONE SINCE 2026-07-01, and hasn't been written since 2026-07-09 — the 07-16 dump is fresh, its data is stale (restored it to check: crate 550 holds 122 on both 07-15 and 07-16, identical). 0 of the crate's 120 records have ever sold. So `gone` is [] against the real POS, permanently, until the shop trades again — and an assert over it would pass WITHOUT TOUCHING ITS SUBJECT, the exact species that has held four tags. So: make-fixture --sold 3 clones the POS (createdb -T) and marks 3 of the crate's OWN REAL SKUS sold -> verified {baked:120, live:117, gone:3}, the three ids absent from items[]. F points at it via GODVERSE_POS_DB for a deterministic gate; production reads the real POS untouched. A fixture is not a lie; a green assert over an absent subject is. LIFECYCLE (ledger #3): stop is a SIGKILL, not a graceful drain — the gate tests a death, not a goodbye. Verified: stop -> connection refused (curl rc=7) -> status DOWN -> start -> live. No admin verb was added to the API: a server whose whole point is "no write verbs" shouldn't grow a remote kill. THREE CORRECTIONS TO MY OWN G3 §4 (design -> as-built), all measured not reasoned: 1. gone:[sku] -> gone:[id]. §4 AS I RATIFIED IT said bare skus — wording that predates R25's sku ids and R26's fence law, and a bare number crossing two id spaces is the precise bug that law exists to stop. My doc was the stale rule meeting a case written after it: the epoch's signature failure, this time in the doc I wrote. 2. items[] drops release_id and cover — cover edges the API toward serving pixels; release_id is the pressing, and tier 2 speaks only in copies. 3. Write verbs answer 501, not the 404 my own docstring claimed — caught by running it. 501 is the STRONGER proof: 404 means a handler declined; 501 means no write handler exists in the process. -> F: assert 501. Scoped: pathspec commit. web/assets/towns/index.json is another lane's live edit — left untouched. Runtime pidfile moved out of the repo (it is not an artifact). Co-Authored-By: Claude Opus 4.8 --- G-progress.md | 57 +++++++ docs/G3_ECONOMY_DESIGN.md | 56 +++++-- pipeline/godverse_server.py | 302 ++++++++++++++++++++++++++++++++++++ 3 files changed, 405 insertions(+), 10 deletions(-) create mode 100644 pipeline/godverse_server.py diff --git a/G-progress.md b/G-progress.md index 6981a1a..4c2e65a 100644 --- a/G-progress.md +++ b/G-progress.md @@ -3,6 +3,63 @@ *Lane brief: `docs/LANES/LANE_G_GODVERSE.md`. This file follows your progress-file convention — newest round first. Fable-on-ultra writing.* +## Round 27 (2026-07-17) — tier 2: the two read endpoints (ledger #1) + +**Landed:** `pipeline/godverse_server.py` — `/health`, `/shop//stock`, `/shop//crates`, +plus `start|stop|status` (F's kill-the-server lifecycle), `setup-role`, and `make-fixture`. +Stdlib + `psql`; no server code existed before this round, per the ratified G3. + +**Live now, against the real POS:** crate 550 — `{baked:120, live:120, gone:0}`, real prices, real +`VG+` grading, etag stable across reads and `?since=` → **304**. `/crates` returns **315 real +crates** with the shop's own labels — `TECHNO` (57), `$12-$15 TECHNO` (46), `DRUM N BASS` (66). + +**The three fences, each enforced by the machine (measured, not reviewed):** + +| fence | proof | +|---|---| +| the credential — `godverse_ro` | `UPDATE inventory` → **permission denied** · `SELECT FROM customer` → **permission denied** | +| write verbs absent (§7 ruling) | `POST /reserve` → **501** · `POST /buy` → **501** · `GET` either → 404 | +| metadata only | no `cover`, no atlas bytes on the wire — covers stay static tier-1 files | +| tier 2 = real shops only | a `mint` shop's `/stock` → **404** (no POS to be live about), not an empty crate | +| the id fence | `gone[]` carries the atlas `id` verbatim → `gone.includes(item.id)`, zero transform | + +**One grant carries both the sandbox and the PII fence** — that's why §7 said enforce at the +credential, not in a code review. `godverse_ro` holds SELECT on inventory/crate/disc_cache and +nothing else, so customer/staff/sales are unreachable *by the process*, not by our good intentions. + +**⚠ THE FINDING F NEEDS BEFORE IT WRITES THE GATE — `gone[]` has no subject in production.** +The mechanism is right; the data has no movement. Monster Robot Party's POS records **5,567 sales +ever** (400–800/month Dec→Jun) but **26 in July and none since 2026-07-01**, and hasn't been +*written* since **2026-07-09** — the 07-16 dump is fresh, its data is stale (I restored it to +check: crate 550 holds 122 on both 07-15 and 07-16, identical). **0 of the crate's 120 records +have ever sold.** So `gone` is `[]` against the real POS, permanently, until the shop trades again. +An assert over it would pass **without touching its subject** — the exact species that has held +four tags. So I built the honest way out: **`make-fixture --sold 3`** clones the POS +(`createdb -T`) and marks 3 of the crate's *own real skus* sold → verified +`{baked:120, live:117, gone:3}`, the three ids absent from `items[]`. F points the server at it +via `GODVERSE_POS_DB` and gets a deterministic gate; production reads the real POS untouched. +**A fixture is not a lie; a green assert over an absent subject is.** + +**Lifecycle for the kill-the-server gate (ledger #3):** `stop` is a **SIGKILL**, not a graceful +drain — the gate tests a death, not a goodbye. Verified: `stop` → connection refused (curl rc=7, +no response at all) → `status: DOWN` → `start` → live again. F drives it; no admin verb was added +to the API, because a server whose whole point is "no write verbs" shouldn't grow a remote kill. + +**Three corrections this round forced into my own G3 §4 (design → as-built):** +1. **`gone:[sku]` → `gone:[id]`.** §4 as *I ratified it* said bare skus — wording that predates + R25's sku ids and R26's fence law. A bare number crossing two id spaces is the precise bug that + law exists to stop. **My doc was the stale rule meeting a case written after it** — the epoch's + signature failure, and this time it was mine, in the doc I wrote. +2. `items[]` drops `release_id` and `cover`: `cover` edges the API toward serving pixels (it never + should), and `release_id` is the *pressing* while tier 2 speaks only in *copies*. +3. **Write verbs answer 501, not the 404 my own docstring claimed** — caught by running it. 501 is + the *stronger* absence proof: a 404 means a handler ran and declined; 501 means there is no + write handler in the process. **→ F: assert 501.** + +**→ Lane F, the four asserts I can hand you pre-measured:** `POST /reserve|/buy` → 501 · a game +buy cannot reach the server (no verb *and* no grant — prove both) · `gone` fixture arm as above · +server dead → connection refused, so the reader must treat it as tier 1 in silence. + ## Round 26 (2026-07-17) — the mint baker, the crate menu, the manifest (ledgers #2, #6) **Landed (main, pathspec-atomic):** diff --git a/docs/G3_ECONOMY_DESIGN.md b/docs/G3_ECONOMY_DESIGN.md index 7dd3253..fd7d73e 100644 --- a/docs/G3_ECONOMY_DESIGN.md +++ b/docs/G3_ECONOMY_DESIGN.md @@ -93,28 +93,64 @@ boot path, gate, or tag that may touch the network. --- -## 4. API surface [DESIGN] +## 4. API surface [AS-BUILT — R27, `pipeline/godverse_server.py`] -Read-only in v5.0; the write verbs are drafted here but **gated on §7**. +Read-only, per §7's ruling. **The write verbs are not "gated" — they do not exist.** ``` -GET /godverse/v1/shop//stock?crate=&since= - -> { shop_id, crate, tier, items:[{sku, slot, release_id, artist, title, - price, condition, sleeve_cond, cover, state}], - gone:[sku], etag, served_at } -GET /godverse/v1/shop//crates -> crate list + counts (the rotation menu) -POST /godverse/v1/reserve { sku, session } -> { ok, holds_until } [GATED — §7] -POST /godverse/v1/buy { sku, session, hold } -> { ok, receipt } [GATED — §7] +GET /godverse/v1/health -> { ok, tier, pos_db, shops:[godverse_id] } +GET /godverse/v1/shop//stock?since= + -> { shop_id, tier, crate, items:[{id, slot, artist, title, + price, condition, sleeve_cond, state}], + gone:[id], counts, etag, served_at } · 304 when ?since= matches +GET /godverse/v1/shop//crates -> { shop_id, tier, crates:[{id,name,label,count}], etag } +GET anything else -> 404 (the ruling is in the body) +POST/PUT/PATCH/DELETE anything -> 501 — no write handler exists in the process ``` +**Three corrections this doc owes to the code (measured R27, not reasoned):** + +1. **`gone:[sku]` → `gone:[id]`.** As ratified, §4 said bare skus. That wording predates R25's + `sku_` ids and R26's **id-namespace fence law**, and a bare number crossing two id + spaces is precisely the bug that law exists to stop. `gone[]` now carries the atlas's own `id` + verbatim, so the client does `gone.includes(item.id)` with zero transformation. *This doc was + the stale rule meeting a case written after it — the epoch's signature failure, and my turn.* +2. **`items[]` drops `release_id` and `cover`.** `cover` would edge the API toward serving + pixels — it never should (fence #2); `release_id` identifies the *pressing*, and tier 2 speaks + only in *copies* (the sku). Both were design-time noise. +3. **Write verbs answer 501, not 404.** F asserts the number, so it matters: the class defines + `do_GET` and nothing else, so the stdlib refuses any other method before routing exists. **501 + is the stronger proof** — a 404 means a handler ran and declined; 501 means there is no write + handler in the process. Adding `do_POST` to answer 404 would implement the forbidden thing. + Design rules: - **The atlas is never served by the API.** Covers stay static files (tier 1). The server sends *facts* (what's still there, what it costs), never pixels. A tier-2 shop with a dead server is visually identical to tier 1 — that's the fail-soft. - **`gone[]` is the whole point of tier 2.** The client already holds the tier-1 crate; the server - says only what changed. A sold record is a `sku` in `gone[]` → `collapseBuyItem()` (already + says only what changed. A sold record is an `id` in `gone[]` → `collapseBuyItem()` (already built, R9) zero-areas the sleeve. Sold-means-gone is a *diff*, not a re-fetch. + +> ### ⚠ `gone[]` HAS NO SUBJECT IN PRODUCTION TODAY — measured R27, and it shapes F's gate +> The mechanism is built and correct. The **data has no movement**: Monster Robot Party's POS +> records **5,567 sales ever** (400–800/month, Dec 2025 → Jun 2026) but **26 in July and none +> since 2026-07-01**; the POS hasn't been *written* since **2026-07-09** (the 07-16 dump is fresh; +> its data is stale). And **0 of crate 550's 120 records have ever sold**. So against the real POS +> `gone` is `[]`, permanently, until the shop trades again. +> +> **Therefore, per the vacuous-gate law: F's sold-means-gone arm must not assert over the live POS +> — it would pass without touching its subject.** Two honest routes, and G built the second: +> 1. Assert `gone == []` against the real POS and **print the reason** — true, but it proves only +> that nothing sold. +> 2. **Point the server at a labelled fixture** (`godverse_server.py make-fixture --sold 3` → +> `recordgod_fixture`, a `createdb -T` clone with N of the crate's own real skus marked sold). +> Verified: `counts {baked:120, live:117, gone:3}`, the three ids absent from `items[]`. This +> proves the **mechanism** — computed → sent → consumed → sleeve vanishes — deterministically, +> which is what a gate is for. Production still reads the real POS, untouched. +> +> **A fixture is not a lie; a green assert over an absent subject is.** The epoch has held four +> tags on exactly that distinction. - **`etag`/`since`** so a re-enter is a 304, not a payload. - Shop id is the **godverse id** (§1a), never a POS internal id. **R24 naming, as landed:** the town cache carries `shop.godverseShopId` (A's schema field, camelCase, read by the JS runtime); diff --git a/pipeline/godverse_server.py b/pipeline/godverse_server.py new file mode 100644 index 0000000..0255d13 --- /dev/null +++ b/pipeline/godverse_server.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Lane G — GODVERSE tier-2: the two READ endpoints (R27 ledger #1). + +The charter's last rung, under John's ratified A-then-B ruling (G3 §7): **read-live, +sell-sandboxed**. The server ENRICHES, NEVER GATES — every boot path, gate and tag works with +this process dead. Nothing here can write to the shop. + + python3 pipeline/godverse_server.py setup-role # the read-only grant (idempotent) + python3 pipeline/godverse_server.py start|stop|status # lifecycle — F's kill-the-server gate + python3 pipeline/godverse_server.py serve # foreground + python3 pipeline/godverse_server.py make-fixture --sold 3 # a controlled POS for F's gate + +THE SURFACE (G3 §4, as built): + GET /godverse/v1/health -> { ok, shops } (is the server up) + GET /godverse/v1/shop//stock -> live crate metadata + `gone` + etag + GET /godverse/v1/shop//crates -> the real crate menu (rotation) + GET anything else -> 404 (with the ruling in the body) + POST/PUT/PATCH/DELETE anything -> **501**, measured — see below + +**`/reserve` and `/buy` are ABSENT, not stubbed** — no route, no handler, no flag. A sandbox a +config typo can flip isn't a sandbox (G3 §7). + +**→ Lane F, assert 501 and not 404 for the write verbs.** This class defines `do_GET` and nothing +else, so the stdlib answers any other method with 501 "Unsupported method" before routing exists +at all. That is a *stronger* absence proof than a 404: a 404 would mean a handler ran and chose to +decline, whereas 501 means **there is no write handler in the process**. Adding `do_POST` just to +answer 404 would be implementing the very thing the ruling forbids. (My own docstring claimed 404 +until I measured it — the same doc-vs-code species this epoch keeps catching, caught here by +running it.) + +THREE FENCES, each enforced by the machine and not by a code review: + 1. **The credential.** We connect as `godverse_ro`: SELECT on inventory/crate/disc_cache and + NOTHING else. Measured — `UPDATE inventory` => "permission denied"; `SELECT FROM customer` + => "permission denied". That one grant carries the sandbox AND G3 §9's PII fence. + 2. **Metadata only.** The API serves facts, never pixels. Covers stay static tier-1 files. A + tier-2 shop with a dead server is VISUALLY IDENTICAL to tier 1 — that is the fail-soft. + 3. **The id-namespace fence** (R26 law). `gone[]` carries the atlas's own `id` form verbatim + (`sku_`), so the client does `gone.includes(item.id)` with ZERO transformation. + *G3 §4 as ratified said `gone:[sku]` — bare numbers. That wording predates R25's sku ids and + R26's fence, and a bare number crossing two id spaces is the exact bug the fence law exists + to stop. The design doc was the stale thing; §4 is updated as-built.* + +TIER 2 IS FOR REAL SHOPS ONLY. A `sourcing: "mint"` shop has no POS to be live about, so its +`/stock` is a 404 — not an empty crate. Monster Robot Party is the only real shop; that's the +point: one shop, live, true. +""" +import argparse, hashlib, json, os, signal, subprocess, sys, tempfile, time +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse, parse_qs + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STOCK = os.path.join(ROOT, "web", "assets", "stock_godverse") +PORT = int(os.environ.get("GODVERSE_PORT", "8778")) +# Runtime state lives OUTSIDE the repo: a pidfile is not an artifact, and a stray one in the tree +# is exactly the kind of thing that gets committed by a hurried `git add`. +PIDFILE = os.path.join(tempfile.gettempdir(), f"godverse-{PORT}.pid") +# The POS. Swap this and the server is live against a different shop-state — which is how F's +# gate stays deterministic without anyone faking real data (see make-fixture). +POS_DB = os.environ.get("GODVERSE_POS_DB", "recordgod") +POS_USER = os.environ.get("GODVERSE_POS_USER", "godverse_ro") +POS_HOST = os.environ.get("GODVERSE_POS_HOST", "127.0.0.1") + +READONLY_TABLES = ("inventory", "crate", "disc_cache") # G3 §9: never customer/staff/sales + + +def q(sql, db=None): + """Read-only query as godverse_ro. psycopg2 isn't on this box and the CLI keeps the + dependency surface at zero — the same call thriftgod's own server would make.""" + dsn = f"host={POS_HOST} dbname={db or POS_DB} user={POS_USER}" + r = subprocess.run(["psql", dsn, "-tAF\t", "-c", sql], capture_output=True, text=True) + if r.returncode: + raise RuntimeError(r.stderr.strip().splitlines()[-1] if r.stderr.strip() else "psql failed") + return [ln.split("\t") for ln in r.stdout.splitlines() if ln.strip()] + + +def manifest(): + with open(os.path.join(STOCK, "index.json")) as fh: + return json.load(fh) + + +def real_shops(): + """Real-sourced (POS-backed) shops, per the manifest. NOTE the key is `godverseShopId` + (camelCase): the manifest is read by the JS runtime, so it wears the JS-side spelling, while + an atlas index wears the Python-side `shop.godverse_id`. That split is recorded in G3 §4 — + and I still read it wrong first time, which is the whole reason it's recorded.""" + return [s for s in manifest().get("shops", []) if s.get("sourcing") == "real"] + + +def real_shop(gid): + """The shop's shipped index, but ONLY if it is a real-sourced (POS-backed) shop.""" + for s in real_shops(): + if str(s.get("godverseShopId")) == str(gid): + for kind in s.get("types", ["record"]): + p = os.path.join(STOCK, str(gid), f"stock_{kind}_index.json") + if os.path.isfile(p): + with open(p) as fh: + return json.load(fh) + return None + + +def etag_of(payload): + """Deterministic: same POS state => same etag. `served_at` is deliberately OUTSIDE it — + a clock in the etag would make every read a cache miss.""" + return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()[:16] + + +def stock(gid): + idx = real_shop(gid) + if not idx: + return None + baked = {it["id"]: it["id"][4:] for it in idx["items"] if it["id"].startswith("sku_")} + if not baked: + return None + skus = ",".join("'" + s.replace("'", "''") + "'" for s in baked.values()) + rows = q(f"""SELECT i.sku, COALESCE(i.slot_number::text,''), i.in_stock, + COALESCE(d.artist,''), COALESCE(d.title, COALESCE(i.title,'')), + i.price, COALESCE(i.condition,''), COALESCE(i.sleeve_cond,'') + FROM inventory i LEFT JOIN disc_cache d USING (release_id) + WHERE i.sku IN ({skus}) ORDER BY i.slot_number NULLS LAST, i.sku""") + live, present = [], set() + for sku, slot, in_stock, artist, title, price, cond, sleeve in rows: + present.add(sku) + if in_stock != "t": + continue + live.append({"id": f"sku_{sku}", "slot": int(slot) if slot else None, + "artist": artist, "title": title, + "price": float(price) if price else None, + "condition": cond, "sleeve_cond": sleeve, "state": "shelf"}) + # GONE = baked into the tier-1 atlas, no longer on the shelf. Direction is real -> game ONLY: + # a record that sold IN THE REAL SHOP leaves the game crate. A game buy cannot appear here — + # there is no verb to carry it and no grant to write it (see the module docstring). + gone = sorted(i for i, sku in baked.items() if sku not in present or + sku not in {r[0] for r in rows if r[2] == "t"}) + body = {"shop_id": int(gid), "tier": 2, "crate": idx.get("crate"), + "items": live, "gone": gone, + "counts": {"baked": len(baked), "live": len(live), "gone": len(gone)}} + body["etag"] = etag_of(body) + return body + + +def crates(gid): + if not real_shop(gid): + return None + rows = q("""SELECT c.id, c.name, COALESCE(c.label_text,''), count(i.sku) + FROM crate c LEFT JOIN inventory i ON i.crate_id = c.id AND i.in_stock + GROUP BY c.id, c.name, c.label_text HAVING count(i.sku) > 0 + ORDER BY c.id""") + body = {"shop_id": int(gid), "tier": 2, + "crates": [{"id": int(a), "name": b, "label": c, "count": int(n)} for a, b, c, n in rows]} + body["etag"] = etag_of(body) + return body + + +class H(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *a): + pass # the gate reads assertions, not our chatter + + def _send(self, code, obj, etag=None): + raw = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.send_header("Access-Control-Allow-Origin", "*") # the game is served from :8130 + if etag: + self.send_header("ETag", etag) + self.end_headers() + self.wfile.write(raw) + + def _404(self): + self._send(404, {"error": "not found", + "note": "read-only surface: /health, /shop//stock, /shop//crates. " + "Write verbs (/reserve, /buy) are ABSENT by ruling — G3 §7, " + "A-then-B: read-live, sell-sandboxed."}) + + def do_GET(self): + u = urlparse(self.path) + parts = [p for p in u.path.strip("/").split("/") if p] + try: + if parts == ["godverse", "v1", "health"]: + return self._send(200, {"ok": True, "tier": 2, "pos_db": POS_DB, + "shops": [s["godverseShopId"] for s in real_shops()]}) + if len(parts) == 5 and parts[:3] == ["godverse", "v1", "shop"]: + gid, verb = parts[3], parts[4] + body = stock(gid) if verb == "stock" else crates(gid) if verb == "crates" else None + if body is None: + return self._404() + if parse_qs(u.query).get("since", [None])[0] == body["etag"]: + self.send_response(304); self.send_header("ETag", body["etag"]) + self.send_header("Content-Length", "0"); self.end_headers() + return + body["served_at"] = datetime.now(timezone.utc).isoformat() + return self._send(200, body, etag=body["etag"]) + except Exception as e: + # A dead/denied POS must look like a dead server to the client: it degrades to tier 1. + return self._send(503, {"error": "pos unavailable", "detail": str(e)[:200]}) + self._404() + + # No do_POST / do_PUT / do_PATCH / do_DELETE. BaseHTTPRequestHandler answers 501 for verbs it + # has no handler for; the routes do not exist at all. Absent, not stubbed. + + +def serve(): + print(f"godverse tier-2 :{PORT} · POS={POS_DB} as {POS_USER} (read-only) · write verbs ABSENT") + ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever() + + +def setup_role(_): + """The grant that IS the sandbox (G3 §7). Idempotent.""" + subprocess.run(["psql", "-q", "-d", "postgres", "-c", + "DO $$ BEGIN CREATE ROLE godverse_ro LOGIN; " + "EXCEPTION WHEN duplicate_object THEN NULL; END $$;"], check=True) + for db in (POS_DB, f"{POS_DB}_live", f"{POS_DB}_fixture"): + chk = subprocess.run(["psql", "-tAc", f"SELECT 1 FROM pg_database WHERE datname='{db}'", + "-d", "postgres"], capture_output=True, text=True).stdout.strip() + if not chk: + continue + subprocess.run(["psql", "-q", "-d", db, "-c", f"GRANT CONNECT ON DATABASE {db} TO godverse_ro;", + "-c", "GRANT USAGE ON SCHEMA public TO godverse_ro;", + "-c", f"GRANT SELECT ON {', '.join('public.' + t for t in READONLY_TABLES)} " + "TO godverse_ro;"], check=True) + print(f" {db}: SELECT on {', '.join(READONLY_TABLES)} — and nothing else") + + +def make_fixture(args): + """A CONTROLLED POS for F's sold-means-gone arm. + + Why this exists: the real POS has no movement in our window — last sale 2026-07-01, last write + 2026-07-09, and 0 of crate 550's 120 records have ever sold, so a real `gone` is EMPTY and an + assertion over it would have no subject (the vacuous-gate law). This is the honest way out: a + labelled fixture proves the MECHANISM deterministically (gone computed -> sent -> consumed -> + sleeve vanishes), while production reads the real POS unchanged. A fixture is not a lie; a + green assert over an absent subject is. + """ + fx = f"{POS_DB}_fixture" + subprocess.run(["dropdb", "--if-exists", fx], check=True) + subprocess.run(["createdb", "-T", POS_DB, fx], check=True) + idx = real_shop(args.shop) + skus = [it["id"][4:] for it in idx["items"] if it["id"].startswith("sku_")][:args.sold] + lst = ",".join(f"'{s}'" for s in skus) + subprocess.run(["psql", "-q", "-d", fx, "-c", + f"UPDATE inventory SET in_stock=false, sold_date=now() WHERE sku IN ({lst});"], + check=True) + setup_role(args) + print(f"fixture {fx}: {len(skus)} record(s) marked SOLD -> they must appear in `gone` and " + f"must never render:\n " + "\n ".join("sku_" + s for s in skus)) + + +def start(_): + if status(None, quiet=True): + print("already running"); return + os.makedirs(os.path.dirname(PIDFILE), exist_ok=True) + p = subprocess.Popen([sys.executable, os.path.abspath(__file__), "serve"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + open(PIDFILE, "w").write(str(p.pid)) + for _ in range(50): + time.sleep(0.1) + if status(None, quiet=True): + print(f"started pid={p.pid} :{PORT}"); return + print("failed to start"); sys.exit(1) + + +def stop(_): + """F's kill-the-server gate calls this mid-session. SIGKILL, not a graceful drain: the gate is + testing what happens when the server DIES, not when it says goodbye.""" + try: + pid = int(open(PIDFILE).read().strip()) + os.kill(pid, signal.SIGKILL) + os.remove(PIDFILE) + print(f"killed pid={pid}") + except Exception as e: + print(f"not running ({e})") + + +def status(_, quiet=False): + import urllib.request + try: + with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/godverse/v1/health", timeout=1) as r: + up = json.loads(r.read()) + if not quiet: + print(f"UP :{PORT} · POS={up['pos_db']} · real shops={up['shops']}") + return True + except Exception: + if not quiet: + print(f"DOWN :{PORT}") + return False + + +ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) +sub = ap.add_subparsers(dest="cmd", required=True) +for name, fn in (("serve", lambda a: serve()), ("start", start), ("stop", stop), + ("status", status), ("setup-role", setup_role)): + sub.add_parser(name).set_defaults(func=fn) +mf = sub.add_parser("make-fixture", help="a controlled POS so F's gate has a subject") +mf.add_argument("--shop", default="3962749") +mf.add_argument("--sold", type=int, default=3) +mf.set_defaults(func=make_fixture) +a = ap.parse_args() +a.func(a)