Research 018 — Player holdings & material prices
Status: complete
Step 1.5 output, written after the spec (which the human approved before discovery ran) and before the plan gate. Every [NEEDS VERIFICATION] marker in spec.md (V1–V5) has a verdict below, plus findings that turned up alongside. All five are Confirmed — none refuted a load-bearing spec claim — but one inherited background premise (016's "GW2 returns 403 for a bad key") is live-refuted and recorded under Refuted claims; its correction makes R5 more correct, not broken.
Verified against. The repo at this worktree's 018-holdings-and-prices HEAD; the live GW2 API (api.guildwars2.com, probed 2026-08-16 — currencies and the authenticated-error paths were exercised directly); and the official GW2 Wiki pages API:2/account/materials, API:2/account/wallet, API:2/currencies (fetched 2026-08-16). The two authenticated success shapes (materials, wallet) are documented, not live-verified — we have no real key with the inventories/wallet scopes, so a 200 body was never observed. The error paths (invalid key) were live-exercised. GW2 can drift from the wiki (the repo already records a documented 200-id cap that measured 400 live — apps/api/src/gw2/gw2-client.ts:22-27); re-verify the success shapes with a real key when one exists.
V1 — On a scope-gated endpoint, is an invalid key distinguishable from a valid key missing the scope? (spec R5, HTTP-endpoint 403, P1 #4, P2 #2, SC3)
Question. R5 maps an invalid/expired key to 401 and a valid key missing the required scope to 403 naming the scope. That needs GW2 to signal the two cases distinguishably. Spec 016 believed GW2 returns 403 for a bad key with no body and no invalid-vs-scope distinction — if that held on a scope-gated endpoint, R5 could not tell them apart and would bounce back to step 1.
Verdict. Confirmed — the two are distinguishable by status, and the split is even cleaner than R5 assumed: an invalid key → 401 (live-measured), a missing scope → 403 (documented). R5 stands as written; no bounce-back. The invalid-key half also refutes 016's inherited premise (see Refuted claims #1).
Evidence.
- Live, 2026-08-16.
GET /v2/account/materials,/v2/account/wallet, and/v2/accounteach, with a malformed key (deadbeef-0000) and a well-formed-but-fake GUID key (1A2B3C4D-…-8C9D0E1F2A3B), returned HTTP401with body{ "text": "Invalid access token" }. No header at all → also401{ "text": "Invalid access token" }. So a rejected key is401, not403, and it does carry a JSON body — on every one of the three endpoints, key-format-independent. - Missing scope →
403(documented).API:2states HTTP403is returned for "a valid API key without the necessary permissions," and the wiki's per-scope error convention is a403body{ "text": "requires scope <name>" }(e.g.requires scope inventories). This is the case the two new endpoints hit when a valid key lacksinventories/wallet. - Repo already tolerant.
Gw2Client.account()maps both401and403toGw2UnauthorizedErrortoday (apps/api/src/gw2/gw2-client.ts:140-142), so 016 works regardless of which status a bad key yields. For 018, R5 needs to split them:401→ our401;403→ our403(name the scope). A second error class distinct fromGw2UnauthorizedErroris the minimal add (see F1; error classes today:Gw2ValidationError,Gw2RateLimitError,Gw2RequestError,Gw2UnauthorizedError—apps/api/src/gw2/gw2.errors.ts).
Caveat → manual record during impl. The missing-scope 403 was not live-exercised — that needs a real, valid key that lacks inventories/wallet, which we don't have. It is documented only. Like 016's SC1 smoke, confirm it with a dated manual record during implementation (the human can create a scope-restricted key, or run ! curl -H "Authorization: Bearer <scoped-key>" …/v2/account/materials and paste the status+body). Mapping keys on status (401 vs 403); the scope name for the message is read from the 403 body's text opportunistically, falling back to the statically-known required scope per endpoint if the body is absent — so the message never depends on parsing the body.
V2 — What is the /v2/account/materials response shape? (spec R3, R4, P1 #1)
Verdict. Confirmed. Array of { id, category, count, binding? }; scopes account + inventories. Our endpoint surfaces id+count+category and joins name/icon/rarity from /v2/items; binding is ignored (not in our contract).
Evidence. GW2 Wiki API:2/account/materials (2026-08-16):
| Field | Type | Required | Meaning |
|---|---|---|---|
id | number | yes | item id of the material (join target for /v2/items) |
category | number | yes | material-category id, resolvable against /v2/materials (we return the bare id — category-name join is out of scope) |
count | number | yes | quantity in the account vault |
binding | string | no | "Account" or omitted; not surfaced |
Caveat. "All materials are included in the response, including those with a count of 0." So the raw read returns storage slots the player has zero of — see F2 (filter to count > 0).
V3 — What is the /v2/account/wallet response shape? (spec R3, R4, P2 #1)
Verdict. Confirmed. Array of { id, value }; scope wallet. Our endpoint surfaces id+value and joins name/icon from /v2/currencies; there is no rarity to join (V4).
Evidence. GW2 Wiki API:2/account/wallet (2026-08-16): each element is id (number — currency id, resolvable against /v2/currencies) and value (number — amount). Requires the wallet scope.
V4 — /v2/currencies shape: id/name/icon, batchable, static, no rarity? (spec R3, R4, P2 #1, SC2)
Verdict. Confirmed live. ?ids=-batchable, static (public, unauthenticated), returns name+icon, and has no rarity — so wallet legitimately omits rarity.
Evidence. Live, 2026-08-16: GET https://api.guildwars2.com/v2/currencies?ids=1,2 → 200 with [{ "id": 1, "name": "Coin", "description": "…", "order": 101, "icon": "https://render.guildwars2.com/file/…/619316.png" }, { "id": 2, "name": "Karma", … }]. The ?ids= batch is honored (two ids, two objects). Fields are id, name, description, order, icon — no rarity, no type, no flags. description/order are stripped at our Zod boundary (we consume id/name/icon). Being public, unauthenticated, and immutable game data, /v2/currencies caches hard like /v2/items — the new Gw2Client.currencies(ids) uses the no-expiry staticCache (apps/api/src/gw2/gw2-client.ts:73-76) with a currency: key prefix, same pattern as items() (:94-102).
V5 — Can the client key a per-user cache on a hash of the API key, with a TTL, without leaking across users? (spec R6)
Verdict. Confirmed. Mechanically trivial on the existing primitives; the only new ingredient is a key hash.
Evidence.
BoundedCache.set(key, value, ttlMs)already recordsexpiresAt = Date.now() + ttlMsand expires lazily onget(apps/api/src/gw2/bounded-cache.ts:42-52,:28-40). The price cache already uses exactly this with a 60 s TTL (gw2-client.ts:114-123,PRICE_TTL_MS:34). A 5-minute TTL is the same mechanism with300_000.- The authenticated GET path exists post-016:
account(apiKey)takes a bucket token, calls the header-awarefetchWithRetry(url, path, init)with{ headers: { Authorization: 'Bearer …' } }, and touches no cache (gw2-client.ts:131-145,:253-257).accountMaterials/accountWalletfollow that shape, but add a cache keyed by<endpoint>:<hash(apiKey)>. - The key must fold the API key, not the URL. Every account read hits the same URL, and
BoundedCacheis a plainMap<string, …>with no user dimension (bounded-cache.ts:18) — a URL-keyed entry would serve one player's holdings to another (the exact reason 016 left/accountuncached — 016 research V1). Hash with Node's built-incrypto.createHash('sha256').update(apiKey).digest('hex')(no dependency; the API runs on Node) so the raw secret is never a map key (SC7).
Caveat. The per-user cache is on the raw storage/wallet fetch only. The enrichment reads (items/currencies) have their own static caches, so even a per-user miss re-joins display data from the warm static cache cheaply — record which layer caches what in the plan (F4).
F1 — 016's inherited "bad key → 403, no body" premise is live-refuted (touches R5, docs)
Why it matters. 016's research.md (V2, and its graduation notes) and 016 spec.md's R2/SC1 state GW2 returns 403 for a rejected key, "no documented 401, no error-body shape," mapping "on status, not body." Live probing on 2026-08-16 shows the opposite: 401 with body { "text": "Invalid access token" }. 016's code is unaffected (it maps 401 and 403 alike), and its browser-facing 401 is still correct — but the documented rationale is wrong. This is a cross-cutting correction to a merged spec, so per the "park cross-cutting findings" rule it also warrants a docs/gaps/ record (candidate — surfaced to the human, not created unprompted). For 018 it is good news: invalid-key 401 and missing-scope 403 are cleanly distinct, so R5's split is real. Note 016 never graduated its auth claim into docs/architecture/gw2-api.md (that file has no auth section as of this HEAD), so no architecture doc is currently wrong — only 016's own artifacts.
F2 — Material storage returns count: 0 slots; filter to count > 0 (touches R4, P1 #1)
Why it matters. Per V2, /v2/account/materials lists every storage slot, including materials the player has zero of. "The materials the player already has" (the P1 intent) should not include zero-count noise. Recommend the service filter to count > 0 before enrichment (also trims the /v2/items join). This is an unspecified behavior the spec's P1 does not pin down — a small plan decision worth an explicit test either way; recommend filtering.
F3 — Prices endpoint needs no new client work (touches R2, R7)
Why it matters. Gw2Service.prices(ids) already exists, is ?ids=-chunked to ≤199, 60 s-TTL cached, and validated (gw2-client.ts:114-123, gw2.schemas.ts:52-63). The commerce module is only a controller (ids parse + 400) + a one-line service wrapper + a Zod response DTO stripping whitelisted. The 199 ids cap (R7) equals MAX_IDS_PER_REQUEST (gw2-client.ts:27) so one endpoint call maps to at most one GW2 batch — confirm the exact number in plan.md.
F4 — Cache the raw per-user fetch, not the enriched result (touches R6)
Why it matters. Two layers cache here: the per-user 5-min cache (R6) and the static item/currency caches (R3). Cleanest is to cache the raw /v2/account/materials array per user (the volatile, per-player part) and always run enrichment from the static caches on the way out — so a stale item name can't be pinned inside a per-user entry, and the per-user cache holds no display data to go stale. The plan should state the caching layer (client method vs service) and what exactly is stored.
Refuted claims
A refuted claim normally sends the spec back to step 1. Here the refuted claim is an inherited background premise from 016, not a load-bearing claim of 018 — and correcting it validates 018's design rather than undermining it — so 018 needs no structural rework, only a wording tidy proposed for human approval (the spec is already approved, so this is an amendment, not a silent patch).
- "GW2 returns
403for a rejected key, with no body and no invalid-vs-scope distinction" (inherited from 016 research V2 / 016 spec R2; echoed in 018's R5 prose as the risk that could bounce R5). Believed: a bad key and a missing scope are indistinguishable, both403, no body — so R5's401/403split might be impossible. True (live 2026-08-16): an invalid key →401with body{ "text": "Invalid access token" }; a missing scope →403{ "text": "requires scope …" }(documented). They are distinct. Proposed amendment to 018spec.md: reword R5 (and the endpoint-contract note) to drop the "016 measured403… if V1 is refuted, R5 bounces" hedge and state the confirmed mapping directly — upstream401(invalid key) → our401; upstream403(missing scope) → our403naming the scope — mapping on status, with the scope name read opportunistically from the403body'stext(fallback: the endpoint's statically-known scope). No user story, endpoint, or success criterion changes. Awaiting human approval of the amendment before the plan is written.
Graduation
Candidates to move to docs/architecture/gw2-api.md at step 6 (they outlive this feature; the next account-scoped spec should not re-derive them):
- GW2 auth failure (corrects 016): an invalid/expired key →
401{ "text": "Invalid access token" }(live-measured, key-format-independent, on/account,/account/materials,/account/wallet); a valid key missing a scope →403{ "text": "requires scope <name>" }. The two are distinguishable by status — supersede 016's "403for both, no body, map on status not body." /v2/currencies: public, immutable,?ids=-batchable; fieldsid,name,description,order,icon; no rarity — cache like/v2/items./v2/account/materials:{ id (item id), category, count, binding? }, scopesaccount+inventories, includescount: 0slots./v2/account/wallet:{ id (currency id), value }, scopewallet.- Per-user caching on the authenticated client path: key on
sha256(apiKey)+ endpoint, short TTL; cache the raw per-user body, enrich from the static caches on egress. docs/gaps/record correcting 016's auth-status claim (F1).