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
3 changed files with 433 additions and 19 deletions

View file

@ -628,6 +628,65 @@ bounded at each point separately, so `n` points admit up to `n` times the limit.
A deployment that needs one bound across points MUST route the grant's calls
through one of them, or share its history between them.
### 4.13 A retry is not a second call (RECOMMENDED where a transport can redeliver)
§4.11 makes a restart give no budget back. It leaves open the case one layer
out: the same call delivered twice.
An endpoint already recognises a repeat it has seen within its freshness window,
by the request's content address, and answers it from the record without
debiting again. That catches a redelivery which reproduced the request's bytes
exactly — and the bytes include the caller's stamp. So it catches the fast,
byte-identical redelivery, 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 content 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 is **incidental** — derived from the bytes — rather
than declared. A caller therefore **SHOULD** name its call: 32 bytes it chose,
the same across every delivery of that call and different between calls, signed
with the rest of the request. Where a request carries one, an endpoint **MUST**
recognise a repeat by it rather than by the request's bytes, and **MUST** answer
a repeat what it answered the first delivery without debiting the grant again.
Two consequences follow from the id being the caller's.
- **It is scoped to the 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.** Where a request carries none,
every delivery is a distinct call and the content-address behaviour above is
all an endpoint can offer. A host **MUST NOT** infer an identity the caller
did not declare: two calls that happen to be byte-identical may be two calls
the caller meant.
**The guarantee is bounded, and says so.** An endpoint remembers a call id for a
declared **horizon**, and a redelivery arriving after it is not recognised. The
horizon **MUST** be at least as long as the longest retry of the transport in
front of it, or the mechanism does not cover the case it was added for.
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.
**What this does not decide.** Whether an action *may* be re-dispatched at all
is a property of the action, not of the call id, and §4.11 already governs the
unresolved case: an endpoint does not silently retry, does not report an
in-flight action as completed or failed, and surfaces it naming the party that
held it. A declared id is what makes a re-dispatch *decidable* where a
dispatcher does classify its actions — "safe to re-dispatch" means nothing until
the re-dispatch can be recognised as the same call. Any such classification is
the implementing party's declaration, and the decision to act on it is the
enforcement point's. **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.
And a recognised repeat is not free of cost, only free of the call's cost. The
endpoint did work to recognise it. What it must not do is debit the call twice.
Conformance class **C29** exercises the re-stamped retry with and without a
declared id, two deliberately-distinct calls, the caller scoping, survival
across a restart, and the horizon's bound.
## 5. Energy (REQUIRED)
Every metered cost is integer microjoules with a provenance tag: `HwShunt`

View file

