jcp-gateway: replay our own receipt stream at startup (SPEC §8.1) #305
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/jcp-gateway-replays-its-own-receipts"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The defect
SPEC §8.1 is REQUIRED:
jcp-runtimeships that primitive —Runtime::restore, whose own doc cites "SPEC §8.1" — andjcp-receiptshipsreplay_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: agrepforrestore|replay_spendacrossjcp-gatewayreturned nothing.Gateway::newbuiltRuntime::new()with an empty spend map andReceiptLog::new()atseq = 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 fullbudget_ujavailable 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_logrequiresseq == iand the previous entry's content id from genesis, so it returnedErr(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::resumeadopts this steward's own prior stream as the log's history, so the nextappendlinks 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. Plussteward()/steward_pubkey()accessors.Gateway::resumeverifies and adopts the chain, then replays the allow-debits onto the runtime. Nothing reaches the budget ledger unless the whole stream verifies.All additive:
ReceiptLog::new,Gateway::newand 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, andverify_logover all three isOk(3).a_log_that_does_not_resume_breaks_the_accumulated_stream— the defect itself, pinned:verify_logisErr(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:remaining + cost == BUDGETremaining + cost == BUDGET - c1remaining + cost == BUDGETExact, and independent of magnitudes. Ran 5/5 at load 88–95 after the rewrite.
Falsification
Gateway::resumeadopts the chain but never replays the debitsa_restart_does_not_refill_a_spent_budgetFAILEDReceiptLog::resumeadopts without verifyingresume_refuses_a_stream_that_does_not_verifyFAILEDa_resumed_log_continues_the_chain(head) and the gateway restart test (chain) FAILEDNotEmptyguardresume_refuses_a_log_that_already_appendedFAILEDG1 and G3 are the two halves of §8.1 — the budget and the chain — and each is independently load-bearing.
Verification
jcp-receiptis a path dependency of six other workspaces, so after the last PR I checked them rather than assuming the change was additive:jcp/jcp-rscheck --workspace --all-targets0;test --workspace0, 0 failed / 64 ok suitesjouledesk-rs,joule-code-rs,sandbox-rs,jouleclaw-rs,joule-ui-rs,joulehook-rscheck --workspace --all-targetsexit 0 eachChecks 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
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]>