1use 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#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct AdmittedMessage(AgentWaitMessage);
53
54impl AdmittedMessage {
55 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
75pub 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
96pub 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
118fn 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 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 ®istrations {
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 ®istrations {
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}