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

238 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
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, 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.