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(
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
414fn 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
446fn 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 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(®istry_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 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(®istry_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 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}