# 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}`. ## Writer routes (mutate cluster state) | Route | Method | Python handler | Writes | Current | Target | Core primitive | |---|---|---|---|---|---|---| | `/ut/game//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//item/` | DELETE | `quick_sell_url_route`→`quick_sell` (utas 1234) | coins, items, purchased | P | **R** | `sell_item` | | `/ut/delete/game//item` | POST | `quick_sell_route`→`quick_sell` (utas 1273) | coins, items, purchased | P | **R** | `sell_item` | | `/ut/game//item` | PUT | `item_route`→`move_items` (utas 1342) | items, purchased | P | **R** | `redeem_entitlement`/move (inventory-only) | | `/ut/game//trade/` | 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//trade/` | DELETE | `delete_trade_route`→`remove_listing` (utas 3935) | listings | P | **R** | listing-state (see note) | | `/ut/game//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, 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.