jcp-wire: the endpoint's spend, revocations and memory of decided requests survive a restart #326
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/jcp-wire-durable-endpoint"
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 gap
The
jcp/1endpoint held everything it enforces in memory. A restart therefore:JCP §4.11 requires an enforcement point that can restart to record its decisions durably and re-establish them before deciding anything new.
The fix
Endpointkeeps aJournal. Each admitted request is recorded (Decided { at, request }) before it is decided. A request that cannot be recorded is deniedunrecorded(a newDenyReason), debiting nothing and remembered as nothing.Endpoint::revokeverifies a revocation, records it, then applies it. One that does not verify is neither recorded nor applied.Endpoint::with_journalreplays the records through the gate, each request at the time it was decided. That re-establishes the spend, the revocations, and the memory of requests still in the window. A request whose signature fails, or that was decided twice, makes the journalInconsistent.FileJournaluses the wire's own framing (length prefix, canonical CBOR) and syncs per record. A torn final record is dropped and cut from the file.Checked
On the build host:
🤖 Generated with Claude Code
The `jcp/1` endpoint decided every request at the `now` the request carried, and debited it every time it arrived. The request is signed by its caller, the grant's subject, so the caller chose both: - **Time.** A caller could stamp a request before its grant expired, or inside a `NotAfter` it had since left, and be decided as of that moment. - **Replay.** A captured request presented again was decided again and debited again. SPEC §11 described that as intended ("replays into the same deterministic decision and the same budget accounting"). The fix: - **Clock.** `Endpoint` decides at its own clock (`with_clock`; the system clock by default). A request's `now` is a freshness stamp: one more than the window (`with_skew`, default 300 s) from the clock is denied `stale-request` and sealed like any other denial. - **Replay.** Within the window the endpoint remembers each request it decided, by content id. One presented again is denied `replayed`, neither decided nor debited. Once a stamp leaves the window it is forgotten, and a replay of it is stale, so memory stays bounded. - **Deny reasons.** `DenyReason::StaleRequest` and `DenyReason::Replayed` are new, with their labels. - **Spec.** SPEC §11 says the endpoint's clock decides, a request is decided once, and the cost is the holder's measurement. The endpoint debits the declared cost as given, so the requester must be the party that metered the action, the grant's holder at the enforcement point (as a Mesh steward is), never the governed agent reporting its own consumption. - **Conformance.** C8 covers a replay, a stale request, and a grant that expires between a request's stamp and the endpoint's clock. jcp-wire's only users are the conformance suite and jcp-threshold's test. Both now pin the endpoint's clock to the stamps their requests carry. Checked on the build host: - jcp-rs: 517 tests pass, three of them new in jcp-wire. The conformance suite passes all checks, including C8 and C13. - Every vector is byte-identical, and clippy is clean. - Every workspace depending on jcp-schema builds: mesh, jouleclaw, joule-code, sandbox, joule-ui, joulehook, jouledesk. - Mutations each fail tests: deciding at the caller's `now` (also fails C8), deciding a replay again (also fails C8), and no freshness window (also fails C8). - jcp-gateway's process-tree energy tests flaked twice under host load during full runs; alone they pass every time, and this change does not touch that crate. Co-Authored-By: Claude Opus 5.5 <[email protected]>