Skip to main content

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(&params.created_after)?;
906        let created_before = parse_opt_rfc3339(&params.created_before)?;
907        let dp_start_after = parse_opt_rfc3339(&params.data_period_start_after)?;
908        let dp_end_before = parse_opt_rfc3339(&params.data_period_end_before)?;
909        let expires_after = parse_opt_rfc3339(&params.expires_after)?;
910        let expires_before = parse_opt_rfc3339(&params.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}