diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 0fc88928c..9fe973e70 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -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` diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs index 548a93c05..7f8d80417 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs @@ -83,11 +83,100 @@ pub fn checks() -> Vec { 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]) } diff --git a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs index 43bbf43ef..aca7db712 100644 --- a/jcp/jcp-rs/crates/jcp-wire/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-wire/src/lib.rs @@ -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, 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, 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, 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, + caller_sk: &SigningKey, + cost: Energy, + now: u64, + challenge: Option<[u8; 32]>, + call_id: Option<[u8; 32]>, + ) -> Result { + 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 { - 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 { - 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, ) -> 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(r: &mut impl Read) -> Result, + /// 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, /// 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 { 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 { + fixture().0 + } }