Skip to content

Spec 008 — Buy-vs-craft pricing ​

Status: implemented Branch: 008-buy-vs-craft-pricing

Status is set by the human, never by the agent. It moves draft → approved → implemented. Transcribed to implemented on the human's explicit instruction (2026-08-02), part of the feature-branch PR diff.

Problem ​

Spec 007 resolves a legendary's full dependency graph and projects it to a nested display tree at GET /recipe-graph/:itemId, but it is purely structural: it computes no gold cost, reads no price, and chooses no recipe — it keeps every RecipeOption and leaves the buy-vs-craft min to 008 (007 R4, R5). So nothing yet answers the question the planner exists for: at each node, is it cheaper to buy the item on the Trading Post or craft it — what does the cheapest whole plan cost, and would selling the finished legendary profit?

There is also a data gap that makes naïve pricing lie. The curated forge dataset today contains recipes only for the 21 Gen 1 weapons, Gift of Fortune (19626) and Gift of Mastery (19674). The Mystic-Forge intermediates on the path — Gift of Might (19672), Gift of Magic (19673), Mystic Clover (19675, ×77 for The Bifrost), Gift of The Bifrost (19654) — have no curated recipe and (being Mystic-Forge output, absent from /v2/recipes) resolve as gated leaves, which also strands everything below them (the sub-gifts Gift of Energy/Color, though they do have discipline recipes, are never reached). Those "leaves" are not free: a Mystic Clover consumes buyable Mystic Coins + Globs of Ectoplasm; the Might/Magic gifts consume 1000+ buyable T6 materials. Priced at 0, a Bifrost's cost — and therefore its profit — is garbage.

008 makes the flagship number real and trustworthy: it prices the graph under a stated market convention, and curates the Bifrost's complete forge path down to buyable raw materials so no gold-bearing node bottoms out at 0. Truly-earned tokens (Obsidian Shards, Bloodstone Shard, Spirit Shards, Gift of Exploration/Battle) stay 0 — you grind them, you don't buy them. Other Gen 1 weapons stay priced-but-partial (a documented, known limitation) until a later curation pass.

A framing note (research F1). Discovery found that on the Gen 1 path the buy-vs-craft min changes no decision: every craftable intermediate is AccountBound (can't be bought → force-crafted) and every buyable node is a raw-material leaf (can't be crafted → force-bought). A genuine buy-vs-craft choice needs a node that is both buyable and craftable — a Gen 2/3 / armor case, absent from Gen 1. So for the flagship, 008 is in practice a cost roll-up + profit calculator; the min(buy, craft) engine is still built and correct because it is forward-compatible (it will bite on later generations), but this spec does not oversell it as an "optimizer" for Gen 1. (Human decision, 2026-08-01: keep the engine, soften the framing.)

User stories ​

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

P1 — Price a resolved graph into an enriched, cost-annotated plan ​

As the planner, I want a pure function that folds Trading Post prices into a resolved graph and enriches every node with its buy-vs-craft decision and cost — plus a root-level cost/net-sell/profit summary — so a player sees, at each node, what to buy, what to craft, what it costs, and whether crafting to sell pays.

