Skip to content

Research 035 — AI stateless-per-request, ranking to the browser ​

Status: complete Step 1.5 output, written between the spec draft and the approval gate. One entry per [NEEDS VERIFICATION] marker in spec.md (V1–V5), plus unprompted findings (F1–F3).

Verified against: origin/main @ a14d4bb (the 034 merge this spec branches off), on 2026-08-23. Live ArenaNet probes were run 2026-08-22/23 from a CLI (server-side); browser latency will differ but the CORS/auth/rate-limit facts and the per-request latency order-of-magnitude carry over. All markers resolve Confirmed; none refuted, so the spec does not return to step 1.

V1 — Is a browser-side ranking over the 21 Gen-1 trees + the four-source owned scan fast enough on a mid-range device? ​

Question. The spec (R1, SC7) moves ranking into the browser. That requires: (a) fetching 21 Surface-A trees, (b) a four-source owned-items scan (materials + bank + shared + every character's inventory), (c) a few price batches, then (d) the priceGraph/aggregateNeed/mergeOwnVsNeed compute — all client-side, within an interactive budget. What would refute it: the request fan-out or compute making a typical run take tens of seconds even with a warm tree cache.

Verdict. Confirmed (acceptable for typical and large accounts; a pathological-character-count tail is covered by the recorded Surface-B fallback). The dominant cost is the account fan-out request count × per-request latency; the browser sheds the server's deliberate 5 req/s self-throttle, so it is faster than the measured server figure, not slower.

Evidence.

  • The set is 21, not ~30. GEN1_IDS derives to 21 ids (apps/api/src/legendaries/legendaries.data.ts:68-73).
  • Compute is cheap. The same priceGraph/aggregateNeed/mergeOwnVsNeed engine already runs server-side over all 21 graphs per request in ranking.service.ts:138-157; it is millisecond-scale arithmetic, not the bottleneck.
  • Per-request ArenaNet latency (CLI probe, 2026-08-23): GET /v2/items?ids=<50> → 206, ttfb 0.79s / total 0.81s; GET /v2/commerce/prices?ids=<8> → 200, total 0.13s. So a batch costs ~0.1–0.8s.
  • Request count. Owned scan = 1 materials + 1 bank + 1 shared + 1 characters-list + N per-character inventories = 4 + N. Prices = the deduped buyable-id union batched at 199 (unionBuyableIds, ranking.service.ts:25) ≈ a handful of batches. Trees = 21 Surface-A GETs, browser-cached after 031/033 (warm cache ≈ no network).
  • The server's 31s is throttle-bound, not latency-bound. The MCP tool description records "over 31 s measured on a 19-character account" (apps/api/src/mcp/mcp.tools.ts:239); that path is capped by the token bucket (300 cap, 5 req/s) that exists to keep all users under our single origin IP. The browser has its own per-IP budget (x-rate-limit-limit: 600, observed on every probe below) and no self-throttle, and HTTP/2 multiplexes the 4 + N account requests to one host concurrently — so wall-clock ≈ max latency across a concurrent wave (~1s), not the sum, and not 5-req/s-serialised.

Caveat. This is a paper + CLI-latency model, not a run of the not-yet-built client on a real account (no key available in discovery). SC7 pins the real on-device number with an implementation-time timing check; if a very-large-character account or a cold tree cache pushes a real run past an interactive budget, the Surface-B server-ranking fallback is already recorded (docs/gaps/surface-b-ranking-fallback.md) as the upgrade path — the epic is not blocked on this tail.

V2 — Do /v2/account/bank, /v2/account/inventory, and /v2/characters work browser-direct with ?access_token=? ​

Question. R4 adds three new browser→ArenaNet fetchers beyond the two spec 034 already proved (/v2/account, /v2/account/materials). They must be CORS-open and accept the query-param key the same way. Refuted if any is CORS-closed to the browser or needs a transport 034 didn't establish.

Verdict. Confirmed. All three are CORS-open (access-control-allow-origin: *) and use the same private, key-authenticated pattern 034 validated for the account family.

Evidence (CLI probe with Origin: https://gw2priory.example, 2026-08-22/23; keyless → 401, headers still reveal CORS):

  • GET /v2/account/bank → HTTP/2 401, www-authenticate: Bearer, access-control-allow-origin: *, x-rate-limit-limit: 600.
  • GET /v2/account/inventory → HTTP/2 401, access-control-allow-origin: *, x-rate-limit-limit: 600.
  • GET /v2/characters?ids=all → HTTP/2 401, access-control-allow-origin: *, x-rate-limit-limit: 600.
  • These are the same family and transport as spec 034's confirmed key transport (?access_token=, CORS-blocked Authorization header — 034 research V1; apps/web/src/api/useAccount.ts:9-13).

Caveat. The exact JSON shapes (bank slot object, shared-inventory slot object, character bags[].inventory[]) are read from ArenaNet's documented schemas and the retired server scan (account.service.ts:38-54), not re-probed with a real key here; the client's boundary Zod schemas (like 034's) validate them at runtime.

V3 — Do RankingService / ranking.schema / getOwnedItems have any consumer outside the retire scope? ​

Question. R3 deletes RankingService, ranking.schema.ts, and AccountService.getOwnedItems. Safe only if every consumer is itself being reshaped or removed by this spec. Refuted if any live consumer outside the AI/ranking surface depends on them.

Verdict. Confirmed. Every consumer is inside the retire set (assistant → reshaped, MCP tool → removed, legendaries ranking route → removed, account scan → removed). No external consumer.

Evidence (grep -rln across apps/api/src + packages, 2026-08-23):

  • RankingService / ranking.service: legendaries.controller.ts (route removed), legendaries.module.ts (provider removed), assistant.service.ts (dependency dropped, R5), assistant.module.ts, mcp.controller.ts + mcp.tools.ts (tool removed, R6). The one recipe-graph.controller.ts:45 hit is a comment ("ranking.service also calls it"), not an import.
  • getOwnedItems: only account.service.ts:34 (definition) and ranking.service.ts:118 (its sole caller); account.module.ts:10 is a comment. No other caller.
  • RankingRow / ranking.schema: assistant.schema.ts (response, reshaped away), assistant/closest-to-craft.ts (moves web-side), mcp/mcp.shape.ts shapeRankingRow (removed with the tool), and apps/web (useLegendaryRanking.ts derives it from the generated type → moves to a web-local type, R2).

V4 — Does any backend endpoint cache per-user data? (the closing audit) ​

Question. R7/SC5 assert the epic invariant end-to-end: no endpoint caches per-user data. What would refute it: a route that emits a cacheable Cache-Control while reading the Authorization header or account state.

Verdict. Confirmed — and structurally enforced, not merely observed. Caching is opt-in via an explicit @SurfaceA; everything else is private, no-store by a fail-safe default; the only two @SurfaceA routes are keyless and user-agnostic.

Evidence.

  • Fail-safe interceptor. cache-control.interceptor.ts:33-35 stamps private, no-store on every response first, and only upgrades to public, max-age inside a tap that runs on a successful response carrying a public policy (:42-48, cacheControlFor :52-56). So undeclared routes, error paths, and @Res() handlers (e.g. the MCP @Post(), which sets no surface — mcp.controller.ts:48-59) are all uncacheable. The interceptor's own doc-comment states this ("Fail-safe: private, no-store unless a successful response declares Surface A", :17-21).
  • The only two @SurfaceA routes are keyless. GET /legendaries (legendaries.controller.ts:50, user-agnostic catalogue) and GET /recipe-graph/:itemId (recipe-graph.controller.ts:31, priceless since spec 032). Neither reads Authorization or calls AccountService.
  • Every other route is Surface B. POST /assistant/ask (assistant.controller.ts:23), GET /health (health.controller.ts:12), and GET /legendaries/ranking (inherits the class @SurfaceB) — the last being deleted by this spec. cache-policy.declarations.test.ts already asserts each of these per-route.
  • After this spec the per-user cacheable surface is empty by construction: the one route that read the key and could have been mis-declared (/legendaries/ranking) is gone, and /assistant/ask no longer reads a key at all. R7 codifies this as an exhaustive test (extend cache-policy.declarations.test.ts: every public route reads no Authorization/account; every other route resolves to private, no-store).

V5 — Does the MCP receive the key only per-request via X-GW2-Key, never storing it? ​

Question. R6/P3 document the MCP's Surface-B key contract. It must already hold: key read per request from the header only, never a tool argument, never logged/persisted/returned.

Verdict. Confirmed. The current code already satisfies it; this spec documents, not changes it.

Evidence.

  • One read site, per request. mcp.controller.ts:69-70 reads req.headers['x-gw2-key'], trims, and passes it (omitted when empty) into a server built per request (buildMcpServer, :71-80); the server + stateless transport are rebuilt each call and closed on connection close (:86-92, mcp.server.ts:5-12).
  • Guard authenticates a different secret. McpGuard checks MCP_AUTH_TOKEN (+ Origin) only, never the GW2 key (mcp.guard.ts:33-54).
  • Never a tool argument. Account tools declare NoInput / id-only schemas — no key parameter (mcp.tools.ts:40, and the comment at :37-39: "NO key parameter on any account tool … it CANNOT be passed as a tool argument"). Errors are scrubbed before returning (toToolErrorText, mcp.tools.ts:98-104).
  • Removing priory_legendary_ranking (R6) drops the only tool that reached RankingService; the key contract is untouched.

F1 — The assistant's target response shape already exists as AssistantIntent ​

The reshaped /assistant/ask response (R5) — { intent: "closest_to_craft" } | { intent: "unsupported", message } — is the existing AssistantIntent union the model already returns (assistant.schema.ts:17-21), with reason surfaced as message. The reshape is therefore a deletion of the summary/results branch from AssistantAnswer (:24-31) plus dropping the RankingRow import, not a new schema. Why it matters: confirms R5/R9 are a small, low-risk contract change; the plan reuses AssistantIntent rather than authoring a new response type.

F2 — AccountService and the account-enrichment methods stay (MCP still needs them) ​

R3 deletes only getOwnedItems; AccountService.getAccount/getMaterials/getWallet stay wired to the MCP account tools (priory_account*, mcp.tools.ts:195-235). Why it matters: the plan must delete the one method and its test (account.service.owned.test.ts), not the module — mirroring spec 034's "drop the route, keep the service" boundary.

F3 — useLegendaryRanking sources its 403/missing-scope UX from our origin ​

useLegendaryRanking.ts:34-38 throws MissingScopeError on our 403; the browser path hits ArenaNet directly, whose missing-scope response is its own 4xx (not our shaped 403 body). Why it matters: R10 — the plan re-sources the missing-scope boundary from the browser account scan (ArenaNet's 4xx), or accepts the generic error/connect boundary; a decision for plan.md, flagged so it is not missed.

Refuted claims ​

None. All five markers confirmed; the spec does not return to step 1.

Graduation ​

Candidates to move to docs/architecture/ at step 6:

  • The fail-safe cache interceptor makes the invariant structural (V4) — caching is opt-in, default is private, no-store. This is the durable end-state of the epic and belongs in docs/architecture/stack.md (or caching), not just this spec.
  • The browser-direct account/character CORS + ?access_token= transport (V2) extends spec 034's note in docs/architecture/gw2-api.md.