Skip to main content

acdp_server/registry/
server.rs

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