Skip to main content

acdp_server/registry/
server.rs

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