Files
OpenFUT/openfut-utas-host/ROUTE_AUTHORITY.md
T
OpenFUT Agent d74e86c065 docs(fifa17): record Rust economy authority proof (E1 cutover ready)
Final per-route authority table: every economy route owner=Rust, Python
proxy=NO (userMassInfo the one hybrid: Python envelope + Rust economy/squad
overlay). Barrier 93a46d4; from_config 43917a0. Proofs: differential 15 PARITY
+ 1 DIFFERENT-BY-DESIGN, concurrency 8x50, failure 10 (complete-sale SAFE, no
E3), importer 5-step, from_config E2E, NEVER-BOTH / no-fallback / stale-reader.
Python source byte-unchanged; oracle suite 32/32.
2026-08-13 22:44:12 +00:00

192 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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}`.
## 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) |
| `/ut/delete/…/match` (POST) | R | -/R | - | - | - | - | - | NO | `handle_match_end``grant_reward` |
| `/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` reward (`handle_match_end` → Core `grant_reward`,
oracle `destroy_match_body` shape).
- **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, 50100 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.