Skip to content

Spec 007 — Recipe graph resolver ​

Status: implemented Branch: 007-recipe-graph-resolver

Status is set by the human, never by the agent. It moves draft → approved → implemented.

Problem ​

Spec 006 delivered single-item facts: CuratedRecipeService.getRecipe(id) (one forge recipe, or null), StationDataService.getRecipes(id) (all station recipes for one output, or []), and ItemDataService.metadata/classify (buyable vs gated for one item). It explicitly punted tree walking to 007 — "006 serves single-item facts only."

Nothing yet turns those facts into the thing the planner is actually built on: a legendary's full dependency graph. Given "The Bifrost," no code today expands Precursor + Gift of Fortune + Gift of Mastery + the weapon gift, recurses each gift into its ingredients, walks every craftable sub-ingredient down to the raw materials and gated inputs where crafting stops, and does so without re-walking items that appear in several branches (Globs of Ectoplasm, Mystic Clovers, a shared gift). Until that graph exists, the buy-vs-craft pricing/optimizer (008) has no structure to price and the frontend has no tree to render.

007 builds that resolver. It is purely structural: it merges the 006 facts into the dependency graph and computes no gold cost and reads no account — prices and the buy-vs-craft min are 008, own-vs-need is a later account spec.

User stories ​

Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.

P1 — Resolve a legendary into its full buy-vs-craft dependency graph ​

As the future pricing/optimizer (008) and the planner, I want to expand any item id into the complete dependency graph — every recipe path, recursed to the leaves, with each distinct item resolved once — so that a legendary's true buy-vs-craft structure exists as one walkable object to price and display.

Independent test: RecipeGraphService.resolve(itemId) is exercised against mocked 006 services (CuratedRecipeService, StationDataService, ItemDataService) returning the 006 fixtures; asserted with no live GW2 API access. The structural claims (root recipe, recursion, dedup, leaf rule, classification) are all verifiable from the returned graph and the mocked call counts.

