Skip to main content

acdp_server/registry/
server.rs

1//! Logical registry handler (feature = "server").
2//!
3//! Wires [`PublishValidator`] together with a [`RegistryStore`] backend
4//! to provide the seven core registry operations enumerated in
5//! RFC-ACDP-0003 §2.1 and RFC-ACDP-0005:
6//!
7//! - capabilities — return the [`CapabilitiesDocument`].
8//! - publish — validate, verify signature, assign identifiers, persist.
9//! - retrieve — fetch a stored body + registry_state (visibility-filtered).
10//! - retrieve_body — fetch just the body (visibility-filtered).
11//! - lineage / current — lineage graph queries.
12//! - search — keyword + filter projection (visibility-filtered).
13//!
14//! This is the building block an HTTP-binding layer can sit on top of;
15//! the integration tests in this crate exercise it directly without
16//! mocking.
17//!
18//! # Conformant publish
19//!
20//! [`RegistryServer::publish_verified`] runs the full RFC-ACDP-0003 §2.1
21//! algorithm — structural validation, hash recomputation, DID resolution,
22//! signature verification — before persistence. It requires the `client`
23//! feature for [`acdp_did::WebResolver`].
24//!
25//! [`RegistryServer::publish_unverified_for_tests`] (and its
26//! idempotency/tenant-capable sibling
27//! [`RegistryServer::publish_unverified_in_tenant_for_tests`]) perform
28//! only steps 1–6 (skipping DID resolution + signature verification)
29//! and are intentionally **not** RFC-conformant; use only in tests
30//! where DID resolution would require a live network or mock server.
31
32use crate::registry::rate_limit::{NoopRateLimiter, RateLimiter};
33use crate::registry::store::RegistryStore;
34use crate::registry::validator::{
35    check_revocation_supersession, key_revocation_gate_applies, PublishValidator,
36};
37use acdp_primitives::error::AcdpError;
38use acdp_types::{
39    body::{Body, FullContext},
40    capabilities::CapabilitiesDocument,
41    primitives::{AgentDid, CtxId, LineageId, Status, Visibility},
42    publish::{PublishRequest, PublishResponse},
43    revocation::KeyRevocation,
44    search::{SearchParams, SearchResponse},
45};
46
47/// Logical registry handler over an arbitrary [`RegistryStore`].
48///
49/// `L` is the rate-limiting policy (RFC-ACDP-0008 §4.3). The default
50/// [`NoopRateLimiter`] accepts every publish; operators that need a
51/// real limiter construct via [`Self::with_rate_limiter`].
52pub struct RegistryServer<S: RegistryStore, L: RateLimiter = NoopRateLimiter> {
53    store: S,
54    caps: CapabilitiesDocument,
55    authority: String,
56    rate_limiter: L,
57    /// Receipt minting identity (ACDP 0.2, RFC-ACDP-0010). `None` =
58    /// 0.1.0-mode registry (no receipts). Set via
59    /// [`Self::with_receipt_signer`], which also advertises the
60    /// `acdp-registry-receipts` profile.
61    receipt_signer: Option<acdp_types::receipt::ReceiptSigner>,
62    /// Lineage-head receipt minting (ACDP 0.3, RFC-ACDP-0011). Enabled
63    /// via [`Self::with_lineage_head_receipts`], which also advertises
64    /// the `acdp-registry-head-receipts` profile. When enabled,
65    /// [`Self::current`] mints a fresh head receipt per response with
66    /// the RFC-ACDP-0010 receipt signing key. Never true without
67    /// `receipt_signer` (the profile's prerequisite).
68    mint_head_receipts: bool,
69    /// Lifecycle events & retraction (ACDP 0.3, RFC-ACDP-0013). Enabled
70    /// via [`Self::with_lifecycle`], which also advertises the
71    /// `acdp-registry-lifecycle` profile. When disabled, the lifecycle
72    /// operations return [`AcdpError::NotImplemented`] (the §6 rule for
73    /// non-advertising registries: HTTP 501) and the registry never
74    /// emits `lifecycle_events` or the `retracted` status.
75    lifecycle_enabled: bool,
76}
77
78impl<S: RegistryStore> RegistryServer<S, NoopRateLimiter> {
79    /// Unchecked constructor. Skips capabilities and DID-authority binding
80    /// validation; prefer [`Self::try_new`] in production. Retained for
81    /// tests that build a server from known-good fixtures.
82    #[doc(hidden)]
83    pub fn new(store: S, caps: CapabilitiesDocument, authority: impl Into<String>) -> Self {
84        Self {
85            store,
86            caps,
87            authority: authority.into(),
88            rate_limiter: NoopRateLimiter,
89            receipt_signer: None,
90            mint_head_receipts: false,
91            lifecycle_enabled: false,
92        }
93    }
94
95    /// Production constructor.
96    ///
97    /// Validates that `authority` is a bare lowercase DNS hostname,
98    /// validates capabilities against RFC-ACDP-0007 §3, and enforces that
99    /// `caps.registry_did` equals `did:web:<authority>` (per
100    /// RFC-ACDP-0006 §4.1 step 3 — the registry's DID document binds it
101    /// to the authority it claims).
102    ///
103    /// A `host:port`, scheme-prefixed, or uppercase authority is rejected:
104    /// the server uses `authority` to mint `ctx_id` (`acdp://<authority>/…`)
105    /// and `origin_registry`, and a colon or slash there violates the
106    /// `acdp://` URI authority rule (RFC-ACDP-0002 §3.1). For `host:port`
107    /// test setups use [`Self::try_new_for_test_authority`].
108    pub fn try_new(
109        store: S,
110        caps: CapabilitiesDocument,
111        authority: impl Into<String>,
112    ) -> Result<Self, AcdpError> {
113        let authority = authority.into();
114        // Production authority MUST be a bare lowercase DNS hostname — no
115        // port, no scheme, no DID prefix (RFC-ACDP-0002 §3.1).
116        if !acdp_types::primitives::is_valid_dns_authority(&authority) {
117            return Err(AcdpError::SchemaViolation(format!(
118                "registry authority '{authority}' is not a valid DNS hostname \
119                 (must be lowercase labels, e.g. 'registry.example.com'); \
120                 use RegistryServer::try_new_for_test_authority for host:port test setups"
121            )));
122        }
123        acdp_validation::validate_capabilities(&caps)?;
124        // BUG-06: percent-encode `:` in `host:port` authorities — the
125        // colon is a structural separator in did:web.
126        let expected_did = acdp_did::authority_to_did_web(&authority);
127        if caps.registry_did != expected_did {
128            return Err(AcdpError::SchemaViolation(format!(
129                "capabilities.registry_did '{}' does not match expected '{expected_did}' \
130                 for authority '{authority}'",
131                caps.registry_did
132            )));
133        }
134        Ok(Self {
135            store,
136            caps,
137            authority,
138            rate_limiter: NoopRateLimiter,
139            receipt_signer: None,
140            mint_head_receipts: false,
141            lifecycle_enabled: false,
142        })
143    }
144
145    /// Test-only constructor that accepts a `host:port` authority such as
146    /// `"localhost:8443"`. The authority is **not** validated as a DNS
147    /// hostname; capabilities and the DID binding are still checked.
148    ///
149    /// **Non-production only.** A server built with this constructor will
150    /// mint `ctx_id` and `origin_registry` values that do not conform to
151    /// the `acdp://` URI syntax rules (a colon in the authority segment).
152    /// Use [`Self::try_new`] for production registries.
153    #[doc(hidden)]
154    pub fn try_new_for_test_authority(
155        store: S,
156        caps: CapabilitiesDocument,
157        authority: impl Into<String>,
158    ) -> Result<Self, AcdpError> {
159        let authority = authority.into();
160        acdp_validation::validate_capabilities(&caps)?;
161        let expected_did = acdp_did::authority_to_did_web(&authority);
162        if caps.registry_did != expected_did {
163            return Err(AcdpError::SchemaViolation(format!(
164                "capabilities.registry_did '{}' does not match expected '{expected_did}' \
165                 for authority '{authority}'",
166                caps.registry_did
167            )));
168        }
169        Ok(Self {
170            store,
171            caps,
172            authority,
173            rate_limiter: NoopRateLimiter,
174            receipt_signer: None,
175            mint_head_receipts: false,
176            lifecycle_enabled: false,
177        })
178    }
179}
180
181impl<S: RegistryStore, L: RateLimiter> RegistryServer<S, L> {
182    /// Replace the rate-limiting policy (RFC-ACDP-0008 §4.3).
183    pub fn with_rate_limiter<L2: RateLimiter>(self, limiter: L2) -> RegistryServer<S, L2> {
184        RegistryServer {
185            store: self.store,
186            caps: self.caps,
187            authority: self.authority,
188            rate_limiter: limiter,
189            receipt_signer: self.receipt_signer,
190            mint_head_receipts: self.mint_head_receipts,
191            lifecycle_enabled: self.lifecycle_enabled,
192        }
193    }
194
195    /// Configure receipt minting (ACDP 0.2, RFC-ACDP-0010). Every
196    /// subsequent verified publish mints a registry-signed receipt
197    /// atomically with persistence, returns it in the publish response,
198    /// and serves it on retrieval.
199    ///
200    /// Also advertises the `acdp-registry-receipts` profile — a
201    /// registry without a signing key MUST NOT advertise it, so the
202    /// profile is bound to this call rather than to raw capabilities
203    /// input. Fails if the signer's `registry_did` does not match
204    /// `caps.registry_did` (a receipt minted under a foreign DID would
205    /// fail every consumer's serving-authority cross-check).
206    ///
207    /// Note: [`Self::publish_unverified_for_tests`] and
208    /// [`Self::publish_unverified_in_tenant_for_tests`] never mint — the
209    /// producer key is not resolved on either path, so a fingerprint
210    /// attestation would be false.
211    pub fn with_receipt_signer(
212        mut self,
213        signer: acdp_types::receipt::ReceiptSigner,
214    ) -> Result<Self, AcdpError> {
215        if signer.registry_did() != self.caps.registry_did {
216            return Err(AcdpError::SchemaViolation(format!(
217                "receipt signer registry_did '{}' ≠ capabilities.registry_did '{}'",
218                signer.registry_did(),
219                self.caps.registry_did
220            )));
221        }
222        // RFC-ACDP-0010 §11: registries advertising the receipts
223        // profile MUST advertise acdp_version >= 0.2.0.
224        self.require_min_acdp_version((0, 2, 0), "acdp-registry-receipts")?;
225        let profile = acdp_types::profile::Profile::RegistryReceipts.as_str();
226        if !self.caps.profiles.iter().any(|p| p == profile) {
227            self.caps.profiles.push(profile.to_string());
228        }
229        self.receipt_signer = Some(signer);
230        Ok(self)
231    }
232
233    /// Enable lineage-head receipt minting (ACDP 0.3, RFC-ACDP-0011).
234    /// Every subsequent [`Self::current`] response carries a freshly
235    /// minted head receipt (`as_of` = the registry clock at response
236    /// time, ms-truncated), signed with the RFC-ACDP-0010 receipt
237    /// signing key — head receipts introduce no new key role (§5, §8).
238    ///
239    /// Also advertises the `acdp-registry-head-receipts` profile. The
240    /// profile's prerequisite is `acdp-registry-receipts` (§9): this
241    /// method fails unless [`Self::with_receipt_signer`] was configured
242    /// first — a registry with no receipt key has nothing to sign head
243    /// receipts with, and MUST NOT advertise the profile (§6: no
244    /// degraded mode on `/current`). Registries advertising the profile
245    /// MUST advertise `acdp_version` >= 0.3.0 (§9).
246    pub fn with_lineage_head_receipts(mut self) -> Result<Self, AcdpError> {
247        if self.receipt_signer.is_none() {
248            return Err(AcdpError::SchemaViolation(
249                "acdp-registry-head-receipts requires the acdp-registry-receipts profile \
250                 (RFC-ACDP-0011 §9): call with_receipt_signer first"
251                    .into(),
252            ));
253        }
254        self.require_min_acdp_version((0, 3, 0), "acdp-registry-head-receipts")?;
255        let profile = acdp_types::profile::Profile::RegistryHeadReceipts.as_str();
256        if !self.caps.profiles.iter().any(|p| p == profile) {
257            self.caps.profiles.push(profile.to_string());
258        }
259        self.mint_head_receipts = true;
260        Ok(self)
261    }
262
263    /// Enable lifecycle events & retraction (ACDP 0.3, RFC-ACDP-0013).
264    /// Advertises the `acdp-registry-lifecycle` profile (prerequisite:
265    /// `acdp-registry-core`) and activates the
266    /// [`Self::retract_verified`] / [`Self::republish_verified`]
267    /// operation surface, the §7 status derivation (`retracted`
268    /// dominating `superseded` and `expired`), the §8.2 default-search
269    /// exclusion, and the §8.3 `/current` head exclusion.
270    ///
271    /// Registries advertising the profile MUST advertise `acdp_version`
272    /// ≥ 0.3.0 (§10). The paired [`RegistryStore`] must implement
273    /// [`RegistryStore::commit_lifecycle_event`] — the default trait
274    /// impl fails with `not_implemented`, so a mispaired backend fails
275    /// loudly on the first lifecycle write rather than silently
276    /// dropping a retraction.
277    pub fn with_lifecycle(mut self) -> Result<Self, AcdpError> {
278        self.require_min_acdp_version((0, 3, 0), "acdp-registry-lifecycle")?;
279        let profile = acdp_types::profile::Profile::RegistryLifecycle.as_str();
280        if !self.caps.profiles.iter().any(|p| p == profile) {
281            self.caps.profiles.push(profile.to_string());
282        }
283        self.lifecycle_enabled = true;
284        Ok(self)
285    }
286
287    /// Profile version gate: `capabilities.acdp_version` must be a plain
288    /// `MAJOR.MINOR.PATCH` version (the capabilities schema's
289    /// `^\d+\.\d+\.\d+$` form — malformed input is an error, never
290    /// coerced) and at least `min`.
291    fn require_min_acdp_version(&self, min: (u64, u64, u64), what: &str) -> Result<(), AcdpError> {
292        let parts: Vec<u64> = self
293            .caps
294            .acdp_version
295            .split('.')
296            .map(|p| p.parse::<u64>())
297            .collect::<Result<_, _>>()
298            .map_err(|_| {
299                AcdpError::SchemaViolation(format!(
300                    "capabilities.acdp_version '{}' is not a plain MAJOR.MINOR.PATCH version",
301                    self.caps.acdp_version
302                ))
303            })?;
304        let [major, minor, patch] = parts.as_slice() else {
305            return Err(AcdpError::SchemaViolation(format!(
306                "capabilities.acdp_version '{}' is not a plain MAJOR.MINOR.PATCH version",
307                self.caps.acdp_version
308            )));
309        };
310        if (*major, *minor, *patch) < min {
311            return Err(AcdpError::SchemaViolation(format!(
312                "{what} requires capabilities.acdp_version >= {}.{}.{}, got '{}'",
313                min.0, min.1, min.2, self.caps.acdp_version
314            )));
315        }
316        Ok(())
317    }
318
319    /// Borrow the underlying store. Useful for tests that want to
320    /// inspect side-effects directly.
321    pub fn store(&self) -> &S {
322        &self.store
323    }
324
325    /// `GET /.well-known/acdp.json`.
326    pub fn capabilities(&self) -> &CapabilitiesDocument {
327        &self.caps
328    }
329
330    /// **RFC-conformant publish.**
331    ///
332    /// Runs RFC-ACDP-0003 §2.1 steps 1–11:
333    ///
334    /// - **1–6.** [`PublishValidator::validate_post_schema`] — schema,
335    ///   payload + embedded size, hash recomputation, algorithm /
336    ///   key_id binding.
337    /// - **7–8.** [`acdp_verify::verify_publish_request_signature`] —
338    ///   DID resolution + signature verification.
339    /// - **9.** Identifier assignment (`ctx_id`, `lineage_id`).
340    /// - **10.** Lineage coherence on supersession.
341    /// - **11.** Persistence and predecessor supersession.
342    ///
343    /// Steps 7–8 require a [`acdp_did::WebResolver`], so this method
344    /// is gated on the `client` feature.
345    #[cfg(feature = "client")]
346    #[cfg_attr(
347        feature = "tracing",
348        tracing::instrument(
349            name = "acdp.publish_verified",
350            skip_all,
351            fields(
352                agent_id = req.agent_id.as_str(),
353                version = req.version,
354                idempotency_key = idempotency_key.is_some(),
355            ),
356            err(Display)
357        )
358    )]
359    pub async fn publish_verified(
360        &self,
361        req: &PublishRequest,
362        idempotency_key: Option<&str>,
363        resolver: &acdp_did::WebResolver,
364    ) -> Result<PublishResponse, AcdpError> {
365        self.publish_verified_in_tenant(req, idempotency_key, resolver, None)
366            .await
367    }
368
369    /// Like [`Self::publish_verified`] but binds the publish to a tenant so a
370    /// multi-tenant store persists `tenant_id` atomically with the context row
371    /// (rather than via a separate, non-transactional stamping UPDATE that a
372    /// crash could leave stranded in the default bucket). `tenant = None` is
373    /// identical to [`Self::publish_verified`].
374    #[cfg(feature = "client")]
375    pub async fn publish_verified_in_tenant(
376        &self,
377        req: &PublishRequest,
378        idempotency_key: Option<&str>,
379        resolver: &acdp_did::WebResolver,
380        tenant: Option<&str>,
381    ) -> Result<PublishResponse, AcdpError> {
382        // Rate-limit gate runs before any expensive work — RFC-ACDP-0008 §4.3.
383        self.check_publish_rate_limit(&req.agent_id)?;
384
385        let raw_bytes = serde_json::to_vec(req)?.len();
386        let validator = PublishValidator::for_authority(&self.caps, &self.authority);
387        let _validated = validator.validate_post_schema(req, raw_bytes)?;
388
389        // Steps 7–8: DID resolution + signature verification.
390        acdp_verify::verify_publish_request_signature(req, resolver).await?;
391
392        // RFC-ACDP-0014 §5 step 2 on the did:web publish path: a
393        // revocation MUST NOT be signed by the very key it revokes.
394        // `PublishValidator::validate_post_schema` (above) already
395        // enforces this for a did:key signer offline, purely from the
396        // body (`KeyRevocation::from_parts`'s did:key sub-case) — but a
397        // did:web signer's fingerprint is not derivable without
398        // resolving its DID document. That resolution already happened
399        // unconditionally just above, to verify the signature
400        // (RFC-ACDP-0003 steps 7–8) — so this is NOT a new resolution.
401        // `producer_key_fingerprint` dispatches by method
402        // internally, so a did:key signer reaching this line would be a
403        // harmless, resolver-free recheck of what was already enforced
404        // above; a did:web signer instead gets a second, cache-hit
405        // resolve of the same DID (via `WebResolver`'s LRU cache)
406        // purely to derive a fingerprint from the key that already
407        // verified — no new I/O, no new failure mode. Scoped to
408        // key-revocation bodies at `acdp_version >= 0.3.0` so no other
409        // publish pays even that cached-lookup cost.
410        let revocation_check_needed = req.context_type.is_key_revocation()
411            && key_revocation_gate_applies(&self.caps.acdp_version);
412
413        // RFC-ACDP-0010: fingerprint the key that was just resolved and
414        // verified, for the receipt's `key_fingerprint` binding. Also
415        // needed (and computed here, not a second time) when the §5
416        // step 2 check above applies, so a key-revocation publish never
417        // triggers two DID resolutions for one fingerprint.
418        let fingerprint = if self.receipt_signer.is_some() || revocation_check_needed {
419            Some(producer_key_fingerprint(req, resolver).await?)
420        } else {
421            None
422        };
423
424        if revocation_check_needed {
425            // `fingerprint` is `Some` here because `revocation_check_needed`
426            // was one of the two disjuncts in the `if` just above that
427            // decided whether to compute it — but that's a non-local
428            // invariant spanning several lines, so treat a `None` as a
429            // recoverable internal-state error rather than panicking the
430            // publish path (this crate is `forbid(unsafe_code)` and ships
431            // to crates.io; see `AcdpError::RegistryInternal`'s other use
432            // in this file for the same "impossible state" idiom).
433            let fp = fingerprint.as_deref().ok_or_else(|| {
434                AcdpError::RegistryInternal(
435                    "key-revocation fingerprint missing despite revocation_check_needed \
436                     — this is an internal invariant violation, not a caller error"
437                        .into(),
438                )
439            })?;
440            KeyRevocation::from_publish_request(req)?.check_not_self_signed(fp)?;
441        }
442
443        // FEAT-01: hand the rest of the pipeline to the store as a
444        // single atomic commit. Idempotency lookup, predecessor
445        // verification, body insertion, predecessor supersession
446        // marking, and idempotency record writing all happen under one
447        // critical section. Two concurrent publishes against the same
448        // `supersedes` (or the same `Idempotency-Key`) can no longer
449        // both succeed.
450        self.commit_via_store(req, idempotency_key, tenant, fingerprint)
451    }
452
453    /// **RFC-conformant publish for `did:key` producers — no resolver.**
454    ///
455    /// Runs the same RFC-ACDP-0003 §2.1 pipeline as
456    /// [`Self::publish_verified`], but performs steps 7–8 via the pure
457    /// did:key verifier
458    /// ([`acdp_verify::verify_publish_request_signature_offline`]),
459    /// so it is available without the `client` feature. Rejects
460    /// `did:web` (and any other method) producers with
461    /// `key_resolution_failed` — those need the resolver-backed
462    /// [`Self::publish_verified`].
463    ///
464    /// The capabilities gate still applies: the request is refused
465    /// unless `supported_did_methods` includes `"did:key"`.
466    pub fn publish_verified_did_key(
467        &self,
468        req: &PublishRequest,
469        idempotency_key: Option<&str>,
470    ) -> Result<PublishResponse, AcdpError> {
471        self.publish_verified_did_key_in_tenant(req, idempotency_key, None)
472    }
473
474    /// Like [`Self::publish_verified_did_key`] but binds the publish to a
475    /// tenant so a multi-tenant store persists `tenant_id` atomically with
476    /// the context row — the same contract as
477    /// [`Self::publish_verified_in_tenant`]. `tenant = None` is identical
478    /// to [`Self::publish_verified_did_key`].
479    #[cfg_attr(
480        feature = "tracing",
481        tracing::instrument(
482            name = "acdp.publish_verified_did_key",
483            skip_all,
484            fields(
485                agent_id = req.agent_id.as_str(),
486                version = req.version,
487                idempotency_key = idempotency_key.is_some(),
488            ),
489            err(Display)
490        )
491    )]
492    pub fn publish_verified_did_key_in_tenant(
493        &self,
494        req: &PublishRequest,
495        idempotency_key: Option<&str>,
496        tenant: Option<&str>,
497    ) -> Result<PublishResponse, AcdpError> {
498        self.check_publish_rate_limit(&req.agent_id)?;
499
500        let raw_bytes = serde_json::to_vec(req)?.len();
501        let validator = PublishValidator::for_authority(&self.caps, &self.authority);
502        let _validated = validator.validate_post_schema(req, raw_bytes)?;
503
504        // Steps 7–8, pure: did:key resolution + signature verification.
505        acdp_verify::verify_publish_request_signature_offline(req)?;
506
507        // did:key fingerprints are derivable from the DID itself — no
508        // resolver needed for the receipt binding.
509        let fingerprint = if self.receipt_signer.is_some() {
510            let material = acdp_did::key::resolve_did_key(req.agent_id.as_str())?;
511            Some(acdp_crypto::fingerprint::fingerprint_did_key_material(
512                &material,
513            )?)
514        } else {
515            None
516        };
517
518        self.commit_via_store(req, idempotency_key, tenant, fingerprint)
519    }
520
521    /// **NOT RFC-conformant.** Skips DID resolution and signature
522    /// verification (RFC-ACDP-0003 §2.1 steps 7–8).
523    ///
524    /// Intended for integration tests where DID resolution would require
525    /// a live network or mock server. Production callers MUST use
526    /// [`Self::publish_verified`].
527    ///
528    /// Delegates to [`Self::publish_unverified_in_tenant_for_tests`] with
529    /// no idempotency key and no tenant; use that method directly for an
530    /// idempotent-replay or tenant-stamped test publish.
531    #[doc(hidden)]
532    pub fn publish_unverified_for_tests(
533        &self,
534        req: &PublishRequest,
535    ) -> Result<PublishResponse, AcdpError> {
536        self.publish_unverified_in_tenant_for_tests(req, None, None)
537    }
538
539    /// Like [`Self::publish_unverified_for_tests`] but additionally
540    /// accepts an idempotency key and a tenant, so tests can exercise the
541    /// idempotent-replay and tenant-stamping behavior of
542    /// [`Self::commit_via_store`] without resorting to store-level
543    /// workarounds. `(None, None)` is identical to
544    /// [`Self::publish_unverified_for_tests`].
545    ///
546    /// **NOT RFC-conformant** — same caveats as
547    /// [`Self::publish_unverified_for_tests`]: skips RFC-ACDP-0003 §2.1
548    /// steps 7–8 (DID resolution + signature verification). Production
549    /// callers MUST use [`Self::publish_verified_in_tenant`].
550    ///
551    /// Passing an idempotency key to a registry whose capabilities don't
552    /// advertise `supports_idempotency_key` is a silent no-op — mirrors
553    /// [`Self::publish_verified_in_tenant`]'s contract via the shared
554    /// [`Self::commit_via_store`] gate.
555    #[doc(hidden)]
556    pub fn publish_unverified_in_tenant_for_tests(
557        &self,
558        req: &PublishRequest,
559        idempotency_key: Option<&str>,
560        tenant: Option<&str>,
561    ) -> Result<PublishResponse, AcdpError> {
562        // Rate-limit gate fires here too — the limiter is intentionally
563        // wired BEFORE validation so it works as a defensive cap even
564        // when the test path is used.
565        self.check_publish_rate_limit(&req.agent_id)?;
566
567        // RFC-ACDP-0010 §7: a receipts-advertising registry has no
568        // degraded mode — every persisted context must carry a receipt,
569        // and minting here would attest a `key_fingerprint` that was
570        // never resolved. Refuse outright rather than persist a
571        // receipt-less context — from either unverified test entry
572        // point, since neither resolves a producer key.
573        if self.receipt_signer.is_some() {
574            return Err(AcdpError::SchemaViolation(
575                "publish_unverified_for_tests / publish_unverified_in_tenant_for_tests are \
576                 unavailable on a receipts-advertising registry (RFC-ACDP-0010 §7: no \
577                 degraded mode); use publish_verified or publish_verified_did_key"
578                    .into(),
579            ));
580        }
581        // RFC-ACDP-0014 §5 step 2 is deliberately NOT extended here for
582        // a did:web signer: this method's entire contract (see its doc
583        // comment above) is to skip DID resolution + signature
584        // verification, so there is no resolved key — and no
585        // resolver — to fingerprint. `validate_post_schema` below still
586        // enforces the did:key sub-case offline (`KeyRevocation::from_parts`),
587        // since that needs no resolution either; a did:web self-revocation
588        // published through this test-only bypass is not caught until a
589        // conformant path re-verifies it.
590        let raw_bytes = serde_json::to_vec(req)?.len();
591        let validator = PublishValidator::for_authority(&self.caps, &self.authority);
592        let _validated = validator.validate_post_schema(req, raw_bytes)?;
593        self.commit_via_store(req, idempotency_key, tenant, None)
594    }
595
596    /// **Publish already verified by the caller against an
597    /// operator-pinned key** (e.g. a demo/playground registry's
598    /// out-of-band pinned-key allowlist — a config-supplied public key
599    /// checked instead of a live-resolved DID document).
600    ///
601    /// Unlike [`Self::publish_unverified_for_tests`], this is safe to call
602    /// on a receipts-advertising registry: the caller has ALREADY
603    /// cryptographically verified `req`'s signature against
604    /// `verified_public_key_b64` before calling this method (steps 7–8 are
605    /// the caller's responsibility, not this method's — there is no DID
606    /// document or did:key to resolve for a pinned key, so this crate has
607    /// nothing further to verify), so the fingerprint of that key can be
608    /// attested in the minted receipt (RFC-ACDP-0010 §7: no degraded mode,
609    /// every persisted context must carry a receipt with a genuinely
610    /// resolved key_fingerprint).
611    ///
612    /// `verified_algorithm` MUST be `"ed25519"` or `"ecdsa-p256"` and MUST
613    /// be the algorithm the caller actually verified `verified_public_key_b64`
614    /// against — this method trusts the caller completely for verification;
615    /// it does not re-verify the signature itself, only recomputes the
616    /// fingerprint of the key the caller names.
617    #[doc(hidden)]
618    pub fn publish_pinned_verified_in_tenant(
619        &self,
620        req: &PublishRequest,
621        idempotency_key: Option<&str>,
622        tenant: Option<&str>,
623        verified_public_key_b64: &str,
624        verified_algorithm: &str,
625    ) -> Result<PublishResponse, AcdpError> {
626        self.check_publish_rate_limit(&req.agent_id)?;
627
628        let raw_bytes = serde_json::to_vec(req)?.len();
629        let validator = PublishValidator::for_authority(&self.caps, &self.authority);
630        let _validated = validator.validate_post_schema(req, raw_bytes)?;
631
632        // RFC-ACDP-0014 §5 step 2 applies here too, and at no extra
633        // resolution cost: `verified_public_key_b64` is the key the
634        // *caller* already verified the signature against — there is
635        // no DID document to fetch, so fingerprinting it is a pure,
636        // local computation regardless of receipt minting. Unlike the
637        // did:web hook in `publish_verified_in_tenant`, this adds no
638        // new I/O.
639        let revocation_check_needed = req.context_type.is_key_revocation()
640            && key_revocation_gate_applies(&self.caps.acdp_version);
641
642        let fingerprint = if self.receipt_signer.is_some() || revocation_check_needed {
643            Some(fingerprint_pinned_key(
644                verified_public_key_b64,
645                verified_algorithm,
646            )?)
647        } else {
648            None
649        };
650
651        if revocation_check_needed {
652            // See the identical guard in `publish_verified_in_tenant` for
653            // why this is `ok_or_else` rather than `expect`: the
654            // `Some`-ness of `fingerprint` here depends on the `if` a few
655            // lines above matching this same `revocation_check_needed`, a
656            // non-local invariant that shouldn't panic the publish path
657            // if it's ever broken by a future edit.
658            let fp = fingerprint.as_deref().ok_or_else(|| {
659                AcdpError::RegistryInternal(
660                    "key-revocation fingerprint missing despite revocation_check_needed \
661                     — this is an internal invariant violation, not a caller error"
662                        .into(),
663                )
664            })?;
665            KeyRevocation::from_publish_request(req)?.check_not_self_signed(fp)?;
666        }
667
668        self.commit_via_store(req, idempotency_key, tenant, fingerprint)
669    }
670
671    /// Rate-limit gate shared by every publish path (RFC-ACDP-0008 §4.3).
672    /// Under the `tracing` feature a rejection emits a structured warn
673    /// event so operators can see limiter hits per agent.
674    fn check_publish_rate_limit(
675        &self,
676        agent_id: &acdp_types::primitives::AgentDid,
677    ) -> Result<(), AcdpError> {
678        match self.rate_limiter.check_publish(agent_id) {
679            Ok(()) => Ok(()),
680            Err(e) => {
681                #[cfg(feature = "tracing")]
682                tracing::warn!(
683                    agent_id = agent_id.as_str(),
684                    "publish rejected by rate limiter"
685                );
686                Err(e)
687            }
688        }
689    }
690
691    /// Drive `RegistryStore::commit_publish` from a validated request.
692    /// Unwraps `PublishCommitOutcome::Inserted` and `IdempotentReplay`
693    /// to the same `PublishResponse` for the caller (the distinction
694    /// only matters internally for logging/tracing).
695    fn commit_via_store(
696        &self,
697        req: &PublishRequest,
698        idempotency_key: Option<&str>,
699        tenant: Option<&str>,
700        producer_key_fingerprint: Option<String>,
701    ) -> Result<PublishResponse, AcdpError> {
702        let idempotency = if self.caps.supports_idempotency_key {
703            idempotency_key.map(|key| crate::registry::store::PendingIdempotencyCommit {
704                key,
705                ttl: chrono::Duration::seconds(
706                    self.caps
707                        .limits
708                        .idempotency_key_ttl_seconds
709                        .unwrap_or(86_400) as i64,
710                ),
711            })
712        } else {
713            None
714        };
715        // RFC-ACDP-0010 minting hook — runs inside the store's critical
716        // section so the receipt persists atomically with the context.
717        #[allow(clippy::type_complexity)]
718        let minter: Option<
719            Box<dyn Fn(&Body) -> Result<serde_json::Value, AcdpError> + Send + Sync>,
720        > = match (&self.receipt_signer, producer_key_fingerprint) {
721            (Some(signer), Some(fp)) => Some(Box::new(move |body: &Body| {
722                let receipt = signer.mint(
723                    &body.ctx_id,
724                    &body.lineage_id,
725                    &body.origin_registry,
726                    body.created_at,
727                    &body.content_hash,
728                    &fp,
729                )?;
730                serde_json::to_value(receipt).map_err(AcdpError::from)
731            })),
732            _ => None,
733        };
734        let minted_expected = minter.is_some();
735
736        // RFC-ACDP-0014 §4 `supersedes`-row admission hook. `Some` iff
737        // the version gate is on AND this request actually carries a
738        // `supersedes` — the mandatory carry-forward from Phase 5:
739        // `check_revocation_supersession` has no internal version
740        // guard, so calling it unconditionally would reject a
741        // key-revocation-shaped body at e.g. acdp_version 0.2.0, where
742        // RFC §4:60 reserves that rejection for ≥0.3.0 registries.
743        //
744        // Captures `req` by reference (not `move`-owned data like
745        // `receipt_minter` above) so it can call
746        // `check_revocation_supersession(prev, req)` — the closure's
747        // hidden lifetime is exactly `req`'s, which the store threads
748        // through the same call as `PublishCommit::req`, so the two
749        // `'a`-tied fields agree.
750        let admission_closure =
751            if key_revocation_gate_applies(&self.caps.acdp_version) && req.supersedes.is_some() {
752                Some(move |prev: &Body| check_revocation_supersession(prev, req))
753            } else {
754                None
755            };
756        #[allow(clippy::type_complexity)]
757        let predecessor_admission: Option<
758            &(dyn Fn(&Body) -> Result<(), AcdpError> + Send + Sync),
759        > = admission_closure
760            .as_ref()
761            .map(|f| f as &(dyn Fn(&Body) -> Result<(), AcdpError> + Send + Sync));
762
763        let outcome = self
764            .store
765            .commit_publish(crate::registry::store::PublishCommit {
766                req,
767                authority: &self.authority,
768                idempotency,
769                tenant,
770                receipt_minter: minter.as_deref(),
771                predecessor_admission,
772            })?;
773        let (response, replayed) = match outcome {
774            crate::registry::store::PublishCommitOutcome::Inserted(r) => (r, false),
775            crate::registry::store::PublishCommitOutcome::IdempotentReplay(r) => (r, true),
776        };
777        #[cfg(feature = "tracing")]
778        tracing::debug!(
779            ctx_id = %response.ctx_id.0,
780            lineage_id = %response.lineage_id.0,
781            version = response.version,
782            replayed,
783            "publish committed"
784        );
785        // RFC-ACDP-0010 §7 belt-and-braces: a receipts-advertising
786        // registry has no degraded mode. A store implementation that
787        // ignores `receipt_minter` (e.g. compiled against the older
788        // trait shape) must fail loudly here, not silently persist a
789        // receipt-less context.
790        //
791        // Scoped to NEWLY INSERTED contexts only: an idempotent replay
792        // returns the ORIGINAL publish response verbatim, and that
793        // original may legitimately predate receipts (a record minted
794        // before the registry enabled its signer, still inside the
795        // idempotency TTL). Failing such a replay would turn a correct
796        // producer retry into a 500 across the upgrade boundary — §7
797        // attests what was persisted at publish time, not re-mint time.
798        if minted_expected && !replayed && response.registry_receipt.is_none() {
799            return Err(AcdpError::RegistryInternal(
800                "receipt signer is configured but the store returned no receipt — \
801                 the RegistryStore implementation must invoke PublishCommit::receipt_minter \
802                 inside its commit (RFC-ACDP-0010 §7: no degraded mode)"
803                    .into(),
804            ));
805        }
806        Ok(response)
807    }
808
809    /// `GET /contexts/{ctx_id}`.
810    ///
811    /// Applies the RFC-ACDP-0008 §4.5 disclosure rules:
812    ///
813    /// | Visibility   | Authorized requester for retrieval                  |
814    /// |--------------|-----------------------------------------------------|
815    /// | `public`     | anyone (when `caps.anonymous_public_reads` is true) |
816    /// | `restricted` | producer (`agent_id`) **or** any DID in `audience`  |
817    /// | `private`    | producer (`agent_id`) **or** any DID in `audience`  |
818    ///
819    /// Returns `Ok(None)` (not `Err`) for unauthorized callers — prevents
820    /// existence leakage via error codes.
821    pub fn retrieve(
822        &self,
823        ctx_id: &CtxId,
824        requester: Option<&AgentDid>,
825    ) -> Result<Option<FullContext>, AcdpError> {
826        let Some(ctx) = self.store.get(ctx_id)? else {
827            return Ok(None);
828        };
829        if !can_retrieve(&ctx.body, requester, &self.caps) {
830            return Ok(None);
831        }
832        Ok(Some(ctx))
833    }
834
835    /// `GET /contexts/{ctx_id}/body`. See [`Self::retrieve`] for visibility rules.
836    pub fn retrieve_body(
837        &self,
838        ctx_id: &CtxId,
839        requester: Option<&AgentDid>,
840    ) -> Result<Option<Body>, AcdpError> {
841        Ok(self.retrieve(ctx_id, requester)?.map(|c| c.body))
842    }
843
844    /// `GET /lineages/{lineage_id}`.
845    ///
846    /// BUG-03: applies the same visibility filter as `retrieve`. A
847    /// caller who knows or guesses a `lineage_id` must not be able to
848    /// surface restricted or private bodies through the lineage
849    /// endpoint when `retrieve(ctx_id, requester)` would deny them.
850    pub fn lineage(
851        &self,
852        lineage_id: &LineageId,
853        requester: Option<&AgentDid>,
854    ) -> Result<Vec<FullContext>, AcdpError> {
855        let all = self.store.lineage(lineage_id)?;
856        Ok(all
857            .into_iter()
858            .filter(|ctx| can_retrieve(&ctx.body, requester, &self.caps))
859            .collect())
860    }
861
862    /// `GET /lineages/{lineage_id}/current`.
863    ///
864    /// BUG-03 + BUG-04: returns the newest version visible to the
865    /// requester that is neither `Superseded` nor `Retracted` (a
866    /// retracted version is NEVER a head — RFC-ACDP-0013 §8.3, fixture
867    /// `lc-003`; contrast `Expired`, which remains a servable head).
868    /// `None` when the lineage is unknown, when every version is
869    /// superseded or retracted (RFC-ACDP-0004 §5 as amended), or when
870    /// no visible version exists. Because head selection excludes
871    /// retracted versions, a lineage-head receipt can never name a
872    /// retracted head (RFC-ACDP-0011 §4 as amended; the signer's mint
873    /// refusal is the backstop).
874    ///
875    /// When the registry advertises `acdp-registry-head-receipts`
876    /// ([`Self::with_lineage_head_receipts`]), the response carries a
877    /// freshly minted lineage-head receipt (RFC-ACDP-0011 §6 rule 1:
878    /// REQUIRED on `/current`, no degraded mode). Because the head is
879    /// resolved *after* visibility filtering, the receipt attests the
880    /// head as visible to this requester (§4: never an existence leak).
881    pub fn current(
882        &self,
883        lineage_id: &LineageId,
884        requester: Option<&AgentDid>,
885    ) -> Result<Option<FullContext>, AcdpError> {
886        let all = self.store.lineage(lineage_id)?;
887        // `lineage` returns versions ordered from v1 → vN; iterate in
888        // reverse to find the newest non-superseded version. `Active`
889        // and `Expired` both qualify as valid current heads (a body
890        // that expired without being superseded is still the latest
891        // and the consumer needs to see it to know it has lapsed).
892        for mut ctx in all.into_iter().rev() {
893            if !matches!(
894                ctx.registry_state.status,
895                Status::Superseded | Status::Retracted
896            ) && can_retrieve(&ctx.body, requester, &self.caps)
897            {
898                if self.mint_head_receipts {
899                    // RFC-ACDP-0011 §6: as_of is the registry's clock at
900                    // response time (ms-truncated by the signer); the
901                    // head fields are exactly the served response's, so
902                    // the §7 step 5 byte-match holds by construction.
903                    let signer = self.receipt_signer.as_ref().ok_or_else(|| {
904                        AcdpError::RegistryInternal(
905                            "head-receipt minting enabled without a receipt signer \
906                             (RFC-ACDP-0011 §9 prerequisite violated)"
907                                .into(),
908                        )
909                    })?;
910                    let receipt = signer.mint_lineage_head(
911                        lineage_id,
912                        &ctx.body.ctx_id,
913                        ctx.body.version,
914                        &ctx.registry_state.status,
915                        chrono::Utc::now(),
916                    )?;
917                    ctx.lineage_head_receipt = Some(serde_json::to_value(receipt)?);
918                }
919                return Ok(Some(ctx));
920            }
921        }
922        Ok(None)
923    }
924
925    /// `GET /contexts/search`.
926    ///
927    /// Applies the RFC-ACDP-0008 §4.5 search disclosure rules (note the
928    /// asymmetry vs retrieval): private contexts surface in search only
929    /// to their producer (audience members must already know the ctx_id).
930    ///
931    /// When `caps.anonymous_public_reads` is `false`, an anonymous search
932    /// request is rejected outright with [`AcdpError::NotAuthorized`]
933    /// (HTTP 403) rather than returning an empty `200`. An empty result
934    /// set would still leak the registry's existence and confirm that
935    /// the keyword query ran; the required response is `not_authorized`
936    /// (RFC-ACDP-0005 §2.5.5, RFC-ACDP-0008 §6.3, fixture `vis-009`).
937    pub fn search(
938        &self,
939        params: &SearchParams,
940        requester: Option<&AgentDid>,
941    ) -> Result<SearchResponse, AcdpError> {
942        // BUG-01 + vis-009: reject anonymous search when the registry
943        // does not allow anonymous reads. An empty 200 would still leak
944        // the registry's existence (and that the query executed); the
945        // normative response is 403 not_authorized.
946        if requester.is_none() && !self.caps.anonymous_public_reads {
947            return Err(AcdpError::NotAuthorized(
948                "anonymous search requires authentication \
949                 (registry caps: anonymous_public_reads=false)"
950                    .into(),
951            ));
952        }
953        // BUG-02: pass `anonymous_public_reads` to the store so search
954        // and retrieve agree. A registry advertising the flag as false
955        // MUST suppress public contexts for anonymous callers in BOTH
956        // endpoints (RFC-ACDP-0008 §4.5).
957        self.store
958            .search(params, requester, self.caps.anonymous_public_reads)
959    }
960
961    // ── Lifecycle events & retraction (ACDP 0.3, RFC-ACDP-0013 §6) ──────
962    //
963    // The logical handlers behind `POST /contexts/{ctx_id}/retract` and
964    // `POST /contexts/{ctx_id}/republish`. An HTTP binding layer should
965    // first run the raw request body through
966    // [`crate::registry::lifecycle::parse_lifecycle_request`] (the
967    // closed-envelope / `immutable_field` check of §6 step 2, fixture
968    // `lc-002`), then hand the parsed event here. The typed
969    // [`acdp_types::lifecycle::LifecycleEvent`] round-trips
970    // byte-identically, so signature verification over its
971    // re-serialization equals verification over the received bytes.
972
973    /// §6 steps 1–3, shared by both endpoints and all verification
974    /// modes (signature *verification* itself is the caller's step —
975    /// it differs by DID method):
976    ///
977    /// 1. **Visibility first** (RFC-ACDP-0008 §4.5): a context the
978    ///    requester could not retrieve yields `not_found` — lifecycle
979    ///    endpoints never leak existence, and error ordering never lets
980    ///    an unauthorized caller distinguish "exists but not yours".
981    /// 2. **Event validation**: closed §4 semantics, the
982    ///    endpoint-binding rule (`retracted` on `/retract`,
983    ///    `republished` on `/republish` — which also excludes every
984    ///    unregistered `event_type`, §7.3), and the future-`occurred_at`
985    ///    rejection (120 s skew allowance, §4).
986    /// 3. **Actor authentication**: `actor` MUST equal `body.agent_id`
987    ///    (`not_authorized` — the supersession rule of RFC-ACDP-0003
988    ///    §3.1 step 3; delegation remains out of scope) and the event
989    ///    MUST be signed (`schema_violation` when missing, §5).
990    ///
991    /// Returns the resolved context for the caller's verification step.
992    fn lifecycle_precheck(
993        &self,
994        event: &acdp_types::lifecycle::LifecycleEvent,
995        expected_type: &acdp_types::lifecycle::LifecycleEventType,
996        requester: Option<&AgentDid>,
997    ) -> Result<FullContext, AcdpError> {
998        if !self.lifecycle_enabled {
999            return Err(AcdpError::NotImplemented(
1000                "this registry does not advertise acdp-registry-lifecycle \
1001                 (RFC-ACDP-0013 §6: lifecycle endpoints are not implemented)"
1002                    .into(),
1003            ));
1004        }
1005        // Step 1 — resolve + visibility before ANY other check.
1006        let ctx = self
1007            .retrieve(&event.ctx_id, requester)?
1008            .ok_or_else(|| AcdpError::NotFound(format!("context '{}' not found", event.ctx_id)))?;
1009        // Step 2 — event validation.
1010        event.validate()?;
1011        if &event.event_type != expected_type {
1012            return Err(AcdpError::SchemaViolation(format!(
1013                "event_type '{}' does not match this endpoint (expected '{}', \
1014                 RFC-ACDP-0013 §6 step 2)",
1015                event.event_type, expected_type
1016            )));
1017        }
1018        let now = chrono::Utc::now();
1019        if event.occurred_at > now + chrono::Duration::seconds(120) {
1020            return Err(AcdpError::SchemaViolation(format!(
1021                "event occurred_at '{}' is in the future beyond the 120s skew allowance \
1022                 (RFC-ACDP-0013 §4)",
1023                event.occurred_at.format("%Y-%m-%dT%H:%M:%S%.3fZ")
1024            )));
1025        }
1026        // Step 3 — actor authentication.
1027        if event.actor != ctx.body.agent_id {
1028            return Err(AcdpError::NotAuthorized(format!(
1029                "event actor '{}' is not the context's producer — only the producer \
1030                 (agent_id) may use the lifecycle endpoints (RFC-ACDP-0013 §6 step 3)",
1031                event.actor
1032            )));
1033        }
1034        // Producer-initiated events MUST be signed (§5); presence and
1035        // the key_id-DID == actor binding are checked here, the
1036        // cryptographic verification by the caller.
1037        event.actor_bound_signature()?;
1038        Ok(ctx)
1039    }
1040
1041    /// §6 steps 4–5: atomic transition + append via the store, mapping
1042    /// both outcomes (fresh append, byte-identical idempotent retry) to
1043    /// the post-transition full-retrieval envelope.
1044    fn lifecycle_commit(
1045        &self,
1046        event: &acdp_types::lifecycle::LifecycleEvent,
1047    ) -> Result<FullContext, AcdpError> {
1048        Ok(self.store.commit_lifecycle_event(event)?.into_context())
1049    }
1050
1051    /// Shared verified pipeline for both endpoints (resolver-backed).
1052    #[cfg(feature = "client")]
1053    async fn lifecycle_transition_verified(
1054        &self,
1055        event: &acdp_types::lifecycle::LifecycleEvent,
1056        expected_type: acdp_types::lifecycle::LifecycleEventType,
1057        requester: Option<&AgentDid>,
1058        resolver: &acdp_did::WebResolver,
1059    ) -> Result<FullContext, AcdpError> {
1060        let ctx = self.lifecycle_precheck(event, &expected_type, requester)?;
1061        // Full RFC-ACDP-0001 §5.11 pipeline over the event hash
1062        // (RFC-ACDP-0013 §5): resolution, assertionMethod, algorithm
1063        // binding, SSRF protections — the same pipeline as a publish.
1064        acdp_verify::verify_lifecycle_event(
1065            &serde_json::to_value(event)?,
1066            &event.ctx_id,
1067            &ctx.body.agent_id,
1068            None, // producer-only: registry events do not use the endpoints
1069            resolver,
1070        )
1071        .await?;
1072        self.lifecycle_commit(event)
1073    }
1074
1075    /// Shared verified pipeline for did:key producers — no resolver.
1076    fn lifecycle_transition_verified_did_key(
1077        &self,
1078        event: &acdp_types::lifecycle::LifecycleEvent,
1079        expected_type: acdp_types::lifecycle::LifecycleEventType,
1080        requester: Option<&AgentDid>,
1081    ) -> Result<FullContext, AcdpError> {
1082        let ctx = self.lifecycle_precheck(event, &expected_type, requester)?;
1083        acdp_verify::verify_lifecycle_event_offline(
1084            &serde_json::to_value(event)?,
1085            &event.ctx_id,
1086            &ctx.body.agent_id,
1087            None,
1088        )?;
1089        self.lifecycle_commit(event)
1090    }
1091
1092    /// **RFC-conformant retraction** — `POST /contexts/{ctx_id}/retract`
1093    /// (RFC-ACDP-0013 §6). Runs the full §6 pipeline: visibility, event
1094    /// validation (`retracted` on this endpoint), actor authentication,
1095    /// signature verification through the RFC-ACDP-0001 §5.11 resolver
1096    /// pipeline, strict-alternation transition validation, and the
1097    /// atomic append. Returns the post-transition full-retrieval
1098    /// envelope (`status: retracted`, event appended) — or the current
1099    /// state unchanged on a byte-identical `event_id` retry.
1100    ///
1101    /// Retraction is **mark-not-delete**: the body remains retrievable
1102    /// (§8.1), falls out of default searches (§8.2), and is never
1103    /// served from `/current` (§8.3).
1104    #[cfg(feature = "client")]
1105    pub async fn retract_verified(
1106        &self,
1107        event: &acdp_types::lifecycle::LifecycleEvent,
1108        requester: Option<&AgentDid>,
1109        resolver: &acdp_did::WebResolver,
1110    ) -> Result<FullContext, AcdpError> {
1111        self.lifecycle_transition_verified(
1112            event,
1113            acdp_types::lifecycle::LifecycleEventType::Retracted,
1114            requester,
1115            resolver,
1116        )
1117        .await
1118    }
1119
1120    /// **RFC-conformant republication** — `POST
1121    /// /contexts/{ctx_id}/republish` (RFC-ACDP-0013 §6): reverses a
1122    /// prior retraction. `status` re-derives per RFC-ACDP-0004 §4 as
1123    /// though the retraction had not occurred; both events remain in
1124    /// the append-only history. Same pipeline as
1125    /// [`Self::retract_verified`].
1126    #[cfg(feature = "client")]
1127    pub async fn republish_verified(
1128        &self,
1129        event: &acdp_types::lifecycle::LifecycleEvent,
1130        requester: Option<&AgentDid>,
1131        resolver: &acdp_did::WebResolver,
1132    ) -> Result<FullContext, AcdpError> {
1133        self.lifecycle_transition_verified(
1134            event,
1135            acdp_types::lifecycle::LifecycleEventType::Republished,
1136            requester,
1137            resolver,
1138        )
1139        .await
1140    }
1141
1142    /// [`Self::retract_verified`] for `did:key` producers — the §5
1143    /// signature verification is pure (the DID is the key), so this is
1144    /// available without the `client` feature. Rejects `did:web` (and
1145    /// any other method) actors with `key_resolution_failed`.
1146    pub fn retract_verified_did_key(
1147        &self,
1148        event: &acdp_types::lifecycle::LifecycleEvent,
1149        requester: Option<&AgentDid>,
1150    ) -> Result<FullContext, AcdpError> {
1151        self.lifecycle_transition_verified_did_key(
1152            event,
1153            acdp_types::lifecycle::LifecycleEventType::Retracted,
1154            requester,
1155        )
1156    }
1157
1158    /// [`Self::republish_verified`] for `did:key` producers.
1159    pub fn republish_verified_did_key(
1160        &self,
1161        event: &acdp_types::lifecycle::LifecycleEvent,
1162        requester: Option<&AgentDid>,
1163    ) -> Result<FullContext, AcdpError> {
1164        self.lifecycle_transition_verified_did_key(
1165            event,
1166            acdp_types::lifecycle::LifecycleEventType::Republished,
1167            requester,
1168        )
1169    }
1170
1171    /// **NOT RFC-conformant.** Skips signature verification (the §6
1172    /// step 3 cryptographic half; presence and actor binding are still
1173    /// enforced). Test-only, mirroring
1174    /// [`Self::publish_unverified_for_tests`].
1175    #[doc(hidden)]
1176    pub fn retract_unverified_for_tests(
1177        &self,
1178        event: &acdp_types::lifecycle::LifecycleEvent,
1179        requester: Option<&AgentDid>,
1180    ) -> Result<FullContext, AcdpError> {
1181        self.lifecycle_precheck(
1182            event,
1183            &acdp_types::lifecycle::LifecycleEventType::Retracted,
1184            requester,
1185        )?;
1186        self.lifecycle_commit(event)
1187    }
1188
1189    /// **NOT RFC-conformant.** See [`Self::retract_unverified_for_tests`].
1190    #[doc(hidden)]
1191    pub fn republish_unverified_for_tests(
1192        &self,
1193        event: &acdp_types::lifecycle::LifecycleEvent,
1194        requester: Option<&AgentDid>,
1195    ) -> Result<FullContext, AcdpError> {
1196        self.lifecycle_precheck(
1197            event,
1198            &acdp_types::lifecycle::LifecycleEventType::Republished,
1199            requester,
1200        )?;
1201        self.lifecycle_commit(event)
1202    }
1203
1204    /// Record a **registry-initiated** lifecycle event (RFC-ACDP-0013
1205    /// §6: deployment policy, legal compulsion). Does NOT use the
1206    /// producer endpoints or their actor rule: `actor` MUST equal the
1207    /// registry's own DID (`capabilities.registry_did`). Subject to the
1208    /// same append-only, uniqueness, transition, and shape rules; the
1209    /// event SHOULD be signed under a key in the registry's DID
1210    /// document (a registry advertising `acdp-registry-receipts` MUST
1211    /// sign — enforced here when a receipt signer is configured, per
1212    /// the §5 same-key precedent). This is the protocol-visible form of
1213    /// "removed by policy": the body stays served, the withdrawal is
1214    /// explicit and attributed.
1215    pub fn record_registry_lifecycle_event(
1216        &self,
1217        event: &acdp_types::lifecycle::LifecycleEvent,
1218    ) -> Result<FullContext, AcdpError> {
1219        if !self.lifecycle_enabled {
1220            return Err(AcdpError::NotImplemented(
1221                "this registry does not advertise acdp-registry-lifecycle \
1222                 (RFC-ACDP-0013 §6)"
1223                    .into(),
1224            ));
1225        }
1226        event.validate()?;
1227        if !event.event_type.is_registered() {
1228            return Err(AcdpError::SchemaViolation(format!(
1229                "event_type '{}' is not registered for acceptance in 0.3.0 \
1230                 (RFC-ACDP-0013 §7.3)",
1231                event.event_type
1232            )));
1233        }
1234        if event.actor.as_str() != self.caps.registry_did {
1235            return Err(AcdpError::NotAuthorized(format!(
1236                "registry-initiated event actor '{}' ≠ this registry's DID '{}' \
1237                 (RFC-ACDP-0013 §6)",
1238                event.actor, self.caps.registry_did
1239            )));
1240        }
1241        if self.receipt_signer.is_some() && !event.is_signed() {
1242            return Err(AcdpError::SchemaViolation(
1243                "a registry advertising acdp-registry-receipts MUST sign its lifecycle \
1244                 events (RFC-ACDP-0013 §5)"
1245                    .into(),
1246            ));
1247        }
1248        if event.is_signed() {
1249            // §5 actor binding for the registry key.
1250            event.actor_bound_signature()?;
1251        }
1252        self.lifecycle_commit(event)
1253    }
1254}
1255
1256/// RFC-ACDP-0008 §4.5 retrieval disclosure rule.
1257pub(crate) fn can_retrieve(
1258    body: &Body,
1259    requester: Option<&AgentDid>,
1260    caps: &CapabilitiesDocument,
1261) -> bool {
1262    match body.visibility {
1263        Visibility::Public => caps.anonymous_public_reads || requester.is_some(),
1264        Visibility::Restricted | Visibility::Private => match requester {
1265            None => false,
1266            Some(r) => {
1267                r == &body.agent_id
1268                    || body
1269                        .audience
1270                        .as_deref()
1271                        .is_some_and(|a| a.iter().any(|d| d == r))
1272            }
1273        },
1274    }
1275}
1276
1277/// Resolve and fingerprint the producer key named by
1278/// `signature.key_id` — the binding recorded in a receipt's
1279/// `key_fingerprint` (RFC-ACDP-0010), and (as of RFC-ACDP-0014 §5 step 2)
1280/// also checked against a did:web key-revocation's own
1281/// `revoked_key_fingerprint` in [`RegistryServer::publish_verified_in_tenant`].
1282/// Delegates to the same
1283/// [`acdp_crypto::fingerprint::fingerprint_for_key_id`] the consumer
1284/// cross-check uses, so mint-time and verify-time fingerprints cannot
1285/// drift. Callers MUST invoke this only after
1286/// `verify_publish_request_signature` succeeded, so the fingerprinted
1287/// key is the one that actually verified (the resolver's cache makes a
1288/// second resolution — receipt minting and/or the §5 step 2 check on
1289/// the same request — cheap, and `publish_verified_in_tenant` computes
1290/// it at most once per publish either way).
1291#[cfg(feature = "client")]
1292async fn producer_key_fingerprint(
1293    req: &PublishRequest,
1294    resolver: &acdp_did::WebResolver,
1295) -> Result<String, AcdpError> {
1296    acdp_crypto::fingerprint::fingerprint_for_key_id(
1297        &req.signature.key_id,
1298        &req.signature.algorithm,
1299        resolver,
1300    )
1301    .await
1302}
1303
1304/// Fingerprint a base64 public key the caller has already verified a
1305/// signature against (an operator-pinned key, not a resolved DID
1306/// document) — no resolution involved, just decode + dispatch by
1307/// algorithm. Used by [`RegistryServer::publish_pinned_verified_in_tenant`].
1308fn fingerprint_pinned_key(public_key_b64: &str, algorithm: &str) -> Result<String, AcdpError> {
1309    use base64::{engine::general_purpose::STANDARD, Engine};
1310
1311    let raw = STANDARD
1312        .decode(public_key_b64)
1313        .map_err(|e| AcdpError::KeyResolution(format!("pinned key is not valid base64: {e}")))?;
1314    match algorithm {
1315        "ed25519" => {
1316            let arr: [u8; 32] = raw.as_slice().try_into().map_err(|_| {
1317                AcdpError::KeyResolution(format!(
1318                    "pinned ed25519 key must be 32 bytes, got {}",
1319                    raw.len()
1320                ))
1321            })?;
1322            Ok(acdp_crypto::fingerprint::fingerprint_ed25519(&arr))
1323        }
1324        "ecdsa-p256" => acdp_crypto::fingerprint::fingerprint_p256_sec1(&raw),
1325        other => Err(AcdpError::UnsupportedAlgorithm(format!(
1326            "cannot fingerprint a pinned key for algorithm '{other}'"
1327        ))),
1328    }
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333    use super::*;
1334    use crate::registry::store::InMemoryStore;
1335    use acdp_crypto::SigningKey;
1336    use acdp_producer::Producer;
1337    use acdp_types::capabilities::Limits;
1338    use acdp_types::primitives::{AgentDid, ContextType, Visibility};
1339
1340    fn caps() -> CapabilitiesDocument {
1341        CapabilitiesDocument {
1342            acdp_version: "0.1.0".into(),
1343            registry_did: "did:web:registry.example.com".into(),
1344            supported_signature_algorithms: vec!["ed25519".into()],
1345            supported_did_methods: vec!["did:web".into()],
1346            profiles: vec!["acdp-registry-core".into()],
1347            limits: Limits {
1348                max_payload_bytes: 1_048_576,
1349                max_embedded_bytes: 65_536,
1350                idempotency_key_ttl_seconds: None,
1351                max_publish_per_minute: None,
1352            },
1353            read_authentication_methods: vec![],
1354            anonymous_public_reads: true,
1355            supports_idempotency_key: false,
1356            extensions: Default::default(),
1357        }
1358    }
1359
1360    fn producer() -> Producer {
1361        Producer::new(
1362            SigningKey::from_bytes(&[1u8; 32]),
1363            AgentDid::new("did:web:agents.example.com:test"),
1364            "did:web:agents.example.com:test#key-1",
1365        )
1366    }
1367
1368    #[test]
1369    fn publish_v1_then_retrieve() {
1370        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1371        let p = producer();
1372        let req = p
1373            .publish_request()
1374            .title("v1")
1375            .context_type(ContextType::DataSnapshot)
1376            .visibility(Visibility::Public)
1377            .build()
1378            .unwrap();
1379        let resp = server.publish_unverified_for_tests(&req).unwrap();
1380        assert_eq!(resp.version, 1);
1381        let ctx = server.retrieve(&resp.ctx_id, None).unwrap().unwrap();
1382        assert_eq!(ctx.body.title, "v1");
1383        // Lineage round-trip
1384        let lineage = server.lineage(&resp.lineage_id, None).unwrap();
1385        assert_eq!(lineage.len(), 1);
1386        // Current points at the same record
1387        let cur = server.current(&resp.lineage_id, None).unwrap().unwrap();
1388        assert_eq!(cur.body.ctx_id, resp.ctx_id);
1389    }
1390
1391    #[test]
1392    fn supersession_marks_predecessor_and_returns_v2() {
1393        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1394        let p = producer();
1395        let v1_req = p
1396            .publish_request()
1397            .title("v1")
1398            .context_type(ContextType::DataSnapshot)
1399            .visibility(Visibility::Public)
1400            .build()
1401            .unwrap();
1402        let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1403
1404        let v2_req = p
1405            .supersede(v1.ctx_id.clone())
1406            .version(2)
1407            .title("v2")
1408            .context_type(ContextType::DataSnapshot)
1409            .visibility(Visibility::Public)
1410            .build()
1411            .unwrap();
1412        let v2 = server.publish_unverified_for_tests(&v2_req).unwrap();
1413        assert_eq!(v2.version, 2);
1414        // v1 was marked superseded
1415        let v1_ctx = server.retrieve(&v1.ctx_id, None).unwrap().unwrap();
1416        assert!(matches!(
1417            v1_ctx.registry_state.status,
1418            acdp_types::Status::Superseded
1419        ));
1420        // Same lineage
1421        assert_eq!(v1.lineage_id, v2.lineage_id);
1422        // Current resolves to v2
1423        let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1424        assert_eq!(cur.body.ctx_id, v2.ctx_id);
1425    }
1426
1427    /// FEAT-01: two concurrent publishes that both supersede the same
1428    /// v1 MUST resolve to exactly one success + one
1429    /// `SupersededTarget { AlreadySuperseded }`. The race was possible
1430    /// when the supersedes check, body insert, and predecessor mark
1431    /// lived in separate mutex acquisitions; `commit_publish` puts
1432    /// them under one critical section so only one of two contenders
1433    /// wins (RFC-ACDP-0003 §6).
1434    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1435    async fn concurrent_supersession_exactly_one_succeeds() {
1436        use std::sync::Arc;
1437        let server = Arc::new(RegistryServer::new(
1438            InMemoryStore::new(),
1439            caps(),
1440            "registry.example.com",
1441        ));
1442        let p = producer();
1443        let v1_req = p
1444            .publish_request()
1445            .title("v1")
1446            .context_type(ContextType::DataSnapshot)
1447            .visibility(Visibility::Public)
1448            .build()
1449            .unwrap();
1450        let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1451
1452        // Pre-build BOTH v2 requests up front, then fire them in
1453        // parallel on a multi-threaded runtime. With the prior
1454        // non-atomic sequence the test would fail intermittently;
1455        // with `commit_publish` it's deterministic.
1456        let v2a_req = p
1457            .supersede(v1.ctx_id.clone())
1458            .version(2)
1459            .title("v2-A")
1460            .context_type(ContextType::DataSnapshot)
1461            .visibility(Visibility::Public)
1462            .build()
1463            .unwrap();
1464        let v2b_req = p
1465            .supersede(v1.ctx_id.clone())
1466            .version(2)
1467            .title("v2-B")
1468            .context_type(ContextType::DataSnapshot)
1469            .visibility(Visibility::Public)
1470            .build()
1471            .unwrap();
1472
1473        let s1 = Arc::clone(&server);
1474        let s2 = Arc::clone(&server);
1475        let h1 = tokio::task::spawn_blocking(move || s1.publish_unverified_for_tests(&v2a_req));
1476        let h2 = tokio::task::spawn_blocking(move || s2.publish_unverified_for_tests(&v2b_req));
1477        let (r1, r2) = (h1.await.unwrap(), h2.await.unwrap());
1478
1479        let outcomes = [r1, r2];
1480        let successes = outcomes.iter().filter(|r| r.is_ok()).count();
1481        let failures = outcomes.iter().filter(|r| r.is_err()).count();
1482        assert_eq!(
1483            successes, 1,
1484            "exactly one concurrent supersession MUST succeed; got {successes} successes / {failures} failures"
1485        );
1486        assert_eq!(failures, 1);
1487        // The loser MUST get AlreadySuperseded — the predecessor was
1488        // marked under the same lock the winner used.
1489        for r in &outcomes {
1490            if let Err(e) = r {
1491                match e {
1492                    AcdpError::SupersededTarget { reason, .. } => assert_eq!(
1493                        *reason,
1494                        acdp_primitives::error::SupersessionReason::AlreadySuperseded,
1495                        "concurrent loser MUST be AlreadySuperseded"
1496                    ),
1497                    other => panic!("concurrent loser had wrong error: {other:?}"),
1498                }
1499            }
1500        }
1501    }
1502
1503    #[test]
1504    fn hostile_supersession_by_non_owner_rejected_predecessor_unchanged() {
1505        // P0-2: an attacker controlling their own DID must not be able to
1506        // supersede a victim's context. Without the producer-continuity
1507        // check this marks the victim's context `Superseded` and re-points
1508        // `current(lineage)` at the attacker's body — a lineage takeover.
1509        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1510        let victim = producer_for(7, "did:web:agents.example.com:victim");
1511        let v1_req = victim
1512            .publish_request()
1513            .title("v1")
1514            .context_type(ContextType::DataSnapshot)
1515            .visibility(Visibility::Public)
1516            .build()
1517            .unwrap();
1518        let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1519
1520        // Attacker signs their own valid v2, omitting lineage_id (the only
1521        // self-declared coherence arm), supersedes = victim's v1.
1522        let attacker = producer_for(9, "did:web:evil.example.com:attacker");
1523        let v2_req = attacker
1524            .supersede(v1.ctx_id.clone())
1525            .version(2)
1526            .title("hijacked")
1527            .context_type(ContextType::DataSnapshot)
1528            .visibility(Visibility::Public)
1529            .build()
1530            .unwrap();
1531        let err = server.publish_unverified_for_tests(&v2_req).unwrap_err();
1532        // Uniform with not-found: no existence / version / status oracle.
1533        match err {
1534            AcdpError::SupersededTarget { reason, .. } => {
1535                assert_eq!(reason, acdp_primitives::error::SupersessionReason::NotFound);
1536            }
1537            other => panic!("expected uniform SupersededTarget::NotFound, got {other:?}"),
1538        }
1539        // Predecessor MUST be untouched: still current, not superseded.
1540        let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1541        assert_eq!(cur.body.ctx_id, v1.ctx_id);
1542        assert_eq!(cur.body.title, "v1");
1543        assert_eq!(
1544            cur.registry_state.status,
1545            acdp_types::primitives::Status::Active
1546        );
1547    }
1548
1549    #[test]
1550    fn owner_supersession_still_succeeds_after_ownership_check() {
1551        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1552        let p = producer();
1553        let v1_req = p
1554            .publish_request()
1555            .title("v1")
1556            .context_type(ContextType::DataSnapshot)
1557            .visibility(Visibility::Public)
1558            .build()
1559            .unwrap();
1560        let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1561        let v2_req = p
1562            .supersede(v1.ctx_id.clone())
1563            .version(2)
1564            .title("v2")
1565            .context_type(ContextType::DataSnapshot)
1566            .visibility(Visibility::Public)
1567            .build()
1568            .unwrap();
1569        let v2 = server.publish_unverified_for_tests(&v2_req).unwrap();
1570        assert_eq!(v2.version, 2);
1571        let cur = server.current(&v1.lineage_id, None).unwrap().unwrap();
1572        assert_eq!(cur.body.ctx_id, v2.ctx_id);
1573    }
1574
1575    #[test]
1576    fn supersession_with_unknown_target_rejected_as_not_found() {
1577        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1578        let p = producer();
1579        let phantom =
1580            CtxId("acdp://registry.example.com/12345678-1234-4321-8123-deadbeefcafe".into());
1581        let req = p
1582            .supersede(phantom)
1583            .version(2)
1584            .title("v2-orphan")
1585            .context_type(ContextType::DataSnapshot)
1586            .visibility(Visibility::Public)
1587            .build()
1588            .unwrap();
1589        let err = server.publish_unverified_for_tests(&req).unwrap_err();
1590        match err {
1591            AcdpError::SupersededTarget { reason, .. } => {
1592                assert_eq!(reason, acdp_primitives::error::SupersessionReason::NotFound);
1593            }
1594            other => panic!("expected SupersededTarget::NotFound, got {other:?}"),
1595        }
1596    }
1597
1598    #[test]
1599    fn version_mismatch_rejected() {
1600        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1601        let p = producer();
1602        let v1_req = p
1603            .publish_request()
1604            .title("v1")
1605            .context_type(ContextType::DataSnapshot)
1606            .visibility(Visibility::Public)
1607            .build()
1608            .unwrap();
1609        let v1 = server.publish_unverified_for_tests(&v1_req).unwrap();
1610        // Build a v3 (wrong) supersession
1611        let v3_req = p
1612            .supersede(v1.ctx_id.clone())
1613            .version(3)
1614            .title("v3-skipped")
1615            .context_type(ContextType::DataSnapshot)
1616            .visibility(Visibility::Public)
1617            .build()
1618            .unwrap();
1619        let err = server.publish_unverified_for_tests(&v3_req).unwrap_err();
1620        match err {
1621            AcdpError::SupersededTarget { reason, .. } => {
1622                assert_eq!(
1623                    reason,
1624                    acdp_primitives::error::SupersessionReason::VersionMismatch
1625                );
1626            }
1627            other => panic!("expected VersionMismatch, got {other:?}"),
1628        }
1629    }
1630
1631    #[test]
1632    fn search_finds_published_context() {
1633        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1634        let p = producer();
1635        let req = p
1636            .publish_request()
1637            .title("Q1 portfolio risk")
1638            .context_type(ContextType::DataSnapshot)
1639            .visibility(Visibility::Public)
1640            .build()
1641            .unwrap();
1642        server.publish_unverified_for_tests(&req).unwrap();
1643        let resp = server
1644            .search(
1645                &SearchParams {
1646                    q: Some("portfolio".into()),
1647                    ..Default::default()
1648                },
1649                None,
1650            )
1651            .unwrap();
1652        assert_eq!(resp.matches.len(), 1);
1653        assert_eq!(resp.matches[0].title, "Q1 portfolio risk");
1654    }
1655
1656    // ── BUG-03 — lineage/current visibility filtering ──────────────────
1657
1658    /// BUG-03: a stranger calling `lineage()` MUST NOT see restricted
1659    /// bodies they aren't on the audience for. The retrieval predicate
1660    /// is now mirrored here.
1661    #[test]
1662    fn lineage_filters_restricted_for_stranger() {
1663        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1664        let p = producer();
1665        let audience = AgentDid::new("did:web:audience.example.com:reader");
1666        let req = p
1667            .publish_request()
1668            .title("restricted v1")
1669            .context_type(ContextType::DataSnapshot)
1670            .visibility(Visibility::Restricted)
1671            .audience(vec![audience.clone()])
1672            .build()
1673            .unwrap();
1674        let resp = server.publish_unverified_for_tests(&req).unwrap();
1675
1676        let stranger = AgentDid::new("did:web:other.example.com:reader");
1677        let stranger_view = server.lineage(&resp.lineage_id, Some(&stranger)).unwrap();
1678        assert!(
1679            stranger_view.is_empty(),
1680            "stranger MUST NOT see restricted bodies via lineage(); got {} entries",
1681            stranger_view.len()
1682        );
1683
1684        let audience_view = server.lineage(&resp.lineage_id, Some(&audience)).unwrap();
1685        assert_eq!(
1686            audience_view.len(),
1687            1,
1688            "audience member MUST see the restricted body via lineage()"
1689        );
1690    }
1691
1692    /// BUG-03: `current()` also filters by requester visibility.
1693    /// A stranger gets `None` for a private lineage.
1694    #[test]
1695    fn current_filters_private_for_stranger() {
1696        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1697        let p = producer();
1698        let req = p
1699            .publish_request()
1700            .title("private v1")
1701            .context_type(ContextType::DataSnapshot)
1702            .visibility(Visibility::Private)
1703            .build()
1704            .unwrap();
1705        let resp = server.publish_unverified_for_tests(&req).unwrap();
1706
1707        let stranger = AgentDid::new("did:web:other.example.com:reader");
1708        assert!(
1709            server
1710                .current(&resp.lineage_id, Some(&stranger))
1711                .unwrap()
1712                .is_none(),
1713            "stranger MUST NOT see private contexts via current()"
1714        );
1715
1716        let producer_did = AgentDid::new("did:web:agents.example.com:test");
1717        assert!(
1718            server
1719                .current(&resp.lineage_id, Some(&producer_did))
1720                .unwrap()
1721                .is_some(),
1722            "producer MUST see private contexts via current()"
1723        );
1724    }
1725
1726    // ── BUG-04 — current() superseded fallback ─────────────────────────
1727
1728    /// BUG-04: when every version of a lineage is `Superseded`,
1729    /// `current()` MUST return `None`. Previously the fallback returned
1730    /// the last entry projected, which is a protocol violation
1731    /// (RFC-ACDP-0004 §5: "If no such version exists, returns not_found").
1732    ///
1733    /// Constructing an all-superseded lineage requires a direct store
1734    /// mark — there's no publish path that produces this state today,
1735    /// but the registry's `current()` MUST not implicitly fall through.
1736    #[test]
1737    fn current_returns_none_when_all_superseded() {
1738        use crate::registry::store::RegistryStore;
1739        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1740        let p = producer();
1741        let req = p
1742            .publish_request()
1743            .title("v1")
1744            .context_type(ContextType::DataSnapshot)
1745            .visibility(Visibility::Public)
1746            .build()
1747            .unwrap();
1748        let resp = server.publish_unverified_for_tests(&req).unwrap();
1749        // Force the only version into Superseded directly.
1750        server.store().mark_superseded(&resp.ctx_id).unwrap();
1751
1752        let cur = server.current(&resp.lineage_id, None).unwrap();
1753        assert!(
1754            cur.is_none(),
1755            "all-superseded lineage MUST resolve to None per RFC-ACDP-0004 §5; got {cur:?}"
1756        );
1757    }
1758
1759    // ── BUG-01 / vis-009 — anonymous search honors anonymous_public_reads ──
1760
1761    /// BUG-01 + vis-009: a registry advertising `anonymous_public_reads:
1762    /// false` MUST reject an anonymous search with `not_authorized`
1763    /// (HTTP 403) — not an empty `200`, which would still leak the
1764    /// registry's existence. The same context surfaces with a `200`
1765    /// once the requester authenticates.
1766    #[test]
1767    fn search_suppresses_public_when_anonymous_public_reads_false() {
1768        let mut c = caps();
1769        c.anonymous_public_reads = false;
1770        let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
1771        let p = producer();
1772        let req = p
1773            .publish_request()
1774            .title("public-but-flag-off")
1775            .context_type(ContextType::DataSnapshot)
1776            .visibility(Visibility::Public)
1777            .build()
1778            .unwrap();
1779        server.publish_unverified_for_tests(&req).unwrap();
1780
1781        // Anonymous: MUST be rejected with NotAuthorized (vis-009 s1).
1782        let err = server
1783            .search(
1784                &SearchParams {
1785                    q: Some("public-but-flag-off".into()),
1786                    ..Default::default()
1787                },
1788                None,
1789            )
1790            .unwrap_err();
1791        assert!(
1792            matches!(err, AcdpError::NotAuthorized(_)),
1793            "vis-009: anonymous search MUST be NotAuthorized when \
1794             anonymous_public_reads=false; got {err:?}"
1795        );
1796
1797        // Authenticated requester (any DID — public is universally visible
1798        // once authenticated): MUST see the context.
1799        let stranger = AgentDid::new("did:web:other.example.com:reader");
1800        let authed = server
1801            .search(
1802                &SearchParams {
1803                    q: Some("public-but-flag-off".into()),
1804                    ..Default::default()
1805                },
1806                Some(&stranger),
1807            )
1808            .unwrap();
1809        assert_eq!(
1810            authed.matches.len(),
1811            1,
1812            "authenticated search MUST see public contexts regardless of anonymous_public_reads"
1813        );
1814    }
1815
1816    // ── try_new validation tests ────────────────────────────────────────
1817
1818    #[test]
1819    fn try_new_rejects_did_authority_mismatch() {
1820        let mut c = caps();
1821        c.registry_did = "did:web:other.example.com".into(); // wrong authority
1822        let res = RegistryServer::try_new(InMemoryStore::new(), c, "registry.example.com");
1823        match res {
1824            Err(AcdpError::SchemaViolation(msg)) => {
1825                assert!(msg.contains("does not match expected"))
1826            }
1827            Err(other) => panic!("expected SchemaViolation, got {other:?}"),
1828            Ok(_) => panic!("expected Err"),
1829        }
1830    }
1831
1832    #[test]
1833    fn try_new_rejects_caps_missing_ed25519() {
1834        let mut c = caps();
1835        c.supported_signature_algorithms = vec!["ecdsa-p256".into()]; // missing ed25519
1836        let res = RegistryServer::try_new(InMemoryStore::new(), c, "registry.example.com");
1837        assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1838    }
1839
1840    #[test]
1841    fn try_new_accepts_valid_caps() {
1842        RegistryServer::try_new(InMemoryStore::new(), caps(), "registry.example.com").unwrap();
1843    }
1844
1845    // ── WIRE-04 — try_new authority-format validation ───────────────────
1846
1847    #[test]
1848    fn try_new_accepts_valid_dns_authority() {
1849        RegistryServer::try_new(InMemoryStore::new(), caps(), "registry.example.com").unwrap();
1850    }
1851
1852    #[test]
1853    fn try_new_rejects_host_port_authority() {
1854        // A `host:port` authority would mint `acdp://localhost:8443/<uuid>`
1855        // ctx_ids — a colon violates the acdp:// authority rule.
1856        let res = RegistryServer::try_new(InMemoryStore::new(), caps(), "localhost:8443");
1857        assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1858    }
1859
1860    #[test]
1861    fn try_new_rejects_uppercase_authority() {
1862        let res = RegistryServer::try_new(InMemoryStore::new(), caps(), "Registry.Example.Com");
1863        assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1864    }
1865
1866    #[test]
1867    fn try_new_rejects_url_form_authority() {
1868        let res =
1869            RegistryServer::try_new(InMemoryStore::new(), caps(), "https://registry.example.com");
1870        assert!(matches!(res, Err(AcdpError::SchemaViolation(_))));
1871    }
1872
1873    #[test]
1874    fn try_new_for_test_accepts_host_port() {
1875        // The test constructor skips the DNS-authority check; it still
1876        // enforces the DID binding, so the caps DID must match.
1877        let mut c = caps();
1878        c.registry_did = acdp_did::authority_to_did_web("localhost:8443");
1879        RegistryServer::try_new_for_test_authority(InMemoryStore::new(), c, "localhost:8443")
1880            .unwrap();
1881    }
1882
1883    // ── Visibility-enforcement tests (RFC-ACDP-0008 §4.5) ───────────────
1884
1885    fn producer_for(seed: u8, did: &str) -> Producer {
1886        Producer::new(
1887            SigningKey::from_bytes(&[seed; 32]),
1888            AgentDid::new(did),
1889            format!("{did}#key-1"),
1890        )
1891    }
1892
1893    #[test]
1894    fn retrieve_restricted_blocks_stranger_returns_none() {
1895        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1896        let owner = AgentDid::new("did:web:agents.example.com:owner");
1897        let audience_member = AgentDid::new("did:web:agents.example.com:friend");
1898        let p = producer_for(2, owner.as_str());
1899        let req = p
1900            .publish_request()
1901            .title("restricted")
1902            .context_type(ContextType::DataSnapshot)
1903            .visibility(Visibility::Restricted)
1904            .audience(vec![audience_member.clone()])
1905            .build()
1906            .unwrap();
1907        let resp = server.publish_unverified_for_tests(&req).unwrap();
1908        let stranger = AgentDid::new("did:web:agents.example.com:stranger");
1909
1910        assert!(server.retrieve(&resp.ctx_id, None).unwrap().is_none());
1911        assert!(server
1912            .retrieve(&resp.ctx_id, Some(&stranger))
1913            .unwrap()
1914            .is_none());
1915        assert!(server
1916            .retrieve(&resp.ctx_id, Some(&owner))
1917            .unwrap()
1918            .is_some());
1919        assert!(server
1920            .retrieve(&resp.ctx_id, Some(&audience_member))
1921            .unwrap()
1922            .is_some());
1923    }
1924
1925    #[test]
1926    fn search_restricted_filters_strangers() {
1927        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1928        let owner = AgentDid::new("did:web:agents.example.com:owner");
1929        let p = producer_for(3, owner.as_str());
1930        let req = p
1931            .publish_request()
1932            .title("hush hush")
1933            .context_type(ContextType::DataSnapshot)
1934            .visibility(Visibility::Restricted)
1935            .audience(vec![AgentDid::new("did:web:agents.example.com:friend")])
1936            .build()
1937            .unwrap();
1938        server.publish_unverified_for_tests(&req).unwrap();
1939
1940        let stranger = AgentDid::new("did:web:agents.example.com:stranger");
1941        let r_anon = server.search(&SearchParams::default(), None).unwrap();
1942        assert!(
1943            r_anon.matches.is_empty(),
1944            "anonymous must not see restricted"
1945        );
1946        let r_stranger = server
1947            .search(&SearchParams::default(), Some(&stranger))
1948            .unwrap();
1949        assert!(r_stranger.matches.is_empty());
1950        let r_owner = server
1951            .search(&SearchParams::default(), Some(&owner))
1952            .unwrap();
1953        assert_eq!(r_owner.matches.len(), 1);
1954    }
1955
1956    /// RFC-ACDP-0008 §4.5 asymmetry: a private context surfaces in search
1957    /// only to its producer — audience members can retrieve by id but can't
1958    /// discover via search.
1959    #[test]
1960    fn search_private_visible_only_to_producer() {
1961        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
1962        let owner = AgentDid::new("did:web:agents.example.com:owner");
1963        let audience_member = AgentDid::new("did:web:agents.example.com:friend");
1964        let p = producer_for(4, owner.as_str());
1965        let req = p
1966            .publish_request()
1967            .title("private note")
1968            .context_type(ContextType::DataSnapshot)
1969            .visibility(Visibility::Private)
1970            .audience(vec![audience_member.clone()])
1971            .build()
1972            .unwrap();
1973        let resp = server.publish_unverified_for_tests(&req).unwrap();
1974
1975        let r_audience = server
1976            .search(&SearchParams::default(), Some(&audience_member))
1977            .unwrap();
1978        assert!(
1979            r_audience.matches.is_empty(),
1980            "audience must NOT see private in search"
1981        );
1982        let r_owner = server
1983            .search(&SearchParams::default(), Some(&owner))
1984            .unwrap();
1985        assert_eq!(
1986            r_owner.matches.len(),
1987            1,
1988            "owner sees their own private context"
1989        );
1990
1991        // Audience CAN retrieve directly by id.
1992        assert!(server
1993            .retrieve(&resp.ctx_id, Some(&audience_member))
1994            .unwrap()
1995            .is_some());
1996    }
1997
1998    // ── publish_verified offline-rejection tests ────────────────────────
1999    //
2000    // Full end-to-end `publish_verified` requires a TLS-mocked DID
2001    // document (because `WebResolver` is HTTPS-only). These tests cover
2002    // the rejection paths that fire BEFORE the resolver call so they
2003    // don't need a network: malformed key_id, non-did:web key_id,
2004    // agent_id ≠ key_id DID portion. Together with the existing
2005    // `verify_signature_envelope` algorithm-downgrade unit test, they
2006    // pin the entry checks of RFC-ACDP-0003 §2.1 steps 7–8 without
2007    // requiring a TLS mock harness.
2008
2009    #[cfg(feature = "client")]
2010    #[tokio::test]
2011    async fn publish_verified_rejects_non_did_web_key_id() {
2012        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2013        let p = producer();
2014        let mut req = p
2015            .publish_request()
2016            .title("v1")
2017            .context_type(ContextType::DataSnapshot)
2018            .visibility(Visibility::Public)
2019            .build()
2020            .unwrap();
2021        // Mutate post-build — validation already ran and accepted did:web.
2022        // Re-sign isn't necessary: the verifier rejects before signature
2023        // check. Use a *well-formed* did:key URL (a malformed one is
2024        // caught earlier by schema validation as of ACDP 0.2): the
2025        // key_id DID portion no longer matches the did:web agent_id, so
2026        // the binding check refuses it.
2027        let did_key = acdp_did::key::did_key_from_ed25519(
2028            &SigningKey::from_bytes(&[9u8; 32]).verifying_key_bytes(),
2029        );
2030        req.signature.key_id = acdp_did::key::did_key_url(&did_key).unwrap();
2031        let resolver = acdp_did::WebResolver::new();
2032        let err = server
2033            .publish_verified(&req, None, &resolver)
2034            .await
2035            .unwrap_err();
2036        match err {
2037            AcdpError::KeyNotAuthorized(msg) => assert!(msg.contains("did:web")),
2038            other => panic!("expected KeyNotAuthorized for non-did:web, got {other:?}"),
2039        }
2040    }
2041
2042    #[cfg(feature = "client")]
2043    #[tokio::test]
2044    async fn publish_verified_rejects_agent_id_keyid_mismatch() {
2045        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2046        let p = producer();
2047        let mut req = p
2048            .publish_request()
2049            .title("v1")
2050            .context_type(ContextType::DataSnapshot)
2051            .visibility(Visibility::Public)
2052            .build()
2053            .unwrap();
2054        req.signature.key_id = "did:web:other.example.com:agent#key-1".into();
2055        let resolver = acdp_did::WebResolver::new();
2056        let err = server
2057            .publish_verified(&req, None, &resolver)
2058            .await
2059            .unwrap_err();
2060        match err {
2061            AcdpError::KeyNotAuthorized(msg) => assert!(msg.contains("agent_id")),
2062            other => panic!("expected KeyNotAuthorized for agent_id mismatch, got {other:?}"),
2063        }
2064    }
2065
2066    #[cfg(feature = "client")]
2067    #[tokio::test]
2068    async fn publish_verified_rejects_keyid_without_fragment() {
2069        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2070        let p = producer();
2071        let mut req = p
2072            .publish_request()
2073            .title("v1")
2074            .context_type(ContextType::DataSnapshot)
2075            .visibility(Visibility::Public)
2076            .build()
2077            .unwrap();
2078        req.signature.key_id = "did:web:agents.example.com:test".into(); // no '#'
2079        let resolver = acdp_did::WebResolver::new();
2080        let err = server
2081            .publish_verified(&req, None, &resolver)
2082            .await
2083            .unwrap_err();
2084        // Schema validation (step 1) catches missing-fragment before
2085        // step 7 fires, so the surface error is SchemaViolation.
2086        assert!(
2087            matches!(
2088                err,
2089                AcdpError::SchemaViolation(_) | AcdpError::KeyResolution(_)
2090            ),
2091            "expected fragment-rejection error, got {err:?}"
2092        );
2093    }
2094
2095    // ── FEAT-04 idempotency tests ──────────────────────────────────────
2096
2097    fn caps_with_idempotency() -> CapabilitiesDocument {
2098        let mut c = caps();
2099        c.supports_idempotency_key = true;
2100        c.limits.idempotency_key_ttl_seconds = Some(86_400);
2101        c
2102    }
2103
2104    #[test]
2105    fn idempotency_same_hash_returns_original_response() {
2106        let server = RegistryServer::new(
2107            InMemoryStore::new(),
2108            caps_with_idempotency(),
2109            "registry.example.com",
2110        );
2111        let p = producer();
2112        let req = p
2113            .publish_request()
2114            .title("once")
2115            .context_type(ContextType::DataSnapshot)
2116            .visibility(Visibility::Public)
2117            .build()
2118            .unwrap();
2119        // Publish twice through the same idempotency key via the real
2120        // path — `publish_unverified_in_tenant_for_tests` can pass an
2121        // idempotency key straight through to `commit_via_store`, so
2122        // there is no more need to fake the store-level entry.
2123        let first = server
2124            .publish_unverified_in_tenant_for_tests(&req, Some("k-001"), None)
2125            .unwrap();
2126        let replayed = server
2127            .publish_unverified_in_tenant_for_tests(&req, Some("k-001"), None)
2128            .unwrap();
2129        assert_eq!(replayed.ctx_id, first.ctx_id);
2130        assert_eq!(replayed.lineage_id, first.lineage_id);
2131        assert_eq!(replayed.created_at, first.created_at);
2132        // And only one context was actually persisted.
2133        let resp = server.search(&SearchParams::default(), None).unwrap();
2134        assert_eq!(
2135            resp.matches.len(),
2136            1,
2137            "idempotent replay must not persist a second context"
2138        );
2139    }
2140
2141    #[test]
2142    fn idempotency_evicts_after_ttl() {
2143        let store = InMemoryStore::new();
2144        let agent = AgentDid::new("did:web:agents.example.com:test");
2145        let resp = PublishResponse {
2146            registry_receipt: None,
2147            ctx_id: acdp_types::CtxId("acdp://r/12345678-1234-4321-8123-000000000099".into()),
2148            lineage_id: acdp_types::LineageId(
2149                "lin:sha256:9999999999999999999999999999999999999999999999999999999999999999"
2150                    .into(),
2151            ),
2152            version: 1,
2153            created_at: chrono::Utc::now(),
2154            status: Status::Active,
2155        };
2156        // Already-past expiration.
2157        let past = chrono::Utc::now() - chrono::Duration::seconds(1);
2158        store
2159            .idempotency_record(
2160                &agent,
2161                "expired",
2162                &acdp_types::ContentHash("sha256:0".into()),
2163                &resp,
2164                past,
2165            )
2166            .unwrap();
2167        // Lookup runs lazy eviction; the expired record MUST be gone.
2168        let prior = store.idempotency_lookup(&agent, "expired").unwrap();
2169        assert!(
2170            prior.is_none(),
2171            "lazy TTL eviction should drop expired record"
2172        );
2173    }
2174
2175    /// A receipts-advertising registry must refuse
2176    /// `publish_unverified_in_tenant_for_tests` identically to
2177    /// `publish_unverified_for_tests` (RFC-ACDP-0010 §7: no degraded
2178    /// mode) — the refusal is inherited by construction since the old
2179    /// method's body moved wholesale into the new one, but this proves
2180    /// it from the new name specifically.
2181    #[test]
2182    fn publish_unverified_in_tenant_for_tests_refuses_receipts_registry() {
2183        let mut c = caps();
2184        c.acdp_version = "0.2.0".into();
2185        let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com")
2186            .with_receipt_signer(
2187                acdp_types::receipt::ReceiptSigner::new(
2188                    SigningKey::from_bytes(&[0x33u8; 32]),
2189                    "did:web:registry.example.com",
2190                    "did:web:registry.example.com#receipt-key-1",
2191                )
2192                .unwrap(),
2193            )
2194            .unwrap();
2195        let p = producer();
2196        let req = p
2197            .publish_request()
2198            .title("should be refused")
2199            .context_type(ContextType::DataSnapshot)
2200            .visibility(Visibility::Public)
2201            .build()
2202            .unwrap();
2203        let err = server
2204            .publish_unverified_in_tenant_for_tests(&req, None, None)
2205            .unwrap_err();
2206        assert!(
2207            matches!(err, AcdpError::SchemaViolation(_)),
2208            "expected SchemaViolation refusal on a receipts-advertising registry, got {err:?}"
2209        );
2210    }
2211
2212    // ── Tenancy-recording test store ────────────────────────────────────
2213    //
2214    // `InMemoryStore::commit_publish` deliberately discards `tenant`
2215    // (store.rs:676-678: "InMemoryStore does not model tenancy") since
2216    // it is a single-tenant reference/test backend — so testing tenancy
2217    // plumbing against it directly would prove nothing. `RecordingStore`
2218    // wraps an `InMemoryStore` for all real behavior and additionally
2219    // captures the `tenant` argument each `commit_publish` call receives.
2220
2221    /// Delegates every [`RegistryStore`] method to an inner
2222    /// [`InMemoryStore`], additionally recording `commit.tenant` from
2223    /// each [`RegistryStore::commit_publish`] call.
2224    struct RecordingStore {
2225        inner: InMemoryStore,
2226        tenants: std::sync::Mutex<Vec<Option<String>>>,
2227    }
2228
2229    impl RecordingStore {
2230        fn new() -> Self {
2231            Self {
2232                inner: InMemoryStore::new(),
2233                tenants: std::sync::Mutex::new(Vec::new()),
2234            }
2235        }
2236    }
2237
2238    impl RegistryStore for RecordingStore {
2239        fn put(&self, body: Body) -> Result<(), AcdpError> {
2240            self.inner.put(body)
2241        }
2242
2243        fn get(&self, ctx_id: &CtxId) -> Result<Option<FullContext>, AcdpError> {
2244            self.inner.get(ctx_id)
2245        }
2246
2247        fn lineage(&self, lineage_id: &LineageId) -> Result<Vec<FullContext>, AcdpError> {
2248            self.inner.lineage(lineage_id)
2249        }
2250
2251        fn current(&self, lineage_id: &LineageId) -> Result<Option<FullContext>, AcdpError> {
2252            self.inner.current(lineage_id)
2253        }
2254
2255        fn mark_superseded(&self, ctx_id: &CtxId) -> Result<(), AcdpError> {
2256            self.inner.mark_superseded(ctx_id)
2257        }
2258
2259        fn first_version_ctx_id(&self, lineage_id: &LineageId) -> Result<Option<CtxId>, AcdpError> {
2260            self.inner.first_version_ctx_id(lineage_id)
2261        }
2262
2263        fn search(
2264            &self,
2265            params: &SearchParams,
2266            requester: Option<&AgentDid>,
2267            anonymous_public_reads: bool,
2268        ) -> Result<SearchResponse, AcdpError> {
2269            self.inner.search(params, requester, anonymous_public_reads)
2270        }
2271
2272        fn idempotency_lookup(
2273            &self,
2274            agent_id: &AgentDid,
2275            key: &str,
2276        ) -> Result<Option<crate::registry::store::IdempotencyRecord>, AcdpError> {
2277            self.inner.idempotency_lookup(agent_id, key)
2278        }
2279
2280        fn idempotency_record(
2281            &self,
2282            agent_id: &AgentDid,
2283            key: &str,
2284            hash: &acdp_types::primitives::ContentHash,
2285            response: &PublishResponse,
2286            expires_at: chrono::DateTime<chrono::Utc>,
2287        ) -> Result<(), AcdpError> {
2288            self.inner
2289                .idempotency_record(agent_id, key, hash, response, expires_at)
2290        }
2291
2292        fn idempotency_evict_expired(
2293            &self,
2294            now: chrono::DateTime<chrono::Utc>,
2295        ) -> Result<(), AcdpError> {
2296            self.inner.idempotency_evict_expired(now)
2297        }
2298
2299        fn commit_publish(
2300            &self,
2301            commit: crate::registry::store::PublishCommit<'_>,
2302        ) -> Result<crate::registry::store::PublishCommitOutcome, AcdpError> {
2303            self.tenants
2304                .lock()
2305                .unwrap()
2306                .push(commit.tenant.map(String::from));
2307            self.inner.commit_publish(commit)
2308        }
2309
2310        fn commit_lifecycle_event(
2311            &self,
2312            event: &acdp_types::lifecycle::LifecycleEvent,
2313        ) -> Result<crate::registry::store::LifecycleCommitOutcome, AcdpError> {
2314            self.inner.commit_lifecycle_event(event)
2315        }
2316    }
2317
2318    /// Proves `tenant` reaches `PublishCommit.tenant` verbatim from
2319    /// `publish_unverified_in_tenant_for_tests`, in call order.
2320    ///
2321    /// What this does NOT prove: tenant *isolation* (that two tenants'
2322    /// data cannot leak into each other's reads) — that is the durable
2323    /// backends' contract (`store.rs:274-279`), entirely out of scope
2324    /// for `InMemoryStore`/`RecordingStore`, which is why this test only
2325    /// asserts on the recorded argument, not on any read-side behavior.
2326    #[test]
2327    fn tenant_reaches_publish_commit_verbatim() {
2328        let server = RegistryServer::new(RecordingStore::new(), caps(), "registry.example.com");
2329        let p = producer();
2330
2331        let req_a = p
2332            .publish_request()
2333            .title("tenant a")
2334            .context_type(ContextType::DataSnapshot)
2335            .visibility(Visibility::Public)
2336            .build()
2337            .unwrap();
2338        server
2339            .publish_unverified_in_tenant_for_tests(&req_a, None, Some("tenant-a"))
2340            .unwrap();
2341
2342        let req_b = p
2343            .publish_request()
2344            .title("tenant none")
2345            .context_type(ContextType::DataSnapshot)
2346            .visibility(Visibility::Public)
2347            .build()
2348            .unwrap();
2349        server
2350            .publish_unverified_in_tenant_for_tests(&req_b, None, None)
2351            .unwrap();
2352
2353        let recorded = server.store().tenants.lock().unwrap().clone();
2354        assert_eq!(
2355            recorded,
2356            vec![Some("tenant-a".to_string()), None],
2357            "tenant must reach PublishCommit.tenant verbatim, in call order"
2358        );
2359    }
2360
2361    // ── FEAT-05 rate limiter tests ─────────────────────────────────────
2362
2363    struct AlwaysDeny;
2364    impl crate::registry::RateLimiter for AlwaysDeny {
2365        fn check_publish(&self, agent_id: &AgentDid) -> Result<(), AcdpError> {
2366            Err(AcdpError::RateLimited(format!("blocked: {agent_id}")))
2367        }
2368    }
2369
2370    #[test]
2371    fn rate_limiter_blocks_publish_before_persist() {
2372        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com")
2373            .with_rate_limiter(AlwaysDeny);
2374        let p = producer();
2375        let req = p
2376            .publish_request()
2377            .title("blocked")
2378            .context_type(ContextType::DataSnapshot)
2379            .visibility(Visibility::Public)
2380            .build()
2381            .unwrap();
2382        let err = server.publish_unverified_for_tests(&req).unwrap_err();
2383        assert!(matches!(err, AcdpError::RateLimited(_)));
2384        // And the store is empty — the limiter MUST short-circuit before persist.
2385        let resp = server.search(&SearchParams::default(), None).unwrap();
2386        assert!(
2387            resp.matches.is_empty(),
2388            "rate-limited publish must not persist"
2389        );
2390    }
2391
2392    #[test]
2393    fn created_at_is_ms_truncated() {
2394        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2395        let p = producer();
2396        let req = p
2397            .publish_request()
2398            .title("ms")
2399            .context_type(ContextType::DataSnapshot)
2400            .visibility(Visibility::Public)
2401            .build()
2402            .unwrap();
2403        let resp = server.publish_unverified_for_tests(&req).unwrap();
2404        // Nanosecond component of a ms-truncated timestamp is always a multiple of 1_000_000.
2405        assert_eq!(
2406            resp.created_at.timestamp_subsec_nanos() % 1_000_000,
2407            0,
2408            "created_at must be millisecond-truncated per RFC-ACDP-0001 §5.3"
2409        );
2410    }
2411
2412    // ── did:key publish (ACDP 0.2) ───────────────────────────────────────
2413
2414    fn did_key_request() -> acdp_types::publish::PublishRequest {
2415        let p = Producer::new_did_key(SigningKey::from_bytes(&[7u8; 32]));
2416        p.publish_request()
2417            .title("did:key publish")
2418            .context_type(ContextType::DataSnapshot)
2419            .visibility(Visibility::Public)
2420            .build()
2421            .unwrap()
2422    }
2423
2424    /// A registry that does NOT advertise `did:key` in
2425    /// `supported_did_methods` refuses a did:key publish with
2426    /// `key_resolution_failed` (permanent) — the anchor-plan decision.
2427    #[test]
2428    fn did_key_publish_rejected_when_not_advertised() {
2429        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2430        let err = server
2431            .publish_verified_did_key(&did_key_request(), None)
2432            .unwrap_err();
2433        assert!(
2434            matches!(err, AcdpError::KeyResolution(ref m) if m.contains("supported_did_methods")),
2435            "got {err:?}"
2436        );
2437    }
2438
2439    /// With `did:key` advertised, the offline pipeline runs end-to-end:
2440    /// schema → hash → pure key resolution → signature → persistence.
2441    /// No resolver, no network — works in a `server`-only build.
2442    #[test]
2443    fn did_key_publish_verified_end_to_end() {
2444        let mut c = caps();
2445        c.supported_did_methods.push("did:key".into());
2446        let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com");
2447        let req = did_key_request();
2448        let resp = server.publish_verified_did_key(&req, None).unwrap();
2449        assert_eq!(resp.ctx_id.authority(), "registry.example.com");
2450
2451        // Tampered title → hash mismatch caught before signature.
2452        let mut tampered = did_key_request();
2453        tampered.title = "tampered".into();
2454        let err = server
2455            .publish_verified_did_key(&tampered, None)
2456            .unwrap_err();
2457        assert!(matches!(err, AcdpError::HashMismatch { .. }), "got {err:?}");
2458    }
2459
2460    /// Upgrade boundary: a registry that enables receipts must still
2461    /// honor idempotent replays of records minted BEFORE the signer
2462    /// existed. The §7 no-degraded-mode check applies to newly inserted
2463    /// contexts only — a replayed pre-receipts response (no
2464    /// `registry_receipt`) is returned verbatim, not failed as a 500.
2465    #[test]
2466    fn receiptless_idempotent_replay_survives_enabling_receipts() {
2467        let mut c = caps();
2468        c.acdp_version = "0.2.0".into();
2469        c.supported_did_methods.push("did:key".into());
2470        c.supports_idempotency_key = true;
2471        c.limits.idempotency_key_ttl_seconds = Some(86_400);
2472        let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com")
2473            .with_receipt_signer(
2474                acdp_types::receipt::ReceiptSigner::new(
2475                    SigningKey::from_bytes(&[0x11u8; 32]),
2476                    "did:web:registry.example.com",
2477                    "did:web:registry.example.com#receipt-key-1",
2478                )
2479                .unwrap(),
2480            )
2481            .unwrap();
2482
2483        // Simulate a record persisted before receipts were enabled: the
2484        // stored response carries no `registry_receipt`.
2485        let req = did_key_request();
2486        let pre_receipts_response = acdp_types::publish::PublishResponse {
2487            ctx_id: CtxId(format!(
2488                "acdp://registry.example.com/{}",
2489                uuid::Uuid::new_v4()
2490            )),
2491            lineage_id: acdp_crypto::derive_lineage_id(&CtxId(
2492                "acdp://registry.example.com/v1".into(),
2493            )),
2494            version: 1,
2495            created_at: acdp_primitives::time::trunc_ms(chrono::Utc::now()),
2496            status: Status::Active,
2497            registry_receipt: None,
2498        };
2499        server
2500            .store()
2501            .idempotency_record(
2502                &req.agent_id,
2503                "pre-receipts-key",
2504                &req.content_hash,
2505                &pre_receipts_response,
2506                chrono::Utc::now() + chrono::Duration::hours(1),
2507            )
2508            .unwrap();
2509
2510        // Same agent + key + content_hash → the replay must return the
2511        // original receipt-less response, not RegistryInternal.
2512        let resp = server
2513            .publish_verified_did_key(&req, Some("pre-receipts-key"))
2514            .expect("replay of a pre-receipts record must succeed");
2515        assert_eq!(resp.ctx_id, pre_receipts_response.ctx_id);
2516        assert!(
2517            resp.registry_receipt.is_none(),
2518            "replay returns the original response verbatim"
2519        );
2520
2521        // A FRESH publish on the same server still enforces minting.
2522        let p2 = Producer::new_did_key(SigningKey::from_bytes(&[8u8; 32]));
2523        let fresh = p2
2524            .publish_request()
2525            .title("fresh after enabling receipts")
2526            .context_type(ContextType::DataSnapshot)
2527            .visibility(Visibility::Public)
2528            .build()
2529            .unwrap();
2530        let fresh_resp = server.publish_verified_did_key(&fresh, None).unwrap();
2531        assert!(
2532            fresh_resp.registry_receipt.is_some(),
2533            "new inserts on a receipts registry must mint"
2534        );
2535    }
2536
2537    /// A pinned-key publish (the caller already verified the signature
2538    /// against an operator-pinned key, e.g. a demo registry's
2539    /// `playground.pinned_keys` allowlist) mints a receipt whose
2540    /// `key_fingerprint` matches the pinned key — proving
2541    /// `publish_pinned_verified_in_tenant` is safe on a
2542    /// receipts-advertising registry, unlike `publish_unverified_for_tests`.
2543    #[test]
2544    fn pinned_verified_publish_mints_receipt_with_correct_fingerprint() {
2545        use base64::{engine::general_purpose::STANDARD, Engine};
2546
2547        let key = SigningKey::from_bytes(&[3u8; 32]);
2548        let verifying_key_bytes = key.verifying_key_bytes();
2549        let pub_b64 = STANDARD.encode(verifying_key_bytes);
2550        let did = "did:web:agents.example.com:pinned-agent";
2551        let p = Producer::new(key, AgentDid::new(did), format!("{did}#key-1"));
2552        let req = p
2553            .publish_request()
2554            .title("pinned publish")
2555            .context_type(ContextType::DataSnapshot)
2556            .visibility(Visibility::Public)
2557            .build()
2558            .unwrap();
2559
2560        let mut c = caps();
2561        c.acdp_version = "0.2.0".into();
2562        let server = RegistryServer::new(InMemoryStore::new(), c, "registry.example.com")
2563            .with_receipt_signer(
2564                acdp_types::receipt::ReceiptSigner::new(
2565                    SigningKey::from_bytes(&[0x22u8; 32]),
2566                    "did:web:registry.example.com",
2567                    "did:web:registry.example.com#receipt-key-1",
2568                )
2569                .unwrap(),
2570            )
2571            .unwrap();
2572
2573        let resp = server
2574            .publish_pinned_verified_in_tenant(&req, None, None, &pub_b64, "ed25519")
2575            .expect("pinned-verified publish must succeed on a receipts registry");
2576        let receipt = resp
2577            .registry_receipt
2578            .expect("a receipts-advertising registry must mint a receipt");
2579        assert_eq!(
2580            receipt["key_fingerprint"].as_str().unwrap(),
2581            acdp_crypto::fingerprint::fingerprint_ed25519(&verifying_key_bytes)
2582        );
2583    }
2584
2585    /// Without a receipt signer configured, `publish_pinned_verified_in_tenant`
2586    /// still succeeds — it just mints no receipt (the fingerprint is only
2587    /// ever needed for the receipt binding).
2588    #[test]
2589    fn pinned_verified_publish_without_receipt_signer_succeeds_with_no_receipt() {
2590        use base64::{engine::general_purpose::STANDARD, Engine};
2591
2592        let key = SigningKey::from_bytes(&[4u8; 32]);
2593        let pub_b64 = STANDARD.encode(key.verifying_key_bytes());
2594        let did = "did:web:agents.example.com:pinned-agent-2";
2595        let p = Producer::new(key, AgentDid::new(did), format!("{did}#key-1"));
2596        let req = p
2597            .publish_request()
2598            .title("pinned publish, no receipts")
2599            .context_type(ContextType::DataSnapshot)
2600            .visibility(Visibility::Public)
2601            .build()
2602            .unwrap();
2603
2604        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2605        let resp = server
2606            .publish_pinned_verified_in_tenant(&req, None, None, &pub_b64, "ed25519")
2607            .unwrap();
2608        assert!(resp.registry_receipt.is_none());
2609    }
2610
2611    /// `publish_verified_did_key` refuses did:web producers — they need
2612    /// the resolver-backed `publish_verified`.
2613    #[test]
2614    fn did_key_publish_path_refuses_did_web() {
2615        let server = RegistryServer::new(InMemoryStore::new(), caps(), "registry.example.com");
2616        let p = producer();
2617        let req = p
2618            .publish_request()
2619            .title("did:web on the offline path")
2620            .context_type(ContextType::DataSnapshot)
2621            .visibility(Visibility::Public)
2622            .build()
2623            .unwrap();
2624        let err = server.publish_verified_did_key(&req, None).unwrap_err();
2625        assert!(
2626            matches!(err, AcdpError::KeyResolution(_)),
2627            "did:web on the offline path must be refused, got {err:?}"
2628        );
2629    }
2630}