jcp-gateway: replay our own receipt stream at startup (SPEC §8.1) #305

Merged
dcharlot merged 1 commit from fix/jcp-gateway-replays-its-own-receipts into main 2026-10-18 00:41:51 -04:00
Owner

The defect

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, whose own doc cites "SPEC §8.1" — and jcp-receipt ships replay_spend, documented as "the receipt stream is the write-ahead log". The gateway, this crate's own documented "JCP enforcement point" and the one shipped long-running process, used neither: a grep for restore|replay_spend across jcp-gateway returned 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, each contradicting the module's own doc:

1. 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. The doc says "when the budget is exhausted the next call is denied".

2. The accumulated stream stopped verifying. The receipts file is appended across runs while each run restarted the chain at seq = 0, prev = 0…0. 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".

The fix — three pieces, using 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 the stream 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.

Tests

jcp-receipt:

  • a_resumed_log_continues_the_chain — resume adopts 2 entries, head() matches the first run's head, a third append lands, and verify_log over all three is Ok(3).
  • a_log_that_does_not_resume_breaks_the_accumulated_stream — the defect itself, pinned: verify_log is Err(2) at the first post-restart entry.
  • resume_refuses_a_broken_chain — Err(BrokenAt(1)), nothing adopted, head still genesis.
  • resume_refuses_a_log_that_already_appended — Err(NotEmpty(1)).

jcp-gateway:

  • a_restart_does_not_refill_a_spent_budget — spend, restart, replay; the debit is still charged against the identical grant id, the chain verifies across the restart, and the discriminating half: a gateway that does not replay starts the ceiling over.
  • resume_refuses_a_stream_that_does_not_verify.

A load-flaky assertion I had to replace

My first draft of the restart test compared two measured energy figures to each other (after_restart < after_one). It failed on the build host at load 91 — costs here are real meter readings. The test now asserts on the identity the runtime guarantees, budget_remaining_uj = budget_uj - (prior_spend + this_call), so it turns on the prior spend alone:

gateway assertion
fresh remaining + cost == BUDGET
resumed remaining + cost == BUDGET - c1
fresh again (no replay) remaining + cost == BUDGET

Exact, and independent of magnitudes. Ran 5/5 at load 88–95 after the rewrite.

Falsification

mutation result
G1 Gateway::resume adopts the chain but never replays the debits a_restart_does_not_refill_a_spent_budget FAILED
G2 ReceiptLog::resume adopts without verifying resume_refuses_a_stream_that_does_not_verify FAILED
G3 resume leaves the head at genesis a_resumed_log_continues_the_chain (head) and the gateway restart test (chain) FAILED
G4 drop the NotEmpty guard resume_refuses_a_log_that_already_appended FAILED

G1 and G3 are the two halves of §8.1 — the budget and the chain — and each is independently load-bearing.

Verification

jcp-receipt is a path dependency of six other workspaces, so after the last PR I checked them rather than assuming the change was additive:

workspace result
jcp/jcp-rs check --workspace --all-targets 0; test --workspace 0, 0 failed / 64 ok suites
jouledesk-rs, joule-code-rs, sandbox-rs, jouleclaw-rs, joule-ui-rs, joulehook-rs check --workspace --all-targets exit 0 each

Checks rather than full test runs for the six consumers: the change alters no existing API or behaviour — it only adds a type and three methods — so compile breakage was the only plausible failure mode, and the host is shared and heavily loaded.

🤖 Generated with Claude Code

## The defect 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`, whose own doc cites "SPEC §8.1" — and `jcp-receipt` ships `replay_spend`, documented as "**the receipt stream is the write-ahead log**". The gateway, this crate's own documented "JCP enforcement point" and the one shipped long-running process, used **neither**: a `grep` for `restore|replay_spend` across `jcp-gateway` returned 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, each contradicting the module's own doc: **1. 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. The doc says *"when the budget is exhausted the next call is denied"*. **2. The accumulated stream stopped verifying.** The receipts file is appended across runs while each run restarted the chain at `seq = 0, prev = 0…0`. `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"*. ## The fix — three pieces, using 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 the stream 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. ## Tests `jcp-receipt`: - `a_resumed_log_continues_the_chain` — resume adopts 2 entries, `head()` matches the first run's head, a third append lands, and `verify_log` over all three is `Ok(3)`. - `a_log_that_does_not_resume_breaks_the_accumulated_stream` — the defect itself, pinned: `verify_log` is `Err(2)` at the first post-restart entry. - `resume_refuses_a_broken_chain` — `Err(BrokenAt(1))`, nothing adopted, head still genesis. - `resume_refuses_a_log_that_already_appended` — `Err(NotEmpty(1))`. `jcp-gateway`: - `a_restart_does_not_refill_a_spent_budget` — spend, restart, replay; the debit is still charged against the identical grant id, the chain verifies across the restart, and the **discriminating half**: a gateway that does *not* replay starts the ceiling over. - `resume_refuses_a_stream_that_does_not_verify`. ### A load-flaky assertion I had to replace My first draft of the restart test compared two **measured energy** figures to each other (`after_restart < after_one`). It failed on the build host at load 91 — costs here are real meter readings. The test now asserts on the identity the runtime guarantees, `budget_remaining_uj = budget_uj - (prior_spend + this_call)`, so it turns on the **prior spend alone**: | gateway | assertion | |---|---| | fresh | `remaining + cost == BUDGET` | | resumed | `remaining + cost == BUDGET - c1` | | fresh again (no replay) | `remaining + cost == BUDGET` | Exact, and independent of magnitudes. Ran **5/5** at load 88–95 after the rewrite. ### Falsification | mutation | result | |---|---| | **G1** `Gateway::resume` adopts the chain but never replays the debits | `a_restart_does_not_refill_a_spent_budget` FAILED | | **G2** `ReceiptLog::resume` adopts without verifying | `resume_refuses_a_stream_that_does_not_verify` FAILED | | **G3** resume leaves the head at genesis | `a_resumed_log_continues_the_chain` (head) **and** the gateway restart test (chain) FAILED | | **G4** drop the `NotEmpty` guard | `resume_refuses_a_log_that_already_appended` FAILED | G1 and G3 are the two halves of §8.1 — the budget and the chain — and each is independently load-bearing. ## Verification `jcp-receipt` is a path dependency of **six other workspaces**, so after the last PR I checked them rather than assuming the change was additive: | workspace | result | |---|---| | `jcp/jcp-rs` | `check --workspace --all-targets` 0; `test --workspace` 0, **0** failed / 64 ok suites | | `jouledesk-rs`, `joule-code-rs`, `sandbox-rs`, `jouleclaw-rs`, `joule-ui-rs`, `joulehook-rs` | `check --workspace --all-targets` exit **0** each | Checks rather than full test runs for the six consumers: the change alters no existing API or behaviour — it only adds a type and three methods — so compile breakage was the only plausible failure mode, and the host is shared and heavily loaded. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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
ca73436701
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]>
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!305
No description provided.