jcp-gateway: replay our own receipt stream at startup (SPEC §8.1) #305
Merged
dcharlot
merged 1 commit from 2026-10-18 00:41:51 -04:00
fix/jcp-gateway-replays-its-own-receipts into main 2026-10-17
jcp-gateway: replay our own receipt stream at startup (SPEC §8.1)
All checks were successful
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 59s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 8s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m47s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m10s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m11s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m24s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 14m38s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 2m37s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 3m24s
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 2m10s
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 5m44s
SPEC §8.1 is REQUIRED: "An enforcement point that crashes MUST be able
to rebuild its budget state by replaying its own verified receipt
stream: allow-receipts re-apply their debits to every grant they name,
ancestors included, with no external index. Durability and audit are
the same artifact; there is no second database to drift from the
evidence."
`jcp-runtime` ships that primitive — `Runtime::restore`, documented
"SPEC §8.1" — and `jcp-receipt` ships `replay_spend`, documented "the
receipt stream is the write-ahead log". The gateway, this crate's own
"JCP enforcement point" and the one shipped long-running process, used
neither: `grep restore|replay_spend` over jcp-gateway found nothing.
`Gateway::new` built `Runtime::new()` with an empty spend map and
`ReceiptLog::new()` at `seq = 0, prev = 0…0`, and the public API offered
no way to seed either.
Two consequences, both of which the module doc denied:
- **Budgets refilled on every restart.** Every grant the gateway issues
is deterministic — `issuer = SigningKey::from_bytes(&[0x6a; 32])`,
`nonce: [0; 16]`, `expiry: 0` — so a restart mints byte-identical
grants with identical ids and the full `budget_uj` available again. A
caller could spend a ceiling, restart the gateway, and spend it again,
without bound, against the doc's "when the budget is exhausted the
next call is denied".
- **The accumulated stream stopped verifying.** The receipts file is
appended across runs while each run restarted the chain at
`seq = 0, prev = 0…0`, and `verify_log` requires `seq == i` and the
previous entry's content id from genesis. So it returned `Err(i)` at
the first receipt of the second run: the one artifact recording the
overspend was also the one that failed to verify, against the doc's
"tamper-evident, non-repudiable record".
Three pieces, using the primitives that already existed:
- `ReceiptLog::resume` adopts this steward's own prior stream as the
log's history, so the next `append` links onto the real head. It
verifies end to end *before* adopting anything, and refuses a log that
has already appended — `ResumeError::{BrokenAt, NotEmpty}` rather than
one opaque failure. Plus `steward()` / `steward_pubkey()` accessors.
- `Gateway::resume` verifies and adopts the chain, then replays the
allow-debits onto the runtime. Nothing reaches the budget ledger
unless the whole stream verifies.
- The binary reads the length-prefixed file at startup and calls it.
**Fail closed:** an unreadable or unverifiable history means the spend
is unknown, and serving as though it were zero is the overspend the
replay exists to prevent, so the process exits 2 with the reason. A
truncated tail is an error, not a shorter history — the receipt whose
bytes are missing carries a debit, and dropping it refills exactly
that much budget. An absent file is simply the first run.
All additive: `ReceiptLog::new`, `Gateway::new` and every existing call
site are unchanged.
The gateway test asserts on the identity the runtime guarantees —
`budget_remaining_uj = budget_uj - (prior_spend + this_call)` — so it
turns on the prior spend alone and never compares two measured energy
figures to each other. A first draft did compare them and failed on the
build host at load 91; costs here are real meter readings, `remaining +
cost` is not. Ran 5/5 at load 88-95 after the rewrite.
Co-Authored-By: Claude Opus 5 <[email protected]>