{"record":{"id":"aa95c42236ca0657","repo":"JuliusBrussee/caveman","slug":"cave-budget-release-reason-required","errorCode":"cave_budget_release_reason_required","errorMessage":"cave_budget_release_reason_required","messagePattern":"cave_budget_release_reason_required","errorType":"error_code","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/agent/src/budget.ts","lineNumber":331,"sourceCode":"  /** Budget that could still be released without breaching `max`. */\n  releasable(): number {\n    return Math.max(0, this.max - this.releasedAmount);\n  }\n\n  /**\n   * Release a further tranche. Throws when it would breach `max`: this is\n   * pre-flight validation at a developer-controlled checkpoint, so the caller\n   * asked for something the contract cannot grant.\n   */\n  release(amount: number, reason: string): BudgetTranche {\n    if (!Number.isFinite(amount) || amount <= 0) {\n      throw new Error(\"cave_budget_release_invalid\");\n    }\n    if (this.denomination === \"tokens\" && !Number.isSafeInteger(amount)) {\n      throw new Error(\"cave_budget_release_invalid\");\n    }\n    if (typeof reason !== \"string\" || reason.trim() === \"\") {\n      throw new Error(\"cave_budget_release_reason_required\");\n    }\n    if (this.revokedFlag) throw new Error(\"cave_budget_revoked\");\n    // A breached ledger is dead. Releasing into it would record a tranche and\n    // raise an escalation for money that can never be spent, and would read on\n    // the receipt as a run that was still being funded after it went past cap.\n    if (this.breachedFlag) throw new Error(\"cave_budget_cap_breached\");\n    if (amount > this.releasable()) throw new Error(\"cave_budget_release_exceeds_max\");\n    this.releasedAmount += amount;\n    const tranche: BudgetTranche = Object.freeze({\n      amount,\n      reason,\n      atCall: this.callIndex,\n    });\n    this.trancheLog.push(tranche);\n    return tranche;\n  }\n\n  /**","sourceCodeStart":313,"sourceCodeEnd":349,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/3ee70a102609e550bd2e68004bf5990a9341c851/packages/agent/src/budget.ts#L313-L349","documentation":"`BudgetMeter.release(amount, reason)` requires a non-empty reason for every released tranche. The library throws `cave_budget_release_reason_required` when `reason` is not a string or is empty/whitespace-only. Reasons are persisted on the frozen tranche and appear in the run receipt's tranche history, so a tranche without a reason would be unauditable — the ledger refuses to record one. This is pre-flight validation at a developer-controlled checkpoint, so the caller asked for something the contract cannot grant.","triggerScenarios":"Calling `release(amount)` with the second argument omitted; passing `undefined`, `null`, `\"\"`, or a whitespace-only string like `\"   \"`; building the reason dynamically (e.g. from a template or config lookup) and having it resolve to an empty string; calling `release` from a `createBudgetController` checkpoint or an `onBudgetExhausted` handler where the reason field was never populated.","commonSituations":"Wiring up a staged budget (`budget.initialUsd`/`initialTokens`) and forgetting the reason argument in the checkpoint callback; refactoring code so a reason constant was removed or renamed; deserializing controller config from JSON where the reason field is missing; copy-pasting an example that only showed the amount argument.","solutions":["Pass a non-empty descriptive string as the second argument, e.g. `meter.release(1.0, \"checkpoint: analysis phase complete\")`.","If the reason is computed, guard it: `const r = cfg.reason?.trim(); if (r) meter.release(amount, r);`","For whitespace-only strings, trim or substitute a default: `meter.release(amount, reason.trim() || \"unspecified-checkpoint\")`.","Inspect the budget controller / escalation handler call site to confirm the reason field is wired through and not lost in a config object."],"exampleFix":"// before\nmeter.release(0.5);\n\n// after\nmeter.release(0.5, \"checkpoint: stage-1 retrieval finished\");","handlingStrategy":"validation","validationCode":"function canReleaseReason(reason: unknown): reason is string {\n  return typeof reason === \"string\" && reason.trim() !== \"\";\n}\n// before calling: if (canReleaseReason(reason)) meter.release(amount, reason);","typeGuard":"const isNonEmptyString = (v: unknown): v is string =>\n  typeof v === \"string\" && v.trim().length > 0;","tryCatchPattern":"try {\n  meter.release(amount, reason);\n} catch (e) {\n  if (e instanceof Error && e.message === \"cave_budget_release_reason_required\") {\n    meter.release(amount, \"unspecified-checkpoint\");\n  } else throw e;\n}","preventionTips":["Always pass a descriptive reason string as the second argument to release().","Wrap dynamic reasons in a helper that trims and substitutes a default when empty.","Type the controller/checkpoint config so the reason field is required, not optional.","Add a unit test asserting every release call site in your code supplies a non-empty reason."],"tags":["budget","validation","arguments"],"backgroundTag":"empty-required-field","analyzedSha":"3ee70a102609e550bd2e68004bf5990a9341c851","analyzedAt":"2026-09-20T15:53:39.229Z","contentChangedAt":"2026-09-20T15:53:39.229Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}