Independent test: priceGraph(graph, priceMap) is a pure function fed a hand-built ResolvedGraph (007's shape) and a plain id → { buys, sells } map; asserted on the returned enriched tree + summary with no I/O, no services, no live GW2 API. Every cost, decision, and summary field is verifiable from the inputs.

Acceptance scenarios

  1. Given a buyable-and-craftable node, when the graph is priced, then the node carries bothunitBuyPrice (its sells.unit_price, the buy option) and craftCost (the summed per-unit cost of its cheapest recipe), unitCost = the min of the two, and decision = which won ('buy' or 'craft'); a buy/craft tie resolves to 'buy'.
  2. Given a node whose decision is 'buy' (buying is cheaper), when the graph is priced, then its crafted subtree is still present (recipe and children kept, fully expanded) — the decision is a marker, not a prune — so the UI can show "you'd craft this like so, but buying is cheaper."
  3. Given a node with more than one RecipeOption, when craftCost is computed, then it is the minimum over the options, and the recipe/children shown are that cheapest option's expansion.
  4. Given a gated leaf (classification: 'gated', recipes: [] — e.g. Gift of Exploration), when the graph is priced, then unitCost is 0, decision is 'gated', and it contributes 0 to the roll-up (player-supplied, per domain.md).
  5. Given a gated-and-craftable node (classification: 'gated' with recipes — e.g. Mystic Clover), when the graph is priced, then it has no buy option (not on the TP), so decision is 'craft', its children expand, and its craftCost rolls up from its buyable ingredients (Mystic Coins, Ectos) — a gated node is 0-gold only when it is a true leaf.
  6. Given the root's chosen path is fully priced, when the graph is priced, then summary carries totalCraftCost (the root's rolled-up cheapest cost — each node contributing its unitCost = min(buy, craft), so a buy-decision node's shown children do not inflate the total), rootBuyPrice (the root's sells.unit_price), netSell = netSellPrice(root.buys.unit_price), and profit = netSell − totalCraftCost.
  7. Given the root is always the product being crafted, when the graph is priced, then the root node's decision is 'craft' (never 'buy') even if rootBuyPrice < totalCraftCost; the buy price is surfaced only via summary.rootBuyPrice for comparison.
  8. Given a root with no buy order on the TP (not sellable), when the graph is priced, thensummary.netSell and summary.profit are null (sellability read from prices, never inferred from generation — domain.md), while totalCraftCost is still produced.

P2 — Serve the priced plan by extending the recipe-graph endpoint ​

As the frontend, I want GET /recipe-graph/:itemId to return the tree already enriched with costs and a summary so that I can render the plan, per-node buy/craft badges, and the profit line without resolving a graph, fetching prices, or running the optimizer myself.

Independent test: a controller/service is exercised with a mocked RecipeGraphService (fixture ResolvedGraph) and a mocked Gw2Service.prices; status codes and the enriched body are asserted with no live server and no 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 the enriched tree — every 007 field (node, count, recipe, children) unchanged and still present, each node additionally carrying decision, unitBuyPrice, craftCost, unitCost, lineCost, and the root additionally carrying summary (a PlanSummary).
  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 (resolved root metadata is null), then it returns 404 — guards unchanged from 007.
  3. Given a resolved graph, when the response is built server-side, then the distinct buyable ids plus the root id are fetched in one batched Gw2Service.prices call (never per-node), the price map is handed to the pure priceGraph, and 007's RecipeGraphService.resolve is left unchanged and price-free (the price fetch and cost fold live in the controller/service layer, not the resolver).

P3 — Curate the Bifrost's forge path so its number is real ​

As a seller pricing The Bifrost, I want every gold-bearing node on its path expanded to buyable raw materials — not bottomed out as a free gated leaf — so that its totalCraftCost and profit reflect reality.

