jcp §4.13: a retry is not a second call, where the caller names the call #588

Merged
dcharlot merged 1 commit from jcp/replay-class into main 2026-11-02 22:38:04 -05:00

2026-11-02

Transaction Science
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]>
2026-11-02 19:14:53 -05:00