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