PROCITY/docs/G3_ECONOMY_DESIGN.md
m3ultra cedc5872fb Lane G R27 (v5.0): tier 2 — the two read endpoints, the three fences, and the gate's missing subject
THE LIVE CRATE READS. godverse_server.py: /health, /shop/<gid>/stock, /shop/<gid>/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 <noreply@anthropic.com>
2026-07-17 13:56:02 +10:00

20 KiB
Raw Blame History

G3 — THE ECONOMY BACKEND (design doc)

Lane G, R23 draft. Drafted per the v5 charter (V5_REAL_SHOP.md) ledger #2.

§7 (THE WRITE-BACK QUESTION) IS RATIFIED — John, 2026-07-17, R24 kickoff: A THEN B. v5.0 ships sandboxed (a game purchase never touches real inventory); reservation is chartered as a v5.x upgrade behind a hold budget John sets; full write-back is not chartered. Charter law #4 is satisfied. The rest of this doc remains DESIGN and still needs both Fables' sign-off; no server code ships before that.

Status of each section: [MEASURED] = verified against the real databases. [DESIGN] = proposed, not built. [RULED] = decided by John under charter law.


1. What the data actually is [MEASURED]

The charter assumed one join: thriftgod's census of real shops → dealgod's real items. That join does not exist. The three datasets are three different populations:

dataset what it really holds inventory?
thriftgod.shop 2,928 real AU secondhand shops, OSM-anchored (the census) no
dealgod.stores 9,285 online sellers — 8,754 are Discogs accounts, not physical shops 1.77M listings
recordgod one real trading record shop's POS — crates, slots, prices, grading 28,624 live

Measured consequences:

  • No Newtown record shop has any stock, in any database. Egg Records, Papa Disquo, Haze Guitars, The Vintage Record, Downtown Music: 0 items each. dealgod has zero Newtown stores and zero census name matches.
  • thriftgod.item (the minted table) holds 2,221 items across 29 shops — all Brisbane/ Newcastle/Alice charity shops. Zero record shops. It is a demo seed, not a stock system.
  • This is not charter risk #3 ("fuzzy matching"). It is a population mismatch. Fuzzy matching cannot fix it, because there is nothing on the other side of the join to match to.

1a. The one shop where all three meet [MEASURED]

monsterrobot147 Musgrave Road, Red Hill, Brisbane QLD (John, R23) — is the same real shop in three forms, and that is the whole reason tier 1 can be honest:

recordgod (its POS)  ──┐
monsterrobot.party     ├── one real shop: real crates · real slots · real prices · its OWN photos
dealgod store 3962749 ─┘
  • 28,624 live vinyl; 24,914 physically placed in 357 real crates with real labels ("DEEP HOUSE", "$12$15 TECHNO") and real slot numbers — with gaps where records actually sold.
  • Covers are the shop's own product shots on the shop's own domain, cached by dealgod (23,392 releases). 99.3% of its crate stock joins to a cached cover by Discogs release_id.
  • It is not in thriftgod's OSM census, so its godverse id is its dealgod store id 3962749 (real, stable, clear of the census space — thriftgod max shop id = 2992).

Shipped this round (G2a): crate 550 "DEEP HOUSE" → 120 real records → web/assets/ stock_godverse/3962749/. Byte-identical re-bake verified.


2. Why mint() cannot be the source of any tier [MEASURED]