@ -83,11 +83,100 @@ pub fn checks() -> Vec<Check> {
Check { id: "C25 flow: label under ceiling, floor, fail-closed, no debit", run: c25_flow },
Check { id: "C27 windows: calls, payloads and joules over a sliding window", run: c27_windows },
Check { id: "C28 endpoint evidence: a signed challenge, bound to the answer", run: c28_endpoint_evidence },
Check { id: "C29 a retry is not a second call", run: c29_call_identity },
Check { id: "C-neg expired / forged / wrong-caller", run: cneg_denials },
Check { id: "C-neg canonical rejects floats", run: cneg_canonical },
]
}
/// C29 — a retry is not a second call (SPEC §4.13).
fn c29_call_identity() -> Result<(), String> {
use jcp_wire::{Endpoint, JournalRecord, MemoryJournal, WireRequest};
fn clock() -> u64 {
10
}
let resource = ContentId::of_bytes(b"c29");
let caller = sk(3);
let chain = vec![grant(&sk(1), did(&caller), resource, &["read"], 100_000, 1_000)];
let ep = || {
Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(clock)
};
let req = |now: u64, who: &SigningKey, call: Option<[u8; 32]>| {
let f = frame(chain.last().unwrap(), resource, "read", Trust::Control);
WireRequest::sign_parts(f, chain.clone(), who, Energy::estimator(7), now, None, call)
.map_err(|e| format!("sign: {e:?}"))
};
let leaf = chain.last().unwrap().grant.id();
// Without a declared id, a retry that re-stamped is a second call.
let mut anon = ep();
anon.handle(&req(10, &caller, None)?);
let once = anon.runtime.spent(&leaf);
anon.handle(&req(11, &caller, None)?);
if anon.runtime.spent(&leaf) != once * 2 {
return Err("a re-stamped retry with no declared id should be a second call".into());
}
// With one, it is the same call: paid once, answered what it was answered.
let mut named = ep();
let first = named.handle(&req(10, &caller, Some([0xC1; 32]))?);
let paid = named.runtime.spent(&leaf);
let again = named.handle(&req(11, &caller, Some([0xC1; 32]))?);
if named.runtime.spent(&leaf) != paid {
return Err("a declared repeat was debited twice".into());
}
if again.receipt != first.receipt {
return Err("a declared repeat was answered something else".into());
}
// Two calls that say they differ both happen.
let mut distinct = ep();
distinct.handle(&req(10, &caller, Some([0xA1; 32]))?);
let one = distinct.runtime.spent(&leaf);
distinct.handle(&req(10, &caller, Some([0xA2; 32]))?);
if distinct.runtime.spent(&leaf) != one * 2 {
return Err("a different declared id should be a different call".into());
}
// The id is scoped to its caller: another caller's identical id is not a
// repeat of mine, or one caller could suppress another's call.
let mut scoped = ep();
let mine = req(10, &caller, Some([0xC1; 32]))?;
let mine_answer = scoped.handle(&mine);
let theirs = req(10, &sk(4), Some([0xC1; 32]))?;
if scoped.handle(&theirs).receipt == mine_answer.receipt {
return Err("another caller's id was taken for mine".into());
}
// The index survives a restart, or the retry is debited again — the
// overspend §4.11 prevents, one layer out.
let recorded = [JournalRecord::Decided { at: 10, request: req(10, &caller, Some([0xC1; 32]))? }];
let mut restarted = ep()
.with_journal(MemoryJournal::default(), &recorded)
.map_err(|e| format!("replay: {e:?}"))?;
let replayed = restarted.runtime.spent(&leaf);
restarted.handle(&req(11, &caller, Some([0xC1; 32]))?);
if restarted.runtime.spent(&leaf) != replayed {
return Err("a restart forgot a call it had decided".into());
}
// And the guarantee is bounded by the horizon rather than unbounded.
fn late() -> u64 {
400
}
let mut past = Endpoint::new(Runtime::new(), sk(7), "did:example:steward")
.with_clock(late)
.with_replay_horizon(60)
.with_journal(MemoryJournal::default(), &recorded)
.map_err(|e| format!("replay: {e:?}"))?;
let before = past.runtime.spent(&leaf);
past.handle(&req(400, &caller, Some([0xC1; 32]))?);
if past.runtime.spent(&leaf) <= before {
return Err("past the horizon a call should no longer be recognised".into());
}
Ok(())
}
fn sk(s: u8) -> SigningKey {
SigningKey::from_bytes(&[s; 32])
}

View file

