Skip to main content

acdp_server/registry/
server.rs

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