joulehook: the joule budget survives the process, so it can be exceeded #310
Merged
dcharlot
merged 2 commits from 2026-10-18 13:45:34 -04:00
fix/joulehook-budget-survives-the-process into main 2026-10-18
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
`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]>
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
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]>