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