{"record":{"id":"b15226b1c10db315","repo":"JuliusBrussee/caveman","slug":"cave-budget-denomination-ambiguous","errorCode":null,"errorMessage":"cave_budget_denomination_ambiguous","messagePattern":"cave_budget_denomination_ambiguous","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/agent/src/budget.ts","lineNumber":93,"sourceCode":"\nexport interface NormalizedBudget {\n  readonly denomination: BudgetDenomination;\n  readonly max: number;\n  readonly initial: number;\n  readonly outputFloorTokens: number;\n  readonly onExhausted: \"compact\" | \"stop\";\n  readonly compaction: NormalizedCompaction;\n}\n\n/**\n * Validate a caller-supplied budget. Fails closed: an ambiguous, unbounded, or\n * self-contradicting budget is rejected before the first provider call rather\n * than silently degrading into no cap at all.\n */\nexport function normalizeRunBudget(budget: RunBudget): NormalizedBudget {\n  const usd = budget.maxUsd !== undefined;\n  const tokens = budget.maxTokens !== undefined;\n  if (usd === tokens) throw new Error(\"cave_budget_denomination_ambiguous\");\n  const denomination: BudgetDenomination = usd ? \"usd\" : \"tokens\";\n  const max = usd ? budget.maxUsd! : budget.maxTokens!;\n  if (!Number.isFinite(max) || max <= 0) throw new Error(\"cave_budget_max_invalid\");\n  if (denomination === \"tokens\" && !Number.isSafeInteger(max)) {\n    throw new Error(\"cave_budget_max_invalid\");\n  }\n  const wrongInitial = denomination === \"usd\" ? budget.initialTokens : budget.initialUsd;\n  if (wrongInitial !== undefined) throw new Error(\"cave_budget_denomination_ambiguous\");\n  const declaredInitial = denomination === \"usd\" ? budget.initialUsd : budget.initialTokens;\n  const initial = declaredInitial ?? max;\n  if (!Number.isFinite(initial) || initial <= 0 || initial > max) {\n    throw new Error(\"cave_budget_initial_invalid\");\n  }\n  if (denomination === \"tokens\" && !Number.isSafeInteger(initial)) {\n    throw new Error(\"cave_budget_initial_invalid\");\n  }\n  const outputFloorTokens = budget.outputFloorTokens ?? OUTPUT_CLAMP_FLOOR_TOKENS;\n  if (!Number.isSafeInteger(outputFloorTokens) || outputFloorTokens <= 0) {","sourceCodeStart":75,"sourceCodeEnd":111,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/27d5a3981a347890211bb1bf2439e5c821a63bc9/packages/agent/src/budget.ts#L75-L111","documentation":"Thrown by normalizeRunBudget when the budget's denomination is ambiguous: exactly one of maxUsd and maxTokens must be set, and this error fires when both are set or neither is. The library fails closed at validation time — before any provider call — rather than silently picking a cap or running unbounded.","triggerScenarios":"Passing a RunBudget with both maxUsd and maxTokens defined; passing a RunBudget with neither (an unbounded run); passing an object where one field is explicitly undefined and the other absent, both of which count as unset.","commonSituations":"Spreading two config objects together (a USD default plus a token override) so both fields end up defined; refactoring a caller from maxUsd to maxTokens and leaving the old field populated; a config file where the budget section is left empty, producing neither field.","solutions":["Set exactly one of maxUsd or maxTokens on the budget object and delete the other.","If merging config layers, explicitly delete the unused denomination key after the merge instead of relying on overrides.","Add a build-time or test-time assertion that calls normalizeRunBudget on the shipped config so ambiguity surfaces in CI, not in production."],"exampleFix":"// before\nconst budget = { ...defaults, ...userOverrides }; // defaults has maxUsd, userOverrides has maxTokens -> both set\n\n// after\nconst budget = { ...defaults, ...userOverrides };\nif (budget.maxTokens !== undefined) delete budget.maxUsd;\nelse if (budget.maxUsd === undefined) throw new Error(\"budget must set maxUsd or maxTokens\");","handlingStrategy":"validation","validationCode":"function pickDenomination(b: RunBudget): { maxUsd: number } | { maxTokens: number } {\n  const usd = b.maxUsd !== undefined;\n  const tok = b.maxTokens !== undefined;\n  if (usd === tok) throw new Error(\"set exactly one of maxUsd or maxTokens\");\n  return usd ? { maxUsd: b.maxUsd! } : { maxTokens: b.maxTokens! };\n}","typeGuard":"function isUnambiguousBudget(b: RunBudget): boolean {\n  return (b.maxUsd !== undefined) !== (b.maxTokens !== undefined);\n}","tryCatchPattern":"try {\n  normalizeRunBudget(budget);\n} catch (e) {\n  if (e instanceof Error && e.message === \"cave_budget_denomination_ambiguous\") {\n    throw new Error(`Budget must set exactly one of maxUsd or maxTokens; got maxUsd=${budget.maxUsd} maxTokens=${budget.maxTokens}`);\n  }\n  throw e;\n}","preventionTips":["After merging config layers, delete the unused denomination key explicitly.","Model budgets in your code as a discriminated union ({kind:'usd',max:number} | {kind:'tokens',max:number}) and convert to the library's shape at the boundary.","Run normalizeRunBudget in a config unit test so ambiguity fails in CI."],"tags":["budget","validation","config","denomination"],"backgroundTag":null,"analyzedSha":"27d5a3981a347890211bb1bf2439e5c821a63bc9","analyzedAt":"2026-08-15T09:26:11.751Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}