Skip to content

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/ask and the MCP priory_legendary_ranking tool both call RankingService.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

  1. 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/ranking request to our origin.
  2. 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 / craftable equal the retired server output.
  3. 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 with null slots skipped — the same four sources getOwnedItems read.
  4. Given a key missing the inventories/characters scope, 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

  1. Given a question, when POST /assistant/ask { question } is called with no Authorization header, then it returns { intent: "closest_to_craft" } or { intent: "unsupported", message } and makes no GW2 API call.
  2. 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.
  3. Given any request, when the response is inspected, then it carries Cache-Control: private, no-store and 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

  1. Given the registered MCP tools, when they are listed, then priory_legendary_ranking is absent and the other nine tools are unchanged.
  2. Given an account tool call with the X-GW2-Key header, 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.
  3. 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). The Authorization header 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: string and results: RankingRow[] fields are removed from the response. (This is the existing AssistantIntent shape, with reason surfaced as message.)

  • Server work — the Anthropic intent parse only. No GW2 call, no key, no account data.

Removed (Surface B → deleted) ​

Method & pathWasNow
GET /legendaries/rankingserver 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) ​

ToolChange
priory_legendary_rankingremoved (with RankingService) — whole-account ranking moved to the web app
the other nine gw2_* / priory_* toolsunchanged

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.

CallAuth (scope)ArenaNet cache-controlUsed for
GET /v2/account/materialskey (inventories)privateowned: material storage (already fetched, 034)
GET /v2/account/bankkey (inventories)privateowned: bank
GET /v2/account/inventorykey (inventories)privateowned: shared inventory
GET /v2/characters?ids=allkey (characters, builds/inventories)privateowned: 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 034 fetchPrices, keyless, 199-id batched), folds via priceGraph, aggregates via aggregateNeed, and personalises via mergeOwnVsNeed against a browser-built owned-items map — then builds one RankingRow per 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 the RankingRow / GatedInput types move into apps/web (web is the only consumer once the endpoint is gone). The engine functions (priceGraph, aggregateNeed, mergeOwnVsNeed, collectPricedIds) stay in @gw2priory/recipe-graph and 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. LegendariesController keeps only list (Surface A); LegendariesModule no longer provides/exports RankingService.
  • 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 null slots skipped, exactly as getOwnedItems did. 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 no Authorization header, makes no GW2 call, and receives no account data. Response private, no-store; nothing is persisted. AssistantService no longer depends on RankingService; buildSummary and the ranking render move web-side.
  • R6 — The MCP ranking tool is removed with RankingService. priory_legendary_ranking, McpDeps.ranking, and the controller's RankingService injection are deleted; shapeRankingRow is removed. The nine other tools are unchanged and their tests stay green. The per-request X-GW2-Key Surface-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 emits private, no-store; no Surface-A route reads Authorization or 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/ranking is deleted (Surface B), /assistant/ask is 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:openapi drops /legendaries/ranking and rewrites /assistant/ask in apps/api/openapi.json; orval drops the ranking client and updates the assistant client under apps/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 useLegendaryRanking sourced from our 403 is re-sourced from ArenaNet's own 4xx on the browser account scan (exact boundary decided in plan.md).
  • R11 — No bespoke ranking cache. Ranking freshness rides the Surface-A tree cache (browser HTTP cache + TanStack staleTime), ArenaNet's public, max-age=120 prices, and the per-user account staleTime (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=all all return access-control-allow-origin: * (live probe, 2026-08-23), the same private, ?access_token= pattern spec 034 proved for /account and /account/materials.
  • V3 — safe to delete → Confirmed. Every consumer of RankingService / ranking.schema / getOwnedItems is inside the retire set (assistant, MCP tool, legendaries route, account scan); no external consumer (grep across apps/api/src + packages).
  • V4 — no endpoint caches per-user data → Confirmed, structurally. The cache interceptor is fail-safe (private, no-store unless a successful response declares @SurfaceA, cache-control.interceptor.ts:33-48); the only two @SurfaceA routes (/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:69 reads the key from X-GW2-Key alone, 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/ranking route 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, and AccountService.getOwnedItems are gone — zero references in apps/api/src (grep, incl. tests and MCP wiring).
  • SC3 — POST /assistant/ask accepts { question } with no Authorization header and returns exactly { intent: "closest_to_craft" } | { intent: "unsupported", message }; it makes no GW2 API call and reads no GW2 key; the response carries Cache-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 getOwnedItems output 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.md V1 (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:build are green on this branch alone, and verify:contract passes: regenerated openapi.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, and fetchPrices / fetchAccount* exist; useRecipeTree already prices client-side; the key lives in localStorage (spec 016).
  • @gw2priory/recipe-graph exposes 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 /v2 is 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).

CriterionTest
P1 #1apps/web/src/api/__tests__/useLegendaryRanking.test.tsx — trees from /recipe-graph, prices/account to ArenaNet, no /legendaries/ranking to our origin
P1 #2apps/web/src/features/legendaries/__tests__/ranking.test.ts — computeRanking == retired server output on a carried-over fixture
P1 #3apps/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 #4apps/web/src/api/__tests__/useLegendaryRanking.test.tsx — missing-scope 4xx surfaces through the boundary
P2 #1apps/api/src/assistant/assistant.controller.test.ts — { question }, no auth, returns { intent }, no GW2 call
P2 #2apps/web/src/features/assistant/__tests__/AssistantView.test.tsx — closest_to_craft renders the client ranking + browser summary
P2 #3apps/api/src/assistant/assistant.controller.test.ts + apps/api/src/caching/cache-policy.declarations.test.ts — private, no-store, nothing persisted
P3 #1apps/api/src/mcp/mcp.tools.test.ts — priory_legendary_ranking absent, nine tools present
P3 #2apps/api/src/mcp/mcp.tools.test.ts — key only from X-GW2-Key, never a tool arg / log / return
P3 #3apps/api/src/mcp/mcp.server.test.ts — per-request server, no cross-request key/state leak
SC1apps/api/src/legendaries/legendaries.controller.test.ts + apps/api/src/generate-openapi.test.ts — no ranking route/path
SC2apps/api/src/ranking-retired.test.ts — module-graph/source scan finds zero RankingService / ranking.schema / getOwnedItems references in apps/api/src (incl. tests)
SC3apps/api/src/assistant/assistant.controller.test.ts — body-only, keyless, two-variant intent, private, no-store
SC4apps/web/src/features/legendaries/__tests__/ranking.test.ts (computeRanking parity)
SC5apps/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)
SC6apps/web/src/shared/lib/gw2/__tests__/ownedItems.test.ts (owned map == getOwnedItems four-source fixture)
SC7research.md measurement (V1) + a client ranking timing check
SC8CI: pnpm lint && pnpm test && pnpm build && pnpm docs:build + pnpm verify:contract (diff-clean, no ranking path, reshaped assistant)