Skip to content

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 to draft for 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/:itemId
  • GET /api/legendaries/ranking
  • the priory_recipe_tree MCP tool — it calls the same RecipeGraphService.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

  1. Given a fresh process (cold caches), when I resolve a Gen-1 legendary (e.g. Bifrost 30698), then the resolver issues zero GET /v2/recipes/search and zero GET /v2/recipes requests; every node's recipe options come from the merged index.
  2. 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.
  3. Given an item id not present in the merged index, when I resolve it, then the resolver falls back to the live curated ∪ station path and the item resolves exactly as before this spec.
  4. 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, leaf flags, classification.
  5. 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.
  6. Given the Gen-1 profit ranking (023) or the priory_recipe_tree MCP 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

  1. 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).
  2. 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-recipes package, used as-is) and a generated station file under apps/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 — including LegendaryComponent-typed recipes. The resolver's existing LegendaryComponent filter (007 T7/R12) stays at runtime, so that domain rule keeps one home.
  • R4 — A RecipeIndexService merges the authored forge recipes and the generated station file at load time into one Map<id, RecipeOption[]> whose keys are the resolved closure. The resolver's buildRecipes(id) becomes a single lookup: optionsFor(id) present → return it (recurse; [] stops the walk); absent → the live fallback (the current curated ∪ station-live path, behaviour unchanged). Item-metadata enrichment (one batched items() 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, and priory_recipe_tree keep identical contracts; the committed apps/api/openapi.json is unchanged by this spec.
  • R9 — pnpm typecheck, pnpm lint, pnpm test, and pnpm build are green; no any, no unexplained escape hatch; no files written under docs/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/search and zeroGET /v2/recipes requests — 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_tree MCP 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, and pnpm build are green; no any; no files under docs/superpowers/.

Out of scope ​

  • The @gw2priory/legendary-recipes → @gw2priory/mystic-forge rename, 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 30703 and Gift of Fortune 19626 return []; Bolt of Damask 46741 returns [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/build supplies 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.

CriterionTest
P1 #1recipe-index.coverage.test.ts — "SC1: all 21 Gen-1 roots resolve with zero /recipes reads"
P1 #2recipe-index.service.test.ts — "reached leaf present as [] → returns [], no live read"
P1 #3recipe-index.service.test.ts — "absent id (neither forge nor index) → one live getRecipes"
P1 #4recipe-graph.service.index-equivalence.test.ts — "resolving from the committed index deep-equals resolving live"
P1 #5recipe-index.service.test.ts — "forge hit → a mystic-forge option, station not consulted"
P1 #6recipe-index.coverage.test.ts — "SC2: resolvePriced(Sunrise 30703) makes zero /recipes reads (MCP/ranking path)"
P2 #1generate-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 #2generate-recipe-index.test.ts — "is deterministic — two runs produce an identical index payload"
SC1recipe-index.coverage.test.ts — "SC1: all 21 Gen-1 roots resolve with zero /recipes reads"
SC2recipe-index.coverage.test.ts — "SC2: resolvePriced(Sunrise 30703) makes zero /recipes reads (MCP/ranking path)"
SC3recipe-graph.service.index-equivalence.test.ts — "resolving from the committed index deep-equals resolving live"
SC4recipe-index.service.test.ts — "reached leaf present as [] …" + "absent id … → one live getRecipes"
SC5generate-recipe-index.test.ts — "is deterministic …"; recipe-index.data.test.ts — "validates against RecipeIndexFileSchema"
SC6pnpm typecheck && lint && test && build && docs:build green; conventions.arch.test.ts guards; zero files under docs/superpowers/