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