@ -114,6 +114,15 @@ pub struct WireRequest {
/// always had.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub challenge: Option<[u8; 32]>,
/// The caller's name for *this call* (SPEC §4.13): 32 bytes it chose,
/// kept the same across every delivery of the same call and different
/// between calls. Where it is present the endpoint recognises a repeat by
/// it instead of by the request's bytes, so a retry that re-stamps `now`
/// is still the same call rather than a second one. It is signed with the
/// rest of the request, and omitted when absent, so a request without one
/// has the bytes it always had.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub call_id: Option<[u8; 32]>,
}
/// The borrowed signable view of a request — every field but the signature.
@ -126,6 +135,8 @@ struct SignableRequest<'a> {
now: u64,
#[serde(skip_serializing_if = "Option::is_none")]
challenge: Option<&'a [u8; 32]>,
#[serde(skip_serializing_if = "Option::is_none")]
call_id: Option<&'a [u8; 32]>,
}
impl WireRequest {
@ -137,7 +148,7 @@ impl WireRequest {
cost: &Energy,
now: u64,
) -> Result<Vec<u8>, SchemaError> {
canonical_cbor(&SignableRequest { frame, chain, caller, cost, now, challenge: None })
canonical_cbor(&SignableRequest { frame, chain, caller, cost, now, challenge: None, call_id: None })
}
/// The canonical bytes the caller signs for a request carrying
@ -151,7 +162,52 @@ impl WireRequest {
now: u64,
challenge: &[u8; 32],
) -> Result<Vec<u8>, SchemaError> {
canonical_cbor(&SignableRequest { frame, chain, caller, cost, now, challenge: Some(challenge) })
canonical_cbor(&SignableRequest { frame, chain, caller, cost, now, challenge: Some(challenge), call_id: None })
}
/// The canonical bytes *this* request's signature covers, whichever
/// optional fields it carries.
///
/// One function rather than one per combination of optionals: each is
/// skipped when absent, so a request carrying none has the bytes it
/// always had, and adding another optional does not double the number of
/// ways to ask this question.
pub fn signed_bytes(&self) -> Result<Vec<u8>, SchemaError> {
canonical_cbor(&SignableRequest {
frame: &self.frame,
chain: &self.chain,
caller: &self.caller,
cost: &self.cost,
now: self.now,
challenge: self.challenge.as_ref(),
call_id: self.call_id.as_ref(),
})
}
/// Build and sign a request, naming each optional field explicitly.
/// [`sign`](Self::sign) and [`sign_challenged`](Self::sign_challenged)
/// are this with one of them fixed.
///
/// The argument list is the request's own fields; collapsing the two
/// optionals into a struct would hide which of them a call site sets,
/// which is the thing a reader of a signing call needs to see.
#[allow(clippy::too_many_arguments)]
pub fn sign_parts(
frame: Frame,
chain: Vec<SignedGrant>,
caller_sk: &SigningKey,
cost: Energy,
now: u64,
challenge: Option<[u8; 32]>,
call_id: Option<[u8; 32]>,
) -> Result<Self, SchemaError> {
use ed25519_dalek::Signer;
let caller = Did::from_verifying_key(&caller_sk.verifying_key());
let mut req =
Self { frame, chain, caller, cost, now, signature: Vec::new(), challenge, call_id };
let bytes = req.signed_bytes()?;
req.signature = caller_sk.sign(&bytes).to_bytes().to_vec();
Ok(req)
}
/// Build and sign a request that asks for the endpoint's evidence, bound
@ -164,11 +220,7 @@ impl WireRequest {
now: u64,
challenge: [u8; 32],
) -> Result<Self, SchemaError> {
use ed25519_dalek::Signer;
let caller = Did::from_verifying_key(&caller_sk.verifying_key());
let bytes = Self::challenged_signable_bytes(&frame, &chain, &caller, &cost, now, &challenge)?;
let signature = caller_sk.sign(&bytes).to_bytes().to_vec();
Ok(Self { frame, chain, caller, cost, now, signature, challenge: Some(challenge) })
Self::sign_parts(frame, chain, caller_sk, cost, now, Some(challenge), None)
}
/// Build and sign a request with the caller's key.
@ -179,11 +231,7 @@ impl WireRequest {
cost: Energy,
now: u64,
) -> Result<Self, SchemaError> {
use ed25519_dalek::Signer;
let caller = Did::from_verifying_key(&caller_sk.verifying_key());
let bytes = Self::signable_bytes(&frame, &chain, &caller, &cost, now)?;
let signature = caller_sk.sign(&bytes).to_bytes().to_vec();
Ok(Self { frame, chain, caller, cost, now, signature, challenge: None })
Self::sign_parts(frame, chain, caller_sk, cost, now, None, None)
}
/// Attach an externally produced signature (e.g. a FROST k-of-n aggregate)
@ -196,18 +244,14 @@ impl WireRequest {
now: u64,
signature: Vec<u8>,
) -> Self {
Self { frame, chain, caller, cost, now, signature, challenge: None }
Self { frame, chain, caller, cost, now, signature, challenge: None, call_id: None }
}
/// Whether `signature` is the caller's, over this request's signable bytes.
pub fn verify_caller(&self) -> bool {
use ed25519_dalek::Verifier;
let Ok(vk) = self.caller.verifying_key() else { return false };
let bytes = match &self.challenge {
None => Self::signable_bytes(&self.frame, &self.chain, &self.caller, &self.cost, self.now),
Some(c) => Self::challenged_signable_bytes(&self.frame, &self.chain, &self.caller, &self.cost, self.now, c),
};
let Ok(bytes) = bytes else {
let Ok(bytes) = self.signed_bytes() else {
return false;
};
let Ok(sig) = ed25519_dalek::Signature::from_slice(&self.signature) else { return false };
@ -330,6 +374,18 @@ pub fn read_msg<T: serde::de::DeserializeOwned>(r: &mut impl Read) -> Result<T,
/// How far, by default, a request's `now` may stray from the endpoint's clock.
pub const DEFAULT_SKEW_SECS: u64 = 300;
/// A call the endpoint decided, kept for the replay horizon (SPEC §4.13).
///
/// Retained by **when it was decided**, not by the request's stamp: the whole
/// point of a declared call id is to recognise a retry that re-stamps `now`,
/// and an entry pruned by the original stamp would already be gone when that
/// retry arrives.
#[derive(Clone, Debug)]
struct Called {
decided_at: u64,
response: WireResponse,
}
/// A request the endpoint decided, kept while its stamp is in the window.
#[derive(Clone, Debug)]
struct Answered {
@ -360,6 +416,15 @@ pub struct Endpoint {
/// The requests decided within the window, by content id: each one's
/// stamp, and the answer it got.
decided: HashMap<ContentId, Answered>,
/// The calls decided within the replay horizon, by the caller and the
/// call id it declared (SPEC §4.13). Scoped to the caller because the id
/// is the caller's to choose: were it global, one caller could suppress
/// another's call by guessing its id.
calls: HashMap<(Did, [u8; 32]), Called>,
/// How long a declared call id is remembered. The guarantee is bounded by
/// this and says so: recognising a repeat forever would need state
/// forever.
replay_horizon_secs: u64,
/// Where each admitted request and each revocation is recorded first.
journal: Box<dyn Journal + Send>,
/// Where this endpoint's evidence comes from, if it can give any.
@ -377,6 +442,8 @@ impl Endpoint {
clock: system_clock,
skew_secs: DEFAULT_SKEW_SECS,
decided: HashMap::new(),
calls: HashMap::new(),
replay_horizon_secs: DEFAULT_SKEW_SECS,
journal: Box::new(MemoryJournal::default()),
attester: None,
}
@ -415,6 +482,16 @@ impl Endpoint {
// this is the answer the request got before the restart,
// byte for byte, and a repeat of it gets that again.
let response = self.respond(request, decision);
// The call index is re-established by the same replay, or
// a restart would forget which calls it had already
// decided and debit their retries again — the overspend
// §4.11 exists to prevent, one layer out.
if let Some(cid) = request.call_id {
self.calls.insert(
(request.caller, cid),
Called { decided_at: *at, response: response.clone() },
);
}
self.decided.insert(id, Answered { stamp: request.now, response });
}
JournalRecord::Revoked { revocation, grant } => {
@ -427,6 +504,8 @@ impl Endpoint {
let now = (self.clock)();
let skew = self.skew_secs;
self.decided.retain(|_, a| a.stamp.saturating_add(skew) >= now);
let horizon = self.replay_horizon_secs;
self.calls.retain(|_, c| c.decided_at.saturating_add(horizon) >= now);
self.journal = Box::new(journal);
Ok(self)
}
@ -441,6 +520,18 @@ impl Endpoint {
Ok(())
}
/// Remember a declared call id for `secs` instead of the default
/// [`DEFAULT_SKEW_SECS`] (SPEC §4.13).
///
/// A transport that can redeliver after an outage needs a horizon at
/// least as long as its longest retry, or a redelivery arrives after the
/// endpoint has forgotten the call and is debited as a new one. The cost
/// is state, which is why it is a choice and not a constant.
pub fn with_replay_horizon(mut self, secs: u64) -> Self {
self.replay_horizon_secs = secs;
self
}
/// Decide at `clock` (unix seconds) instead of the system clock.
pub fn with_clock(mut self, clock: fn() -> u64) -> Self {
self.clock = clock;
@ -462,9 +553,20 @@ impl Endpoint {
fn admit(&mut self, req: &WireRequest, now: u64) -> Result<Admission, jcp_schema::DenyReason> {
let skew = self.skew_secs;
self.decided.retain(|_, a| a.stamp.saturating_add(skew) >= now);
let horizon = self.replay_horizon_secs;
self.calls.retain(|_, c| c.decided_at.saturating_add(horizon) >= now);
if now.abs_diff(req.now) > skew {
return Err(jcp_schema::DenyReason::StaleRequest);
}
// A declared call id answers first. Recognising a repeat by the
// request's bytes only catches a redelivery that reproduced them
// exactly, including `now` — so it misses the retry that re-stamped,
// which is the one a slow transport actually sends.
if let Some(cid) = req.call_id {
if let Some(c) = self.calls.get(&(req.caller, cid)) {
return Ok(Admission::Repeat(c.response.clone()));
}
}
let id = ContentId::of(req).map_err(|_| jcp_schema::DenyReason::BadSignature)?;
Ok(match self.decided.get(&id) {
Some(a) => Admission::Repeat(a.response.clone()),
@ -538,6 +640,12 @@ impl Endpoint {
let response = self.respond(req, decision);
if let (Some(id), None) = (id, refused) {
self.decided.insert(id, Answered { stamp: req.now, response: response.clone() });
if let Some(cid) = req.call_id {
self.calls.insert(
(req.caller, cid),
Called { decided_at: now, response: response.clone() },
);
}
}
response
}
@ -840,7 +948,7 @@ mod tests {
assert_eq!(d.decision.verdict, Verdict::Deny(DenyReason::BudgetExceeded));
}
/// A window survives a restart (SPEC §4.12): the endpoint replays each
/// A window survives a restart (SPEC §4.13): the endpoint replays each
/// decision at the time it made it, so the window a call filled is still
/// full afterwards.
#[test]
@ -1209,4 +1317,162 @@ mod tests {
tampered.now = 99;
assert!(!tampered.verify_caller());
}
/// A request stamped `now`, carrying the caller's name for the call.
fn identified(chain: &[SignedGrant], r: ContentId, cost: u64, now: u64, call: [u8; 32]) -> WireRequest {
let frame = request(chain, did(&sk(3)), r, Trust::Control, cost).frame;
WireRequest::sign_parts(frame, chain.to_vec(), &sk(3), Energy::estimator(cost), now, None, Some(call))
.unwrap()
}
fn spent_on_leaf(ep: &Endpoint, chain: &[SignedGrant]) -> u64 {
ep.runtime.spent(&chain.last().unwrap().grant.id())
}
/// A request without a call id is byte-for-byte what it was before the
/// field existed, and still verifies against the old signable bytes.
#[test]
fn a_request_without_a_call_id_has_the_bytes_it_always_had() {
let (chain, _, r) = fixture();
let plain = stamped(&chain, r, 1, 10);
let wire = canonical_cbor(&plain).unwrap();
assert!(!wire.windows(7).any(|w| w == b"call_id"), "no call_id field on the wire");
use ed25519_dalek::Verifier;
let old =
WireRequest::signable_bytes(&plain.frame, &plain.chain, &plain.caller, &plain.cost, plain.now)
.unwrap();
let sig = ed25519_dalek::Signature::from_slice(&plain.signature).unwrap();
sk(3).verifying_key().verify(&old, &sig).expect("signed over the bytes it always was");
}
/// The id is inside the signature, so a relay cannot retarget a call by
/// editing it — which would let it replay one call as another.
#[test]
fn the_call_id_is_covered_by_the_signature() {
let (chain, _, r) = fixture();
let mut req = identified(&chain, r, 1, 10, [0xC1; 32]);
assert!(req.verify_caller());
req.call_id = Some([0xC2; 32]);
assert!(!req.verify_caller(), "editing the call id must break the signature");
}
/// The gap this closes. A transport retries; the first stamp has aged, so
/// the client re-stamps. Recognising a repeat by the request's bytes
/// cannot see that this is the same call, and the grant is debited twice.
/// A declared id is seen.
#[test]
fn a_re_stamped_retry_is_the_same_call_only_when_it_says_so() {
let (chain, _, r) = fixture();
// Without an id: the re-stamped retry is a second call, and pays.
let mut anon = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10);
anon.handle(&stamped(&chain, r, 7, 10));
let once = spent_on_leaf(&anon, &chain);
anon.handle(&stamped(&chain, r, 7, 11));
assert_eq!(spent_on_leaf(&anon, &chain), once * 2, "the retry was debited again");
// With one: the same call, answered from the record.
let mut named = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10);
let first = named.handle(&identified(&chain, r, 7, 10, [0xC1; 32]));
let after_first = spent_on_leaf(&named, &chain);
let again = named.handle(&identified(&chain, r, 7, 11, [0xC1; 32]));
assert_eq!(spent_on_leaf(&named, &chain), after_first, "a declared repeat pays once");
assert_eq!(again.receipt, first.receipt, "and is answered what it was answered");
}
/// Two calls the caller means as distinct are distinct, even when they
/// would otherwise be byte-identical. Identity is declared, not inferred.
#[test]
fn two_calls_that_say_they_differ_both_happen() {
let (chain, _, r) = fixture();
let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10);
ep.handle(&identified(&chain, r, 7, 10, [0xA1; 32]));
let once = spent_on_leaf(&ep, &chain);
ep.handle(&identified(&chain, r, 7, 10, [0xA2; 32]));
assert_eq!(spent_on_leaf(&ep, &chain), once * 2, "a different id is a different call");
}
/// The id is the caller's to choose, so it is scoped to the caller. Were
/// it global, one caller could suppress another's call by guessing its id.
#[test]
fn a_call_id_is_scoped_to_its_caller() {
let (chain, _, r) = fixture();
let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(at_10);
let mine = identified(&chain, r, 7, 10, [0xC1; 32]);
ep.handle(&mine);
// The same 32 bytes from a different caller. The grant does not admit
// that caller, so what matters is that it is *decided* rather than
// answered from my record: a suppressed call would be a repeat.
let frame = request(&chain, did(&sk(4)), r, Trust::Control, 7).frame;
let theirs = WireRequest::sign_parts(
frame,
chain.to_vec(),
&sk(4),
Energy::estimator(7),
10,
None,
Some([0xC1; 32]),
)
.unwrap();
let resp = ep.handle(&theirs);
assert_ne!(resp.receipt, ep.handle(&mine).receipt, "another caller's id is not mine");
}
/// The index is re-established by the same replay §4.11 already does, or a
/// restart would forget which calls it decided and debit their retries
/// again — the overspend §4.11 exists to prevent, one layer out.
#[test]
fn a_restart_still_knows_which_calls_it_decided() {
let (chain, _, r) = fixture();
let req = identified(&chain, r, 7, 10, [0xC1; 32]);
let mut before = Endpoint::new(Runtime::new(), sk(7), "did:example:steward")
.with_clock(at_10)
.with_journal(MemoryJournal::default(), &[])
.unwrap();
before.handle(&req);
let recorded = [JournalRecord::Decided { at: 10, request: req.clone() }];
let mut after = Endpoint::new(Runtime::new(), sk(7), "did:example:steward")
.with_clock(at_10)
.with_journal(MemoryJournal::default(), &recorded)
.unwrap();
let replayed = spent_on_leaf(&after, &chain);
// The retry arrives after the restart, re-stamped.
after.handle(&identified(&chain, r, 7, 11, [0xC1; 32]));
assert_eq!(spent_on_leaf(&after, &chain), replayed, "the retry pays nothing after a restart");
}
/// The guarantee is bounded by the horizon, and says so. Remembering a
/// call forever would need state forever, so past it the endpoint no
/// longer recognises the repeat — which is a limit to state, not a limit
/// to pretend is absent.
#[test]
fn the_guarantee_is_bounded_by_the_horizon() {
let (chain, _, r) = fixture();
fn at_400() -> u64 {
400
}
// Decided at 10 and remembered for 60 s; the endpoint now reads 400.
let recorded = [JournalRecord::Decided {
at: 10,
request: identified(&chain_of(), ContentId::of_bytes(b"db"), 7, 10, [0xC1; 32]),
}];
let mut ep = Endpoint::new(Runtime::new(), sk(7), "did:example:steward")
.with_clock(at_400)
.with_replay_horizon(60)
.with_journal(MemoryJournal::default(), &recorded)
.unwrap();
let replayed = spent_on_leaf(&ep, &chain);
// A retry stamped inside the freshness window, but past the horizon.
ep.handle(&identified(&chain, r, 7, 400, [0xC1; 32]));
assert!(
spent_on_leaf(&ep, &chain) > replayed,
"past the horizon the call is no longer recognised, and is debited"
);
}
fn chain_of() -> Vec<SignedGrant> {
fixture().0
}
}