1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
82pub enum Since {
83 Start,
85 Head,
89 Cursor(String),
91}
92
93impl Since {
94 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#[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 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 pub fn since(mut self, since: Since) -> Self {
133 self.since = since;
134 self
135 }
136
137 pub fn limit(mut self, limit: u32) -> Self {
139 self.limit = Some(limit);
140 self
141 }
142
143 pub fn position(&self) -> &Since {
145 &self.since
146 }
147
148 pub fn filter(&self) -> &WatchFilter {
150 &self.filter
151 }
152
153 pub async fn fetch(&self) -> Result<ChangesResponse, AcdpError> {
155 self.client
156 .changes(&self.since, &self.filter, self.limit)
157 .await
158 }
159
160 pub fn advance(&mut self, page: &ChangesResponse) {
163 self.since = Since::Cursor(page.next_cursor.clone());
164 }
165
166 pub fn reset_to_head(&mut self) {
169 self.since = Since::Head;
170 }
171
172 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
200pub struct SeenChanges {
201 high_water: BTreeMap<String, u64>,
202}
203
204impl SeenChanges {
205 pub fn new() -> Self {
207 Self::default()
208 }
209
210 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 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 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#[derive(Debug, Clone, Copy)]
238pub struct HeldContext<'a> {
239 pub body: &'a Body,
241 pub events: &'a [Value],
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249#[non_exhaustive]
250pub enum LifecycleSource {
251 Held,
253 Retrieved,
255}
256
257#[derive(Debug)]
260#[non_exhaustive]
261pub enum ChangeEffect {
262 Lifecycle {
264 event: LifecycleEvent,
266 retracted: bool,
269 source: LifecycleSource,
271 },
272 Superseded {
275 new_version: Box<VerifiedContext>,
277 },
278 KeyRevocation {
282 revocation: SourcedRevocation,
284 },
285}
286
287#[derive(Debug)]
289#[non_exhaustive]
290pub enum ChangeVerdict {
291 Verified(ChangeEffect),
293 Rejected(AcdpError),
297}
298
299impl ChangeVerdict {
300 pub fn effect(&self) -> Option<&ChangeEffect> {
302 match self {
303 ChangeVerdict::Verified(e) => Some(e),
304 ChangeVerdict::Rejected(_) => None,
305 }
306 }
307
308 pub fn rejection(&self) -> Option<&AcdpError> {
310 match self {
311 ChangeVerdict::Verified(_) => None,
312 ChangeVerdict::Rejected(e) => Some(e),
313 }
314 }
315}
316
317pub 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
413fn 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
445fn 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 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(®istry_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 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(®istry_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 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}