acdp_server/registry/store.rs
1//! Registry persistence abstraction (feature = "server").
2//!
3//! [`RegistryStore`] is the minimal contract a registry implementation
4//! must satisfy: store an immutable [`Body`] under a registry-assigned
5//! [`CtxId`], track the parent lineage, mark predecessors superseded,
6//! and project search/lineage queries. The trait is synchronous and
7//! object-safe so a [`RegistryServer`](super::server::RegistryServer)
8//! can be parameterised over any backend (in-memory for tests, SQLite
9//! for production, etc.) without an async runtime dependency.
10//!
11//! [`InMemoryStore`] is the reference implementation used by the
12//! integration tests and intended as a drop-in for prototyping.
13
14use std::sync::Mutex;
15
16use acdp_primitives::error::AcdpError;
17use acdp_types::{
18 body::{Body, FullContext, RegistryState},
19 lifecycle::{LifecycleEvent, LifecycleEventType},
20 primitives::{AgentDid, CtxId, LineageId, Status, Visibility},
21 publish::{PublishRequest, PublishResponse},
22 search::{SearchParams, SearchResponse, SearchResult},
23};
24
25/// Abstract registry persistence backend.
26///
27/// Synchronous — the in-memory implementation is mutex-guarded; async
28/// backends should wrap blocking calls with `spawn_blocking` at the
29/// HTTP boundary.
30pub trait RegistryStore: Send + Sync {
31 /// Persist a freshly-assigned context. `body.ctx_id` and
32 /// `body.lineage_id` are already populated by the server.
33 fn put(&self, body: Body) -> Result<(), AcdpError>;
34
35 /// Retrieve a stored context by `ctx_id`.
36 fn get(&self, ctx_id: &CtxId) -> Result<Option<FullContext>, AcdpError>;
37
38 /// All contexts in a lineage, oldest first.
39 fn lineage(&self, lineage_id: &LineageId) -> Result<Vec<FullContext>, AcdpError>;
40
41 /// Returns the newest version of a lineage that is **neither
42 /// [`Status::Superseded`] nor [`Status::Retracted`]** — either
43 /// [`Status::Active`] or [`Status::Expired`] (an
44 /// expired-but-unreplaced body is still the latest version, and
45 /// callers need to see it to know it has lapsed; a retracted one is
46 /// never a head, RFC-ACDP-0013 §8.3).
47 ///
48 /// Returns `Ok(None)` when the lineage is unknown or every version is
49 /// superseded or retracted (RFC-ACDP-0004 §5.2: "if no such version
50 /// exists, returns not_found" — fixtures `ret-002` / `lc-003`).
51 /// Visibility rules are NOT applied here — filter at the server
52 /// layer via [`crate::registry::server::RegistryServer::current`].
53 fn current(&self, lineage_id: &LineageId) -> Result<Option<FullContext>, AcdpError>;
54
55 /// Mark `ctx_id`'s registry state as `superseded`. Idempotent.
56 fn mark_superseded(&self, ctx_id: &CtxId) -> Result<(), AcdpError>;
57
58 /// First-version `ctx_id` for a lineage, used to derive the
59 /// lineage_id of a supersession publish per RFC-ACDP-0001 §5.6.
60 ///
61 /// LINEAGE ANCHORING (WS-D3). Implementations SHOULD answer this
62 /// (and the supersession checks in [`Self::commit_publish`]) from
63 /// **persisted rows** — the immediate predecessor's stored
64 /// `lineage_id`/`version` and a lineage index — NOT by re-walking
65 /// the full `supersedes` chain at publish time. A registry's own
66 /// storage is trusted; anchoring removes the
67 /// `lineage_walk_failed` liveness failure where a v(N+1) publish is
68 /// rejected because some deep intermediate is unretrievable even
69 /// though the immediate predecessor exists. Reserve the full
70 /// chain walk for offline integrity audits, off the publish path.
71 /// (`InMemoryStore` implements exactly this pattern.)
72 fn first_version_ctx_id(&self, lineage_id: &LineageId) -> Result<Option<CtxId>, AcdpError>;
73
74 /// Keyword/filter search. Implementations MUST apply the RFC-ACDP-0008
75 /// §4.5 search-disclosure rules using `requester`:
76 ///
77 /// | Visibility | Surfaces in search to |
78 /// |--------------|----------------------------------------------|
79 /// | `public` | anyone |
80 /// | `restricted` | producer (`agent_id`) **or** any DID in `audience` |
81 /// | `private` | producer (`agent_id`) only — audience members must already know the ctx_id |
82 ///
83 /// `requester == None` represents an anonymous caller. Public
84 /// contexts surface only when `anonymous_public_reads` is true (the
85 /// capability flag from [`RegistryServer`](super::server::RegistryServer));
86 /// the store implements the same predicate as `RegistryServer::retrieve`
87 /// so the two endpoints stay symmetric (RFC-ACDP-0008 §4.5).
88 ///
89 /// Projection follows RFC-ACDP-0005 §2.2 `match_summary`.
90 fn search(
91 &self,
92 params: &SearchParams,
93 requester: Option<&AgentDid>,
94 anonymous_public_reads: bool,
95 ) -> Result<SearchResponse, AcdpError>;
96
97 // ── Idempotency (RFC-ACDP-0003 §6) ─────────────────────────────────
98 //
99 // Stores supporting the `idempotency_key` capability MUST implement
100 // these three methods. The default impls treat the store as
101 // non-idempotent: lookup always returns `None`, record is a no-op,
102 // evict is a no-op. A `RegistryServer` configured with
103 // `caps.supports_idempotency_key = false` MUST never call them
104 // (RFC-ACDP-0007 §3.2).
105 //
106 // ATOMICITY CONTRACT (WS-D4). Keys are scoped per `agent_id`: two
107 // different agents using the same key never interact. The
108 // idempotency record and the body persistence MUST commit
109 // atomically (single transaction / compare-and-swap / one lock —
110 // see `commit_publish`); a backend that cannot provide this (e.g.
111 // an eventually-consistent store) MUST NOT be paired with
112 // `supports_idempotency_key: true`, because concurrent identical-key
113 // publishes could mint two ctx_ids and silently defeat the
114 // guarantee the capability advertises. Durable backends should
115 // enforce a UNIQUE constraint on `(agent_id, idempotency_key)` and
116 // insert it in the same transaction as the context row.
117
118 /// Look up a prior publish record for `(agent_id, key)`.
119 ///
120 /// Returns `Some((content_hash, response))` if a record exists and
121 /// has not expired. Scoping by `agent_id` prevents a malicious
122 /// producer from poisoning another producer's key namespace
123 /// (RFC-ACDP-0003 §6 — idem-004 fixture).
124 fn idempotency_lookup(
125 &self,
126 _agent_id: &AgentDid,
127 _key: &str,
128 ) -> Result<Option<IdempotencyRecord>, AcdpError> {
129 Ok(None)
130 }
131
132 /// Record a successful publish under `(agent_id, key)` with TTL
133 /// `expires_at`. Calling on a store that does not support
134 /// idempotency is a no-op.
135 fn idempotency_record(
136 &self,
137 _agent_id: &AgentDid,
138 _key: &str,
139 _hash: &acdp_types::primitives::ContentHash,
140 _response: &acdp_types::publish::PublishResponse,
141 _expires_at: chrono::DateTime<chrono::Utc>,
142 ) -> Result<(), AcdpError> {
143 Ok(())
144 }
145
146 /// Evict records whose `expires_at` is past `now`. Implementations
147 /// may call this on a janitor schedule or lazily at lookup time.
148 fn idempotency_evict_expired(
149 &self,
150 _now: chrono::DateTime<chrono::Utc>,
151 ) -> Result<(), AcdpError> {
152 Ok(())
153 }
154
155 // ── Atomic publish commit (FEAT-01) ────────────────────────────────
156
157 /// Atomically commit a publish: idempotency lookup, supersession
158 /// validation, body insertion, predecessor supersession marking,
159 /// and idempotency record write — all under a single critical
160 /// section so two concurrent publishes targeting the same
161 /// `supersedes` (or sharing an `idempotency_key`) cannot both
162 /// succeed.
163 ///
164 /// Eliminates the TOCTOU races that the old
165 /// `put → mark_superseded → idempotency_record` sequence allowed.
166 /// Returns:
167 /// - `Inserted(response)` — the body was newly persisted.
168 /// - `IdempotentReplay(response)` — a prior record with the same
169 /// `(agent_id, key, content_hash)` was found and its response is
170 /// replayed verbatim (idem-002).
171 ///
172 /// On supersession contention (predecessor already marked
173 /// `Superseded`, lineage mismatch, etc.) returns
174 /// `AcdpError::SupersededTarget { reason, … }`. On idempotency-key
175 /// collision with a different `content_hash` returns
176 /// `AcdpError::DuplicatePublish` (idem-003).
177 fn commit_publish(&self, commit: PublishCommit<'_>) -> Result<PublishCommitOutcome, AcdpError>;
178
179 // ── Lifecycle events (ACDP 0.3, RFC-ACDP-0013) ─────────────────────
180
181 /// Atomically append a lifecycle event and apply its status effect
182 /// (RFC-ACDP-0013 §6 steps 4–5).
183 ///
184 /// CONTRACT — an implementation MUST, under one critical section /
185 /// transaction:
186 ///
187 /// 1. Resolve `event.ctx_id`; unknown → [`AcdpError::NotFound`]
188 /// (visibility filtering is the server's job, before this call).
189 /// 2. **Retry idempotency** (§6): if an already-appended event has
190 /// the same `event_id` *and identical content*, append nothing
191 /// and return [`LifecycleCommitOutcome::IdempotentReplay`] with
192 /// the current projected context; same `event_id` with
193 /// *different* content → [`AcdpError::SchemaViolation`].
194 /// 3. **Strict alternation** (§6 step 4, §7.1): `retracted` is
195 /// accepted only when the context's retraction state is *not
196 /// retracted*; `republished` only when it *is*. A violation
197 /// (double retract, spurious republish) →
198 /// [`AcdpError::InvalidLifecycleTransition`], with **no state
199 /// change**. An unregistered `event_type` →
200 /// [`AcdpError::SchemaViolation`] (§7.3).
201 /// 4. Append the event at the END of `lifecycle_events` (append-only
202 /// — never remove, reorder, or mutate, §4.1) atomically with the
203 /// status effect, and return
204 /// [`LifecycleCommitOutcome::Applied`] with the post-transition
205 /// context (`status` reflecting the §7.2 precedence:
206 /// `retracted` > `superseded` > `expired` > `active`).
207 ///
208 /// The default returns [`AcdpError::NotImplemented`] — deliberately
209 /// NOT a benign no-op like the idempotency defaults: silently
210 /// dropping a retraction would defeat the withdrawal the producer
211 /// signed, whereas a missing idempotency record only costs a
212 /// replay. A `RegistryServer` enabled with
213 /// [`with_lifecycle`](crate::registry::server::RegistryServer::with_lifecycle)
214 /// MUST be paired with a store that implements this method; a
215 /// registry not advertising `acdp-registry-lifecycle` never calls
216 /// it (and its wire layer maps the error to `not_implemented` /
217 /// HTTP 501, the §6 rule).
218 fn commit_lifecycle_event(
219 &self,
220 event: &LifecycleEvent,
221 ) -> Result<LifecycleCommitOutcome, AcdpError> {
222 let _ = event;
223 Err(AcdpError::NotImplemented(
224 "this RegistryStore backend does not implement lifecycle events \
225 (RFC-ACDP-0013): commit_lifecycle_event is not available"
226 .into(),
227 ))
228 }
229}
230
231/// Outcome of an atomic [`RegistryStore::commit_lifecycle_event`].
232#[derive(Debug)]
233pub enum LifecycleCommitOutcome {
234 /// The event was appended and the status effect applied; carries
235 /// the post-transition full context (RFC-ACDP-0013 §6 step 5: the
236 /// caller sees the new state in the shape it already knows).
237 Applied(FullContext),
238 /// The event's `event_id` matched an already-appended event with
239 /// identical content — nothing was appended; carries the current
240 /// state (§6 retry idempotency: a producer whose POST timed out
241 /// after the append must not receive a spurious
242 /// `invalid_lifecycle_transition`).
243 IdempotentReplay(FullContext),
244}
245
246impl LifecycleCommitOutcome {
247 /// The post-transition (or current, on replay) full context.
248 pub fn into_context(self) -> FullContext {
249 match self {
250 LifecycleCommitOutcome::Applied(ctx)
251 | LifecycleCommitOutcome::IdempotentReplay(ctx) => ctx,
252 }
253 }
254}
255
256/// Single-shot atomic publish input (FEAT-01).
257///
258/// Passed to [`RegistryStore::commit_publish`] so the predecessor
259/// lookup, supersession check, body insertion, predecessor
260/// supersession marking, and idempotency record are all done under one
261/// critical section. Eliminates TOCTOU races between two concurrent
262/// publishes that target the same `supersedes` ctx_id or share an
263/// `idempotency_key`.
264pub struct PublishCommit<'a> {
265 /// The validated, signature-verified publish request.
266 pub req: &'a PublishRequest,
267 /// The hostname the registry serves — used to mint `ctx_id`s and
268 /// stored verbatim into `body.origin_registry` (BUG-01).
269 pub authority: &'a str,
270 /// Idempotency wiring, present iff the registry advertises
271 /// `caps.supports_idempotency_key` and the request carries an
272 /// `Idempotency-Key`.
273 pub idempotency: Option<PendingIdempotencyCommit<'a>>,
274 /// Tenant this publish is scoped to, if the registry is multi-tenant.
275 /// `None` means untenanted (single-tenant / V0). A multi-tenant store
276 /// MUST persist this atomically with the context row so the tenancy is
277 /// never observable as the default bucket (and never stranded there on a
278 /// crash between insert and a separate stamping UPDATE). Stores that do
279 /// not implement tenancy ignore it.
280 pub tenant: Option<&'a str>,
281 /// Receipt minting hook (ACDP 0.2, RFC-ACDP-0010). When present,
282 /// the store MUST invoke it with the fully assigned [`Body`]
283 /// (ctx_id / lineage_id / created_at populated) **inside the same
284 /// critical section / transaction as the insert**, persist the
285 /// returned receipt with the context, and include it in the
286 /// response. A context published under the
287 /// `acdp-registry-receipts` profile must never exist without its
288 /// receipt — a crash between insert and mint must not be
289 /// observable. `None` for receipt-less (0.1.0-mode) registries.
290 #[allow(clippy::type_complexity)]
291 pub receipt_minter:
292 Option<&'a (dyn Fn(&Body) -> Result<serde_json::Value, AcdpError> + Send + Sync)>,
293 /// RFC-ACDP-0014 §4 `supersedes`-row admission hook (key-revocation
294 /// lineages). `Some` iff the registry's RFC-0014 version gate is on
295 /// (`key_revocation_gate_applies`) **and** `req.supersedes` is set;
296 /// otherwise `None`.
297 ///
298 /// Stores MUST invoke it with the predecessor's stored [`Body`],
299 /// inside the same critical section / transaction as their own
300 /// supersession checks, and only **after** producer-continuity and
301 /// tenant scoping have already passed — never before. Calling it
302 /// earlier turns the check into a cross-tenant, non-owner
303 /// existence-and-type oracle: the predecessor's presence and
304 /// `context_type` would leak to a caller who does not own it and is
305 /// not scoped to its tenant. `Err` from the closure MUST abort the
306 /// commit — no insert, no supersession side effect. A store that
307 /// silently ignores a `Some` hook fails to enforce an RFC-ACDP-0014
308 /// §4 MUST.
309 #[allow(clippy::type_complexity)]
310 pub predecessor_admission: Option<&'a (dyn Fn(&Body) -> Result<(), AcdpError> + Send + Sync)>,
311}
312
313/// Idempotency parameters threaded through [`PublishCommit`].
314pub struct PendingIdempotencyCommit<'a> {
315 /// The key the producer supplied in the `Idempotency-Key` header.
316 pub key: &'a str,
317 /// TTL after which the idempotency record may be evicted (typically
318 /// `caps.limits.idempotency_key_ttl_seconds`).
319 pub ttl: chrono::Duration,
320}
321
322/// Outcome of an atomic [`RegistryStore::commit_publish`].
323#[derive(Debug)]
324pub enum PublishCommitOutcome {
325 /// Fresh publish — the body was newly persisted and the response
326 /// describes the just-assigned identifiers.
327 Inserted(PublishResponse),
328 /// `(agent_id, idempotency_key)` had a prior record with the same
329 /// `content_hash` — return the original response per idem-002.
330 IdempotentReplay(PublishResponse),
331}
332
333/// Cached publish response keyed by `(agent_id, idempotency_key)`
334/// (RFC-ACDP-0003 §6).
335#[derive(Debug, Clone)]
336pub struct IdempotencyRecord {
337 /// The original request's `content_hash`. A retry with the same key
338 /// but a different hash MUST be rejected as `duplicate_publish`.
339 pub content_hash: acdp_types::primitives::ContentHash,
340 /// The response the registry returned on the first acceptance.
341 pub response: acdp_types::publish::PublishResponse,
342 /// Eviction time (TTL window from caps.limits.idempotency_key_ttl_seconds).
343 pub expires_at: chrono::DateTime<chrono::Utc>,
344}
345
346// ── In-memory reference implementation ───────────────────────────────────────
347
348/// Minimal in-memory backend. Not durable; intended for tests and
349/// prototyping. Concurrency-safe (a single `Mutex` over the table).
350#[derive(Default)]
351pub struct InMemoryStore {
352 inner: Mutex<Inner>,
353}
354
355#[derive(Default)]
356struct Inner {
357 /// All contexts keyed by `ctx_id`. Insertion-ordered per lineage
358 /// thanks to the parallel `lineages` index.
359 by_ctx: std::collections::BTreeMap<String, FullContext>,
360 /// `lineage_id -> [ctx_id, ctx_id, ...]` in publish order.
361 lineages: std::collections::BTreeMap<String, Vec<String>>,
362 /// `(agent_did, idempotency_key) -> record` (RFC-ACDP-0003 §6).
363 idempotency: std::collections::HashMap<(String, String), IdempotencyRecord>,
364}
365
366impl InMemoryStore {
367 /// Construct an empty store.
368 pub fn new() -> Self {
369 Self::default()
370 }
371
372 fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
373 self.inner.lock().expect("InMemoryStore mutex poisoned")
374 }
375}
376
377/// RFC-ACDP-0004 §4 (as amended by RFC-ACDP-0013 §7.2) — derive the
378/// served `status` at read time from the stored state, the lifecycle
379/// event history, and the clock, so a registry that does not run a
380/// janitor still surfaces the correct lifecycle status.
381///
382/// Precedence: `retracted` > `superseded` > `expired` > `active`.
383/// Retraction state is derived from `lifecycle_events` (§7.1) and
384/// dominates everything — including a stored `Superseded` and a lapsed
385/// `expires_at`; republication removes the retraction from the
386/// derivation (not the history) and the status re-derives as though
387/// never retracted.
388pub(crate) fn project_status(
389 state: &RegistryState,
390 body: &Body,
391 now: chrono::DateTime<chrono::Utc>,
392) -> Status {
393 if state.is_retracted() {
394 return Status::Retracted;
395 }
396 match &state.status {
397 Status::Active => match body.expires_at {
398 Some(exp) if exp <= now => Status::Expired,
399 _ => Status::Active,
400 },
401 other => other.clone(),
402 }
403}
404
405/// Materialize the effective view of a stored context: applies
406/// [`project_status`] to override the stored status when retracted or
407/// expired.
408pub(crate) fn project_context(
409 mut ctx: FullContext,
410 now: chrono::DateTime<chrono::Utc>,
411) -> FullContext {
412 ctx.registry_state.status = project_status(&ctx.registry_state, &ctx.body, now);
413 ctx
414}
415
416/// RFC-ACDP-0008 §4.5 search-disclosure rule.
417///
418/// Note the asymmetry vs retrieval: a `Private` context surfaces in search
419/// **only** to its producer — audience members must already know the
420/// `ctx_id` to fetch it. `Restricted` surfaces to producer + audience.
421///
422/// `anonymous_public_reads` mirrors the capability advertisement
423/// (RFC-ACDP-0008 §4.5): a registry that does NOT permit anonymous
424/// public reads MUST suppress public contexts for unauthenticated
425/// callers in both `retrieve` and `search`. The retrieval helper
426/// already consults this flag; this function pulls it through to the
427/// store-side search path (BUG-02).
428fn can_surface_in_search(
429 body: &Body,
430 requester: Option<&AgentDid>,
431 anonymous_public_reads: bool,
432) -> bool {
433 match body.visibility {
434 Visibility::Public => anonymous_public_reads || requester.is_some(),
435 Visibility::Restricted => match requester {
436 None => false,
437 Some(r) => {
438 r == &body.agent_id
439 || body
440 .audience
441 .as_deref()
442 .is_some_and(|a| a.iter().any(|d| d == r))
443 }
444 },
445 Visibility::Private => requester == Some(&body.agent_id),
446 }
447}
448
449impl RegistryStore for InMemoryStore {
450 fn put(&self, body: Body) -> Result<(), AcdpError> {
451 let ctx_id = body.ctx_id.0.clone();
452 let lineage_id = body.lineage_id.0.clone();
453 let ctx = FullContext {
454 body,
455 registry_state: RegistryState {
456 status: Status::Active,
457 lifecycle_events: None,
458 extensions: Default::default(),
459 },
460 registry_receipt: None,
461 lineage_head_receipt: None,
462 log_inclusion: None,
463 extensions: Default::default(),
464 };
465 let mut g = self.lock();
466 if g.by_ctx.contains_key(&ctx_id) {
467 return Err(AcdpError::SchemaViolation(format!(
468 "duplicate ctx_id '{ctx_id}' in store"
469 )));
470 }
471 g.by_ctx.insert(ctx_id.clone(), ctx);
472 g.lineages.entry(lineage_id).or_default().push(ctx_id);
473 Ok(())
474 }
475
476 fn get(&self, ctx_id: &CtxId) -> Result<Option<FullContext>, AcdpError> {
477 let now = chrono::Utc::now();
478 Ok(self
479 .lock()
480 .by_ctx
481 .get(ctx_id.as_str())
482 .cloned()
483 .map(|c| project_context(c, now)))
484 }
485
486 fn lineage(&self, lineage_id: &LineageId) -> Result<Vec<FullContext>, AcdpError> {
487 let now = chrono::Utc::now();
488 let g = self.lock();
489 let Some(ids) = g.lineages.get(lineage_id.as_str()) else {
490 return Ok(Vec::new());
491 };
492 Ok(ids
493 .iter()
494 .filter_map(|id| g.by_ctx.get(id).cloned().map(|c| project_context(c, now)))
495 .collect())
496 }
497
498 fn current(&self, lineage_id: &LineageId) -> Result<Option<FullContext>, AcdpError> {
499 let now = chrono::Utc::now();
500 let g = self.lock();
501 let Some(ids) = g.lineages.get(lineage_id.as_str()) else {
502 return Ok(None);
503 };
504 // RFC-ACDP-0004 §5: "Returns the unique version that has no
505 // successor. If no such version exists, returns not_found."
506 // Walk newest-to-oldest and return the first version that is
507 // neither `Superseded` nor `Retracted`. Both `Active` and
508 // `Expired` count — an expired body that hasn't been replaced
509 // is still the latest, and the consumer needs to see it (with
510 // status=Expired) to know it has lapsed. A retracted version is
511 // NEVER a head (RFC-ACDP-0013 §8.3): it has been explicitly
512 // withdrawn from reliance, and falling back to a superseded
513 // predecessor would silently serve a replaced context — so
514 // retracting a linear lineage's head yields `None` (fixture
515 // `lc-003`).
516 //
517 // BUG-04: an earlier fallback returned the last entry even when
518 // every version was `Superseded`; that's a protocol violation.
519 // Now we return `None` instead.
520 for id in ids.iter().rev() {
521 if let Some(ctx) = g.by_ctx.get(id) {
522 let projected = project_context(ctx.clone(), now);
523 if !matches!(
524 projected.registry_state.status,
525 Status::Superseded | Status::Retracted
526 ) {
527 return Ok(Some(projected));
528 }
529 }
530 }
531 Ok(None)
532 }
533
534 fn commit_lifecycle_event(
535 &self,
536 event: &LifecycleEvent,
537 ) -> Result<LifecycleCommitOutcome, AcdpError> {
538 let now = chrono::Utc::now();
539 let mut g = self.lock();
540 let ctx = g.by_ctx.get_mut(event.ctx_id.as_str()).ok_or_else(|| {
541 AcdpError::NotFound(format!(
542 "context '{}' not found in this registry",
543 event.ctx_id
544 ))
545 })?;
546 let events = ctx
547 .registry_state
548 .lifecycle_events
549 .as_deref()
550 .unwrap_or(&[]);
551
552 // §6 retry idempotency / duplicate event_id (step 2).
553 if let Some(prior) = events.iter().find(|e| e.event_id == event.event_id) {
554 if prior == event {
555 return Ok(LifecycleCommitOutcome::IdempotentReplay(project_context(
556 ctx.clone(),
557 now,
558 )));
559 }
560 return Err(AcdpError::SchemaViolation(format!(
561 "event_id '{}' was already appended with different content \
562 (RFC-ACDP-0013 §4: event_id MUST be unique within lifecycle_events)",
563 event.event_id
564 )));
565 }
566
567 // §6 step 4 — strict retracted/republished alternation against
568 // the §7.1 retraction state, under the same lock as the append.
569 let currently_retracted = acdp_types::lifecycle::retraction_state(events);
570 match &event.event_type {
571 LifecycleEventType::Retracted if currently_retracted => {
572 return Err(AcdpError::InvalidLifecycleTransition(format!(
573 "context '{}' is already retracted — double retract violates the \
574 strict alternation rule (RFC-ACDP-0013 §6 step 4)",
575 event.ctx_id
576 )));
577 }
578 LifecycleEventType::Republished if !currently_retracted => {
579 return Err(AcdpError::InvalidLifecycleTransition(format!(
580 "context '{}' is not retracted — republish requires a prior \
581 retraction (RFC-ACDP-0013 §6 step 4)",
582 event.ctx_id
583 )));
584 }
585 LifecycleEventType::Other(other) => {
586 return Err(AcdpError::SchemaViolation(format!(
587 "event_type '{other}' is not registered for acceptance in 0.3.0 — \
588 only 'retracted' and 'republished' transition state \
589 (RFC-ACDP-0013 §7.3)"
590 )));
591 }
592 LifecycleEventType::Retracted | LifecycleEventType::Republished => {}
593 }
594
595 // §6 step 5 — append atomically with the status effect. The
596 // served status is DERIVED from this same array (§7.2
597 // precedence, applied by `project_status`), so appending the
598 // event IS the status change; the stored status keeps tracking
599 // the supersession fact only.
600 ctx.registry_state
601 .lifecycle_events
602 .get_or_insert_with(Vec::new)
603 .push(event.clone());
604 Ok(LifecycleCommitOutcome::Applied(project_context(
605 ctx.clone(),
606 now,
607 )))
608 }
609
610 fn mark_superseded(&self, ctx_id: &CtxId) -> Result<(), AcdpError> {
611 let mut g = self.lock();
612 if let Some(ctx) = g.by_ctx.get_mut(ctx_id.as_str()) {
613 ctx.registry_state.status = Status::Superseded;
614 }
615 Ok(())
616 }
617
618 fn first_version_ctx_id(&self, lineage_id: &LineageId) -> Result<Option<CtxId>, AcdpError> {
619 let g = self.lock();
620 Ok(g.lineages
621 .get(lineage_id.as_str())
622 .and_then(|ids| ids.first().cloned())
623 .map(CtxId))
624 }
625
626 fn idempotency_lookup(
627 &self,
628 agent_id: &AgentDid,
629 key: &str,
630 ) -> Result<Option<IdempotencyRecord>, AcdpError> {
631 // Lazy TTL eviction at lookup time keeps the table bounded
632 // without requiring a janitor — see idempotency_evict_expired.
633 self.idempotency_evict_expired(chrono::Utc::now())?;
634 let g = self.lock();
635 Ok(g.idempotency
636 .get(&(agent_id.as_str().to_string(), key.to_string()))
637 .cloned())
638 }
639
640 fn idempotency_record(
641 &self,
642 agent_id: &AgentDid,
643 key: &str,
644 hash: &acdp_types::primitives::ContentHash,
645 response: &acdp_types::publish::PublishResponse,
646 expires_at: chrono::DateTime<chrono::Utc>,
647 ) -> Result<(), AcdpError> {
648 let mut g = self.lock();
649 g.idempotency.insert(
650 (agent_id.as_str().to_string(), key.to_string()),
651 IdempotencyRecord {
652 content_hash: hash.clone(),
653 response: response.clone(),
654 expires_at,
655 },
656 );
657 Ok(())
658 }
659
660 fn idempotency_evict_expired(
661 &self,
662 now: chrono::DateTime<chrono::Utc>,
663 ) -> Result<(), AcdpError> {
664 let mut g = self.lock();
665 g.idempotency.retain(|_, r| r.expires_at > now);
666 Ok(())
667 }
668
669 fn commit_publish(&self, commit: PublishCommit<'_>) -> Result<PublishCommitOutcome, AcdpError> {
670 use crate::registry::validator::assign_identifiers;
671
672 let PublishCommit {
673 req,
674 authority,
675 idempotency,
676 // InMemoryStore does not model tenancy (it is a single-tenant
677 // reference/test backend); the durable backends honor this.
678 tenant: _,
679 receipt_minter,
680 predecessor_admission,
681 } = commit;
682 let now = chrono::Utc::now();
683 let mut g = self.lock();
684
685 // ── 1. Idempotency replay / collision ────────────────────────
686 if let Some(idem) = &idempotency {
687 let idem_key = (req.agent_id.as_str().to_string(), idem.key.to_string());
688 if let Some(prior) = g.idempotency.get(&idem_key) {
689 if prior.expires_at > now {
690 return if prior.content_hash == req.content_hash {
691 // idem-002: same key + same hash → replay.
692 Ok(PublishCommitOutcome::IdempotentReplay(
693 prior.response.clone(),
694 ))
695 } else {
696 // idem-003: same key + different hash → duplicate_publish.
697 Err(AcdpError::DuplicatePublish(format!(
698 "Idempotency-Key '{}' was previously used by '{}' \
699 with a different content_hash",
700 idem.key, req.agent_id
701 )))
702 };
703 }
704 // Expired record — fall through and overwrite below.
705 }
706 }
707
708 // ── 2. Supersession lookups + coherence checks ──────────────
709 let first_v1 = if let Some(prev) = &req.supersedes {
710 let prev_full = g.by_ctx.get(prev.as_str()).cloned().ok_or_else(|| {
711 AcdpError::SupersededTarget {
712 reason: acdp_primitives::error::SupersessionReason::NotFound,
713 message: format!("supersedes target '{prev}' not found in this registry"),
714 }
715 })?;
716
717 // Producer-continuity: only the predecessor's producer (or a
718 // declared contributor) may publish a successor in its lineage.
719 // Signature verification only proves the *requester* signed
720 // their own request — it does not bind `supersedes` to the
721 // predecessor's owner. Without this check any signer could
722 // supersede another producer's context (`Superseded` side
723 // effect below + `current(lineage)` re-pointing), a full
724 // lineage takeover. RFC-ACDP-0001 §5.9 supersession is
725 // producer-scoped.
726 let is_owner = req.agent_id == prev_full.body.agent_id
727 || prev_full.body.contributors.contains(&req.agent_id);
728 if !is_owner {
729 // Uniform with the genuine not-found case above: a
730 // non-owner learns neither that the predecessor exists nor
731 // its version / superseded status (supersession existence
732 // oracle). Anyone who can legitimately read the
733 // predecessor learns nothing new from this shape.
734 return Err(AcdpError::SupersededTarget {
735 reason: acdp_primitives::error::SupersessionReason::NotFound,
736 message: format!("supersedes target '{prev}' not found in this registry"),
737 });
738 }
739
740 // Lineage coherence — when the producer self-verifies.
741 if let Some(declared) = &req.lineage_id {
742 if declared != &prev_full.body.lineage_id {
743 return Err(AcdpError::SupersededTarget {
744 reason: acdp_primitives::error::SupersessionReason::LineageMismatch,
745 message: format!(
746 "declared lineage_id '{declared}' ≠ predecessor's '{}'",
747 prev_full.body.lineage_id
748 ),
749 });
750 }
751 }
752 // Version coherence: new.version MUST be predecessor.version + 1.
753 if req.version != prev_full.body.version + 1 {
754 return Err(AcdpError::SupersededTarget {
755 reason: acdp_primitives::error::SupersessionReason::VersionMismatch,
756 message: format!(
757 "version {} ≠ predecessor.version + 1 ({})",
758 req.version,
759 prev_full.body.version + 1
760 ),
761 });
762 }
763 // FEAT-01 atomicity: the check that previously raced with
764 // another concurrent publish. Now under the same lock as
765 // the insert below — exactly one of two contenders succeeds.
766 if matches!(prev_full.registry_state.status, Status::Superseded) {
767 return Err(AcdpError::SupersededTarget {
768 reason: acdp_primitives::error::SupersessionReason::AlreadySuperseded,
769 message: format!("supersedes target '{prev}' has already been superseded"),
770 });
771 }
772
773 // RFC-ACDP-0014 §4 `supersedes`-row enforcement. Runs AFTER
774 // producer-continuity, lineage/version coherence, and
775 // AlreadySuperseded have all passed — never earlier — so a
776 // non-owner (or wrong-tenant, on multi-tenant stores) probe
777 // is already turned away as `SupersededTarget::NotFound`
778 // above and never reaches this line. Doing this check first
779 // would make it a cross-tenant, non-owner
780 // existence-and-type oracle on the predecessor.
781 if let Some(admission) = predecessor_admission {
782 admission(&prev_full.body)?;
783 }
784
785 // Derive the v1 ctx_id from the predecessor's lineage —
786 // same logic as `first_version_ctx_id`, inlined to stay
787 // under the existing lock.
788 g.lineages
789 .get(prev_full.body.lineage_id.as_str())
790 .and_then(|ids| ids.first().cloned())
791 .map(CtxId)
792 } else {
793 None
794 };
795
796 // ── 3. Identifier assignment ────────────────────────────────
797 let validated = crate::registry::validator::ValidatedPublish {
798 recomputed_hash: req.content_hash.clone(),
799 };
800 let (ctx_id, lineage_id) =
801 assign_identifiers(authority, &req.supersedes, first_v1.as_ref(), &validated)?;
802
803 // ── 4. Build the stored Body ────────────────────────────────
804 // Single materialization point (IMP-02): the constructor copies
805 // every producer field and ms-truncates `created_at`, so this
806 // backend cannot drift from the SQL backends when a producer
807 // field is added.
808 let body =
809 Body::from_publish_request(req, ctx_id.clone(), lineage_id.clone(), authority, now);
810 let created_at = body.created_at;
811
812 // ── 5. Insert (mirrors `put` but inline so we keep the lock) ─
813 let ctx_id_str = body.ctx_id.0.clone();
814 let lineage_id_str = body.lineage_id.0.clone();
815 if g.by_ctx.contains_key(&ctx_id_str) {
816 // UUID collision is astronomically unlikely but we still
817 // surface it as a SchemaViolation rather than silently
818 // overwriting.
819 return Err(AcdpError::SchemaViolation(format!(
820 "ctx_id collision: '{ctx_id_str}' already exists"
821 )));
822 }
823 // Receipt minting (RFC-ACDP-0010) — inside the critical section,
824 // before the insert becomes visible, so a context published
825 // under the receipts profile never exists without its receipt.
826 let registry_receipt = receipt_minter.map(|mint| mint(&body)).transpose()?;
827
828 let stored = FullContext {
829 body,
830 registry_state: RegistryState {
831 status: Status::Active,
832 lifecycle_events: None,
833 extensions: Default::default(),
834 },
835 registry_receipt: registry_receipt.clone(),
836 // Head receipts are ephemeral serve-time attestations —
837 // never persisted; `RegistryServer::current` mints per
838 // response (RFC-ACDP-0011 §6).
839 lineage_head_receipt: None,
840 log_inclusion: None,
841 extensions: Default::default(),
842 };
843 g.by_ctx.insert(ctx_id_str.clone(), stored);
844 g.lineages
845 .entry(lineage_id_str)
846 .or_default()
847 .push(ctx_id_str);
848
849 // ── 6. Mark predecessor superseded ──────────────────────────
850 if let Some(prev) = &req.supersedes {
851 if let Some(prev_ctx) = g.by_ctx.get_mut(prev.as_str()) {
852 prev_ctx.registry_state.status = Status::Superseded;
853 }
854 }
855
856 let response = PublishResponse {
857 ctx_id,
858 lineage_id,
859 version: req.version,
860 created_at,
861 status: Status::Active,
862 registry_receipt,
863 };
864
865 // ── 7. Idempotency record ───────────────────────────────────
866 if let Some(idem) = idempotency {
867 let expires_at = now + idem.ttl;
868 g.idempotency.insert(
869 (req.agent_id.as_str().to_string(), idem.key.to_string()),
870 IdempotencyRecord {
871 content_hash: req.content_hash.clone(),
872 response: response.clone(),
873 expires_at,
874 },
875 );
876 }
877
878 Ok(PublishCommitOutcome::Inserted(response))
879 }
880
881 fn search(
882 &self,
883 params: &SearchParams,
884 requester: Option<&AgentDid>,
885 anonymous_public_reads: bool,
886 ) -> Result<SearchResponse, AcdpError> {
887 let g = self.lock();
888 let now = chrono::Utc::now();
889
890 let q_lower = params.q.as_deref().map(str::to_lowercase);
891 let domain = params.domain.as_deref();
892 let agent = params.agent_id.as_deref();
893 let context_type = params.context_type.as_deref();
894 let derived_from = params.derived_from.as_deref();
895 let schema_uri = params.schema_uri.as_deref();
896 let tags: Option<Vec<&str>> = params.tags.as_deref().map(|s| {
897 s.split(',')
898 .map(str::trim)
899 .filter(|t| !t.is_empty())
900 .collect()
901 });
902
903 // BUG-10: parse date-time filter params at the boundary so the
904 // hot loop just compares DateTime<Utc> values.
905 let created_after = parse_opt_rfc3339(¶ms.created_after)?;
906 let created_before = parse_opt_rfc3339(¶ms.created_before)?;
907 let dp_start_after = parse_opt_rfc3339(¶ms.data_period_start_after)?;
908 let dp_end_before = parse_opt_rfc3339(¶ms.data_period_end_before)?;
909 let expires_after = parse_opt_rfc3339(¶ms.expires_after)?;
910 let expires_before = parse_opt_rfc3339(¶ms.expires_before)?;
911
912 let mut matches: Vec<&FullContext> = g
913 .by_ctx
914 .values()
915 .filter(|ctx| {
916 let body = &ctx.body;
917
918 // RFC-ACDP-0008 §4.5 search-disclosure gate (note the
919 // private/restricted asymmetry: private contexts surface
920 // in search only to their producer).
921 if !can_surface_in_search(body, requester, anonymous_public_reads) {
922 return false;
923 }
924
925 if let Some(q) = &q_lower {
926 let haystack = format!(
927 "{} {} {} {} {} {}",
928 body.title,
929 body.description.as_deref().unwrap_or(""),
930 body.summary.as_deref().unwrap_or(""),
931 body.domain.as_deref().unwrap_or(""),
932 body.agent_id.as_str(),
933 body.tags.as_ref().map(|t| t.join(" ")).unwrap_or_default(),
934 )
935 .to_lowercase();
936 if !haystack.contains(q) {
937 return false;
938 }
939 }
940 if let Some(d) = domain {
941 if body.domain.as_deref() != Some(d) {
942 return false;
943 }
944 }
945 if let Some(a) = agent {
946 if body.agent_id.as_str() != a {
947 return false;
948 }
949 }
950 if let Some(t) = context_type {
951 let body_type = serde_json::to_value(&body.context_type)
952 .ok()
953 .and_then(|v| v.as_str().map(str::to_string))
954 .unwrap_or_default();
955 if body_type != t {
956 return false;
957 }
958 }
959 if let Some(df) = derived_from {
960 if !body.derived_from.iter().any(|c| c.as_str() == df) {
961 return false;
962 }
963 }
964 if let Some(req_tags) = &tags {
965 let body_tags = body.tags.as_deref().unwrap_or(&[]);
966 if !req_tags.iter().all(|t| body_tags.iter().any(|bt| bt == t)) {
967 return false;
968 }
969 }
970 if let Some(uri) = schema_uri {
971 if body.schema_uri.as_deref() != Some(uri) {
972 return false;
973 }
974 }
975 if let Some(after) = created_after {
976 if body.created_at < after {
977 return false;
978 }
979 }
980 if let Some(before) = created_before {
981 if body.created_at > before {
982 return false;
983 }
984 }
985 if let Some(after) = dp_start_after {
986 match &body.data_period {
987 Some(p) if p.start >= after => {}
988 _ => return false,
989 }
990 }
991 if let Some(before) = dp_end_before {
992 match &body.data_period {
993 Some(p) if p.end <= before => {}
994 _ => return false,
995 }
996 }
997 if let Some(after) = expires_after {
998 match body.expires_at {
999 Some(e) if e >= after => {}
1000 _ => return false,
1001 }
1002 }
1003 if let Some(before) = expires_before {
1004 match body.expires_at {
1005 Some(e) if e <= before => {}
1006 _ => return false,
1007 }
1008 }
1009 // Status filter — registry default is `active`. Compare
1010 // against PROJECTED status so a stored-Active body whose
1011 // expires_at has passed is filtered out (RFC-ACDP-0004 §4)
1012 // and a retracted context falls out of default searches —
1013 // and out of status=superseded / status=expired even where
1014 // those facts also hold (RFC-ACDP-0013 §8.2: the §7.2
1015 // precedence applies to the filter).
1016 let want_status = params.status.as_deref().unwrap_or("active");
1017 let effective = project_status(&ctx.registry_state, body, now);
1018 if effective.as_str() != want_status {
1019 return false;
1020 }
1021 true
1022 })
1023 .collect();
1024
1025 // Newest first; IMP-03 — fall back to ctx_id for a deterministic
1026 // total order when many contexts share a millisecond.
1027 matches.sort_by(|a, b| {
1028 b.body
1029 .created_at
1030 .cmp(&a.body.created_at)
1031 .then_with(|| a.body.ctx_id.as_str().cmp(b.body.ctx_id.as_str()))
1032 });
1033
1034 // BUG-08: capture `total_estimate` BEFORE cursor filtering so
1035 // it represents the total count across all pages (RFC-ACDP-0005
1036 // §3 — clients use this for "page 1 of N" UIs). If we captured
1037 // it after `retain`, page 2 would show "80 matches" for a
1038 // 100-item search, page 3 "60", and so on.
1039 let total_estimate = Some(matches.len() as u64);
1040
1041 // BUG-10 cursor: opaque base64 of "<created_at_ms>:<ctx_id>".
1042 // ≥1h validity is implicit — cursors do not embed a timestamp,
1043 // so they remain valid until the underlying context is deleted.
1044 let cursor_anchor = params
1045 .cursor
1046 .as_deref()
1047 .map(decode_cursor)
1048 .transpose()?
1049 .flatten();
1050 if let Some((anchor_ms, anchor_id)) = &cursor_anchor {
1051 matches.retain(|c| {
1052 let ms = c.body.created_at.timestamp_millis();
1053 ms < *anchor_ms || (ms == *anchor_ms && c.body.ctx_id.as_str() > anchor_id.as_str())
1054 });
1055 }
1056
1057 // Clamp into [1, 100]: `limit` is attacker-controlled and never
1058 // lower-bounded. `limit=0` with ≥1 match would compute `limit - 1`
1059 // below → debug-build subtraction panic (request-thread DoS) /
1060 // release-build wrap to usize::MAX → broken pagination.
1061 let limit = params.limit.unwrap_or(50).clamp(1, 100) as usize;
1062 let next_cursor = if matches.len() > limit {
1063 matches.get(limit - 1).map(|c| {
1064 encode_cursor(c.body.created_at.timestamp_millis(), c.body.ctx_id.as_str())
1065 })
1066 } else {
1067 None
1068 };
1069
1070 let projected: Vec<SearchResult> = matches
1071 .iter()
1072 .take(limit)
1073 .map(|ctx| SearchResult {
1074 ctx_id: ctx.body.ctx_id.clone(),
1075 lineage_id: ctx.body.lineage_id.clone(),
1076 agent_id: ctx.body.agent_id.clone(),
1077 title: ctx.body.title.clone(),
1078 summary: ctx.body.summary.clone(),
1079 context_type: ctx.body.context_type.clone(),
1080 domain: ctx.body.domain.clone(),
1081 created_at: ctx.body.created_at,
1082 status: project_status(&ctx.registry_state, &ctx.body, now),
1083 // RFC-ACDP-0008 §4.5: only disclose visibility when the
1084 // requester is authorized for it. Public is always safe.
1085 // For restricted/private, the search filter above guarantees
1086 // the requester is producer-or-audience, so it's safe to
1087 // surface the label.
1088 visibility: Some(ctx.body.visibility.clone()),
1089 })
1090 .collect();
1091
1092 Ok(SearchResponse {
1093 matches: projected,
1094 total_estimate,
1095 next_cursor,
1096 })
1097 }
1098}
1099
1100/// Parse an optional RFC 3339 string parameter; surface a
1101/// [`AcdpError::SchemaViolation`] on malformed input.
1102fn parse_opt_rfc3339(
1103 s: &Option<String>,
1104) -> Result<Option<chrono::DateTime<chrono::Utc>>, AcdpError> {
1105 let Some(raw) = s.as_deref() else {
1106 return Ok(None);
1107 };
1108 let dt = chrono::DateTime::parse_from_rfc3339(raw)
1109 .map_err(|e| AcdpError::SchemaViolation(format!("malformed datetime '{raw}': {e}")))?;
1110 Ok(Some(dt.with_timezone(&chrono::Utc)))
1111}
1112
1113/// Cursor TTL — clients SHOULD re-fetch after this window.
1114/// RFC-ACDP-0005 §3 leaves the exact value to implementations; 1 hour
1115/// matches the common "≥1h" cursor-validity expectation.
1116const CURSOR_TTL: chrono::Duration = chrono::Duration::seconds(3600);
1117
1118/// Opaque cursor encoding — base64 of
1119/// `<mint_unix_ms>:<created_at_millis>:<ctx_id>`.
1120///
1121/// The `mint_unix_ms` prefix lets [`decode_cursor`] enforce the
1122/// `CURSOR_TTL` window and surface `AcdpError::CursorExpired` rather
1123/// than silently accepting an ancient cursor (FEAT-04). Plain
1124/// `STANDARD` engine so cursors are stable across machines.
1125fn encode_cursor(created_at_ms: i64, ctx_id: &str) -> String {
1126 use base64::{engine::general_purpose::STANDARD, Engine};
1127 let mint_ms = chrono::Utc::now().timestamp_millis();
1128 STANDARD.encode(format!("{mint_ms}:{created_at_ms}:{ctx_id}"))
1129}
1130
1131fn decode_cursor(s: &str) -> Result<Option<(i64, String)>, AcdpError> {
1132 use base64::{engine::general_purpose::STANDARD, Engine};
1133 let bytes = STANDARD
1134 .decode(s)
1135 .map_err(|_| AcdpError::InvalidCursor("cursor is not valid base64".into()))?;
1136 let decoded = String::from_utf8(bytes)
1137 .map_err(|_| AcdpError::InvalidCursor("cursor is not utf-8".into()))?;
1138 // Format: "<mint_ms>:<anchor_ms>:<ctx_id>". The mint timestamp
1139 // prefix is FEAT-04 expiry tracking.
1140 let mut parts = decoded.splitn(3, ':');
1141 let mint_str = parts
1142 .next()
1143 .ok_or_else(|| AcdpError::InvalidCursor("cursor missing mint timestamp".into()))?;
1144 let anchor_str = parts
1145 .next()
1146 .ok_or_else(|| AcdpError::InvalidCursor("cursor missing anchor timestamp".into()))?;
1147 let ctx_id = parts
1148 .next()
1149 .ok_or_else(|| AcdpError::InvalidCursor("cursor missing ctx_id".into()))?;
1150 let mint_ms: i64 = mint_str
1151 .parse()
1152 .map_err(|_| AcdpError::InvalidCursor("cursor mint millis is not an integer".into()))?;
1153 let anchor_ms: i64 = anchor_str
1154 .parse()
1155 .map_err(|_| AcdpError::InvalidCursor("cursor anchor millis is not an integer".into()))?;
1156
1157 // FEAT-04: reject cursors older than CURSOR_TTL with the
1158 // dedicated `cursor_expired` wire code so clients can distinguish
1159 // "you typo'd the cursor" (InvalidCursor) from "your cursor aged
1160 // out, restart the scan from page 1" (CursorExpired).
1161 let now = chrono::Utc::now().timestamp_millis();
1162 let age_ms = now.saturating_sub(mint_ms);
1163 if age_ms > CURSOR_TTL.num_milliseconds() {
1164 // CursorExpired is a unit variant in the wire-error mapping;
1165 // the diagnostic ("aged Xms ago") is intentionally surfaced
1166 // via Display rather than a payload so it round-trips through
1167 // `AcdpError::from_wire_error`.
1168 return Err(AcdpError::CursorExpired);
1169 }
1170 Ok(Some((anchor_ms, ctx_id.to_string())))
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175 use super::*;
1176 use acdp_crypto::SigningKey;
1177 use acdp_producer::Producer;
1178 use acdp_types::body::{DataPeriod, Signature};
1179 use acdp_types::primitives::{AgentDid, ContentHash, ContextType, Visibility};
1180 use chrono::Utc;
1181
1182 fn fake_body(ctx_id: &str, lineage_id: &str, title: &str) -> Body {
1183 Body {
1184 ctx_id: CtxId(ctx_id.into()),
1185 lineage_id: LineageId(lineage_id.into()),
1186 origin_registry: "registry.example.com".into(),
1187 created_at: Utc::now(),
1188 content_hash: ContentHash("sha256:0".into()),
1189 signature: Signature {
1190 algorithm: "ed25519".into(),
1191 key_id: "did:web:agents.example.com:test#key-1".into(),
1192 value: "A".repeat(88),
1193 },
1194 version: 1,
1195 supersedes: None,
1196 agent_id: AgentDid::new("did:web:agents.example.com:test"),
1197 contributors: vec![],
1198 title: title.into(),
1199 context_type: ContextType::DataSnapshot,
1200 data_refs: vec![],
1201 derived_from: vec![],
1202 visibility: Visibility::Public,
1203 audience: None,
1204 acdp_version: None,
1205 description: None,
1206 summary: None,
1207 tags: None,
1208 domain: None,
1209 expires_at: None,
1210 data_period: None,
1211 metadata: None,
1212 schema_uri: None,
1213 anchors: None,
1214 extensions: Default::default(),
1215 }
1216 }
1217
1218 #[test]
1219 fn put_get_round_trip() {
1220 let s = InMemoryStore::new();
1221 let id = "acdp://r/12345678-1234-4321-8123-123456781234";
1222 let lin = "lin:sha256:1111111111111111111111111111111111111111111111111111111111111111";
1223 s.put(fake_body(id, lin, "A")).unwrap();
1224 let got = s.get(&CtxId(id.into())).unwrap().unwrap();
1225 assert_eq!(got.body.title, "A");
1226 assert!(matches!(got.registry_state.status, Status::Active));
1227 }
1228
1229 #[test]
1230 fn lineage_orders_by_publish_order() {
1231 let s = InMemoryStore::new();
1232 let lin = "lin:sha256:2222222222222222222222222222222222222222222222222222222222222222";
1233 let v1 = "acdp://r/12345678-1234-4321-8123-000000000001";
1234 let v2 = "acdp://r/12345678-1234-4321-8123-000000000002";
1235 s.put(fake_body(v1, lin, "v1")).unwrap();
1236 s.put(fake_body(v2, lin, "v2")).unwrap();
1237 let lineage = s.lineage(&LineageId(lin.into())).unwrap();
1238 assert_eq!(lineage.len(), 2);
1239 assert_eq!(lineage[0].body.title, "v1");
1240 assert_eq!(lineage[1].body.title, "v2");
1241 }
1242
1243 #[test]
1244 fn supersession_marks_predecessor() {
1245 let s = InMemoryStore::new();
1246 let lin = "lin:sha256:3333333333333333333333333333333333333333333333333333333333333333";
1247 let v1 = "acdp://r/12345678-1234-4321-8123-000000000003";
1248 s.put(fake_body(v1, lin, "v1")).unwrap();
1249 s.mark_superseded(&CtxId(v1.into())).unwrap();
1250 let got = s.get(&CtxId(v1.into())).unwrap().unwrap();
1251 assert!(matches!(got.registry_state.status, Status::Superseded));
1252 }
1253
1254 // BUG-11 — Status::Expired derived from body.expires_at at read time.
1255
1256 fn expired_body(
1257 ctx_id: &str,
1258 lineage_id: &str,
1259 title: &str,
1260 expires_at: chrono::DateTime<chrono::Utc>,
1261 ) -> Body {
1262 let mut b = fake_body(ctx_id, lineage_id, title);
1263 b.expires_at = Some(expires_at);
1264 b
1265 }
1266
1267 #[test]
1268 fn get_projects_active_to_expired_when_past_expires_at() {
1269 use chrono::Duration;
1270 let s = InMemoryStore::new();
1271 let lin = "lin:sha256:5555555555555555555555555555555555555555555555555555555555555555";
1272 let id = "acdp://r/12345678-1234-4321-8123-000000000006";
1273 s.put(expired_body(
1274 id,
1275 lin,
1276 "old",
1277 chrono::Utc::now() - Duration::hours(1),
1278 ))
1279 .unwrap();
1280 let got = s.get(&CtxId(id.into())).unwrap().unwrap();
1281 assert!(
1282 matches!(got.registry_state.status, Status::Expired),
1283 "expected Status::Expired projection, got {:?}",
1284 got.registry_state.status
1285 );
1286 }
1287
1288 #[test]
1289 fn get_keeps_active_when_expires_at_in_future() {
1290 use chrono::Duration;
1291 let s = InMemoryStore::new();
1292 let lin = "lin:sha256:6666666666666666666666666666666666666666666666666666666666666666";
1293 let id = "acdp://r/12345678-1234-4321-8123-000000000007";
1294 s.put(expired_body(
1295 id,
1296 lin,
1297 "fresh",
1298 chrono::Utc::now() + Duration::hours(1),
1299 ))
1300 .unwrap();
1301 let got = s.get(&CtxId(id.into())).unwrap().unwrap();
1302 assert!(matches!(got.registry_state.status, Status::Active));
1303 }
1304
1305 #[test]
1306 fn search_status_active_filters_out_expired() {
1307 use chrono::Duration;
1308 let s = InMemoryStore::new();
1309 let lin = "lin:sha256:7777777777777777777777777777777777777777777777777777777777777777";
1310 let id = "acdp://r/12345678-1234-4321-8123-000000000008";
1311 s.put(expired_body(
1312 id,
1313 lin,
1314 "old",
1315 chrono::Utc::now() - Duration::hours(1),
1316 ))
1317 .unwrap();
1318 let resp = s.search(&SearchParams::default(), None, true).unwrap();
1319 assert!(
1320 resp.matches.is_empty(),
1321 "expired must not surface under status=active default"
1322 );
1323 // Asking for `expired` SHOULD surface it.
1324 let resp = s
1325 .search(
1326 &SearchParams {
1327 status: Some("expired".into()),
1328 ..Default::default()
1329 },
1330 None,
1331 true,
1332 )
1333 .unwrap();
1334 assert_eq!(resp.matches.len(), 1);
1335 }
1336
1337 /// BUG-10 — date/time filter is honored.
1338 #[test]
1339 fn search_filters_by_created_after() {
1340 let s = InMemoryStore::new();
1341 let lin = "lin:sha256:8888888888888888888888888888888888888888888888888888888888888888";
1342 let mut body = fake_body(
1343 "acdp://r/12345678-1234-4321-8123-000000000009",
1344 lin,
1345 "match",
1346 );
1347 body.created_at = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00.000Z")
1348 .unwrap()
1349 .with_timezone(&chrono::Utc);
1350 s.put(body).unwrap();
1351 // `created_after` AFTER body.created_at → 0 matches
1352 let resp = s
1353 .search(
1354 &SearchParams {
1355 created_after: Some("2026-02-01T00:00:00.000Z".into()),
1356 ..Default::default()
1357 },
1358 None,
1359 true,
1360 )
1361 .unwrap();
1362 assert_eq!(resp.matches.len(), 0);
1363 // `created_after` BEFORE body.created_at → 1 match
1364 let resp = s
1365 .search(
1366 &SearchParams {
1367 created_after: Some("2025-12-01T00:00:00.000Z".into()),
1368 ..Default::default()
1369 },
1370 None,
1371 true,
1372 )
1373 .unwrap();
1374 assert_eq!(resp.matches.len(), 1);
1375 }
1376
1377 #[test]
1378 fn search_invalid_rfc3339_filter_rejected() {
1379 let s = InMemoryStore::new();
1380 let err = s
1381 .search(
1382 &SearchParams {
1383 created_after: Some("not-a-date".into()),
1384 ..Default::default()
1385 },
1386 None,
1387 true,
1388 )
1389 .unwrap_err();
1390 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1391 }
1392
1393 /// BUG-10 cursor round-trips and pages correctly.
1394 #[test]
1395 fn search_cursor_pages_results() {
1396 let s = InMemoryStore::new();
1397 let lin = "lin:sha256:9999999999999999999999999999999999999999999999999999999999999999";
1398 // Insert 5 contexts with distinct created_at so order is deterministic.
1399 let base = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00.000Z")
1400 .unwrap()
1401 .with_timezone(&chrono::Utc);
1402 for i in 0..5u8 {
1403 let mut body = fake_body(
1404 &format!("acdp://r/12345678-1234-4321-8123-00000000010{i}"),
1405 lin,
1406 "match",
1407 );
1408 body.created_at = base + chrono::Duration::minutes(i as i64);
1409 s.put(body).unwrap();
1410 }
1411 let p1 = s
1412 .search(
1413 &SearchParams {
1414 limit: Some(2),
1415 ..Default::default()
1416 },
1417 None,
1418 true,
1419 )
1420 .unwrap();
1421 assert_eq!(p1.matches.len(), 2);
1422 let cursor = p1.next_cursor.expect("page 1 should carry a cursor");
1423 let p2 = s
1424 .search(
1425 &SearchParams {
1426 limit: Some(2),
1427 cursor: Some(cursor.clone()),
1428 ..Default::default()
1429 },
1430 None,
1431 true,
1432 )
1433 .unwrap();
1434 assert_eq!(p2.matches.len(), 2);
1435 // No overlap between page 1 and page 2.
1436 for r in &p2.matches {
1437 assert!(
1438 !p1.matches.iter().any(|q| q.ctx_id == r.ctx_id),
1439 "page 2 overlapped page 1"
1440 );
1441 }
1442 // BUG-08: total_estimate MUST be stable across pages — captured
1443 // BEFORE cursor filtering. Before the fix, page 2 reported a
1444 // smaller total than page 1 (the remaining-from-cursor count).
1445 assert_eq!(
1446 p1.total_estimate, p2.total_estimate,
1447 "total_estimate MUST be stable across pages (BUG-08); \
1448 p1={:?}, p2={:?}",
1449 p1.total_estimate, p2.total_estimate
1450 );
1451 assert_eq!(
1452 p1.total_estimate,
1453 Some(5),
1454 "total_estimate MUST reflect total matches across all pages, got {:?}",
1455 p1.total_estimate
1456 );
1457 }
1458
1459 #[test]
1460 fn search_limit_zero_does_not_underflow() {
1461 // P1-1: limit=0 with ≥1 match previously computed `limit - 1`
1462 // (debug panic / release wrap). It MUST be clamped to ≥1.
1463 let s = InMemoryStore::new();
1464 let lin = "lin:sha256:8888888888888888888888888888888888888888888888888888888888888888";
1465 for i in 0..3u8 {
1466 let body = fake_body(
1467 &format!("acdp://r/12345678-1234-4321-8123-00000000020{i}"),
1468 lin,
1469 "match",
1470 );
1471 s.put(body).unwrap();
1472 }
1473 let page = s
1474 .search(
1475 &SearchParams {
1476 limit: Some(0),
1477 ..Default::default()
1478 },
1479 None,
1480 true,
1481 )
1482 .expect("limit=0 must not panic or error");
1483 // Clamped to 1: one result, and a cursor since more remain.
1484 assert_eq!(page.matches.len(), 1);
1485 assert!(page.next_cursor.is_some());
1486 }
1487
1488 #[test]
1489 fn search_malformed_cursor_rejected() {
1490 let s = InMemoryStore::new();
1491 let err = s
1492 .search(
1493 &SearchParams {
1494 cursor: Some("not_base64!@#".into()),
1495 ..Default::default()
1496 },
1497 None,
1498 true,
1499 )
1500 .unwrap_err();
1501 assert!(matches!(err, AcdpError::InvalidCursor(_)));
1502 }
1503
1504 /// FEAT-04: a cursor whose embedded mint timestamp is older than
1505 /// `CURSOR_TTL` MUST surface as `CursorExpired`, not `InvalidCursor`.
1506 /// Clients distinguish "you typo'd the cursor" from "your cursor
1507 /// aged out, restart the scan from page 1".
1508 #[test]
1509 fn search_aged_cursor_rejected_as_cursor_expired() {
1510 use base64::{engine::general_purpose::STANDARD, Engine};
1511 let s = InMemoryStore::new();
1512 // Mint a cursor 7200s in the past — twice the 3600s TTL.
1513 let stale_mint_ms = chrono::Utc::now().timestamp_millis() - 7200 * 1000;
1514 let aged = STANDARD.encode(format!(
1515 "{stale_mint_ms}:0:acdp://r/12345678-1234-4321-8123-1234567812aa"
1516 ));
1517 let err = s
1518 .search(
1519 &SearchParams {
1520 cursor: Some(aged),
1521 ..Default::default()
1522 },
1523 None,
1524 true,
1525 )
1526 .unwrap_err();
1527 assert!(
1528 matches!(err, AcdpError::CursorExpired),
1529 "expired cursor MUST surface CursorExpired, got {err:?}"
1530 );
1531 }
1532
1533 #[test]
1534 fn search_filters_by_status_default_active() {
1535 let s = InMemoryStore::new();
1536 let lin = "lin:sha256:4444444444444444444444444444444444444444444444444444444444444444";
1537 let v1 = "acdp://r/12345678-1234-4321-8123-000000000004";
1538 let v2 = "acdp://r/12345678-1234-4321-8123-000000000005";
1539 s.put(fake_body(v1, lin, "old")).unwrap();
1540 s.put(fake_body(v2, lin, "new")).unwrap();
1541 s.mark_superseded(&CtxId(v1.into())).unwrap();
1542 let resp = s
1543 .search(
1544 &SearchParams {
1545 q: Some("old".into()),
1546 ..Default::default()
1547 },
1548 None,
1549 true,
1550 )
1551 .unwrap();
1552 // Only `active` matches — superseded "old" filtered out.
1553 assert_eq!(resp.matches.len(), 0);
1554 let resp = s
1555 .search(
1556 &SearchParams {
1557 q: Some("new".into()),
1558 ..Default::default()
1559 },
1560 None,
1561 true,
1562 )
1563 .unwrap();
1564 assert_eq!(resp.matches.len(), 1);
1565 }
1566
1567 /// End-to-end: producer → server pipeline using the actual signing
1568 /// path. Uses a builder and the `RegistryServer` (see server.rs)
1569 /// to confirm the integration story.
1570 #[test]
1571 fn store_round_trip_from_real_publish_request() {
1572 use crate::registry::server::RegistryServer;
1573 use acdp_types::capabilities::{CapabilitiesDocument, Limits};
1574
1575 let key = SigningKey::from_bytes(&[7u8; 32]);
1576 let p = Producer::new(
1577 key,
1578 AgentDid::new("did:web:agents.example.com:test"),
1579 "did:web:agents.example.com:test#key-1",
1580 );
1581 let req = p
1582 .publish_request()
1583 .title("hello")
1584 .context_type(ContextType::DataSnapshot)
1585 .visibility(Visibility::Public)
1586 .build()
1587 .unwrap();
1588
1589 let caps = CapabilitiesDocument {
1590 acdp_version: "0.1.0".into(),
1591 registry_did: "did:web:registry.example.com".into(),
1592 supported_signature_algorithms: vec!["ed25519".into()],
1593 supported_did_methods: vec!["did:web".into()],
1594 profiles: vec!["acdp-registry-core".into()],
1595 limits: Limits {
1596 max_payload_bytes: 1_048_576,
1597 max_embedded_bytes: 65_536,
1598 idempotency_key_ttl_seconds: None,
1599 max_publish_per_minute: None,
1600 },
1601 read_authentication_methods: vec![],
1602 anonymous_public_reads: true,
1603 supports_idempotency_key: false,
1604 extensions: Default::default(),
1605 };
1606
1607 let server = RegistryServer::new(InMemoryStore::new(), caps, "registry.example.com");
1608 let resp = server.publish_unverified_for_tests(&req).unwrap();
1609 assert_eq!(resp.version, 1);
1610 let ctx = server.retrieve(&resp.ctx_id, None).unwrap().unwrap();
1611 assert_eq!(ctx.body.title, "hello");
1612
1613 // Ignore unused imports under different feature combinations
1614 let _: Option<DataPeriod> = ctx.body.data_period.clone();
1615 }
1616}