Spec 035 — AI stateless-per-request, ranking to the browser
Status: implemented Branch: 035-ai-ranking-reconcileEpic: gw2.app alignment — Wave 3, Spec 5. Surface: net removal + one reshape (no new endpoints).
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Problem
Two per-user compute paths still run the wrong way round, and they are the last things standing between the codebase and the epic invariant (anything cacheable is user-agnostic and keyless; anything per-user is stateless and never cached):
- Ranking is server-side.
GET /legendaries/ranking(RankingService) resolves all 21 Gen-1 graphs, then overlaps a ~40-request account scan (AccountService.getOwnedItems: material storage + bank + shared inventory + every character's inventory) through our single origin IP, folds live prices, and ranks. Every ingredient of that answer — cached trees (Surface A), prices, account state — is already available in the browser after spec 034. The server is doing per-user work the client can do itself, on our shared rate budget, with the key flowing to our origin. - The AI layer takes the user's key.
POST /assistant/askand the MCPpriory_legendary_rankingtool both callRankingService.rank(key)with the caller's GW2 key. The assistant's only genuine server reason-to-exist is the Anthropic intent parse (a service secret that cannot ship to the browser); the ranking arithmetic wrapped around it does not need to be server-side at all.
This spec moves ranking into the browser, shrinks the assistant to a stateless keyless intent classifier, retires the server ranking path (rather than porting it), documents the MCP's already-correct stateless-key contract, and closes the epic with an audit proving no backend endpoint caches per-user data.
User stories
Ordered by priority. Each is independently testable and shippable.
P1 — The legendary ranking, computed in the browser
As a player, I want "which legendary am I closest to crafting / most profitable to craft" answered by my own browser from data it already has, so the answer costs our origin nothing and my key never leaves the browser.
Independent test: with a key in localStorage, the ranking page renders the same ordering and per-row figures the server used to return, computed entirely client-side — a network trace shows the trees fetched from Surface-A GET /recipe-graph/:itemId, prices and account reads going to api.guildwars2.com directly, and no /legendaries/ranking request to our origin (the route no longer exists).
Acceptance scenarios
- Given a stored key, when the ranking page loads, then the browser resolves each of the 21 Gen-1 ids from Surface-A
GET /recipe-graph/:itemId, prices them browser→ArenaNet, and issues no/legendaries/rankingrequest to our origin. - Given the same fixture account and fixed prices the retired server ranking was tested on, when the browser computes the ranking, then the row ordering and every row's
netSell/marketCost/myCost/personalProfit/gatedInputs/craftableequal the retired server output. - Given a stored key, when the browser builds the owned-items map, then it reads material storage, bank, shared inventory, and every character's inventory from
api.guildwars2.com/v2/*directly, summed per item id withnullslots skipped — the same four sourcesgetOwnedItemsread. - Given a key missing the
inventories/charactersscope, when the account scan runs, then ArenaNet's own 4xx surfaces through the app's existing error/connect boundary (no new handling built).
P2 — The assistant is a stateless, keyless intent classifier
As a player, I want to ask the assistant a natural-language question without my GW2 key or account data ever reaching GW2Priory's servers, because the server only needs to understand the question — not read my account.
Independent test: POST /assistant/ask succeeds with a body of { question } and noAuthorization header; it returns { intent } only; a trace shows the server makes no GW2 call and reads no GW2 key; the response carries Cache-Control: private, no-store.
Acceptance scenarios
- Given a question, when
POST /assistant/ask { question }is called with noAuthorizationheader, then it returns{ intent: "closest_to_craft" }or{ intent: "unsupported", message }and makes no GW2 API call. - Given the assistant answers
closest_to_craft, when the assistant view renders, then it shows the ranking computed client-side (P1) and a summary line built in the browser — the server returned no ranking rows. - Given any request, when the response is inspected, then it carries
Cache-Control: private, no-storeand the server has persisted nothing (no key, no question, no account data at rest or in a log).
P3 — The MCP is stateless-per-request, and no longer offers whole-account ranking
As an MCP client, I want the tools that read my account to take my key per request and never store it, and I accept that whole-account ranking is not offered server-side (it moved to the web app).
Independent test: the MCP tool inventory no longer lists priory_legendary_ranking; every remaining account tool takes its key only from the per-request X-GW2-Key header, declares no key parameter, and the server is rebuilt per request (nothing cached across requests).
Acceptance scenarios
- Given the registered MCP tools, when they are listed, then
priory_legendary_rankingis absent and the other nine tools are unchanged. - Given an account tool call with the
X-GW2-Keyheader, when it runs, then the key is read only from that header, is never a tool argument, and appears in no log, response, or persisted state. - Given two sequential MCP requests, when both run, then each builds its own server instance with its own per-request key — no key or account state leaks between them.
Endpoints & contract
Endpoints and OpenAPI are first-class spec content (epic cross-cutting rule).
Reshaped (Surface B, unchanged surface kind)
POST /assistant/ask — stays Surface B (private, no-store), loses its key and its ranking payload.
Request —
{ question: string }(1–500 chars, non-blank; unchanged). TheAuthorizationheader is no longer read; the route requires no credential.Response
AssistantAnswer— reshaped to the intent alone:jsonc{ "intent": "closest_to_craft" } // or { "intent": "unsupported", "message": "…" }The
summary: stringandresults: RankingRow[]fields are removed from the response. (This is the existingAssistantIntentshape, withreasonsurfaced asmessage.)Server work — the Anthropic intent parse only. No GW2 call, no key, no account data.
Removed (Surface B → deleted)
| Method & path | Was | Now |
|---|---|---|
GET /legendaries/ranking | server account scan + priced graphs, ranked (key via Authorization) | deleted — the browser computes it |
Deleting the route removes its path and the RankingListDto / RankingRow / GatedInput response schemas from the generated OpenAPI, and on orval regen removes the legendariesControllerRanking generated web client.
MCP tool inventory (Surface B, one tool removed)
| Tool | Change |
|---|---|
priory_legendary_ranking | removed (with RankingService) — whole-account ranking moved to the web app |
the other nine gw2_* / priory_* tools | unchanged |
Documented Surface-B contract (unchanged, made explicit): the caller's GW2 key arrives per request in the X-GW2-Key header, read by mcp.controller.ts and nowhere else; it is never a tool argument, never logged, persisted, or returned; the MCP server is rebuilt per request (stateless transport). No per-user data is cached.
New browser → ArenaNet calls (documented contracts — NOT our OpenAPI)
The owned-items scan the browser needs for P1. These are ArenaNet's own endpoints, called from the browser with the localStorage key as ?access_token= (spec 034 transport, V2 below). They are not our surface, so they are documented here, not added to our OpenAPI. /v2/account/materials already exists (spec 034); the other three are new browser fetchers.
| Call | Auth (scope) | ArenaNet cache-control | Used for |
|---|---|---|---|
GET /v2/account/materials | key (inventories) | private | owned: material storage (already fetched, 034) |
GET /v2/account/bank | key (inventories) | private | owned: bank |
GET /v2/account/inventory | key (inventories) | private | owned: shared inventory |
GET /v2/characters?ids=all | key (characters, builds/inventories) | private | owned: every character's bags |
Exact response shapes and the per-character fetch shape are confirmed in V2. The four sources are summed per item id, null slots skipped — reproducing the retired getOwnedItems.
Unchanged
GET /legendaries (Surface A), GET /recipe-graph/:itemId (Surface A, priceless), GET /health (Surface B), and the nine remaining MCP tools. No shape change.
Generated-contract handling (orval / OpenAPI)
apps/api/openapi.json and apps/web/src/api/generated/ are committed artifacts; root verify:contract regenerates both and runs git diff --exit-code. This spec therefore regenerates and commits both: generate:openapi drops /legendaries/ranking and rewrites /assistant/ask; orval removes the ranking client and updates the assistant client, so verify:contract passes on this branch.
Requirements
- R1 — Ranking is computed in the browser. For each of the 21
GEN1_IDS, the client fetches the Surface-A priceless tree (GET /recipe-graph/:itemId), prices it browser→ArenaNet (spec 034fetchPrices, keyless, 199-id batched), folds viapriceGraph, aggregates viaaggregateNeed, and personalises viamergeOwnVsNeedagainst a browser-built owned-items map — then builds oneRankingRowper legendary. No ranking request is made to our origin. - R2 — The ranking helpers move to their single consumer (web), the engine stays shared. The pure functions
toRankingRow,byPersonalProfitDescIdAsc,byRemainingGoldAscIdAsc,buildSummary, and theRankingRow/GatedInputtypes move intoapps/web(web is the only consumer once the endpoint is gone). The engine functions (priceGraph,aggregateNeed,mergeOwnVsNeed,collectPricedIds) stay in@gw2priory/recipe-graphand are reused, not reimplemented. No parallel copy of any of them. - R3 — Server ranking is retired, not ported. Delete
GET /legendaries/ranking,RankingService(ranking.service.ts),ranking.schema.ts,AccountService.getOwnedItems, and their tests.LegendariesControllerkeeps onlylist(Surface A);LegendariesModuleno longer provides/exportsRankingService. - R4 — The browser owned-items scan reads all four sources — material storage, bank, shared inventory, and every character's inventory — summed per item id with
nullslots skipped, exactly asgetOwnedItemsdid. New browser→ArenaNet fetchers for bank / shared inventory / characters (+ per- character inventory) use the?access_token=transport; the key never reaches our origin. - R5 — The assistant is a stateless, keyless intent classifier.
POST /assistant/ask { question }→{ intent }(the two-variant union above). The server performs only the Anthropic intent parse (its own service secret, not per-user); it reads noAuthorizationheader, makes no GW2 call, and receives no account data. Responseprivate, no-store; nothing is persisted.AssistantServiceno longer depends onRankingService;buildSummaryand the ranking render move web-side. - R6 — The MCP ranking tool is removed with
RankingService.priory_legendary_ranking,McpDeps.ranking, and the controller'sRankingServiceinjection are deleted;shapeRankingRowis removed. The nine other tools are unchanged and their tests stay green. The per-requestX-GW2-KeySurface-B contract is documented (above); it already holds in code — this spec asserts, not changes it. - R7 — The invariant holds end-to-end (closing audit). An exhaustive test asserts, over every Nest controller route: each declares a cache surface (
@SurfaceA/@SurfaceB); every Surface-B route emitsprivate, no-store; no Surface-A route readsAuthorizationor account data. The audit result — no backend endpoint caches per-user data — is recorded in this spec's verification section as the epic's end-to-end gate. - R8 — Surface = net removal + one reshape. No new endpoint is added to our API.
/legendaries/rankingis deleted (Surface B),/assistant/askis reshaped (still Surface B), the MCP loses one tool. The new owned-scan fetches are browser→ArenaNet (private), not our surface. - R9 — Contract artifacts regenerated and committed.
generate:openapidrops/legendaries/rankingand rewrites/assistant/askinapps/api/openapi.json;orvaldrops the ranking client and updates the assistant client underapps/web/src/api/generated.verify:contract(regenerate +git diff --exit-code) passes on this branch. - R10 — No new failure-handling layer (consistent with spec 034 R11). The owned-scan and ranking errors surface through the app's existing error/connect boundaries; a partial price or account batch (206/404, or a per-character read that drops) merges what returned. The missing-scope UX that
useLegendaryRankingsourced from our 403 is re-sourced from ArenaNet's own 4xx on the browser account scan (exact boundary decided inplan.md). - R11 — No bespoke ranking cache. Ranking freshness rides the Surface-A tree cache (browser HTTP cache + TanStack
staleTime), ArenaNet'spublic, max-age=120prices, and the per-user accountstaleTime(spec 034). The ranking result is never cached at our origin — there is no origin ranking.
Discovery verdicts (all resolved in research.md — V1–V5; recorded here so the approved spec carries no open marker). None refuted.
- V1 — client ranking is fast enough → Confirmed. Cost is
4 + N-character account requests + a few price batches + 21 warm-cached tree reads, browser-direct over HTTP/2 (concurrent); per-request latency ~0.1–0.8s with no 5 req/s self-throttle → a few seconds even for a 19-character account, under the 31s the throttled server path measured (mcp.tools.ts:239). The pathological tail (very large character count / cold cache) is covered by the recorded Surface-B fallback (docs/gaps/surface-b-ranking-fallback.md); SC7 pins the real on-device number at implementation. - V2 — bank / shared / characters are browser-direct → Confirmed.
/v2/account/bank,/v2/account/inventory, and/v2/characters?ids=allall returnaccess-control-allow-origin: *(live probe, 2026-08-23), the sameprivate,?access_token=pattern spec 034 proved for/accountand/account/materials. - V3 — safe to delete → Confirmed. Every consumer of
RankingService/ranking.schema/getOwnedItemsis inside the retire set (assistant, MCP tool, legendaries route, account scan); no external consumer (grep acrossapps/api/src+packages). - V4 — no endpoint caches per-user data → Confirmed, structurally. The cache interceptor is fail-safe (
private, no-storeunless a successful response declares@SurfaceA,cache-control.interceptor.ts:33-48); the only two@SurfaceAroutes (/legendaries,/recipe-graph/:itemId) are keyless and user-agnostic. Per-user caching is unreachable by construction; R7/SC5 codify it as an exhaustive test. - V5 — MCP key is per-request only → Confirmed.
mcp.controller.ts:69reads the key fromX-GW2-Keyalone, per request, never a tool argument, never logged/persisted/returned; the guard authenticates a different secret. The spec documents, not changes, this.
Success criteria
Measurable and technology-agnostic — outcomes, not implementation.
- SC1 — No
GET /legendaries/rankingroute exists (asserted over the controller and the generated OpenAPI); loading the ranking page issues zero ranking requests to our origin. - SC2 —
RankingService,ranking.schema.ts, andAccountService.getOwnedItemsare gone — zero references inapps/api/src(grep, incl. tests and MCP wiring). - SC3 —
POST /assistant/askaccepts{ question }with noAuthorizationheader and returns exactly{ intent: "closest_to_craft" } | { intent: "unsupported", message }; it makes no GW2 API call and reads no GW2 key; the response carriesCache-Control: private, no-store. - SC4 — For a fixed fixture account and fixed prices, the browser-computed ranking equals the retired server ranking's ordering and every row's fields — a parity test over the moved pure functions proves the client answer is the one the server used to give.
- SC5 — The audit passes: every Nest route is surface-declared; no Surface-B route emits a cacheable header; no Surface-A route reads
Authorization/account data — no backend endpoint caches per-user data (the epic's end-to-end invariant gate). - SC6 — The browser owned-items scan reads all four sources (materials + bank + shared + characters), summed per id — verified equal to the retired
getOwnedItemsoutput on a fixture. - SC7 — Client ranking over the 21 Gen-1 set completes within an interactive budget on a mid-range device. Measured acceptable in
research.mdV1 (a few seconds even for a 19-character account); the real on-device number is pinned by an implementation-time timing check. - SC8 — Typecheck, lint, tests, both app builds, and
docs:buildare green on this branch alone, andverify:contractpasses: regeneratedopenapi.json+generated/are diff-clean, contain no/legendaries/ranking, and carry the reshaped/assistant/ask.
Out of scope
- A server-side ranking implementation (the Surface-B fallback). Building server ranking now is explicitly excluded; it is a documented follow-up in
docs/gaps/only, triggered if V1 measures the browser compute too heavy. Retire, do not port. - Optimising the browser owned-scan (client throttling, caching beyond
staleTime, incremental scans) — not built unless V1 forces it. - The caching mechanism (031), client-direct data (034), frontend perf (033) — merged; untouched beyond what this spec edits.
- The Postgres-in-docs / no-DB-in-code divergence (epic open decision 6) — this spec adds no persistence; it remains open.
Assumptions
- Spec 034 is merged: browser→ArenaNet prices and account (materials/wallet), the
?access_token=transport, andfetchPrices/fetchAccount*exist;useRecipeTreealready prices client-side; the key lives inlocalStorage(spec 016). @gw2priory/recipe-graphexposes the engine (priceGraph,aggregateNeed,mergeOwnVsNeed,collectPricedIds) and both apps runtime-import it (spec 034 R3).- The Anthropic key is a server-side service secret (spec 029) — not per-user, never in the browser — so keeping the assistant's intent parse server-side does not violate the invariant.
- ArenaNet's
/v2is CORS-open and rate-limited per source IP (epic givens; spec 034 V1/V2), so the extra browser account reads spend the user's own budget.
Known limitations (accepted)
- The owned-scan fans out per character — one request per character, so a large account issues ~N+ browser requests, spending the user's own per-IP ArenaNet budget (spec 034's accepted trade-off; blast radius is one user, not our origin). If V1 shows this is too heavy, the recorded upgrade path is the Surface-B server ranking.
ponytail:per-character client fan-out; Surface-B server ranking is the documented fallback if measured too heavy.
Coordination
This is the final spec of the gw2.app-alignment epic. It has no downstream spec; its closing audit (R7/SC5) is the gate that certifies the epic invariant holds end-to-end. Spec 034 deliberately left /legendaries/ranking and /assistant key-to-origin as "Wave 3, out of scope here"; this spec is that Wave 3.
Parked findings
- The Postgres-in-docs / no-DB-in-code divergence remains open (this spec adds no persistence).
- Any doc or comment still describing ranking/assistant as "key-to-origin" (e.g. spec 034's own out-of-scope note) is made stale by this spec landing; a broader doc reconciliation, if needed, is recorded in
docs/gaps/rather than fixed in passing.
Traceability
Each acceptance scenario and success criterion maps to a named test. Confirmed against the implemented branch (spec 035, tasks T1–T8).
| Criterion | Test |
|---|---|
| P1 #1 | apps/web/src/api/__tests__/useLegendaryRanking.test.tsx — trees from /recipe-graph, prices/account to ArenaNet, no /legendaries/ranking to our origin |
| P1 #2 | apps/web/src/features/legendaries/__tests__/ranking.test.ts — computeRanking == retired server output on a carried-over fixture |
| P1 #3 | apps/web/src/shared/lib/gw2/__tests__/ownedItems.test.ts (four-source sum, null slots skipped) + .../gw2/__tests__/client.test.ts (bank/shared/characters fetchers, ?access_token=) |
| P1 #4 | apps/web/src/api/__tests__/useLegendaryRanking.test.tsx — missing-scope 4xx surfaces through the boundary |
| P2 #1 | apps/api/src/assistant/assistant.controller.test.ts — { question }, no auth, returns { intent }, no GW2 call |
| P2 #2 | apps/web/src/features/assistant/__tests__/AssistantView.test.tsx — closest_to_craft renders the client ranking + browser summary |
| P2 #3 | apps/api/src/assistant/assistant.controller.test.ts + apps/api/src/caching/cache-policy.declarations.test.ts — private, no-store, nothing persisted |
| P3 #1 | apps/api/src/mcp/mcp.tools.test.ts — priory_legendary_ranking absent, nine tools present |
| P3 #2 | apps/api/src/mcp/mcp.tools.test.ts — key only from X-GW2-Key, never a tool arg / log / return |
| P3 #3 | apps/api/src/mcp/mcp.server.test.ts — per-request server, no cross-request key/state leak |
| SC1 | apps/api/src/legendaries/legendaries.controller.test.ts + apps/api/src/generate-openapi.test.ts — no ranking route/path |
| SC2 | apps/api/src/ranking-retired.test.ts — module-graph/source scan finds zero RankingService / ranking.schema / getOwnedItems references in apps/api/src (incl. tests) |
| SC3 | apps/api/src/assistant/assistant.controller.test.ts — body-only, keyless, two-variant intent, private, no-store |
| SC4 | apps/web/src/features/legendaries/__tests__/ranking.test.ts (computeRanking parity) |
| SC5 | apps/api/src/caching/cache-policy.declarations.test.ts (exhaustive 035 T8 audit — module-graph walk from AppModule; no Surface-A route reads auth/AccountService; teeth-verified) |
| SC6 | apps/web/src/shared/lib/gw2/__tests__/ownedItems.test.ts (owned map == getOwnedItems four-source fixture) |
| SC7 | research.md measurement (V1) + a client ranking timing check |
| SC8 | CI: pnpm lint && pnpm test && pnpm build && pnpm docs:build + pnpm verify:contract (diff-clean, no ranking path, reshaped assistant) |