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
Owner

§4.11 makes a restart give no budget back. This is the case one layer out: the
same call delivered twice.

The gap, precisely

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 this narrows an existing mechanism rather than inventing one. 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. Tests pin both, and that editing the id breaks the signature —
    otherwise a relay could retarget one call as another.
  • 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 index survives a restart, re-established by the replay with_journal
    already performs — or a restart would forget which calls it decided and debit
    their retries, which is §4.11's own failure one layer out.
  • 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. An 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 claims 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 by guessing it, 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, because two byte-identical calls may be two calls the caller meant
the guarantee is bounded, and says so past the horizon a repeat is not recognised; remembering every call forever would need state forever, and a bounded guarantee that names its bound is honest where an unbounded one would not be true

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 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
— it would be guessing about state it
cannot observe, and a guess is not evidence.

Incidental tidying

The signable-bytes variants collapse into one. signed_bytes(&self) builds the
canonical bytes from whichever optionals a request carries, verify_caller reads
it instead of branching on challenge, and sign_parts takes both optionals
with sign/sign_challenged delegating. Adding a third optional field would
otherwise have doubled the number of ways to ask the same question.

Conformance

Class C29. The pack reports ✓ C29 a retry is not a second call and all
checks passed.

Evidence

On the Framework box, deliberately not the CI host (mid-matrix for another PR):

  • the conformance pack: all checks passed, C29 among them;
  • 66 test suites green across the workspace, including seven new wire tests
    named for the properties above;
  • cargo clippy -p jcp-wire -p jcp-conformance --all-targets --no-deps -- -D warnings: clean;
  • check-comparative-prose.sh clean over 4,063 files; check-no-vanity-metrics.sh
    clean over 517 Markdown files.

The two unused_mut warnings clippy reports in jcp-energy are pre-existing, in
a crate this change does not touch, and left for their own change.

🤖 Generated with Claude Code

§4.11 makes a restart give no budget back. This is the case one layer out: **the same call delivered twice.** ### The gap, precisely 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 this **narrows an existing mechanism rather than inventing one**. 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. Tests pin both, and that editing the id breaks the signature — otherwise a relay could retarget one call as another. - **`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 index survives a restart**, re-established by the replay `with_journal` already performs — or a restart would forget which calls it decided and debit their retries, which is §4.11's own failure one layer out. - **`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. An 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 claims 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 by guessing it, 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, because two byte-identical calls may be two calls the caller meant | | **the guarantee is bounded, and says so** | past the horizon a repeat is not recognised; remembering every call forever would need state forever, and a bounded guarantee that names its bound is honest where an unbounded one would not be true | ### 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 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** — it would be guessing about state it cannot observe, and a guess is not evidence. ### Incidental tidying The signable-bytes variants collapse into one. `signed_bytes(&self)` builds the canonical bytes from whichever optionals a request carries, `verify_caller` reads it instead of branching on `challenge`, and `sign_parts` takes both optionals with `sign`/`sign_challenged` delegating. Adding a third optional field would otherwise have doubled the number of ways to ask the same question. ### Conformance Class **C29**. The pack reports `✓ C29 a retry is not a second call` and all checks passed. ### Evidence On the Framework box, deliberately not the CI host (mid-matrix for another PR): - the conformance pack: **all checks passed**, C29 among them; - **66 test suites green** across the workspace, including seven new wire tests named for the properties above; - `cargo clippy -p jcp-wire -p jcp-conformance --all-targets --no-deps -- -D warnings`: clean; - `check-comparative-prose.sh` clean over 4,063 files; `check-no-vanity-metrics.sh` clean over 517 Markdown files. The two `unused_mut` warnings clippy reports in `jcp-energy` are pre-existing, in a crate this change does not touch, and left for their own change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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
ec2292174b
§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]>
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!588
No description provided.