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; it must originate at the
335///   serving registry (§7 origin binding); its `supersedes`,
336///   `lineage_id` and `agent_id` must be the entry's, and its verified
337///   receipt must equal the entry's.
338/// - **`key_revocation`** — the revocation context is retrieved and
339///   verified under RFC-ACDP-0014 §5 (including the historical-key arm)
340///   or §6 (a registry-attested one must be published by this registry);
341///   the served receipt must verify under RFC-ACDP-0010 §8 and equal the
342///   entry's under JCS; and the revocation must
343///   speak for the entry's `agent_id` (§7 producer scope).
344///
345/// `Ok(Rejected)` is a final verdict. `Err` means the entry could not be
346/// decided (a transient failure: network, rate-limited DID refresh) or
347/// `held` is for another context; leave the entry unprocessed and retry.
348pub async fn verify_change(
349    client: &RegistryClient,
350    resolver: &WebResolver,
351    change: &Change,
352    held: Option<HeldContext<'_>>,
353    policy: &VerificationPolicy,
354) -> Result<ChangeVerdict, AcdpError> {
355    if let Some(h) = held {
356        if &h.body.ctx_id != change.ctx_id() {
357            return Err(AcdpError::SchemaViolation(format!(
358                "held context {} is not the entry's {}",
359                h.body.ctx_id,
360                change.ctx_id()
361            )));
362        }
363    }
364    let outcome = match change.check_consistency() {
365        Err(e) => Err(e),
366        Ok(()) => match change.evidence() {
367            ChangeEvidence::Lifecycle {
368                event,
369                event_receipt,
370            } => {
371                verify_lifecycle(client, resolver, change, event, event_receipt, held, policy).await
372            }
373            ChangeEvidence::Superseded {
374                superseded_by,
375                registry_receipt,
376            } => {
377                verify_superseded(
378                    client,
379                    resolver,
380                    change,
381                    superseded_by,
382                    registry_receipt,
383                    policy,
384                )
385                .await
386            }
387            ChangeEvidence::KeyRevocation { registry_receipt } => {
388                verify_key_revocation(client, resolver, change, registry_receipt).await
389            }
390        },
391    };
392    match outcome {
393        Ok(effect) => Ok(ChangeVerdict::Verified(effect)),
394        Err(e) if e.is_transient() => Err(e),
395        Err(e) => Ok(ChangeVerdict::Rejected(e)),
396    }
397}
398
399fn reject(change: &Change, why: impl std::fmt::Display) -> AcdpError {
400    AcdpError::SchemaViolation(format!(
401        "change {} ({} {}): {why} (RFC-ACDP-0018 §7)",
402        change.seq(),
403        change.kind(),
404        change.ctx_id()
405    ))
406}
407
408fn serving_authority(client: &RegistryClient) -> Result<String, AcdpError> {
409    client
410        .authority()
411        .ok_or_else(|| AcdpError::SchemaViolation("registry client base URL has no host".into()))
412}
413
414/// The entry's context, producer and lineage, at this registry.
415fn check_body_binding(change: &Change, body: &Body, authority: &str) -> Result<(), AcdpError> {
416    if &body.ctx_id != change.ctx_id() {
417        return Err(AcdpError::ContextIdMismatch {
418            requested: change.ctx_id().as_str().to_owned(),
419            served: body.ctx_id.as_str().to_owned(),
420        });
421    }
422    if &body.agent_id != change.agent_id() {
423        return Err(reject(
424            change,
425            format!("the context's producer is {}", body.agent_id),
426        ));
427    }
428    if &body.lineage_id != change.lineage_id() {
429        return Err(reject(
430            change,
431            format!("the context's lineage is {}", body.lineage_id),
432        ));
433    }
434    if body.origin_registry != authority {
435        return Err(reject(
436            change,
437            format!(
438                "the context originates at {}, not this registry",
439                body.origin_registry
440            ),
441        ));
442    }
443    Ok(())
444}
445
446/// JCS byte equality, via the RFC-ACDP-0017 §10.1 hash (SHA-256 of JCS).
447fn jcs_equal(a: &Value, b: &Value) -> Result<bool, AcdpError> {
448    Ok(EventReceipt::event_hash_of_value(a)? == EventReceipt::event_hash_of_value(b)?)
449}
450
451async fn verify_lifecycle(
452    client: &RegistryClient,
453    resolver: &WebResolver,
454    change: &Change,
455    raw_event: &Value,
456    raw_receipt: &Value,
457    held: Option<HeldContext<'_>>,
458    policy: &VerificationPolicy,
459) -> Result<ChangeEffect, AcdpError> {
460    let authority = serving_authority(client)?;
461    let index = EventReceipt::from_value(raw_receipt)?.index;
462    let index_usize = usize::try_from(index).map_err(|_| reject(change, "index out of range"))?;
463    // §7 step 1: the held list may stand in for a retrieval only when it
464    // covers every earlier event.
465    let held = held.filter(|h| h.events.len() >= index_usize);
466    let retrieved;
467    let (body, list, source): (&Body, Vec<Value>, LifecycleSource) = match held {
468        Some(h) => {
469            let mut list = h.events.to_vec();
470            match list.get(index_usize) {
471                Some(have) if !jcs_equal(have, raw_event)? => {
472                    return Err(reject(
473                        change,
474                        format!("the held event at index {index} differs from the entry's"),
475                    ));
476                }
477                Some(_) => {}
478                None => list.push(raw_event.clone()),
479            }
480            (h.body, list, LifecycleSource::Held)
481        }
482        None => {
483            retrieved =
484                VerifiedContext::fetch_with_policy(client, resolver, change.ctx_id(), policy)
485                    .await?;
486            let list: Vec<Value> = retrieved
487                .registry_state()
488                .lifecycle_events
489                .iter()
490                .flatten()
491                .map(serde_json::to_value)
492                .collect::<Result<_, _>>()?;
493            match list.get(index_usize) {
494                Some(served) if jcs_equal(served, raw_event)? => {}
495                _ => {
496                    return Err(reject(
497                        change,
498                        format!(
499                            "the re-retrieved lifecycle_events[{index}] is not the entry's event"
500                        ),
501                    ))
502                }
503            }
504            (retrieved.body(), list, LifecycleSource::Retrieved)
505        }
506    };
507    check_body_binding(change, body, &authority)?;
508    let registry_did = acdp_did::web::authority_to_did_web(&authority);
509    let event = acdp_verify::verify_lifecycle_event(
510        raw_event,
511        change.ctx_id(),
512        &body.agent_id,
513        Some(&registry_did),
514        resolver,
515    )
516    .await?;
517    verify_event_receipt_value(
518        raw_receipt,
519        &authority,
520        change.ctx_id(),
521        raw_event,
522        index,
523        body.created_at,
524        resolver,
525        std::time::Instant::now(),
526    )
527    .await?;
528    // RFC-ACDP-0013 §7.1 over the full list. A held list was verified by
529    // the caller; a retrieved one is verified here for the events that
530    // decide the state.
531    let mut events = Vec::with_capacity(list.len());
532    for (i, raw) in list.iter().enumerate() {
533        let parsed = if i == index_usize {
534            event.clone()
535        } else if source == LifecycleSource::Retrieved {
536            let parsed = LifecycleEvent::from_value(raw)?;
537            if matches!(
538                parsed.event_type,
539                LifecycleEventType::Retracted | LifecycleEventType::Republished
540            ) {
541                acdp_verify::verify_lifecycle_event(
542                    raw,
543                    change.ctx_id(),
544                    &body.agent_id,
545                    Some(&registry_did),
546                    resolver,
547                )
548                .await?
549            } else {
550                parsed
551            }
552        } else {
553            LifecycleEvent::from_value(raw)?
554        };
555        events.push(parsed);
556    }
557    Ok(ChangeEffect::Lifecycle {
558        retracted: retraction_state(&events),
559        event,
560        source,
561    })
562}
563
564async fn verify_superseded(
565    client: &RegistryClient,
566    resolver: &WebResolver,
567    change: &Change,
568    superseded_by: &CtxId,
569    raw_receipt: &Value,
570    policy: &VerificationPolicy,
571) -> Result<ChangeEffect, AcdpError> {
572    let policy = VerificationPolicy {
573        receipts: ReceiptPolicy::Require,
574        ..policy.clone()
575    };
576    let authority = serving_authority(client)?;
577    let new_version =
578        VerifiedContext::fetch_with_policy(client, resolver, superseded_by, &policy).await?;
579    let body = new_version.body();
580    if body.origin_registry != authority {
581        return Err(reject(
582            change,
583            format!(
584                "{superseded_by} originates at {}, not this registry",
585                body.origin_registry
586            ),
587        ));
588    }
589    if body.supersedes.as_ref() != Some(change.ctx_id()) {
590        return Err(reject(
591            change,
592            format!("{superseded_by} does not supersede it"),
593        ));
594    }
595    if &body.lineage_id != change.lineage_id() {
596        return Err(reject(
597            change,
598            format!("{superseded_by} is in lineage {}", body.lineage_id),
599        ));
600    }
601    if &body.agent_id != change.agent_id() {
602        return Err(reject(
603            change,
604            format!(
605                "{superseded_by} has producer {} (RFC-ACDP-0003 §3.1)",
606                body.agent_id
607            ),
608        ));
609    }
610    let matches_entry = match (new_version.verified_receipt(), new_version.receipt()) {
611        (Some(_), Some(served)) => jcs_equal(served, raw_receipt)?,
612        _ => false,
613    };
614    if !matches_entry {
615        return Err(reject(
616            change,
617            "the entry's registry_receipt is not the new version's verified receipt",
618        ));
619    }
620    Ok(ChangeEffect::Superseded {
621        new_version: Box::new(new_version),
622    })
623}
624
625async fn verify_key_revocation(
626    client: &RegistryClient,
627    resolver: &WebResolver,
628    change: &Change,
629    raw_receipt: &Value,
630) -> Result<ChangeEffect, AcdpError> {
631    let authority = serving_authority(client)?;
632    let ctx = client.retrieve(change.ctx_id()).await?;
633    if &ctx.body.ctx_id != change.ctx_id() {
634        return Err(AcdpError::ContextIdMismatch {
635            requested: change.ctx_id().as_str().to_owned(),
636            served: ctx.body.ctx_id.as_str().to_owned(),
637        });
638    }
639    if &ctx.body.lineage_id != change.lineage_id() {
640        return Err(reject(
641            change,
642            format!("the revocation is in lineage {}", ctx.body.lineage_id),
643        ));
644    }
645    let since = std::time::Instant::now();
646    let revocation = verify_revocation_candidate(&ctx, client, resolver, since).await?;
647    if revocation.trust_class == RevocationTrustClass::RegistryAttested {
648        let caps = client.capabilities().await?;
649        revocation.cross_check_registry_binding(&authority, &caps.registry_did)?;
650    }
651    RegistryReceipt::from_value(raw_receipt)?;
652    let Some(served) = ctx.registry_receipt.as_ref() else {
653        return Err(reject(
654            change,
655            "the revocation is served without a registry_receipt",
656        ));
657    };
658    if !jcs_equal(served, raw_receipt)? {
659        return Err(reject(
660            change,
661            "the entry's registry_receipt is not the receipt served with the revocation",
662        ));
663    }
664    // The served receipt verifies under RFC-ACDP-0010 §8 on every arm
665    // (the producer-signed §5 path need not touch it), so JCS equality
666    // above makes the entry's copy verified too (RFC-ACDP-0018 §7 step 3).
667    // `content_hash` was recomputed and checked by the candidate pipeline.
668    let body = &ctx.body;
669    let producer_fp = acdp_crypto::fingerprint::fingerprint_for_key_id_since(
670        &body.signature.key_id,
671        &body.signature.algorithm,
672        resolver,
673        since,
674    )
675    .await?;
676    crate::receipt::verify_receipt_value_since(
677        served,
678        &body.ctx_id,
679        body,
680        &body.content_hash,
681        &producer_fp,
682        &authority,
683        resolver,
684        since,
685    )
686    .await?;
687    let revocation = revocation.sourced(RevocationSource::Discovered { authority });
688    if !revocation.speaks_for(change.agent_id()) {
689        return Err(reject(
690            change,
691            format!(
692                "the revocation does not speak for {} (RFC-ACDP-0014 §7 producer scope)",
693                change.agent_id()
694            ),
695        ));
696    }
697    Ok(ChangeEffect::KeyRevocation { revocation })
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    #[test]
705    fn since_query_values() {
706        assert_eq!(Since::Start.as_query(), None);
707        assert_eq!(Since::Head.as_query(), Some("head"));
708        assert_eq!(Since::Cursor("abc".into()).as_query(), Some("abc"));
709    }
710
711    #[test]
712    fn seen_changes_dedups_by_authority_and_seq() {
713        let mut seen = SeenChanges::new();
714        assert!(seen.first_time("a.example.com", 5));
715        assert!(!seen.first_time("a.example.com", 5));
716        assert!(!seen.is_new("a.example.com", 4));
717        assert!(seen.is_new("b.example.com", 5));
718        assert!(seen.first_time("a.example.com", 6));
719        seen.mark("a.example.com", 2);
720        assert!(!seen.is_new("a.example.com", 6));
721    }
722}