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
333impl PublishCommitOutcome {
334    /// The response either way, discarding the insert/replay distinction.
335    ///
336    /// This is what every `publish_*` entry point that predates
337    /// [`crate::registry::server::RegistryServer::publish_verified_in_tenant_with_outcome`]
338    /// returns,
339    /// and the reason the distinction was invisible to callers: a registry
340    /// front-end answering `POST /contexts` needs it to choose between
341    /// `201 Created` + `Location` on a fresh publish and `200 OK` on an
342    /// idempotent replay (RFC-ACDP-0003 idem-002, which requires 200 and
343    /// explicitly NOT 201). Prefer the `*_with_outcome` entry points when
344    /// that choice is yours to make.
345    pub fn into_response(self) -> PublishResponse {
346        match self {
347            Self::Inserted(r) | Self::IdempotentReplay(r) => r,
348        }
349    }
350
351    /// Borrow the response without consuming the outcome.
352    pub fn response(&self) -> &PublishResponse {
353        match self {
354            Self::Inserted(r) | Self::IdempotentReplay(r) => r,
355        }
356    }
357
358    /// `true` when this publish was served from a prior record rather than
359    /// newly persisted — i.e. the caller must answer `200`, not `201`.
360    pub fn is_replay(&self) -> bool {
361        matches!(self, Self::IdempotentReplay(_))
362    }
363}
364
365/// Cached publish response keyed by `(agent_id, idempotency_key)`
366/// (RFC-ACDP-0003 §6).
367#[derive(Debug, Clone)]
368pub struct IdempotencyRecord {
369    /// The original request's `content_hash`. A retry with the same key
370    /// but a different hash MUST be rejected as `duplicate_publish`.
371    pub content_hash: acdp_types::primitives::ContentHash,
372    /// The response the registry returned on the first acceptance.
373    pub response: acdp_types::publish::PublishResponse,
374    /// Eviction time (TTL window from caps.limits.idempotency_key_ttl_seconds).
375    pub expires_at: chrono::DateTime<chrono::Utc>,
376}
377
378// ── In-memory reference implementation ───────────────────────────────────────
379
380/// Minimal in-memory backend. Not durable; intended for tests and
381/// prototyping. Concurrency-safe (a single `Mutex` over the table).
382#[derive(Default)]
383pub struct InMemoryStore {
384    inner: Mutex<Inner>,
385}
386
387#[derive(Default)]
388struct Inner {
389    /// All contexts keyed by `ctx_id`. Insertion-ordered per lineage
390    /// thanks to the parallel `lineages` index.
391    by_ctx: std::collections::BTreeMap<String, FullContext>,
392    /// `lineage_id -> [ctx_id, ctx_id, ...]` in publish order.
393    lineages: std::collections::BTreeMap<String, Vec<String>>,
394    /// `(agent_did, idempotency_key) -> record` (RFC-ACDP-0003 §6).
395    idempotency: std::collections::HashMap<(String, String), IdempotencyRecord>,
396}
397
398impl InMemoryStore {
399    /// Construct an empty store.
400    pub fn new() -> Self {
401        Self::default()
402    }
403
404    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
405        self.inner.lock().expect("InMemoryStore mutex poisoned")
406    }
407}
408
409/// RFC-ACDP-0004 §4 (as amended by RFC-ACDP-0013 §7.2) — derive the
410/// served `status` at read time from the stored state, the lifecycle
411/// event history, and the clock, so a registry that does not run a
412/// janitor still surfaces the correct lifecycle status.
413///
414/// Precedence: `retracted` > `superseded` > `expired` > `active`.
415/// Retraction state is derived from `lifecycle_events` (§7.1) and
416/// dominates everything — including a stored `Superseded` and a lapsed
417/// `expires_at`; republication removes the retraction from the
418/// derivation (not the history) and the status re-derives as though
419/// never retracted.
420pub(crate) fn project_status(
421    state: &RegistryState,
422    body: &Body,
423    now: chrono::DateTime<chrono::Utc>,
424) -> Status {
425    if state.is_retracted() {
426        return Status::Retracted;
427    }
428    match &state.status {
429        Status::Active => match body.expires_at {
430            Some(exp) if exp <= now => Status::Expired,
431            _ => Status::Active,
432        },
433        other => other.clone(),
434    }
435}
436
437/// Materialize the effective view of a stored context: applies
438/// [`project_status`] to override the stored status when retracted or
439/// expired.
440pub(crate) fn project_context(
441    mut ctx: FullContext,
442    now: chrono::DateTime<chrono::Utc>,
443) -> FullContext {
444    ctx.registry_state.status = project_status(&ctx.registry_state, &ctx.body, now);
445    ctx
446}
447
448/// RFC-ACDP-0008 §4.5 search-disclosure rule.
449///
450/// Note the asymmetry vs retrieval: a `Private` context surfaces in search
451/// **only** to its producer — audience members must already know the
452/// `ctx_id` to fetch it. `Restricted` surfaces to producer + audience.
453///
454/// `anonymous_public_reads` mirrors the capability advertisement
455/// (RFC-ACDP-0008 §4.5): a registry that does NOT permit anonymous
456/// public reads MUST suppress public contexts for unauthenticated
457/// callers in both `retrieve` and `search`. The retrieval helper
458/// already consults this flag; this function pulls it through to the
459/// store-side search path (BUG-02).
460fn can_surface_in_search(
461    body: &Body,
462    requester: Option<&AgentDid>,
463    anonymous_public_reads: bool,
464) -> bool {
465    match body.visibility {
466        Visibility::Public => anonymous_public_reads || requester.is_some(),
467        Visibility::Restricted => match requester {
468            None => false,
469            Some(r) => {
470                r == &body.agent_id
471                    || body
472                        .audience
473                        .as_deref()
474                        .is_some_and(|a| a.iter().any(|d| d == r))
475            }
476        },
477        Visibility::Private => requester == Some(&body.agent_id),
478    }
479}
480
481impl RegistryStore for InMemoryStore {
482    fn put(&self, body: Body) -> Result<(), AcdpError> {
483        let ctx_id = body.ctx_id.0.clone();
484        let lineage_id = body.lineage_id.0.clone();
485        let ctx = FullContext {
486            body,
487            registry_state: RegistryState {
488                status: Status::Active,
489                lifecycle_events: None,
490                event_receipts: None,
491                extensions: Default::default(),
492            },
493            registry_receipt: None,
494            lineage_head_receipt: None,
495            log_inclusion: None,
496            extensions: Default::default(),
497            raw_body: None,
498        };
499        let mut g = self.lock();
500        if g.by_ctx.contains_key(&ctx_id) {
501            return Err(AcdpError::SchemaViolation(format!(
502                "duplicate ctx_id '{ctx_id}' in store"
503            )));
504        }
505        g.by_ctx.insert(ctx_id.clone(), ctx);
506        g.lineages.entry(lineage_id).or_default().push(ctx_id);
507        Ok(())
508    }
509
510    fn get(&self, ctx_id: &CtxId) -> Result<Option<FullContext>, AcdpError> {
511        let now = chrono::Utc::now();
512        Ok(self
513            .lock()
514            .by_ctx
515            .get(ctx_id.as_str())
516            .cloned()
517            .map(|c| project_context(c, now)))
518    }
519
520    fn lineage(&self, lineage_id: &LineageId) -> Result<Vec<FullContext>, AcdpError> {
521        let now = chrono::Utc::now();
522        let g = self.lock();
523        let Some(ids) = g.lineages.get(lineage_id.as_str()) else {
524            return Ok(Vec::new());
525        };
526        Ok(ids
527            .iter()
528            .filter_map(|id| g.by_ctx.get(id).cloned().map(|c| project_context(c, now)))
529            .collect())
530    }
531
532    fn current(&self, lineage_id: &LineageId) -> Result<Option<FullContext>, AcdpError> {
533        let now = chrono::Utc::now();
534        let g = self.lock();
535        let Some(ids) = g.lineages.get(lineage_id.as_str()) else {
536            return Ok(None);
537        };
538        // RFC-ACDP-0004 §5: "Returns the unique version that has no
539        // successor. If no such version exists, returns not_found."
540        // Walk newest-to-oldest and return the first version that is
541        // neither `Superseded` nor `Retracted`. Both `Active` and
542        // `Expired` count — an expired body that hasn't been replaced
543        // is still the latest, and the consumer needs to see it (with
544        // status=Expired) to know it has lapsed. A retracted version is
545        // NEVER a head (RFC-ACDP-0013 §8.3): it has been explicitly
546        // withdrawn from reliance, and falling back to a superseded
547        // predecessor would silently serve a replaced context — so
548        // retracting a linear lineage's head yields `None` (fixture
549        // `lc-003`).
550        //
551        // BUG-04: an earlier fallback returned the last entry even when
552        // every version was `Superseded`; that's a protocol violation.
553        // Now we return `None` instead.
554        for id in ids.iter().rev() {
555            if let Some(ctx) = g.by_ctx.get(id) {
556                let projected = project_context(ctx.clone(), now);
557                if !matches!(
558                    projected.registry_state.status,
559                    Status::Superseded | Status::Retracted
560                ) {
561                    return Ok(Some(projected));
562                }
563            }
564        }
565        Ok(None)
566    }
567
568    fn commit_lifecycle_event(
569        &self,
570        event: &LifecycleEvent,
571    ) -> Result<LifecycleCommitOutcome, AcdpError> {
572        let now = chrono::Utc::now();
573        let mut g = self.lock();
574        let ctx = g.by_ctx.get_mut(event.ctx_id.as_str()).ok_or_else(|| {
575            AcdpError::NotFound(format!(
576                "context '{}' not found in this registry",
577                event.ctx_id
578            ))
579        })?;
580        let events = ctx
581            .registry_state
582            .lifecycle_events
583            .as_deref()
584            .unwrap_or(&[]);
585
586        // §6 retry idempotency / duplicate event_id (step 2).
587        if let Some(prior) = events.iter().find(|e| e.event_id == event.event_id) {
588            if prior == event {
589                return Ok(LifecycleCommitOutcome::IdempotentReplay(project_context(
590                    ctx.clone(),
591                    now,
592                )));
593            }
594            return Err(AcdpError::SchemaViolation(format!(
595                "event_id '{}' was already appended with different content \
596                 (RFC-ACDP-0013 §4: event_id MUST be unique within lifecycle_events)",
597                event.event_id
598            )));
599        }
600
601        // §6 step 4 — strict retracted/republished alternation against
602        // the §7.1 retraction state, under the same lock as the append.
603        let currently_retracted = acdp_types::lifecycle::retraction_state(events);
604        match &event.event_type {
605            LifecycleEventType::Retracted if currently_retracted => {
606                return Err(AcdpError::InvalidLifecycleTransition(format!(
607                    "context '{}' is already retracted — double retract violates the \
608                     strict alternation rule (RFC-ACDP-0013 §6 step 4)",
609                    event.ctx_id
610                )));
611            }
612            LifecycleEventType::Republished if !currently_retracted => {
613                return Err(AcdpError::InvalidLifecycleTransition(format!(
614                    "context '{}' is not retracted — republish requires a prior \
615                     retraction (RFC-ACDP-0013 §6 step 4)",
616                    event.ctx_id
617                )));
618            }
619            LifecycleEventType::Other(other) => {
620                return Err(AcdpError::SchemaViolation(format!(
621                    "event_type '{other}' is not registered for acceptance in 0.3.0 — \
622                     only 'retracted' and 'republished' transition state \
623                     (RFC-ACDP-0013 §7.3)"
624                )));
625            }
626            LifecycleEventType::Retracted | LifecycleEventType::Republished => {}
627        }
628
629        // §6 step 5 — append atomically with the status effect. The
630        // served status is DERIVED from this same array (§7.2
631        // precedence, applied by `project_status`), so appending the
632        // event IS the status change; the stored status keeps tracking
633        // the supersession fact only.
634        ctx.registry_state
635            .lifecycle_events
636            .get_or_insert_with(Vec::new)
637            .push(event.clone());
638        Ok(LifecycleCommitOutcome::Applied(project_context(
639            ctx.clone(),
640            now,
641        )))
642    }
643
644    fn mark_superseded(&self, ctx_id: &CtxId) -> Result<(), AcdpError> {
645        let mut g = self.lock();
646        if let Some(ctx) = g.by_ctx.get_mut(ctx_id.as_str()) {
647            ctx.registry_state.status = Status::Superseded;
648        }
649        Ok(())
650    }
651
652    fn first_version_ctx_id(&self, lineage_id: &LineageId) -> Result<Option<CtxId>, AcdpError> {
653        let g = self.lock();
654        Ok(g.lineages
655            .get(lineage_id.as_str())
656            .and_then(|ids| ids.first().cloned())
657            .map(CtxId))
658    }
659
660    fn idempotency_lookup(
661        &self,
662        agent_id: &AgentDid,
663        key: &str,
664    ) -> Result<Option<IdempotencyRecord>, AcdpError> {
665        // Lazy TTL eviction at lookup time keeps the table bounded
666        // without requiring a janitor — see idempotency_evict_expired.
667        self.idempotency_evict_expired(chrono::Utc::now())?;
668        let g = self.lock();
669        Ok(g.idempotency
670            .get(&(agent_id.as_str().to_string(), key.to_string()))
671            .cloned())
672    }
673
674    fn idempotency_record(
675        &self,
676        agent_id: &AgentDid,
677        key: &str,
678        hash: &acdp_types::primitives::ContentHash,
679        response: &acdp_types::publish::PublishResponse,
680        expires_at: chrono::DateTime<chrono::Utc>,
681    ) -> Result<(), AcdpError> {
682        let mut g = self.lock();
683        g.idempotency.insert(
684            (agent_id.as_str().to_string(), key.to_string()),
685            IdempotencyRecord {
686                content_hash: hash.clone(),
687                response: response.clone(),
688                expires_at,
689            },
690        );
691        Ok(())
692    }
693
694    fn idempotency_evict_expired(
695        &self,
696        now: chrono::DateTime<chrono::Utc>,
697    ) -> Result<(), AcdpError> {
698        let mut g = self.lock();
699        g.idempotency.retain(|_, r| r.expires_at > now);
700        Ok(())
701    }
702
703    fn commit_publish(&self, commit: PublishCommit<'_>) -> Result<PublishCommitOutcome, AcdpError> {
704        use crate::registry::validator::assign_identifiers;
705
706        let PublishCommit {
707            req,
708            authority,
709            idempotency,
710            // InMemoryStore does not model tenancy (it is a single-tenant
711            // reference/test backend); the durable backends honor this.
712            tenant: _,
713            receipt_minter,
714            predecessor_admission,
715        } = commit;
716        let now = chrono::Utc::now();
717        let mut g = self.lock();
718
719        // ── 1. Idempotency replay / collision ────────────────────────
720        if let Some(idem) = &idempotency {
721            let idem_key = (req.agent_id.as_str().to_string(), idem.key.to_string());
722            if let Some(prior) = g.idempotency.get(&idem_key) {
723                if prior.expires_at > now {
724                    return if prior.content_hash == req.content_hash {
725                        // idem-002: same key + same hash → replay.
726                        Ok(PublishCommitOutcome::IdempotentReplay(
727                            prior.response.clone(),
728                        ))
729                    } else {
730                        // idem-003: same key + different hash → duplicate_publish.
731                        Err(AcdpError::DuplicatePublish(format!(
732                            "Idempotency-Key '{}' was previously used by '{}' \
733                             with a different content_hash",
734                            idem.key, req.agent_id
735                        )))
736                    };
737                }
738                // Expired record — fall through and overwrite below.
739            }
740        }
741
742        // ── 2. Supersession lookups + coherence checks ──────────────
743        let first_v1 = if let Some(prev) = &req.supersedes {
744            let prev_full = g.by_ctx.get(prev.as_str()).cloned().ok_or_else(|| {
745                AcdpError::SupersededTarget {
746                    reason: acdp_primitives::error::SupersessionReason::NotFound,
747                    message: format!("supersedes target '{prev}' not found in this registry"),
748                }
749            })?;
750
751            // Producer-continuity: only the predecessor's producer (or a
752            // declared contributor) may publish a successor in its lineage.
753            // Signature verification only proves the *requester* signed
754            // their own request — it does not bind `supersedes` to the
755            // predecessor's owner. Without this check any signer could
756            // supersede another producer's context (`Superseded` side
757            // effect below + `current(lineage)` re-pointing), a full
758            // lineage takeover. RFC-ACDP-0001 §5.9 supersession is
759            // producer-scoped.
760            let is_owner = req.agent_id == prev_full.body.agent_id
761                || prev_full.body.contributors.contains(&req.agent_id);
762            if !is_owner {
763                // Uniform with the genuine not-found case above: a
764                // non-owner learns neither that the predecessor exists nor
765                // its version / superseded status (supersession existence
766                // oracle). Anyone who can legitimately read the
767                // predecessor learns nothing new from this shape.
768                return Err(AcdpError::SupersededTarget {
769                    reason: acdp_primitives::error::SupersessionReason::NotFound,
770                    message: format!("supersedes target '{prev}' not found in this registry"),
771                });
772            }
773
774            // Lineage coherence — when the producer self-verifies.
775            if let Some(declared) = &req.lineage_id {
776                if declared != &prev_full.body.lineage_id {
777                    return Err(AcdpError::SupersededTarget {
778                        reason: acdp_primitives::error::SupersessionReason::LineageMismatch,
779                        message: format!(
780                            "declared lineage_id '{declared}' ≠ predecessor's '{}'",
781                            prev_full.body.lineage_id
782                        ),
783                    });
784                }
785            }
786            // Version coherence: new.version MUST be predecessor.version + 1.
787            if req.version != prev_full.body.version + 1 {
788                return Err(AcdpError::SupersededTarget {
789                    reason: acdp_primitives::error::SupersessionReason::VersionMismatch,
790                    message: format!(
791                        "version {} ≠ predecessor.version + 1 ({})",
792                        req.version,
793                        prev_full.body.version + 1
794                    ),
795                });
796            }
797            // FEAT-01 atomicity: the check that previously raced with
798            // another concurrent publish. Now under the same lock as
799            // the insert below — exactly one of two contenders succeeds.
800            if matches!(prev_full.registry_state.status, Status::Superseded) {
801                return Err(AcdpError::SupersededTarget {
802                    reason: acdp_primitives::error::SupersessionReason::AlreadySuperseded,
803                    message: format!("supersedes target '{prev}' has already been superseded"),
804                });
805            }
806
807            // RFC-ACDP-0014 §4 `supersedes`-row enforcement. Runs AFTER
808            // producer-continuity, lineage/version coherence, and
809            // AlreadySuperseded have all passed — never earlier — so a
810            // non-owner (or wrong-tenant, on multi-tenant stores) probe
811            // is already turned away as `SupersededTarget::NotFound`
812            // above and never reaches this line. Doing this check first
813            // would make it a cross-tenant, non-owner
814            // existence-and-type oracle on the predecessor.
815            if let Some(admission) = predecessor_admission {
816                admission(&prev_full.body)?;
817            }
818
819            // Derive the v1 ctx_id from the predecessor's lineage —
820            // same logic as `first_version_ctx_id`, inlined to stay
821            // under the existing lock.
822            g.lineages
823                .get(prev_full.body.lineage_id.as_str())
824                .and_then(|ids| ids.first().cloned())
825                .map(CtxId)
826        } else {
827            None
828        };
829
830        // ── 3. Identifier assignment ────────────────────────────────
831        let validated = crate::registry::validator::ValidatedPublish {
832            recomputed_hash: req.content_hash.clone(),
833        };
834        let (ctx_id, lineage_id) =
835            assign_identifiers(authority, &req.supersedes, first_v1.as_ref(), &validated)?;
836
837        // ── 4. Build the stored Body ────────────────────────────────
838        // Single materialization point (IMP-02): the constructor copies
839        // every producer field and ms-truncates `created_at`, so this
840        // backend cannot drift from the SQL backends when a producer
841        // field is added.
842        let body =
843            Body::from_publish_request(req, ctx_id.clone(), lineage_id.clone(), authority, now);
844        let created_at = body.created_at;
845
846        // ── 5. Insert (mirrors `put` but inline so we keep the lock) ─
847        let ctx_id_str = body.ctx_id.0.clone();
848        let lineage_id_str = body.lineage_id.0.clone();
849        if g.by_ctx.contains_key(&ctx_id_str) {
850            // UUID collision is astronomically unlikely but we still
851            // surface it as a SchemaViolation rather than silently
852            // overwriting.
853            return Err(AcdpError::SchemaViolation(format!(
854                "ctx_id collision: '{ctx_id_str}' already exists"
855            )));
856        }
857        // Receipt minting (RFC-ACDP-0010) — inside the critical section,
858        // before the insert becomes visible, so a context published
859        // under the receipts profile never exists without its receipt.
860        let registry_receipt = receipt_minter.map(|mint| mint(&body)).transpose()?;
861
862        let stored = FullContext {
863            body,
864            registry_state: RegistryState {
865                status: Status::Active,
866                lifecycle_events: None,
867                event_receipts: None,
868                extensions: Default::default(),
869            },
870            registry_receipt: registry_receipt.clone(),
871            // Head receipts are ephemeral serve-time attestations —
872            // never persisted; `RegistryServer::current` mints per
873            // response (RFC-ACDP-0011 §6).
874            lineage_head_receipt: None,
875            log_inclusion: None,
876            extensions: Default::default(),
877            raw_body: None,
878        };
879        g.by_ctx.insert(ctx_id_str.clone(), stored);
880        g.lineages
881            .entry(lineage_id_str)
882            .or_default()
883            .push(ctx_id_str);
884
885        // ── 6. Mark predecessor superseded ──────────────────────────
886        if let Some(prev) = &req.supersedes {
887            if let Some(prev_ctx) = g.by_ctx.get_mut(prev.as_str()) {
888                prev_ctx.registry_state.status = Status::Superseded;
889            }
890        }
891
892        let response = PublishResponse {
893            ctx_id,
894            lineage_id,
895            version: req.version,
896            created_at,
897            status: Status::Active,
898            registry_receipt,
899        };
900
901        // ── 7. Idempotency record ───────────────────────────────────
902        if let Some(idem) = idempotency {
903            let expires_at = now + idem.ttl;
904            g.idempotency.insert(
905                (req.agent_id.as_str().to_string(), idem.key.to_string()),
906                IdempotencyRecord {
907                    content_hash: req.content_hash.clone(),
908                    response: response.clone(),
909                    expires_at,
910                },
911            );
912        }
913
914        Ok(PublishCommitOutcome::Inserted(response))
915    }
916
917    fn search(
918        &self,
919        params: &SearchParams,
920        requester: Option<&AgentDid>,
921        anonymous_public_reads: bool,
922    ) -> Result<SearchResponse, AcdpError> {
923        let g = self.lock();
924        let now = chrono::Utc::now();
925
926        let q_lower = params.q.as_deref().map(str::to_lowercase);
927        let domain = params.domain.as_deref();
928        let agent = params.agent_id.as_deref();
929        let context_type = params.context_type.as_deref();
930        let derived_from = params.derived_from.as_deref();
931        let schema_uri = params.schema_uri.as_deref();
932        let tags: Option<Vec<&str>> = params.tags.as_deref().map(|s| {
933            s.split(',')
934                .map(str::trim)
935                .filter(|t| !t.is_empty())
936                .collect()
937        });
938
939        // BUG-10: parse date-time filter params at the boundary so the
940        // hot loop just compares DateTime<Utc> values.
941        let created_after = parse_opt_rfc3339(&params.created_after)?;
942        let created_before = parse_opt_rfc3339(&params.created_before)?;
943        let dp_start_after = parse_opt_rfc3339(&params.data_period_start_after)?;
944        let dp_end_before = parse_opt_rfc3339(&params.data_period_end_before)?;
945        let expires_after = parse_opt_rfc3339(&params.expires_after)?;
946        let expires_before = parse_opt_rfc3339(&params.expires_before)?;
947
948        let mut matches: Vec<&FullContext> = g
949            .by_ctx
950            .values()
951            .filter(|ctx| {
952                let body = &ctx.body;
953
954                // RFC-ACDP-0008 §4.5 search-disclosure gate (note the
955                // private/restricted asymmetry: private contexts surface
956                // in search only to their producer).
957                if !can_surface_in_search(body, requester, anonymous_public_reads) {
958                    return false;
959                }
960
961                if let Some(q) = &q_lower {
962                    let haystack = format!(
963                        "{} {} {} {} {} {}",
964                        body.title,
965                        body.description.as_deref().unwrap_or(""),
966                        body.summary.as_deref().unwrap_or(""),
967                        body.domain.as_deref().unwrap_or(""),
968                        body.agent_id.as_str(),
969                        body.tags.as_ref().map(|t| t.join(" ")).unwrap_or_default(),
970                    )
971                    .to_lowercase();
972                    if !haystack.contains(q) {
973                        return false;
974                    }
975                }
976                if let Some(d) = domain {
977                    if body.domain.as_deref() != Some(d) {
978                        return false;
979                    }
980                }
981                if let Some(a) = agent {
982                    if body.agent_id.as_str() != a {
983                        return false;
984                    }
985                }
986                if let Some(t) = context_type {
987                    let body_type = serde_json::to_value(&body.context_type)
988                        .ok()
989                        .and_then(|v| v.as_str().map(str::to_string))
990                        .unwrap_or_default();
991                    if body_type != t {
992                        return false;
993                    }
994                }
995                if let Some(df) = derived_from {
996                    if !body.derived_from.iter().any(|c| c.as_str() == df) {
997                        return false;
998                    }
999                }
1000                if let Some(req_tags) = &tags {
1001                    let body_tags = body.tags.as_deref().unwrap_or(&[]);
1002                    if !req_tags.iter().all(|t| body_tags.iter().any(|bt| bt == t)) {
1003                        return false;
1004                    }
1005                }
1006                if let Some(uri) = schema_uri {
1007                    if body.schema_uri.as_deref() != Some(uri) {
1008                        return false;
1009                    }
1010                }
1011                if let Some(after) = created_after {
1012                    if body.created_at < after {
1013                        return false;
1014                    }
1015                }
1016                if let Some(before) = created_before {
1017                    if body.created_at > before {
1018                        return false;
1019                    }
1020                }
1021                if let Some(after) = dp_start_after {
1022                    match &body.data_period {
1023                        Some(p) if p.start >= after => {}
1024                        _ => return false,
1025                    }
1026                }
1027                if let Some(before) = dp_end_before {
1028                    match &body.data_period {
1029                        Some(p) if p.end <= before => {}
1030                        _ => return false,
1031                    }
1032                }
1033                if let Some(after) = expires_after {
1034                    match body.expires_at {
1035                        Some(e) if e >= after => {}
1036                        _ => return false,
1037                    }
1038                }
1039                if let Some(before) = expires_before {
1040                    match body.expires_at {
1041                        Some(e) if e <= before => {}
1042                        _ => return false,
1043                    }
1044                }
1045                // Status filter — registry default is `active`. Compare
1046                // against PROJECTED status so a stored-Active body whose
1047                // expires_at has passed is filtered out (RFC-ACDP-0004 §4)
1048                // and a retracted context falls out of default searches —
1049                // and out of status=superseded / status=expired even where
1050                // those facts also hold (RFC-ACDP-0013 §8.2: the §7.2
1051                // precedence applies to the filter).
1052                let want_status = params.status.as_deref().unwrap_or("active");
1053                let effective = project_status(&ctx.registry_state, body, now);
1054                if effective.as_str() != want_status {
1055                    return false;
1056                }
1057                true
1058            })
1059            .collect();
1060
1061        // Newest first; IMP-03 — fall back to ctx_id for a deterministic
1062        // total order when many contexts share a millisecond.
1063        matches.sort_by(|a, b| {
1064            b.body
1065                .created_at
1066                .cmp(&a.body.created_at)
1067                .then_with(|| a.body.ctx_id.as_str().cmp(b.body.ctx_id.as_str()))
1068        });
1069
1070        // BUG-08: capture `total_estimate` BEFORE cursor filtering so
1071        // it represents the total count across all pages (RFC-ACDP-0005
1072        // §3 — clients use this for "page 1 of N" UIs). If we captured
1073        // it after `retain`, page 2 would show "80 matches" for a
1074        // 100-item search, page 3 "60", and so on.
1075        let total_estimate = Some(matches.len() as u64);
1076
1077        // BUG-10 cursor: opaque base64 of "<created_at_ms>:<ctx_id>".
1078        // ≥1h validity is implicit — cursors do not embed a timestamp,
1079        // so they remain valid until the underlying context is deleted.
1080        let cursor_anchor = params
1081            .cursor
1082            .as_deref()
1083            .map(decode_cursor)
1084            .transpose()?
1085            .flatten();
1086        if let Some((anchor_ms, anchor_id)) = &cursor_anchor {
1087            matches.retain(|c| {
1088                let ms = c.body.created_at.timestamp_millis();
1089                ms < *anchor_ms || (ms == *anchor_ms && c.body.ctx_id.as_str() > anchor_id.as_str())
1090            });
1091        }
1092
1093        // Clamp into [1, 100]: `limit` is attacker-controlled and never
1094        // lower-bounded. `limit=0` with ≥1 match would compute `limit - 1`
1095        // below → debug-build subtraction panic (request-thread DoS) /
1096        // release-build wrap to usize::MAX → broken pagination.
1097        let limit = params.limit.unwrap_or(50).clamp(1, 100) as usize;
1098        let next_cursor = if matches.len() > limit {
1099            matches.get(limit - 1).map(|c| {
1100                encode_cursor(c.body.created_at.timestamp_millis(), c.body.ctx_id.as_str())
1101            })
1102        } else {
1103            None
1104        };
1105
1106        let projected: Vec<SearchResult> = matches
1107            .iter()
1108            .take(limit)
1109            .map(|ctx| SearchResult {
1110                ctx_id: ctx.body.ctx_id.clone(),
1111                lineage_id: ctx.body.lineage_id.clone(),
1112                agent_id: ctx.body.agent_id.clone(),
1113                title: ctx.body.title.clone(),
1114                summary: ctx.body.summary.clone(),
1115                context_type: ctx.body.context_type.clone(),
1116                domain: ctx.body.domain.clone(),
1117                created_at: ctx.body.created_at,
1118                status: project_status(&ctx.registry_state, &ctx.body, now),
1119                // RFC-ACDP-0008 §4.5: only disclose visibility when the
1120                // requester is authorized for it. Public is always safe.
1121                // For restricted/private, the search filter above guarantees
1122                // the requester is producer-or-audience, so it's safe to
1123                // surface the label.
1124                visibility: Some(ctx.body.visibility.clone()),
1125            })
1126            .collect();
1127
1128        Ok(SearchResponse {
1129            matches: projected,
1130            total_estimate,
1131            next_cursor,
1132        })
1133    }
1134}
1135
1136/// Parse an optional RFC 3339 string parameter; surface a
1137/// [`AcdpError::SchemaViolation`] on malformed input.
1138fn parse_opt_rfc3339(
1139    s: &Option<String>,
1140) -> Result<Option<chrono::DateTime<chrono::Utc>>, AcdpError> {
1141    let Some(raw) = s.as_deref() else {
1142        return Ok(None);
1143    };
1144    let dt = chrono::DateTime::parse_from_rfc3339(raw)
1145        .map_err(|e| AcdpError::SchemaViolation(format!("malformed datetime '{raw}': {e}")))?;
1146    Ok(Some(dt.with_timezone(&chrono::Utc)))
1147}
1148
1149/// Cursor TTL — clients SHOULD re-fetch after this window.
1150/// RFC-ACDP-0005 §3 leaves the exact value to implementations; 1 hour
1151/// matches the common "≥1h" cursor-validity expectation.
1152const CURSOR_TTL: chrono::Duration = chrono::Duration::seconds(3600);
1153
1154/// Opaque cursor encoding — base64 of
1155/// `<mint_unix_ms>:<created_at_millis>:<ctx_id>`.
1156///
1157/// The `mint_unix_ms` prefix lets [`decode_cursor`] enforce the
1158/// `CURSOR_TTL` window and surface `AcdpError::CursorExpired` rather
1159/// than silently accepting an ancient cursor (FEAT-04). Plain
1160/// `STANDARD` engine so cursors are stable across machines.
1161fn encode_cursor(created_at_ms: i64, ctx_id: &str) -> String {
1162    use base64::{engine::general_purpose::STANDARD, Engine};
1163    let mint_ms = chrono::Utc::now().timestamp_millis();
1164    STANDARD.encode(format!("{mint_ms}:{created_at_ms}:{ctx_id}"))
1165}
1166
1167fn decode_cursor(s: &str) -> Result<Option<(i64, String)>, AcdpError> {
1168    use base64::{engine::general_purpose::STANDARD, Engine};
1169    let bytes = STANDARD
1170        .decode(s)
1171        .map_err(|_| AcdpError::InvalidCursor("cursor is not valid base64".into()))?;
1172    let decoded = String::from_utf8(bytes)
1173        .map_err(|_| AcdpError::InvalidCursor("cursor is not utf-8".into()))?;
1174    // Format: "<mint_ms>:<anchor_ms>:<ctx_id>". The mint timestamp
1175    // prefix is FEAT-04 expiry tracking.
1176    let mut parts = decoded.splitn(3, ':');
1177    let mint_str = parts
1178        .next()
1179        .ok_or_else(|| AcdpError::InvalidCursor("cursor missing mint timestamp".into()))?;
1180    let anchor_str = parts
1181        .next()
1182        .ok_or_else(|| AcdpError::InvalidCursor("cursor missing anchor timestamp".into()))?;
1183    let ctx_id = parts
1184        .next()
1185        .ok_or_else(|| AcdpError::InvalidCursor("cursor missing ctx_id".into()))?;
1186    let mint_ms: i64 = mint_str
1187        .parse()
1188        .map_err(|_| AcdpError::InvalidCursor("cursor mint millis is not an integer".into()))?;
1189    let anchor_ms: i64 = anchor_str
1190        .parse()
1191        .map_err(|_| AcdpError::InvalidCursor("cursor anchor millis is not an integer".into()))?;
1192
1193    // FEAT-04: reject cursors older than CURSOR_TTL with the
1194    // dedicated `cursor_expired` wire code so clients can distinguish
1195    // "you typo'd the cursor" (InvalidCursor) from "your cursor aged
1196    // out, restart the scan from page 1" (CursorExpired).
1197    let now = chrono::Utc::now().timestamp_millis();
1198    let age_ms = now.saturating_sub(mint_ms);
1199    if age_ms > CURSOR_TTL.num_milliseconds() {
1200        // CursorExpired is a unit variant in the wire-error mapping;
1201        // the diagnostic ("aged Xms ago") is intentionally surfaced
1202        // via Display rather than a payload so it round-trips through
1203        // `AcdpError::from_wire_error`.
1204        return Err(AcdpError::CursorExpired);
1205    }
1206    Ok(Some((anchor_ms, ctx_id.to_string())))
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211    use super::*;
1212    use acdp_crypto::SigningKey;
1213    use acdp_producer::Producer;
1214    use acdp_types::body::{DataPeriod, Signature};
1215    use acdp_types::primitives::{AgentDid, ContentHash, ContextType, Visibility};
1216    use chrono::Utc;
1217
1218    fn fake_body(ctx_id: &str, lineage_id: &str, title: &str) -> Body {
1219        Body {
1220            ctx_id: CtxId(ctx_id.into()),
1221            lineage_id: LineageId(lineage_id.into()),
1222            origin_registry: "registry.example.com".into(),
1223            created_at: Utc::now(),
1224            content_hash: ContentHash("sha256:0".into()),
1225            signature: Signature {
1226                algorithm: "ed25519".into(),
1227                key_id: "did:web:agents.example.com:test#key-1".into(),
1228                value: "A".repeat(88),
1229            },
1230            version: 1,
1231            supersedes: None,
1232            agent_id: AgentDid::new("did:web:agents.example.com:test"),
1233            contributors: vec![],
1234            title: title.into(),
1235            context_type: ContextType::DataSnapshot,
1236            data_refs: vec![],
1237            derived_from: vec![],
1238            visibility: Visibility::Public,
1239            audience: None,
1240            acdp_version: None,
1241            description: None,
1242            summary: None,
1243            tags: None,
1244            domain: None,
1245            expires_at: None,
1246            data_period: None,
1247            metadata: None,
1248            schema_uri: None,
1249            anchors: None,
1250            extensions: Default::default(),
1251        }
1252    }
1253
1254    #[test]
1255    fn put_get_round_trip() {
1256        let s = InMemoryStore::new();
1257        let id = "acdp://r/12345678-1234-4321-8123-123456781234";
1258        let lin = "lin:sha256:1111111111111111111111111111111111111111111111111111111111111111";
1259        s.put(fake_body(id, lin, "A")).unwrap();
1260        let got = s.get(&CtxId(id.into())).unwrap().unwrap();
1261        assert_eq!(got.body.title, "A");
1262        assert!(matches!(got.registry_state.status, Status::Active));
1263    }
1264
1265    #[test]
1266    fn lineage_orders_by_publish_order() {
1267        let s = InMemoryStore::new();
1268        let lin = "lin:sha256:2222222222222222222222222222222222222222222222222222222222222222";
1269        let v1 = "acdp://r/12345678-1234-4321-8123-000000000001";
1270        let v2 = "acdp://r/12345678-1234-4321-8123-000000000002";
1271        s.put(fake_body(v1, lin, "v1")).unwrap();
1272        s.put(fake_body(v2, lin, "v2")).unwrap();
1273        let lineage = s.lineage(&LineageId(lin.into())).unwrap();
1274        assert_eq!(lineage.len(), 2);
1275        assert_eq!(lineage[0].body.title, "v1");
1276        assert_eq!(lineage[1].body.title, "v2");
1277    }
1278
1279    #[test]
1280    fn supersession_marks_predecessor() {
1281        let s = InMemoryStore::new();
1282        let lin = "lin:sha256:3333333333333333333333333333333333333333333333333333333333333333";
1283        let v1 = "acdp://r/12345678-1234-4321-8123-000000000003";
1284        s.put(fake_body(v1, lin, "v1")).unwrap();
1285        s.mark_superseded(&CtxId(v1.into())).unwrap();
1286        let got = s.get(&CtxId(v1.into())).unwrap().unwrap();
1287        assert!(matches!(got.registry_state.status, Status::Superseded));
1288    }
1289
1290    // BUG-11 — Status::Expired derived from body.expires_at at read time.
1291
1292    fn expired_body(
1293        ctx_id: &str,
1294        lineage_id: &str,
1295        title: &str,
1296        expires_at: chrono::DateTime<chrono::Utc>,
1297    ) -> Body {
1298        let mut b = fake_body(ctx_id, lineage_id, title);
1299        b.expires_at = Some(expires_at);
1300        b
1301    }
1302
1303    #[test]
1304    fn get_projects_active_to_expired_when_past_expires_at() {
1305        use chrono::Duration;
1306        let s = InMemoryStore::new();
1307        let lin = "lin:sha256:5555555555555555555555555555555555555555555555555555555555555555";
1308        let id = "acdp://r/12345678-1234-4321-8123-000000000006";
1309        s.put(expired_body(
1310            id,
1311            lin,
1312            "old",
1313            chrono::Utc::now() - Duration::hours(1),
1314        ))
1315        .unwrap();
1316        let got = s.get(&CtxId(id.into())).unwrap().unwrap();
1317        assert!(
1318            matches!(got.registry_state.status, Status::Expired),
1319            "expected Status::Expired projection, got {:?}",
1320            got.registry_state.status
1321        );
1322    }
1323
1324    #[test]
1325    fn get_keeps_active_when_expires_at_in_future() {
1326        use chrono::Duration;
1327        let s = InMemoryStore::new();
1328        let lin = "lin:sha256:6666666666666666666666666666666666666666666666666666666666666666";
1329        let id = "acdp://r/12345678-1234-4321-8123-000000000007";
1330        s.put(expired_body(
1331            id,
1332            lin,
1333            "fresh",
1334            chrono::Utc::now() + Duration::hours(1),
1335        ))
1336        .unwrap();
1337        let got = s.get(&CtxId(id.into())).unwrap().unwrap();
1338        assert!(matches!(got.registry_state.status, Status::Active));
1339    }
1340
1341    #[test]
1342    fn search_status_active_filters_out_expired() {
1343        use chrono::Duration;
1344        let s = InMemoryStore::new();
1345        let lin = "lin:sha256:7777777777777777777777777777777777777777777777777777777777777777";
1346        let id = "acdp://r/12345678-1234-4321-8123-000000000008";
1347        s.put(expired_body(
1348            id,
1349            lin,
1350            "old",
1351            chrono::Utc::now() - Duration::hours(1),
1352        ))
1353        .unwrap();
1354        let resp = s.search(&SearchParams::default(), None, true).unwrap();
1355        assert!(
1356            resp.matches.is_empty(),
1357            "expired must not surface under status=active default"
1358        );
1359        // Asking for `expired` SHOULD surface it.
1360        let resp = s
1361            .search(
1362                &SearchParams {
1363                    status: Some("expired".into()),
1364                    ..Default::default()
1365                },
1366                None,
1367                true,
1368            )
1369            .unwrap();
1370        assert_eq!(resp.matches.len(), 1);
1371    }
1372
1373    /// BUG-10 — date/time filter is honored.
1374    #[test]
1375    fn search_filters_by_created_after() {
1376        let s = InMemoryStore::new();
1377        let lin = "lin:sha256:8888888888888888888888888888888888888888888888888888888888888888";
1378        let mut body = fake_body(
1379            "acdp://r/12345678-1234-4321-8123-000000000009",
1380            lin,
1381            "match",
1382        );
1383        body.created_at = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00.000Z")
1384            .unwrap()
1385            .with_timezone(&chrono::Utc);
1386        s.put(body).unwrap();
1387        // `created_after` AFTER body.created_at → 0 matches
1388        let resp = s
1389            .search(
1390                &SearchParams {
1391                    created_after: Some("2026-02-01T00:00:00.000Z".into()),
1392                    ..Default::default()
1393                },
1394                None,
1395                true,
1396            )
1397            .unwrap();
1398        assert_eq!(resp.matches.len(), 0);
1399        // `created_after` BEFORE body.created_at → 1 match
1400        let resp = s
1401            .search(
1402                &SearchParams {
1403                    created_after: Some("2025-12-01T00:00:00.000Z".into()),
1404                    ..Default::default()
1405                },
1406                None,
1407                true,
1408            )
1409            .unwrap();
1410        assert_eq!(resp.matches.len(), 1);
1411    }
1412
1413    #[test]
1414    fn search_invalid_rfc3339_filter_rejected() {
1415        let s = InMemoryStore::new();
1416        let err = s
1417            .search(
1418                &SearchParams {
1419                    created_after: Some("not-a-date".into()),
1420                    ..Default::default()
1421                },
1422                None,
1423                true,
1424            )
1425            .unwrap_err();
1426        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1427    }
1428
1429    /// BUG-10 cursor round-trips and pages correctly.
1430    #[test]
1431    fn search_cursor_pages_results() {
1432        let s = InMemoryStore::new();
1433        let lin = "lin:sha256:9999999999999999999999999999999999999999999999999999999999999999";
1434        // Insert 5 contexts with distinct created_at so order is deterministic.
1435        let base = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00.000Z")
1436            .unwrap()
1437            .with_timezone(&chrono::Utc);
1438        for i in 0..5u8 {
1439            let mut body = fake_body(
1440                &format!("acdp://r/12345678-1234-4321-8123-00000000010{i}"),
1441                lin,
1442                "match",
1443            );
1444            body.created_at = base + chrono::Duration::minutes(i as i64);
1445            s.put(body).unwrap();
1446        }
1447        let p1 = s
1448            .search(
1449                &SearchParams {
1450                    limit: Some(2),
1451                    ..Default::default()
1452                },
1453                None,
1454                true,
1455            )
1456            .unwrap();
1457        assert_eq!(p1.matches.len(), 2);
1458        let cursor = p1.next_cursor.expect("page 1 should carry a cursor");
1459        let p2 = s
1460            .search(
1461                &SearchParams {
1462                    limit: Some(2),
1463                    cursor: Some(cursor.clone()),
1464                    ..Default::default()
1465                },
1466                None,
1467                true,
1468            )
1469            .unwrap();
1470        assert_eq!(p2.matches.len(), 2);
1471        // No overlap between page 1 and page 2.
1472        for r in &p2.matches {
1473            assert!(
1474                !p1.matches.iter().any(|q| q.ctx_id == r.ctx_id),
1475                "page 2 overlapped page 1"
1476            );
1477        }
1478        // BUG-08: total_estimate MUST be stable across pages — captured
1479        // BEFORE cursor filtering. Before the fix, page 2 reported a
1480        // smaller total than page 1 (the remaining-from-cursor count).
1481        assert_eq!(
1482            p1.total_estimate, p2.total_estimate,
1483            "total_estimate MUST be stable across pages (BUG-08); \
1484             p1={:?}, p2={:?}",
1485            p1.total_estimate, p2.total_estimate
1486        );
1487        assert_eq!(
1488            p1.total_estimate,
1489            Some(5),
1490            "total_estimate MUST reflect total matches across all pages, got {:?}",
1491            p1.total_estimate
1492        );
1493    }
1494
1495    #[test]
1496    fn search_limit_zero_does_not_underflow() {
1497        // P1-1: limit=0 with ≥1 match previously computed `limit - 1`
1498        // (debug panic / release wrap). It MUST be clamped to ≥1.
1499        let s = InMemoryStore::new();
1500        let lin = "lin:sha256:8888888888888888888888888888888888888888888888888888888888888888";
1501        for i in 0..3u8 {
1502            let body = fake_body(
1503                &format!("acdp://r/12345678-1234-4321-8123-00000000020{i}"),
1504                lin,
1505                "match",
1506            );
1507            s.put(body).unwrap();
1508        }
1509        let page = s
1510            .search(
1511                &SearchParams {
1512                    limit: Some(0),
1513                    ..Default::default()
1514                },
1515                None,
1516                true,
1517            )
1518            .expect("limit=0 must not panic or error");
1519        // Clamped to 1: one result, and a cursor since more remain.
1520        assert_eq!(page.matches.len(), 1);
1521        assert!(page.next_cursor.is_some());
1522    }
1523
1524    #[test]
1525    fn search_malformed_cursor_rejected() {
1526        let s = InMemoryStore::new();
1527        let err = s
1528            .search(
1529                &SearchParams {
1530                    cursor: Some("not_base64!@#".into()),
1531                    ..Default::default()
1532                },
1533                None,
1534                true,
1535            )
1536            .unwrap_err();
1537        assert!(matches!(err, AcdpError::InvalidCursor(_)));
1538    }
1539
1540    /// FEAT-04: a cursor whose embedded mint timestamp is older than
1541    /// `CURSOR_TTL` MUST surface as `CursorExpired`, not `InvalidCursor`.
1542    /// Clients distinguish "you typo'd the cursor" from "your cursor
1543    /// aged out, restart the scan from page 1".
1544    #[test]
1545    fn search_aged_cursor_rejected_as_cursor_expired() {
1546        use base64::{engine::general_purpose::STANDARD, Engine};
1547        let s = InMemoryStore::new();
1548        // Mint a cursor 7200s in the past — twice the 3600s TTL.
1549        let stale_mint_ms = chrono::Utc::now().timestamp_millis() - 7200 * 1000;
1550        let aged = STANDARD.encode(format!(
1551            "{stale_mint_ms}:0:acdp://r/12345678-1234-4321-8123-1234567812aa"
1552        ));
1553        let err = s
1554            .search(
1555                &SearchParams {
1556                    cursor: Some(aged),
1557                    ..Default::default()
1558                },
1559                None,
1560                true,
1561            )
1562            .unwrap_err();
1563        assert!(
1564            matches!(err, AcdpError::CursorExpired),
1565            "expired cursor MUST surface CursorExpired, got {err:?}"
1566        );
1567    }
1568
1569    #[test]
1570    fn search_filters_by_status_default_active() {
1571        let s = InMemoryStore::new();
1572        let lin = "lin:sha256:4444444444444444444444444444444444444444444444444444444444444444";
1573        let v1 = "acdp://r/12345678-1234-4321-8123-000000000004";
1574        let v2 = "acdp://r/12345678-1234-4321-8123-000000000005";
1575        s.put(fake_body(v1, lin, "old")).unwrap();
1576        s.put(fake_body(v2, lin, "new")).unwrap();
1577        s.mark_superseded(&CtxId(v1.into())).unwrap();
1578        let resp = s
1579            .search(
1580                &SearchParams {
1581                    q: Some("old".into()),
1582                    ..Default::default()
1583                },
1584                None,
1585                true,
1586            )
1587            .unwrap();
1588        // Only `active` matches — superseded "old" filtered out.
1589        assert_eq!(resp.matches.len(), 0);
1590        let resp = s
1591            .search(
1592                &SearchParams {
1593                    q: Some("new".into()),
1594                    ..Default::default()
1595                },
1596                None,
1597                true,
1598            )
1599            .unwrap();
1600        assert_eq!(resp.matches.len(), 1);
1601    }
1602
1603    /// End-to-end: producer → server pipeline using the actual signing
1604    /// path. Uses a builder and the `RegistryServer` (see server.rs)
1605    /// to confirm the integration story.
1606    #[test]
1607    fn store_round_trip_from_real_publish_request() {
1608        use crate::registry::server::RegistryServer;
1609        use acdp_types::capabilities::{CapabilitiesDocument, Limits};
1610
1611        let key = SigningKey::from_bytes(&[7u8; 32]);
1612        let p = Producer::new(
1613            key,
1614            AgentDid::new("did:web:agents.example.com:test"),
1615            "did:web:agents.example.com:test#key-1",
1616        );
1617        let req = p
1618            .publish_request()
1619            .title("hello")
1620            .context_type(ContextType::DataSnapshot)
1621            .visibility(Visibility::Public)
1622            .build()
1623            .unwrap();
1624
1625        let caps = CapabilitiesDocument {
1626            acdp_version: "0.1.0".into(),
1627            registry_did: "did:web:registry.example.com".into(),
1628            supported_signature_algorithms: vec!["ed25519".into()],
1629            supported_did_methods: vec!["did:web".into()],
1630            profiles: vec!["acdp-registry-core".into()],
1631            limits: Limits {
1632                max_payload_bytes: 1_048_576,
1633                max_embedded_bytes: 65_536,
1634                idempotency_key_ttl_seconds: None,
1635                max_publish_per_minute: None,
1636                change_retention_seconds: None,
1637                max_change_filters: None,
1638                max_changes_page: None,
1639                max_subscriptions_per_requester: None,
1640            },
1641            read_authentication_methods: vec![],
1642            anonymous_public_reads: true,
1643            supports_idempotency_key: false,
1644            extensions: Default::default(),
1645        };
1646
1647        let server = RegistryServer::new(InMemoryStore::new(), caps, "registry.example.com");
1648        let resp = server.publish_unverified_for_tests(&req).unwrap();
1649        assert_eq!(resp.version, 1);
1650        let ctx = server.retrieve(&resp.ctx_id, None).unwrap().unwrap();
1651        assert_eq!(ctx.body.title, "hello");
1652
1653        // Ignore unused imports under different feature combinations
1654        let _: Option<DataPeriod> = ctx.body.data_period.clone();
1655    }
1656}
1657
1658#[cfg(test)]
1659mod publish_commit_outcome_tests {
1660    use super::*;
1661    use acdp_types::publish::PublishResponse;
1662    use acdp_types::Status;
1663
1664    fn response(version: u32) -> PublishResponse {
1665        PublishResponse {
1666            registry_receipt: None,
1667            ctx_id: acdp_types::CtxId(
1668                "acdp://registry.example.com/12345678-1234-4321-8123-000000000099".into(),
1669            ),
1670            lineage_id: acdp_types::LineageId(
1671                "lin:sha256:9999999999999999999999999999999999999999999999999999999999999999"
1672                    .into(),
1673            ),
1674            version,
1675            created_at: chrono::Utc::now(),
1676            status: Status::Active,
1677        }
1678    }
1679
1680    /// `is_replay` is the whole point of this type surviving past
1681    /// `commit_via_store`, so it is asserted on BOTH variants rather than
1682    /// only on the interesting one: a `matches!` that had been written
1683    /// against the wrong variant passes a one-sided test.
1684    #[test]
1685    fn is_replay_discriminates_both_ways() {
1686        assert!(
1687            !PublishCommitOutcome::Inserted(response(1)).is_replay(),
1688            "a fresh insert must not report as a replay — a registry reading \
1689             this answers 200 instead of 201 Created"
1690        );
1691        assert!(
1692            PublishCommitOutcome::IdempotentReplay(response(1)).is_replay(),
1693            "a replay must report as one — a registry reading this answers \
1694             201 Created to a retry, which idem-002 forbids explicitly"
1695        );
1696    }
1697
1698    /// Both variants must yield their OWN response, not a fixed arm. An
1699    /// `into_response` mistakenly written as `Self::Inserted(r) => r, _ =>
1700    /// <the other one>` is caught by varying `version` per variant rather
1701    /// than reusing one value — the field is read, so the assertion cannot
1702    /// pass on a response the test never built.
1703    #[test]
1704    fn into_response_and_response_yield_the_carried_value_on_both_variants() {
1705        let inserted = PublishCommitOutcome::Inserted(response(1));
1706        let replayed = PublishCommitOutcome::IdempotentReplay(response(7));
1707
1708        assert_eq!(inserted.response().version, 1);
1709        assert_eq!(replayed.response().version, 7);
1710
1711        // `response()` borrows, so the outcome is still usable afterwards —
1712        // that is the property the registry needs (inspect, then consume).
1713        assert_eq!(inserted.into_response().version, 1);
1714        assert_eq!(replayed.into_response().version, 7);
1715    }
1716}