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