Files
OpenFUT/openfut-utas-host/ROUTE_AUTHORITY.md
T
OpenFUT Agent 884ecbba64 docs(route-authority): async bridge + dispatch wiring + real E2E landed (580d80a)
Records the AsyncBridge + classify_economy + try_handle_economy dispatch
(unrouted) and the economy_full_sequence_through_dispatch E2E, the
reqwest-blocking-in-async fix (off_runtime), and narrows Remaining to: Python
differential, host concurrency matrix, failure injection, importer, then the
from_config attachment + classifier barrier + reachability proofs. Flags the
two market-handler gaps (resourceId->card_id mapping; GET /purchased reveal
cache).
2026-08-13 21:30:56 +00:00

151 lines
10 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}`.
## 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.
**Two market-handler correctness gaps to close before the barrier:** (a) the
synthetic-seller buy mints `listing.card_id`, which `handle_market_list` sets to the
numeric wire `resourceId` string — a real Core `card_id` requires a resourceId→card_id
mapping (Core's content preflight rejects an unmapped mint on reboot); (b) `GET
/purchased` reveal needs a last-opened cache (POST returns metadata; items are polled
via GET) — Store BUY (open-on-buy) already returns items inline, so this only affects
owned reward-pack (pack 70) opens.