Files
OpenFUT/docs/PRODUCTION_AUTHORITY_MATRIX.md
T
funman300 aa2abc2772 fix(market): make the transfer market work end-to-end (live-verified)
Four defects found by driving a real FIFA 17 client. Each was independently
sufficient to break listing, so all four had to go:

1. Every owned card was shaped `untradeable: true` (adapter item.rs), so the
   client greyed out "Place/List on Transfer Market" for the whole club. Owned
   and pack-pulled cards are TRADEABLE in FIFA 17; the oracle forces this off
   for owned copies too (item_def keeps `true`; instances do not).

2. `POST /auctionhouse` required `itemData.resourceId`, which the client's
   FutISStart body never sends (the oracle lists by wire id ALONE). Missing it,
   the handler fail-closed and returned 200 while persisting NOTHING. It now
   resolves server-side: wire id -> Core owned instance -> its card_id (minted on
   a synthetic buy) + FIFA resourceId (the auction record). This also enforces
   that a listing can only name a card the club actually owns.

3. An auction record's `itemData` was a 4-field STUB, so the Transfer List had a
   row the client could not draw -> "1 item listed" but no visible sale. A
   listing now persists a full shaped-card SNAPSHOT (new `listings.item_json`,
   additive migration) built by the same `shape_item` shaper `/club` and the
   squad projection use, so the auction card renders identically to the club
   card. The seller's own pile stamps `itemState: listFS`; market search keeps
   `forSale` (the oracle distinguishes these).

4. `/tradePile/counts` shared a handler with `/tradePile`. They are DIFFERENT
   deserializers: `/counts` is FutGetAuctionCount, five scalar ints
   (count/maxAuctionsAllowed/offered/selling/sold) that it reads and skips
   everything else. Served the `auctionInfo` body it left every count at 0, so
   the Transfer List screen showed no active sale while the hub tile showed one.
   New Route::MarketCounts, classified BEFORE the base tradePile matcher (which
   also accepts the /counts path).

Also: a listed card no longer appears in the club. `/club` and the hub's
`clubPlayers` now exclude the transfer pile. Pile membership is host-owned state
Core cannot filter on, so when anything is hidden `/club` reuses the existing
local-filter path (the one `rare=SP` already needed) and paginates the
club-visible set -- letting Core paginate would return short pages. With nothing
hidden the fast Core-paginated path is untouched, and only an EXPLICIT non-club
pile hides a card, so no-pile-row items still default to the club.

Fixed 5 pre-existing test fixtures across 4 targets that listed FABRICATED wire
ids -- only "valid" because the old handler skipped the ownership check.

Tests: 14 targets green + clippy clean, incl. new coverage for the 5-int tally
(asserting it must NOT carry auctionInfo), the full-card snapshot + listFS, and
club pile-exclusion with full-width pagination. The differential test against the
live Python oracle passes.

Verified live on prod: listed=true with a 21-field snapshot; counts
{count:1,selling:1,maxAuctionsAllowed:100}; tradePile renders the 94-rated card;
clubPlayers 1966 -> 1961 (exactly the 5 trade-pile items); listed wire absent
from the club page. Operator confirmed the card is visible in the Transfer List.
2026-08-17 18:03:06 +00:00

13 KiB

Production Authority Matrix (post-P1 -> P2-routes promoted 2026-08-17)

P2-routes promoted to production 2026-08-17. prod-host swapped P1 e5be8730 -> post-P1 fda40d12; catalog 35a0913b -> 9f6addaa (resolves all owned assets, dropped_no_asset=0). Every previously-Python non-economy route now RUST in prod (OBSERVED prod log). Rollback hot: restore P1 binary + backup catalog (economy-2026-08-17-p2/backup/).

Definitive inventory of every production-reachable FIFA17 route/service and its current owner. Derived from the live prod-host dispatch log (owner= labels, real Client A session 2026-08-14), openfut-utas-host/ROUTE_AUTHORITY.md, and the prod container config (OPENFUT_SERVERS="blaze roster pow").

Evidence labels: OBSERVED (live log/db), PROVEN (test), INFERRED, HYPOTHESIS.

