33300f2ad1
"There was an error creating your game session. Please try again." on advancing
past the starting XI. The host log names it exactly:
utas-host ERROR passthrough to Python failed: … /ut/game/fifa17/match
utas-host owner=PYTHON_FALLBACK method=POST path=/ut/game/fifa17/match status=502
Neither `match` nor `match/end` was claimed by either classifier, so both fell to
Passthrough. This is the third instance of one defect: `season/list` and
`watchList` were the first two, and like `watchList` the handler already existed
and was simply unreachable — `EconomyRoute::MatchEnd` was produced ONLY by
`POST /ut/delete/game/<sku>/match`, a URL the retail client never sends. The
adapter's `match_wire::create_response` had zero callers.
The family is now classified by PATH SUFFIX and is deliberately VERB-AGNOSTIC:
the strings "PUT" and "DELETE" do not occur anywhere in cardsdll.dll, so verb
selection happens outside the DLL and cannot be pinned statically. Matching on a
verb is precisely how these came to be proxied. All three arms live in the
ECONOMY classifier, because `/match/end` credits coins and `try_handle_economy`
is the barrier guaranteeing a claimed route can never also reach Python — and
because create and end must share the in-flight match id, splitting the family
across two classifiers is what let them diverge.
`POST …/match` is both FutCreateMatch and FutPlayGame, discriminated by an
integer `matchId` in the body exactly as the client serializes them; play acks
`{}` and must not mint a second session. `squad` is omitted from the create
response: nested, half-read, the documented freeze mode.
MATCH IDS GET THEIR OWN IDENTITY SCOPE. The oracle mints them from the same
counter as owned items, which is why an observed match id looks like an item id,
but that is an artifact of a single-counter save file. Here the identity store
keeps a real reverse map, so an item-scoped match id would make
`owned_id_for_wire` resolve a match to a bogus owned card and corrupt quick-sell
and move. A new `(game, "match")` scope costs one constant — the store is
already generic over the pair — and an integration assertion now pins that a
match id never appears in the owned-item reverse map.
ECONOMY: `/match/end` is NOT a new authority. It renders Core's single
exactly-once `complete_match` transaction, the same one the legacy
`/matches/result` path was closed in favour of earlier today, and it still omits
`expire_loans`/`advance_season` so FIFA 17 keeps its own seasons and loans.
THE LATENT BUG THIS EXPOSED, which would have been a silent permanent
under-credit the moment the route became reachable: the per-match identity fell
back to a hash of the request body. Every abandoned match sends a BYTE-IDENTICAL
body (`matchReportId:0`, empty items/matchData/telemetry, flags 0), so all of
them collapsed onto one identity and Core's UNIQUE(profile_id, match_identity)
would refuse every DNF after the first — `applied=false`, nothing awarded, no
error. The identity is now the id minted at create, which is unique per match by
construction; the fingerprint remains only as a floor for an end with no create.
It also removes a durable dependency on `DefaultHasher`, which has no
cross-version stability guarantee yet was being persisted.
One bug of my own, caught by driving the real dispatch rather than the handler:
taking the in-flight id on end looked tidy but sent a REPLAYED `/match/end` down
the fingerprint path — a different identity — so Core paid a second time
(measured: a second +75 for one abandoned match). The id is now read and held,
so a replay reuses one identity and the next create overwrites it.
Verified end to end on the restored club: create → ready → play → end returns the
reversed reward shape (`boostConis` included, `bidTokens`/`qualifiedChampionEventId`
never emitted), a DNF credits once, two replays credit zero, and a second match
with a byte-identical body credits again. Host 115 lib + 36 host_test + 7
economy_integration + concurrency/differential/failure, adapter 217 + 25, all green.
Note for the record: a DNF pays Core's COINS_LOSS (75), not the oracle's 100.
Nothing on the wire settles the number — the client renders whatever we send, and
the oracle's own comment says its values were never reversed — so the declared
Rust authority's table wins rather than being bent to match Python.
245 lines
17 KiB
Markdown
245 lines
17 KiB
Markdown
# FIFA17 UTAS Route Authority (economy cutover gate)
|
||
|
||
Machine-auditable ownership of every FIFA17 UTAS route that touches the economy
|
||
cluster. This is the **deployment gate** for the Rust economy cutover (R1/E1):
|
||
before Rust economy authority is enabled, every row's `Target` must be reached
|
||
and no `Python (proxied)` row may still write Core-owned state.
|
||
|
||
Cluster state = `coins`, owned inventory (`items`/`purchased`), unopened pack
|
||
entitlements (`unopenedPackIds`). `points` has **no** writer (read-only). EASFC
|
||
`powFunds` is a separate balance, out of cluster.
|
||
|
||
Legend: **R** = Rust/Core authoritative, **P** = Python proxied (oracle).
|
||
Evidence lines refer to `fifa17-recon/tools/{utas_server.py,fut_store.py}`.
|
||
|
||
|
||
## Accepted URL prefixes (v1 + v2) — S2 fix
|
||
|
||
The retail FIFA 17 client issues the **Store family** under a `/ut/v2/game/<sku>/`
|
||
prefix (live-observed `PUT /ut/v2/game/fifa17/store/transaction/0`), while other
|
||
routes use `/ut/game/<sku>/`. `classify_economy` normalizes BOTH prefixes to the
|
||
same tail (`ut_tail`), so economy ownership is prefix-agnostic. This closes the
|
||
S2 live-staging defect where the v2 Store BUY escaped to Python.
|
||
|
||
| Route | Accepted method + path shapes (both prefixes) | Economy route |
|
||
|---|---|---|
|
||
| Credits | `GET (/ut/game\|/ut/v2/game)/<sku>/user/credits` | `Credits` |
|
||
| Store catalogue | `GET …/store/purchasegroup[/…]` | `PurchaseGroup` |
|
||
| **Store BUY** | `PUT …/store/transaction` **and** `PUT …/store/transaction/<txn-id>` (numeric, e.g. `…/store/transaction/0`) | `StoreBuy` |
|
||
| **Pack open** | `POST …/purchased` **and** `POST …/purchased/items` | `PackOpen` |
|
||
| **Pack reveal** | `GET …/purchased` **and** `GET …/purchased/items` | `PackReveal` |
|
||
| Quick-sell (path) | `DELETE …/item/<digits>` | `QuickSellPath` |
|
||
| Quick-sell (body) | `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/item` | `QuickSellBody` |
|
||
| Move | `PUT …/item` | `MoveItems` |
|
||
| Match create / play | `…/match` (any verb; `matchId` in body = FutPlayGame) | `MatchCreate` |
|
||
| Match ready | `…/match/ready` (any verb) | `MatchReady` |
|
||
| Match end | `…/match/end` (any verb) — also `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/match` | `MatchEnd` |
|
||
| Market list | `POST …/auctionhouse` \| `…/transfermarket` | `MarketList` |
|
||
| Market query | `GET …/tradePile` **and** `…/tradePile/counts` (CASE-INSENSITIVE: `tradepile` too) | `MarketQuery` |
|
||
| Market buy | `…/trade/<id>` | `MarketBuy` |
|
||
| Market cancel | `DELETE (/ut/delete/game\|/ut/v2/delete/game)/<sku>/trade/<id>` | `MarketCancel` |
|
||
|
||
`store/transaction` matching is BOUNDED to a single all-digit id segment — it
|
||
never absorbs `store/transactions`, `store/transactionfoo`, or
|
||
`store/transaction/<id>/extra` (those proxy to Python as non-economy). Audit
|
||
basis: Python route table `utas_server.py:1420` matches the store family
|
||
"regardless of /ut/game vs /ut/v2/game prefix"; all other economy routes are
|
||
`G = /ut/game/[^/]+`-prefixed (v1-only) and the retail client uses v1 for them.
|
||
|
||
### Round-2 fix (retail `/purchased/items` + `tradePile` case) — candidate supersedes `47ced22`
|
||
|
||
Live re-stage of `47ced22` showed the CONFIRMED retail Store BUY uses
|
||
`POST /ut/game/fifa17/purchased/items` (reveal `GET …/purchased/items`), which the
|
||
exact-tail `purchased` match missed → Python (Core coins unchanged = no debit). Fix:
|
||
`is_purchased_tail` accepts `purchased` AND `purchased/items` (bounded: rejects
|
||
`purchasedfoo`, `purchased/items/extra`); `is_tradepile_tail` matches the `tradepile`
|
||
family case-insensitively (`tradePile`, `tradepile`, `…/counts`) since the FUT hub tile
|
||
polls lowercase while the screen uses camelCase (oracle routes both via `re.I`). Audit
|
||
basis: oracle `utas_server.py:1428` bare `/purchased` regex matches `/purchased/items`;
|
||
`:1539-1540` `tradePile`/`tradePile/counts` are `re.I`. The full contract is the
|
||
machine-auditable `retail_route_matrix` unit test + the `pure_economy_routes` dispatch
|
||
matrix (NEVER-BOTH / no-fallback) + `retail_purchased_items_buy_debits_core_through_dispatch`.
|
||
|
||
## E1 — CUTOVER READY (barrier `93a46d4`, superproject source-ready; NOT deployed)
|
||
|
||
The economy authority barrier is committed: `Server::handle_with_ip` dispatches
|
||
every economy route to Rust/Core (`try_handle_economy`) BEFORE `classify()`, and
|
||
`from_config` (`43917a0`) attaches the economy services in production. Final
|
||
per-route authority (all economy routes owner = Rust, Python proxy = NO):
|
||
|
||
| Route (method) | Owner | coins R/W | inv R/W | ent R/W | pile R/W | listing R/W | reveal | Py proxy | Rust handler / Core primitive |
|
||
|---|---|---|---|---|---|---|---|---|---|
|
||
| `/user/credits` (GET) | R | R/- | - | R/- | - | - | - | NO | `handle_credits` (`balance`+ent count) |
|
||
| `/userMassInfo` (GET) | R (hybrid) | R/- | R/- | R/- | - | - | - | envelope only | `overlay_massinfo_economy`+squad (Py non-economy envelope only) |
|
||
| `/store/purchasegroup` (GET) | R | R/- | - | R/- | - | - | - | NO | `handle_purchasegroup` full-gen + `StoreMode` |
|
||
| `/store/transaction` (PUT) | R | -/R | -/R | - | -/R | - | R | NO | `handle_store_buy` → `purchase_items` |
|
||
| `/purchased` (POST) | R | -/R | -/R | -/R | -/R | - | R | NO | `handle_pack_open` → `redeem_entitlement`/mint |
|
||
| `/purchased` (GET) | R | - | R/- | - | R/- | - | R | NO | `shape_purchased_reveal` (pile+inventory) |
|
||
| `/item/<id>` (DELETE) | R | -/R | -/R | - | - | - | - | NO | `handle_quick_sell_path` → `sell_item` |
|
||
| `/ut/delete/…/item` (POST) | R | -/R | -/R | - | - | - | - | NO | `handle_quick_sell_body` → `sell_item` |
|
||
| `/item` (PUT) | R | - | R/- | - | -/R | - | - | NO | `handle_move_items` (PileStore) |
|
||
| `/match` (any verb) | R | - | - | - | - | - | - | NO | `handle_match_create` (mints the session id; no economy) |
|
||
| `/match/ready` (any verb) | R | - | - | - | - | - | - | NO | `handle_match_ready` |
|
||
| `/match/end` (any verb) | R | -/R | - | - | - | - | - | NO | `handle_match_end` → Core `complete_match` |
|
||
| `/auctionhouse`,`/transfermarket` | R | R/- | - | - | - | -/R | - | NO | `handle_market_list` (MarketStore) |
|
||
| `/tradePile` (GET) | R | R/- | - | - | - | R/- | - | NO | `handle_market_query` |
|
||
| `/trade/<id>` (POST/PUT/GET) | R | R/R | -/R | - | - | R/R | - | NO | `handle_market_buy` → `purchase_item` |
|
||
| `/ut/delete/…/trade/<id>` (DELETE) | R | - | - | - | - | -/R | - | NO | `handle_market_cancel` |
|
||
|
||
`/ut/auth`, `/openfut/fifa17/capability`, `/club`, `/squad/*` are NOT economy
|
||
routes (classify_economy → None) and are unchanged. `userMassInfo` is the one
|
||
intentional hybrid: Python supplies the non-economy envelope, Rust overlays the
|
||
squad AND the economy fields — no Python economy value is authoritative/visible.
|
||
|
||
**Proofs (all green, source-ready):** differential 15 PARITY + 1
|
||
DIFFERENT-BY-DESIGN (market second-buy: Rust single-debit ledger vs oracle
|
||
stateless re-debit; compat NONE); host concurrency 8 races × 50 iters; failure
|
||
injection 10 cases incl. complete-sale-after-commit = SAFE (listing left
|
||
`reserved`, not buyable; one debit + one mint; no E3); importer
|
||
dry-run/apply/restart/idempotency/conflict; `from_config` E2E + restart;
|
||
NEVER-BOTH (economy routes → Rust, Python proxy count 0); no-fallback (dead Core
|
||
→ 503, proxy count 0); stale-reader (Core values only, Python 111 never visible).
|
||
Python source byte-unchanged; oracle suite 32/32 green.
|
||
|
||
## Writer routes (mutate cluster state)
|
||
|
||
| Route | Method | Python handler | Writes | Current | Target | Core primitive |
|
||
|---|---|---|---|---|---|---|
|
||
| `/ut/game/<sku>/match` | POST | `match_route`→`record_match` (fut_store 554) | coins | P | **R** | `grant_reward` |
|
||
| `/store/transaction` | PUT | `store_buy`→`open_pack`→`spend` (utas 3702) | coins, purchased, packsOpened, nextItemId | P | **R** | `purchase_entitlement` (+ `redeem_entitlement`) |
|
||
| `/purchased` | POST | `purchased_items`→`open_pack`+`consume_unopened_pack`+`move_items` (utas 3716) | coins, purchased, unopenedPackIds, items, nextItemId | P | **R** | `redeem_entitlement` |
|
||
| `/ut/game/<sku>/item/<id>` | DELETE | `quick_sell_url_route`→`quick_sell` (utas 1234) | coins, items, purchased | P | **R** | `sell_item` |
|
||
| `/ut/delete/game/<sku>/item` | POST | `quick_sell_route`→`quick_sell` (utas 1273) | coins, items, purchased | P | **R** | `sell_item` |
|
||
| `/ut/game/<sku>/item` | PUT | `item_route`→`move_items` (utas 1342) | items, purchased | P | **R** | `redeem_entitlement`/move (inventory-only) |
|
||
| `/ut/game/<sku>/trade/<id>` | POST/PUT | `trade_route` buy-now `spend`+`add_items` (utas 3895/3899) | coins, items, nextItemId | P | **R** | `purchase_item` (synthetic-seller mint) |
|
||
| `/auctionhouse`,`/transfermarket` | POST | `auctionhouse_route`→`list_for_sale` (utas 3865) | listings, nextListingSeq | P | **R** | listing-state (see note) |
|
||
| `/ut/delete/game/<sku>/trade/<id>` | DELETE | `delete_trade_route`→`remove_listing` (utas 3935) | listings | P | **R** | listing-state (see note) |
|
||
| `/ut/game/<sku>/squad` | PUT | `squad_route`→`save_squad` (utas 3430) | squads (item refs) | **R** (SquadReplace→Core) | R | Core squad tx (already migrated) |
|
||
|
||
Note (market listings): `listings`/`nextListingSeq` are the user's own sale pile;
|
||
the buyable auction inventory is **synthetic** (PACK_POOL-derived, not persisted).
|
||
There is **no** sale-credit, expiry-return, or fee (audit §5). Listing/cancel move
|
||
no coins and no ownership, so they are low-risk; a minimal durable listing store
|
||
(or keeping the synthetic-only model) is the market slice's only decision.
|
||
|
||
## Reader routes (emit cluster state; go STALE if Rust writes while these read Python)
|
||
|
||
| Route | Method | Python handler | Reads | Current | Target |
|
||
|---|---|---|---|---|---|
|
||
| `/user/credits` | GET | `credits_route` (utas 3765) | coins, unopenedPackIds count | P | **R** (`balance` + entitlement count) |
|
||
| `/userMassInfo` | GET | `massinfo` currencies (utas 578) | coins, points, record, items, unopenedPackIds, squad | P (`.squad` overlaid R) | **R** economy fields (coins/packs), squad already R |
|
||
| `/store/purchasegroup` | GET | `store_catalog`→`unopened_packs` (utas 3614) | unopenedPackIds | P + R topology overlay | **R** full-gen (catalog + SessionStore mode + Core entitlements) |
|
||
| `/tradePile` | GET | `tradepile_route` (utas 3911) | items, listings, coins | P | **R** (reads Core inventory/balance/listings) |
|
||
| `/hub`,`/tradePile/counts`,`/watchList` | GET | `hub_data`/`auction_counts`/`watchlist` | items, listings, coins | P | **R** |
|
||
| `/club`,`/club/stats`,`/user/list`,`/clubUser` | GET | club readers→`items` | items | **R** (`/club` Core-backed) / P others | **R** |
|
||
|
||
## Writer → Core primitive map (Phase 2)
|
||
|
||
| Python writer | Reachable | Core primitive (services::economy) |
|
||
|---|---|---|
|
||
| `spend` (pack buy leg) | YES | `purchase_entitlement` debit leg |
|
||
| `open_pack` | YES | `purchase_entitlement` + `redeem_entitlement` (buy→entitlement→open split) |
|
||
| `consume_unopened_pack` | YES | `redeem_entitlement` (consume-once) |
|
||
| `move_items` (purchased→club) | YES | inventory add within `redeem_entitlement` / move op |
|
||
| `add_items` (market mint) | YES | `purchase_item` (debit + mint) |
|
||
| `quick_sell` | YES | `sell_item` (remove + credit) |
|
||
| `record_match` (coins) | YES | `grant_reward` (credit) |
|
||
| `new_item_id` | YES | adapter numeric-id via `openfut-identity` (Core ids opaque) |
|
||
| `list_for_sale` / `remove_listing` | YES | listing-state (market slice) |
|
||
| `grant_coins` | **NO** (dead) | — drop |
|
||
| `grant_unopened_pack` | **NO** (test-only) | — drop |
|
||
| `save_squad` | YES | already Core-authoritative (SquadReplace) |
|
||
|
||
## Single-writer rule
|
||
|
||
Coins live **only** in Python `fut_profile.json` today (Core `clubs.coins` is a
|
||
separate imported value). Because every coin reader (`credits`, `userMassInfo`,
|
||
`tradePile`, market bodies) reads that same JSON, the coins cluster **must flip
|
||
readers and writers together** — a partial flip desyncs the client's counter
|
||
(audit "STALENESS RISK"). The coherent first cut is therefore the whole coins
|
||
bundle: 4 writer routes + `credits`/`userMassInfo`/`purchasegroup` readers, all
|
||
on Core, seeded by a one-time profile import into Core.
|
||
|
||
## Proxied-route safety (R1 requirement: no unsafe YES)
|
||
|
||
After cutover, every remaining `Python (proxied)` route MUST have
|
||
`economy state touched = NONE`. Routes with `economy state touched != NONE` are
|
||
part of the migration cluster and MUST be Rust before R1. This table is the
|
||
audit source; the host `classify()` is the enforcement point (NEVER BOTH).
|
||
|
||
## Cutover progress (2026-08-13)
|
||
|
||
Landed (Core authority + transport + several handlers; classifier NOT yet flipped):
|
||
|
||
- **Core economy HTTP API** (`d32dc6e`, `bcc4f51`): generic `/economy/{balance,
|
||
entitlements,purchase-entitlement,redeem-entitlement,sell-item,grant-reward,
|
||
purchase-item,purchase-items}`, server-side club resolution, atomic. Import now
|
||
seeds `unopenedPackIds`→entitlements. Core 178 tests green.
|
||
- **Host `CoreEconomy` client** (`d240a61`): typed reqwest, **fail-closed, no
|
||
Python fallback** by contract.
|
||
- **Reader handlers**: `/user/credits` (`handle_credits`), `/store/purchasegroup`
|
||
full-gen (`handle_purchasegroup`, no Python body dependency), `userMassInfo`
|
||
economy overlay (`overlay_massinfo_economy`). Invariant test: all three read one
|
||
Core state.
|
||
- **Writer handler**: `/match/end` reward (`handle_match_end` → Core
|
||
`complete_match`, the single exactly-once transaction — NOT `grant_reward`).
|
||
The per-match identity is the id minted by `POST …/match`, which is what makes
|
||
two abandoned matches (whose bodies are byte-identical) both payable while a
|
||
replay of either is refused.
|
||
- **Adapter policy mappers** (`181bd94`): match reward, pack price.
|
||
- All Core-backed, fail-closed (503, never Python), `FakeEconomy`-tested.
|
||
- **Store/item writers** (`4d2b8b9`, `economy_store.rs`, unrouted): `handle_store_buy`
|
||
(`purchase_items` debit+mint N → `createPackResponse`; 461 insufficient; 200 {}
|
||
cancel/unknown/owned_only), `handle_pack_open` (owned_only `redeem_entitlement`
|
||
consume-once; normal debit+mint), `handle_quick_sell{_path,_body}` (reverse-resolve
|
||
wire→Core id via `SquadWireResolver` → `sell_item`). Item wire shaping via
|
||
`fut::item::shape_item`; numeric ids via `openfut-identity`.
|
||
- **Pack-content generator** (`fut/pack_content.rs`): pure seeded
|
||
`generate_pack_contents`, gold-tier + special_chance PLACEHOLDER policy, fail-closed.
|
||
- **Market** (`market_store.rs`/`pile_store.rs`/`market.rs`, unrouted): durable sqlx
|
||
SQLite listing store (WAL+`BEGIN IMMEDIATE`, states active/reserved/sold/cancelled,
|
||
CAS reserve/complete/rollback, typed errors), durable pile metadata,
|
||
`handle_market_{list,query,cancel,buy}` (synthetic-seller buy-now = reserve→
|
||
`purchase_item`→complete_sale, race-safe two-buyer) + `handle_move_items`.
|
||
- Adapter +7 / host +43 tests incl. `two_reservers_exactly_one_wins`,
|
||
`two_buyers_exactly_one_sale_one_debit`, `state_survives_reopen`,
|
||
`move_persists_across_reopen`. clippy -D warnings + rustfmt clean.
|
||
|
||
- **Async runtime bridge + dispatch** (`580d80a`, `async_bridge.rs`, unrouted):
|
||
`AsyncBridge` (one process-lifetime multi-thread Tokio runtime, nested-safe
|
||
`block_on`) + `classify_economy` + `EconomyServices` + `Server::try_handle_economy`
|
||
wire ALL the handlers into real dispatch. `classify()` is untouched (handler
|
||
wiring ≠ authority cutover). Load-bearing fix: the async market handlers call the
|
||
BLOCKING reqwest Core client, which panics if run while a runtime is entered
|
||
(`reqwest::blocking::wait::enter`) — the `FakeEconomy` unit tests missed this;
|
||
`market::off_runtime` hops each Core call to a plain OS thread.
|
||
- **Real dispatch E2E** (`economy_full_sequence_through_dispatch`): drives the whole
|
||
cluster through `try_handle_economy` + the bridge against a live in-process Core
|
||
(fifa17 dev content, 100k coins) over the real blocking client, no fakes — Store
|
||
BUY (pool+shape+debit+mint 5), credits, quick-sell, match WIN, market
|
||
list→query→buy→query(sold)→second-buy-fails, cancel, move, + durable store reopen.
|
||
|
||
**Not flipped:** `classify()` still routes every economy route to Python. Per the
|
||
single-writer rule the flip is one coherent barrier once ALL writers+readers are
|
||
proven — a partial flip would desync coins.
|
||
|
||
**Remaining before barrier:** (1) **Python-oracle differential** runner (subprocess,
|
||
isolated fixture, all ~13 ops, PARITY / DIFFERENT-BY-DESIGN); (2) **host-level
|
||
concurrency matrix** through real dispatch (two-BUY / dup-open / dup-sell /
|
||
two-buyers / reward+BUY / move+sell / id-collision, 50–100 iterations); (3)
|
||
**failure-injection** seams (Core failure at each step → no debit/grant/double);
|
||
(4) **importer** disposable-DB apply / idempotency / restart; (5) `from_config`
|
||
attachment of `EconomyServices` + `classify()` barrier flip; then no-Python-fallback
|
||
/ writer-unreachable / stale-reader / NEVER-BOTH proofs. Retire in-memory
|
||
`ProfileEconomy` from any prod path.
|
||
|
||
- **Market resourceId→Core card_id mapping** (`fe72f0d`): listings carry BOTH
|
||
`card_id` (authoritative Core content, minted on buy) and `wire_resource_id`
|
||
(FIFA wire, echoed in the auction record); `handle_market_list` reverse-maps via
|
||
the catalog `by_resource` index and fails closed on an unmappable resource. The
|
||
E2E now survives a FULL Core+store restart (synthetic mint is real content).
|
||
- **GET /purchased reveal** (`747cc23`): the reveal is the durable FIFA "purchased"
|
||
pile (PileStore `list_by_pile`), shaped by the same `shape_club_response` /club
|
||
uses; idempotent, cleared per-item on move-to-club. E2E opens pack 70 and asserts
|
||
the reveal + idempotent repeat.
|