Plan 035 — AI stateless-per-request, ranking to the browser
Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Spec: specs/035-ai-ranking-reconcile/spec.md (approved). Research: specs/035-ai-ranking-reconcile/research.md (V1–V5 confirmed).
Goal
Move legendary ranking into the browser (reusing the shared @gw2priory/recipe-graph engine and the spec-034 browser→ArenaNet layer), shrink the assistant to a stateless keyless intent classifier, and retire the server ranking path (RankingService, GET /legendaries/ranking, AccountService.getOwnedItems, the MCP ranking tool) rather than porting it — then codify the epic invariant with a closing audit that no backend endpoint caches per-user data.
Architecture
Net effect on our API: removal + one reshape. /legendaries/ranking is deleted; /assistant/ask becomes { question } → { intent } with no key; the MCP loses one tool. The ranking arithmetic already exists as pure functions (in @gw2priory/recipe-graph and in ranking.service.ts); the browser runs the same functions over browser-fetched trees + prices + a four-source owned-items scan. No new endpoint, no new dependency, no server ranking implementation (the Surface-B fallback stays a docs/gaps record).
Approach
Backend — retire the server ranking path (R3). Delete legendaries/ranking.service.ts + ranking.schema.ts (+ tests), drop the @Get('ranking') route and the RankingService field from legendaries.controller.ts, and drop RankingService from legendaries.module.ts providers/exports. LegendariesController keeps only list (Surface A). Regenerate openapi.json; the /legendaries/ranking path and the RankingListDto/RankingRow/GatedInput response schemas drop out, and orval removes the generated legendariesControllerRanking client (R9/SC8).
Backend — retire the four-source account scan (R3/R4). Delete AccountService.getOwnedItems (+ account.service.owned.test.ts). Its exclusive callees in the GW2 client become dead and are deleted too: Gw2Client/Gw2Service accountBank, accountSharedInventory, characters, characterInventory, and the api-local Gw2ItemSlotSchema / Gw2CharacterInventorySchema (research V3 shows getOwnedItems is their only consumer). AccountService.getAccount/getMaterials/getWallet and the price/item methods stay — the MCP account tools still use them (research F2).
Backend — assistant becomes a keyless intent classifier (R5). assistant.schema.ts: replace the AssistantAnswer union with the existing AssistantIntent shape, surfacing reason as message — i.e. { intent: "closest_to_craft" } | { intent: "unsupported", message }; drop the RankingRow import and the summary/results branch (research F1). assistant.service.ts: drop the RankingService dependency; ask(question) returns the parsed intent only — no GW2 call, no key. assistant.controller.ts: drop the Authorization header read and the requireBearer/mapError GW2 branches (the LLM/provider 502 path stays). Stays @SurfaceB(). assistant.module.ts: drop the LegendariesModule import (it was only for RankingService). Regenerate openapi.json/orval for the reshaped /assistant/ask (R9/SC8).
Backend — MCP drops the ranking tool (R6). Remove the priory_legendary_ranking tool from mcp.tools.ts, ranking from McpDeps, shapeRankingRow from mcp.shape.ts, and the RankingService injection from mcp.controller.ts/buildMcpServer call. The nine other tools and the per-request X-GW2-Key contract are unchanged (research V5); their tests stay green.
Frontend — the browser→ArenaNet owned-items scan (R4). Add to apps/web/src/shared/lib/gw2/: web-local Zod schemas for the account-scan wire shapes (Gw2ItemSlot = { id, count }; Gw2Character with bags[].inventory[]) — single consumer is the web now, so app-side, not a package — and fetchers fetchAccountBank, fetchAccountSharedInventory, fetchCharacters (all ?access_token=, research V2). fetchCharacters uses /characters?ids=all (one call returns every character with its bags), falling back to names-then-per-character only if an account rejects the bulk call. A pure buildOwnedItems(materials, bank, shared, characters) → Map<number, number> reproduces getOwnedItems's sum (four sources, per id, null slots skipped).
Frontend — client-side ranking compute (R1/R2). Add apps/web/src/features/legendaries/ranking.ts: the pure helpers moved from the server — toRankingRow, byPersonalProfitDescIdAsc, and the RankingRow / GatedInput types (from ranking.service.ts + ranking.schema.ts) — plus computeRanking(trees, prices, owned) → RankingRow[] that folds each of the 21 Gen-1 trees through priceGraph → aggregateNeed → mergeOwnVsNeed (the engine apps/web already imports; the feature already has aggregateNeed.ts / mergeOwnVsNeed.ts wrappers) and sorts by profit. Rewrite useLegendaryRanking(apiKey) to fetch the 21 Surface-A trees (recipeGraphControllerGet) + their priced-id union (fetchPrices) + the owned scan, then call computeRanking — returning the same RankingRow[] the endpoint used to. RankingTable is unchanged (it consumes RankingRow[]). Key kept out of the queryKey via hashKey (existing pattern).
Frontend — assistant view computes the answer client-side (R5). useAskAssistant: POST { question } with no Authorization header; drop the 403/MissingScopeError branch (no key is sent). Move buildSummary + byRemainingGoldAscIdAsc (the "closest to craft" sort) from assistant/closest-to-craft.ts into apps/web/src/features/assistant/. AssistantView: on closest_to_craft, render a small ClosestToCraftAnswer that suspends on useLegendaryRanking(apiKey), applies the closest-to-craft sort, and renders the client-built summary + list (reusing the ranking rows); on unsupported, the message.
Closing audit — the invariant as a test (R7/SC5). Extend cache-policy.declarations.test.ts to be exhaustive: enumerate every controller route; assert each resolves to a surface; assert every public (Surface-A) route's handler takes no @Headers('authorization') param and its controller injects no AccountService; assert everything else is private, no-store (the interceptor default, research V4). This is the end-to-end gate that certifies no endpoint caches per-user data.
Architecture (before → after)
RANKING
before: browser → GET /api/legendaries/ranking (key) after: browser ─→ GET /recipe-graph/:id ×21 (Surface A, cached)
→ RankingService: 21 graphs + ~40-req account browser ─→ api.guildwars2.com prices + account scan
scan (our origin IP, our budget) + prices (user's own IP/budget, ?access_token=)
→ ranked RankingRow[] → computeRanking() in the browser → RankingRow[]
ASSISTANT
before: POST /assistant/ask (key) → parseIntent → rank after: POST /assistant/ask { question } → { intent }
→ { intent, summary, results } (LLM parse only; no key, no account data)
browser renders ranking on closest_to_craft
MCP priory_legendary_ranking (RankingService) ── removed; other 9 tools + per-request X-GW2-Key unchanged
Reused, not rewritten:
@gw2priory/recipe-graph : priceGraph, aggregateNeed, mergeOwnVsNeed, collectPricedIds ← apps/web computeRanking
moved server→web (single consumer): toRankingRow, byPersonalProfitDescIdAsc, byRemainingGoldAscIdAsc,
buildSummary, RankingRow/GatedInput types, getOwnedItems' sum
Deleted (dead after retire): RankingService, ranking.schema, getOwnedItems, Gw2Client account-scan methods
+ their api-local slot/character schemas, priory_legendary_ranking, /legendaries/rankingTech stack
Existing stack only — no new external dependency, no new workspace package. Everything the browser needs already exists: @gw2priory/recipe-graph (engine, both apps import it), the spec-034 browser→ArenaNet fetch layer (apps/web/src/shared/lib/gw2/client.ts), hashKey, TanStack Suspense queries.
- apps/api: NestJS,
nestjs-zod, Zod, Vitest — net deletion. - apps/web: React, TanStack Query (Suspense), Zod, Vitest, MSW (tests);
orvalregenerates from the new OpenAPI. - packages:
@gw2priory/recipe-graph(engine, unchanged),@gw2priory/gw2(wire schemas, unchanged). - docs: VitePress (
docs:buildcompilesspecs/*.md).
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: never logged, never persisted server-side, never returned to the client. In the MVP the key is held client-side — the browser's
localStorage— and sent per request asAuthorization: Bearer; the api forwards it to GW2 and stores nothing at rest. Encryption at rest applies only if/when server-side key storage is introduced; no such storage exists today. Client-sidelocalStorageis plaintext and readable by any script on the origin (XSS) — a deliberate MVP limitation, established by spec 016 (client-custody, revisit before adding write-scoped or higher-value keys).
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
Supersession note (not part of the verbatim block). Per the gw2.app-alignment epic, spec 034 already moved prices + account fetching browser→ArenaNet with the key as
?access_token=. This spec extends that to the ranking account-scan and removes the key from/assistantentirely.stack.md's "key sent to the api asAuthorization: Bearer" note is updated at step 6 (graduation), not a code prerequisite.
File Structure
Exact paths; a path here is a commitment. [D] delete, [M] modify, [C] create, [R] regenerate.
| Path | Responsibility | |
|---|---|---|
apps/api/src/legendaries/ranking.service.ts | [D] | server ranking retired (R3) |
apps/api/src/legendaries/ranking.service.test.ts | [D] | its test |
apps/api/src/legendaries/ranking.schema.ts | [D] | RankingRow/GatedInput move web-side |
apps/api/src/legendaries/ranking.schema.test.ts | [D] | its test |
apps/api/src/legendaries/legendaries.controller.ts | [M] | drop @Get('ranking') + RankingService field + mapGw2Error/requireBearer if now unused; keep list |
apps/api/src/legendaries/legendaries.controller.test.ts | [M] | drop ranking-route tests |
apps/api/src/legendaries/legendaries.module.ts | [M] | drop RankingService from providers/exports |
apps/api/src/legendaries/legendaries.module.test.ts | [M] | update provider assertions |
apps/api/src/account/account.service.ts | [M] | delete getOwnedItems; keep account/materials/wallet |
apps/api/src/account/account.service.owned.test.ts | [D] | tested only getOwnedItems |
apps/api/src/account/account.module.ts | [M] | drop the stale getOwnedItems/ranking comment |
apps/api/src/gw2/gw2-client.ts | [M] | delete accountBank/accountSharedInventory/characters/characterInventory (dead, V3) |
apps/api/src/gw2/gw2.service.ts | [M] | delete the same four passthroughs |
apps/api/src/gw2/gw2.schemas.ts | [M] | delete Gw2ItemSlotSchema/Gw2CharacterInventorySchema if now unused (verify in a task) |
apps/api/src/gw2/*.test.ts | [M] | drop the deleted methods' tests |
apps/api/src/assistant/assistant.schema.ts | [M] | AssistantAnswer → intent-only union (message); drop RankingRow import |
apps/api/src/assistant/assistant.schema.test.ts | [M] | assert the reshaped union |
apps/api/src/assistant/assistant.service.ts | [M] | drop RankingService; ask(question) returns the intent only |
apps/api/src/assistant/assistant.service.test.ts | [M] | no ranking; intent-only |
apps/api/src/assistant/assistant.controller.ts | [M] | drop Authorization/requireBearer/GW2 error branches; keep Surface B + LLM-error 502 |
apps/api/src/assistant/assistant.controller.test.ts | [M] | keyless body-only; private, no-store |
apps/api/src/assistant/closest-to-craft.ts | [D] | buildSummary/sort move web-side |
apps/api/src/assistant/closest-to-craft.test.ts | [D] | moves web-side |
apps/api/src/assistant/assistant.module.ts | [M] | drop LegendariesModule import |
apps/api/src/assistant/assistant.module.test.ts | [M] | update |
apps/api/src/mcp/mcp.tools.ts | [M] | remove priory_legendary_ranking + ranking from McpDeps |
apps/api/src/mcp/mcp.tools.test.ts | [M] | assert tool absent; 9 tools present |
apps/api/src/mcp/mcp.shape.ts | [M] | remove shapeRankingRow |
apps/api/src/mcp/mcp.shape.test.ts | [M] | drop its test |
apps/api/src/mcp/mcp.controller.ts | [M] | drop RankingService injection + the ranking dep in buildMcpServer |
apps/api/src/caching/cache-policy.declarations.test.ts | [M] | make exhaustive: the closing audit (R7/SC5); drop the removed ranking-route case |
apps/api/src/generate-openapi.test.ts (or equiv) | [M] | assert no /legendaries/ranking; reshaped /assistant/ask |
apps/api/openapi.json | [R] | drop /legendaries/ranking; rewrite /assistant/ask (committed) |
apps/web/src/shared/lib/gw2/schemas.ts (or client.ts) | [M] | add web-local Gw2ItemSlot/Gw2Character (bags/inventory) schemas |
apps/web/src/shared/lib/gw2/client.ts | [M] | add fetchAccountBank/fetchAccountSharedInventory/fetchCharacters (?access_token=) |
apps/web/src/shared/lib/gw2/ownedItems.ts | [C] | pure buildOwnedItems(materials, bank, shared, characters) sum |
apps/web/src/shared/lib/gw2/__tests__/* | [M] | MSW: new fetchers hit ArenaNet with ?access_token=; owned sum == fixture |
apps/web/src/features/legendaries/ranking.ts | [C] | moved toRankingRow/byPersonalProfitDescIdAsc/RankingRow/GatedInput + computeRanking |
apps/web/src/features/legendaries/__tests__/ranking.test.ts | [C] | parity: client ranking == retired server output (SC4) |
apps/web/src/api/useLegendaryRanking.ts | [M] | compute client-side (trees + prices + owned → computeRanking); RankingRow from the feature |
apps/web/src/api/__tests__/useLegendaryRanking.test.tsx | [M] | trees from /recipe-graph, account/prices to ArenaNet, none to origin, no key to origin |
apps/web/src/api/index.ts | [M] | RankingRow re-exported from the feature, not the generated type |
apps/web/src/api/useAskAssistant.ts | [M] | POST { question } only; drop auth + 403 branch; reshaped response type |
apps/web/src/api/__tests__/useAskAssistant.test.tsx (if any) | [M] | keyless; { intent } |
apps/web/src/features/assistant/summary.ts | [C] | moved buildSummary + byRemainingGoldAscIdAsc |
apps/web/src/features/assistant/AssistantView.tsx | [M] | on closest_to_craft, render client ranking + client summary |
apps/web/src/features/assistant/__tests__/* | [M] | closest_to_craft renders the client ranking |
apps/web/src/api/generated/** | [R] | orval drops the ranking client; updates the assistant client |
docs/gaps/surface-b-ranking-fallback.md | (done) | already committed in step 1.5 |
Keep, explicitly: AccountService.getAccount/getMaterials/getWallet, the GW2 client price/item/account methods the MCP uses, AnthropicClient, LegendariesService.list, the nine other MCP tools, /assistant/ask (reshaped), /legendaries (Surface A), /recipe-graph/:itemId (Surface A).
Data & contracts
Removed from our OpenAPI: GET /legendaries/ranking (+ RankingListDto/RankingRow/GatedInput). Reshaped: POST /assistant/ask.
// assistant.schema.ts — the reshaped response (was AssistantAnswer with summary+results)
AssistantAnswer =
| { intent: "closest_to_craft" }
| { intent: "unsupported"; message: string }
// request unchanged: { question: string } (1–500, non-blank) — but no Authorization header is read
// moved server → apps/web/src/features/legendaries/ranking.ts (web is the only consumer now)
type GatedInput = { id: number; name: string; needed: number; have: number; satisfied: boolean }
type RankingRow = { id: number; name: string; icon: string|null; subtype: string|null;
netSell: number|null; marketCost: number|null; myCost: number|null;
personalProfit: number|null; gatedInputs: GatedInput[]; craftable: boolean }
toRankingRow(id, meta, summary, need, remainingCost, owned): RankingRow // verbatim from ranking.service.ts
byPersonalProfitDescIdAsc(a, b): number // verbatim
computeRanking(trees: ResolvedGraph[], prices: PriceMap, owned: Map<number,number>): RankingRow[]
// for each tree: priceGraph → aggregateNeed → mergeOwnVsNeed → toRankingRow; sort byPersonalProfitDescIdAsc
// apps/web/src/shared/lib/gw2 — the owned-items scan (browser→ArenaNet, ?access_token=)
Gw2ItemSlot = { id: number; count: number } // bank/shared/inventory slot
Gw2Character = { name: string; bags: ({ inventory: (Gw2ItemSlot|null)[] } | null)[] }
buildOwnedItems(materials: Gw2Material[], bank: (Gw2ItemSlot|null)[],
shared: (Gw2ItemSlot|null)[], characters: Gw2Character[]): Map<number, number>
// sum per id across all four sources, null slots skipped — reproduces AccountService.getOwnedItems
// apps/web/src/features/assistant/summary.ts (moved from assistant/closest-to-craft.ts)
byRemainingGoldAscIdAsc(a, b): number ; buildSummary(rows: RankingRow[]): stringThe moved toRankingRow/comparators/buildSummary are copied verbatim from the server files (byte-for-byte logic), so the parity test (SC4) is against identical arithmetic — the point of "reuse, not rewrite".
Test strategy
- Ranking parity (P1 #2 / SC4) —
features/legendaries/__tests__/ranking.test.ts:computeRankingover a fixture (trees +PriceMap+ owned map) equals the retired server output — reuse the fixtures/expectations fromranking.service.test.tsbefore deleting it, so the numbers are the same authority. - Client ranking wiring + no origin ranking (P1 #1 / SC1) —
useLegendaryRanking.test.tsx(MSW): trees fetched from/recipe-graph, prices + the four account calls hitapi.guildwars2.comwith?access_token=, no/legendaries/rankingrequest and no key to our origin. - Owned scan == getOwnedItems (P1 #3 / SC6) —
shared/lib/gw2/__tests__:buildOwnedItemssums the four sources withnullslots skipped, equal to a fixture carried over fromaccount.service.owned.test.ts. - Missing scope (P1 #4) —
useLegendaryRanking.test.tsx: an ArenaNet 4xx on the account scan surfaces through the existing error/connect boundary (R10; exact boundary is an implementation choice — see Risks). - Assistant keyless intent (P2 #1/#3 / SC3) —
assistant.controller.test.ts:{ question }with no auth returns{ intent }, makes no GW2 call, carriesprivate, no-store. - Assistant view computes the answer (P2 #2) —
features/assistant/__tests__: onclosest_to_craftthe view renders the client-computed ranking + client summary; the server returned no rows. - MCP tool removed (P3 #1) —
mcp.tools.test.ts:priory_legendary_rankingabsent, nine tools present; key-only-from-header assertions (P3 #2/#3) stay green unchanged. - Retired symbols gone (SC1/SC2) — a grep/structure test: zero references to
RankingService/ranking.schema/getOwnedItemsinapps/api/src;generate-openapitest asserts no/legendaries/ranking. - The closing audit (SC5) —
cache-policy.declarations.test.ts(exhaustive): every route surface-declared, nopublicroute readsAuthorization/account, everything elseprivate, no-store. - Perf sanity (SC7) — a lightweight timing assertion around
computeRankingover the 21-tree fixture (compute is the deterministic part; network wall-clock is research V1, not a CI assertion). - CI gate (SC8) —
pnpm lint && pnpm test && pnpm build && pnpm docs:buildgreen on this branch, pluspnpm verify:contract(regeneratedopenapi.json+generated/diff-clean, no ranking path, reshaped assistant).pnpm buildexercises the React-Compiler gate on the new web compute.
Not tested directly: browser HTTP-cache honouring ArenaNet's max-age and the per-IP budget (GW2-side facts, research V1/V2) — design assumptions with cited evidence, not CI assertions.
Alternatives considered
- Port ranking to a Surface-B server endpoint now — rejected: the spec's out-of-scope + the ponytail "retire, not port" directive; it stays a
docs/gapsfallback until measured necessary (research V1). - Keep
priory_legendary_rankingvia a tiny inlined server compute — rejected at brainstorming: that IS a server ranking implementation; the tool is removed, an MCP agent composes frompriory_recipe_tree+priory_account_materialsif asked. - Assistant receives the client's ranking snapshot and re-sorts server-side — rejected at brainstorming: ships account-derived data to the origin and back for a sort; the intent-only classifier keeps all account-derived data in the browser.
- Per-character inventory fan-out (
4 + Ncalls) like the server — the browser prefers/characters?ids=all(one call, all inventories); the per-character path is only the fallback for accounts that reject the bulk call. - Keep the dead
Gw2Clientaccount-scan methods — rejected (no-dead-code); they have no consumer aftergetOwnedItems(research V3). Flagged for the human at approval as the one deletion that reaches beyond the spec's literal R3 wording.
Risks
- Over-deletion.
AccountService.getAccount/getMaterials/getWalletand the MCP-used GW2 methods must survive. Mitigation: the File Structure "keep" list + the MCP tool tests staying green guard it; delete the four scan methods only after a task confirmsgetOwnedItemswas their sole caller (V3). /characters?ids=allpayload/size. Some large accounts historically 502 on the bulk character call. Mitigation: the fetcher falls back to names-then-per-character on a non-2xx bulk response; both paths feed the samebuildOwnedItems.- Missing-scope UX regression (R10).
useLegendaryRankingsourced a typedMissingScopeErrorfrom our 403; ArenaNet returns its own 4xx. Mitigation: re-source the boundary from the browser account scan, or accept the generic error/connect boundary — decided in the task, tested (P1 #4). The assistant no longer sends a key, so its 403 branch simply goes away. - OpenAPI ↔ orval drift. Mitigation: regenerate
openapi.json+ the web client in the same task and commit both;verify:contractgates it (SC8). - React-Compiler build gate. The new web compute only fails (if it will) in
vite build. Mitigation: the CI gate runspnpm build; the compute is pure non-component logic — low risk. - Parity fixture drift. The moved helpers must be byte-identical to the server's. Mitigation: move the code verbatim and carry over
ranking.service.test.ts's fixtures/expectations into the web parity test before deleting the server test.
Open questions
None blocking. Two decisions surfaced for the human at approval: (1) deleting the dead Gw2Client account-scan methods (beyond R3's literal wording — recommended, ponytail no-dead-code); (2) the missing-scope boundary re-sourcing (R10) — a small UX choice settled in the task. All spec.md markers have verdicts in research.md (V1–V5). The stack.md key-transport note update is a step-6 graduation, not a code prerequisite.