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
- Given the id of a Gen 1 legendary (The Bifrost, 30698), when
resolveis called, then the returned graph's root node carries amystic-forgerecipe 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. - 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/CuratedRecipeServiceare queried at most once for that id. - 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).
- Given an item with no usable recipe — a raw drop, a gated input, or a Gen 1 precursor (whose only recipe is the
LegendaryComponentpath filtered by R12) — when it is reached, then its node hasrecipes: []andleaf: true. - 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. - Given each reached node, when the graph is returned, then every node carries its
classification(buyable/gated, via 006'sclassify) and display metadata (name,rarity,vendorValue), fetched in a single batchedItemDataService.metadatapass over the distinct ids (chunked ≤199), with ids the GW2 API omitted left asnullfields rather than erroring. - Given an item with more than one station recipe that is also
gated(Lump of Mithrillium, 46742,AccountBoundwith two recipes), when the graph is resolved, then the node is not a leaf, both recipes are kept as options, and itsclassificationisgated— provingclassification(a flag fact) andleaf(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
- Given a resolved graph, when
projectTree(graph)is called, then it returns a tree anchored atrootIdwhere each entry is{ node, count, recipe, children },countis the quantity the parent recipe needs (root = 1), andchildrenare the ingredients of the chosen recipe. - 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).
- Given a node with more than one recipe option and no
chooseRecipeargument, 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 achooseRecipethat 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
- Given a valid legendary id, when
GET /recipe-graph/:itemIdis called, then it returns200with a nestedTreeNode(projectTreeof the resolved graph, run server-side), each node carrying its metadata +classificationand the parent-recipecount. - 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 returns404. - Given the offline-generated OpenAPI document, when it is inspected, then it documents
GET /recipe-graph/{itemId}with the tree response schema.
Requirements
- R1 —
RecipeGraphModuleprovidesRecipeGraphServicewith one I/O method,resolve(itemId: number): Promise<ResolvedGraph>. It depends only on 006'sCuratedRecipeService,StationDataService, andItemDataService(imported viaStaticDataModule) and never callsGw2Servicedirectly (stack.md — all GW2 access goes through the one budgeted client, which 006 already wraps). - R2 —
ResolvedGraphis{ rootId: number; nodes: Record<number, GraphNode> }.nodesis 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 JavaScriptMapcrosses the HTTP boundary (aMapserializes to{}). AMapmay be used internally. - R3 — Each
GraphNodeis{ 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/classificationarenulliff the GW2 API omitted the id (206/404 — inherited from 006'smetadata). Metadata is inlined (not a nested 006ItemMeta) so the type can live in a shared package without dragging anapps/apitype into it. - R4 — A node's
recipesis the union of the curated forge recipe (0..1, fromCuratedRecipeService.getRecipe) and all station recipes (0..N, fromStationDataService.getRecipes). EachRecipeOptionis{ source: 'mystic-forge'|'station'; outputCount: number; ingredients: RecipeEdge[]; provenance: string }(provenance= the curatedsourceURL, or the station recipe's disciplines + min-rating). All options are kept — 007 chooses none (the buy-vs-craftminis 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 —
RecipeEdgeis a discriminated union mirroring 006's curated ingredient:{ kind: 'item'; itemId; count }(resolvable vianodes[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). Edgecountis 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:
resolvemaintains the current ancestor path during the walk and throws a clearCycleError(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 whichresolvethrows. - R8 —
resolvedoes not throw on "not found": an unknown or recipe-less root id yields a valid one-node graph whose node is aleaf(withnullmetadata if the id does not exist). Missing child metadata likewise yields a present node withnullfields, never an exception. - R9 — A hard
Gw2Servicefailure (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 atrootId.chooseRecipe(node) => RecipeOption|nulldefaults 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.projectTreeand the endpoint's Zod schema live inapps/api— the SWC-built API cannot load a runtime value from a.tsworkspace package (F12 — research.md;curated-recipe.service.ts:1-6), and API-boundary Zod belongs in the app (thehealth.schema.tspattern).projectTreestays 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/:itemIdreturns the projected display tree —projectTreerun 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; otherwise200with the tree. The response is documented in the generated OpenAPI document using the project's nestjs-zod DTO pattern (createZodDto+@ZodResponse, ashealthdoes). The rawResolvedGraph(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 inresearch.mdwith 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 amystic-forgerecipe = Precursor + Gift of Fortune + Gift of Mastery + Gift of the Bifrost; every non-leaf node has ≥1 recipe option; every leaf hasrecipes: []and (for an existing id) a non-nullclassification. - 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 (itsLegendaryComponentrecipe filtered per R12) or a raw material resolves to a leaf classifiedbuyable; both haverecipes: []. - 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 standaloneresolve(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
resolvethrow a clearCycleError(no stack overflow, no infinite loop). - SC9 — A resolved graph serializes to plain JSON (
nodesan id-keyed object) and round-trips unchanged — noMapon the wire. - SC10 — Zero files are written under
docs/superpowers/(the existing repo invariant test still passes; all 007 artifacts live underspecs/007-recipe-graph-resolver/). - SC11 —
GET /recipe-graph/:itemIdfor a known legendary returns200with the projected display tree (server-sideprojectTree); a non-integer param returns400; an unknown item returns404. - SC12 — The generated OpenAPI document (and the committed
openapi.json) documentsGET /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
minselection — 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
currencyingredients) — a forward seam only; no Gen 1 weapon uses it, and 007 builds no currency logic. - Precursor-crafting — the API's
LegendaryComponentprecursor 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
projectTreehelper, 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.
| Criterion | Test |
|---|---|
| P1 #1 | svc — "P1 #1: root carries the Bifrost forge recipe (precursor+Fortune+Mastery+weapon-gift)" |
| P1 #2 | svc — "P1 #2 / SC2: a shared child is one node, each 006 service queried once per id" |
| P1 #3 | svc — "P1 #3 / SC5: resolve(46741) expands the station recipe past a buyable item" |
| P1 #4 | svc — "P1 #4 / SC4: a precursor whose only recipe is LegendaryComponent resolves as a leaf" |
| P1 #5 | svc — "P1 #5 / SC3: resolve(Eternity) expands Sunrise+Twilight, shared sub-nodes single" |
| P1 #6 | svc — "P1 #6: nodes are enriched via one batched metadata call; omitted ids stay null" |
| P1 #7 | svc — "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 #1 | tree — "P2 #1: nests {node,count,recipe,children} from rootId" |
| P2 #2 | tree — "P2 #2: a shared item is rendered at each usage and projection terminates" |
| P2 #3 | tree — "P2 #3 / SC6: default chooser picks one recipe; a passed chooser overrides" |
| P3 #1 | ctl — "P3 #1 / SC11: GET returns 200 with projectTree(resolve(id))" |
| P3 #2 | ctl — "P3 #2: a non-integer param returns 400"; "…a non-positive param returns 400"; "…an unknown item (null root metadata) returns 404" |
| P3 #3 | oapi — "SC12 / P3 #3: documents GET /recipe-graph/{itemId} with the tree response schema" |
| SC1 | svc — "SC1: every leaf carries a classification; non-leaves carry ≥1 recipe" |
| SC2 | svc — "P1 #2 / SC2: a shared child is one node, each 006 service queried once per id" |
| SC3 | svc — "P1 #5 / SC3: resolve(Eternity) expands Sunrise+Twilight, shared sub-nodes single" |
| SC4 | svc — "SC4: a gated input classifies gated, a buyable leaf classifies buyable"; "P1 #4 / SC4: …precursor…leaf" |
| SC5 | svc — "P1 #3 / SC5: resolve(46741) expands the station recipe past a buyable item" |
| SC6 | tree — "P2 #3 / SC6: default chooser picks one recipe; a passed chooser overrides" |
| SC7 | wc — "SC7: the second resolve issues 0 new GW2 fetches" |
| SC8 | svc — "SC8: a cyclic dataset makes resolve throw CycleError naming the id" |
| SC9 | svc — "SC9: graph JSON round-trips and nodes is a plain object" |
| SC10 | tests/workflow/repo-invariants.test.ts — the existing docs/superpowers/ zero-write invariant (all 007 artifacts live under specs/007-recipe-graph-resolver/) |
| SC11 | ctl — "P3 #1 / SC11: GET returns 200 with projectTree(resolve(id))" |
| SC12 | oapi — "SC12 / P3 #3: documents GET /recipe-graph/{itemId} with the tree response schema" |
| R1 | mod — "R1: RecipeGraphService resolves via DI from StaticDataModule"; "R1: the controller resolves via DI" |