jcp-wire: a request presented again gets its first answer, so a lost response is recoverable #349

Merged
dcharlot merged 1 commit from fix/jcp-wire-retry-and-single-writer into main 2026-10-20 03:49:38 -04:00
Owner

What was wrong

The jcp/1 endpoint remembered each request it decided inside the freshness window, and it refused a repeat of that request as replayed. That stopped a captured request from being decided and debited twice. It also left a caller stuck if its response was lost:

  • The identical request was refused, so the caller could not learn the decision or get its receipt.
  • Its only way forward was to stamp the request again. That makes it a new request, so the same action was decided and debited a second time.

So the guard against double debits produced one.

What this changes

  • A repeat gets its first answer. The endpoint now remembers the answer as well as the request. A repeat inside the window gets that answer unchanged: the same decision and the same receipt bytes. Nothing is decided, debited, recorded or sealed again.
  • It holds across a restart. The journal replay re-decides each request at the time it was originally decided. The decision and the Ed25519 seal are both deterministic, so each response comes back byte for byte.
  • A captured request gains nothing. Whoever presents it receives only what its first exchange already carried.
  • Undecided requests are not remembered. A request refused before it was decided (stale, unauthenticated or unrecorded) is decided again when presented again.
  • Revocation binds new requests, not old answers. A repeat after a revocation gets the answer it got before. The next new request is refused revoked.
  • SPEC §11 ("A request is decided once") and the C8 conformance check now state this. DenyReason::Replayed stays, so receipts sealed with it still decode.

This complements #346, which keeps a second endpoint from writing the same journal; this PR covers a second send of the same request. It is rebased onto #346, and the 21 jcp-wire tests from both PRs pass together.

Verification (build host)

  • cargo test -p jcp-wire: 21 pass. Of these, 4 are new and 2 were rewritten from the old replayed expectation. The new ones include a lost response recovered over a live stream, and a byte-identical answer after a restart.
  • 7 mutations of the new logic, all killed:
    • a repeat decided again;
    • the answer not remembered;
    • a refused request remembered;
    • the restart forgetting answers;
    • the restart rebuilding a different answer;
    • the freshness window not applied to repeats;
    • a request journalled twice not caught.
  • The jcp-rs workspace and the jcp conformance suite (C8 under its new rule) pass, and clippy is clean on the touched crates.
  • Repo checks pass: tests-in-ci, spec-refs, orphan-sources, vanity-metrics, comparative-prose.

🤖 Generated with Claude Code

## What was wrong The jcp/1 endpoint remembered each request it decided inside the freshness window, and it refused a repeat of that request as `replayed`. That stopped a captured request from being decided and debited twice. It also left a caller stuck if its response was lost: - The identical request was refused, so the caller could not learn the decision or get its receipt. - Its only way forward was to stamp the request again. That makes it a new request, so the same action was decided and debited a second time. So the guard against double debits produced one. ## What this changes - **A repeat gets its first answer.** The endpoint now remembers the answer as well as the request. A repeat inside the window gets that answer unchanged: the same decision and the same receipt bytes. Nothing is decided, debited, recorded or sealed again. - **It holds across a restart.** The journal replay re-decides each request at the time it was originally decided. The decision and the Ed25519 seal are both deterministic, so each response comes back byte for byte. - **A captured request gains nothing.** Whoever presents it receives only what its first exchange already carried. - **Undecided requests are not remembered.** A request refused before it was decided (stale, unauthenticated or `unrecorded`) is decided again when presented again. - **Revocation binds new requests, not old answers.** A repeat after a revocation gets the answer it got before. The next new request is refused `revoked`. - **SPEC §11 ("A request is decided once") and the C8 conformance check** now state this. `DenyReason::Replayed` stays, so receipts sealed with it still decode. This complements #346, which keeps a second endpoint from writing the same journal; this PR covers a second send of the same request. It is rebased onto #346, and the 21 jcp-wire tests from both PRs pass together. ## Verification (build host) - `cargo test -p jcp-wire`: 21 pass. Of these, 4 are new and 2 were rewritten from the old `replayed` expectation. The new ones include a lost response recovered over a live stream, and a byte-identical answer after a restart. - 7 mutations of the new logic, all killed: - a repeat decided again; - the answer not remembered; - a refused request remembered; - the restart forgetting answers; - the restart rebuilding a different answer; - the freshness window not applied to repeats; - a request journalled twice not caught. - The jcp-rs workspace and the jcp conformance suite (C8 under its new rule) pass, and clippy is clean on the touched crates. - Repo checks pass: tests-in-ci, spec-refs, orphan-sources, vanity-metrics, comparative-prose. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jcp-wire: a request presented again gets its first answer, so a lost response is recoverable
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 16s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m10s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m22s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m43s
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, sandbox-rs, linux) (pull_request) Successful in 2m22s
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 3m0s
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 5m35s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 5m32s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 9m7s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 4m18s
323e76af64
The endpoint remembered each request it decided inside the freshness
window and refused a repeat as `replayed`. That stopped a captured request
being decided and debited twice. It also stranded a caller whose response
was lost: the identical request was refused, so the caller could not learn
the decision or get its receipt. Its only recourse was to stamp the request
again, which is a new request, so the same action was decided and debited a
second time. The guard against double debits caused one.

Now the endpoint remembers the answer as well as the request. A repeat
inside the window gets that answer unchanged: the same decision and the same
receipt bytes. It is not decided, debited, recorded or sealed a second time.
This holds across a restart. The journal replay re-decides each request at
the time it was decided, and both the decision and the Ed25519 seal are
deterministic, so the replay rebuilds each response byte for byte.

A captured request is worth nothing beyond what its first exchange carried.
A request refused before it was decided (stale, unauthenticated, or
`unrecorded`) is not remembered, and is decided when presented again. A
repeat after a revocation still gets the answer it got before the
revocation; the next new request is refused `revoked`.

SPEC §11 ("A request is decided once") and the C8 conformance check now say
this. `DenyReason::Replayed` stays, so that receipts sealed with it still
decode.

Tests: 4 new and 2 rewritten. All 7 mutations of the new logic are killed.

Co-Authored-By: Claude Opus 5.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!349
No description provided.