Spec 027 — Gen-1 recipe index (committed forge + station, merged at load)
Status: implemented Branch: 027-recipe-index
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Revision note (design change — reset to draft). The first approved draft cached only the station recipes as a standalone index with a separate closure set. Continued design review replaced it with a load-time merge: the authored forge recipes (the existing package) and the generated station file are merged at startup into one
Map<id, RecipeOption[]>whose keys are the resolved closure — so the resolver does a single lookup per node and no separate closure structure is needed (map.has(id)is the membership check; a reached leaf is present as[]and stops the walk; an absent id falls back to live). This materially rewrites R2–R4, so the status is reset todraftfor re-approval. The success criteria (zero live recipe reads, equivalence, deterministic regen) are unchanged. The design rests on a fact verified this session — forge recipes are absent from the GW2 API:/v2/recipes/search?output=returns[]for Sunrise (30703) and Gift of Fortune (19626), while Bolt of Damask (46741) returns[7309]. That is why there are two sources — one authored, one fetched.
Problem
The recipe-graph resolver (007) discovers each item's station (discipline-crafted) recipes with a live GET /v2/recipes/search?output=<id> per node — one network round-trip for every node in the tree, including the many that are leaves. Measured this session: the Gen-1 closure is 103 distinct ids, of which only 9 carry a station recipe — so ~94 of the per-node searches return empty (research V1/F4). A cold Gen-1 profit ranking issues ~103 station reads (all-21 cold ≈ 3–8 s; warm ≈ 5–12 ms). Render's free tier spins the API down on idle and cold-starts the next request, wiping the in-memory caches — so the same static reads are paid again on every wake.
Forge recipes (Sunrise, the gifts) don't have this problem: they aren't fetched at all — they're hand-authored in the @gw2priory/legendary-recipes package, because ArenaNet never published legendary Mystic Forge recipes to the API. So recipe topology has two sources: authored forge and fetched station. Only the fetched half is re-read on every cold start, yet it is static game data that changes only on a patch.
HTTP endpoints (contract-first)
This spec adds no HTTP endpoint and changes no OpenAPI shape. It is an internal data-loading change behind three existing surfaces, all of which get faster (latency only):
GET /api/recipe-graph/:itemIdGET /api/legendaries/ranking- the
priory_recipe_treeMCP tool — it calls the sameRecipeGraphService.resolvePriced(mcp.tools.ts:181), so resolving a Gen-1 legendary through the MCP tool benefits identically (SC2).
Their request/response contracts stay byte-for-byte identical; only latency and the upstream station-read count change. The requirement here is the explicit absence of a contract change (R8), asserted against the committed openapi.json.
User stories
Ordered by priority. Each story is independently testable and shippable.
P1 — Resolve Gen-1 trees from the merged index, fall back to live off-closure
As a seller (and the ranking + MCP tool that serve them), I want the Gen-1 recipe trees to resolve from a committed index — authored forge recipes merged with a generated station file — instead of a live search per node, so that resolution is fast even on a cold free-tier process, while any item outside the Gen-1 closure still resolves exactly as before.
Independent test: with fetch stubbed to throw on /v2/recipes/search and /v2/recipes, resolving each of the 21 Gen-1 roots (and the ranking, and the MCP tool) completes from the merged index alone — zero recipe fetches — and the resolved graph deep-equals the graph the live path produces; resolving an id absent from the index instead issues the live reads and resolves unchanged.
Acceptance scenarios
- Given a fresh process (cold caches), when I resolve a Gen-1 legendary (e.g. Bifrost
30698), then the resolver issues zeroGET /v2/recipes/searchand zeroGET /v2/recipesrequests; every node's recipe options come from the merged index. - Given a reached leaf in a Gen-1 tree (e.g. Glob of Ectoplasm
19721), when it is resolved, then the index holds it as a present entry with an empty option list ([]), so the walk stops there and no live search is issued — it is presence, not a non-empty recipe, that prevents the fetch. - Given an item id not present in the merged index, when I resolve it, then the resolver falls back to the live
curated ∪ stationpath and the item resolves exactly as before this spec. - Given a Gen-1 root, when its graph is built from the merged index, then it deep-equals the graph built via the live path — same nodes, recipe options, ingredient order,
leafflags,classification. - Given a Gen-1 legendary (e.g. Sunrise
30703), when it is resolved, then its forge recipe is served from the authored package and its (empty) station result from the generated file, merged into one lookup — a legendary is a real entry (its forge recipe), never an empty one. - Given the Gen-1 profit ranking (023) or the
priory_recipe_treeMCP tool on a cold process, when it runs, then it makes zero station-recipe upstream requests; only the existing prices, owned-items, and item-metadata reads remain.
P2 — Regenerate the station file deterministically, with provenance
As a maintainer, I want a single documented command that regenerates the committed station file from the live API and stamps it with provenance, so that a game patch is a small, reviewable diff.
Independent test: run the generator against a stubbed API; it writes a deterministically-ordered file recording every reached id (real station recipes and leaves as []) plus a provenance stamp, and a second run against the same stub produces a byte-identical file.
Acceptance scenarios
- Given the generator is run, when it completes, then it writes an entry for every id reached while resolving the Gen-1 roots — real station recipes for the craftable ones,
[]for every reached leaf — keyed by output item id, plus a provenance stamp (GW2 build id from/v2/build). - Given the committed file and unchanged upstream data, when the generator is re-run, then the output is byte-identical (stable key order), so a diff appears only when a recipe actually changed.
Requirements
- R1 — Recipe topology has two committed sources: authored forge recipes (the existing
@gw2priory/legendary-recipespackage, used as-is) and a generated station file underapps/api(single consumer — app-side, not a new workspace package). - R2 — The generated station file records every id reached while resolving the 21 Gen-1 roots (ids
30684–30704): the raw station recipes for the ~9 discipline-crafted items, and an empty list[]for every reached leaf (raw materials, precursors). Present-with-[](a stop) is distinct from absent (a live fallback). This is the optimization — F4: ~94 of 103 reached ids are leaves, and recording them is exactly what turns ~103 station reads into 0. Omitting a leaf would make it a miss → a live search. - R3 — Station entries store exactly what
StationDataService.getRecipes(id)returns from the API —outputItemId,outputCount,ingredients([{ itemId, count }], order preserved),disciplines(stable enum identifiers, not localized text — verified across?lang=),minRating,type— includingLegendaryComponent-typed recipes. The resolver's existingLegendaryComponentfilter (007 T7/R12) stays at runtime, so that domain rule keeps one home. - R4 — A
RecipeIndexServicemerges the authored forge recipes and the generated station file at load time into oneMap<id, RecipeOption[]>whose keys are the resolved closure. The resolver'sbuildRecipes(id)becomes a single lookup:optionsFor(id)present → return it (recurse;[]stops the walk); absent → the live fallback (the currentcurated ∪ station-livepath, behaviour unchanged). Item-metadata enrichment (one batcheditems()call) and pricing are unchanged. No separate closure structure —map.has(id)is the membership check. - R5 — The graph resolved via the merged index for any Gen-1 root equals the graph resolved via the live path (deep-equal: nodes, recipe options, ingredient order,
leaf,classification). The index is a cache, never a second implementation of the topology. - R6 — A generator script regenerates the station file from the live API by resolving the 21 Gen-1 roots (reusing the resolver, so R5 holds by construction), records every reached id (leaves as
[]), serializes deterministically (stable key order), and writes a provenance stamp (GW2 build id,/v2/build). Run by hand on a game patch; no automated freshness check (staleness accepted, surfaced by the stamp — human decision). - R7 — The generator, the load-time merge, and the runtime lookup are unit-tested with no live network in the suite; a fetch spy asserts zero station reads for a full Gen-1 resolve (SC1).
- R8 — No HTTP endpoint or OpenAPI shape changes.
/api/recipe-graph/:itemId,/api/legendaries/ranking, andpriory_recipe_treekeep identical contracts; the committedapps/api/openapi.jsonis unchanged by this spec. - R9 —
pnpm typecheck,pnpm lint,pnpm test, andpnpm buildare green; noany, no unexplained escape hatch; no files written underdocs/superpowers/.
All [NEEDS VERIFICATION] markers were resolved in research.md (V1 closure bounded/covered, V2 getRecipes is the only per-node station read, V3 determinism), plus this session's finding that forge recipes are absent from the API (recorded there as the design's premise). No markers remain open.
Success criteria
Measurable and technology-agnostic — outcomes, not implementation.
- SC1 — Resolving any of the 21 Gen-1 roots issues zero
GET /v2/recipes/searchand zeroGET /v2/recipesrequests — assertable with a fetch spy that throws on those paths. - SC2 — On a cold process, the Gen-1 profit ranking (023) and the
priory_recipe_treeMCP tool for a Gen-1 legendary each make zero station-recipe upstream requests; only prices, owned-items, and item-metadata reads remain. - SC3 — For every Gen-1 root, the graph resolved from the merged index deep-equals the graph resolved via the live path.
- SC4 — A reached leaf (e.g. Ecto
19721) is a present[]entry that stops the walk with no live read; an id absent from the index falls back to the live path and resolves unchanged. - SC5 — Re-running the generator against unchanged upstream data yields a byte-identical file, and the file carries a provenance stamp (GW2 build id).
- SC6 — The whole test suite runs with no live network;
pnpm typecheck,pnpm lint,pnpm test, andpnpm buildare green; noany; no files underdocs/superpowers/.
Out of scope
- The
@gw2priory/legendary-recipes→@gw2priory/mystic-forgerename, and authoring every Mystic Forge recipe into it. This is the explicit next step for the feature, in its own spec: the package will hold general forge recipes (not only legendaries), so it must be renamed by what-it-is, and every Mystic Forge recipe should be curated from the wiki into it. This spec uses the package's existing Gen-1 forge recipes as-is, under its current name. - Gen-2 / Gen-3 and non-weapon legendaries. The generated file covers the 21 Gen-1 roots' closure; other generations are added later by extending the root list and re-running (YAGNI).
- An in-memory index warmed at boot from the full ~13k-recipe table, or any runtime fetch of it (rejected: rebuilt on every free-tier spin-down; committed data supersedes it).
- A Postgres/Redis store for static game data (rejected: net-new subsystem for read-only, patch-cadence data; the git repo is the persistence layer —
stack.md). - Automated freshness/staleness detection. Handled by manual regen + the provenance stamp.
- Snapshotting item metadata (names/rarity). Enrichment is already one batched call, cached, not the per-node waste. Names localize (unlike disciplines), so they belong at the display layer, not the index.
- The resolver DFS parallelization measured earlier this session — orthogonal, not committed.
- Any HTTP endpoint or OpenAPI change (R8).
Assumptions
- Forge recipes are absent from the GW2 API (verified: Sunrise
30703and Gift of Fortune19626return[]; Bolt of Damask46741returns[7309]), so they must be authored (the package) and merged with the fetched station recipes — the two-source model is a consequence of this, not a choice. - Station-recipe topology is static game data, changing only on GW2 patches; a manually-regenerated committed file is acceptably fresh.
- The existing resolver (
RecipeGraphService) is correct and is reused offline to generate the station file; its determinism was verified (research V3), so the file is byte-stable across runs. - The Gen-1 closure is 103 ids, 9 with a station recipe (research V1/F4), against GW2 build
205780(2026-08-20); the closure and the ~9 recipes fit a committed file comfortably. GET /v2/buildsupplies a GW2 build id for the provenance stamp.- Curated forge recipes and item-metadata enrichment remain exactly as today; only the per-node station search is replaced by the merged-index lookup.
Traceability
Each acceptance scenario and success criterion maps to a named test. Filled in during implementation.
| Criterion | Test |
|---|---|
| P1 #1 | recipe-index.coverage.test.ts — "SC1: all 21 Gen-1 roots resolve with zero /recipes reads" |
| P1 #2 | recipe-index.service.test.ts — "reached leaf present as [] → returns [], no live read" |
| P1 #3 | recipe-index.service.test.ts — "absent id (neither forge nor index) → one live getRecipes" |
| P1 #4 | recipe-graph.service.index-equivalence.test.ts — "resolving from the committed index deep-equals resolving live" |
| P1 #5 | recipe-index.service.test.ts — "forge hit → a mystic-forge option, station not consulted" |
| P1 #6 | recipe-index.coverage.test.ts — "SC2: resolvePriced(Sunrise 30703) makes zero /recipes reads (MCP/ranking path)" |
| P2 #1 | generate-recipe-index.test.ts — "records non-forge reached ids …, skips forge-covered ids, sorts keys"; recipe-index.data.test.ts — "meta.rootIds is the 21 Gen-1 ids; entryCount matches" |
| P2 #2 | generate-recipe-index.test.ts — "is deterministic — two runs produce an identical index payload" |
| SC1 | recipe-index.coverage.test.ts — "SC1: all 21 Gen-1 roots resolve with zero /recipes reads" |
| SC2 | recipe-index.coverage.test.ts — "SC2: resolvePriced(Sunrise 30703) makes zero /recipes reads (MCP/ranking path)" |
| SC3 | recipe-graph.service.index-equivalence.test.ts — "resolving from the committed index deep-equals resolving live" |
| SC4 | recipe-index.service.test.ts — "reached leaf present as [] …" + "absent id … → one live getRecipes" |
| SC5 | generate-recipe-index.test.ts — "is deterministic …"; recipe-index.data.test.ts — "validates against RecipeIndexFileSchema" |
| SC6 | pnpm typecheck && lint && test && build && docs:build green; conventions.arch.test.ts guards; zero files under docs/superpowers/ |