"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.
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 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 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:
/match/endreward (handle_match_end→ Corecomplete_match, the single exactly-once transaction — NOTgrant_reward). The per-match identity is the id minted byPOST …/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_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.