1use std::sync::Mutex;
15
16use acdp_primitives::error::AcdpError;
17use acdp_types::{
18 body::{Body, FullContext, RegistryState},
19 primitives::{AgentDid, CtxId, LineageId, Status, Visibility},
20 publish::{PublishRequest, PublishResponse},
21 search::{SearchParams, SearchResponse, SearchResult},
22};
23
24pub trait RegistryStore: Send + Sync {
30 fn put(&self, body: Body) -> Result<(), AcdpError>;
33
34 fn get(&self, ctx_id: &CtxId) -> Result<Option<FullContext>, AcdpError>;
36
37 fn lineage(&self, lineage_id: &LineageId) -> Result<Vec<FullContext>, AcdpError>;
39
40 fn current(&self, lineage_id: &LineageId) -> Result<Option<FullContext>, AcdpError>;
51
52 fn mark_superseded(&self, ctx_id: &CtxId) -> Result<(), AcdpError>;
54
55 fn first_version_ctx_id(&self, lineage_id: &LineageId) -> Result<Option<CtxId>, AcdpError>;
70
71 fn search(
88 &self,
89 params: &SearchParams,
90 requester: Option<&AgentDid>,
91 anonymous_public_reads: bool,
92 ) -> Result<SearchResponse, AcdpError>;
93
94 fn idempotency_lookup(
122 &self,
123 _agent_id: &AgentDid,
124 _key: &str,
125 ) -> Result<Option<IdempotencyRecord>, AcdpError> {
126 Ok(None)
127 }
128
129 fn idempotency_record(
133 &self,
134 _agent_id: &AgentDid,
135 _key: &str,
136 _hash: &acdp_types::primitives::ContentHash,
137 _response: &acdp_types::publish::PublishResponse,
138 _expires_at: chrono::DateTime<chrono::Utc>,
139 ) -> Result<(), AcdpError> {
140 Ok(())
141 }
142
143 fn idempotency_evict_expired(
146 &self,
147 _now: chrono::DateTime<chrono::Utc>,
148 ) -> Result<(), AcdpError> {
149 Ok(())
150 }
151
152 fn commit_publish(&self, commit: PublishCommit<'_>) -> Result<PublishCommitOutcome, AcdpError>;
175}
176
177pub struct PublishCommit<'a> {
186 pub req: &'a PublishRequest,
188 pub authority: &'a str,
191 pub idempotency: Option<PendingIdempotencyCommit<'a>>,
195 pub tenant: Option<&'a str>,
202 #[allow(clippy::type_complexity)]
212 pub receipt_minter:
213 Option<&'a (dyn Fn(&Body) -> Result<serde_json::Value, AcdpError> + Send + Sync)>,
214}
215
216pub struct PendingIdempotencyCommit<'a> {
218 pub key: &'a str,
220 pub ttl: chrono::Duration,
223}
224
225#[derive(Debug)]
227pub enum PublishCommitOutcome {
228 Inserted(PublishResponse),
231 IdempotentReplay(PublishResponse),
234}
235
236#[derive(Debug, Clone)]
239pub struct IdempotencyRecord {
240 pub content_hash: acdp_types::primitives::ContentHash,
243 pub response: acdp_types::publish::PublishResponse,
245 pub expires_at: chrono::DateTime<chrono::Utc>,
247}
248
249#[derive(Default)]
254pub struct InMemoryStore {
255 inner: Mutex<Inner>,
256}
257
258#[derive(Default)]
259struct Inner {
260 by_ctx: std::collections::BTreeMap<String, FullContext>,
263 lineages: std::collections::BTreeMap<String, Vec<String>>,
265 idempotency: std::collections::HashMap<(String, String), IdempotencyRecord>,
267}
268
269impl InMemoryStore {
270 pub fn new() -> Self {
272 Self::default()
273 }
274
275 fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
276 self.inner.lock().expect("InMemoryStore mutex poisoned")
277 }
278}
279
280pub(crate) fn project_status(
288 stored: &Status,
289 body: &Body,
290 now: chrono::DateTime<chrono::Utc>,
291) -> Status {
292 match stored {
293 Status::Active => match body.expires_at {
294 Some(exp) if exp <= now => Status::Expired,
295 _ => Status::Active,
296 },
297 other => other.clone(),
298 }
299}
300
301pub(crate) fn project_context(
304 mut ctx: FullContext,
305 now: chrono::DateTime<chrono::Utc>,
306) -> FullContext {
307 ctx.registry_state.status = project_status(&ctx.registry_state.status, &ctx.body, now);
308 ctx
309}
310
311fn can_surface_in_search(
324 body: &Body,
325 requester: Option<&AgentDid>,
326 anonymous_public_reads: bool,
327) -> bool {
328 match body.visibility {
329 Visibility::Public => anonymous_public_reads || requester.is_some(),
330 Visibility::Restricted => match requester {
331 None => false,
332 Some(r) => {
333 r == &body.agent_id
334 || body
335 .audience
336 .as_deref()
337 .is_some_and(|a| a.iter().any(|d| d == r))
338 }
339 },
340 Visibility::Private => requester == Some(&body.agent_id),
341 }
342}
343
344impl RegistryStore for InMemoryStore {
345 fn put(&self, body: Body) -> Result<(), AcdpError> {
346 let ctx_id = body.ctx_id.0.clone();
347 let lineage_id = body.lineage_id.0.clone();
348 let ctx = FullContext {
349 body,
350 registry_state: RegistryState {
351 status: Status::Active,
352 extensions: Default::default(),
353 },
354 registry_receipt: None,
355 lineage_head_receipt: None,
356 extensions: Default::default(),
357 };
358 let mut g = self.lock();
359 if g.by_ctx.contains_key(&ctx_id) {
360 return Err(AcdpError::SchemaViolation(format!(
361 "duplicate ctx_id '{ctx_id}' in store"
362 )));
363 }
364 g.by_ctx.insert(ctx_id.clone(), ctx);
365 g.lineages.entry(lineage_id).or_default().push(ctx_id);
366 Ok(())
367 }
368
369 fn get(&self, ctx_id: &CtxId) -> Result<Option<FullContext>, AcdpError> {
370 let now = chrono::Utc::now();
371 Ok(self
372 .lock()
373 .by_ctx
374 .get(ctx_id.as_str())
375 .cloned()
376 .map(|c| project_context(c, now)))
377 }
378
379 fn lineage(&self, lineage_id: &LineageId) -> Result<Vec<FullContext>, AcdpError> {
380 let now = chrono::Utc::now();
381 let g = self.lock();
382 let Some(ids) = g.lineages.get(lineage_id.as_str()) else {
383 return Ok(Vec::new());
384 };
385 Ok(ids
386 .iter()
387 .filter_map(|id| g.by_ctx.get(id).cloned().map(|c| project_context(c, now)))
388 .collect())
389 }
390
391 fn current(&self, lineage_id: &LineageId) -> Result<Option<FullContext>, AcdpError> {
392 let now = chrono::Utc::now();
393 let g = self.lock();
394 let Some(ids) = g.lineages.get(lineage_id.as_str()) else {
395 return Ok(None);
396 };
397 for id in ids.iter().rev() {
409 if let Some(ctx) = g.by_ctx.get(id) {
410 let projected = project_context(ctx.clone(), now);
411 if !matches!(projected.registry_state.status, Status::Superseded) {
412 return Ok(Some(projected));
413 }
414 }
415 }
416 Ok(None)
417 }
418
419 fn mark_superseded(&self, ctx_id: &CtxId) -> Result<(), AcdpError> {
420 let mut g = self.lock();
421 if let Some(ctx) = g.by_ctx.get_mut(ctx_id.as_str()) {
422 ctx.registry_state.status = Status::Superseded;
423 }
424 Ok(())
425 }
426
427 fn first_version_ctx_id(&self, lineage_id: &LineageId) -> Result<Option<CtxId>, AcdpError> {
428 let g = self.lock();
429 Ok(g.lineages
430 .get(lineage_id.as_str())
431 .and_then(|ids| ids.first().cloned())
432 .map(CtxId))
433 }
434
435 fn idempotency_lookup(
436 &self,
437 agent_id: &AgentDid,
438 key: &str,
439 ) -> Result<Option<IdempotencyRecord>, AcdpError> {
440 self.idempotency_evict_expired(chrono::Utc::now())?;
443 let g = self.lock();
444 Ok(g.idempotency
445 .get(&(agent_id.as_str().to_string(), key.to_string()))
446 .cloned())
447 }
448
449 fn idempotency_record(
450 &self,
451 agent_id: &AgentDid,
452 key: &str,
453 hash: &acdp_types::primitives::ContentHash,
454 response: &acdp_types::publish::PublishResponse,
455 expires_at: chrono::DateTime<chrono::Utc>,
456 ) -> Result<(), AcdpError> {
457 let mut g = self.lock();
458 g.idempotency.insert(
459 (agent_id.as_str().to_string(), key.to_string()),
460 IdempotencyRecord {
461 content_hash: hash.clone(),
462 response: response.clone(),
463 expires_at,
464 },
465 );
466 Ok(())
467 }
468
469 fn idempotency_evict_expired(
470 &self,
471 now: chrono::DateTime<chrono::Utc>,
472 ) -> Result<(), AcdpError> {
473 let mut g = self.lock();
474 g.idempotency.retain(|_, r| r.expires_at > now);
475 Ok(())
476 }
477
478 fn commit_publish(&self, commit: PublishCommit<'_>) -> Result<PublishCommitOutcome, AcdpError> {
479 use crate::registry::validator::assign_identifiers;
480
481 let PublishCommit {
482 req,
483 authority,
484 idempotency,
485 tenant: _,
488 receipt_minter,
489 } = commit;
490 let now = chrono::Utc::now();
491 let mut g = self.lock();
492
493 if let Some(idem) = &idempotency {
495 let idem_key = (req.agent_id.as_str().to_string(), idem.key.to_string());
496 if let Some(prior) = g.idempotency.get(&idem_key) {
497 if prior.expires_at > now {
498 return if prior.content_hash == req.content_hash {
499 Ok(PublishCommitOutcome::IdempotentReplay(
501 prior.response.clone(),
502 ))
503 } else {
504 Err(AcdpError::DuplicatePublish(format!(
506 "Idempotency-Key '{}' was previously used by '{}' \
507 with a different content_hash",
508 idem.key, req.agent_id
509 )))
510 };
511 }
512 }
514 }
515
516 let first_v1 = if let Some(prev) = &req.supersedes {
518 let prev_full = g.by_ctx.get(prev.as_str()).cloned().ok_or_else(|| {
519 AcdpError::SupersededTarget {
520 reason: acdp_primitives::error::SupersessionReason::NotFound,
521 message: format!("supersedes target '{prev}' not found in this registry"),
522 }
523 })?;
524
525 let is_owner = req.agent_id == prev_full.body.agent_id
535 || prev_full.body.contributors.contains(&req.agent_id);
536 if !is_owner {
537 return Err(AcdpError::SupersededTarget {
543 reason: acdp_primitives::error::SupersessionReason::NotFound,
544 message: format!("supersedes target '{prev}' not found in this registry"),
545 });
546 }
547
548 if let Some(declared) = &req.lineage_id {
550 if declared != &prev_full.body.lineage_id {
551 return Err(AcdpError::SupersededTarget {
552 reason: acdp_primitives::error::SupersessionReason::LineageMismatch,
553 message: format!(
554 "declared lineage_id '{declared}' ≠ predecessor's '{}'",
555 prev_full.body.lineage_id
556 ),
557 });
558 }
559 }
560 if req.version != prev_full.body.version + 1 {
562 return Err(AcdpError::SupersededTarget {
563 reason: acdp_primitives::error::SupersessionReason::VersionMismatch,
564 message: format!(
565 "version {} ≠ predecessor.version + 1 ({})",
566 req.version,
567 prev_full.body.version + 1
568 ),
569 });
570 }
571 if matches!(prev_full.registry_state.status, Status::Superseded) {
575 return Err(AcdpError::SupersededTarget {
576 reason: acdp_primitives::error::SupersessionReason::AlreadySuperseded,
577 message: format!("supersedes target '{prev}' has already been superseded"),
578 });
579 }
580
581 g.lineages
585 .get(prev_full.body.lineage_id.as_str())
586 .and_then(|ids| ids.first().cloned())
587 .map(CtxId)
588 } else {
589 None
590 };
591
592 let validated = crate::registry::validator::ValidatedPublish {
594 recomputed_hash: req.content_hash.clone(),
595 };
596 let (ctx_id, lineage_id) =
597 assign_identifiers(authority, &req.supersedes, first_v1.as_ref(), &validated)?;
598
599 let body =
605 Body::from_publish_request(req, ctx_id.clone(), lineage_id.clone(), authority, now);
606 let created_at = body.created_at;
607
608 let ctx_id_str = body.ctx_id.0.clone();
610 let lineage_id_str = body.lineage_id.0.clone();
611 if g.by_ctx.contains_key(&ctx_id_str) {
612 return Err(AcdpError::SchemaViolation(format!(
616 "ctx_id collision: '{ctx_id_str}' already exists"
617 )));
618 }
619 let registry_receipt = receipt_minter.map(|mint| mint(&body)).transpose()?;
623
624 let stored = FullContext {
625 body,
626 registry_state: RegistryState {
627 status: Status::Active,
628 extensions: Default::default(),
629 },
630 registry_receipt: registry_receipt.clone(),
631 lineage_head_receipt: None,
635 extensions: Default::default(),
636 };
637 g.by_ctx.insert(ctx_id_str.clone(), stored);
638 g.lineages
639 .entry(lineage_id_str)
640 .or_default()
641 .push(ctx_id_str);
642
643 if let Some(prev) = &req.supersedes {
645 if let Some(prev_ctx) = g.by_ctx.get_mut(prev.as_str()) {
646 prev_ctx.registry_state.status = Status::Superseded;
647 }
648 }
649
650 let response = PublishResponse {
651 ctx_id,
652 lineage_id,
653 version: req.version,
654 created_at,
655 status: Status::Active,
656 registry_receipt,
657 };
658
659 if let Some(idem) = idempotency {
661 let expires_at = now + idem.ttl;
662 g.idempotency.insert(
663 (req.agent_id.as_str().to_string(), idem.key.to_string()),
664 IdempotencyRecord {
665 content_hash: req.content_hash.clone(),
666 response: response.clone(),
667 expires_at,
668 },
669 );
670 }
671
672 Ok(PublishCommitOutcome::Inserted(response))
673 }
674
675 fn search(
676 &self,
677 params: &SearchParams,
678 requester: Option<&AgentDid>,
679 anonymous_public_reads: bool,
680 ) -> Result<SearchResponse, AcdpError> {
681 let g = self.lock();
682 let now = chrono::Utc::now();
683
684 let q_lower = params.q.as_deref().map(str::to_lowercase);
685 let domain = params.domain.as_deref();
686 let agent = params.agent_id.as_deref();
687 let context_type = params.context_type.as_deref();
688 let derived_from = params.derived_from.as_deref();
689 let schema_uri = params.schema_uri.as_deref();
690 let tags: Option<Vec<&str>> = params.tags.as_deref().map(|s| {
691 s.split(',')
692 .map(str::trim)
693 .filter(|t| !t.is_empty())
694 .collect()
695 });
696
697 let created_after = parse_opt_rfc3339(¶ms.created_after)?;
700 let created_before = parse_opt_rfc3339(¶ms.created_before)?;
701 let dp_start_after = parse_opt_rfc3339(¶ms.data_period_start_after)?;
702 let dp_end_before = parse_opt_rfc3339(¶ms.data_period_end_before)?;
703 let expires_after = parse_opt_rfc3339(¶ms.expires_after)?;
704 let expires_before = parse_opt_rfc3339(¶ms.expires_before)?;
705
706 let mut matches: Vec<&FullContext> = g
707 .by_ctx
708 .values()
709 .filter(|ctx| {
710 let body = &ctx.body;
711
712 if !can_surface_in_search(body, requester, anonymous_public_reads) {
716 return false;
717 }
718
719 if let Some(q) = &q_lower {
720 let haystack = format!(
721 "{} {} {} {} {} {}",
722 body.title,
723 body.description.as_deref().unwrap_or(""),
724 body.summary.as_deref().unwrap_or(""),
725 body.domain.as_deref().unwrap_or(""),
726 body.agent_id.as_str(),
727 body.tags.as_ref().map(|t| t.join(" ")).unwrap_or_default(),
728 )
729 .to_lowercase();
730 if !haystack.contains(q) {
731 return false;
732 }
733 }
734 if let Some(d) = domain {
735 if body.domain.as_deref() != Some(d) {
736 return false;
737 }
738 }
739 if let Some(a) = agent {
740 if body.agent_id.as_str() != a {
741 return false;
742 }
743 }
744 if let Some(t) = context_type {
745 let body_type = serde_json::to_value(&body.context_type)
746 .ok()
747 .and_then(|v| v.as_str().map(str::to_string))
748 .unwrap_or_default();
749 if body_type != t {
750 return false;
751 }
752 }
753 if let Some(df) = derived_from {
754 if !body.derived_from.iter().any(|c| c.as_str() == df) {
755 return false;
756 }
757 }
758 if let Some(req_tags) = &tags {
759 let body_tags = body.tags.as_deref().unwrap_or(&[]);
760 if !req_tags.iter().all(|t| body_tags.iter().any(|bt| bt == t)) {
761 return false;
762 }
763 }
764 if let Some(uri) = schema_uri {
765 if body.schema_uri.as_deref() != Some(uri) {
766 return false;
767 }
768 }
769 if let Some(after) = created_after {
770 if body.created_at < after {
771 return false;
772 }
773 }
774 if let Some(before) = created_before {
775 if body.created_at > before {
776 return false;
777 }
778 }
779 if let Some(after) = dp_start_after {
780 match &body.data_period {
781 Some(p) if p.start >= after => {}
782 _ => return false,
783 }
784 }
785 if let Some(before) = dp_end_before {
786 match &body.data_period {
787 Some(p) if p.end <= before => {}
788 _ => return false,
789 }
790 }
791 if let Some(after) = expires_after {
792 match body.expires_at {
793 Some(e) if e >= after => {}
794 _ => return false,
795 }
796 }
797 if let Some(before) = expires_before {
798 match body.expires_at {
799 Some(e) if e <= before => {}
800 _ => return false,
801 }
802 }
803 let want_status = params.status.as_deref().unwrap_or("active");
807 let effective = project_status(&ctx.registry_state.status, body, now);
808 if effective.as_str() != want_status {
809 return false;
810 }
811 true
812 })
813 .collect();
814
815 matches.sort_by(|a, b| {
818 b.body
819 .created_at
820 .cmp(&a.body.created_at)
821 .then_with(|| a.body.ctx_id.as_str().cmp(b.body.ctx_id.as_str()))
822 });
823
824 let total_estimate = Some(matches.len() as u64);
830
831 let cursor_anchor = params
835 .cursor
836 .as_deref()
837 .map(decode_cursor)
838 .transpose()?
839 .flatten();
840 if let Some((anchor_ms, anchor_id)) = &cursor_anchor {
841 matches.retain(|c| {
842 let ms = c.body.created_at.timestamp_millis();
843 ms < *anchor_ms || (ms == *anchor_ms && c.body.ctx_id.as_str() > anchor_id.as_str())
844 });
845 }
846
847 let limit = params.limit.unwrap_or(50).clamp(1, 100) as usize;
852 let next_cursor = if matches.len() > limit {
853 matches.get(limit - 1).map(|c| {
854 encode_cursor(c.body.created_at.timestamp_millis(), c.body.ctx_id.as_str())
855 })
856 } else {
857 None
858 };
859
860 let projected: Vec<SearchResult> = matches
861 .iter()
862 .take(limit)
863 .map(|ctx| SearchResult {
864 ctx_id: ctx.body.ctx_id.clone(),
865 lineage_id: ctx.body.lineage_id.clone(),
866 agent_id: ctx.body.agent_id.clone(),
867 title: ctx.body.title.clone(),
868 summary: ctx.body.summary.clone(),
869 context_type: ctx.body.context_type.clone(),
870 domain: ctx.body.domain.clone(),
871 created_at: ctx.body.created_at,
872 status: project_status(&ctx.registry_state.status, &ctx.body, now),
873 visibility: Some(ctx.body.visibility.clone()),
879 })
880 .collect();
881
882 Ok(SearchResponse {
883 matches: projected,
884 total_estimate,
885 next_cursor,
886 })
887 }
888}
889
890fn parse_opt_rfc3339(
893 s: &Option<String>,
894) -> Result<Option<chrono::DateTime<chrono::Utc>>, AcdpError> {
895 let Some(raw) = s.as_deref() else {
896 return Ok(None);
897 };
898 let dt = chrono::DateTime::parse_from_rfc3339(raw)
899 .map_err(|e| AcdpError::SchemaViolation(format!("malformed datetime '{raw}': {e}")))?;
900 Ok(Some(dt.with_timezone(&chrono::Utc)))
901}
902
903const CURSOR_TTL: chrono::Duration = chrono::Duration::seconds(3600);
907
908fn encode_cursor(created_at_ms: i64, ctx_id: &str) -> String {
916 use base64::{engine::general_purpose::STANDARD, Engine};
917 let mint_ms = chrono::Utc::now().timestamp_millis();
918 STANDARD.encode(format!("{mint_ms}:{created_at_ms}:{ctx_id}"))
919}
920
921fn decode_cursor(s: &str) -> Result<Option<(i64, String)>, AcdpError> {
922 use base64::{engine::general_purpose::STANDARD, Engine};
923 let bytes = STANDARD
924 .decode(s)
925 .map_err(|_| AcdpError::InvalidCursor("cursor is not valid base64".into()))?;
926 let decoded = String::from_utf8(bytes)
927 .map_err(|_| AcdpError::InvalidCursor("cursor is not utf-8".into()))?;
928 let mut parts = decoded.splitn(3, ':');
931 let mint_str = parts
932 .next()
933 .ok_or_else(|| AcdpError::InvalidCursor("cursor missing mint timestamp".into()))?;
934 let anchor_str = parts
935 .next()
936 .ok_or_else(|| AcdpError::InvalidCursor("cursor missing anchor timestamp".into()))?;
937 let ctx_id = parts
938 .next()
939 .ok_or_else(|| AcdpError::InvalidCursor("cursor missing ctx_id".into()))?;
940 let mint_ms: i64 = mint_str
941 .parse()
942 .map_err(|_| AcdpError::InvalidCursor("cursor mint millis is not an integer".into()))?;
943 let anchor_ms: i64 = anchor_str
944 .parse()
945 .map_err(|_| AcdpError::InvalidCursor("cursor anchor millis is not an integer".into()))?;
946
947 let now = chrono::Utc::now().timestamp_millis();
952 let age_ms = now.saturating_sub(mint_ms);
953 if age_ms > CURSOR_TTL.num_milliseconds() {
954 return Err(AcdpError::CursorExpired);
959 }
960 Ok(Some((anchor_ms, ctx_id.to_string())))
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use acdp_crypto::SigningKey;
967 use acdp_producer::Producer;
968 use acdp_types::body::{DataPeriod, Signature};
969 use acdp_types::primitives::{AgentDid, ContentHash, ContextType, Visibility};
970 use chrono::Utc;
971
972 fn fake_body(ctx_id: &str, lineage_id: &str, title: &str) -> Body {
973 Body {
974 ctx_id: CtxId(ctx_id.into()),
975 lineage_id: LineageId(lineage_id.into()),
976 origin_registry: "registry.example.com".into(),
977 created_at: Utc::now(),
978 content_hash: ContentHash("sha256:0".into()),
979 signature: Signature {
980 algorithm: "ed25519".into(),
981 key_id: "did:web:agents.example.com:test#key-1".into(),
982 value: "A".repeat(88),
983 },
984 version: 1,
985 supersedes: None,
986 agent_id: AgentDid::new("did:web:agents.example.com:test"),
987 contributors: vec![],
988 title: title.into(),
989 context_type: ContextType::DataSnapshot,
990 data_refs: vec![],
991 derived_from: vec![],
992 visibility: Visibility::Public,
993 audience: None,
994 acdp_version: None,
995 description: None,
996 summary: None,
997 tags: None,
998 domain: None,
999 expires_at: None,
1000 data_period: None,
1001 metadata: None,
1002 schema_uri: None,
1003 extensions: Default::default(),
1004 }
1005 }
1006
1007 #[test]
1008 fn put_get_round_trip() {
1009 let s = InMemoryStore::new();
1010 let id = "acdp://r/12345678-1234-4321-8123-123456781234";
1011 let lin = "lin:sha256:1111111111111111111111111111111111111111111111111111111111111111";
1012 s.put(fake_body(id, lin, "A")).unwrap();
1013 let got = s.get(&CtxId(id.into())).unwrap().unwrap();
1014 assert_eq!(got.body.title, "A");
1015 assert!(matches!(got.registry_state.status, Status::Active));
1016 }
1017
1018 #[test]
1019 fn lineage_orders_by_publish_order() {
1020 let s = InMemoryStore::new();
1021 let lin = "lin:sha256:2222222222222222222222222222222222222222222222222222222222222222";
1022 let v1 = "acdp://r/12345678-1234-4321-8123-000000000001";
1023 let v2 = "acdp://r/12345678-1234-4321-8123-000000000002";
1024 s.put(fake_body(v1, lin, "v1")).unwrap();
1025 s.put(fake_body(v2, lin, "v2")).unwrap();
1026 let lineage = s.lineage(&LineageId(lin.into())).unwrap();
1027 assert_eq!(lineage.len(), 2);
1028 assert_eq!(lineage[0].body.title, "v1");
1029 assert_eq!(lineage[1].body.title, "v2");
1030 }
1031
1032 #[test]
1033 fn supersession_marks_predecessor() {
1034 let s = InMemoryStore::new();
1035 let lin = "lin:sha256:3333333333333333333333333333333333333333333333333333333333333333";
1036 let v1 = "acdp://r/12345678-1234-4321-8123-000000000003";
1037 s.put(fake_body(v1, lin, "v1")).unwrap();
1038 s.mark_superseded(&CtxId(v1.into())).unwrap();
1039 let got = s.get(&CtxId(v1.into())).unwrap().unwrap();
1040 assert!(matches!(got.registry_state.status, Status::Superseded));
1041 }
1042
1043 fn expired_body(
1046 ctx_id: &str,
1047 lineage_id: &str,
1048 title: &str,
1049 expires_at: chrono::DateTime<chrono::Utc>,
1050 ) -> Body {
1051 let mut b = fake_body(ctx_id, lineage_id, title);
1052 b.expires_at = Some(expires_at);
1053 b
1054 }
1055
1056 #[test]
1057 fn get_projects_active_to_expired_when_past_expires_at() {
1058 use chrono::Duration;
1059 let s = InMemoryStore::new();
1060 let lin = "lin:sha256:5555555555555555555555555555555555555555555555555555555555555555";
1061 let id = "acdp://r/12345678-1234-4321-8123-000000000006";
1062 s.put(expired_body(
1063 id,
1064 lin,
1065 "old",
1066 chrono::Utc::now() - Duration::hours(1),
1067 ))
1068 .unwrap();
1069 let got = s.get(&CtxId(id.into())).unwrap().unwrap();
1070 assert!(
1071 matches!(got.registry_state.status, Status::Expired),
1072 "expected Status::Expired projection, got {:?}",
1073 got.registry_state.status
1074 );
1075 }
1076
1077 #[test]
1078 fn get_keeps_active_when_expires_at_in_future() {
1079 use chrono::Duration;
1080 let s = InMemoryStore::new();
1081 let lin = "lin:sha256:6666666666666666666666666666666666666666666666666666666666666666";
1082 let id = "acdp://r/12345678-1234-4321-8123-000000000007";
1083 s.put(expired_body(
1084 id,
1085 lin,
1086 "fresh",
1087 chrono::Utc::now() + Duration::hours(1),
1088 ))
1089 .unwrap();
1090 let got = s.get(&CtxId(id.into())).unwrap().unwrap();
1091 assert!(matches!(got.registry_state.status, Status::Active));
1092 }
1093
1094 #[test]
1095 fn search_status_active_filters_out_expired() {
1096 use chrono::Duration;
1097 let s = InMemoryStore::new();
1098 let lin = "lin:sha256:7777777777777777777777777777777777777777777777777777777777777777";
1099 let id = "acdp://r/12345678-1234-4321-8123-000000000008";
1100 s.put(expired_body(
1101 id,
1102 lin,
1103 "old",
1104 chrono::Utc::now() - Duration::hours(1),
1105 ))
1106 .unwrap();
1107 let resp = s.search(&SearchParams::default(), None, true).unwrap();
1108 assert!(
1109 resp.matches.is_empty(),
1110 "expired must not surface under status=active default"
1111 );
1112 let resp = s
1114 .search(
1115 &SearchParams {
1116 status: Some("expired".into()),
1117 ..Default::default()
1118 },
1119 None,
1120 true,
1121 )
1122 .unwrap();
1123 assert_eq!(resp.matches.len(), 1);
1124 }
1125
1126 #[test]
1128 fn search_filters_by_created_after() {
1129 let s = InMemoryStore::new();
1130 let lin = "lin:sha256:8888888888888888888888888888888888888888888888888888888888888888";
1131 let mut body = fake_body(
1132 "acdp://r/12345678-1234-4321-8123-000000000009",
1133 lin,
1134 "match",
1135 );
1136 body.created_at = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00.000Z")
1137 .unwrap()
1138 .with_timezone(&chrono::Utc);
1139 s.put(body).unwrap();
1140 let resp = s
1142 .search(
1143 &SearchParams {
1144 created_after: Some("2026-02-01T00:00:00.000Z".into()),
1145 ..Default::default()
1146 },
1147 None,
1148 true,
1149 )
1150 .unwrap();
1151 assert_eq!(resp.matches.len(), 0);
1152 let resp = s
1154 .search(
1155 &SearchParams {
1156 created_after: Some("2025-12-01T00:00:00.000Z".into()),
1157 ..Default::default()
1158 },
1159 None,
1160 true,
1161 )
1162 .unwrap();
1163 assert_eq!(resp.matches.len(), 1);
1164 }
1165
1166 #[test]
1167 fn search_invalid_rfc3339_filter_rejected() {
1168 let s = InMemoryStore::new();
1169 let err = s
1170 .search(
1171 &SearchParams {
1172 created_after: Some("not-a-date".into()),
1173 ..Default::default()
1174 },
1175 None,
1176 true,
1177 )
1178 .unwrap_err();
1179 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1180 }
1181
1182 #[test]
1184 fn search_cursor_pages_results() {
1185 let s = InMemoryStore::new();
1186 let lin = "lin:sha256:9999999999999999999999999999999999999999999999999999999999999999";
1187 let base = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00.000Z")
1189 .unwrap()
1190 .with_timezone(&chrono::Utc);
1191 for i in 0..5u8 {
1192 let mut body = fake_body(
1193 &format!("acdp://r/12345678-1234-4321-8123-00000000010{i}"),
1194 lin,
1195 "match",
1196 );
1197 body.created_at = base + chrono::Duration::minutes(i as i64);
1198 s.put(body).unwrap();
1199 }
1200 let p1 = s
1201 .search(
1202 &SearchParams {
1203 limit: Some(2),
1204 ..Default::default()
1205 },
1206 None,
1207 true,
1208 )
1209 .unwrap();
1210 assert_eq!(p1.matches.len(), 2);
1211 let cursor = p1.next_cursor.expect("page 1 should carry a cursor");
1212 let p2 = s
1213 .search(
1214 &SearchParams {
1215 limit: Some(2),
1216 cursor: Some(cursor.clone()),
1217 ..Default::default()
1218 },
1219 None,
1220 true,
1221 )
1222 .unwrap();
1223 assert_eq!(p2.matches.len(), 2);
1224 for r in &p2.matches {
1226 assert!(
1227 !p1.matches.iter().any(|q| q.ctx_id == r.ctx_id),
1228 "page 2 overlapped page 1"
1229 );
1230 }
1231 assert_eq!(
1235 p1.total_estimate, p2.total_estimate,
1236 "total_estimate MUST be stable across pages (BUG-08); \
1237 p1={:?}, p2={:?}",
1238 p1.total_estimate, p2.total_estimate
1239 );
1240 assert_eq!(
1241 p1.total_estimate,
1242 Some(5),
1243 "total_estimate MUST reflect total matches across all pages, got {:?}",
1244 p1.total_estimate
1245 );
1246 }
1247
1248 #[test]
1249 fn search_limit_zero_does_not_underflow() {
1250 let s = InMemoryStore::new();
1253 let lin = "lin:sha256:8888888888888888888888888888888888888888888888888888888888888888";
1254 for i in 0..3u8 {
1255 let body = fake_body(
1256 &format!("acdp://r/12345678-1234-4321-8123-00000000020{i}"),
1257 lin,
1258 "match",
1259 );
1260 s.put(body).unwrap();
1261 }
1262 let page = s
1263 .search(
1264 &SearchParams {
1265 limit: Some(0),
1266 ..Default::default()
1267 },
1268 None,
1269 true,
1270 )
1271 .expect("limit=0 must not panic or error");
1272 assert_eq!(page.matches.len(), 1);
1274 assert!(page.next_cursor.is_some());
1275 }
1276
1277 #[test]
1278 fn search_malformed_cursor_rejected() {
1279 let s = InMemoryStore::new();
1280 let err = s
1281 .search(
1282 &SearchParams {
1283 cursor: Some("not_base64!@#".into()),
1284 ..Default::default()
1285 },
1286 None,
1287 true,
1288 )
1289 .unwrap_err();
1290 assert!(matches!(err, AcdpError::InvalidCursor(_)));
1291 }
1292
1293 #[test]
1298 fn search_aged_cursor_rejected_as_cursor_expired() {
1299 use base64::{engine::general_purpose::STANDARD, Engine};
1300 let s = InMemoryStore::new();
1301 let stale_mint_ms = chrono::Utc::now().timestamp_millis() - 7200 * 1000;
1303 let aged = STANDARD.encode(format!(
1304 "{stale_mint_ms}:0:acdp://r/12345678-1234-4321-8123-1234567812aa"
1305 ));
1306 let err = s
1307 .search(
1308 &SearchParams {
1309 cursor: Some(aged),
1310 ..Default::default()
1311 },
1312 None,
1313 true,
1314 )
1315 .unwrap_err();
1316 assert!(
1317 matches!(err, AcdpError::CursorExpired),
1318 "expired cursor MUST surface CursorExpired, got {err:?}"
1319 );
1320 }
1321
1322 #[test]
1323 fn search_filters_by_status_default_active() {
1324 let s = InMemoryStore::new();
1325 let lin = "lin:sha256:4444444444444444444444444444444444444444444444444444444444444444";
1326 let v1 = "acdp://r/12345678-1234-4321-8123-000000000004";
1327 let v2 = "acdp://r/12345678-1234-4321-8123-000000000005";
1328 s.put(fake_body(v1, lin, "old")).unwrap();
1329 s.put(fake_body(v2, lin, "new")).unwrap();
1330 s.mark_superseded(&CtxId(v1.into())).unwrap();
1331 let resp = s
1332 .search(
1333 &SearchParams {
1334 q: Some("old".into()),
1335 ..Default::default()
1336 },
1337 None,
1338 true,
1339 )
1340 .unwrap();
1341 assert_eq!(resp.matches.len(), 0);
1343 let resp = s
1344 .search(
1345 &SearchParams {
1346 q: Some("new".into()),
1347 ..Default::default()
1348 },
1349 None,
1350 true,
1351 )
1352 .unwrap();
1353 assert_eq!(resp.matches.len(), 1);
1354 }
1355
1356 #[test]
1360 fn store_round_trip_from_real_publish_request() {
1361 use crate::registry::server::RegistryServer;
1362 use acdp_types::capabilities::{CapabilitiesDocument, Limits};
1363
1364 let key = SigningKey::from_bytes(&[7u8; 32]);
1365 let p = Producer::new(
1366 key,
1367 AgentDid::new("did:web:agents.example.com:test"),
1368 "did:web:agents.example.com:test#key-1",
1369 );
1370 let req = p
1371 .publish_request()
1372 .title("hello")
1373 .context_type(ContextType::DataSnapshot)
1374 .visibility(Visibility::Public)
1375 .build()
1376 .unwrap();
1377
1378 let caps = CapabilitiesDocument {
1379 acdp_version: "0.1.0".into(),
1380 registry_did: "did:web:registry.example.com".into(),
1381 supported_signature_algorithms: vec!["ed25519".into()],
1382 supported_did_methods: vec!["did:web".into()],
1383 profiles: vec!["acdp-registry-core".into()],
1384 limits: Limits {
1385 max_payload_bytes: 1_048_576,
1386 max_embedded_bytes: 65_536,
1387 idempotency_key_ttl_seconds: None,
1388 max_publish_per_minute: None,
1389 },
1390 read_authentication_methods: vec![],
1391 anonymous_public_reads: true,
1392 supports_idempotency_key: false,
1393 extensions: Default::default(),
1394 };
1395
1396 let server = RegistryServer::new(InMemoryStore::new(), caps, "registry.example.com");
1397 let resp = server.publish_unverified_for_tests(&req).unwrap();
1398 assert_eq!(resp.version, 1);
1399 let ctx = server.retrieve(&resp.ctx_id, None).unwrap().unwrap();
1400 assert_eq!(ctx.body.title, "hello");
1401
1402 let _: Option<DataPeriod> = ctx.body.data_period.clone();
1404 }
1405}