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