Independent test: resolve(30698) against the real curated dataset (007's resolver, unchanged code) is asserted to expand Gift of Might, Gift of Magic, Mystic Clover, and Gift of the Bifrost into their buyable ingredients down to raw materials, with only the earned tokens remaining as leaves; and the curated dataset's own guard tests stay green.

Acceptance scenarios

  1. Given the curated dataset, when it is loaded, then it contains exactly four new Mystic-Forge recipes — Gift of Might (19672), Gift of Magic (19673), Mystic Clover (19675), Gift of The Bifrost (19654) — each ingredient id verified against the wiki (research V2); the sub-gifts Gift of Energy (19623) and Gift of Color (19638) are not curated (they are /v2/recipes discipline recipes 007's station path already resolves).
  2. Given resolve(30698), when the graph is resolved, then Gift of Might / Gift of Magic expand to their (buyable) T6 materials, Mystic Clover expands to Mystic Coins + Obsidian Shards + Ectoplasm + Mystic Crystal, and Gift of The Bifrost expands to Gift of Energy + Gift of Color (which the station path further expands to buyable dusts / dyes / Opal Orbs) + Icy Runestones + a Sigil — none of these four remains a leaf.
  3. Given the probabilistic Mystic Clover recipe, when it is curated, then it is encoded as an expected-value recipe: its outputCount is the expected clover yield per Forge batch (a fractional number, verified against the wiki), so per-unit clover cost = batch input cost ÷ expected yield.
  4. Given the earned tokens on the path (Obsidian Shards 19925, Bloodstone Shard 20797, Spirit Shards, Gift of Exploration 19677, Gift of Battle 19678), when the graph is priced, then they remain leaves valued at 0 (they are not curated into recipes; they are player-supplied).

Requirements ​

  • R1 — A pure priceGraph(graph: ResolvedGraph, priceMap): PricedTree lives in apps/api next to project-tree.ts (007 R11 — the SWC-built API cannot load a runtime value from a .ts workspace package). It performs no I/O and calls no service. priceMap is a plain Map<number, { buys: { quantity; unit_price }; sells: { quantity; unit_price } }> — the fields 008 needs from Gw2Price, decoupled from the client type.
  • R2 — Per-unit chosen cost is computed once per distinct item id (memoised over the DAG, mirroring 007's dedup). For an id, the two options are:
    • the buy option (unitBuyPrice) — sells.unit_price iff classification === 'buyable' and the id has a usable price (present in priceMap, sells.quantity > 0, sells.unit_price > 0); otherwise no buy option.
    • craftCost — the minimum over the node's RecipeOptions of ceil( Σ_itemEdges (childChosenCost(edge.itemId) × edge.count) / outputCount ). ceil never understates; dividing by outputCount yields a per-unit cost (a recipe yields outputCount items — possibly fractional, R11). Currency edges contribute 0. If no option is fully priced, no craft option.
  • R3 — Per id: unitCost = min(unitBuyPrice, craftCost) over the defined options; decision = the winner, with a tie → 'buy' (simpler plan; deterministic for golden tests). A gated leaf (classification: 'gated', recipes: []) → unitCost 0, decision 'gated'. If neither option is defined (a rare unpriced, non-craftable node — see Assumptions) → unitCost null, decision 'unknown', and the null propagates through the roll-up so no total is silently faked. No special counting or flag is emitted for this case (unpriced items are treated as a non-issue per the human decision).
  • R4 — Unlike 007, 008 selects a recipe: the tree shows the cheapest RecipeOption's expansion at each node. But it never prunes — a 'buy'-decision node keeps its recipe and children fully expanded (decision is a marker, not a cut). The root is always crafted: its decision is 'craft' (or 'unknown'), regardless of its own buy price. (Human decisions, 2026-08-01: "keep full structure, mark decision"; root is the product.)
  • R5 — A PricedTreeNode is 007's TreeNode plus cost fields: { node: GraphNode; count: number; recipe: RecipeOption|null; children: PricedTreeNode[]; decision: 'buy'|'craft'|'gated'|'unknown'; unitBuyPrice: number|null; craftCost: number|null; unitCost: number|null; lineCost: number|null }. node/count/recipe/children are unchanged from 007 (existing consumers keep finding them). unitBuyPrice = the usable sells.unit_price or null (the buy option, R2); craftCost = the cheapest recipe's per-unit cost (or null); unitCost = min (R3); lineCost = unitCost × count (or null). A shared id renders at each usage position (display duplication, as in 007), each carrying its own count/lineCost but the same per-id unitCost/decision.
  • R6 — A PlanSummary is { rootId: number; totalCraftCost: number|null; rootBuyPrice: number|null; netSell: number|null; profit: number|null }. totalCraftCost = the root's rolled-up cost, each node contributing its unitCost (so 'buy' nodes' shown children do not inflate it). netSell = netSellPrice(root.buys.unit_price) when the root has a buy order, else null. profit = netSell − totalCraftCost when both are non-null, else null. rootBuyPrice = the root's sells.unit_price or null. The summary is attached to the root PricedTreeNode only (child nodes have no summary), so the HTTP body stays "the tree" and existing consumers are undisturbed. (Human decision, 2026-08-01: "always priced, extra fields — structure unchanged.")
  • R7 — netSell uses netSellPrice from @gw2priory/domain (the existing floor-of-85% helper) — 008 does not re-derive the 15% tax. Sellability is a per-item /v2/commerce/prices fact (the root has a buy order or it does not); 008 never infers it from legendary generation (domain.md).
  • R8 — The price convention is instant / conservative: inputs are acquired at sells.unit_price (buy now) and the finished legendary is valued at buys.unit_price before tax (fill now). No buy-order / optimistic model is built. (Human decision, 2026-08-01.)
  • R9 — GET /recipe-graph/:itemId is extended (not replaced, not siblinged): it now resolves, fetches prices, folds priceGraph, and returns the enriched tree + root summary. It always prices (no query flag). The path-param guards (400 non-positive/non-integer, 404 unknown root) are unchanged. The price fetch (R2's single batched Gw2Service.prices over distinct buyable ids ∪ root) and the fold live in the controller/service layer; RecipeGraphService.resolve is not modified. (Human decision, 2026-08-01: "extend 007's endpoint.")
  • R10 — The response is documented in the generated OpenAPI via the nestjs-zod pattern (createZodDto + @ZodResponse). 007's TreeNodeSchema (recursive, z.lazy, : z.ZodType<…> drift guard) is extended with the cost fields into a PricedTreeNodeSchema, and the root DTO adds summary. The deterministic-emit guard test still passes; the OpenAPI GET /recipe-graph/{itemId} entry reflects the enriched shape.
  • R11 — Fractional outputCount. The curated recipe schema (packages/legendary-recipes/src/schema.ts) and 007's API response RecipeOptionSchema (recipe-graph.schema.ts) currently pin outputCount to a positive integer. 008 relaxes both to a positive number so an expected-value forge recipe (Mystic Clover) can encode a fractional yield. The shared TS type is already number. Ingredient counts remain positive integers. (Human-approved recommendation, 2026-08-01.)
  • R12 — Curation (Bifrost path) — exactly four Forge recipes. Discovery (research V2) pinned the tree: only four items on the Bifrost path have no /v2/recipes entry and therefore need curated Mystic-Forge recipes — Gift of Might (19672), Gift of Magic (19673), Mystic Clover (19675), and Gift of The Bifrost (19654). New curated recipes for these four are added to the @gw2priory/legendary-recipes dataset (method: 'mystic-forge'), each ingredient id cited from the wiki. The two sub-gifts of Gift of The Bifrost — Gift of Energy (19623 → recipe 4315, Artificer) and Gift of Color (19638 → recipe 3165, Chef) — are ordinary discipline recipes 007's station path already resolves, so they are not curated; once Gift of The Bifrost is curated they expand automatically to their buyable dusts/dyes/orbs. The dataset's own guard tests (packages/legendary-recipes) stay green (research V6). (Human decision, 2026-08-01: "full Bifrost, careful ids" — which discovery showed needs only these four Forge recipes.)
  • R13 — Earned/vendor tokens are not curated into recipes and remain gated leaves valued at 0: Obsidian Shards (19925), Bloodstone Shard (20797), Spirit Shards (currency), Mystic Crystal / Philosopher's Stone (vendor), Gift of Zhaitan (19669, dungeon-token vendor), Icy Runestone (karma vendor), Gift of Exploration (19677), Gift of Battle (19678). Curation stops at the boundary between buyable materials and earned/vendor items (research V2/V3).
  • R14 — 008 adds no new price cache (the 005 client owns the 60 s price TTL and 199-id batching — gw2-api.md); a hard Gw2Service failure propagates with no added retry/backoff. 008 writes no file under docs/superpowers/ and adds no persistence (no Postgres, no Redis).

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 an endpoint returns that field, whether a recipe's contents are as stated). Answered in research.md with cited evidence, never by assumption. Blocks the approval gate.

All six [NEEDS VERIFICATION] markers below were resolved in step 1.5 — every one confirmed; verdicts and cited evidence live in research.md (V1–V6). Kept here as the record of what was checked.

  • ✅ [VERIFIED — research V1] exact ingredients of Gift of Might (19672 = 250× Vicious Fang / Armored Scale / Vicious Claw / Ancient Bone) and Gift of Magic (19673 = 250× Vial of Powerful Blood / Powerful Venom Sac / Elaborate Totem / Pile of Crystalline Dust) — all TP-buyable.
  • ✅ [VERIFIED — research V2] Gift of The Bifrost (19654) = Gift of Energy (19623) + Gift of Color (19638) + 100 Icy Runestone + 1 Superior Sigil of Nullification; the two sub-gifts are /v2/recipes discipline recipes (4315, 3165) 007 resolves without curation; Gift of Zhaitan (19669) is a vendor leaf.
  • ✅ [VERIFIED — research V3] Mystic Clover (19675): ~31% success ⇒ ≈3.1 clovers per 10-input batch (10 Obsidian + 10 Mystic Coin + 10 Ecto + 10 Mystic Crystal) → fractional outputCount ≈ 3.1; only Mystic Coin + Ecto are buyable.
  • ✅ [VERIFIED — research V4] Gw2Client.getByIds omits ids the API doesn't return (gw2-client.ts:197-215); a live 206 probe dropped a non-tradeable id; empty market reads as quantity: 0.
  • ✅ [VERIFIED — research V5] The Bifrost (30698) has a live buys order and no AccountBound flag ⇒ classified buyable; netSell/profit compute.
  • ✅ [VERIFIED — research V6] adding the four curated recipes keeps the real-data guard test and 007's (mock-based) resolver tests green; curated shape carries method: 'mystic-forge'; the fractional-outputCount relaxation is isolated to two schema lines.

Any success criterion stating a number carries a [NEEDS VERIFICATION] until a measurement in research.md backs it. 008's success criteria assert behaviour over fixtures (deterministic golden values) plus the verified curation, not live market numbers.

Success criteria ​

Measurable and technology-agnostic — outcomes, not implementation.

  • SC1 — Over a fixture graph, a node carries both unitBuyPrice and craftCost; unitCost = min and decision is the winner; a tie resolves to 'buy'.
  • SC2 — A 'buy'-decision node keeps its recipe and fully-expanded children (never pruned); its unitCost equals unitBuyPrice, and its children do not contribute to totalCraftCost.
  • SC3 — Multi-recipe: given two options of different cost, the chosen recipe/craftCost are the cheaper option's.
  • SC4 — Gated: a gated leaf → unitCost 0 / decision 'gated', excluded from totalCraftCost; a gated craftable node (Mystic Clover) → decision 'craft', children expanded, cost rolled up from buyable ingredients (never a 0-gold leaf).
  • SC5 — Root economics: for a fixture legendary with a priced path and a sellable root, totalCraftCost equals the rolled-up cheapest cost, netSell = netSellPrice(root.buys.unit_price), profit = netSell − totalCraftCost, and the root's decision is 'craft' even when rootBuyPrice < totalCraftCost.
  • SC6 — Non-sellable root: with the root absent from prices (no buys), netSell and profit are null while totalCraftCost is still produced.
  • SC7 — priceGraph is pure: same (graph, priceMap) → identical PricedTree, no I/O (asserted with a frozen input and no mocked network).
  • SC8 — GET /recipe-graph/:itemId returns 200 with the enriched tree + root summary for a known legendary; every 007 field is still present on each node; a non-integer/non-positive param → 400; an unknown item → 404. The endpoint issues exactly one Gw2Service.prices call whose id set = distinct buyable ids ∪ { rootId }, and calls resolve once (mocked call counts).
  • SC9 — Fractional outputCount: the curated schema and the API response schema accept a positive non-integer outputCount; a Mystic Clover recipe with a fractional expected yield validates and its per-unit cost = batch input cost ÷ yield.
  • SC10 — resolve(30698) against the real dataset expands Gift of Might, Gift of Magic, Mystic Clover, and Gift of the Bifrost to buyable raw materials; the earned tokens (R13) remain leaves; the resulting priced Bifrost has a non-zero, complete totalCraftCost (no gold-bearing node valued at 0).
  • SC11 — A PricedTree serializes to plain JSON and round-trips unchanged (no Map on the wire; summary and nested children intact).
  • SC12 — The generated OpenAPI document (and committed openapi.json) documents the enriched GET /recipe-graph/{itemId} response (PricedTreeNode + root summary); the deterministic-emit guard test still passes.
  • SC13 — Zero files are written under docs/superpowers/ (the existing repo invariant test still passes; all 008 artifacts live under specs/008-buy-vs-craft-pricing/).

Out of scope ​

  • Aggregated shopping list — a deduped "buy N of item X across all branches" roll-up is its own later spec. 008 annotates the tree and summarises totals; it emits no flattened buy-list.
  • Curating the other 20 Gen 1 weapons' paths — 008 curates The Bifrost end-to-end (P3); other weapons are priced-but-partial (their uncurated weapon-gifts stay gated leaves = 0) — a documented, known limitation.
  • Account state / own-vs-need — "you already own 40 ectos" personalisation is a later account spec. 008 prices as if nothing is owned.
  • Opportunity-costing gated inputs — earned tokens are 0 gold; valuing them (gold-per-Gift, time) is out of scope.
  • The patient / optimistic (buy-order) price convention — 008 uses instant/conservative only (R8).
  • Ranking legendaries by margin — running the priced engine across all Gen 1 weapons and sorting is a later spec.
  • Robust unpriced-item handling — treated as a non-issue (R3); no counting, flagging, or "incomplete" UI is built (human decision, 2026-08-01).
  • Frontend rendering — 008 ships the engine, the extended endpoint, and the curation; the tree/profit UI is a frontend spec.

Assumptions ​

  • Currency edges cost 0 gold — RecipeEdge of kind: 'currency' (Spirit Shards, tokens) is player- supplied, like a gated input. A recipe requiring Coin as a currency is a known gap, recorded not handled.
  • Buy/craft ties resolve to 'buy' — arbitrary but deterministic, so golden tests are stable.
  • Every buyable leaf has a usable TP price — in practice all real items trade on the TP; unpriced is a non-issue (R3). A genuinely missing price yields a null that propagates without special handling.
  • 007's RecipeGraphService.resolve is available with its documented shape and is not modified by 008; the 005 Gw2Service.prices caching (60 s TTL) / 206–404 / batching behaviour holds (gw2-api.md).
  • The Mystic Clover recipe is probabilistic; modelling it by expected yield (a fractional outputCount) is an accepted approximation of its real gold cost, not an exact per-craft figure.
  • Adding R12's curated recipes does not require any change to 007's resolver code — the resolver already unions curated recipes into the graph; only data (and the R11 schema relaxation) change.
  • summary is modeled as an optional property on the recursive node schema in the generated OpenAPI, not required-at-root (implementation note, R6/R10/SC12). A required-at-root summary would need a separate root component, which breaks SC12's recursive $ref cycle (children must $ref the same named component — the generate-openapi.test.ts guard asserts this). The "summary on root only" guarantee therefore holds at the type level (PricedRoot = PricedTreeNode & { summary }) and at runtime (the controller always populates it, only at the root), but the recursive OpenAPI schema documents it as optional-on-any-node. This is a documentation-fidelity trade-off, not a runtime gap.

Traceability ​

Each acceptance scenario and success criterion maps to a named test. Filled during implementation — titles below are transcribed verbatim from the shipped tests. Paths: eng = apps/api/src/recipe-graph/pricing.test.ts (pure priceGraph), ctl = apps/api/src/recipe-graph/recipe-graph.controller.test.ts (extended), svc = apps/api/src/recipe-graph/recipe-graph.service.bifrost.test.ts (real-data resolve of the Bifrost path), sch = apps/api/src/recipe-graph/recipe-graph.schema.test.ts (response schema) + packages/legendary-recipes/src/index.test.ts (fractional outputCount, curated dataset guards), oapi = apps/api/src/generate-openapi.test.ts, inv = the contract no-drift guard + the existing repo-invariant test. Amended by spec 012 — the contract half was tests/contract/no-drift.test.ts, deleted there as a duplicate of CI's own pnpm verify:contract step (see spec 004's P3 #2 row); the guarantee is unchanged, its owner is the CI step rather than a test.

CriterionTest
P1 #1eng — "P1 #1 / SC1: node carries unitBuyPrice+craftCost, unitCost=min, decision, tie→buy"
P1 #2eng — "P1 #2 / SC2: a buy-decision node keeps its expanded subtree and its children do not inflate the total"
P1 #3eng — "P1 #3 / SC3: cheapest recipe drives craftCost and the shown children"
P1 #4eng — "P1 #4 / SC4: a gated leaf is 0-gold, decision gated, excluded from totalCraftCost"
P1 #5eng — "P1 #5 / SC4: a gated-craftable node (Mystic Clover) is crafted, rolled up from buyable mats"
P1 #6eng — "P1 #6 / SC5: summary totalCraftCost/netSell/profit rolled up via unitCost"
P1 #7eng — "P1 #7 / SC5: root decision is always craft; rootBuyPrice surfaced even when cheaper"
P1 #8eng — "P1 #8 / SC6: a non-sellable root yields null netSell and profit, total still produced"
P2 #1ctl — "P2 #1 / SC8: GET returns 200, enriched tree + root summary, 007 fields intact"
P2 #2ctl — "P2 #2 / SC8: a non-integer param returns 400" / "… a non-positive param returns 400" / "… an unknown item (null root metadata) returns 404"
P2 #3ctl — "P2 #3 / SC8: one batched prices call over buyable ids ∪ root; resolve once"
P3 #1sch (legendary-recipes) — "P3 #1: the dataset curates exactly the four Bifrost forge intermediates (Might/Magic/Clover/Bifrost-gift)", "P3 #1: Gift of Might (19672) = 250× each of the four T6 fine materials …", "P3 #1: Gift of The Bifrost (19654) = 1 Gift of Energy + 1 Gift of Color + 100 Icy Runestone + 1 Sigil"
P3 #2svc — "P3 #2 / SC10: resolve(30698) expands the forge path to buyable raw materials"
P3 #3sch (legendary-recipes) — "P3 #3 / SC9: Mystic Clover (19675) is an expected-value forge recipe with a fractional outputCount"
P3 #4svc — "P3 #4: earned/vendor tokens remain leaves valued at 0"
SC1eng — "P1 #1 / SC1"
SC2eng — "P1 #2 / SC2"
SC3eng — "P1 #3 / SC3"
SC4eng — "P1 #4 / SC4" and "P1 #5 / SC4"
SC5eng — "P1 #6 / SC5", "P1 #7 / SC5", and "R3/SC5: a priced root whose craft path is poisoned yields null totalCraftCost and profit (no faked total)"
SC6eng — "P1 #8 / SC6"
SC7eng — "SC7: priceGraph is pure and deterministic over a frozen input"
SC8ctl — "P2 #1 / SC8", "P2 #2 / SC8" (×3), "P2 #3 / SC8"; sch — "SC8: the schema validates an enriched node with cost fields", "SC8: the root DTO accepts a summary", "SC8: a node missing unitCost is rejected (cost fields are required)"
SC9sch (legendary-recipes) — "SC9: a curated recipe with a fractional outputCount (expected-value yield) validates" (+ "… outputCount 0 is still rejected", "… ingredient count stays integer — 2.5 is rejected"); sch (schema) — "SC9: the response schema accepts a fractional recipe outputCount"; and P3 #3 above
SC10svc — "P3 #2 / SC10: the priced Bifrost has a non-zero, complete totalCraftCost"
SC11eng — "SC11: a PricedTree round-trips as plain JSON (no Map on the wire, summary + children intact)"
SC12oapi — "SC12: GET /recipe-graph/{itemId} documents the enriched priced shape (decision + summary)" + the deterministic no-drift guard (inv)
SC13inv — the existing docs/superpowers/ zero-write invariant (008 artifacts under specs/008-buy-vs-craft-pricing/)

Also shipped beyond the table: eng — "R3: neither option defined → unitCost null, decision unknown, propagates" (R3 unknown propagation), and eng — "collectPricedIds returns the distinct buyable ids plus the root id, and nothing else" (the SC8 id set, unit-level).