{"record":{"id":"2a9c834e64841bc9","repo":"JuliusBrussee/caveman","slug":"cave-budget-revoked","errorCode":"cave_budget_revoked","errorMessage":"cave_budget_revoked","messagePattern":"cave_budget_revoked","errorType":"error_code","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/agent/src/budget.ts","lineNumber":333,"sourceCode":"    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  /**\n   * Hold `amount` against the ledger. Returns `undefined` when it does not fit,\n   * which is the caller's signal to clamp, compact, or stop — never to proceed.","sourceCodeStart":315,"sourceCodeEnd":351,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/3ee70a102609e550bd2e68004bf5990a9341c851/packages/agent/src/budget.ts#L315-L351","documentation":"`BudgetMeter.release` throws `cave_budget_revoked` when the ledger's `revokedFlag` is set. A revoked budget is dead by contract: a parent run that is revoked (e.g. aborted or failed above) revokes every wallet under it, and releasing a new tranche into a dead ledger would book money that can never be spent. The check runs before any tranche is recorded, so a revoked meter rejects all further releases unconditionally.","triggerScenarios":"Calling `release()` on a `BudgetMeter` after `revoke()` was called; releasing on a subagent wallet whose parent run was revoked (a revoked parent revokes every wallet beneath it); resuming a checkpoint/controller after the run already terminated; racing an abort — the run is revoked between the controller's decision and the `release` call.","commonSituations":"A budget controller that keeps releasing tranches from a callback after the run was aborted or hit its deadline; a nested agent workflow where a failure at the root revokes wallets but a per-child checkpoint loop still tries to top them up; retry logic that holds a stale meter reference across run restarts.","solutions":["Check `meter.revoked` before releasing: `if (!meter.revoked) meter.release(amount, reason);`","If this is a subagent wallet, stop releasing once the parent run is revoked — the whole tree is dead.","Rework the controller so release attempts stop after run termination (abort/deadline/stop handlers) instead of continuing on a stale meter.","If revocation was unintentional, restart the run with a fresh budget rather than trying to un-revoke the meter."],"exampleFix":"// before\ncontroller.onCheckpoint(() => meter.release(next, \"top-up\"));\n\n// after\ncontroller.onCheckpoint(() => {\n  if (meter.revoked) return;\n  meter.release(next, \"top-up\");\n});","handlingStrategy":"validation","validationCode":"if (!meter.revoked) {\n  meter.release(amount, reason);\n}","typeGuard":"const releasableMeter = (m: BudgetMeter): boolean => !m.revoked && !m.capBreached;","tryCatchPattern":"try {\n  meter.release(amount, reason);\n} catch (e) {\n  if (e instanceof Error && e.message === \"cave_budget_revoked\") {\n    // run terminated; stop releasing and finalize\n    return null;\n  }\n  throw e;\n}","preventionTips":["Check meter.revoked before every release, especially inside controllers and callbacks.","Stop all budget-controller work once the run aborts, hits its deadline, or stops.","Remember a revoked parent revokes all subagent wallets — never top up child wallets after a root failure.","Avoid caching BudgetMeter references across run restarts; obtain a fresh meter per run."],"tags":["budget","state","lifecycle"],"backgroundTag":"invalid-state-transition","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"}