John ruled at the R24 kickoff: v5.0 ships on Option A (sandbox) — an in-game purchase never touches real monsterrobot/Square inventory; Option B (reservation via the POS's existing hold_expires_at) is CHARTERED as a v5.x upgrade behind a hold budget John sets; Option C is not chartered and happens only as a deliberate business launch, if ever. Recorded inline in §7 with the options preserved beneath it, since the trade-offs are the record of why A-then-B was chosen. Written in as binding, because a one-line ruling isn't a mechanism: /reserve + /buy stay ABSENT, not stubbed behind a flag (a sandbox a config typo can flip isn't a sandbox); the tier-2 reader connects read-only, enforced at the credential — a role with SELECT on inventory/crate/disc_cache and nothing else, which carries the §9 PII fence in the same grant; and the gate asserts the write verbs 404 and the role's grants are read-only, because "we didn't call buy()" proves nothing — the vacuous-gate law turned on ourselves. Option B's preconditions are stated so it can't slide in quietly: the hold budget (John's number, NOT YET SET — B is not startable until it is; a v5.x item, not a v5.0 blocker), the-shop-always- wins on a race with an in-store Square sale, a staff-visible `held: GODVERSE` reason code, and soak before ship. Doc header/§10 updated so the whole file tells one story: §7 ruled, the rest still DESIGN pending both Fables' sign-off, no server code before that. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
293 lines
17 KiB
Markdown
293 lines
17 KiB
Markdown
# G3 — THE ECONOMY BACKEND (design doc)
|
||
|
||
*Lane G, R23 draft. Drafted per the v5 charter ([V5_REAL_SHOP.md](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]
|
||
|
||
`monsterrobot` — **147 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 0–1, 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 [DESIGN]
|
||
|
||
Read-only in v5.0; the write verbs are drafted here but **gated on §7**.
|
||
|
||
```
|
||
GET /godverse/v1/shop/<godverse_id>/stock?crate=<id>&since=<iso>
|
||
-> { 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/<godverse_id>/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]
|
||
```
|
||
|
||
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
|
||
built, R9) zero-areas the sleeve. Sold-means-gone is a *diff*, not a re-fetch.
|
||
- **`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).
|