17 KiB
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 end | 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) |
/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 seedsunopenedPackIds→entitlements. Core 178 tests green. -
Host
CoreEconomyclient (d240a61): typed reqwest, fail-closed, no Python fallback by contract. -
Reader handlers:
/user/credits(handle_credits),/store/purchasegroupfull-gen (handle_purchasegroup, no Python body dependency),userMassInfoeconomy overlay (overlay_massinfo_economy). Invariant test: all three read one Core state. -
Writer handler:
/matchreward (handle_match_end→ Coregrant_reward, oracledestroy_match_bodyshape). -
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_itemsdebit+mint N →createPackResponse; 461 insufficient; 200 {} cancel/unknown/owned_only),handle_pack_open(owned_onlyredeem_entitlementconsume-once; normal debit+mint),handle_quick_sell{_path,_body}(reverse-resolve wire→Core id viaSquadWireResolver→sell_item). Item wire shaping viafut::item::shape_item; numeric ids viaopenfut-identity. -
Pack-content generator (
fut/pack_content.rs): pure seededgenerate_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-safeblock_on) +classify_economy+EconomyServices+Server::try_handle_economywire 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) — theFakeEconomyunit tests missed this;market::off_runtimehops each Core call to a plain OS thread. -
Real dispatch E2E (
economy_full_sequence_through_dispatch): drives the whole cluster throughtry_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 BOTHcard_id(authoritative Core content, minted on buy) andwire_resource_id(FIFA wire, echoed in the auction record);handle_market_listreverse-maps via the catalogby_resourceindex 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 (PileStorelist_by_pile), shaped by the sameshape_club_response/club uses; idempotent, cleared per-item on move-to-club. E2E opens pack 70 and asserts the reveal + idempotent repeat.