joulehook: the joule budget survives the process, so it can be exceeded #310

Merged
dcharlot merged 2 commits from fix/joulehook-budget-survives-the-process into main 2026-10-18 13:45:34 -04:00
Owner

The defect

SPEC §3.3 is normative:

"3. If the action carries an estimable cost, the grant's joule budget MUST be ≥ the estimate; over budget is deny. On allow, the spent amount is debited."

The debit lived in the LocalToolGate that gate() constructs, and gate() runs once per process — this binary is a PreToolUse command the host spawns per action. So the spend total died with the process on every single call, and the next action started again at the full JOULEHOOK_BUDGET_UJ.

There was no sequence of actions that could exhaust the budget, so the deny branch could never be taken. conformance/v1/receipt.json records the symptom: budget_remaining_uj: 1000000000, the untouched ceiling.

And the deny branch that was reachable reported the wrong thing — one message, "is not in the granted capability set", covered every denial. So a budget denial would have been recorded as a capability denial, if one could ever have happened.

The hook already wrote the evidence and never read it: sealed receipts are appended to JOULEHOOK_RECEIPT_LOG, hex per line.

The fix

  • LocalToolGate::resume replays a sealed stream into the gate's spend ledger via jcp_receipt::replay_spend, so only verified allow-receipts sealed by this gate's own steward contribute debits. Bridge gains steward(), steward_pubkey() and restore() to make that possible.
  • gate() replays before deciding, and the allow reason now records the spend and how many receipts it replayed.
  • A budget denial says it is a budget denial.
  • No stream configured ⇒ the ceiling covers one call only, and that is now disclosed in the allow reason rather than asserted. §3.3 states the budget MUST hold, and per-call it cannot. The crate's env doc previously carried this disclosure ("Cross-call budget accounting needs a persistent gate") while the SPEC's MUST did not; the doc now points at the stream that provides it.
  • A stream that does not decode denies. The prior spend is then unknown, the budget cannot be asserted, and allowing anyway is the refill the replay exists to prevent — fail closed, as SPEC §8 has this binary do for an unparseable payload and for an unrecognised shape (#290). A missing file is simply the first call.

The grants are deterministic — fixed issuer key, nonce: [0; 16], expiry: 0 — which is exactly why a later process rebuilds byte-identical grant ids for the debits to land on, and exactly why the budget refilled without them.

Tests

the_budget_is_a_session_ceiling_when_the_stream_is_kept runs three separate run() calls — each a fresh gate, which is what a per-action process is — persisting between them as main does:

call spend decision
1 2 mJ of 5 mJ allow
2 4 mJ of 5 mJ allow
3 6 mJ would exceed deny, naming the budget

Previously the third call allowed, and so would the three-hundredth.

Plus without_a_stream_the_budget_is_not_enforced_across_calls (four calls, all allow, each disclosing it) and an_unreadable_receipt_stream_denies.

Falsification

mutation result
K1 gate() replays into a throwaway gate, not the deciding one (the original defect) the ceiling test FAILED
K2 resume applies no debits the ceiling test FAILED
K3 an unreadable stream is treated as empty only an_unreadable_receipt_stream_denies FAILED
K4 a budget denial is reported as a capability denial again the ceiling test FAILED

K1, K2 and K4 each hit a different assertion of the ceiling test — the replay reaching the deciding gate, the debits actually landing, and the denial naming its cause. All ten pre-existing tests, including #290's fail-closed pair, passed throughout.

Verification

jcp-bridge is a path dependency of jouledesk-rs and joulehook-rs as well as jcp-rs, so all three were built:

workspace check --workspace --all-targets test --workspace
jcp/jcp-rs 0 0 — 0 failed, 64 ok suites
joulehook/joulehook-rs 0 0 — 0 failed, 8 ok suites
jouledesk/jouledesk-rs 0 0 — 0 failed, 9 ok suites

No dependency changed, so no Cargo.lock in any workspace is affected.

🤖 Generated with Claude Code

## The defect SPEC §3.3 is normative: > "3. If the action carries an estimable cost, the grant's joule budget **MUST** be ≥ the estimate; over budget is `deny`. On `allow`, the spent amount is debited." The debit lived in the `LocalToolGate` that `gate()` constructs, and `gate()` runs once per process — this binary is a PreToolUse command the host spawns **per action**. So the spend total died with the process on every single call, and the next action started again at the full `JOULEHOOK_BUDGET_UJ`. **There was no sequence of actions that could exhaust the budget**, so the deny branch could never be taken. `conformance/v1/receipt.json` records the symptom: `budget_remaining_uj: 1000000000`, the untouched ceiling. And the deny branch that *was* reachable reported the wrong thing — one message, *"is not in the granted capability set"*, covered every denial. So a budget denial would have been recorded as a capability denial, if one could ever have happened. The hook already wrote the evidence and never read it: sealed receipts are appended to `JOULEHOOK_RECEIPT_LOG`, hex per line. ## The fix - **`LocalToolGate::resume`** replays a sealed stream into the gate's spend ledger via `jcp_receipt::replay_spend`, so only verified allow-receipts sealed by *this gate's own steward* contribute debits. `Bridge` gains `steward()`, `steward_pubkey()` and `restore()` to make that possible. - **`gate()` replays before deciding**, and the allow reason now records the spend and how many receipts it replayed. - **A budget denial says it is a budget denial.** - **No stream configured** ⇒ the ceiling covers one call only, and that is now *disclosed in the allow reason* rather than asserted. §3.3 states the budget MUST hold, and per-call it cannot. The crate's env doc previously carried this disclosure ("Cross-call budget accounting needs a persistent gate") while the SPEC's MUST did not; the doc now points at the stream that provides it. - **A stream that does not decode denies.** The prior spend is then unknown, the budget cannot be asserted, and allowing anyway is the refill the replay exists to prevent — fail closed, as SPEC §8 has this binary do for an unparseable payload and for an unrecognised shape (#290). A missing file is simply the first call. The grants are deterministic — fixed issuer key, `nonce: [0; 16]`, `expiry: 0` — which is exactly why a later process rebuilds byte-identical grant ids for the debits to land on, and exactly why the budget refilled without them. ## Tests `the_budget_is_a_session_ceiling_when_the_stream_is_kept` runs three separate `run()` calls — each a fresh gate, which is what a per-action process *is* — persisting between them as `main` does: | call | spend | decision | |---|---|---| | 1 | 2 mJ of 5 mJ | allow | | 2 | 4 mJ of 5 mJ | allow | | 3 | 6 mJ would exceed | **deny**, naming the budget | Previously the third call allowed, and so would the three-hundredth. Plus `without_a_stream_the_budget_is_not_enforced_across_calls` (four calls, all allow, each disclosing it) and `an_unreadable_receipt_stream_denies`. ### Falsification | mutation | result | |---|---| | **K1** `gate()` replays into a throwaway gate, not the deciding one (the original defect) | the ceiling test FAILED | | **K2** `resume` applies no debits | the ceiling test FAILED | | **K3** an unreadable stream is treated as empty | **only** `an_unreadable_receipt_stream_denies` FAILED | | **K4** a budget denial is reported as a capability denial again | the ceiling test FAILED | K1, K2 and K4 each hit a *different* assertion of the ceiling test — the replay reaching the deciding gate, the debits actually landing, and the denial naming its cause. All ten pre-existing tests, including #290's fail-closed pair, passed throughout. ## Verification `jcp-bridge` is a path dependency of `jouledesk-rs` and `joulehook-rs` as well as `jcp-rs`, so all three were built: | workspace | `check --workspace --all-targets` | `test --workspace` | |---|---|---| | `jcp/jcp-rs` | 0 | 0 — 0 failed, 64 ok suites | | `joulehook/joulehook-rs` | 0 | 0 — 0 failed, 8 ok suites | | `jouledesk/jouledesk-rs` | 0 | 0 — 0 failed, 9 ok suites | No dependency changed, so no `Cargo.lock` in any workspace is affected. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
joulehook: the joule budget survives the process, so it can be exceeded
Some checks failed
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 13s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Failing after 2m24s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 1m46s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 6m7s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 59s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 2m38s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 1m21s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m39s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m12s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m14s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m26s
449172c36e
SPEC §3.3 is normative: "If the action carries an estimable cost, the
grant's joule budget MUST be ≥ the estimate; over budget is `deny`. On
`allow`, the spent amount is debited."

The debit lived in the `LocalToolGate` that `gate()` constructs, and
`gate()` runs once per process — this binary is a PreToolUse command the
host spawns per action. So the spend total died with the process on every
single call and the next action started again at the full
`JOULEHOOK_BUDGET_UJ`. **There was no sequence of actions that could
exhaust the budget**, so the deny branch could never be taken, and
`conformance/v1/receipt.json` records the symptom: `budget_remaining_uj:
1000000000`, the untouched ceiling.

Worse, the deny branch that *was* reachable reported the wrong thing: a
single message, "is not in the granted capability set", covered every
denial — so a budget denial would have been recorded as a capability
denial if one could ever have happened.

The hook already wrote the evidence and never read it: sealed receipts
are appended to `JOULEHOOK_RECEIPT_LOG`, hex per line.

- `LocalToolGate::resume` replays a sealed stream into the gate's spend
  ledger via `jcp_receipt::replay_spend`, so only verified allow-receipts
  of this gate's own steward contribute debits. `Bridge` gains
  `steward()`, `steward_pubkey()` and `restore()` to make that possible.
  The grants are deterministic (fixed issuer key, `nonce: [0; 16]`,
  `expiry: 0`), which is exactly why a later process rebuilds
  byte-identical grant ids for the debits to land on — and exactly why
  the budget refilled without them.
- `gate()` replays the stream before deciding, and the allow reason now
  records the spend and how many receipts it replayed.
- A budget denial says it is a budget denial.
- **No stream configured** ⇒ the ceiling covers one call only. That is
  now disclosed in the allow reason rather than asserted: §3.3 states
  the budget MUST hold, and per-call it cannot. The crate's env doc
  previously carried the disclosure ("Cross-call budget accounting needs
  a persistent gate") while the SPEC's MUST did not; the doc is updated
  to point at the stream that now provides it.
- **A stream that does not decode denies.** The prior spend is then
  unknown, the budget cannot be asserted, and allowing anyway is the
  refill the replay exists to prevent — fail closed, as SPEC §8 has this
  binary do for an unparseable payload and an unrecognised shape.
  A missing file is simply the first call.

The headline test runs three separate `run()` calls — each a fresh gate,
which is what a per-action process is — persisting between them as
`main` does: 2 mJ, 2 mJ, 2 mJ against a 5 mJ ceiling gives allow, allow,
**deny**, with the denial naming the budget. Previously that third call
allowed, and so would the three-hundredth.

Co-Authored-By: Claude Opus 5 <[email protected]>
jcp-bridge: cite §8.1, the rule this standard actually states
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 15s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m44s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m37s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m12s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m12s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m49s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 9m48s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 3m44s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 3m1s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 2m20s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 4m55s
a63d788046
`check-spec-refs` caught a dangling citation in the previous commit:
`LocalToolGate::resume` cited "SPEC §3.3's 'over budget is deny'", but
§3.3 is JouleHook's section. A citation in a jcp crate resolves against
`jcp/SPEC.md`, which defines no §3.3 — that binding rule is exactly what
the check exists to enforce, and it fired on its author.

The rule this method implements is jcp's own: §8.1, "The receipt stream
is the write-ahead log (REQUIRED)" — an enforcement point that crashes
rebuilds its budget state by replaying its own verified receipt stream.
That is what `resume` does, and it is the same section `Bridge::restore`,
`Runtime::restore` and `Gateway::resume` already cite.

The JouleHook consequence is still described, without a bare §-citation
that would bind to the wrong standard; the rule is cited where it lives,
in `joulehook-hook`.

Co-Authored-By: Claude Opus 5 <[email protected]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!310
No description provided.