Files
OpenFUT/openfut-utas-host/ROUTE_AUTHORITY.md
T
funman300 33300f2ad1 fix(fifa17): serve the match lifecycle instead of proxying it to a dead upstream
"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.
2026-08-21 18:00:28 +00:00

17 KiB
Raw Blame History

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_buypurchase_items
/purchased (POST) R -/R -/R -/R -/R - R NO handle_pack_openredeem_entitlement/mint
/purchased (GET) R - R/- - R/- - R NO shape_purchased_reveal (pile+inventory)
/item/<id> (DELETE) R -/R -/R - - - - NO handle_quick_sell_pathsell_item
/ut/delete/…/item (POST) R -/R -/R - - - - NO handle_quick_sell_bodysell_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_buypurchase_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_routerecord_match (fut_store 554) coins P R grant_reward
/store/transaction PUT store_buyopen_packspend (utas 3702) coins, purchased, packsOpened, nextItemId P R purchase_entitlement (+ redeem_entitlement)
/purchased POST purchased_itemsopen_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_routequick_sell (utas 1234) coins, items, purchased P R sell_item
/ut/delete/game/<sku>/item POST quick_sell_routequick_sell (utas 1273) coins, items, purchased P R sell_item
/ut/game/<sku>/item PUT item_routemove_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_routelist_for_sale (utas 3865) listings, nextListingSeq P R listing-state (see note)
/ut/delete/game/<sku>/trade/<id> DELETE delete_trade_routeremove_listing (utas 3935) listings P R listing-state (see note)
/ut/game/<sku>/squad PUT squad_routesave_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_catalogunopened_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 SquadWireResolversell_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.