Skip to content

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/ranking

Tech 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); orval regenerates from the new OpenAPI.
  • packages: @gw2priory/recipe-graph (engine, unchanged), @gw2priory/gw2 (wire schemas, unchanged).
  • docs: VitePress (docs:build compiles specs/*.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. Use unknown plus 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-error without 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" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.
  • TypeScript's default lib includes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), or document/window resolve everywhere, silently defeating profile isolation. A profile that needs DOM adds "DOM", "DOM.Iterable" on top of that floor.
  • experimentalDecorators and emitDecoratorMetadata are supported for typecheck only — tsc --noEmit accepts decorator syntax and resolves metadata types, but --noEmit never emits design:paramtypes at runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; see stack.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, 429 on 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 as Authorization: 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-side localStorage is 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 /assistant entirely. stack.md's "key sent to the api as Authorization: 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.

PathResponsibility
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.

ts
// 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[]): string

The 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: computeRanking over a fixture (trees + PriceMap + owned map) equals the retired server output — reuse the fixtures/expectations from ranking.service.test.ts before 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 hit api.guildwars2.com with ?access_token=, no /legendaries/ranking request and no key to our origin.
  • Owned scan == getOwnedItems (P1 #3 / SC6) — shared/lib/gw2/__tests__: buildOwnedItems sums the four sources with null slots skipped, equal to a fixture carried over from account.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, carries private, no-store.
  • Assistant view computes the answer (P2 #2) — features/assistant/__tests__: on closest_to_craft the view renders the client-computed ranking + client summary; the server returned no rows.
  • MCP tool removed (P3 #1) — mcp.tools.test.ts: priory_legendary_ranking absent, 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 / getOwnedItems in apps/api/src; generate-openapi test asserts no /legendaries/ranking.
  • The closing audit (SC5) — cache-policy.declarations.test.ts (exhaustive): every route surface-declared, no public route reads Authorization/account, everything else private, no-store.
  • Perf sanity (SC7) — a lightweight timing assertion around computeRanking over 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:build green on this branch, plus pnpm verify:contract (regenerated openapi.json + generated/ diff-clean, no ranking path, reshaped assistant). pnpm build exercises 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/gaps fallback until measured necessary (research V1).
  • Keep priory_legendary_ranking via a tiny inlined server compute — rejected at brainstorming: that IS a server ranking implementation; the tool is removed, an MCP agent composes from priory_recipe_tree + priory_account_materials if 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 + N calls) 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 Gw2Client account-scan methods — rejected (no-dead-code); they have no consumer after getOwnedItems (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/getWallet and 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 confirms getOwnedItems was their sole caller (V3).
  • /characters?ids=all payload/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 same buildOwnedItems.
  • Missing-scope UX regression (R10). useLegendaryRanking sourced a typed MissingScopeError from 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:contract gates it (SC8).
  • React-Compiler build gate. The new web compute only fails (if it will) in vite build. Mitigation: the CI gate runs pnpm 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.