# 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
  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 `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, 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 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.
