# p0151d: pipeline cost cap on PerSkillBreakdown

- [Architecture] `PipelineCostCapConfig` + `CostCapValues` as Contracts/Models/Configuration types. The config has a `Default` plus a per-pipeline override dictionary; resolution is `ResolveFor(pipelineName)`. Case-insensitive lookup so operators do not have to mirror exact catalog casing. Defaults baked in (`Usd = 5.0m`, `Tokens = 500_000`) so the cap kicks in for every pipeline even when the config file omits the section.
- [Architecture] Cap check hangs on `PipelineCostTracker.IsBudgetExhausted` — a property computed from the tracker's existing `_totalInputTokens / _totalOutputTokens / _totalCacheCreateTokens / _totalCacheReadTokens / EstimateCostUsdLocked()`. No parallel signal, no new service. p0132a/b made `PerSkillBreakdown` the single source of cost truth; this phase reads from the same place. Same lesson as p0148's tool-set plumbing where a parallel mechanism would have drifted from the canonical accounting.
- [Architecture] `SkillCallRuntime.ExecuteAsync` checks `IsBudgetExhausted` on entry. When set, it short-circuits before the LLM call, builds a `SkillCallResult` with `Outcome = Incomplete`, `FailureReason = "cost cap exhausted"`, and a single runtime observation carrying `Category = "cost-cap-exhausted"` + the actual USD/token totals. The downstream handlers already process `Incomplete` outcomes (existing path for token / wall-clock caps from p0147b); Compile + Deliver run normally because they do not call `SkillCallRuntime`.
- [Architecture] Cap values flow through the pipeline context: `ExecutePipelineUseCase` resolves the cap for the active pipeline name and stores it under the `"PipelineCostCap"` context key alongside the existing `"ProjectPricing"`. `PipelineCostTracker.GetOrCreate` reads both keys when constructing the tracker. The "one source of truth at creation time" pattern keeps the tracker immutable with respect to its cap; no setter, no mutation race.
- [Implementation] Token total in `IsBudgetExhausted` sums every counter (`input + output + cacheCreate + cacheRead`). Cache reads count toward the cap because they are still tokens the provider processes — even though they cost less, an unbounded loop on cache hits would still drain real budget over time. The USD calculation already handles the pricing nuance; the token cap is a hard ceiling on raw volume.
- [Implementation] No `IPipelineBudgetExhaustedSignal` interface (the previous draft had one). The boolean property on the tracker is enough — there is exactly one consumer (`SkillCallRuntime`) and one writer (the `Track` callback flow). Adding an interface would have been ceremony for no observable seam.
- [Implementation] The cost-cap observation builds via `RuntimeObservationFactory.BuildCostCapExhausted`. Stays in the same factory as the other execution-limit observations from p0147b — same `Severity.Info`, same `Concern.Risk`, same overall shape. The new `ExecutionLimitCategories.CostCapExhausted` constant joins the closed set; `IsExecutionLimit` is updated so output strategies render it with the same prefix as other limit hits.
- [Implementation] Existing `SkillCostScopeManager` flow is unchanged. The short-circuit path still calls `costTracker.BeginCall(...)` and `scope.Finalize(enforcer)` so the per-skill record exists in `PerSkillBreakdown` (with zero usage, marking the skipped call). Operators reading the breakdown see "skill X was attempted but produced zero tokens" rather than "skill X was never tried".
- [TradeOff] The cap is checked at the start of `ExecuteAsync`, not during the call. A single mid-flight skill can exceed the cap by its own tokens (whatever the per-skill limit allows). For practical caps the overshoot is bounded by `max_input_tokens_per_skill_call + max_output_tokens_per_skill_call` — manageable. Per-call interception would require hooking into the chat client's streaming or aborting mid-response, both of which add complexity and provider-specific edges. The "small overshoot acceptable" trade is documented in the YAML decision and the docs file.
- [TradeOff] When no cap is configured (operator removed the section entirely), `IsBudgetExhausted` is always false. The defaults are populated in `PipelineCostCapConfig.Default` so this scenario only arises when an operator hand-builds the config without using the binding path. Test coverage includes the explicit no-cap-tracker case.
- [TradeOff] PerPipeline lookup is case-insensitive (`StringComparer.OrdinalIgnoreCase` on the dictionary). Tolerant of operator typo casing; explicit collisions ("Api-Security-Scan" vs "api-security-scan") would fold to the same entry — acceptable because pipeline names are themselves canonicalised to lowercase by `PipelinePresets`.
