Files
OpenFUT/openfut-utas-host/ROUTE_AUTHORITY.md
T

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 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_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)
/ut/delete/…/match (POST) R -/R - - - - - NO handle_match_endgrant_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_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 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 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.