jcp §4.13: a retry is not a second call, where the caller names the call #588
Merged
dcharlot
merged 1 commit from 2026-11-02 22:38:04 -05:00
jcp/replay-class into main 2026-11-02
jcp §4.13: a retry is not a second call, where the caller names the call
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 12s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 4m13s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 1m20s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 27s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m48s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m19s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, -p sandbox-tee --features amd-kds,intel-pcs, sandbox-rs, linux) (pull_request) Successful in 1m22s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 19m53s
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 4m56s
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 1m7s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 7m18s
§4.11 makes a restart give no budget back. This is the case one layer out: the same call delivered twice. An endpoint already recognised a repeat within its freshness window, by the request's content address, and answered it from the record without debiting again — so the mechanism was there, and this narrows it rather than inventing it. The gap is what that key is made of. The content address covers the caller's stamp, so it catches a redelivery that reproduced the request's bytes exactly, and misses the one that matters: a transport whose retry is slow enough that the first stamp has aged out, where the caller MUST re-stamp to be admitted at all. That retry has a different address, is admitted as a new call, and is debited again. The overspend §4.11 exists to prevent happens anyway, for a reason §4.11 does not reach. The cause is that identity was incidental — derived from the bytes — rather than declared. What changes - `WireRequest::call_id`: 32 bytes the caller chose, the same across every delivery of one call and different between calls. Signed with the rest of the request, `Option` with `serde(default, skip_serializing_if)`, so a request without one has the bytes it always had and still verifies against the old signable bytes. A test pins both. - Where a request carries one, `admit` recognises a repeat by `(caller, call_id)` before it looks at the bytes, and answers what the first delivery was answered without debiting the grant again. - The index is re-established by the replay `with_journal` already performs, so a restart does not forget which calls it decided and debit their retries. - `Endpoint::with_replay_horizon` sets how long a call id is remembered, defaulting to `DEFAULT_SKEW_SECS` so an endpoint that sets nothing behaves as it did. A `Called` entry is retained by **when it was decided**, not by the request's stamp: an entry pruned by the original stamp would already be gone when the re-stamped retry arrives, which is the whole case. Three things the spec says that the code holds **The id is scoped to its caller.** The key is the pair, never the id alone. A global id space would let one caller suppress another's call by guessing its id — a denial with no trace, since a suppressed call looks exactly like a recognised repeat. **An absent id is not a promise of uniqueness.** Without one, every delivery is a distinct call. A host must not infer an identity the caller did not declare: two byte-identical calls may be two calls the caller meant, and a test holds that two ids that differ both happen. **The guarantee is bounded, and says so.** Past the horizon a repeat is no longer recognised. Remembering every call forever would need state forever; a bounded guarantee that names its bound is honest where an unbounded one would not be true. The horizon MUST be at least the longest retry of the transport in front of it, or the mechanism does not cover the case it was added for. What this deliberately does not add Whether an action *may* be re-dispatched is a property of the action, not of the id, and §4.11 already governs the unresolved case well: no silent retry, no reporting an in-flight action as done or failed, surfaced naming the party that held it. A declared id is what makes a re-dispatch *decidable* for a dispatcher that does classify its actions — "safe to re-dispatch" means nothing until the re-dispatch can be recognised as the same call. So no second replay class is introduced here beside the one the dispatching layer already has. The spec states the authority instead: the classification is the implementing party's, the decision to act on it is the enforcement point's, and a model is never asked whether an action already ran, because it would be guessing about state it cannot observe and a guess is not evidence. Incidentally, the signable-bytes variants collapse into one. `signed_bytes(&self)` builds the canonical bytes from whichever optionals a request carries, and `verify_caller` reads it instead of branching on `challenge`; `sign_parts` takes both optionals and `sign`/`sign_challenged` delegate. Adding the third optional field would otherwise have doubled the number of ways to ask the same question. Conformance class C29. Evidence, on the Framework box (nothing on the CI host, which is mid-matrix for another PR): the pack reports `✓ C29 a retry is not a second call` and all checks passed; 66 test suites green across the workspace; clippy `-D warnings --no-deps` clean on both crates touched. The two `unused_mut` warnings clippy reports in `jcp-energy` are pre-existing and in a crate this change does not touch, so they are left for their own change. Co-Authored-By: Claude Opus 5 <[email protected]>