Skip to main content

waitprims_core/
validate.rs

1//! Structural, normative, and set validation for `contract: agent-wait/v0`.
2//!
3//! [`validate_message`] and [`validate_raw_documents`] are the **only
4//! contract-admission path**. Public [`serde::Deserialize`] on
5//! [`AgentWaitMessage`] can bypass schema and normative checks; callers
6//! must not treat `serde_json::from_str` as admission.
7//!
8//! Schema validation uses the pin's relative entry schema. Cross-message
9//! rules are enforced in Rust.
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::ops::Deref;
13use std::sync::OnceLock;
14
15use jsonschema::Validator;
16use serde_json::Value;
17
18use crate::contract::{self, CAPABILITY};
19use crate::digest::verify_registration_digest;
20use crate::error::{Error, NormativeReason, Result, ValidationError};
21use crate::rfc3339;
22use crate::types::AgentWaitMessage;
23
24const ADMITTED_MESSAGE_TYPES: &[&str] = &[
25    "registration_set",
26    "live_wait_request",
27    "live_wait_outcome",
28    "poll_cycle_request",
29    "poll_cycle_outcome",
30    "poll_cycle_ack",
31];
32
33fn compiled_entry_schema() -> Result<&'static Validator> {
34    static CELL: OnceLock<std::result::Result<Validator, String>> = OnceLock::new();
35    match CELL.get_or_init(|| {
36        let resolved = contract::resolve_bundled(CAPABILITY).map_err(|e| e.to_string())?;
37        jsonschema::validator_for(&resolved.entry_schema).map_err(|e| e.to_string())
38    }) {
39        Ok(validator) => Ok(validator),
40        Err(_) => Err(Error::Contract {
41            path: "entry_schema",
42            constraint: "compile",
43        }),
44    }
45}
46
47/// An `agent-wait/v0` message that passed contract admission.
48///
49/// Construct only through [`validate_message`] or [`validate_raw_documents`].
50/// [`serde_json::from_str`] on [`AgentWaitMessage`] is not admission.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct AdmittedMessage(AgentWaitMessage);
53
54impl AdmittedMessage {
55    /// The admitted message.
56    pub fn into_inner(self) -> AgentWaitMessage {
57        self.0
58    }
59}
60
61impl Deref for AdmittedMessage {
62    type Target = AgentWaitMessage;
63
64    fn deref(&self) -> &Self::Target {
65        &self.0
66    }
67}
68
69impl AsRef<AgentWaitMessage> for AdmittedMessage {
70    fn as_ref(&self) -> &AgentWaitMessage {
71        &self.0
72    }
73}
74
75/// Validate one JSON document as an `agent-wait/v0` message.
76///
77/// This function and [`validate_raw_documents`] are the **only
78/// contract-admission path**. Public [`serde::Deserialize`] on
79/// [`AgentWaitMessage`] can bypass schema and normative checks. Callers
80/// must not treat `serde_json::from_str` as admission.
81///
82/// File validation handles one message. Use [`validate_raw_documents`]
83/// when checking a directory or other multi-document set (revision freeze,
84/// fairness, ack/retention).
85///
86/// Entry is raw JSON so JCS uniqueness is checked before any typed value
87/// path can collapse duplicate keys.
88pub fn validate_message(raw: &str) -> Result<AdmittedMessage> {
89    let messages = validate_raw_documents(std::iter::once(raw))?;
90    messages
91        .into_iter()
92        .next()
93        .ok_or_else(|| Error::from(ValidationError::new("/", "empty_document")))
94}
95
96/// Validate one or more raw JSON documents. Set rules run across the slice.
97///
98/// This function and [`validate_message`] are the **only contract-admission
99/// path**. Public [`serde::Deserialize`] on [`AgentWaitMessage`] can bypass
100/// schema and normative checks. Callers must not treat `serde_json::from_str`
101/// as admission.
102///
103/// A single document is checked alone. Two or more documents are also
104/// checked as a set: revision freeze, fairness across outcomes, and
105/// ack/retention. Independent goldens are not one registration set.
106pub fn validate_raw_documents<I, S>(raws: I) -> Result<Vec<AdmittedMessage>>
107where
108    I: IntoIterator<Item = S>,
109    S: AsRef<str>,
110{
111    let mut documents = Vec::new();
112    for raw in raws {
113        documents.push(crate::jcs::parse_strict(raw.as_ref())?);
114    }
115    validate_documents(&documents)
116}
117
118/// Validate one or more already-unique documents. Callers must have parsed
119/// with [`crate::jcs::parse_strict`].
120fn validate_documents(documents: &[Value]) -> Result<Vec<AdmittedMessage>> {
121    if documents.is_empty() {
122        return Err(ValidationError::new("/", "empty_target").into());
123    }
124    let mut typed = Vec::with_capacity(documents.len());
125    for document in documents {
126        reject_undeclared_kind(document)?;
127        schema_validate(document)?;
128        per_message_normative(document)?;
129        let message: AgentWaitMessage = serde_json::from_value(document.clone())
130            .map_err(|_| ValidationError::new("/", "typed_decode"))?;
131        typed.push(AdmittedMessage(message));
132    }
133    set_rules(documents)?;
134    Ok(typed)
135}
136
137fn reject_undeclared_kind(document: &Value) -> Result<()> {
138    let Some(message_type) = document.get("message_type").and_then(Value::as_str) else {
139        return Err(ValidationError::new("/message_type", "required").into());
140    };
141    if ADMITTED_MESSAGE_TYPES.contains(&message_type) {
142        return Ok(());
143    }
144    Err(ValidationError::new("/message_type", "undeclared_message_type").into())
145}
146
147fn schema_validate(document: &Value) -> Result<()> {
148    let validator = compiled_entry_schema()?;
149    if validator.is_valid(document) {
150        return Ok(());
151    }
152    let error = validator.iter_errors(document).next();
153    match error {
154        Some(err) => {
155            let path = {
156                let rendered = err.instance_path.to_string();
157                if rendered.is_empty() {
158                    "/".to_string()
159                } else if rendered.starts_with('/') {
160                    rendered
161                } else {
162                    format!("/{rendered}")
163                }
164            };
165            Err(ValidationError::new(path, schema_constraint(&err)).into())
166        }
167        None => Err(ValidationError::new("/", "schema").into()),
168    }
169}
170
171fn schema_constraint(err: &jsonschema::ValidationError<'_>) -> &'static str {
172    // Keyword-only: do not include the instance value.
173    let schema_path = err.schema_path.to_string();
174    if schema_path.contains("oneOf") {
175        return "oneOf";
176    }
177    if schema_path.contains("additionalProperties") {
178        return "additionalProperties";
179    }
180    if schema_path.contains("required") {
181        return "required";
182    }
183    if schema_path.contains("const") {
184        return "const";
185    }
186    if schema_path.contains("enum") {
187        return "enum";
188    }
189    if schema_path.contains("minItems") {
190        return "minItems";
191    }
192    if schema_path.contains("maxItems") {
193        return "maxItems";
194    }
195    if schema_path.contains("uniqueItems") {
196        return "uniqueItems";
197    }
198    if schema_path.contains("contains") {
199        return "contains";
200    }
201    if schema_path.contains("pattern") {
202        return "pattern";
203    }
204    if schema_path.contains("minLength") {
205        return "minLength";
206    }
207    if schema_path.contains("type") {
208        return "type";
209    }
210    "schema"
211}
212
213fn per_message_normative(document: &Value) -> Result<()> {
214    let message_type = document
215        .get("message_type")
216        .and_then(Value::as_str)
217        .unwrap_or("");
218    match message_type {
219        "live_wait_request" | "poll_cycle_request" => {
220            let run = string_field(document, "/run_deadline", "run_deadline")?;
221            let logical = string_field(document, "/logical_deadline", "logical_deadline")?;
222            if rfc3339::compare(run, logical)? > 0 {
223                return Err(ValidationError::normative(
224                    "/run_deadline",
225                    "must_be_at_or_before_logical_deadline",
226                    NormativeReason::DeadlineOrdering,
227                )
228                .into());
229            }
230        }
231        "registration_set" => {
232            let claimed = document
233                .pointer("/registration_digest/value")
234                .and_then(Value::as_str)
235                .ok_or_else(|| ValidationError::new("/registration_digest/value", "required"))?;
236            let registrations = document
237                .get("registrations")
238                .ok_or_else(|| ValidationError::new("/registrations", "required"))?;
239            verify_registration_digest(registrations, claimed)?;
240        }
241        _ => {}
242    }
243
244    if message_type == "poll_cycle_outcome" || message_type == "live_wait_outcome" {
245        outcome_normative(document)?;
246    }
247    Ok(())
248}
249
250fn outcome_normative(document: &Value) -> Result<()> {
251    let kind = document
252        .get("outcome_kind")
253        .and_then(Value::as_str)
254        .unwrap_or("");
255    let events_n = document
256        .get("events")
257        .and_then(Value::as_array)
258        .map(Vec::len)
259        .unwrap_or(0);
260    let complete = document.get("coverage_complete").and_then(Value::as_bool);
261    let completed = string_field(document, "/completed_at", "completed_at")?;
262    let logical = document.get("logical_deadline").and_then(Value::as_str);
263    let arms = document.get("arms").and_then(Value::as_array);
264
265    let mut dirty = 0usize;
266    let mut req_no_change = false;
267    let mut req_complete = false;
268    if let Some(arms) = arms {
269        let required: Vec<&Value> = arms
270            .iter()
271            .filter(|arm| arm.get("required") == Some(&Value::Bool(true)))
272            .collect();
273        dirty = required
274            .iter()
275            .filter(|arm| {
276                let status = arm.get("status").and_then(Value::as_str).unwrap_or("");
277                let degraded = arm.get("degraded") == Some(&Value::Bool(true));
278                status == "outage" || status == "cursor_uncertain" || degraded
279            })
280            .count();
281        let ok_no_change = required
282            .iter()
283            .filter(|arm| {
284                arm.get("status").and_then(Value::as_str) == Some("no_change")
285                    && arm.get("degraded") == Some(&Value::Bool(false))
286            })
287            .count();
288        req_no_change = !required.is_empty() && required.len() == ok_no_change;
289        let ok_complete = required
290            .iter()
291            .filter(|arm| {
292                let status = arm.get("status").and_then(Value::as_str).unwrap_or("");
293                arm.get("degraded") == Some(&Value::Bool(false))
294                    && status != "outage"
295                    && status != "cursor_uncertain"
296            })
297            .count();
298        req_complete = !required.is_empty() && required.len() == ok_complete;
299    }
300
301    if dirty > 0 && (kind == "no_change" || kind == "logical_deadman") {
302        return Err(ValidationError::normative(
303            "/outcome_kind",
304            "required_arm_not_clean",
305            NormativeReason::OutageNotClean,
306        )
307        .into());
308    }
309
310    if kind == "no_change" {
311        let logical = logical.ok_or_else(|| {
312            ValidationError::normative(
313                "/logical_deadline",
314                "required",
315                NormativeReason::NoChangeInvariants,
316            )
317        })?;
318        let rel = rfc3339::compare(completed, logical)?;
319        if events_n != 0 || complete != Some(true) || !req_no_change || rel >= 0 {
320            return Err(ValidationError::normative(
321                "/outcome_kind",
322                "no_change_invariants",
323                NormativeReason::NoChangeInvariants,
324            )
325            .into());
326        }
327    }
328
329    if kind == "logical_deadman" {
330        let logical = logical.ok_or_else(|| {
331            ValidationError::normative(
332                "/logical_deadline",
333                "required",
334                NormativeReason::DeadmanInvariants,
335            )
336        })?;
337        let rel = rfc3339::compare(completed, logical)?;
338        if events_n != 0 || complete != Some(true) || !req_complete || rel < 0 {
339            return Err(ValidationError::normative(
340                "/outcome_kind",
341                "deadman_invariants",
342                NormativeReason::DeadmanInvariants,
343            )
344            .into());
345        }
346    }
347    Ok(())
348}
349
350fn set_rules(documents: &[Value]) -> Result<()> {
351    coverage_cardinality(documents)?;
352    ack_rules(documents)?;
353    fairness_starvation(documents)?;
354    silent_cursor_advance(documents)?;
355    revision_cross(documents)?;
356    authn_lease_bounds(documents)?;
357    Ok(())
358}
359
360fn coverage_cardinality(documents: &[Value]) -> Result<()> {
361    let mut required = Vec::new();
362    for document in documents {
363        if document.get("message_type").and_then(Value::as_str) != Some("poll_cycle_request") {
364            continue;
365        }
366        if let Some(arms) = document.get("required_arms").and_then(Value::as_array) {
367            for arm in arms {
368                if let Some(id) = arm.as_str() {
369                    required.push(id.to_string());
370                }
371            }
372        }
373    }
374    if required.is_empty() {
375        return Ok(());
376    }
377    let required: BTreeSet<String> = required.into_iter().collect();
378    for document in documents {
379        if document.get("message_type").and_then(Value::as_str) != Some("poll_cycle_outcome") {
380            continue;
381        }
382        if document.get("coverage_complete") != Some(&Value::Bool(true)) {
383            continue;
384        }
385        let have: BTreeSet<String> = document
386            .get("arms")
387            .and_then(Value::as_array)
388            .map(|arms| {
389                arms.iter()
390                    .filter_map(|arm| {
391                        arm.get("arm_id")
392                            .and_then(Value::as_str)
393                            .map(str::to_string)
394                    })
395                    .collect()
396            })
397            .unwrap_or_default();
398        if required.iter().any(|arm| !have.contains(arm)) {
399            return Err(ValidationError::normative(
400                "/arms",
401                "missing_required_arm",
402                NormativeReason::CoverageCardinality,
403            )
404            .into());
405        }
406    }
407    Ok(())
408}
409
410fn ack_rules(documents: &[Value]) -> Result<()> {
411    let acks: Vec<&Value> = documents
412        .iter()
413        .filter(|d| d.get("message_type").and_then(Value::as_str) == Some("poll_cycle_ack"))
414        .collect();
415    let outs: Vec<&Value> = documents
416        .iter()
417        .filter(|d| d.get("message_type").and_then(Value::as_str) == Some("poll_cycle_outcome"))
418        .collect();
419    if acks.is_empty() || outs.is_empty() {
420        return Ok(());
421    }
422
423    for ack in &acks {
424        let outcome_ref = ack.get("outcome_ref").and_then(Value::as_str);
425        let Some(outcome) = outs
426            .iter()
427            .find(|out| out.get("message_id").and_then(Value::as_str) == outcome_ref)
428        else {
429            continue;
430        };
431        let committed = object_map(ack.get("committed_anchors"));
432        let retained_through = object_map(outcome.get("retained_through"));
433        let ack_events = string_list_map(ack.get("retained_events"));
434        let out_events = string_list_map(outcome.get("retained_events"));
435
436        for (rid, anchor) in &committed {
437            if retained_through.get(rid) != Some(anchor) {
438                let stolen = retained_through
439                    .iter()
440                    .any(|(other_rid, other)| other_rid != rid && other == anchor);
441                if stolen {
442                    return Err(ValidationError::normative(
443                        "/committed_anchors",
444                        "cross_registration",
445                        NormativeReason::CrossArmCommit,
446                    )
447                    .into());
448                }
449            }
450        }
451        for (rid, events) in &ack_events {
452            for event_id in events {
453                let stolen = out_events.iter().any(|(other_rid, other_events)| {
454                    other_rid != rid && other_events.iter().any(|e| e == event_id)
455                });
456                if stolen {
457                    return Err(ValidationError::normative(
458                        "/retained_events",
459                        "cross_registration",
460                        NormativeReason::CrossArmCommit,
461                    )
462                    .into());
463                }
464            }
465        }
466
467        let cursor_mismatch = committed
468            .iter()
469            .any(|(rid, anchor)| retained_through.get(rid) != Some(anchor));
470        let event_mismatch = ack_events.iter().any(|(rid, events)| {
471            let allowed = out_events.get(rid).cloned().unwrap_or_default();
472            events.iter().any(|event_id| !allowed.contains(event_id))
473        });
474        if cursor_mismatch || event_mismatch {
475            return Err(ValidationError::normative(
476                "/committed_anchors",
477                "past_unretained",
478                NormativeReason::AckPastUnretained,
479            )
480            .into());
481        }
482    }
483    Ok(())
484}
485
486fn fairness_starvation(documents: &[Value]) -> Result<()> {
487    let mut by_waiter: BTreeMap<String, Vec<&Value>> = BTreeMap::new();
488    for document in documents {
489        if document.get("message_type").and_then(Value::as_str) != Some("poll_cycle_outcome") {
490            continue;
491        }
492        let waiter = document
493            .get("waiter_id")
494            .and_then(Value::as_str)
495            .unwrap_or("")
496            .to_string();
497        by_waiter.entry(waiter).or_default().push(document);
498    }
499    for outcomes in by_waiter.values() {
500        if outcomes.len() < 2 {
501            continue;
502        }
503        let mut ordered = outcomes.clone();
504        ordered.sort_by_key(|out| out.get("created_at").and_then(Value::as_str).unwrap_or(""));
505        let mut arms = BTreeSet::new();
506        for out in &ordered {
507            if let Some(list) = out.get("arms").and_then(Value::as_array) {
508                for arm in list {
509                    if arm.get("required") == Some(&Value::Bool(true)) {
510                        if let Some(id) = arm.get("arm_id").and_then(Value::as_str) {
511                            arms.insert(id.to_string());
512                        }
513                    }
514                }
515            }
516        }
517        let cursors: BTreeSet<&str> = ordered
518            .iter()
519            .filter_map(|out| out.get("next_fairness_cursor").and_then(Value::as_str))
520            .collect();
521        let cursor_frozen = cursors.len() == 1;
522        for arm_id in &arms {
523            let always_deferred = ordered
524                .iter()
525                .all(|out| arm_status(out, arm_id).as_deref() == Some("deferred"));
526            let other_events = ordered.iter().any(|out| {
527                out.get("arms")
528                    .and_then(Value::as_array)
529                    .into_iter()
530                    .flatten()
531                    .any(|arm| {
532                        arm.get("required") == Some(&Value::Bool(true))
533                            && arm.get("arm_id").and_then(Value::as_str) != Some(arm_id.as_str())
534                            && arm.get("status").and_then(Value::as_str) == Some("events")
535                    })
536            });
537            if always_deferred && other_events && cursor_frozen {
538                return Err(ValidationError::normative(
539                    "/next_fairness_cursor",
540                    "starvation",
541                    NormativeReason::FairnessStarvation,
542                )
543                .into());
544            }
545        }
546    }
547    Ok(())
548}
549
550fn silent_cursor_advance(documents: &[Value]) -> Result<()> {
551    let acks = documents
552        .iter()
553        .any(|d| d.get("message_type").and_then(Value::as_str) == Some("poll_cycle_ack"));
554    let mut outcomes: Vec<&Value> = documents
555        .iter()
556        .filter(|d| d.get("message_type").and_then(Value::as_str) == Some("poll_cycle_outcome"))
557        .collect();
558    if acks || outcomes.len() < 2 {
559        return Ok(());
560    }
561    outcomes.sort_by_key(|out| out.get("created_at").and_then(Value::as_str).unwrap_or(""));
562    let first = outcomes[0];
563    let last = outcomes[outcomes.len() - 1];
564    let first_ids = event_ids(first);
565    let last_ids = event_ids(last);
566    if first_ids.is_empty() {
567        return Ok(());
568    }
569    let lost = first_ids.iter().any(|id| !last_ids.contains(id));
570    let first_anchors = object_map(first.get("proposed_next_anchors"));
571    let last_anchors = object_map(last.get("proposed_next_anchors"));
572    let advanced = first_anchors.iter().any(|(rid, anchor)| {
573        last_anchors.get(rid).and_then(|a| a.get("value")) != anchor.get("value")
574    });
575    if lost && advanced {
576        return Err(ValidationError::normative(
577            "/proposed_next_anchors",
578            "silent_advance",
579            NormativeReason::SilentCursorAdvance,
580        )
581        .into());
582    }
583    Ok(())
584}
585
586fn revision_cross(documents: &[Value]) -> Result<()> {
587    let sets: Vec<&Value> = documents
588        .iter()
589        .filter(|d| d.get("message_type").and_then(Value::as_str) == Some("registration_set"))
590        .collect();
591    let reqs: Vec<&Value> = documents
592        .iter()
593        .filter(|d| {
594            matches!(
595                d.get("message_type").and_then(Value::as_str),
596                Some("poll_cycle_request" | "live_wait_request")
597            )
598        })
599        .collect();
600    if sets.is_empty() || reqs.is_empty() {
601        return Ok(());
602    }
603    let rev = sets
604        .last()
605        .and_then(|s| s.get("registration_revision"))
606        .and_then(Value::as_str);
607    if reqs
608        .iter()
609        .any(|r| r.get("registration_revision").and_then(Value::as_str) != rev)
610    {
611        return Err(ValidationError::normative(
612            "/registration_revision",
613            "revision_mismatch",
614            NormativeReason::RevisionCross,
615        )
616        .into());
617    }
618    Ok(())
619}
620
621fn authn_lease_bounds(documents: &[Value]) -> Result<()> {
622    let sets: Vec<&Value> = documents
623        .iter()
624        .filter(|d| d.get("message_type").and_then(Value::as_str) == Some("registration_set"))
625        .collect();
626    let outs: Vec<&Value> = documents
627        .iter()
628        .filter(|d| {
629            matches!(
630                d.get("message_type").and_then(Value::as_str),
631                Some("poll_cycle_outcome" | "live_wait_outcome")
632            )
633        })
634        .collect();
635    let reqs: Vec<&Value> = documents
636        .iter()
637        .filter(|d| {
638            matches!(
639                d.get("message_type").and_then(Value::as_str),
640                Some("poll_cycle_request" | "live_wait_request")
641            )
642        })
643        .collect();
644    if sets.is_empty() || outs.is_empty() {
645        return Ok(());
646    }
647
648    let clean_outcome = outs.iter().any(|o| {
649        matches!(
650            o.get("outcome_kind").and_then(Value::as_str),
651            Some("events" | "no_change" | "logical_deadman" | "partial")
652        )
653    });
654    for set in &sets {
655        if set.get("authn_mode").and_then(Value::as_str) != Some("required") {
656            continue;
657        }
658        let missing_receipt = reqs.is_empty()
659            || reqs.iter().any(|r| {
660                r.get("verification_receipt_ref")
661                    .and_then(Value::as_str)
662                    .is_none()
663            });
664        if missing_receipt && clean_outcome {
665            return Err(ValidationError::normative(
666                "/verification_receipt_ref",
667                "required",
668                NormativeReason::AuthnRequired,
669            )
670            .into());
671        }
672    }
673
674    for set in &sets {
675        let registrations = set
676            .get("registrations")
677            .and_then(Value::as_array)
678            .cloned()
679            .unwrap_or_default();
680        for registration in &registrations {
681            let lease = registration
682                .get("lease_expires_at")
683                .and_then(Value::as_str)
684                .ok_or_else(|| {
685                    ValidationError::new("/registrations/lease_expires_at", "required")
686                })?;
687            for out in &outs {
688                let completed = string_field(out, "/completed_at", "completed_at")?;
689                let kind = out
690                    .get("outcome_kind")
691                    .and_then(Value::as_str)
692                    .unwrap_or("");
693                if rfc3339::compare(lease, completed)? < 0 && kind != "reauthentication_required" {
694                    return Err(ValidationError::normative(
695                        "/completed_at",
696                        "lease_expired",
697                        NormativeReason::LeaseReauth,
698                    )
699                    .into());
700                }
701            }
702        }
703    }
704
705    for set in &sets {
706        let aggregate = set
707            .pointer("/aggregate_limits/max_events")
708            .and_then(Value::as_u64)
709            .unwrap_or(u64::MAX);
710        let registrations = set
711            .get("registrations")
712            .and_then(Value::as_array)
713            .cloned()
714            .unwrap_or_default();
715        for out in &outs {
716            let kind = out
717                .get("outcome_kind")
718                .and_then(Value::as_str)
719                .unwrap_or("");
720            if kind == "partial" || kind == "coverage_degraded" {
721                continue;
722            }
723            let events = out
724                .get("events")
725                .and_then(Value::as_array)
726                .cloned()
727                .unwrap_or_default();
728            for registration in &registrations {
729                let rid = registration
730                    .get("registration_id")
731                    .and_then(Value::as_str)
732                    .unwrap_or("");
733                let max_events = registration
734                    .pointer("/bounds/max_events")
735                    .and_then(Value::as_u64)
736                    .unwrap_or(u64::MAX);
737                let count = events
738                    .iter()
739                    .filter(|e| e.get("registration_id").and_then(Value::as_str) == Some(rid))
740                    .count() as u64;
741                if count > max_events {
742                    return Err(ValidationError::normative(
743                        "/events",
744                        "registration_max_events",
745                        NormativeReason::RegistrationBound,
746                    )
747                    .into());
748                }
749            }
750            if events.len() as u64 > aggregate {
751                return Err(ValidationError::normative(
752                    "/events",
753                    "aggregate_max_events",
754                    NormativeReason::AggregateBound,
755                )
756                .into());
757            }
758        }
759    }
760    Ok(())
761}
762
763fn string_field<'a>(document: &'a Value, path: &str, name: &'static str) -> Result<&'a str> {
764    document
765        .get(name)
766        .and_then(Value::as_str)
767        .ok_or_else(|| ValidationError::new(path, "required").into())
768}
769
770fn object_map(value: Option<&Value>) -> BTreeMap<String, Value> {
771    value
772        .and_then(Value::as_object)
773        .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
774        .unwrap_or_default()
775}
776
777fn string_list_map(value: Option<&Value>) -> BTreeMap<String, Vec<String>> {
778    value
779        .and_then(Value::as_object)
780        .map(|obj| {
781            obj.iter()
782                .map(|(k, v)| {
783                    let list = v
784                        .as_array()
785                        .map(|items| {
786                            items
787                                .iter()
788                                .filter_map(Value::as_str)
789                                .map(str::to_string)
790                                .collect()
791                        })
792                        .unwrap_or_default();
793                    (k.clone(), list)
794                })
795                .collect()
796        })
797        .unwrap_or_default()
798}
799
800fn event_ids(document: &Value) -> Vec<String> {
801    document
802        .get("events")
803        .and_then(Value::as_array)
804        .map(|events| {
805            events
806                .iter()
807                .filter_map(|e| {
808                    e.get("event_id")
809                        .and_then(Value::as_str)
810                        .map(str::to_string)
811                })
812                .collect()
813        })
814        .unwrap_or_default()
815}
816
817fn arm_status(document: &Value, arm_id: &str) -> Option<String> {
818    document
819        .get("arms")
820        .and_then(Value::as_array)?
821        .iter()
822        .find(|arm| arm.get("arm_id").and_then(Value::as_str) == Some(arm_id))
823        .and_then(|arm| {
824            arm.get("status")
825                .and_then(Value::as_str)
826                .map(str::to_string)
827        })
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833
834    #[test]
835    fn rejects_live_wait_ack() {
836        let raw = r#"{
837            "capabilities": ["contract: agent-wait/v0"],
838            "message_type": "live_wait_ack",
839            "message_id": "msg:1"
840        }"#;
841        let err = validate_message(raw).unwrap_err();
842        let shown = err.to_string();
843        assert!(shown.contains("undeclared_message_type"));
844        assert!(!shown.contains("live_wait_ack"));
845    }
846
847    #[test]
848    fn rejects_waitspec_shaped_public_json() {
849        let raw = r#"{
850            "capabilities": ["contract: agent-wait/v0"],
851            "message_type": "wait_spec",
852            "deadline": "2026-08-15T17:00:00Z",
853            "registrations": []
854        }"#;
855        let err = validate_message(raw).unwrap_err();
856        assert!(err.to_string().contains("undeclared_message_type"));
857    }
858
859    #[test]
860    fn deserialize_is_not_admission() {
861        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
862            .join("../../schemas/v0/rejects/normative/reject-deadline-ordering.json");
863        let raw = std::fs::read_to_string(&path).expect("read deadline-ordering reject");
864        let typed: AgentWaitMessage =
865            serde_json::from_str(&raw).expect("Deserialize can succeed without admission");
866        assert!(matches!(typed, AgentWaitMessage::LiveWaitRequest(_)));
867        let err = validate_message(&raw).expect_err("admission must still reject");
868        assert!(err
869            .to_string()
870            .contains("must_be_at_or_before_logical_deadline"));
871        assert!(!err.to_string().contains("2026-08-15T18:00:00Z"));
872    }
873}