Acceptance scenarios

  1. Given the id of a Gen 1 legendary (The Bifrost, 30698), when resolve is called, then the returned graph's root node carries a mystic-forge recipe whose item-ingredients are exactly Precursor + Gift of Fortune + Gift of Mastery + Gift of the Bifrost, and each of those appears as its own node in the graph.
  2. Given any item reachable by two or more branches (e.g. Globs of Ectoplasm), when the graph is resolved, then it appears as a single node referenced by multiple edges (a DAG, not a duplicated subtree), and the mocked StationDataService/CuratedRecipeService are queried at most once for that id.
  3. Given a buyable-and-craftable intermediate (Bolt of Damask, 46741 — buyable on the TP and station-craftable), when the graph is resolved, then the node is not a leaf: it carries its station recipe as an option and that recipe's ingredients are themselves expanded — the craft path is preserved past a merely-buyable item (research.md V1).
  4. Given an item with no usable recipe — a raw drop, a gated input, or a Gen 1 precursor (whose only recipe is the LegendaryComponent path filtered by R12) — when it is reached, then its node has recipes: [] and leaf: true.
  5. Given Eternity (30689), whose recipe references Sunrise and Twilight (themselves legendary outputs with their own recipes), when resolve(30689) is called, then the Sunrise and Twilight sub-graphs are expanded recursively, any node they share is single, and no cycle error is raised.
  6. Given each reached node, when the graph is returned, then every node carries its classification (buyable / gated, via 006's classify) and display metadata (name, rarity, vendorValue), fetched in a single batched ItemDataService.metadata pass over the distinct ids (chunked ≤199), with ids the GW2 API omitted left as null fields rather than erroring.
  7. Given an item with more than one station recipe that is also gated (Lump of Mithrillium, 46742, AccountBound with two recipes), when the graph is resolved, then the node is not a leaf, both recipes are kept as options, and its classification is gated — proving classification (a flag fact) and leaf (a graph fact) are orthogonal: the resolver never shortcuts "gated ⇒ leaf" (research.md V1/F8).

P2 — Project the graph into a nested tree for the frontend ​

As the frontend (the MVP's collapsible tree view), I want a pure helper that turns the resolved DAG into a root-anchored nested tree so that I can render indented, expandable rows without re-implementing graph traversal or holding any server state.

Independent test: projectTree(graph, chooseRecipe?) is a pure function fed a hand-built ResolvedGraph and asserted on the returned nesting; no I/O, no services.

Acceptance scenarios

  1. Given a resolved graph, when projectTree(graph) is called, then it returns a tree anchored at rootId where each entry is { node, count, recipe, children }, count is the quantity the parent recipe needs (root = 1), and children are the ingredients of the chosen recipe.
  2. Given a graph where an item is used in several places, when the tree is projected, then that item appears at each usage position (display duplication is intended — the DAG dedup is for resolution/pricing, not display), and projection terminates (the source DAG is acyclic).
  3. Given a node with more than one recipe option and no chooseRecipe argument, when the tree is projected, then the default chooser selects a single recipe per node (so the tree shows one path); a caller (008) may pass a chooseRecipe that picks the min-cost recipe instead.

P3 — Fetch a display-ready crafting tree over HTTP ​

As the frontend, I want to GET a legendary's crafting tree as ready-to-render nested JSON so that I can display the collapsible tree view without reconstructing a graph or making per-node lookups.

Independent test: the controller is exercised with a mocked RecipeGraphService; the route's status codes and the projected-tree body are asserted without a live server or the GW2 API. The OpenAPI assertion runs against the offline-generated document.

Acceptance scenarios

  1. Given a valid legendary id, when GET /recipe-graph/:itemId is called, then it returns 200 with a nested TreeNode (projectTree of the resolved graph, run server-side), each node carrying its metadata + classification and the parent-recipe count.
  2. Given a param that is not a positive integer, when the route is called, then it returns 400; given an id the GW2 API does not know, then it returns 404.
  3. Given the offline-generated OpenAPI document, when it is inspected, then it documents GET /recipe-graph/{itemId} with the tree response schema.

Requirements ​

  • R1 — RecipeGraphModule provides RecipeGraphService with one I/O method, resolve(itemId: number): Promise<ResolvedGraph>. It depends only on 006's CuratedRecipeService, StationDataService, and ItemDataService (imported via StaticDataModule) and never calls Gw2Service directly (stack.md — all GW2 access goes through the one budgeted client, which 006 already wraps).
  • R2 — ResolvedGraph is { rootId: number; nodes: Record<number, GraphNode> }. nodes is keyed by distinct item id — each reached item is walked, resolved, and metadata-fetched exactly once (memoized). The wire/DTO shape is plain JSON; no JavaScript Map crosses the HTTP boundary (a Map serializes to {}). A Map may be used internally.
  • R3 — Each GraphNode is { id; name: string|null; rarity: string|null; vendorValue: number|null; classification: 'buyable'|'gated'|null; leaf: boolean; recipes: RecipeOption[] }. leaf === (recipes.length === 0). name/rarity/vendorValue/classification are null iff the GW2 API omitted the id (206/404 — inherited from 006's metadata). Metadata is inlined (not a nested 006 ItemMeta) so the type can live in a shared package without dragging an apps/api type into it.
  • R4 — A node's recipes is the union of the curated forge recipe (0..1, from CuratedRecipeService.getRecipe) and all station recipes (0..N, from StationDataService.getRecipes). Each RecipeOption is { source: 'mystic-forge'|'station'; outputCount: number; ingredients: RecipeEdge[]; provenance: string } (provenance = the curated source URL, or the station recipe's disciplines + min-rating). All options are kept — 007 chooses none (the buy-vs-craft min is 008).
  • R5 — Expansion continues through every item that has any recipe, including items that are also buyable (buy-vs-craft must be discoverable at every level). A node is a leaf only when neither source yields a recipe. There is no depth cap and no always-buy allowlist; the one targeted recipe-type filter is R12 (precursor-crafting).
  • R6 — RecipeEdge is a discriminated union mirroring 006's curated ingredient: { kind: 'item'; itemId; count } (resolvable via nodes[itemId]) or { kind: 'currency'; currencyId; count } (a player-supplied terminal — not expanded into a node; no Gen 1 weapon uses it; the armor seam only). Edge count is the raw per-recipe quantity as stated by the source; 007 performs no quantity aggregation or path multiplication (that is 008's fold). Ingredient order is preserved from the source so the projected tree is stable across requests.
  • R7 — Cycle guard: resolve maintains the current ancestor path during the walk and throws a clear CycleError (naming the offending id) if an item recurs on its own path. This is defense-in-depth against a malformed curated entry; the happy-path curated set is acyclic (Eternity→Sunrise/Twilight is not a cycle). A cycle is the only condition under which resolve throws.
  • R8 — resolve does not throw on "not found": an unknown or recipe-less root id yields a valid one-node graph whose node is a leaf (with null metadata if the id does not exist). Missing child metadata likewise yields a present node with null fields, never an exception.
  • R9 — A hard Gw2Service failure (surfaced through 006) propagates; the resolver adds no retry or backoff of its own (the 005 client owns rate-limit budgeting and backoff — gw2-api.md).
  • R10 — projectTree(graph, chooseRecipe?) is a pure function (no I/O, no service access) returning a nested { node, count, recipe, children } tree anchored at rootId. chooseRecipe(node) => RecipeOption|null defaults to selecting the first recipe option; 008 supplies a min-cost chooser.
  • R11 — The shared graph types (ResolvedGraph, GraphNode, RecipeOption, RecipeEdge, TreeNode) live in a dedicated types-only package @gw2priory/recipe-graph, consumed as type-only imports. projectTree and the endpoint's Zod schema live in apps/api — the SWC-built API cannot load a runtime value from a .ts workspace package (F12 — research.md; curated-recipe.service.ts:1-6), and API-boundary Zod belongs in the app (the health.schema.ts pattern). projectTree stays pure (R10) and is reusable by 008 (also API-side); sharing it to the FE is deferred to the FE spec. (Package boundary — human decision, transcribed 2026-07-29.)
  • R12 — The resolver filters out recipes of type: "LegendaryComponent" — the precursor-crafting path the API exposes for every precursor (e.g. The Legend 29180 → recipe 11134, LearnedFromItem — research.md F7). Gen 1 precursors therefore stay buyable leaves, aligning 007 with 006's out-of-scope decision. This is the single, targeted exception to R5. (Human decision, transcribed 2026-07-29.)
  • R13 — The graph is exposed over HTTP: GET /recipe-graph/:itemId returns the projected display tree — projectTree run server-side so the frontend renders it without reconstructing a graph — not the raw DAG. The path param is validated (positive integer → 400); an item the GW2 API does not know → 404; otherwise 200 with the tree. The response is documented in the generated OpenAPI document using the project's nestjs-zod DTO pattern (createZodDto + @ZodResponse, as health does). The raw ResolvedGraph (resolve) stays an in-process contract for 008 and is not exposed over HTTP. (Scope addition — human decision, transcribed 2026-07-29.)

Mark anything unresolved inline rather than assuming an answer. Two markers, split by who can answer:

  • [NEEDS CLARIFICATION: specific question] — only the human can answer (a product decision, a scope boundary, a preference). Blocks step 1.5.
  • [NEEDS VERIFICATION: specific question] — only reality can answer (whether the codebase works that way, whether the tree terminates, whether that number is achievable). Answered in research.md with cited evidence, never by assumption. Blocks the approval gate.

Any success criterion stating a number carries a [NEEDS VERIFICATION] until a measurement in research.md backs it.

Success criteria ​

Measurable and technology-agnostic — outcomes, not implementation.

  • SC1 — resolve(30698) returns a graph whose root has a mystic-forge recipe = Precursor + Gift of Fortune + Gift of Mastery + Gift of the Bifrost; every non-leaf node has ≥1 recipe option; every leaf has recipes: [] and (for an existing id) a non-null classification.
  • SC2 — DAG dedup: an item reachable by multiple branches is a single node referenced by multiple edges, and the mocked 006 services are queried at most once per distinct id (asserted via call counts).
  • SC3 — resolve(30689) (Eternity) expands the Sunrise and Twilight sub-graphs recursively, keeps shared sub-nodes single, and raises no cycle error.
  • SC4 — Leaf kinds: a gated input (Gift of Exploration) resolves to a leaf classified gated; a precursor (its LegendaryComponent recipe filtered per R12) or a raw material resolves to a leaf classified buyable; both have recipes: [].
  • SC5 — Full-depth buy-vs-craft is preserved on station items: a standalone resolve(46741) (Bolt of Damask, buyable) yields a non-leaf whose station recipe expands toward raw materials — not truncated at the buyable item; a standalone resolve(46742) (Lump of Mithrillium) keeps both its station recipes. (Both confirmed against the live API — research.md V1. These deep / multi-recipe cases are exercised by resolving the station item directly: within a current Gen 1 tree the forge branches bottom out earlier — research.md F6.)
  • SC6 — projectTree(graph) yields a root-anchored nested tree, renders a shared item at each usage position, selects one recipe per node via the default chooser, and performs no I/O.
  • SC7 — On a warm static-data cache (005 caches populated), a repeat resolve(30698) issues 0 new GW2 API calls (asserted via mocked call counts; the 005 item/recipe/search caches have no TTL — research.md V2). Any wall-clock figure is an observed value recorded at step 5, not a spec-time target.
  • SC8 — A curated dataset doctored with a cycle makes resolve throw a clear CycleError (no stack overflow, no infinite loop).
  • SC9 — A resolved graph serializes to plain JSON (nodes an id-keyed object) and round-trips unchanged — no Map on the wire.
  • SC10 — Zero files are written under docs/superpowers/ (the existing repo invariant test still passes; all 007 artifacts live under specs/007-recipe-graph-resolver/).
  • SC11 — GET /recipe-graph/:itemId for a known legendary returns 200 with the projected display tree (server-side projectTree); a non-integer param returns 400; an unknown item returns 404.
  • SC12 — The generated OpenAPI document (and the committed openapi.json) documents GET /recipe-graph/{itemId} with the tree response schema, and the deterministic-emit guard test still passes.

Out of scope ​

  • Prices, cost, profit, and the buy-vs-craft min selection — spec 008. 007 keeps every recipe option and chooses none.
  • Cost roll-ups / aggregate-to-root quantities — need prices and path multiplication (008). 007 carries raw per-recipe counts only.
  • Ranking legendaries by margin — 008 and beyond.
  • Account state / own-vs-need personalisation — a later account spec.
  • Currency pricing or expansion (armor's currency ingredients) — a forward seam only; no Gen 1 weapon uses it, and 007 builds no currency logic.
  • Precursor-crafting — the API's LegendaryComponent precursor recipes are filtered (R12); the precursor-crafting tree is out of scope and precursors remain buyable leaves (research.md F7, inherited from 006).
  • Deepening the mid-tree forge gifts — Gift of Might/Magic and the weapon-specific gifts are uncurated by 006 and absent from /v2/recipes, so they resolve as gated leaves and their buyable T6-mat cost is not yet expanded (a real Gen 1 tree is shallow today — ~13 nodes for The Bifrost). Curating them is a future spec; 007 resolves available data faithfully (research.md F6).
  • Any new cache or persistence — 007 reuses 005's in-memory client caches; no Postgres, no Redis.
  • Frontend rendering — 007 ships the pure projectTree helper, not React components; the tree view itself is a frontend spec.

Assumptions ​

  • The 006 services (CuratedRecipeService, StationDataService, ItemDataService) are available for DI with the semantics documented in spec 006, and the 005 client's caching / 206-404 / backoff behaviour holds (gw2-api.md).
  • The input universe is the 006 curated Gen 1 weapon dataset; all its curated item ids are real (006 R9). Station recipes for sub-ingredients are fetched live through 006.
  • The happy-path dependency graph is acyclic; the cycle guard (R7) is defense-in-depth, not an expected path.
  • A fully-expanded tree is bounded and terminates — station chains descend to raw mats, while forge / gated / precursor nodes are leaves — so the whole graph is resolved eagerly in one call. Under 006's current curation a Gen 1 tree is small (~13 distinct nodes for The Bifrost — research.md F6) and deepens only as more forge nodes are curated. No pagination or streaming.

Traceability ​

Each acceptance scenario and success criterion maps to a named test. Paths: pkg = packages/recipe-graph/src/types.test.ts, tree = apps/api/src/recipe-graph/project-tree.test.ts, svc = apps/api/src/recipe-graph/recipe-graph.service.test.ts, wc = apps/api/src/recipe-graph/recipe-graph.service.warm-cache.test.ts, ctl = apps/api/src/recipe-graph/recipe-graph.controller.test.ts, mod = apps/api/src/recipe-graph/recipe-graph.module.test.ts, oapi = apps/api/src/generate-openapi.test.ts, stn = apps/api/src/static-data/station-data.service.test.ts.

CriterionTest
P1 #1svc — "P1 #1: root carries the Bifrost forge recipe (precursor+Fortune+Mastery+weapon-gift)"
P1 #2svc — "P1 #2 / SC2: a shared child is one node, each 006 service queried once per id"
P1 #3svc — "P1 #3 / SC5: resolve(46741) expands the station recipe past a buyable item"
P1 #4svc — "P1 #4 / SC4: a precursor whose only recipe is LegendaryComponent resolves as a leaf"
P1 #5svc — "P1 #5 / SC3: resolve(Eternity) expands Sunrise+Twilight, shared sub-nodes single"
P1 #6svc — "P1 #6: nodes are enriched via one batched metadata call; omitted ids stay null"
P1 #7svc — "P1 #7: an output with two station recipes keeps both options"; "P1 #7 / F8: a gated-but-craftable node keeps its recipes and classifies gated"
P2 #1tree — "P2 #1: nests {node,count,recipe,children} from rootId"
P2 #2tree — "P2 #2: a shared item is rendered at each usage and projection terminates"
P2 #3tree — "P2 #3 / SC6: default chooser picks one recipe; a passed chooser overrides"
P3 #1ctl — "P3 #1 / SC11: GET returns 200 with projectTree(resolve(id))"
P3 #2ctl — "P3 #2: a non-integer param returns 400"; "…a non-positive param returns 400"; "…an unknown item (null root metadata) returns 404"
P3 #3oapi — "SC12 / P3 #3: documents GET /recipe-graph/{itemId} with the tree response schema"
SC1svc — "SC1: every leaf carries a classification; non-leaves carry ≥1 recipe"
SC2svc — "P1 #2 / SC2: a shared child is one node, each 006 service queried once per id"
SC3svc — "P1 #5 / SC3: resolve(Eternity) expands Sunrise+Twilight, shared sub-nodes single"
SC4svc — "SC4: a gated input classifies gated, a buyable leaf classifies buyable"; "P1 #4 / SC4: …precursor…leaf"
SC5svc — "P1 #3 / SC5: resolve(46741) expands the station recipe past a buyable item"
SC6tree — "P2 #3 / SC6: default chooser picks one recipe; a passed chooser overrides"
SC7wc — "SC7: the second resolve issues 0 new GW2 fetches"
SC8svc — "SC8: a cyclic dataset makes resolve throw CycleError naming the id"
SC9svc — "SC9: graph JSON round-trips and nodes is a plain object"
SC10tests/workflow/repo-invariants.test.ts — the existing docs/superpowers/ zero-write invariant (all 007 artifacts live under specs/007-recipe-graph-resolver/)
SC11ctl — "P3 #1 / SC11: GET returns 200 with projectTree(resolve(id))"
SC12oapi — "SC12 / P3 #3: documents GET /recipe-graph/{itemId} with the tree response schema"
R1mod — "R1: RecipeGraphService resolves via DI from StaticDataModule"; "R1: the controller resolves via DI"