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_IDSderives to 21 ids (apps/api/src/legendaries/legendaries.data.ts:68-73). - Compute is cheap. The same
priceGraph/aggregateNeed/mergeOwnVsNeedengine already runs server-side over all 21 graphs per request inranking.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 the4 + Naccount 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-blockedAuthorizationheader — 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 onerecipe-graph.controller.ts:45hit is a comment ("ranking.service also calls it"), not an import.getOwnedItems: onlyaccount.service.ts:34(definition) andranking.service.ts:118(its sole caller);account.module.ts:10is 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.tsshapeRankingRow(removed with the tool), andapps/web(useLegendaryRanking.tsderives 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-35stampsprivate, no-storeon every response first, and only upgrades topublic, max-ageinside atapthat runs on a successful response carrying apublicpolicy (: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-storeunless a successful response declares Surface A",:17-21). - The only two
@SurfaceAroutes are keyless.GET /legendaries(legendaries.controller.ts:50, user-agnostic catalogue) andGET /recipe-graph/:itemId(recipe-graph.controller.ts:31, priceless since spec 032). Neither readsAuthorizationor callsAccountService. - Every other route is Surface B.
POST /assistant/ask(assistant.controller.ts:23),GET /health(health.controller.ts:12), andGET /legendaries/ranking(inherits the class@SurfaceB) — the last being deleted by this spec.cache-policy.declarations.test.tsalready 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/askno longer reads a key at all. R7 codifies this as an exhaustive test (extendcache-policy.declarations.test.ts: everypublicroute reads noAuthorization/account; every other route resolves toprivate, 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-70readsreq.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.
McpGuardchecksMCP_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 reachedRankingService; 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 indocs/architecture/stack.md(orcaching), not just this spec. - The browser-direct account/character CORS +
?access_token=transport (V2) extends spec 034's note indocs/architecture/gw2-api.md.