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