Skip to content

Research 032 — Discovery ​

Spec: 032 — Recipe graph: cacheable, priceless, live station recipesMethod: codebase reading (evidence as file:line) plus one throwaway live-API spike (discarded after answering, per the constitution). Every claim the spec rests on is checked here before approval.

Outcome: the spec's core stands — delete the committed index, resolve station recipes live + cached, make the endpoint priceless — confirmed by the human choosing option B after the cold-cost measurement below. Discovery surfaced one real ceiling: a cold first resolve of a large tree is slow (measured ~40 s for The Bifrost), accepted now with resolver parallelisation as a deferred optimisation (Q2). No claim was refuted.

Verified claims (codebase) ​

  • F1 — resolve() already produces the exact priceless artifact the endpoint needs.RecipeGraphService.resolve(itemId) returns { rootId, nodes } (ResolvedGraph), enriched, no prices — recipe-graph.service.ts:45-50. The controller's switch from resolvePriced to resolve is a one-line change (recipe-graph.controller.ts:33). The endpoint change is subtractive, not new code. ✓
  • F2 — the price fold is the only thing making the response uncacheable. resolvePriced fetches gw2.prices and folds them via priceGraph (recipe-graph.service.ts:66-86, pricing.ts). The route is already keyless and user-agnostic (useRecipeTree.ts:27 "the route is keyless"); prices carry a 60 s TTL (gw2-client.ts:50 PRICE_TTL_MS), so removing them is exactly what unlocks hours-long caching. ✓
  • F3 — the live station path already exists. StationDataService.getRecipes(id) → gw2.searchRecipes({output:id}) + gw2.recipes(ids) (station-data.service.ts:26-41). No new fetching code is needed. ✓
  • F4 — recipes and recipe-searches are already cached server-side with no expiry. staticCache (items + recipes, key recipe:<id>) and searchCache (key search:output:<id>) are both BoundedCache with maxEntries: 10_000 and no TTL (gw2-client.ts:106-122, 147-163, 343-376); eviction is FIFO on the 10 000 cap (bounded-cache.ts:42-52). The "cache them server-side" requirement is already met — deleting the committed index just routes every station lookup through this existing cache. ✓
  • F5 — the committed index is only a cold-start warm-up. RecipeIndexService.recipesFor checks curated, then the committed index, then falls back to a live station.getRecipes for any id in neither (recipe-index.service.ts:48-55). Removing the committed index leaves the live fallback as the sole station path — behaviour-equivalent, just cold on first touch. ✓
  • F6 — the curated dataset is single-consumer. @gw2priory/legendary-recipes is imported only under apps/api (recipe-index.service.ts, curated-recipe.service.ts, recipe-index.schema.ts, a test, and tsconfig/package.json) — nothing in apps/web or other packages. Confirms the parked finding: it is single-consumer, so the epic's "app-side" wording is achievable later; deferring the move changes no behaviour. ✓
  • F7 — the MCP priory_recipe_tree test does not couple to the REST route. mcp.tools.test.ts:160-171 mocks resolvePriced and asserts the tool returns it by identity (.toBe(root)); it never invokes the controller. So switching the controller to resolve() leaves this test green. Only the comment's "same value GET /recipe-graph serves (SC15)" claim becomes false — corrected under R9. ✓
  • F8 — a test already proves the server-side cache absorbs live reads.recipe-graph.service.warm-cache.test.ts:78-92 ("SC7: the second resolve issues 0 new GW2 fetches") asserts a cold resolve then a warm resolve with warmCallCount === coldCallCount new fetches beyond the first (i.e. 0). This is the evidence for SC4. ✓
  • F9 — the web coupling is exactly the legendary detail view. useRecipeTree feeds the detail view; the whole legendaries feature reads price-derived fields (decision, unitCost, craftCost, summary) — grep across apps/web/src/features/legendaries/*. Ranking (useLegendaryRanking / /legendaries/ranking) is a separate endpoint (Wave 3) and is not fed by /recipe-graph. Confirms R10's boundary. ✓

Marker resolutions ​

R7 / SC3 [NEEDS VERIFICATION] — which Gen-1 recipes are genuinely absent from /v2/recipes → CONFIRMED ​

Throwaway spike (Node + global fetch, run outside the repo, discarded): queried /v2/recipes/search?output=<id> live for all 27 curated output ids and a spot-check of station-craftable intermediates. Measured 2026-08-22.

SetidsResult
Curated (@gw2priory/legendary-recipes)19626, 19654, 19672, 19673, 19674, 19675, 30684–30704 (27)every one returns [] — genuinely absent from /v2/recipes → must stay curated
Station spot-check19684 → [18], 19685 → [21], 46742 → [12053,7319], 46745 → [7321]present → the live path resolves station recipes for non-curated ids

Verdict: the curated set is exactly the API-absent set (Mystic-Forge assemblies: 21 Gen-1 weapons + Gift of Fortune, Gift of the Bifrost, Gift of Might, Gift of Magic, Gift of Mastery, Mystic Clover). The live station path covers everything else. R7 and SC3's premise hold. The equivalence guard (SC3/R6) is implemented as a test, not left to this one-time spike.

Q1 [NEEDS CLARIFICATION] — live-recipe cache TTL/eviction → RESOLVED (human confirmed): keep the existing no-expiry static cache (no new layer) ​

Evidence: staticCache/searchCache are already no-TTL, size-capped at 10 000, FIFO (gw2-client.ts:106-122, bounded-cache.ts). Station recipes are static game data that changes only on a game patch (gw2-client.ts:96 "effectively static game data"). A single planning session touches far fewer than 10 000 distinct recipes/searches, so the cap never bites in practice.

  • Recommendation: build no new cache layer (ponytail). Keep the no-expiry static cache as-is.
  • Staleness ceiling: a recipe changed by a game patch is not picked up until the process restarts (Render restarts on deploy / cold-start after idle — deploy.md). This is acceptable: recipe structure changes rarely, and the HTTP edge cache (Spec 031) independently caps response freshness at its max-age. If a patch-time flush is ever wanted, that is a Spec-031/ops concern (redeploy), not a new in-process eviction policy here.
  • Confirmed by the human.

Q2 [NEEDS CLARIFICATION] — cold-start posture → RESOLVED (human, option B): delete the committed index and resolve live; keep the resolve sequential for now, parallelise later only if the cold latency bites ​

Correction from measurement. An earlier draft of this section estimated a cold resolve at "a small fraction of 76 calls." That was wrong. A live spike resolving The Bifrost (30698) cold — hybrid like production (curated recipe where local, live searchRecipes + recipes otherwise) — measured (2026-08-22, throwaway, discarded):

rootnodescuratedlive searcheslive recipe-fetches429scold wall-clock
30698 (The Bifrost)62755190~40 s

A legendary tree is ~60 nodes, and expand() is a sequential awaited DFS (recipe-graph.service.ts:119-149), so a cold resolve serialises ~74 network round-trips → tens of seconds. (Node's fetch has no keep-alive; production with connection pooling is faster, but still many seconds.) The committed index avoided this by making recipesFor a synchronous local lookup — a per-fetch cost of ~200 ms was also measured, confirming the total is sequential fan-out, not any single slow call.

Why fully-live is nonetheless acceptable now (the human's call — option B, "optimise later if needed"):

  • Recipe structure is immutable game data (unchanged for years), so the no-TTL server cache (F4 / Q1) is permanently valid once warm — the cold cost is paid once per item per process, never repeated.
  • The Surface-A edge cache (Spec 031) serves the whole response for hours across all users, so only the single cache-fill request per item per window pays it.
  • Spec 031 keeps the origin warm (removes the free-tier cold start), so process restarts — the only thing that clears the server cache — become rare. Between restarts each item resolves cold at most once.
  • Anything outside the Gen-1 closure (gen 2/3, arbitrary ids) already resolves live today via the existing fallback (recipe-index.service.ts:51-53), so going fully live makes the behaviour uniform rather than introducing a new class of slow request.

Known ceiling (accepted, deferred). The first cold resolve of a large tree can take tens of seconds and risks a proxy/request timeout; if it aborts before completing, the server cache does not fill and the next request repeats the cold path. Upgrade path (deferred until measured painful): parallelise the resolver's DFS (fetch sibling recipes concurrently under the 300-burst budget — token-bucket.ts), collapsing cold resolves to a few seconds uniformly. This will be marked at the resolve seam with a ponytail: comment naming the ceiling. Not built now (YAGNI) — this is the "optimise later" the human chose.

Deletion blast radius (informs the plan) ​

Delete (subject removed):

  • static-data/data/station-recipes.ts — the committed index.
  • static-data/generate-recipe-index.ts + generate-recipe-index.cli.ts + generate-recipe-index.test.ts — the sync builder/CLI and its test.
  • static-data/recipe-index.schema.ts + recipe-index.schema.test.ts — the RecipeIndexFile schema exists only for the committed index. (StationRecipe is re-sourced from station-data.service.ts, which already exports a structurally identical interface — station-data.service.ts:8-15.)
  • static-data/recipe-index.data.test.ts and recipe-index.coverage.test.ts — they validate the committed data / assert it covers the Gen-1 closure. The closure-coverage guarantee migrates to the live-equivalence test (SC3/R6); the one-time confirmation is the spike above.
  • generate:recipe-index script in apps/api/package.json.

Update (signature/premise change):

  • static-data/recipe-index.service.ts — drop the RECIPE_INDEX_DATA token, the @Injected data param, and the index Map. recipesFor becomes: curated first; otherwise the live station.getRecipes. Keeps the forge short-circuit so forge-covered (API-absent) ids skip a pointless live search.
  • static-data/static-data.module.ts:4,15 — remove the station-recipes import and the { provide: RECIPE_INDEX_DATA, useValue: stationRecipeIndex } provider.
  • static-data/recipe-index.service.test.ts and recipe-graph.service.index-equivalence.test.ts and recipe-graph.service.warm-cache.test.ts — construct RecipeIndexService with the old 3-arg signature; update to the 2-arg one and refocus from "index == live" to "live resolution / cache".
  • recipe-graph/recipe-graph.controller.ts + recipe-graph.schema.ts + their tests — controller returns the priceless ResolvedGraph; add a RecipeGraphDto reusing the existing GraphNodeSchema; the priced Zod schemas (PricedTreeNodeSchema, PlanSummarySchema, DecisionSchema, RecipeTreeDto) are no longer response-validated by any route (MCP returns resolvePriced un-validated) and are removed with them.
  • generate-openapi.test.ts — its SC12 assertion pins a recursive $ref cycle for the priced tree. The flat graph's OpenAPI is additionalProperties + a oneOf edge union, not a z.lazy recursion, so that assertion changes to match the new shape. Flagged so it is not a surprise at build time.
  • conventions/guards.ts:77 — a comment cites RECIPE_INDEX_DATA as the first token-injected DI case for the G2 guard's skip branch. After deletion that example is stale; verify no guard test relies on it as its only fixture (if it does, substitute another token-injected param or a synthetic fixture).

Keep (still used by the out-of-scope MCP path):

  • pricing.ts, project-tree.ts, resolvePriced, and the priced TS types in @gw2priory/recipe-graph — mcp.tools.ts:181 still calls resolvePriced (Surface B, Wave 3).
  • curated-recipe.service.ts and @gw2priory/legendary-recipes — unchanged (R7).

Coordination confirmations ​

  • Spec 031 (header mechanism): not yet on origin/main (sibling in flight). 032 makes the body eligible and declares Surface A; the per-route Cache-Control declaration is wired when 031's seam lands. 032 merges independently without it (body change + deletions carry their own value). ✓
  • Spec 033 (client price merge): 032 emits the complete graph (every node, every option — R3), which is what lets 033 project the tree and pick the cheapest path client-side after fetching prices. ✓

Impact on the surface invariant ​

Post-change, /recipe-graph/:itemId (Surface A) is priceless/keyless/user-agnostic and the MCP priory_recipe_tree (Surface B) stays priced/per-request — the two surfaces differ by design, which is the hybrid contract, not a regression. The retired SC15 parity claim (R9) is a consequence of adopting the contract, recorded rather than silently dropped.