Skip to main content

acdp_client/
recall.rs

1//! Retraction recall, consumer side (ACDP 0.5.0 Draft, RFC-ACDP-0018,
2//! profile `acdp-consumer-recall`).
3//!
4//! - [`Since`] and [`ChangeFeed`] — read a registry's `GET /changes`
5//!   tail from a persisted cursor ([`crate::RegistryClient::changes`] is
6//!   the single-page call underneath).
7//! - [`verify_change`] — the §7 procedure that turns a change entry (a
8//!   hint) into a [`ChangeVerdict`]: [`ChangeVerdict::Verified`] with the
9//!   [`ChangeEffect`] to apply, or [`ChangeVerdict::Rejected`], which MUST
10//!   NOT change any reliance decision.
11//! - [`SeenChanges`] — §7 idempotency, by (registry authority, `seq`).
12//!
13//! The processing loop a consumer runs:
14//!
15//! ```no_run
16//! # async fn ex(
17//! #     client: &acdp_client::RegistryClient,
18//! #     resolver: &acdp_did::WebResolver,
19//! #     filter: acdp_types::change::WatchFilter,
20//! #     persisted: acdp_client::Since,
21//! # ) -> Result<(), acdp_primitives::AcdpError> {
22//! use acdp_client::recall::{verify_change, ChangeFeed, ChangeVerdict, SeenChanges};
23//! use acdp_client::VerificationPolicy;
24//!
25//! let mut feed = ChangeFeed::new(client, filter).since(persisted);
26//! let mut seen = SeenChanges::new();
27//! let authority = client.authority().unwrap_or_default();
28//! loop {
29//!     let page = match feed.fetch().await {
30//!         Err(acdp_primitives::AcdpError::CursorExpired) => {
31//!             // §7 "Resync": re-verify every watched context directly,
32//!             // then start again from the head.
33//!             feed.reset_to_head();
34//!             continue;
35//!         }
36//!         other => other?,
37//!     };
38//!     for change in &page.changes {
39//!         if !seen.is_new(&authority, change.seq()) {
40//!             continue;
41//!         }
42//!         match verify_change(client, resolver, change, None, &VerificationPolicy::default())
43//!             .await?
44//!         {
45//!             ChangeVerdict::Verified(_effect) => { /* re-evaluate reliance */ }
46//!             ChangeVerdict::Rejected(_why) => { /* no reliance change; re-verify directly */ }
47//!             _ => {}
48//!         }
49//!         seen.mark(&authority, change.seq());
50//!     }
51//!     feed.advance(&page); // persist feed.position() here
52//!     if !page.has_more {
53//!         break;
54//!     }
55//! }
56//! # Ok(()) }
57//! ```
58
59use std::collections::BTreeMap;
60
61use acdp_did::web::WebResolver;
62use acdp_primitives::error::AcdpError;
63use acdp_primitives::revocation::{RevocationSource, SourcedRevocation};
64use acdp_types::body::Body;
65use acdp_types::change::{Change, ChangeEvidence, ChangeHint, ChangesResponse, WatchFilter};
66use acdp_types::lifecycle::{retraction_state, LifecycleEvent, LifecycleEventType};
67use acdp_types::primitives::CtxId;
68use acdp_types::receipt::RegistryReceipt;
69use acdp_types::revocation::RevocationTrustClass;
70use acdp_types::EventReceipt;
71use serde_json::Value;
72
73use crate::receipt::verify_event_receipt_value;
74use crate::revocation::verify_revocation_candidate;
75use crate::verified::{ReceiptPolicy, VerificationPolicy, VerifiedContext};
76use crate::RegistryClient;
77
78// ── Reading the feed ─────────────────────────────────────────────────────────
79
80/// Where a `GET /changes` request starts (RFC-ACDP-0018 §5.1).
81#[derive(Debug, Clone, PartialEq, Eq, Hash)]
82pub enum Since {
83    /// The oldest retained entry (`since` absent): replay history.
84    Start,
85    /// The current head (`since=head`): no entries, just the head cursor.
86    /// Where a new consumer, or one resyncing after `cursor_expired`,
87    /// starts.
88    Head,
89    /// After a cursor from an earlier response.
90    Cursor(String),
91}
92
93impl Since {
94    /// The `since` query value, or `None` to omit the parameter.
95    pub fn as_query(&self) -> Option<&str> {
96        match self {
97            Since::Start => None,
98            Since::Head => Some("head"),
99            Since::Cursor(c) => Some(c),
100        }
101    }
102}
103
104/// A consumer's position in one registry's changes feed: the filter it
105/// watches and the cursor it has processed up to.
106///
107/// [`Self::fetch`] reads the next page WITHOUT moving the cursor; call
108/// [`Self::advance`] once every entry of that page has been processed,
109/// then persist [`Self::position`] (§7 "Cursor persistence"). A crash in
110/// between re-reads the page, which [`SeenChanges`] makes harmless.
111#[derive(Clone)]
112pub struct ChangeFeed<'a> {
113    client: &'a RegistryClient,
114    filter: WatchFilter,
115    limit: Option<u32>,
116    since: Since,
117}
118
119impl<'a> ChangeFeed<'a> {
120    /// A feed over `client`'s registry, watching `filter`, starting at
121    /// [`Since::Start`].
122    pub fn new(client: &'a RegistryClient, filter: WatchFilter) -> Self {
123        Self {
124            client,
125            filter,
126            limit: None,
127            since: Since::Start,
128        }
129    }
130
131    /// Start from `since` (a persisted position, or [`Since::Head`]).
132    pub fn since(mut self, since: Since) -> Self {
133        self.since = since;
134        self
135    }
136
137    /// Ask for at most `limit` entries per page.
138    pub fn limit(mut self, limit: u32) -> Self {
139        self.limit = Some(limit);
140        self
141    }
142
143    /// The position the next [`Self::fetch`] reads from.
144    pub fn position(&self) -> &Since {
145        &self.since
146    }
147
148    /// The watched filter.
149    pub fn filter(&self) -> &WatchFilter {
150        &self.filter
151    }
152
153    /// Read the page after [`Self::position`]. Does not move the cursor.
154    pub async fn fetch(&self) -> Result<ChangesResponse, AcdpError> {
155        self.client
156            .changes(&self.since, &self.filter, self.limit)
157            .await
158    }
159
160    /// Move past `page` (to its `next_cursor`) once all of its entries
161    /// are processed.
162    pub fn advance(&mut self, page: &ChangesResponse) {
163        self.since = Since::Cursor(page.next_cursor.clone());
164    }
165
166    /// After `cursor_expired` and a direct re-verification of every
167    /// watched context: resume from the head (§7 "Resync").
168    pub fn reset_to_head(&mut self) {
169        self.since = Since::Head;
170    }
171
172    /// React to an authenticated change hint (RFC-ACDP-0018 §8.2): read
173    /// the page after this feed's OWN position. The hint's `head_cursor`
174    /// is informational and never used: jumping to it would skip
175    /// entries (fixture `rec-007` E). Verify the hint's `ACDP-Signature`
176    /// first ([`acdp_types::change::verify_hint_signature`]).
177    ///
178    /// A hint naming another registry is
179    /// [`AcdpError::SchemaViolation`].
180    pub async fn on_hint(&self, hint: &ChangeHint) -> Result<ChangesResponse, AcdpError> {
181        if self.client.authority().as_deref() != Some(hint.registry_authority.as_str()) {
182            return Err(AcdpError::SchemaViolation(format!(
183                "change hint names registry '{}', not this feed's",
184                hint.registry_authority
185            )));
186        }
187        self.fetch().await
188    }
189}
190
191/// §7 idempotency: delivery is at-least-once, so a consumer deduplicates
192/// by (registry authority, `seq`).
193///
194/// Because `seq` is strictly increasing per registry and a feed is
195/// processed in ascending order, a per-authority high-water mark is the
196/// whole state: an entry at or below it was already processed. Mark an
197/// entry only once its verdict is final (a transient `Err` from
198/// [`verify_change`] leaves it unmarked, to be retried).
199#[derive(Debug, Clone, Default, PartialEq, Eq)]
200pub struct SeenChanges {
201    high_water: BTreeMap<String, u64>,
202}
203
204impl SeenChanges {
205    /// Nothing seen.
206    pub fn new() -> Self {
207        Self::default()
208    }
209
210    /// Whether entry `seq` of `authority` is not yet processed.
211    pub fn is_new(&self, authority: &str, seq: u64) -> bool {
212        self.high_water.get(authority).is_none_or(|hw| seq > *hw)
213    }
214
215    /// Record entry `seq` of `authority` as processed.
216    pub fn mark(&mut self, authority: &str, seq: u64) {
217        let hw = self.high_water.entry(authority.to_owned()).or_insert(seq);
218        *hw = (*hw).max(seq);
219    }
220
221    /// Check and record in one step: `true` the first time.
222    pub fn first_time(&mut self, authority: &str, seq: u64) -> bool {
223        let new = self.is_new(authority, seq);
224        if new {
225            self.mark(authority, seq);
226        }
227        new
228    }
229}
230
231// ── Verifying entries ────────────────────────────────────────────────────────
232
233/// What a consumer already holds for the context a `lifecycle` entry is
234/// about. Lets [`verify_change`] apply the entry without re-retrieving,
235/// which §7 step 1 allows only when every earlier event (indexes `0` to
236/// `index − 1`) is held and verified.
237#[derive(Debug, Clone, Copy)]
238pub struct HeldContext<'a> {
239    /// The context's verified body.
240    pub body: &'a Body,
241    /// Its verified lifecycle events, raw as served, in array order from
242    /// index 0.
243    pub events: &'a [Value],
244}
245
246/// Where a verified `lifecycle` entry's retraction state was derived
247/// from.
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249#[non_exhaustive]
250pub enum LifecycleSource {
251    /// The consumer's [`HeldContext`] plus the entry's event.
252    Held,
253    /// A fresh, verified retrieval of the context.
254    Retrieved,
255}
256
257/// The verified effect of a change entry: what the consumer should
258/// re-evaluate. Policy (whether reliance moves) stays the consumer's.
259#[derive(Debug)]
260#[non_exhaustive]
261pub enum ChangeEffect {
262    /// A lifecycle event of the affected context, verified.
263    Lifecycle {
264        /// The verified event.
265        event: LifecycleEvent,
266        /// The context's retraction state over its full event list
267        /// (RFC-ACDP-0013 §7.1).
268        retracted: bool,
269        /// Where the full list came from.
270        source: LifecycleSource,
271    },
272    /// The affected context was superseded by this verified version,
273    /// whose receipt verified and matches the entry's.
274    Superseded {
275        /// The new version.
276        new_version: Box<VerifiedContext>,
277    },
278    /// A verified revocation that speaks for the entry's `agent_id`
279    /// (RFC-ACDP-0014 §7 producer scope). Apply it with the §7
280    /// compromise boundary and the earliest-`compromised_since` fold.
281    KeyRevocation {
282        /// The revocation, attributed to this registry.
283        revocation: SourcedRevocation,
284    },
285}
286
287/// The outcome of [`verify_change`] (RFC-ACDP-0018 §7).
288#[derive(Debug)]
289#[non_exhaustive]
290pub enum ChangeVerdict {
291    /// The entry verified; re-evaluate reliance per the effect.
292    Verified(ChangeEffect),
293    /// The entry failed verification. It MUST NOT change any reliance
294    /// decision; the consumer SHOULD re-verify the affected context
295    /// directly, since a faulty source could equally drop a real entry.
296    Rejected(AcdpError),
297}
298
299impl ChangeVerdict {
300    /// The effect, when verified.
301    pub fn effect(&self) -> Option<&ChangeEffect> {
302        match self {
303            ChangeVerdict::Verified(e) => Some(e),
304            ChangeVerdict::Rejected(_) => None,
305        }
306    }
307
308    /// The reason, when rejected.
309    pub fn rejection(&self) -> Option<&AcdpError> {
310        match self {
311            ChangeVerdict::Verified(_) => None,
312            ChangeVerdict::Rejected(e) => Some(e),
313        }
314    }
315}
316
317/// Verify one change entry from `client`'s feed (RFC-ACDP-0018 §7).
318///
319/// For every kind, the §7 envelope rule first:
320/// [`Change::check_consistency`] (`recorded_at` and `ctx_id` agree with
321/// the evidence). Then, by kind:
322///
323/// - **`lifecycle`** — the context's body comes from `held` or a verified
324///   retrieval under `policy`; it must be the entry's context, producer
325///   and lineage, at this registry. The event is verified under
326///   RFC-ACDP-0013 §5 and its event receipt under RFC-ACDP-0017 §10.3
327///   (*i* = `event_receipt.index`, against the body's `created_at`).
328///   The retraction state is then derived over the full event list:
329///   from `held` when it covers indexes `0..index` (a held event at
330///   `index` must equal the entry's), otherwise from a re-retrieval whose
331///   `lifecycle_events[index]` must equal the entry's event under JCS.
332///   Every `retracted`/`republished` event in that list is verified.
333/// - **`superseded`** — `superseded_by` is retrieved and verified under
334///   `policy` with its receipt required; its `supersedes`, `lineage_id`
335///   and `agent_id` must be the entry's, and its verified receipt must
336///   equal the entry's.
337/// - **`key_revocation`** — the revocation context is retrieved and
338///   verified under RFC-ACDP-0014 §5 (including the historical-key arm)
339///   or §6 (a registry-attested one must be published by this registry);
340///   the served receipt must verify under RFC-ACDP-0010 §8 and equal the
341///   entry's under JCS; and the revocation must
342///   speak for the entry's `agent_id` (§7 producer scope).
343///
344/// `Ok(Rejected)` is a final verdict. `Err` means the entry could not be
345/// decided (a transient failure: network, rate-limited DID refresh) or
346/// `held` is for another context; leave the entry unprocessed and retry.
347pub async fn verify_change(
348    client: &RegistryClient,
349    resolver: &WebResolver,
350    change: &Change,
351    held: Option<HeldContext<'_>>,
352    policy: &VerificationPolicy,
353) -> Result<ChangeVerdict, AcdpError> {
354    if let Some(h) = held {
355        if &h.body.ctx_id != change.ctx_id() {
356            return Err(AcdpError::SchemaViolation(format!(
357                "held context {} is not the entry's {}",
358                h.body.ctx_id,
359                change.ctx_id()
360            )));
361        }
362    }
363    let outcome = match change.check_consistency() {
364        Err(e) => Err(e),
365        Ok(()) => match change.evidence() {
366            ChangeEvidence::Lifecycle {
367                event,
368                event_receipt,
369            } => {
370                verify_lifecycle(client, resolver, change, event, event_receipt, held, policy).await
371            }
372            ChangeEvidence::Superseded {
373                superseded_by,
374                registry_receipt,
375            } => {
376                verify_superseded(
377                    client,
378                    resolver,
379                    change,
380                    superseded_by,
381                    registry_receipt,
382                    policy,
383                )
384                .await
385            }
386            ChangeEvidence::KeyRevocation { registry_receipt } => {
387                verify_key_revocation(client, resolver, change, registry_receipt).await
388            }
389        },
390    };
391    match outcome {
392        Ok(effect) => Ok(ChangeVerdict::Verified(effect)),
393        Err(e) if e.is_transient() => Err(e),
394        Err(e) => Ok(ChangeVerdict::Rejected(e)),
395    }
396}
397
398fn reject(change: &Change, why: impl std::fmt::Display) -> AcdpError {
399    AcdpError::SchemaViolation(format!(
400        "change {} ({} {}): {why} (RFC-ACDP-0018 §7)",
401        change.seq(),
402        change.kind(),
403        change.ctx_id()
404    ))
405}
406
407fn serving_authority(client: &RegistryClient) -> Result<String, AcdpError> {
408    client
409        .authority()
410        .ok_or_else(|| AcdpError::SchemaViolation("registry client base URL has no host".into()))
411}
412
413/// The entry's context, producer and lineage, at this registry.
414fn check_body_binding(change: &Change, body: &Body, authority: &str) -> Result<(), AcdpError> {
415    if &body.ctx_id != change.ctx_id() {
416        return Err(AcdpError::ContextIdMismatch {
417            requested: change.ctx_id().as_str().to_owned(),
418            served: body.ctx_id.as_str().to_owned(),
419        });
420    }
421    if &body.agent_id != change.agent_id() {
422        return Err(reject(
423            change,
424            format!("the context's producer is {}", body.agent_id),
425        ));
426    }
427    if &body.lineage_id != change.lineage_id() {
428        return Err(reject(
429            change,
430            format!("the context's lineage is {}", body.lineage_id),
431        ));
432    }
433    if body.origin_registry != authority {
434        return Err(reject(
435            change,
436            format!(
437                "the context originates at {}, not this registry",
438                body.origin_registry
439            ),
440        ));
441    }
442    Ok(())
443}
444
445/// JCS byte equality, via the RFC-ACDP-0017 §10.1 hash (SHA-256 of JCS).
446fn jcs_equal(a: &Value, b: &Value) -> Result<bool, AcdpError> {
447    Ok(EventReceipt::event_hash_of_value(a)? == EventReceipt::event_hash_of_value(b)?)
448}
449
450async fn verify_lifecycle(
451    client: &RegistryClient,
452    resolver: &WebResolver,
453    change: &Change,
454    raw_event: &Value,
455    raw_receipt: &Value,
456    held: Option<HeldContext<'_>>,
457    policy: &VerificationPolicy,
458) -> Result<ChangeEffect, AcdpError> {
459    let authority = serving_authority(client)?;
460    let index = EventReceipt::from_value(raw_receipt)?.index;
461    let index_usize = usize::try_from(index).map_err(|_| reject(change, "index out of range"))?;
462    // §7 step 1: the held list may stand in for a retrieval only when it
463    // covers every earlier event.
464    let held = held.filter(|h| h.events.len() >= index_usize);
465    let retrieved;
466    let (body, list, source): (&Body, Vec<Value>, LifecycleSource) = match held {
467        Some(h) => {
468            let mut list = h.events.to_vec();
469            match list.get(index_usize) {
470                Some(have) if !jcs_equal(have, raw_event)? => {
471                    return Err(reject(
472                        change,
473                        format!("the held event at index {index} differs from the entry's"),
474                    ));
475                }
476                Some(_) => {}
477                None => list.push(raw_event.clone()),
478            }
479            (h.body, list, LifecycleSource::Held)
480        }
481        None => {
482            retrieved =
483                VerifiedContext::fetch_with_policy(client, resolver, change.ctx_id(), policy)
484                    .await?;
485            let list: Vec<Value> = retrieved
486                .registry_state()
487                .lifecycle_events
488                .iter()
489                .flatten()
490                .map(serde_json::to_value)
491                .collect::<Result<_, _>>()?;
492            match list.get(index_usize) {
493                Some(served) if jcs_equal(served, raw_event)? => {}
494                _ => {
495                    return Err(reject(
496                        change,
497                        format!(
498                            "the re-retrieved lifecycle_events[{index}] is not the entry's event"
499                        ),
500                    ))
501                }
502            }
503            (retrieved.body(), list, LifecycleSource::Retrieved)
504        }
505    };
506    check_body_binding(change, body, &authority)?;
507    let registry_did = acdp_did::web::authority_to_did_web(&authority);
508    let event = acdp_verify::verify_lifecycle_event(
509        raw_event,
510        change.ctx_id(),
511        &body.agent_id,
512        Some(&registry_did),
513        resolver,
514    )
515    .await?;
516    verify_event_receipt_value(
517        raw_receipt,
518        &authority,
519        change.ctx_id(),
520        raw_event,
521        index,
522        body.created_at,
523        resolver,
524        std::time::Instant::now(),
525    )
526    .await?;
527    // RFC-ACDP-0013 §7.1 over the full list. A held list was verified by
528    // the caller; a retrieved one is verified here for the events that
529    // decide the state.
530    let mut events = Vec::with_capacity(list.len());
531    for (i, raw) in list.iter().enumerate() {
532        let parsed = if i == index_usize {
533            event.clone()
534        } else if source == LifecycleSource::Retrieved {
535            let parsed = LifecycleEvent::from_value(raw)?;
536            if matches!(
537                parsed.event_type,
538                LifecycleEventType::Retracted | LifecycleEventType::Republished
539            ) {
540                acdp_verify::verify_lifecycle_event(
541                    raw,
542                    change.ctx_id(),
543                    &body.agent_id,
544                    Some(&registry_did),
545                    resolver,
546                )
547                .await?
548            } else {
549                parsed
550            }
551        } else {
552            LifecycleEvent::from_value(raw)?
553        };
554        events.push(parsed);
555    }
556    Ok(ChangeEffect::Lifecycle {
557        retracted: retraction_state(&events),
558        event,
559        source,
560    })
561}
562
563async fn verify_superseded(
564    client: &RegistryClient,
565    resolver: &WebResolver,
566    change: &Change,
567    superseded_by: &CtxId,
568    raw_receipt: &Value,
569    policy: &VerificationPolicy,
570) -> Result<ChangeEffect, AcdpError> {
571    let policy = VerificationPolicy {
572        receipts: ReceiptPolicy::Require,
573        ..policy.clone()
574    };
575    let new_version =
576        VerifiedContext::fetch_with_policy(client, resolver, superseded_by, &policy).await?;
577    let body = new_version.body();
578    if body.supersedes.as_ref() != Some(change.ctx_id()) {
579        return Err(reject(
580            change,
581            format!("{superseded_by} does not supersede it"),
582        ));
583    }
584    if &body.lineage_id != change.lineage_id() {
585        return Err(reject(
586            change,
587            format!("{superseded_by} is in lineage {}", body.lineage_id),
588        ));
589    }
590    if &body.agent_id != change.agent_id() {
591        return Err(reject(
592            change,
593            format!(
594                "{superseded_by} has producer {} (RFC-ACDP-0003 §3.1)",
595                body.agent_id
596            ),
597        ));
598    }
599    let matches_entry = match (new_version.verified_receipt(), new_version.receipt()) {
600        (Some(_), Some(served)) => jcs_equal(served, raw_receipt)?,
601        _ => false,
602    };
603    if !matches_entry {
604        return Err(reject(
605            change,
606            "the entry's registry_receipt is not the new version's verified receipt",
607        ));
608    }
609    Ok(ChangeEffect::Superseded {
610        new_version: Box::new(new_version),
611    })
612}
613
614async fn verify_key_revocation(
615    client: &RegistryClient,
616    resolver: &WebResolver,
617    change: &Change,
618    raw_receipt: &Value,
619) -> Result<ChangeEffect, AcdpError> {
620    let authority = serving_authority(client)?;
621    let ctx = client.retrieve(change.ctx_id()).await?;
622    if &ctx.body.ctx_id != change.ctx_id() {
623        return Err(AcdpError::ContextIdMismatch {
624            requested: change.ctx_id().as_str().to_owned(),
625            served: ctx.body.ctx_id.as_str().to_owned(),
626        });
627    }
628    if &ctx.body.lineage_id != change.lineage_id() {
629        return Err(reject(
630            change,
631            format!("the revocation is in lineage {}", ctx.body.lineage_id),
632        ));
633    }
634    let since = std::time::Instant::now();
635    let revocation = verify_revocation_candidate(&ctx, client, resolver, since).await?;
636    if revocation.trust_class == RevocationTrustClass::RegistryAttested {
637        let caps = client.capabilities().await?;
638        revocation.cross_check_registry_binding(&authority, &caps.registry_did)?;
639    }
640    RegistryReceipt::from_value(raw_receipt)?;
641    let Some(served) = ctx.registry_receipt.as_ref() else {
642        return Err(reject(
643            change,
644            "the revocation is served without a registry_receipt",
645        ));
646    };
647    if !jcs_equal(served, raw_receipt)? {
648        return Err(reject(
649            change,
650            "the entry's registry_receipt is not the receipt served with the revocation",
651        ));
652    }
653    // The served receipt verifies under RFC-ACDP-0010 §8 on every arm
654    // (the producer-signed §5 path need not touch it), so JCS equality
655    // above makes the entry's copy verified too (RFC-ACDP-0018 §7 step 3).
656    // `content_hash` was recomputed and checked by the candidate pipeline.
657    let body = &ctx.body;
658    let producer_fp = acdp_crypto::fingerprint::fingerprint_for_key_id_since(
659        &body.signature.key_id,
660        &body.signature.algorithm,
661        resolver,
662        since,
663    )
664    .await?;
665    crate::receipt::verify_receipt_value_since(
666        served,
667        &body.ctx_id,
668        body,
669        &body.content_hash,
670        &producer_fp,
671        &authority,
672        resolver,
673        since,
674    )
675    .await?;
676    let revocation = revocation.sourced(RevocationSource::Discovered { authority });
677    if !revocation.speaks_for(change.agent_id()) {
678        return Err(reject(
679            change,
680            format!(
681                "the revocation does not speak for {} (RFC-ACDP-0014 §7 producer scope)",
682                change.agent_id()
683            ),
684        ));
685    }
686    Ok(ChangeEffect::KeyRevocation { revocation })
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn since_query_values() {
695        assert_eq!(Since::Start.as_query(), None);
696        assert_eq!(Since::Head.as_query(), Some("head"));
697        assert_eq!(Since::Cursor("abc".into()).as_query(), Some("abc"));
698    }
699
700    #[test]
701    fn seen_changes_dedups_by_authority_and_seq() {
702        let mut seen = SeenChanges::new();
703        assert!(seen.first_time("a.example.com", 5));
704        assert!(!seen.first_time("a.example.com", 5));
705        assert!(!seen.is_new("a.example.com", 4));
706        assert!(seen.is_new("b.example.com", 5));
707        assert!(seen.first_time("a.example.com", 6));
708        seen.mark("a.example.com", 2);
709        assert!(!seen.is_new("a.example.com", 6));
710    }
711}