Legend: owner R = Rust/Core, P = Python oracle (:8199 proxied via PYTHON_FALLBACK).

UTAS HTTP (front door: openfut-utas-host :8099)

ECONOMY — already Rust (Python economy hits = 0, OBSERVED)

Method/path (tail) Prod owner Writes state Rust handler Py proxy
GET /user/credits R no handle_credits NO
GET /store/purchasegroup[/all] R no handle_purchasegroup NO
PUT /store/transaction[/] R coins,inv,pile handle_store_buy NO
POST /purchased[/items] R coins,inv,ent,pile handle_pack_open NO
GET /purchased[/items] R no shape_purchased_reveal NO
DELETE /item/ R coins,inv handle_quick_sell_path NO
POST /ut/delete/../item R coins,inv handle_quick_sell_body NO
PUT /item R inv,pile handle_move_items NO
POST /ut/delete/../match R coins handle_match_end NO
POST /auctionhouse,/transfermarket R listings handle_market_list NO
GET /tradePile R no handle_market_query NO
GET /tradePile/counts R no handle_market_counts NO
/trade/ (POST/PUT/GET) R coins,inv,listings handle_market_buy NO
DELETE /ut/delete/../trade/ R listings handle_market_cancel NO

Transfer-market semantics (live-verified 2026-08-17). Three things here are load-bearing and were each a live defect:

  1. /tradePile and /tradePile/counts are different deserializers and MUST NOT share a handler. /counts is FutGetAuctionCount: five scalar ints (count, maxAuctionsAllowed, offered, selling, sold) and nothing else. Served the auctionInfo body it skips every field, leaving the counts at 0 — the hub tile shows a listing while the Transfer List screen shows no active sale.
  2. An auction record's itemData MUST be the full card object (the same shape /club emits), not a stub. A listing therefore persists a shaped-card SNAPSHOT (listings.item_json) at list time. The seller's own pile stamps itemState: listFS; market search uses forSale.
  3. POST /auctionhouse resolves the listed card server-side from the wire item id (wire → Core instance → card_id + resourceId); the client's FutISStart body carries only the id. This also enforces that you can only list what you own.

NON-ECONOMY — Rust-owned (route migration 2026-08-17, OBSERVED prod log)