thriftgod's mint() is the obvious candidate for stocking shops. It disqualifies itself three ways:

  1. It is not deterministic. FEED_RECORDS uses TABLESAMPLE SYSTEM (0.5); mint() uses random.random(), random.uniform(), random.shuffle(). random.seed appears nowhere in server.py. Re-running mint gives different stock at different prices. That is fatal to tier 1 (charter law #2), and it is why tier 1 reads the POS instead: a crate is a table of facts.
  2. The shop assignment is fictional. mint samples arbitrary Discogs sellers' listings and assigns them to a census shop that never owned them.
  3. Therefore it breaks two laws downstream. The licence law's green rests on "the stores' own product shots" — a Discogs seller's photo shown as Egg Records' stock isn't that. And the write-back question (§7) is meaningless for minted stock: you cannot mark real inventory sold for an item the shop never had.

mint stays what it is: a fun demo seeder for parody tier 0. It is not an inventory system. (If a future round wants seeded mint for tier 0 variety, seeding it is a small, safe change — random.Random(shop_id) + an ordered ORDER BY id sample instead of TABLESAMPLE.)


3. The tier ladder, as built [MEASURED for 01, DESIGN for 2]

tier source deterministic offline
0 · parody seeded canvas / E's parody packs yes yes
1 · static real per-shop atlas baked from the POS snapshot (G2a, shipped) yes — verified yes — files
2 · live thriftgod server over the POS (this doc) no, by design enriches only

The offline law is structural, not a promise. stockpack.js already returns null when an index or atlas is missing, and every caller falls back to parody canvas. Tier 2 rides the same seam: the server is an enrichment that upgrades an already-playable tier-1 room. There is no boot path, gate, or tag that may touch the network.


4. API surface [AS-BUILT — R27, pipeline/godverse_server.py]

Read-only, per §7's ruling. The write verbs are not "gated" — they do not exist.

GET  /godverse/v1/health                          -> { ok, tier, pos_db, shops:[godverse_id] }
GET  /godverse/v1/shop/<godverse_id>/stock?since=<etag>
       -> { 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/<godverse_id>/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_<POS 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 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 (400800/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 3recordgod_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); the atlas index carries shop.godverse_id (snake_case, what E's validate_atlas.py requires). Same id, two artifacts, two house conventions — the API uses godverse_id on the wire to match the Python/JSON side it is served from. Recorded rather than silently reconciled: if the integrators want one spelling, it is a one-line change in each emitter, not a design question.

5. Async enrichment + caching (risk #1) [DESIGN]

Law: the dig never blocks on the network. Not "usually" — never. The mechanism:

  1. Room builds at tier 1 from static files. Playable. Riffling works. No await.
  2. The stock fetch is fired in parallel, never awaited by any build path, with a hard ~400 ms budget and AbortController.
  3. If it lands in time: apply gone[] + price deltas before the player reaches the crate.
  4. If it lands late: apply on arrival (a record vanishing mid-riffle is correct — that's a real shop). If it never lands: the room stays tier 1, silently. Zero console noise — a dead server is an expected state, not an error.
  5. Cache the response per (shop, crate) in memory + sessionStorage, keyed by etag. One fetch per shop per session, not per crate entry.
  6. One flight at a time per shop; re-entering a shop reuses the cache. No polling loop.

The v1 machinery makes this cheap: the pack is a shared material + per-item UVs, so applying gone[] is a vertex collapse on an existing merged mesh — no rebuild, no re-upload, no stutter.


6. Identity: census ↔ lot ↔ POS (risk #3) [DESIGN, informed by §1]

The R22 contract said "atlas per shop keyed by godverse shop id". §1 shows the id must come from whichever dataset actually has the shop, and mismatches must fail soft, never mis-stock:

  • A shop is stocked only by explicit, recorded identity — a godverse_id that resolves to a real POS. There is no fuzzy name matching, ever. A shop we cannot identify with certainty gets tier 0. Mis-stocking is worse than no stock: it puts one shop's records in another's crate, which is a lie the whole epoch exists to avoid.
  • Every unmatched census shop is counted, not guessed (the drops law). R23's count: 2,928 census shops, 1 identified real POS.
  • The census is not a stock oracle. Being in the census means the shop exists, not that we know what's in it. v5.0-beta's "every hero shop stocked" is therefore bounded by real POS access, not by census coverage — see §9.

7. THE WRITE-BACK QUESTION — RULED: A THEN B (John, 2026-07-17, R24 kickoff)

THE RULING

v5.0 ships on Option A (sandbox). An in-game purchase never touches real monsterrobot/Square inventory. Sold-means-gone is per-session, tier-1 semantics. Option B (reservation) is CHARTERED as a v5.x upgrade, behind a hold budget John sets. Option C is not chartered — it happens only as a deliberate business launch, if ever.

Charter law #4 is hereby satisfied: the write-back question has an explicit, John-ratified answer, and until B ships the standing state is read-live, sell-sandboxed.

What this makes law for v5.0 (binding on the server work, when it starts):

  • The API's POST /reserve and POST /buy (§4) stay unbuilt and unrouted. Not stubbed behind a flag — absent. A sandbox that can be flipped on by a config typo is not a sandbox.
  • The game's buy path terminates in the client's wallet. No code path from a game purchase to inventory.in_stock, sold_date, hold_expires_at, qty, or Square exists. The tier-2 reader connects to the POS read-only — enforce it at the credential, not in a code review: a role with SELECT on inventory/crate/disc_cache and nothing else (this also carries the §9 PII fence in the same grant).
  • A gate worth its name here (the vacuous-gate law): assert the write verbs 404, and that the tier-2 role's grants are read-only. A test that "we didn't call buy()" proves nothing.

Option B's charter (v5.x — the preconditions, so it can't slide in quietly):

  1. The hold budget — John's number, not yet set. Max concurrent game-held SKUs, and max hold duration. Until both are set, B is not startable. (Open item, not a blocker for v5.0.)
  2. The shop always wins. If a held record sells in-store via Square, the game loses the hold, silently and immediately, and the player is told the truth ("someone bought it in the shop") — the one race policy that is never up for negotiation.
  3. Staff-visible reason code on every hold (held: GODVERSE), so a human at the counter can see why a record is out of the crate and clear it by hand.
  4. Soak first. B rides plumbing that has soaked, not plumbing that tags a round — Fable's framing, and the reason A ships first.

The options as framed for the ruling are preserved below, since the trade-offs are the record of why A-then-B was chosen.


The question as put: does an in-game purchase ever touch real thriftgod/recordgod inventory? Live, not hypothetical: monsterrobot is a real trading shop whose POS syncs to Square, with real customers buying these exact records off the real shelf.

Relevant measured facts: the POS already has in_stock, sold_date, hold_expires_at, qty and square_item_id. A reservation primitive therefore already exists — the question is whether the game is allowed to pull it.

Option A — Sandbox (read-live, sell-local)

Game reads real stock; a purchase never leaves the game. Sold-means-gone is per-session.

  • For: zero real-world risk; no payment, tax, or fulfilment surface; a bug can't cost real stock; ships fastest.
  • Against: the one-of-a-kind thrill is fiction — the real crate never changes, and two players (or the same player, next boot) buy the same record. Tier 2 becomes "live prices" more than "live stock".

Option B — Reservation (a real, expiring hold)

A game purchase places a real hold on the real SKU via the existing hold_expires_at. The record genuinely leaves the real crate for N hours; unclaimed, it returns.

  • For: genuinely one-of-a-kind, and reversible — the strongest truth-per-unit-risk; uses a primitive the POS already has.
  • Against: a game action removes real sellable stock from real in-store customers. Needs a hold cap/budget, a staff-visible reason code ("held: GODVERSE"), and a race policy against Square in-store sales. Someone must decide what happens when a held record sells in the shop anyway (the game must lose, gracefully).

Option C — Full write-back (game sale = real sale)

A purchase marks the real SKU sold and syncs to Square.

  • For: total truth — buy it in the game and it is gone from the real shop, forever.
  • Against: irreversible real-world inventory and revenue consequences; the game becomes a sales channel (real payment, tax, postage, fulfilment, refunds, chargebacks); a bug destroys real stock; a game wallet is not money. This is a business decision, not an engineering one.

(Lane G asked nothing and recommended nothing here — the options above are the record as put to John. Ruled A-then-B; the binding terms are at the top of this section.)


8. Failure modes [DESIGN]

failure behaviour gate class
server unreachable / DNS dead tier 1, silent, zero console noise tier-2 smoke
server dies mid-session room keeps last-good state; no re-fetch storm; no stutter tier-2 smoke
server slow (> budget) request aborted; tier 1 stands; late arrival applies or is dropped tier-2 smoke
5xx / malformed JSON treated as unreachable — never a partial apply tier-2 smoke
atlas missing but server up tier 0 — no stock without covers; never text-only sleeves tier-1 gate
gone[] names an unknown sku ignored + counted, never an error tier-2 smoke
clock skew on holds server time is authoritative; client never computes expiry tier-2 smoke

No tier-2 failure may fail a gate or block a tag (charter law #1). Tier 2 gets its own smoke class: reachability, fail-soft, latency. Kill the server mid-session and the game must not stutter — that is v5.0's release gate, and it is a client test, not a server test.


9. Fences (non-negotiable, both already load-bearing)

  • PII. recordgod holds real customer, customer_session, mailing_list, staff, sales — real private-individual data. The pipeline and the server read only inventory / crate / disc_cache. No exceptions, no joins, no "just for debugging". Charter law #3: no private-individual data, ever.
  • Licence. Covers are the shop's own product shots → in-house 🟢, flagged before any public/commercial release. Real titles/artists ship at tier 1 per John's R23 amendment to the no-real-trademarks law, under the same flag. Every atlas carries its provenance.
  • Scope reality check for v5.0-beta. "Every hero shop stocked" needs a real POS per shop. We have exactly one (John's own). Stocking a second real shop means a second real shopkeeper's data and consent — a licence and relationship question, not a pipeline question. Beta should be scoped to monsterrobot's 357 crates (24,646 real records — the whole shop, crate rotation per risk #2) rather than to more shops, unless John says otherwise.

10. Open items for the reviewers

  1. [Fable-m3 / C] Per-shop pack resolution is not wired: stockpack.js caches by type alone (_packs/_resolved), and interior_mode.js:192 resolves getStockPack(shop.type) with base fixed at assets/models/. R23's single shop works via a base override; two stocked shops will collide in that cache. The seam C/F need: key the cache by type + base (or godverse_id), and preload per-shop on shop entry. G re-emits to whatever wording C lands (LANE_C_PUB).
  2. [Fable-m3 / E] redhill_real doesn't exist — the only Brisbane cache (westend_real) stops ~2.3 km south of Red Hill. The crate has no street until E's build_towns.py gets a Red Hill entry (~center: (-27.4553, 153.0064), span_km: 2.4) and one Overpass fetch. That's E's file and an outward call — G has not touched either.
  3. [John, R24] §7, the write-back question. RULED 2026-07-17: A then B (§7). Carried forward: the hold budget is John's number and is not yet set — Option B is not startable until it is, which is a v5.x item, not a v5.0 blocker.
  4. [Both Fables] §9's beta scoping — depth (one real shop, 357 crates) vs breadth (more shops, which needs other people's data).