{"record":{"id":"aa95c42236ca0657","repo":"JuliusBrussee/caveman","slug":"cave-budget-release-reason-required","errorCode":null,"errorMessage":"cave_budget_release_reason_required","messagePattern":"cave_budget_release_reason_required","errorType":"exception","errorClass":"Error","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/27d5a3981a347890211bb1bf2439e5c821a63bc9/packages/agent/src/budget.ts#L313-L349","documentation":"Thrown by BudgetMeter.release when the reason argument is not a non-empty string (after trimming). Every tranche is audited — amount, reason, and call index are frozen into the tranche log — so an anonymous funding event cannot be recorded; the reason is mandatory, not decorative.","triggerScenarios":"Calling release(amount, \"\"); passing a whitespace-only string; omitting the argument entirely (undefined); passing a non-string such as a number or an object.","commonSituations":"Refactoring release call sites and dropping the second argument; generating reasons from variables that can be undefined at edge cases (e.g. `call-${call.name}` where name is missing); copy-pasted stub code with a placeholder empty string.","solutions":["Always pass a descriptive non-empty reason, e.g. release(1000, \"model call 3: gpt-class provider\").","If the reason is assembled from variables, default the pieces: `call-${call.label ?? \"unlabeled\"}`.","Type your wrapper so reason is a required string parameter, letting the compiler catch omissions."],"exampleFix":"// before\nmeter.release(1000, \"\");\n\n// after\nmeter.release(1000, `model call ${index}: ${toolName}`);","handlingStrategy":"validation","validationCode":"function reasonFor(call: { name?: string }, index: number): string {\n  return `call ${index}: ${call.name ?? \"unlabeled\"}`;\n}","typeGuard":"function isNonEmptyReason(v: unknown): v is string {\n  return typeof v === \"string\" && v.trim() !== \"\";\n}","tryCatchPattern":null,"preventionTips":["Make reason a required string parameter in your own wrapper signature.","Build reasons from variables with defaulted pieces so they can never be empty.","Write reasons that will be useful in an audit later: what the tranche funds and why."],"tags":["budget","tranche","audit","validation"],"backgroundTag":null,"analyzedSha":"27d5a3981a347890211bb1bf2439e5c821a63bc9","analyzedAt":"2026-08-15T09:26:11.751Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}