Route Owner Notes
POST /ut/auth (+ /ut/delete/auth) R Rust mints the SID (OPENFUT-SID-{:016X}); opens Rust session; adopts persona from body. No Python.
POST /openfut/account/sync R full Rust envelope; coins/unopenedPacks from Core; clubName OpenFUT / clubAbbr OFC constants
GET /userMassInfo R FULL Rust envelope (userInfo+squad+settings+pileSizeClientData); no Python. coins from Core, squad == /squad/active
GET/PUT /clientdata/ R host ClientDataStore (JSON-persisted); PUT acks {}, GET returns blob or {}
capability (/openfut/fifa17/capability) R -> Bound (CleanV1)
GET /club, /club/* readers R Core-backed collection (dropped_no_asset=0). Cards in the transfer pile are excluded — a listed card has left the club. Pagination then runs over the club-visible set (Core cannot filter on host-owned pile state, so letting it paginate would yield short pages); with nothing hidden the fast Core-paginated path is unchanged. Only an EXPLICIT non-club pile hides a card, so no-pile-row items default to the club.
GET /squad/0, /squad/active, /squad/list; PUT /squad/ R Core squad projection + tx; GET /squad/0 == active squad (verified structurally identical)
GET /user/accountinfo R {}
GET /user R {"userInfo": …} — same userInfo builder as userMassInfo (shared); squad rating is Core-authoritative (DIFFERENT-BY-DESIGN vs Python's stale value)
GET /settings R {"configs":[]}
GET /leaderboards/options R {}
PUT /match/reset R {}
GET /phishing/trusteddevice R security-question stateless ack
GET /hub R Core-derived counts; clubPlayers excludes transfer-pile cards (1966 → 1961 with 5 listed/moved), auctionCount/tradePile from the durable market store
GET /club/stats/{year,consumables,staff,country,league,team} R Core aggregation; context buckets keyed nation/league/team (owned=1982); staff={}
GET /store, /match/keepalive, /captcha, /tfa, /livemessage, /activeMessage R unconditional constant acks (byte-identical to the oracle; StaticAck route)
GET /watchList (+ PUT/POST/DELETE) R empty watch list + authoritative Core credits; add/remove is a no-op ack (oracle persists none)
GET /season, /tournament, /champion, /clubUser, /user/list R FUT modes + club-identity off → {} (FeatureOffEmpty; byte-identical to the flag-off oracle). Migrated + deployed 2026-08-17
POST /ut/.../match/end (DestroyMatch) R economy reward (coins credited via Core grant_reward)
GET /item/resource, /defid R {itemData:[item_def…]} — asset=rid&0xffffff; hardcoded Ronaldo (20801) + placeholder ("Player",75,CM,attrs 70), mirroring the oracle's item_def. Client renders from its LOCAL DB, so the placeholder is exact parity. Migrated + deployed 2026-08-17
GET /marketdata, /marketdata/pricelimits R suggested pricing, constant band 150..15000. /pricelimits = bare ARRAY (one per defId); plain /marketdata = OBJECT — container type is load-bearing (object-where-array froze a live client). Migrated + deployed 2026-08-17

NON-ECONOMY — still Python (PYTHON_FALLBACK)

Method/path Owner Reason
POST /user/club (rename) P mutating club rename; Core has clubs but rename needs a Core write (deferred). Reads clubUser/user/list are now Rust.
GET /squad/ (n≠0, non-active) P no multi-squad Core model (Rust owns squad/0, squad/active, /squad/list, PUT)
/squad/mode/draft/* P FUT Draft mode
GET /leaderboards, /sbs/* P mode-gated (FUT_MODES/_SBC off → {}/content; /sbs/sets ships content); real behavior needs the mode logic ported. season/tournament/champion are now Rust.
POST /ut/.../match (CREATE), /match/ready (READY), /match (PLAY) P match handshake legs; no match ever played in-game (see docs/MATCH_LIFECYCLE.md)

AUXILIARY SERVICES (prod container OPENFUT_SERVERS="blaze roster pow")

All aux services are Python in production today (container entrypoint.sh runs blaze_responder_v3b.py/roster_server.py/pow_server.py). Rust equivalents exist outside Docker; deploying them is operator-gated (production deployment forbidden here).

Service Rust crate Completeness Prod owner Reachable Blocker to candidate
Blaze (:42130) openfut-blaze-host + openfut-protocol-blaze + adapter::blaze COMPLETE, gate-proven to real FUT (Gate 10: 3 logins, 10 pack opens, 448/448 py suite) Python yes ERRC error-reply placement unresolved; wire into candidate bring-up
Redirector (:42127 TLS) openfut-redirector-host + openfut-tls (OpenSSL vendored) COMPLETE, 1 live handshake 2026-08-11 (TLSv1.2/AES256-GCM-SHA384) Python yes never full-path gated; cert consistency
Roster (:8081) openfut-roster-host COMPLETE, oracle-parity + lifecycle tests, unit-only Python yes never live-gated
POW (:8094 + :8080) NONE (only pow_server.py) no Rust host; 58 templates, bodies placeholder Python yes LARGEST: no crate + bodies un-reversed
Nucleus (:42131) none (advertised string only) n/a Python stub (advertised, unused) NO (0 live hits) DEAD in current flow — keep advertising URL, no migration

RETIRED/out-of-scope: fifa-blaze (FIFA23 capture stub), openfut-bridge (FIFA23). Not in FIFA17 flow.

NON-ECONOMY UTAS TARGET OWNER (confirmed via scouts)

Core already exposes GET/PUT /settings, GET /club|/collection|/statistics|/profile, /squad/*. New Rust arm = Route variant + classify() arm (lib.rs:118-140) + owner-labelled handler.

Route Port complexity Target Notes
user/accountinfo trivial-static ({}) R host constant
settings trivial-static ({"configs":[]}) R host constant / Core /settings
leaderboards/options trivial-static R host constant
match/reset trivial-static ({}) R host constant ack
phishing/trusteddevice small (validate hex + constant) R security-question: stateless ack, always verified/trusted
clientdata/userHubData small stateful R Core PUT /settings upsert keyed userHubData
hub medium (derived counts) R Core collection + market counts
club/stats/year medium (rating-tier aggregation) R Core /collection + /statistics
club/stats/consumables medium R BLOCKED on 17-consumable import
club/stats/staff trivial ({}) or derived R BLOCKED on 3-staff import
account/sync small-medium (persona select + save) R hardest: no direct Core route; host session/persona logic
ut/auth envelope small (SID mint) R (currently OBSERVE) Python still mints envelope; boundary decision

MIGRATION PRIORITY ORDER (Phase 12)

  1. Content gap consumable+staff emit (unblocks club/stats/{consumables,staff}) — import crate.
  2. Trivial-static host routes: accountinfo, settings, leaderboards/options, match/reset, phishing/trusteddevice.
  3. clientdata/userHubData (Core settings upsert).
  4. hub + club/stats/year (Core-derived aggregation).
  5. userMassInfo full ownership (envelope scaffold; econ+squad already Rust).
  6. account/sync + ut/auth envelope (account/session boundary).
  7. Aux candidate wiring (blaze/roster/redirector already built) + POW (blocked) + Nucleus (dead).

ECONOMY EXPECTATION

Python economy hits = 0 (OBSERVED live + PROVEN by NEVER-BOTH/no-fallback tests). Any nonzero Python economy hit = P1 regression, priority zero.

STATUS

Baseline + economy + Rust-owned non-economy rows: OBSERVED/PROVEN. Aux + non-economy target owners: confirmed via HostSourceMap/PythonContractMap/AuxServiceMap/ContentGapMap scouts.

CANDIDATE MIGRATION STATUS (post-P1 progress, off-production)

DONE (Rust-owned in candidate source; committed on top of 5020137; workspace tests green):

  • Content gap: consumable+staff emit (20 instances / 18 defs; verified real-profile re-import 1962+20).
  • Non-economy routes migrated to Rust: user/accountinfo, settings, leaderboards/options, match/reset, phishing/{trusteddevice,question,validate}, club/stats/staff, hub, club/stats/{year,consumables}. (hub clubPlayers = owned player count from Core; may be < Python profile count by the deferred Legend instances = DIFFERENT-BY-DESIGN.)
  • Reachability reporter (scripts/openfut-reachability.py) gates Python-hit invariants.

RESIDUAL PYTHON (still proxied; each has a concrete blocker, not ordinary difficulty):

  • club/stats/{country,league,team} — RESIDUAL (nation/league/team context sub-screens). The global MY-CLUB stat set (club/stats/{year,consumables,staff}) is now Rust (adapter club_stats.rs faithful port of fut_club_stats.py VOCAB + counts, Core-accurate over real imported content, staging-verified RUST route=club-stats owned=1982 players=1962). The per-nation-bucket context sub-screens still proxy Python (low value, unrecognized atoms inert); migrate with a live context capture if ever needed.
  • account/sync — launcher-facing (POST /openfut/account/sync, pre-auth); envelope has fields not in Core (clubAbbr, established, profilePath, accountFunds cap); changing it risks the launcher. Needs a Core account endpoint or host account logic + launcher-contract verification.
  • userMassInfo — SAFE hybrid today (Rust overlays economy+squad on Python envelope). Full ownership needs a real full-envelope capture first (missing one scaffold field breaks a hot route); deferred.
  • ut/auth — ORDERING-BLOCKED: still-proxied Python routes rely on Python's session table, so auth must stay proxied (Python mints SID, Rust OBSERVEs) until every session-needing route is Rust. Migrate LAST.
  • clientdata/userHubData — BLOCKED: Core /settings supports only 2 fixed keys (difficulty, preferred_formation); storing an arbitrary blob needs a frozen-Core change or a new host store. Low-value UI-pref blob; kept Python per Phase 23 (no Python-by-ideology).

AUX (Rust built, deploy = operator-gated container cutover, NOT this task): blaze/roster/redirector. POW = blocked (no crate, bodies un-reversed). Nucleus = dead (0 live hits).