Skip to main content

acdp_validation/
lib.rs

1//! Runtime validation against the ACDP schemas.
2//!
3//! The JSON schemas are the single source of truth for wire-shape constraints,
4//! but JSON Schema cannot express every invariant in the ACDP RFCs. This
5//! module implements the runtime checks the schema delegates to producers
6//! and registries:
7//!
8//! - String length / array uniqueness / array size limits
9//! - `data_period.start <= end`
10//! - `DataRef` oneOf (location XOR embedded), URI credential rejection,
11//!   structured-locator scheme pattern, embedded size cap, embedded
12//!   `content` typing per encoding
13//! - `metadata` runtime depth / JCS-size / property-count caps
14//! - `agent_id` DID pattern + `did:web` enforcement (v0.1.0)
15//! - Signature value length per algorithm
16//! - Embedded `content_hash` computation and verification
17//! - Identifier pattern validation (`ctx_id`, `lineage_id`, `content_hash`)
18//!
19//! Each function is independently usable; [`validate_publish_request`] and
20//! [`validate_body`] aggregate everything for end-to-end validation.
21
22use acdp_crypto::try_canonicalize_value;
23use acdp_primitives::error::AcdpError;
24use acdp_types::anchor::AnchorEntry;
25use acdp_types::body::Body;
26use acdp_types::data_ref::{DataRef, EmbeddedContent, EmbeddedEncoding, Location};
27use acdp_types::primitives::{
28    AgentDid, ContentHash, ContextType, CtxId, LineageId, Status, Visibility,
29};
30use acdp_types::publish::PublishRequest;
31use base64::{engine::general_purpose::STANDARD, Engine};
32use sha2::{Digest, Sha256};
33
34// ── Constants from the schemas ────────────────────────────────────────────────
35
36const MAX_TITLE_LEN: usize = 500;
37const MAX_DESCRIPTION_LEN: usize = 5000;
38const MAX_SUMMARY_LEN: usize = 1000;
39const MAX_DOMAIN_LEN: usize = 200;
40const MAX_DATA_REF_DESCRIPTION_LEN: usize = 1000;
41const MAX_TAG_LEN: usize = 100;
42const MAX_CONTRIBUTORS: usize = 100;
43const MAX_TAGS: usize = 200;
44const MAX_DERIVED_FROM: usize = 1000;
45const MAX_AUDIENCE: usize = 1000;
46const MAX_METADATA_PROPERTIES: usize = 100;
47const MAX_METADATA_DEPTH: usize = 8;
48const MAX_METADATA_JCS_BYTES: usize = 65_536;
49const MAX_URI_LEN: usize = 4096;
50const MAX_ANCHORS: usize = 100;
51const MAX_EMBEDDED_BYTES: usize = 65_536;
52const ED25519_SIG_B64_LEN: usize = 88;
53const ECDSA_P256_SIG_B64_LEN: usize = 88;
54
55// ── Capabilities ─────────────────────────────────────────────────────────────
56
57/// True when a `major.minor.patch` version string is >= the given
58/// major.minor (version-conditional capability checks; malformed
59/// versions are rejected by `validate_semver_pattern` first).
60fn version_at_least(v: &str, major: u64, minor: u64) -> bool {
61    let mut it = v.split('.').filter_map(|p| p.parse::<u64>().ok());
62    match (it.next(), it.next()) {
63        (Some(ma), Some(mi)) => ma > major || (ma == major && mi >= minor),
64        _ => false,
65    }
66}
67
68/// Validate a [`acdp_types::CapabilitiesDocument`] against the
69/// runtime constraints listed in RFC-ACDP-0007 §3.
70///
71/// The JSON schema enforces *types*; this validator enforces the
72/// constraints the schema cannot express:
73///
74/// 1. `acdp_version` matches `^\d+\.\d+\.\d+$`.
75/// 2. `registry_did` is a v0.1.0 `did:web` DID.
76/// 3. `supported_signature_algorithms` MUST contain `"ed25519"`.
77/// 4. `supported_did_methods` MUST contain `"did:web"`.
78/// 5. `profiles` MUST contain `"acdp-registry-core"`.
79/// 6. `limits.max_embedded_bytes` MUST equal exactly 65536.
80/// 7. If `supports_idempotency_key` is `true`,
81///    `limits.idempotency_key_ttl_seconds` MUST be present and in
82///    `86400..=604800`.
83/// 8. `limits.max_payload_bytes` MUST be at least 1024 bytes.
84///
85/// Wired into `acdp::client::RegistryClient::capabilities` and
86/// `acdp::client::CrossRegistryResolver::resolve`.
87pub fn validate_capabilities(caps: &acdp_types::CapabilitiesDocument) -> Result<(), AcdpError> {
88    validate_semver_pattern("acdp_version", &caps.acdp_version)?;
89
90    // §3.5 item 11 *(0.3.0)*: limits.max_publish_per_minute, when
91    // present, MUST be an integer >= 1 (schema minimum).
92    if let Some(mppm) = caps.limits.max_publish_per_minute {
93        if mppm < 1 {
94            return Err(AcdpError::SchemaViolation(
95                "capabilities.limits.max_publish_per_minute MUST be >= 1 \
96                 (RFC-ACDP-0007 \u{a7}3.5 item 11)"
97                    .into(),
98            ));
99        }
100    }
101
102    // §3.5 item 10 *(0.3.0)* / RFC-ACDP-0003 §6.4: a registry
103    // advertising acdp_version >= 0.3.0 MUST support Idempotency-Key;
104    // supports_idempotency_key absent-or-false alongside such a version
105    // makes the document self-contradictory (fixture idem-007).
106    if version_at_least(&caps.acdp_version, 0, 3) && !caps.supports_idempotency_key {
107        return Err(AcdpError::SchemaViolation(
108            "capabilities advertising acdp_version >= 0.3.0 MUST set \
109             supports_idempotency_key: true (RFC-ACDP-0003 \u{a7}6.4, \
110             RFC-ACDP-0007 \u{a7}3.5 item 10)"
111                .into(),
112        ));
113    }
114
115    AgentDid::parse_web(caps.registry_did.as_str()).map_err(|e| {
116        AcdpError::SchemaViolation(format!(
117            "capabilities.registry_did must be did:web for v0.1.0: {e}"
118        ))
119    })?;
120
121    if !caps
122        .supported_signature_algorithms
123        .iter()
124        .any(|a| a == "ed25519")
125    {
126        return Err(AcdpError::SchemaViolation(
127            "capabilities.supported_signature_algorithms MUST contain 'ed25519' \
128             (RFC-ACDP-0001 §5.10)"
129                .into(),
130        ));
131    }
132
133    if !caps.supported_did_methods.iter().any(|m| m == "did:web") {
134        return Err(AcdpError::SchemaViolation(
135            "capabilities.supported_did_methods MUST contain 'did:web' \
136             (RFC-ACDP-0001 §5.4)"
137                .into(),
138        ));
139    }
140
141    if !caps.profiles.iter().any(|p| p == "acdp-registry-core") {
142        return Err(AcdpError::SchemaViolation(
143            "capabilities.profiles MUST contain 'acdp-registry-core' \
144             (RFC-ACDP-0001 §9.1)"
145                .into(),
146        ));
147    }
148
149    if caps.limits.max_embedded_bytes != 65_536 {
150        return Err(AcdpError::SchemaViolation(format!(
151            "capabilities.limits.max_embedded_bytes must be 65536 (fixed by \
152             RFC-ACDP-0007 §3.1), got {}",
153            caps.limits.max_embedded_bytes
154        )));
155    }
156
157    if caps.limits.max_payload_bytes < 1024 {
158        return Err(AcdpError::SchemaViolation(format!(
159            "capabilities.limits.max_payload_bytes must be ≥ 1024, got {}",
160            caps.limits.max_payload_bytes
161        )));
162    }
163
164    if caps.supports_idempotency_key {
165        let ttl = caps.limits.idempotency_key_ttl_seconds.ok_or_else(|| {
166            AcdpError::SchemaViolation(
167                "limits.idempotency_key_ttl_seconds is required when \
168                 supports_idempotency_key is true (RFC-ACDP-0007 §3.2)"
169                    .into(),
170            )
171        })?;
172        if !(86_400..=604_800).contains(&ttl) {
173            return Err(AcdpError::SchemaViolation(format!(
174                "limits.idempotency_key_ttl_seconds must be in 86400..=604800, got {ttl}"
175            )));
176        }
177    }
178
179    Ok(())
180}
181
182// ── Top-level entry points ───────────────────────────────────────────────────
183
184/// Validate a complete [`PublishRequest`] against every schema constraint
185/// and runtime invariant.
186pub fn validate_publish_request(req: &PublishRequest) -> Result<(), AcdpError> {
187    validate_title(&req.title)?;
188    validate_optional_string(
189        req.description.as_deref(),
190        "description",
191        MAX_DESCRIPTION_LEN,
192    )?;
193    validate_optional_string(req.summary.as_deref(), "summary", MAX_SUMMARY_LEN)?;
194    validate_optional_string(req.domain.as_deref(), "domain", MAX_DOMAIN_LEN)?;
195
196    validate_agent_did(&req.agent_id, true)?;
197    for c in &req.contributors {
198        validate_loose_did(c)?;
199    }
200    validate_unique_array("contributors", &req.contributors, MAX_CONTRIBUTORS)?;
201    validate_unique_array("derived_from", &req.derived_from, MAX_DERIVED_FROM)?;
202
203    if let Some(tags) = &req.tags {
204        validate_tags(tags)?;
205    }
206    if let Some(audience) = &req.audience {
207        validate_unique_array("audience", audience, MAX_AUDIENCE)?;
208        for did in audience {
209            validate_loose_did(did)?;
210        }
211    }
212
213    validate_visibility_audience(&req.visibility, req.audience.as_deref())?;
214
215    if let Some(dp) = &req.data_period {
216        if dp.start > dp.end {
217            return Err(AcdpError::SchemaViolation(
218                "data_period.start must not be after data_period.end".into(),
219            ));
220        }
221    }
222
223    if let Some(ct) = &req.context_type.namespaced_form() {
224        validate_namespaced_context_type(ct)?;
225    }
226
227    if let Some(meta) = &req.metadata {
228        validate_metadata(meta)?;
229    }
230
231    for dr in &req.data_refs {
232        validate_data_ref(dr)?;
233    }
234
235    if let Some(anchors) = &req.anchors {
236        validate_anchors(anchors)?;
237    }
238
239    if let Some(ka) = &req.key_attestation {
240        ka.validate()?;
241    }
242    acdp_types::keyless::check_key_attestation_presence(
243        &req.signature.key_id,
244        req.key_attestation.is_some(),
245    )?;
246
247    validate_signature_length(&req.signature.algorithm, &req.signature.value)?;
248    validate_did_key_key_id_form(&req.signature.key_id)?;
249    ContentHash::parse(req.content_hash.as_str())?;
250
251    // Identifier patterns on every supplied ctx_id
252    if let Some(prev) = &req.supersedes {
253        CtxId::parse(prev.as_str())?;
254    }
255    for ancestor in &req.derived_from {
256        CtxId::parse(ancestor.as_str())?;
257    }
258    if let Some(lineage) = &req.lineage_id {
259        acdp_types::primitives::LineageId::parse(lineage.as_str())?;
260    }
261
262    // acdp_version pattern (semver `^\d+\.\d+\.\d+$`)
263    if let Some(v) = &req.acdp_version {
264        validate_semver_pattern("acdp_version", v)?;
265    }
266
267    // Version coherence (also enforced by the builder)
268    match (&req.supersedes, req.version) {
269        (None, 1) => {}
270        (None, v) => {
271            return Err(AcdpError::SchemaViolation(format!(
272                "first-version publish requires version=1, got {v}"
273            )));
274        }
275        (Some(_), v) if v >= 2 => {}
276        (Some(_), v) => {
277            return Err(AcdpError::SchemaViolation(format!(
278                "supersession publish requires version >= 2, got {v}"
279            )));
280        }
281    }
282
283    // RFC-ACDP-0003 §2.2 / `acdp-publish-request.schema.json` allOf:
284    // v1 publications MUST NOT include lineage_id (the value would
285    // necessarily be wrong because the formula depends on the
286    // registry-assigned ctx_id). The builder enforces this too, but
287    // applying it here lets the validator stand alone for callers that
288    // do not go through `RequestBuilder` (e.g. the conformance harness,
289    // server-side validators).
290    if req.version == 1 && req.lineage_id.is_some() {
291        return Err(AcdpError::SchemaViolation(
292            "lineage_id MUST NOT be set on v1 publish requests (RFC-ACDP-0003 §2.2)".into(),
293        ));
294    }
295
296    Ok(())
297}
298
299/// Validate a stored [`Body`] (retrieval-side check).
300pub fn validate_body(body: &Body) -> Result<(), AcdpError> {
301    validate_body_inner(
302        body, /* check_embedded_hashes = */ true, /* resolve_keys = */ true,
303    )
304}
305
306/// [`validate_body`] without resolving `did:key` identifiers.
307///
308/// A `did:key` `agent_id` or `signature.key_id` that does not decode is
309/// a verification outcome, not a schema failure: RFC-ACDP-0017 §7.6
310/// reports it as `key: unresolvable` with `signature: unresolvable`
311/// rather than rejecting the evidence bundle (§4.1). Syntax is still
312/// checked; only the resolution step is skipped.
313pub fn validate_body_schema(body: &Body) -> Result<(), AcdpError> {
314    validate_body_inner(
315        body, /* check_embedded_hashes = */ true, /* resolve_keys = */ false,
316    )
317}
318
319/// Same as [`validate_body`] but skips the embedded-`content_hash` recomputation.
320///
321/// Used by `acdp::client::VerifiedContext::fetch_report` so per-`DataRef`
322/// embedded-hash outcomes can be recorded individually rather than
323/// short-circuiting the whole verification. Callers that want the
324/// embedded-hash check MUST run [`verify_embedded_hash`] themselves —
325/// `fetch_report`'s recording loop is one such caller.
326///
327/// Production code that doesn't need partial-failure reporting should
328/// prefer [`validate_body`].
329pub fn validate_body_structural(body: &Body) -> Result<(), AcdpError> {
330    validate_body_inner(
331        body, /* check_embedded_hashes = */ false, /* resolve_keys = */ true,
332    )
333}
334
335fn validate_body_inner(
336    body: &Body,
337    check_embedded_hashes: bool,
338    resolve_keys: bool,
339) -> Result<(), AcdpError> {
340    validate_title(&body.title)?;
341    validate_optional_string(
342        body.description.as_deref(),
343        "description",
344        MAX_DESCRIPTION_LEN,
345    )?;
346    validate_optional_string(body.summary.as_deref(), "summary", MAX_SUMMARY_LEN)?;
347    validate_optional_string(body.domain.as_deref(), "domain", MAX_DOMAIN_LEN)?;
348
349    validate_agent_did(&body.agent_id, resolve_keys)?;
350    for c in &body.contributors {
351        validate_loose_did(c)?;
352    }
353    validate_unique_array("contributors", &body.contributors, MAX_CONTRIBUTORS)?;
354    validate_unique_array("derived_from", &body.derived_from, MAX_DERIVED_FROM)?;
355
356    if let Some(tags) = &body.tags {
357        validate_tags(tags)?;
358    }
359    if let Some(audience) = &body.audience {
360        validate_unique_array("audience", audience, MAX_AUDIENCE)?;
361        for did in audience {
362            validate_loose_did(did)?;
363        }
364    }
365    validate_visibility_audience(&body.visibility, body.audience.as_deref())?;
366
367    if let Some(dp) = &body.data_period {
368        if dp.start > dp.end {
369            return Err(AcdpError::SchemaViolation(
370                "data_period.start must not be after data_period.end".into(),
371            ));
372        }
373    }
374
375    if let Some(meta) = &body.metadata {
376        validate_metadata(meta)?;
377    }
378
379    // Forward-compat `extensions` (`#[serde(flatten)]`) are producer-
380    // controlled and flow into JCS + content_hash; cap them like metadata
381    // so they cannot bypass the §3.3 size/count/depth limits (P1-3).
382    validate_extensions(&body.extensions)?;
383
384    for dr in &body.data_refs {
385        if check_embedded_hashes {
386            validate_data_ref(dr)?;
387        } else {
388            validate_data_ref_structural(dr)?;
389        }
390    }
391
392    if let Some(anchors) = &body.anchors {
393        validate_anchors(anchors)?;
394    }
395
396    // RFC-ACDP-0021 §6.2: a stored body carries `key_attestation` among
397    // its extension fields.
398    acdp_types::keyless::check_key_attestation_presence(
399        &body.signature.key_id,
400        body.key_attestation()?.is_some(),
401    )?;
402
403    validate_signature_length(&body.signature.algorithm, &body.signature.value)?;
404    if resolve_keys {
405        validate_did_key_key_id_form(&body.signature.key_id)?;
406    }
407    validate_identifiers(&body.ctx_id, &body.lineage_id, &body.content_hash)?;
408
409    // Every entry in supersedes / derived_from MUST be a valid ctx_id.
410    if let Some(prev) = &body.supersedes {
411        CtxId::parse(prev.as_str())?;
412    }
413    for ancestor in &body.derived_from {
414        CtxId::parse(ancestor.as_str())?;
415    }
416
417    if let Some(v) = &body.acdp_version {
418        validate_semver_pattern("acdp_version", v)?;
419    }
420
421    let _ = &body.created_at; // schema-derived; serde already enforces RFC 3339
422    validate_origin_registry(&body.origin_registry)?;
423
424    // Avoid unused-import warnings on Status / Visibility
425    let _ = std::any::type_name::<Status>();
426    let _: &Visibility = &body.visibility;
427
428    Ok(())
429}
430
431/// Validate an identifier triple — convenient for retrieval-side use.
432pub fn validate_identifiers(
433    ctx_id: &CtxId,
434    lineage_id: &LineageId,
435    content_hash: &ContentHash,
436) -> Result<(), AcdpError> {
437    CtxId::parse(ctx_id.as_str())?;
438    LineageId::parse(lineage_id.as_str())?;
439    ContentHash::parse(content_hash.as_str())?;
440    Ok(())
441}
442
443// ── DataRef ──────────────────────────────────────────────────────────────────
444
445/// Validate a single [`DataRef`] against `acdp-data-ref.schema.json` and the
446/// runtime invariants the schema delegates.
447pub fn validate_data_ref(dr: &DataRef) -> Result<(), AcdpError> {
448    validate_data_ref_structural(dr)?;
449    // BUG-02: verify the declared content_hash against the decoded bytes
450    // (RFC-ACDP-0002 §6.6 #8). A producer-supplied wrong hash is a
451    // signed commitment to a misleading integrity claim, so we catch
452    // it at validate time, not just inside `PublishValidator`.
453    if dr.embedded.is_some() {
454        verify_embedded_hash(dr)?;
455    }
456    Ok(())
457}
458
459/// Same as [`validate_data_ref`] but skips the embedded-`content_hash`
460/// recomputation. Callers that want to report per-`DataRef` hash failures
461/// (e.g. `acdp::client::VerifiedContext::fetch_report`) run the
462/// structural checks via this helper, then call [`verify_embedded_hash`]
463/// themselves and record the outcome instead of short-circuiting.
464pub fn validate_data_ref_structural(dr: &DataRef) -> Result<(), AcdpError> {
465    // oneOf: exactly one of location / embedded
466    match (&dr.location, &dr.embedded) {
467        (None, None) => {
468            return Err(AcdpError::SchemaViolation(
469                "DataRef requires exactly one of 'location' or 'embedded' (got neither)".into(),
470            ));
471        }
472        (Some(_), Some(_)) => {
473            return Err(AcdpError::SchemaViolation(
474                "DataRef requires exactly one of 'location' or 'embedded' (got both)".into(),
475            ));
476        }
477        _ => {}
478    }
479
480    // Root-level content_hash (RFC-ACDP-0002 §6.1) is format-checked here
481    // like every other ContentHash-typed field in this validator (the
482    // body's own, anchors' — see the `ContentHash::parse` call sites
483    // above); nothing downstream (`verify_embedded_hash` never reads this
484    // field at all — see its own doc comment) would otherwise catch a
485    // malformed string, only ever a value that happens to already look
486    // like a `sha256:` digest.
487    if let Some(ch) = &dr.content_hash {
488        ContentHash::parse(ch.as_str())?;
489    }
490
491    if let Some(desc) = &dr.description {
492        if desc.len() > MAX_DATA_REF_DESCRIPTION_LEN {
493            return Err(AcdpError::SchemaViolation(format!(
494                "DataRef.description {} chars exceeds {} limit",
495                desc.len(),
496                MAX_DATA_REF_DESCRIPTION_LEN
497            )));
498        }
499    }
500
501    if let Some(loc) = &dr.location {
502        validate_location(loc)?;
503    }
504    if let Some(emb) = &dr.embedded {
505        validate_embedded(emb)?;
506    }
507
508    Ok(())
509}
510
511fn validate_location(loc: &Location) -> Result<(), AcdpError> {
512    match loc {
513        Location::Uri(uri) => validate_uri_location(uri),
514        Location::Structured(map) => validate_structured_locator(map),
515    }
516}
517
518fn validate_uri_location(uri: &str) -> Result<(), AcdpError> {
519    if uri.len() < 3 || uri.len() > MAX_URI_LEN {
520        return Err(AcdpError::SchemaViolation(format!(
521            "DataRef.location URI length {} not in 3..={}",
522            uri.len(),
523            MAX_URI_LEN
524        )));
525    }
526    // Scheme: ^[a-z][a-z0-9+.-]*:
527    let (scheme, rest) = uri
528        .split_once(':')
529        .ok_or_else(|| AcdpError::SchemaViolation(format!("URI missing scheme: {uri}")))?;
530    if scheme.is_empty()
531        || !scheme
532            .chars()
533            .next()
534            .is_some_and(|c| c.is_ascii_lowercase())
535        || !scheme
536            .chars()
537            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '+' | '.' | '-'))
538    {
539        return Err(AcdpError::SchemaViolation(format!(
540            "URI scheme '{scheme}' invalid; must match [a-z][a-z0-9+.-]*"
541        )));
542    }
543    // userinfo rejection: ^[a-z][a-z0-9+.-]*://[^/?#@]+@
544    if let Some(after_slashes) = rest.strip_prefix("//") {
545        if let Some(authority_end) = after_slashes.find(['/', '?', '#']) {
546            let authority = &after_slashes[..authority_end];
547            if authority.contains('@') {
548                return Err(AcdpError::SchemaViolation(format!(
549                    "URI MUST NOT contain credentials in userinfo: {uri}"
550                )));
551            }
552        } else if after_slashes.contains('@') {
553            return Err(AcdpError::SchemaViolation(format!(
554                "URI MUST NOT contain credentials in userinfo: {uri}"
555            )));
556        }
557    }
558    Ok(())
559}
560
561fn validate_structured_locator(
562    map: &serde_json::Map<String, serde_json::Value>,
563) -> Result<(), AcdpError> {
564    let scheme = map.get("scheme").and_then(|v| v.as_str()).ok_or_else(|| {
565        AcdpError::SchemaViolation("structured locator missing required 'scheme'".into())
566    })?;
567    if !is_dotted_namespace_scheme(scheme) {
568        return Err(AcdpError::SchemaViolation(format!(
569            "structured locator scheme '{scheme}' must match ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$"
570        )));
571    }
572    Ok(())
573}
574
575fn is_dotted_namespace_scheme(s: &str) -> bool {
576    let parts: Vec<&str> = s.split('.').collect();
577    if parts.len() < 2 {
578        return false;
579    }
580    parts.iter().all(|part| {
581        !part.is_empty()
582            && part.chars().next().is_some_and(|c| c.is_ascii_lowercase())
583            && part
584                .chars()
585                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
586    })
587}
588
589/// Validate `Body::anchors` / `PublishRequest::anchors`
590/// (RFC-ACDP-0016 §4). Called only when the field is `Some` — an
591/// absent field needs no validation, and the caller is responsible for
592/// the absent-vs-null distinction (`de_present` on the field itself).
593fn validate_anchors(anchors: &[AnchorEntry]) -> Result<(), AcdpError> {
594    if anchors.is_empty() {
595        return Err(AcdpError::SchemaViolation(
596            "anchors MUST be omitted entirely (never sent as an empty array) when there is \
597             nothing to anchor — the absent-when-empty convention (RFC-ACDP-0016 \u{a7}4)"
598                .into(),
599        ));
600    }
601    validate_unique_array("anchors", anchors, MAX_ANCHORS)?;
602    for anchor in anchors {
603        if !is_dotted_namespace_scheme(&anchor.scheme) {
604            return Err(AcdpError::SchemaViolation(format!(
605                "anchor scheme '{}' must match ^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+$ \
606                 (RFC-ACDP-0016 \u{a7}4)",
607                anchor.scheme
608            )));
609        }
610        ContentHash::parse(anchor.content_hash.as_str())?;
611    }
612    Ok(())
613}
614
615fn validate_embedded(emb: &EmbeddedContent) -> Result<(), AcdpError> {
616    // utf8 / base64: content MUST be a JSON string
617    match emb.encoding {
618        EmbeddedEncoding::Utf8 | EmbeddedEncoding::Base64 => {
619            if !emb.content.is_string() {
620                return Err(AcdpError::SchemaViolation(format!(
621                    "embedded {:?} content MUST be a JSON string",
622                    emb.encoding
623                )));
624            }
625        }
626        EmbeddedEncoding::Json => {}
627    }
628    // `embedded.content_hash` (RFC-ACDP-0002 §6.3) format-checked the same
629    // way — a malformed value here would otherwise only ever surface as a
630    // `DataRefHashMismatch` from `verify_embedded_hash`'s string compare,
631    // which is a confusing error for what is actually a schema violation.
632    if let Some(ch) = &emb.content_hash {
633        ContentHash::parse(ch.as_str())?;
634    }
635    // Decoded size cap
636    let decoded = embedded_decoded_bytes(emb)?;
637    if decoded.len() > MAX_EMBEDDED_BYTES {
638        return Err(AcdpError::EmbeddedTooLarge(format!(
639            "embedded decoded size {} bytes exceeds {} limit",
640            decoded.len(),
641            MAX_EMBEDDED_BYTES
642        )));
643    }
644    Ok(())
645}
646
647/// Decode an [`EmbeddedContent`] to its canonical byte form per
648/// `acdp-data-ref.schema.json` `content_hash` semantics:
649/// - `json`   → JCS-canonicalized bytes
650/// - `utf8`   → raw UTF-8 bytes of the string
651/// - `base64` → base64-decoded bytes of the string
652pub fn embedded_decoded_bytes(emb: &EmbeddedContent) -> Result<Vec<u8>, AcdpError> {
653    Ok(match emb.encoding {
654        EmbeddedEncoding::Json => try_canonicalize_value(&emb.content)?,
655        EmbeddedEncoding::Utf8 => {
656            let s = emb.content.as_str().ok_or_else(|| {
657                AcdpError::SchemaViolation("utf8 embedded content must be a JSON string".into())
658            })?;
659            s.as_bytes().to_vec()
660        }
661        EmbeddedEncoding::Base64 => {
662            let s = emb.content.as_str().ok_or_else(|| {
663                AcdpError::SchemaViolation("base64 embedded content must be a JSON string".into())
664            })?;
665            STANDARD
666                .decode(s)
667                .map_err(|e| AcdpError::SchemaViolation(format!("base64 decode failed: {e}")))?
668        }
669    })
670}
671
672/// Compute the SHA-256 [`ContentHash`] of decoded embedded content.
673pub fn compute_embedded_hash(emb: &EmbeddedContent) -> Result<ContentHash, AcdpError> {
674    let bytes = embedded_decoded_bytes(emb)?;
675    let digest = Sha256::digest(&bytes);
676    Ok(ContentHash(format!("sha256:{}", hex::encode(digest))))
677}
678
679/// Verify a [`DataRef`]'s declared `embedded.content_hash` against its
680/// embedded payload. Does nothing if the ref has no `embedded`, or if
681/// `embedded.content_hash` is absent.
682///
683/// RFC-ACDP-0002 §6.6 ("Check 8") scopes the publish-time integrity
684/// obligation to `embedded.content_hash` **only**: when present, it MUST
685/// match the SHA-256 of the decoded `embedded.content` bytes — that is
686/// this function's entire job. The DataRef-root `content_hash` (§6.1)
687/// carries no publish-time obligation for embedded refs; §6.6 explicitly
688/// says a registry "MAY additionally verify" it, but this function
689/// deliberately does not, so as to accept every context whose only
690/// obligation is the required one. Root-checking used to also be
691/// exercised here — reverted (see `CHANGELOG.md`) after it was found to
692/// reject the spec's own canonical `examples/mixed-data-refs/` example
693/// (root and embedded hashes legitimately differ there): a registry
694/// exercising the root-check MAY is spec-permitted, but doing so
695/// unconditionally, with no way to opt out, made this crate reject
696/// spec-conformant publishes that the minimum required check accepts.
697/// Consumers verify the root field after fetching `location`-form data
698/// (§6.5, `acdp-client`) — that check lives entirely there and is
699/// unaffected by this function, which only ever runs for `embedded` refs.
700///
701/// A mismatch is a *data-reference-level* integrity failure
702/// ([`AcdpError::DataRefHashMismatch`], wire code `data_ref_hash_mismatch`)
703/// — the embedded bytes diverged from a producer-declared hash, but the
704/// body's own `content_hash` / signature are unaffected. It is NOT the
705/// body-level [`AcdpError::HashMismatch`] (RFC-ACDP-0007 §5, data-ref-007).
706pub fn verify_embedded_hash(dr: &DataRef) -> Result<(), AcdpError> {
707    let Some(emb) = &dr.embedded else {
708        return Ok(());
709    };
710    let Some(embedded_hash) = &emb.content_hash else {
711        return Ok(());
712    };
713    let recomputed = compute_embedded_hash(emb)?;
714    if &recomputed != embedded_hash {
715        return Err(AcdpError::DataRefHashMismatch(format!(
716            "embedded.content_hash mismatch: declared {}, computed {}",
717            embedded_hash.as_str(),
718            recomputed.as_str()
719        )));
720    }
721    Ok(())
722}
723
724// ── Metadata ─────────────────────────────────────────────────────────────────
725
726/// Validate `metadata`'s runtime invariants per RFC-ACDP-0002 §3.3:
727/// max 100 top-level properties, max 8 nesting levels, max 64 KB JCS size.
728pub fn validate_metadata(value: &serde_json::Value) -> Result<(), AcdpError> {
729    validate_json_object_limits(value, "metadata")
730}
731
732/// Shared object-limit check for any producer-controlled free-form JSON
733/// object (`metadata` and the flattened `extensions`): max 100 top-level
734/// properties, max 8 nesting levels, max 64 KB JCS size. Without this,
735/// `extensions` (P1-3) would carry unbounded keys/values into JCS+SHA-256.
736fn validate_json_object_limits(value: &serde_json::Value, field: &str) -> Result<(), AcdpError> {
737    let obj = value
738        .as_object()
739        .ok_or_else(|| AcdpError::SchemaViolation(format!("{field} must be a JSON object")))?;
740    if obj.len() > MAX_METADATA_PROPERTIES {
741        return Err(AcdpError::SchemaViolation(format!(
742            "{field} has {} top-level properties, exceeds {} limit",
743            obj.len(),
744            MAX_METADATA_PROPERTIES
745        )));
746    }
747    let depth = json_depth(value);
748    if depth > MAX_METADATA_DEPTH {
749        return Err(AcdpError::SchemaViolation(format!(
750            "{field} nesting depth {depth} exceeds {MAX_METADATA_DEPTH}"
751        )));
752    }
753    let canonical_size = try_canonicalize_value(value)?.len();
754    if canonical_size > MAX_METADATA_JCS_BYTES {
755        return Err(AcdpError::SchemaViolation(format!(
756            "{field} JCS-canonical size {canonical_size} bytes exceeds {MAX_METADATA_JCS_BYTES}"
757        )));
758    }
759    Ok(())
760}
761
762/// Validate the flattened forward-compatibility `extensions` object with
763/// the same property-count / depth / JCS-size caps as `metadata`.
764pub fn validate_extensions(
765    extensions: &serde_json::Map<String, serde_json::Value>,
766) -> Result<(), AcdpError> {
767    if extensions.is_empty() {
768        return Ok(());
769    }
770    // Wrap in a `Value::Object` (clones the map) so the shared
771    // object-limit walker can scan it; `extensions` is small and capped,
772    // so the clone is negligible.
773    let value = serde_json::Value::Object(extensions.clone());
774    validate_json_object_limits(&value, "extensions")
775}
776
777/// Depth measured per RFC-ACDP-0002 §3.3: nested-object/array count,
778/// not counting leaf scalars. The cap of 8 is inclusive (`≤ 8`).
779/// `meta-003` pins this boundary.
780///
781/// Recursion is bounded by `MAX_JSON_DEPTH_SCAN` (well above the §3.3 cap
782/// of 8 and serde_json's 128-level parse limit) so a pathologically deep
783/// programmatically-built `Value` cannot blow the stack here. Any value
784/// that reaches the budget already exceeds `MAX_METADATA_DEPTH`, so the
785/// caller rejects it regardless of the exact (clamped) count.
786fn json_depth(v: &serde_json::Value) -> usize {
787    /// Above §3.3's cap of 8 and serde's 128 parse limit; bounds stack use.
788    const MAX_JSON_DEPTH_SCAN: usize = 256;
789    fn go(v: &serde_json::Value, budget: usize) -> usize {
790        if budget == 0 {
791            return 1; // stop descending; already far over MAX_METADATA_DEPTH
792        }
793        match v {
794            serde_json::Value::Object(map) => {
795                1 + map.values().map(|x| go(x, budget - 1)).max().unwrap_or(0)
796            }
797            serde_json::Value::Array(arr) => {
798                1 + arr.iter().map(|x| go(x, budget - 1)).max().unwrap_or(0)
799            }
800            _ => 0,
801        }
802    }
803    go(v, MAX_JSON_DEPTH_SCAN)
804}
805
806// ── Visibility ───────────────────────────────────────────────────────────────
807
808fn validate_visibility_audience(
809    vis: &Visibility,
810    audience: Option<&[AgentDid]>,
811) -> Result<(), AcdpError> {
812    match vis {
813        Visibility::Restricted => {
814            if audience.is_none_or(|a| a.is_empty()) {
815                return Err(AcdpError::SchemaViolation(
816                    "visibility:restricted requires a non-empty audience".into(),
817                ));
818            }
819        }
820        Visibility::Public => {
821            if audience.is_some_and(|a| !a.is_empty()) {
822                return Err(AcdpError::SchemaViolation(
823                    "visibility:public MUST NOT include audience".into(),
824                ));
825            }
826        }
827        Visibility::Private => {}
828    }
829    Ok(())
830}
831
832// ── Strings & arrays ─────────────────────────────────────────────────────────
833
834fn validate_title(title: &str) -> Result<(), AcdpError> {
835    if title.is_empty() || title.chars().count() > MAX_TITLE_LEN {
836        return Err(AcdpError::SchemaViolation(format!(
837            "title length {} not in 1..={}",
838            title.chars().count(),
839            MAX_TITLE_LEN
840        )));
841    }
842    Ok(())
843}
844
845fn validate_optional_string(s: Option<&str>, name: &str, max_len: usize) -> Result<(), AcdpError> {
846    if let Some(value) = s {
847        if value.chars().count() > max_len {
848            return Err(AcdpError::SchemaViolation(format!(
849                "{name} length {} exceeds {max_len}",
850                value.chars().count()
851            )));
852        }
853    }
854    Ok(())
855}
856
857fn validate_unique_array<T: PartialEq + std::fmt::Debug>(
858    name: &str,
859    items: &[T],
860    max: usize,
861) -> Result<(), AcdpError> {
862    if items.len() > max {
863        return Err(AcdpError::SchemaViolation(format!(
864            "{name} has {} items, exceeds {max}",
865            items.len()
866        )));
867    }
868    for (i, item) in items.iter().enumerate() {
869        if items[i + 1..].iter().any(|other| other == item) {
870            return Err(AcdpError::SchemaViolation(format!(
871                "{name} contains duplicate entry: {item:?}"
872            )));
873        }
874    }
875    Ok(())
876}
877
878fn validate_tags(tags: &[String]) -> Result<(), AcdpError> {
879    if tags.len() > MAX_TAGS {
880        return Err(AcdpError::SchemaViolation(format!(
881            "tags has {} entries, exceeds {}",
882            tags.len(),
883            MAX_TAGS
884        )));
885    }
886    for tag in tags {
887        validate_tag(tag)?;
888    }
889    // Uniqueness
890    for (i, tag) in tags.iter().enumerate() {
891        if tags[i + 1..].iter().any(|t| t == tag) {
892            return Err(AcdpError::SchemaViolation(format!(
893                "tags contains duplicate entry: {tag}"
894            )));
895        }
896    }
897    Ok(())
898}
899
900fn validate_tag(tag: &str) -> Result<(), AcdpError> {
901    if tag.is_empty() || tag.len() > MAX_TAG_LEN {
902        return Err(AcdpError::SchemaViolation(format!(
903            "tag '{tag}' length not in 1..={MAX_TAG_LEN}"
904        )));
905    }
906    let mut chars = tag.chars();
907    let first = chars.next().unwrap();
908    if !first.is_ascii_alphanumeric() {
909        return Err(AcdpError::SchemaViolation(format!(
910            "tag '{tag}' first char must be alphanumeric"
911        )));
912    }
913    if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')) {
914        return Err(AcdpError::SchemaViolation(format!(
915            "tag '{tag}' must match [A-Za-z0-9][A-Za-z0-9_.-]*"
916        )));
917    }
918    Ok(())
919}
920
921// ── DID / agent_id ───────────────────────────────────────────────────────────
922
923/// Validate a DID used as `agent_id`.
924///
925/// Producers MUST use a resolvable method: `did:web` (RFC-ACDP-0001
926/// §5.4, the v0.1.0 baseline) or `did:key` (ACDP 0.2 — pure offline
927/// resolution; the DID is the key). For did:key the embedded key
928/// material is decoded here so a garbage identifier fails at schema
929/// validation rather than at signature verification. Whether a given
930/// *registry* accepts did:key producers is a capabilities decision
931/// (`supported_did_methods`), enforced by
932/// `registry::PublishValidator` — this function checks protocol-level
933/// well-formedness only.
934/// Validate the `signature.key_id` form when it is a `did:key` URL
935/// (ACDP 0.2). The only verification method a did:key document has is
936/// the key itself, so the fragment MUST equal the method-specific
937/// identifier and the key material MUST decode. No-op for other
938/// methods — their key_id resolves against a DID document at
939/// verification time.
940fn validate_did_key_key_id_form(key_id: &str) -> Result<(), AcdpError> {
941    if !key_id.starts_with("did:key:") {
942        return Ok(());
943    }
944    // RFC-ACDP-0001 §5.11.1: any did:key resolver fault (steps 1-4) is
945    // REQUIRED to surface as `key_resolution_failed`. `schema_violation` is
946    // only a spec-tolerated MAY-level alternative for a registry's own
947    // stricter grammar on steps 1-2 (dk-002's own
948    // `expected.alternative_applies_to_cases: [1, 2]` proves the tolerance
949    // is narrower than "all did:key resolver faults") — never a
950    // requirement, so propagating the resolver's own `AcdpError::KeyResolution`
951    // unconditionally is conformant for all did:key fixtures and needs no
952    // step-level distinction the resolver doesn't expose anyway.
953    acdp_did::key::resolve_did_key_url(key_id)?;
954    Ok(())
955}
956
957fn validate_agent_did(did: &AgentDid, resolve: bool) -> Result<(), AcdpError> {
958    if did.as_str().starts_with("did:key:") {
959        AgentDid::parse(did.as_str())?;
960        if !resolve {
961            return Ok(());
962        }
963        // See validate_did_key_key_id_form above: propagate the resolver's
964        // own AcdpError::KeyResolution rather than downgrading to
965        // SchemaViolation.
966        acdp_did::key::resolve_did_key(did.as_str())?;
967        return Ok(());
968    }
969    AgentDid::parse_web(did.as_str())?;
970    Ok(())
971}
972
973/// Validate `body.origin_registry` per `acdp-context-body.schema.json`
974/// (RFC-ACDP-0002 §3.1, fixture body-001/body-002).
975///
976/// MUST be a bare DNS hostname — NOT a `did:web:` URI, NOT a URL.
977/// `capabilities.registry_did` carries the `did:web` encoding; the
978/// stored body carries the hostname encoding. Storing either form in
979/// the other field is a conformance violation.
980fn validate_origin_registry(s: &str) -> Result<(), AcdpError> {
981    if s.is_empty() {
982        return Err(AcdpError::SchemaViolation(
983            "origin_registry must be a non-empty DNS hostname".into(),
984        ));
985    }
986    if s.starts_with("did:") {
987        return Err(AcdpError::SchemaViolation(format!(
988            "origin_registry must be a DNS hostname, not a DID URI (got '{s}'); \
989             use the bare authority — capabilities.registry_did carries the did:web form"
990        )));
991    }
992    if s.contains("://") {
993        return Err(AcdpError::SchemaViolation(format!(
994            "origin_registry must be a DNS hostname, not a URL (got '{s}')"
995        )));
996    }
997    if s.ends_with('.') || s.starts_with('.') {
998        return Err(AcdpError::SchemaViolation(format!(
999            "origin_registry must be a syntactically valid DNS hostname (got '{s}')"
1000        )));
1001    }
1002    // BUG-02: delegate the full hostname grammar to the same validator
1003    // `CtxId::parse` uses for its authority. Enforces lowercase-only,
1004    // no underscore, no port, and valid label structure — values like
1005    // `REGISTRY.EXAMPLE.COM`, `registry_example.com`, or `registry-.com`
1006    // pass the coarse checks above but are not schema-valid hostnames.
1007    if !acdp_types::primitives::is_valid_dns_authority(s) {
1008        return Err(AcdpError::SchemaViolation(format!(
1009            "origin_registry '{s}' is not a valid DNS hostname (must be lowercase \
1010             labels of [a-z0-9-] separated by dots, e.g. 'registry.example.com')"
1011        )));
1012    }
1013    Ok(())
1014}
1015
1016/// Validate a DID used in `contributors[]` or `audience[]`.
1017///
1018/// Per the spec plan's RFC-FIX-11 method-scope table:
1019/// - contributors[] SHOULD be `did:web` (attribution; no key resolution),
1020/// - audience[] MAY be any DID method (authorization list; not resolved
1021///   in v0.1.0).
1022///
1023/// This helper enforces only the loose `did:` syntax (no method
1024/// constraint) so other-method contributors are accepted.
1025fn validate_loose_did(did: &AgentDid) -> Result<(), AcdpError> {
1026    AgentDid::parse(did.as_str())?;
1027    Ok(())
1028}
1029
1030// ── Context type ─────────────────────────────────────────────────────────────
1031
1032fn validate_namespaced_context_type(value: &str) -> Result<(), AcdpError> {
1033    // Schema pattern: ^[a-z][a-z0-9_]*:[a-z][a-z0-9_-]*$
1034    let (ns, name) = value.split_once(':').ok_or_else(|| {
1035        AcdpError::SchemaViolation(format!(
1036            "context_type '{value}' missing namespace separator"
1037        ))
1038    })?;
1039    if ns.is_empty()
1040        || !ns.chars().next().is_some_and(|c| c.is_ascii_lowercase())
1041        || !ns
1042            .chars()
1043            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
1044    {
1045        return Err(AcdpError::SchemaViolation(format!(
1046            "context_type namespace '{ns}' must match [a-z][a-z0-9_]*"
1047        )));
1048    }
1049    if name.is_empty()
1050        || !name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
1051        || !name
1052            .chars()
1053            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-'))
1054    {
1055        return Err(AcdpError::SchemaViolation(format!(
1056            "context_type name '{name}' must match [a-z][a-z0-9_-]*"
1057        )));
1058    }
1059    Ok(())
1060}
1061
1062trait ContextTypeExt {
1063    fn namespaced_form(&self) -> Option<&str>;
1064}
1065
1066impl ContextTypeExt for ContextType {
1067    fn namespaced_form(&self) -> Option<&str> {
1068        match self {
1069            ContextType::Custom(s) => Some(s.as_str()),
1070            _ => None,
1071        }
1072    }
1073}
1074
1075// ── Signatures ───────────────────────────────────────────────────────────────
1076
1077fn validate_semver_pattern(name: &str, value: &str) -> Result<(), AcdpError> {
1078    let parts: Vec<&str> = value.split('.').collect();
1079    let ok = parts.len() == 3
1080        && parts
1081            .iter()
1082            .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
1083    if !ok {
1084        return Err(AcdpError::SchemaViolation(format!(
1085            "{name} '{value}' must match the semver pattern ^\\d+\\.\\d+\\.\\d+$"
1086        )));
1087    }
1088    Ok(())
1089}
1090
1091fn validate_signature_length(algorithm: &str, value_b64: &str) -> Result<(), AcdpError> {
1092    let expected = match algorithm {
1093        "ed25519" => Some(ED25519_SIG_B64_LEN),
1094        "ecdsa-p256" => Some(ECDSA_P256_SIG_B64_LEN),
1095        _ => None,
1096    };
1097    if let Some(n) = expected {
1098        if value_b64.len() != n {
1099            return Err(AcdpError::InvalidSignature(format!(
1100                "signature.value for '{algorithm}' must be {n} base64 chars, got {}",
1101                value_b64.len()
1102            )));
1103        }
1104    }
1105    Ok(())
1106}
1107
1108// ── Tests ─────────────────────────────────────────────────────────────────────
1109
1110#[cfg(test)]
1111mod tests {
1112    use super::*;
1113    use acdp_types::data_ref::DataRefType;
1114    use serde_json::json;
1115
1116    fn embedded_json(v: serde_json::Value) -> EmbeddedContent {
1117        EmbeddedContent {
1118            encoding: EmbeddedEncoding::Json,
1119            content: v,
1120            content_hash: None,
1121        }
1122    }
1123
1124    fn embedded_json_with_hash(v: serde_json::Value, hash: ContentHash) -> EmbeddedContent {
1125        EmbeddedContent {
1126            encoding: EmbeddedEncoding::Json,
1127            content: v,
1128            content_hash: Some(hash),
1129        }
1130    }
1131
1132    // ── origin_registry (BUG-02) ─────────────────────────────────────────────
1133
1134    #[test]
1135    fn origin_registry_accepts_valid_hostname() {
1136        validate_origin_registry("registry.example.com").unwrap();
1137        validate_origin_registry("reg.example").unwrap();
1138        validate_origin_registry("a-b-c.io").unwrap();
1139    }
1140
1141    #[test]
1142    fn origin_registry_rejects_uppercase() {
1143        assert!(matches!(
1144            validate_origin_registry("REGISTRY.EXAMPLE.COM"),
1145            Err(AcdpError::SchemaViolation(_))
1146        ));
1147    }
1148
1149    #[test]
1150    fn origin_registry_rejects_underscore() {
1151        assert!(matches!(
1152            validate_origin_registry("registry_example.com"),
1153            Err(AcdpError::SchemaViolation(_))
1154        ));
1155    }
1156
1157    #[test]
1158    fn origin_registry_rejects_hyphen_label_edges() {
1159        assert!(matches!(
1160            validate_origin_registry("registry-.com"),
1161            Err(AcdpError::SchemaViolation(_))
1162        ));
1163        assert!(matches!(
1164            validate_origin_registry("-registry.example.com"),
1165            Err(AcdpError::SchemaViolation(_))
1166        ));
1167    }
1168
1169    // ── DataRef.oneOf ────────────────────────────────────────────────────────
1170
1171    #[test]
1172    fn data_ref_neither_location_nor_embedded_rejected() {
1173        let dr = DataRef {
1174            ref_type: DataRefType::PrimaryResult,
1175            description: None,
1176            size_bytes: None,
1177            format: None,
1178            schema_version: None,
1179            content_hash: None,
1180            location: None,
1181            embedded: None,
1182            extensions: serde_json::Map::new(),
1183        };
1184        assert!(matches!(
1185            validate_data_ref(&dr),
1186            Err(AcdpError::SchemaViolation(_))
1187        ));
1188    }
1189
1190    #[test]
1191    fn data_ref_both_location_and_embedded_rejected() {
1192        let dr = DataRef {
1193            ref_type: DataRefType::PrimaryResult,
1194            description: None,
1195            size_bytes: None,
1196            format: None,
1197            schema_version: None,
1198            content_hash: None,
1199            location: Some(Location::Uri("https://x/y".into())),
1200            embedded: Some(embedded_json(json!({"a": 1}))),
1201            extensions: serde_json::Map::new(),
1202        };
1203        assert!(matches!(
1204            validate_data_ref(&dr),
1205            Err(AcdpError::SchemaViolation(_))
1206        ));
1207    }
1208
1209    #[test]
1210    fn data_ref_malformed_root_content_hash_rejected() {
1211        let dr = DataRef {
1212            ref_type: DataRefType::PrimaryResult,
1213            description: None,
1214            size_bytes: None,
1215            format: None,
1216            schema_version: None,
1217            content_hash: Some(ContentHash("not-a-content-hash".into())),
1218            location: Some(Location::Uri("https://x/y".into())),
1219            embedded: None,
1220            extensions: serde_json::Map::new(),
1221        };
1222        let err = validate_data_ref_structural(&dr)
1223            .expect_err("a malformed root content_hash must be caught structurally");
1224        assert!(
1225            matches!(err, AcdpError::SchemaViolation(ref msg) if msg.contains("content_hash")),
1226            "expected a SchemaViolation naming content_hash, got {err:?}"
1227        );
1228    }
1229
1230    #[test]
1231    fn data_ref_malformed_embedded_content_hash_rejected() {
1232        let dr = DataRef {
1233            ref_type: DataRefType::PrimaryResult,
1234            description: None,
1235            size_bytes: None,
1236            format: None,
1237            schema_version: None,
1238            content_hash: None,
1239            location: None,
1240            embedded: Some(EmbeddedContent {
1241                encoding: EmbeddedEncoding::Utf8,
1242                content: serde_json::Value::String("hello".into()),
1243                content_hash: Some(ContentHash("sha256:not-hex".into())),
1244            }),
1245            extensions: serde_json::Map::new(),
1246        };
1247        let err = validate_data_ref_structural(&dr)
1248            .expect_err("a malformed embedded content_hash must be caught structurally");
1249        assert!(
1250            matches!(err, AcdpError::SchemaViolation(ref msg) if msg.contains("content_hash")),
1251            "expected a SchemaViolation naming content_hash, got {err:?}"
1252        );
1253    }
1254
1255    // ── DataRef.location URI ─────────────────────────────────────────────────
1256
1257    #[test]
1258    fn uri_credentials_rejected() {
1259        let dr = DataRef::uri(DataRefType::RawData, "https://user:pass@example.com/data");
1260        assert!(matches!(
1261            validate_data_ref(&dr),
1262            Err(AcdpError::SchemaViolation(_))
1263        ));
1264    }
1265
1266    #[test]
1267    fn uri_without_scheme_rejected() {
1268        let dr = DataRef::uri(DataRefType::RawData, "no-scheme");
1269        assert!(matches!(
1270            validate_data_ref(&dr),
1271            Err(AcdpError::SchemaViolation(_))
1272        ));
1273    }
1274
1275    #[test]
1276    fn uri_too_long_rejected() {
1277        let long_uri = format!("https://x.com/{}", "a".repeat(MAX_URI_LEN));
1278        let dr = DataRef::uri(DataRefType::RawData, long_uri);
1279        assert!(matches!(
1280            validate_data_ref(&dr),
1281            Err(AcdpError::SchemaViolation(_))
1282        ));
1283    }
1284
1285    // ── DataRef.location structured ──────────────────────────────────────────
1286
1287    #[test]
1288    fn structured_locator_missing_scheme_rejected() {
1289        let mut map = serde_json::Map::new();
1290        map.insert("offset".into(), json!(42));
1291        let dr = DataRef {
1292            ref_type: DataRefType::RawData,
1293            description: None,
1294            size_bytes: None,
1295            format: None,
1296            schema_version: None,
1297            content_hash: None,
1298            location: Some(Location::Structured(map)),
1299            embedded: None,
1300            extensions: serde_json::Map::new(),
1301        };
1302        assert!(matches!(
1303            validate_data_ref(&dr),
1304            Err(AcdpError::SchemaViolation(_))
1305        ));
1306    }
1307
1308    #[test]
1309    fn structured_locator_bad_scheme_rejected() {
1310        // try_structured rejects at construction time; structured() panics
1311        // in debug builds. The validate_data_ref guard catches anyone who
1312        // assembles a `DataRef` literal with a bad scheme directly.
1313        let err =
1314            DataRef::try_structured(DataRefType::RawData, "not_dotted", serde_json::Map::new())
1315                .unwrap_err();
1316        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1317
1318        // Direct literal construction (skipping the constructor): must
1319        // also be caught by validate_data_ref.
1320        let mut bad = serde_json::Map::new();
1321        bad.insert(
1322            "scheme".into(),
1323            serde_json::Value::String("not_dotted".into()),
1324        );
1325        let dr = DataRef {
1326            ref_type: DataRefType::RawData,
1327            description: None,
1328            size_bytes: None,
1329            format: None,
1330            schema_version: None,
1331            content_hash: None,
1332            location: Some(Location::Structured(bad)),
1333            embedded: None,
1334            extensions: serde_json::Map::new(),
1335        };
1336        assert!(matches!(
1337            validate_data_ref(&dr),
1338            Err(AcdpError::SchemaViolation(_))
1339        ));
1340    }
1341
1342    #[test]
1343    fn structured_locator_valid() {
1344        let mut extra = serde_json::Map::new();
1345        extra.insert("topic".into(), json!("events"));
1346        let dr = DataRef::structured(DataRefType::RawData, "kafka.offset", extra);
1347        validate_data_ref(&dr).unwrap();
1348    }
1349
1350    // ── DataRef.embedded ─────────────────────────────────────────────────────
1351
1352    #[test]
1353    fn embedded_utf8_must_be_string() {
1354        let dr = DataRef {
1355            ref_type: DataRefType::PrimaryResult,
1356            description: None,
1357            size_bytes: None,
1358            format: None,
1359            schema_version: None,
1360            content_hash: None,
1361            location: None,
1362            embedded: Some(EmbeddedContent {
1363                encoding: EmbeddedEncoding::Utf8,
1364                content: json!(42),
1365                content_hash: None,
1366            }),
1367            extensions: serde_json::Map::new(),
1368        };
1369        assert!(matches!(
1370            validate_data_ref(&dr),
1371            Err(AcdpError::SchemaViolation(_))
1372        ));
1373    }
1374
1375    #[test]
1376    fn embedded_too_large_rejected() {
1377        // 70 KB of UTF-8 content
1378        let big = "a".repeat(70 * 1024);
1379        let dr = DataRef::embedded_utf8(DataRefType::PrimaryResult, big);
1380        assert!(matches!(
1381            validate_data_ref(&dr),
1382            Err(AcdpError::EmbeddedTooLarge(_))
1383        ));
1384    }
1385
1386    // ── Embedded hash ────────────────────────────────────────────────────────
1387
1388    #[test]
1389    fn embedded_hash_json_round_trip() {
1390        let emb = embedded_json(json!({"b": 2, "a": 1}));
1391        let h = compute_embedded_hash(&emb).unwrap();
1392        // JCS sorts keys → {"a":1,"b":2}, hash is deterministic
1393        let expected = {
1394            let bytes = b"{\"a\":1,\"b\":2}";
1395            format!("sha256:{}", hex::encode(Sha256::digest(bytes)))
1396        };
1397        assert_eq!(h.as_str(), expected);
1398    }
1399
1400    #[test]
1401    fn embedded_hash_utf8() {
1402        let emb = EmbeddedContent {
1403            encoding: EmbeddedEncoding::Utf8,
1404            content: json!("hello"),
1405            content_hash: None,
1406        };
1407        let h = compute_embedded_hash(&emb).unwrap();
1408        let expected = format!("sha256:{}", hex::encode(Sha256::digest(b"hello")));
1409        assert_eq!(h.as_str(), expected);
1410    }
1411
1412    #[test]
1413    fn embedded_hash_base64() {
1414        let raw = b"binary data";
1415        let b64 = STANDARD.encode(raw);
1416        let emb = EmbeddedContent {
1417            encoding: EmbeddedEncoding::Base64,
1418            content: json!(b64),
1419            content_hash: None,
1420        };
1421        let h = compute_embedded_hash(&emb).unwrap();
1422        let expected = format!("sha256:{}", hex::encode(Sha256::digest(raw)));
1423        assert_eq!(h.as_str(), expected);
1424    }
1425
1426    /// A root `content_hash` with no `embedded.content_hash` at all has
1427    /// nothing for Check 8 to verify (RFC-ACDP-0002 §6.6 scopes the
1428    /// obligation to `embedded.content_hash`, which is absent here) —
1429    /// `verify_embedded_hash` no longer independently checks the root
1430    /// field for embedded refs (see the function's own doc comment), so
1431    /// this is accepted regardless of what the root field says.
1432    #[test]
1433    fn verify_embedded_hash_ignores_root_only_content_hash() {
1434        let emb = embedded_json(json!({"x": 1}));
1435        let dr = DataRef {
1436            ref_type: DataRefType::PrimaryResult,
1437            description: None,
1438            size_bytes: None,
1439            format: None,
1440            schema_version: None,
1441            content_hash: Some(ContentHash("sha256:0000".into())),
1442            location: None,
1443            embedded: Some(emb),
1444            extensions: serde_json::Map::new(),
1445        };
1446        verify_embedded_hash(&dr).unwrap();
1447    }
1448
1449    // ── Metadata ─────────────────────────────────────────────────────────────
1450
1451    #[test]
1452    fn metadata_too_many_properties_rejected() {
1453        let mut obj = serde_json::Map::new();
1454        for i in 0..101 {
1455            obj.insert(format!("k{i}"), json!(i));
1456        }
1457        assert!(matches!(
1458            validate_metadata(&serde_json::Value::Object(obj)),
1459            Err(AcdpError::SchemaViolation(_))
1460        ));
1461    }
1462
1463    #[test]
1464    fn metadata_too_deep_rejected() {
1465        // Build an object nested 10 levels deep
1466        let mut v = json!("leaf");
1467        for _ in 0..10 {
1468            let mut o = serde_json::Map::new();
1469            o.insert("a".into(), v);
1470            v = serde_json::Value::Object(o);
1471        }
1472        assert!(matches!(
1473            validate_metadata(&v),
1474            Err(AcdpError::SchemaViolation(_))
1475        ));
1476    }
1477
1478    #[test]
1479    fn metadata_too_large_rejected() {
1480        let big = "a".repeat(70 * 1024);
1481        let v = json!({"big": big});
1482        assert!(matches!(
1483            validate_metadata(&v),
1484            Err(AcdpError::SchemaViolation(_))
1485        ));
1486    }
1487
1488    #[test]
1489    fn metadata_must_be_object() {
1490        assert!(matches!(
1491            validate_metadata(&json!([1, 2, 3])),
1492            Err(AcdpError::SchemaViolation(_))
1493        ));
1494    }
1495
1496    // ── Visibility / audience ────────────────────────────────────────────────
1497
1498    #[test]
1499    fn public_with_audience_rejected() {
1500        let aud = vec![AgentDid::new("did:web:x")];
1501        assert!(matches!(
1502            validate_visibility_audience(&Visibility::Public, Some(&aud)),
1503            Err(AcdpError::SchemaViolation(_))
1504        ));
1505    }
1506
1507    #[test]
1508    fn public_with_empty_audience_ok() {
1509        validate_visibility_audience(&Visibility::Public, Some(&[])).unwrap();
1510        validate_visibility_audience(&Visibility::Public, None).unwrap();
1511    }
1512
1513    #[test]
1514    fn restricted_without_audience_rejected() {
1515        assert!(matches!(
1516            validate_visibility_audience(&Visibility::Restricted, None),
1517            Err(AcdpError::SchemaViolation(_))
1518        ));
1519    }
1520
1521    // ── data_period ──────────────────────────────────────────────────────────
1522
1523    #[test]
1524    fn data_period_start_after_end_rejected_via_builder() {
1525        use acdp_crypto::SigningKey;
1526        use acdp_producer::Producer;
1527        use acdp_types::body::DataPeriod;
1528        use chrono::TimeZone;
1529
1530        let p = Producer::new(
1531            SigningKey::from_bytes(&[0u8; 32]),
1532            AgentDid::new("did:web:agents.example.com:test"),
1533            "did:web:agents.example.com:test#key-1",
1534        );
1535        let err = p
1536            .publish_request()
1537            .title("t")
1538            .context_type(ContextType::DataSnapshot)
1539            .data_period(DataPeriod {
1540                start: chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap(),
1541                end: chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1542            })
1543            .build()
1544            .unwrap_err();
1545        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1546    }
1547
1548    // ── Tags ─────────────────────────────────────────────────────────────────
1549
1550    #[test]
1551    fn tag_pattern_validation() {
1552        validate_tag("hello").unwrap();
1553        validate_tag("Q1-2026").unwrap();
1554        validate_tag("a_b.c").unwrap();
1555        // Cannot start with non-alphanumeric
1556        assert!(validate_tag("-bad").is_err());
1557        // Disallowed chars
1558        assert!(validate_tag("space here").is_err());
1559        // Empty
1560        assert!(validate_tag("").is_err());
1561    }
1562
1563    #[test]
1564    fn duplicate_tags_rejected() {
1565        let tags = vec!["a".to_string(), "b".to_string(), "a".to_string()];
1566        assert!(validate_tags(&tags).is_err());
1567    }
1568
1569    // ── Signature length ─────────────────────────────────────────────────────
1570
1571    #[test]
1572    fn ed25519_sig_must_be_88_chars() {
1573        assert!(validate_signature_length("ed25519", "AAAA").is_err());
1574        validate_signature_length("ed25519", &"A".repeat(88)).unwrap();
1575        // Unknown algorithm: skipped
1576        validate_signature_length("future-alg", "any").unwrap();
1577    }
1578
1579    // ── context_type custom ──────────────────────────────────────────────────
1580
1581    #[test]
1582    fn namespaced_context_type_pattern() {
1583        validate_namespaced_context_type("finance:portfolio_snapshot").unwrap();
1584        assert!(validate_namespaced_context_type("Finance:portfolio").is_err());
1585        assert!(validate_namespaced_context_type("finance:Portfolio").is_err());
1586        assert!(validate_namespaced_context_type("no-colon").is_err());
1587    }
1588
1589    // ── R2 audit test-coverage matrix ────────────────────────────────────────
1590
1591    /// T8 — `acdp_version` semver pattern is enforced.
1592    #[test]
1593    fn acdp_version_pattern_rejects_non_semver() {
1594        validate_semver_pattern("acdp_version", "0.1.0").unwrap();
1595        validate_semver_pattern("acdp_version", "10.20.30").unwrap();
1596        assert!(validate_semver_pattern("acdp_version", "0.1.0-rc.1").is_err());
1597        assert!(validate_semver_pattern("acdp_version", "0.0").is_err());
1598        assert!(validate_semver_pattern("acdp_version", "vee.zero.zero").is_err());
1599    }
1600
1601    /// T7 — `derived_from` containing a malformed ctx_id is rejected by
1602    /// `validate_publish_request`.
1603    #[test]
1604    fn derived_from_malformed_ctx_id_rejected() {
1605        use acdp_crypto::SigningKey;
1606        use acdp_producer::Producer;
1607
1608        let p = Producer::new(
1609            SigningKey::from_bytes(&[0u8; 32]),
1610            AgentDid::new("did:web:agents.example.com:test"),
1611            "did:web:agents.example.com:test#key-1",
1612        );
1613        let err = p
1614            .publish_request()
1615            .title("t")
1616            .context_type(ContextType::DataSnapshot)
1617            .derived_from(vec![CtxId("not-a-ctx-id".into())])
1618            .build()
1619            .unwrap_err();
1620        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1621    }
1622
1623    /// T2 (corrected) — despite its original name, this DataRef has no
1624    /// `embedded.content_hash` at all (only a mismatching root one), so
1625    /// there is nothing for Check 8 to catch — see
1626    /// `verify_embedded_hash_ignores_root_only_content_hash` above, which
1627    /// covers the identical shape; kept here under its original T2 label
1628    /// so that catalog reference doesn't silently disappear.
1629    #[test]
1630    fn embedded_content_hash_absent_root_mismatch_not_an_error() {
1631        use acdp_types::data_ref::DataRefType;
1632        let dr = DataRef {
1633            ref_type: DataRefType::PrimaryResult,
1634            description: None,
1635            size_bytes: None,
1636            format: None,
1637            schema_version: None,
1638            content_hash: Some(ContentHash("sha256:0000".into())),
1639            location: None,
1640            embedded: Some(EmbeddedContent {
1641                encoding: EmbeddedEncoding::Json,
1642                content: json!({"x": 1}),
1643                content_hash: None,
1644            }),
1645            extensions: serde_json::Map::new(),
1646        };
1647        verify_embedded_hash(&dr).unwrap();
1648    }
1649
1650    /// RFC-ACDP-0002 §6.6: a DataRef carrying both root `content_hash` and
1651    /// `embedded.content_hash` over the same, *consistent* decoded bytes
1652    /// MUST be accepted.
1653    #[test]
1654    fn both_content_hashes_consistent_accepted() {
1655        use acdp_types::data_ref::DataRefType;
1656        let emb = embedded_json(json!({"a": 1, "b": 2}));
1657        let hash = compute_embedded_hash(&emb).unwrap();
1658        let dr = DataRef {
1659            ref_type: DataRefType::PrimaryResult,
1660            description: None,
1661            size_bytes: None,
1662            format: None,
1663            schema_version: None,
1664            content_hash: Some(hash.clone()),
1665            location: None,
1666            embedded: Some(embedded_json_with_hash(json!({"a": 1, "b": 2}), hash)),
1667            extensions: serde_json::Map::new(),
1668        };
1669        verify_embedded_hash(&dr).unwrap();
1670    }
1671
1672    /// RFC-ACDP-0002 §6.6: root `content_hash` and `embedded.content_hash`
1673    /// disagreeing is accepted as long as `embedded.content_hash` itself
1674    /// is correct — checking the root field for embedded refs is a
1675    /// registry MAY, not exercised by `verify_embedded_hash`, and §6.6 is
1676    /// explicit that a registry "MUST NOT reject a publish merely because
1677    /// a root `content_hash` is present alongside an embedded one." This
1678    /// is exactly the shape of the spec's own canonical
1679    /// `examples/mixed-data-refs/alert-mixed-data-refs.json` example
1680    /// (`data_refs[0]`, per `conformance.rs`'s
1681    /// `mixed_data_refs_example_deserializes` test) — a regression here
1682    /// previously rejected that spec-conformant example.
1683    #[test]
1684    fn root_content_hash_disagreement_accepted_when_embedded_correct() {
1685        use acdp_types::data_ref::DataRefType;
1686        let emb = embedded_json(json!({"a": 1, "b": 2}));
1687        let correct_hash = compute_embedded_hash(&emb).unwrap();
1688        let wrong_hash = ContentHash(
1689            "sha256:0000000000000000000000000000000000000000000000000000000000000000".into(),
1690        );
1691        let dr = DataRef {
1692            ref_type: DataRefType::PrimaryResult,
1693            description: None,
1694            size_bytes: None,
1695            format: None,
1696            schema_version: None,
1697            content_hash: Some(wrong_hash),
1698            location: None,
1699            embedded: Some(embedded_json_with_hash(
1700                json!({"a": 1, "b": 2}),
1701                correct_hash,
1702            )),
1703            extensions: serde_json::Map::new(),
1704        };
1705        verify_embedded_hash(&dr).unwrap();
1706    }
1707
1708    /// T14 — duplicate audience entries rejected (uniqueItems: true).
1709    #[test]
1710    fn audience_uniqueness_rejected() {
1711        let dup = vec![
1712            AgentDid::new("did:web:a.example.com"),
1713            AgentDid::new("did:web:a.example.com"),
1714        ];
1715        let err = validate_unique_array("audience", &dup, MAX_AUDIENCE).unwrap_err();
1716        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1717    }
1718
1719    // ── P1-3: extensions caps + bounded walkers ──────────────────────────────
1720
1721    #[test]
1722    fn extensions_empty_ok() {
1723        validate_extensions(&serde_json::Map::new()).unwrap();
1724    }
1725
1726    #[test]
1727    fn extensions_small_forward_compat_accepted() {
1728        // The can-008/can-009 shape: a couple of unknown producer fields.
1729        let mut ext = serde_json::Map::new();
1730        ext.insert("priority".into(), json!("high"));
1731        ext.insert("custom".into(), json!({"k": [1, 2, 3]}));
1732        validate_extensions(&ext).unwrap();
1733    }
1734
1735    #[test]
1736    fn extensions_too_many_properties_rejected() {
1737        let mut ext = serde_json::Map::new();
1738        for i in 0..(MAX_METADATA_PROPERTIES + 1) {
1739            ext.insert(format!("k{i}"), json!(i));
1740        }
1741        let err = validate_extensions(&ext).unwrap_err();
1742        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1743    }
1744
1745    #[test]
1746    fn extensions_oversized_jcs_rejected() {
1747        let mut ext = serde_json::Map::new();
1748        ext.insert("blob".into(), json!("x".repeat(MAX_METADATA_JCS_BYTES + 1)));
1749        let err = validate_extensions(&ext).unwrap_err();
1750        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1751    }
1752
1753    #[test]
1754    fn extensions_too_deep_rejected() {
1755        // Build a value nested past MAX_METADATA_DEPTH.
1756        let mut v = json!(0);
1757        for _ in 0..(MAX_METADATA_DEPTH + 2) {
1758            v = json!({ "n": v });
1759        }
1760        let mut ext = serde_json::Map::new();
1761        ext.insert("deep".into(), v);
1762        let err = validate_extensions(&ext).unwrap_err();
1763        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1764    }
1765
1766    #[test]
1767    fn json_depth_clamps_past_scan_budget() {
1768        // Deeper than the 256-frame scan budget but shallow enough that
1769        // building/dropping the Value is itself safe. `json_depth` must
1770        // bound its own recursion and still report a value over the §3.3
1771        // cap, and the canonicalizer must refuse it rather than overflow.
1772        let mut v = json!(0);
1773        for _ in 0..400 {
1774            v = json!([v]);
1775        }
1776        assert!(json_depth(&v) > MAX_METADATA_DEPTH);
1777        assert!(acdp_crypto::try_canonicalize_value(&v).is_err());
1778    }
1779}
1780
1781#[cfg(test)]
1782mod capabilities_0_3_0_tests {
1783    use super::*;
1784    use acdp_types::capabilities::{CapabilitiesDocument, Limits};
1785
1786    fn caps(version: &str, supports_idem: bool, mppm: Option<u64>) -> CapabilitiesDocument {
1787        CapabilitiesDocument {
1788            acdp_version: version.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: if supports_idem { Some(86_400) } else { None },
1797                max_publish_per_minute: mppm,
1798                change_retention_seconds: None,
1799                max_change_filters: None,
1800                max_changes_page: None,
1801                max_subscriptions_per_requester: None,
1802            },
1803            read_authentication_methods: vec![],
1804            anonymous_public_reads: false,
1805            supports_idempotency_key: supports_idem,
1806            extensions: Default::default(),
1807        }
1808    }
1809
1810    /// RFC-ACDP-0007 §3.5 item 11 *(0.3.0)* — caps-007's reject variants:
1811    /// zero is rejected at validation; the ≥1 accept case passes.
1812    #[test]
1813    fn max_publish_per_minute_bounds() {
1814        assert!(validate_capabilities(&caps("0.1.0", false, Some(600))).is_ok());
1815        let err = validate_capabilities(&caps("0.1.0", false, Some(0)))
1816            .expect_err("zero MUST be rejected");
1817        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1818    }
1819
1820    /// RFC-ACDP-0003 §6.4 / RFC-ACDP-0007 §3.5 item 10 *(0.3.0)* — the
1821    /// idem-007 rule: acdp_version ≥ 0.3.0 without idempotency support
1822    /// is self-contradictory and MUST be rejected; 0.1.0/0.2.0 without
1823    /// support stay valid; 0.3.0 with support is valid.
1824    #[test]
1825    fn idempotency_required_at_0_3_0() {
1826        assert!(validate_capabilities(&caps("0.1.0", false, None)).is_ok());
1827        assert!(validate_capabilities(&caps("0.2.0", false, None)).is_ok());
1828        assert!(validate_capabilities(&caps("0.3.0", true, None)).is_ok());
1829        assert!(validate_capabilities(&caps("0.4.0", true, None)).is_ok());
1830        for v in ["0.3.0", "0.4.0", "1.0.0"] {
1831            let err = validate_capabilities(&caps(v, false, None))
1832                .expect_err("version >= 0.3.0 without idempotency MUST be rejected");
1833            assert!(matches!(err, AcdpError::SchemaViolation(_)), "{v}: {err:?}");
1834        }
1835    }
1836}