Spec 032 — Recipe graph: cacheable, priceless, live station recipes
Status: implemented Branch: 032-recipe-graph-cacheableEpic: gw2.app alignment — Wave 1, Spec 2. Surface: A.
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Problem
GET /recipe-graph/:itemId folds live trading-post prices into its response (resolvePriced → pricing.ts). Prices carry a 60-second life, so the expensive, effectively-static recipe structure inherits that 60-second life and can never be cached for hours — even though the structure changes only on a game patch. The response is otherwise already keyless and user-agnostic; the folded prices are the one thing making it uncacheable.
Separately, station (discipline) recipes are served from a committed snapshot, static-data/data/station-recipes.ts, regenerated by a hand-run CLI (generate:recipe-index) on every game patch. The live resolution path (StationDataService.getRecipes → /v2/recipes/search + /v2/recipes) already exists and is already cached in-process with no expiry (gw2-client.ts staticCache/searchCache), so the committed snapshot is only a cold-start warm-up — one that silently goes stale whenever someone forgets to regenerate it after a patch.
User stories
P1 — A priceless, cacheable recipe graph (Surface A)
As a player, I want the recipe-graph endpoint to return a user-agnostic structure, so a shared CDN/edge cache can serve it for hours and my repeat views are instant instead of a fresh origin round-trip.
Independent test: GET /recipe-graph/:itemId returns a body containing recipe structure and static item metadata but no trading-post prices, and the same bytes for any caller (no API key accepted or required). Verified without any client or Spec 033 work.
Acceptance scenarios
- Given any item id in the Gen-1 closure, when
GET /recipe-graph/:itemIdis called, then the response is the resolved graph{ rootId, nodes }and contains none ofunitBuyPrice,craftCost,unitCost,lineCost,decision, orsummary. - Given the same item id, when the endpoint is called twice with different (or absent) API keys, then the two response bodies are byte-identical.
- Given a node reachable only through a non-cheapest recipe option, when the graph is returned, then that node and all of its recipe options are present in
nodes(the graph is complete, not pruned to one chosen recipe per node — Spec 033 needs every option to re-pick the cheapest path). - Given an item id the GW2 API does not know, when the endpoint is called, then it responds
404(unchanged behaviour).
P2 — Live, self-maintaining station recipes
As a maintainer, I want station recipes resolved live from ArenaNet and cached server-side, so there is no committed index to regenerate after a game patch and no snapshot that can silently go stale.
Independent test: with static-data/data/station-recipes.ts and the generate-recipe-index CLI/script deleted, resolving a Gen-1 root still produces the full closure (curated forge ∪ live station), and a second resolve of the same root issues zero new GW2 fetches.
Acceptance scenarios
- Given the committed station index and its generator are removed, when a Gen-1 root is resolved, then the resolved graph equals the graph the committed index produced (same node ids, same recipe options, same leaves).
- Given a cold in-process cache, when the same root is resolved a second time, then the second resolve issues 0 new GW2 fetches (the existing static cache absorbs the live reads).
- Given an item whose only recipe is a curated Mystic-Forge / precursor recipe, when it is resolved, then its curated recipe is returned (the curated dataset remains the source for recipes absent from the GW2 API).
Endpoints & contract
Endpoints and OpenAPI are first-class spec content (epic cross-cutting rule).
GET /recipe-graph/{itemId} — Surface A
- Surface A: public, user-agnostic, keyless. No API key is accepted or required.
- Body change: returns the priceless
ResolvedGraphinstead of the priced tree. Every reachable item id is resolved once intonodes, each carrying its recipe options and static item metadata (name,rarity,vendorValue,classification,leaf). No trading-post prices, no price-derived fields, nosummary. - Caching: the
Cache-Controlheader is applied by Spec 031's mechanism — this spec does not invent a header scheme (epic: 031 owns the mechanism). Spec 032's job is to make the body eligible (user-agnostic, no volatile data) and to declare the route Surface A with an intendedpublic, max-ageon the order of hours (recipe structure changes only on a game patch). The concrete per-route declaration is wired when 031's seam is available; see Coordination. - Errors:
400for a non-positive/non-integeritemId(unchanged);404when the GW2 API does not knowitemId(unchanged; still surfaced as a domain error mapped by the controller).
OpenAPI (the new shape — generated from the Zod DTO; documented here as the contract):
paths:
/recipe-graph/{itemId}:
get:
summary: Resolve the priceless recipe dependency graph for an item (Surface A)
description: >
Public, user-agnostic, keyless. Returns the full recipe dependency graph for itemId —
every reachable item id resolved once, each with its recipe options and static item
metadata. Carries NO trading-post prices; the client fetches and merges prices (Spec 033).
parameters:
- name: itemId
in: path
required: true
schema: { type: integer, minimum: 1 }
responses:
'200':
description: The resolved graph.
content:
application/json:
schema: { $ref: '#/components/schemas/ResolvedGraph' }
'400': { description: itemId is not a positive integer. }
'404': { description: The GW2 API did not know itemId. }
components:
schemas:
ResolvedGraph:
type: object
required: [rootId, nodes]
properties:
rootId: { type: integer, minimum: 1 }
nodes:
type: object
description: Keyed by item id (a string in JSON). Every distinct reachable id, resolved once.
additionalProperties: { $ref: '#/components/schemas/GraphNode' }
GraphNode:
type: object
required: [id, name, rarity, vendorValue, classification, leaf, recipes]
properties:
id: { type: integer, minimum: 1 }
name: { type: [string, 'null'] }
rarity: { type: [string, 'null'] }
vendorValue: { type: [integer, 'null'] }
classification:
description: buyable | gated | null (null iff item metadata was missing). Not price-derived.
type: [string, 'null']
enum: [buyable, gated, null]
leaf: { type: boolean }
recipes:
type: array
items: { $ref: '#/components/schemas/RecipeOption' }
RecipeOption:
type: object
required: [source, outputCount, ingredients, provenance]
properties:
source: { type: string, enum: [mystic-forge, station] }
outputCount: { type: number, exclusiveMinimum: 0 }
provenance: { type: string }
ingredients:
type: array
items: { $ref: '#/components/schemas/RecipeEdge' }
RecipeEdge:
oneOf:
- $ref: '#/components/schemas/ItemEdge'
- $ref: '#/components/schemas/CurrencyEdge'
discriminator: { propertyName: kind }
ItemEdge:
type: object
required: [kind, itemId, count]
properties:
kind: { type: string, enum: [item] }
itemId: { type: integer, minimum: 1 }
count: { type: integer, minimum: 1 }
CurrencyEdge:
type: object
required: [kind, currencyId, count]
properties:
kind: { type: string, enum: [currency] }
currencyId: { type: integer, minimum: 1 }
count: { type: integer, minimum: 1 }No other endpoint changes shape. The MCP priory_recipe_tree tool is out of scope and keeps its priced output (see R9 / Coordination).
Requirements
- R1 —
GET /recipe-graph/{itemId}returns the pricelessResolvedGraph({ rootId, nodes }), each node carryingid,name,rarity,vendorValue,classification,leaf, and its fullrecipesarray. The response contains none ofunitBuyPrice,craftCost,unitCost,lineCost,decision, orsummary. - R2 — The endpoint is user-agnostic and keyless: no API key is accepted or required, and the body is byte-identical for all callers given the same game data.
- R3 — The graph is complete, not pruned: every id reachable through any recipe option is a node in
nodes, and every node keeps all of its recipe options (so a client can re-pick the cheapest path after merging prices). - R4 — Station recipes are resolved live from ArenaNet (
/v2/recipes/search+/v2/recipes) and cached server-side. Resolution depends on no committed station data. - R5 —
static-data/data/station-recipes.ts, thegenerate-recipe-indexpure builder + CLI, and thegenerate:recipe-indexpackage script are deleted, along with theRECIPE_INDEX_DATADI token and its injection. Tests whose only subject is the committed index or the generator are removed; tests that constructRecipeIndexServiceare updated to its new signature. - R6 — Live resolution is equivalent to the former committed-index resolution: for any item, the resolved graph has the same node ids, recipe options, and leaves it had via the committed index.
- R7 — The curated Mystic-Forge / precursor dataset (
@gw2priory/legendary-recipesviaCuratedRecipeService) is retained unchanged as the sole source for recipes absent from the GW2 API. Verified (research.mdR7): all 27 curated Gen-1 output ids return[]from/v2/recipes/search, so they are genuinely API-absent and must stay curated; the live station path covers every other id. - R8 — The route is declared Surface A and its
Cache-Controlis emitted by Spec 031's mechanism. This spec adds no header scheme of its own; it only makes the body cacheable and declares the surface + intendedmax-age(hours). Wiring the per-route declaration is a coordination point with 031. - R9 — The MCP
priory_recipe_treetool is unchanged (stays priced — Surface B, owned by Wave 3). The prior invariant that it mirrorsGET /recipe-graph/:itemId(SC15 of spec 017/029) is retired: under the hybrid contract, Surface A (priceless) and Surface B (priced) differ by design. The now-inaccurate "same value the REST route serves" test comment is corrected. - R10 — The web legendary detail view (fed by
useRecipeTree) renders recipe structure and item metadata only; price/cost/decision/profit UI is removed and returns in Spec 033. This is the minimum web change to keep the build and tests green. No other endpoint's UI changes — ranking (/legendaries/ranking, Wave 3) is untouched.
Open questions — RESOLVED in research.md (decided by the human after discovery).
- Q1 — live-recipe cache TTL/eviction → keep the existing no-expiry in-process static cache; no new cache layer. Recipes are immutable game data; a process restart re-fetches, and Spec 031's edge
max-ageindependently caps response freshness. - Q2 — cold-start posture → go fully live, no committed warm-up, resolving sequentially for now. Discovery measured a cold first resolve of a large tree at ~40 s (The Bifrost, 62 nodes, sequential DFS). Accepted as a known ceiling (see Known limitations), with parallelising the resolver as a deferred optimisation, built only if the latency proves painful.
Success criteria
Measurable and technology-agnostic — outcomes, not implementation.
- SC1 — The
GET /recipe-graph/:itemIdresponse body contains zero trading-post price data and zero per-user data: it is a pure function of game data. - SC2 — The response is keyless: the endpoint neither reads nor requires an API key, and two calls with different keys return identical bytes.
- SC3 — With the committed station index and generator removed, every Gen-1 root still resolves to its complete closure, and the resolved graph matches the pre-deletion graph node-for-node and option-for-option. Verified (
research.mdR7 + the live-equivalence test): the live path + curated set reproduce the committed closure. - SC4 — A second resolve of the same root issues 0 new GW2 fetches (server-side cache hit).
- SC5 — The repository contains no
station-recipes.ts, nogenerate-recipe-index*source, and nogenerate:recipe-indexscript; the counts are asserted by test/grep. - SC6 — Typecheck, lint, tests, and both app builds are green with the web detail view rendering structure only (no price UI) — CI passes on this branch alone.
Out of scope
- The client-side price merge (Spec 033) — fetching prices browser→ArenaNet and merging them into the priceless graph, and restoring cost/decision/profit UI.
- The caching-header mechanism (Spec 031) — the interceptor/policy that emits
Cache-Control. This spec declares the surface and makes the body eligible; it does not build the header machinery. - The MCP / assistant surface (Wave 3, Spec 5) —
priory_recipe_treeand the assistant staying priced/stateless-per-request. Untouched here beyond retiring the REST-parity claim (R9). - Ranking (
/legendaries/ranking, Wave 3) — untouched. - Moving the curated dataset app-side — it stays the
@gw2priory/legendary-recipespackage this spec; the divergence from the epic's "no workspace package" wording is a parked finding (see below).
Assumptions
- The existing in-process caches in
gw2-client.ts(staticCache/searchCache, no expiry) are the server-side cache for live recipes; no new cache layer is built (Q1 resolved: keep as-is). RecipeGraphService.resolve()already produces the exact priceless artifact this endpoint needs; the controller switches fromresolvePriced()toresolve()andresolvePriced()/pricing.tsremain only for the MCP tool.- Spec 031 and Spec 033 are sibling specs in flight; this spec merges independently (CI green on its own branch) and coordinates with 031 on the header declaration only.
Known limitations (accepted)
- Cold first-resolve latency. With the committed index deleted, the first resolve of an item on an empty server cache fetches its whole recipe tree live, node by node, over the resolver's sequential DFS. Measured: The Bifrost (62 nodes) ≈ 40 s cold in the discovery spike (production likely faster with connection keep-alive, still several seconds). It is paid once per item per process — recipe data is immutable, so the no-TTL cache stays valid until a restart — and is shared by the Surface-A edge cache and made rare by Spec 031 keeping the origin warm. Items outside the Gen-1 closure already resolve live today, so this is uniform behaviour, not a new slow path. Risk: that first request can hit a proxy/request timeout; if it aborts, the cache does not fill and the next request repeats it. Deferred upgrade path: parallelise the resolver's DFS (concurrent sibling fetches under the token-bucket budget) to collapse cold resolves to a few seconds — built only if measured painful (the human's "optimise later"), and marked in code with a
ponytail:comment at the resolve seam.
Coordination
- With Spec 031 (caching mechanism):
/recipe-graph/:itemIdis Surface A. Whichever of 031/032 merges second wires the per-routeCache-Controldeclaration to 031's mechanism; 032 must not invent its own. - With Spec 033 (client price merge): 032 emits the complete priceless graph (R3) so 033 can project the tree and pick the cheapest path client-side. 032 removes the detail-view price UI; 033 restores it.
Parked finding
The curated forge/precursor dataset is the workspace package @gw2priory/legendary-recipes, consumed only by apps/api (single-consumer). The epic's Spec-2 wording says "app-side … not a workspace package," which would mean relocating it. That move is orthogonal to cacheability and out of scope here; it is recorded in docs/gaps/ for a later spec rather than fixed in passing.
Traceability
Each acceptance scenario and success criterion maps to a named test. Filled during implementation.
| Criterion | Test |
|---|---|
| P1 #1 | recipe-graph.controller.test.ts — priceless body, no price fields |
| P1 #2 | recipe-graph.controller.test.ts — keyless, identical bytes across differing auth |
| P1 #3 | recipe-graph.service.test.ts — node reached only via a non-first recipe is kept, all options intact |
| P1 #4 | recipe-graph.controller.test.ts — unknown id (null root name) → 404 |
| P2 #1 | recipe-graph.service.index-equivalence.test.ts — live resolution == former committed-index graph |
| P2 #2 | recipe-graph.service.warm-cache.test.ts — second resolve issues 0 new GW2 fetches |
| P2 #3 | recipe-index.service.test.ts — forge-covered id short-circuits, station never consulted |
| SC1 | recipe-graph.controller.test.ts + generate-openapi.test.ts — no price fields in body or OpenAPI |
| SC2 | recipe-graph.controller.test.ts — keyless, identical bytes |
| SC3 | recipe-graph.service.index-equivalence.test.ts |
| SC4 | recipe-graph.service.warm-cache.test.ts |
| SC5 | sync-machinery-removed.test.ts — deleted files + generate:recipe-index script absent |
| SC6 | CI: pnpm lint && pnpm test && pnpm build && pnpm docs:build — all green |
| R1 → R10 (contract → web) | generate-openapi.test.ts (flat, non-recursive shape) + useRecipeTree.test.tsx (client-side priceless projection) |