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