1use crate::identity::{
2 EffectVariantId, EnumTypeId, EnumVariantId, FieldId, InputVariantId, MachineId,
3 NamedTypeBinding, NamedTypeId, PhaseId, ProtocolId, RustTypeAtom, SignalVariantId,
4 TransitionId,
5};
6use crate::seam::SeamClassification;
7use indexmap::{IndexMap, IndexSet};
8use std::fmt;
9
10const NATIVE_MOB_MACHINE_HELPERS: &[&str] = &[
11 "meerkat_peer_endpoint_set_cardinality_matches",
12 "meerkat_peer_endpoint_set_contains_peer_id",
13 "meerkat_peer_endpoint_option_peer_id_matches",
14 "meerkat_peer_endpoint_peer_id_matches",
15 "meerkat_peer_endpoint_set_peer_ids_unique",
16 "mob_machine_identity_has_session_binding",
17 "mob_machine_external_peer_edge_has_matching_key",
18 "mob_machine_external_peer_edge_local",
19 "mob_machine_external_peer_edge_peer_id",
20 "mob_machine_external_peer_identity_absent",
21 "mob_machine_external_peer_key_matches_edge",
22 "mob_machine_external_peer_key_matches_local",
23 "mob_machine_session_bound_live_runtime_ids_match",
24 "mob_machine_member_peer_endpoint_peer_id",
25 "mob_machine_member_peer_overlay",
26 "mob_machine_member_peer_overlay_complete",
27 "mob_machine_member_peer_overlay_peer_ids_unique",
28 "mob_machine_wiring_edge_matches_members",
29 "mob_machine_run_step_status_after_set",
30 "mob_machine_run_step_bool_after_set",
31 "mob_machine_run_step_condition_result_after_set",
32 "mob_machine_run_step_u64_after_set",
33 "mob_machine_run_step_u64_after_increment",
34 "mob_machine_run_retry_count_after_increment",
35 "mob_machine_frame_node_bool_after_set",
36 "mob_machine_frame_node_status_after_admit",
37 "mob_machine_frame_ready_queue_after_admit",
38 "mob_machine_frame_node_status_after_terminal",
39 "mob_machine_frame_ready_queue_after_terminal",
40 "mob_machine_node_terminal",
41 "mob_machine_step_status_from_frame_node_status",
42 "mob_coordination_work_intent_unexpired",
43 "mob_coordination_resource_claim_unexpired",
44 "mob_coordination_resource_claim_active_at",
45 "mob_coordination_resource_claim_inactive_at",
46 "mob_machine_next_respawn_generation",
48 "mob_machine_members_to_spawn",
49 "mob_machine_members_to_retire",
50];
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct MachineSchema {
54 pub machine: MachineId,
55 pub version: u32,
56 pub rust: RustBinding,
57 pub state: StateSchema,
58 pub inputs: EnumSchema,
59 pub surface_only_inputs: Vec<InputVariantId>,
60 pub runtime_internal_inputs: Vec<InputVariantId>,
63 pub signals: EnumSchema,
64 pub effects: EnumSchema,
65 pub helpers: Vec<HelperSchema>,
66 pub derived: Vec<HelperSchema>,
67 pub command_plans: Vec<CommandPlanSchema>,
70 pub invariants: Vec<InvariantSchema>,
71 pub transitions: Vec<TransitionSchema>,
72 pub effect_dispositions: Vec<EffectDispositionRule>,
73 pub named_types: Vec<NamedTypeBinding>,
78 pub ci_step_limit: Option<u32>,
83}
84
85impl MachineSchema {
86 pub fn named_type_binding(&self, name: &NamedTypeId) -> Option<&NamedTypeBinding> {
89 self.named_types
90 .iter()
91 .find(|binding| binding.name == *name)
92 }
93}
94
95impl MachineSchema {
96 pub fn validate(&self) -> Result<(), MachineSchemaError> {
97 let phase_names = self.state.phase.variants_by_name()?;
98 let input_variants = self.inputs.variants_by_name()?;
99 let surface_only_inputs = unique_names(
100 self.surface_only_inputs.iter().map(AsRef::as_ref),
101 "surface-only input",
102 )?;
103 let _runtime_internal_inputs = unique_names(
104 self.runtime_internal_inputs.iter().map(AsRef::as_ref),
105 "runtime-internal input",
106 )?;
107 let signal_variants = self.signals.variants_by_name()?;
108 let effect_variants = self.effects.variants_by_name()?;
109 let field_names = self.state.fields_by_name()?;
110 let helper_names = unique_names(
111 self.helpers
112 .iter()
113 .map(|helper| helper.name.as_str())
114 .chain(self.derived.iter().map(|helper| helper.name.as_str())),
115 "helper/derived",
116 )?;
117 let _command_plan_names = unique_names(
118 self.command_plans.iter().map(|plan| plan.name.as_str()),
119 "command plan",
120 )?;
121
122 if !phase_names.contains(self.state.init.phase.as_str()) {
123 return Err(MachineSchemaError::UnknownPhase {
124 phase: self.state.init.phase.as_str().to_owned(),
125 });
126 }
127
128 for terminal in &self.state.terminal_phases {
129 if !phase_names.contains(terminal.as_str()) {
130 return Err(MachineSchemaError::UnknownPhase {
131 phase: terminal.as_str().to_owned(),
132 });
133 }
134 }
135
136 for initializer in &self.state.init.fields {
137 if !field_names.contains(initializer.field.as_str()) {
138 return Err(MachineSchemaError::UnknownField {
139 field: initializer.field.as_str().to_owned(),
140 });
141 }
142 }
143
144 let initialized_fields: IndexSet<&str> = self
159 .state
160 .init
161 .fields
162 .iter()
163 .map(|initializer| initializer.field.as_str())
164 .collect();
165 for field in &self.state.fields {
166 if !initialized_fields.contains(field.name.as_str()) {
167 return Err(MachineSchemaError::MissingInitializer {
168 field: field.name.as_str().to_owned(),
169 });
170 }
171 }
172
173 for surface_only_input in &self.surface_only_inputs {
174 if !input_variants
175 .iter()
176 .any(|variant| *variant == surface_only_input.as_str())
177 {
178 return Err(MachineSchemaError::UnknownSurfaceOnlyInputVariant {
179 variant: surface_only_input.as_str().to_owned(),
180 });
181 }
182 }
183
184 for runtime_internal_input in &self.runtime_internal_inputs {
185 if !input_variants
186 .iter()
187 .any(|variant| *variant == runtime_internal_input.as_str())
188 {
189 return Err(MachineSchemaError::UnknownRuntimeInternalInputVariant {
190 variant: runtime_internal_input.as_str().to_owned(),
191 });
192 }
193 }
194
195 for plan in &self.command_plans {
196 if plan.authority_type.is_empty() {
197 return Err(MachineSchemaError::EmptyName("command-plan authority type"));
198 }
199 for input in &plan.source_inputs {
200 if !input_variants.contains(input.as_str()) {
201 return Err(MachineSchemaError::UnknownCommandPlanInput {
202 plan: plan.name.clone(),
203 input: input.as_str().to_owned(),
204 });
205 }
206 }
207 for signal in &plan.source_signals {
208 if !signal_variants.contains(signal.as_str()) {
209 return Err(MachineSchemaError::UnknownCommandPlanSignal {
210 plan: plan.name.clone(),
211 signal: signal.as_str().to_owned(),
212 });
213 }
214 }
215 for effect in &plan.effects {
216 if !effect_variants.contains(effect.as_str()) {
217 return Err(MachineSchemaError::UnknownCommandPlanEffect {
218 plan: plan.name.clone(),
219 effect: effect.as_str().to_owned(),
220 });
221 }
222 }
223 for closure in &plan.effect_closures {
224 if closure.authority_type.is_empty() {
225 return Err(MachineSchemaError::EmptyName(
226 "command-plan effect-closure authority type",
227 ));
228 }
229 if closure.closure_policy.is_empty() {
230 return Err(MachineSchemaError::EmptyName(
231 "command-plan effect-closure policy",
232 ));
233 }
234 if closure.lifecycle.is_empty() || closure.lifecycle.iter().any(String::is_empty) {
235 return Err(MachineSchemaError::EmptyName(
236 "command-plan effect-closure lifecycle state",
237 ));
238 }
239 if !effect_variants.contains(closure.effect.as_str()) {
240 return Err(MachineSchemaError::UnknownCommandPlanEffect {
241 plan: plan.name.clone(),
242 effect: closure.effect.as_str().to_owned(),
243 });
244 }
245 if !plan.effects.contains(&closure.effect) {
246 return Err(MachineSchemaError::UnknownCommandPlanClosureEffect {
247 plan: plan.name.clone(),
248 effect: closure.effect.as_str().to_owned(),
249 });
250 }
251 }
252 }
253
254 for invariant in &self.invariants {
255 if invariant.name.is_empty() {
256 return Err(MachineSchemaError::EmptyName("invariant"));
257 }
258 invariant.expr.validate(
259 &phase_names,
260 &field_names,
261 &input_variants,
262 &signal_variants,
263 &effect_variants,
264 &helper_names,
265 &IndexSet::new(),
266 )?;
267 }
268
269 let mut transition_names = IndexSet::new();
270 for transition in &self.transitions {
271 if transition.name.as_str().is_empty() {
272 return Err(MachineSchemaError::EmptyName("transition"));
273 }
274 if !transition_names.insert(transition.name.as_str()) {
275 return Err(MachineSchemaError::DuplicateName {
276 kind: "transition",
277 name: transition.name.as_str().to_owned(),
278 });
279 }
280 for from in &transition.from {
281 if !phase_names.contains(from.as_str()) {
282 return Err(MachineSchemaError::UnknownPhase {
283 phase: from.as_str().to_owned(),
284 });
285 }
286 }
287 match &transition.on {
288 TriggerMatch::Input { variant, .. }
289 if !input_variants.contains(variant.as_str()) =>
290 {
291 return Err(MachineSchemaError::UnknownInputVariant {
292 variant: variant.as_str().to_owned(),
293 });
294 }
295 TriggerMatch::Signal { variant, .. }
296 if !signal_variants.contains(variant.as_str()) =>
297 {
298 return Err(MachineSchemaError::UnknownSignalVariant {
299 variant: variant.as_str().to_owned(),
300 });
301 }
302 _ => {}
303 }
304 if let TriggerMatch::Input { variant, .. } = &transition.on
305 && surface_only_inputs.contains(variant.as_str())
306 {
307 return Err(MachineSchemaError::SurfaceOnlyInputHasTransition {
308 variant: variant.as_str().to_owned(),
309 transition: transition.name.as_str().to_owned(),
310 });
311 }
312 if !phase_names.contains(transition.to.as_str()) {
313 return Err(MachineSchemaError::UnknownPhase {
314 phase: transition.to.as_str().to_owned(),
315 });
316 }
317
318 let bindings = unique_names(
319 transition.on.bindings().iter().map(AsRef::as_ref),
320 "transition binding",
321 )?;
322
323 for guard in &transition.guards {
324 guard.expr.validate(
325 &phase_names,
326 &field_names,
327 &input_variants,
328 &signal_variants,
329 &effect_variants,
330 &helper_names,
331 &bindings,
332 )?;
333 }
334 for update in &transition.updates {
335 update.validate(
336 &phase_names,
337 &field_names,
338 &input_variants,
339 &signal_variants,
340 &effect_variants,
341 &helper_names,
342 &bindings,
343 )?;
344 }
345 for effect in &transition.emit {
346 if !effect_variants.contains(effect.variant.as_str()) {
347 return Err(MachineSchemaError::UnknownEffectVariant {
348 variant: effect.variant.as_str().to_owned(),
349 });
350 }
351 for expr in effect.fields.values() {
352 expr.validate(
353 &phase_names,
354 &field_names,
355 &input_variants,
356 &signal_variants,
357 &effect_variants,
358 &helper_names,
359 &bindings,
360 )?;
361 }
362 }
363 }
364
365 let transition_names: IndexSet<&str> = self
366 .transitions
367 .iter()
368 .map(|transition| transition.name.as_str())
369 .collect();
370 for plan in &self.command_plans {
371 for transition in &plan.transitions {
372 if !transition_names.contains(transition.as_str()) {
373 return Err(MachineSchemaError::UnknownCommandPlanTransition {
374 plan: plan.name.clone(),
375 transition: transition.as_str().to_owned(),
376 });
377 }
378 }
379 }
380
381 let mut seen_bindings: IndexSet<&str> = IndexSet::new();
387 for binding in &self.named_types {
388 if !seen_bindings.insert(binding.name.as_str()) {
389 return Err(MachineSchemaError::DuplicateNamedTypeBinding {
390 name: binding.name.as_str().to_owned(),
391 });
392 }
393 validate_named_type_binding_payload(binding)?;
394 }
395 {
396 let mut referenced: IndexSet<String> = IndexSet::new();
397 collect_named_type_references_machine(self, &mut referenced);
398 for binding in &self.named_types {
399 collect_named_type_references_binding(binding, &mut referenced);
400 }
401 for name in &referenced {
402 if !seen_bindings.contains(name.as_str()) {
403 return Err(MachineSchemaError::MissingNamedTypeBinding { name: name.clone() });
404 }
405 }
406 }
407 {
408 let mut referenced: IndexSet<String> = IndexSet::new();
409 collect_enum_type_references_machine(self, &mut referenced);
410 for name in &referenced {
411 let Some(binding) = self
412 .named_types
413 .iter()
414 .find(|binding| binding.name.as_str() == name.as_str())
415 else {
416 return Err(MachineSchemaError::MissingStringEnumBinding {
417 name: name.clone(),
418 });
419 };
420 if !matches!(binding.rust, RustTypeAtom::StringEnum { .. }) {
421 return Err(MachineSchemaError::InvalidStringEnumBinding {
422 name: name.clone(),
423 reason: "TypeRef::Enum domains must use RustTypeAtom::StringEnum"
424 .to_owned(),
425 });
426 }
427 }
428 }
429 validate_string_enum_named_variants_machine(self)?;
430
431 {
438 let mut disposed_variants: IndexSet<&str> = IndexSet::new();
439 for rule in &self.effect_dispositions {
440 if !effect_variants.contains(rule.effect_variant.as_str()) {
441 return Err(MachineSchemaError::UnknownEffectDispositionVariant {
442 variant: rule.effect_variant.as_str().to_owned(),
443 });
444 }
445 if !disposed_variants.insert(rule.effect_variant.as_str()) {
446 return Err(MachineSchemaError::DuplicateEffectDisposition {
447 variant: rule.effect_variant.as_str().to_owned(),
448 });
449 }
450 if rule.handoff_protocol.is_some()
451 && matches!(rule.disposition, EffectDisposition::Routed { .. })
452 {
453 return Err(MachineSchemaError::HandoffProtocolOnRoutedEffect {
454 variant: rule.effect_variant.as_str().to_owned(),
455 });
456 }
457 }
458 for variant in &effect_variants {
459 if !disposed_variants.contains(*variant) {
460 return Err(MachineSchemaError::MissingEffectDisposition {
461 variant: (*variant).to_owned(),
462 });
463 }
464 }
465 }
466
467 Ok(())
468 }
469}
470
471#[derive(Debug, Clone, PartialEq, Eq)]
472pub enum EffectDisposition {
473 Local,
475 External,
477 Routed { consumer_machines: Vec<MachineId> },
479}
480
481#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct EffectDispositionRule {
483 pub effect_variant: EffectVariantId,
484 pub disposition: EffectDisposition,
485 pub handoff_protocol: Option<ProtocolId>,
490 pub seam_classification: SeamClassification,
494}
495
496#[derive(Debug, Clone, PartialEq, Eq)]
497pub struct RustBinding {
498 pub crate_name: String,
499 pub module: String,
500}
501
502#[derive(Debug, Clone, PartialEq, Eq)]
503pub struct StateSchema {
504 pub phase: EnumSchema,
505 pub fields: Vec<FieldSchema>,
506 pub init: InitSchema,
507 pub terminal_phases: Vec<PhaseId>,
508}
509
510impl StateSchema {
511 fn fields_by_name(&self) -> Result<IndexSet<&str>, MachineSchemaError> {
512 unique_names(
513 self.fields.iter().map(|field| field.name.as_str()),
514 "state field",
515 )
516 }
517}
518
519#[derive(Debug, Clone, PartialEq, Eq)]
520pub struct InitSchema {
521 pub phase: PhaseId,
522 pub fields: Vec<FieldInit>,
523}
524
525#[derive(Debug, Clone, PartialEq, Eq)]
526pub struct FieldInit {
527 pub field: FieldId,
528 pub expr: Expr,
529}
530
531#[derive(Debug, Clone, PartialEq, Eq)]
532pub struct EnumSchema {
533 pub name: String,
534 pub variants: Vec<VariantSchema>,
535}
536
537impl EnumSchema {
538 pub(crate) fn variants_by_name(&self) -> Result<IndexSet<&str>, MachineSchemaError> {
539 let mut names: IndexSet<&str> = IndexSet::new();
540 for variant in &self.variants {
541 if variant.name.as_str().is_empty() {
542 return Err(MachineSchemaError::EmptyName("variant"));
543 }
544 if !names.insert(variant.name.as_str()) {
545 return Err(MachineSchemaError::DuplicateName {
546 kind: "variant",
547 name: variant.name.as_str().to_owned(),
548 });
549 }
550 unique_names(
551 variant.fields.iter().map(|field| field.name.as_str()),
552 "variant field",
553 )?;
554 }
555 Ok(names)
556 }
557
558 pub fn variant_named(
559 &self,
560 name: impl AsRef<str>,
561 ) -> Result<&VariantSchema, MachineSchemaError> {
562 let name = name.as_ref();
563 self.variants
564 .iter()
565 .find(|variant| variant.name.as_str() == name)
566 .ok_or_else(|| MachineSchemaError::UnknownVariant {
567 variant: name.to_owned(),
568 })
569 }
570}
571
572#[derive(Debug, Clone, PartialEq, Eq)]
573pub struct VariantSchema {
574 pub name: EnumVariantId,
575 pub fields: Vec<FieldSchema>,
576}
577
578impl VariantSchema {
579 pub fn field_named(&self, name: impl AsRef<str>) -> Result<&FieldSchema, MachineSchemaError> {
580 let name = name.as_ref();
581 self.fields
582 .iter()
583 .find(|field| field.name.as_str() == name)
584 .ok_or_else(|| MachineSchemaError::UnknownVariantField {
585 variant: self.name.as_str().to_owned(),
586 field: name.to_owned(),
587 })
588 }
589}
590
591#[derive(Debug, Clone, PartialEq, Eq)]
592pub struct FieldSchema {
593 pub name: FieldId,
594 pub ty: TypeRef,
595}
596
597#[derive(Debug, Clone, PartialEq, Eq)]
598pub enum TypeRef {
599 Bool,
600 U32,
601 U64,
602 String,
603 Named(NamedTypeId),
604 Enum(EnumTypeId),
605 Option(Box<TypeRef>),
606 Set(Box<TypeRef>),
607 Seq(Box<TypeRef>),
608 Map(Box<TypeRef>, Box<TypeRef>),
609}
610
611pub type FieldType = TypeRef;
612
613#[derive(Debug, Clone, PartialEq, Eq)]
614pub struct HelperSchema {
615 pub name: String,
616 pub params: Vec<FieldSchema>,
617 pub returns: TypeRef,
618 pub body: Expr,
619}
620
621#[derive(Debug, Clone, PartialEq, Eq)]
622pub struct InvariantSchema {
623 pub name: String,
624 pub expr: Expr,
625}
626
627#[derive(Debug, Clone, PartialEq, Eq)]
628pub struct CommandPlanSchema {
629 pub name: String,
630 pub authority_type: String,
631 pub source_inputs: Vec<InputVariantId>,
632 pub source_signals: Vec<SignalVariantId>,
633 pub transitions: Vec<TransitionId>,
634 pub effects: Vec<EffectVariantId>,
635 pub effect_closures: Vec<EffectClosureSchema>,
636}
637
638#[derive(Debug, Clone, PartialEq, Eq)]
639pub struct EffectClosureSchema {
640 pub effect: EffectVariantId,
641 pub authority_type: String,
642 pub closure_policy: String,
643 pub lifecycle: Vec<String>,
644}
645
646#[derive(Debug, Clone, PartialEq, Eq)]
647pub struct TransitionSchema {
648 pub name: TransitionId,
649 pub from: Vec<PhaseId>,
650 pub on: TriggerMatch,
651 pub guards: Vec<Guard>,
652 pub updates: Vec<Update>,
653 pub to: PhaseId,
654 pub emit: Vec<EffectEmit>,
655}
656
657#[derive(Debug, Clone, Copy, PartialEq, Eq)]
658pub enum TriggerKind {
659 Input,
660 Signal,
661}
662
663#[derive(Debug, Clone, PartialEq, Eq)]
673pub enum TriggerMatch {
674 Input {
675 variant: InputVariantId,
676 bindings: Vec<FieldId>,
677 },
678 Signal {
679 variant: SignalVariantId,
680 bindings: Vec<FieldId>,
681 },
682}
683
684impl TriggerMatch {
685 pub fn kind(&self) -> TriggerKind {
688 match self {
689 Self::Input { .. } => TriggerKind::Input,
690 Self::Signal { .. } => TriggerKind::Signal,
691 }
692 }
693
694 pub fn variant_str(&self) -> &str {
697 match self {
698 Self::Input { variant, .. } => variant.as_str(),
699 Self::Signal { variant, .. } => variant.as_str(),
700 }
701 }
702
703 pub fn bindings(&self) -> &[FieldId] {
705 match self {
706 Self::Input { bindings, .. } | Self::Signal { bindings, .. } => bindings,
707 }
708 }
709}
710
711pub type InputMatch = TriggerMatch;
712
713#[derive(Debug, Clone, PartialEq, Eq)]
714pub struct Guard {
715 pub name: String,
716 pub expr: Expr,
717}
718
719#[derive(Debug, Clone, PartialEq, Eq)]
720pub struct EffectEmit {
721 pub variant: EffectVariantId,
722 pub fields: IndexMap<FieldId, Expr>,
723}
724
725#[derive(Debug, Clone, PartialEq, Eq)]
726pub enum Update {
727 Assign {
728 field: FieldId,
729 expr: Expr,
730 },
731 Increment {
732 field: FieldId,
733 amount: u64,
734 },
735 Decrement {
736 field: FieldId,
737 amount: u64,
738 },
739 MapInsert {
740 field: FieldId,
741 key: Expr,
742 value: Expr,
743 },
744 MapIncrement {
745 field: FieldId,
746 key: Expr,
747 amount: u64,
748 },
749 MapDecrement {
750 field: FieldId,
751 key: Expr,
752 amount: u64,
753 },
754 MapRemove {
755 field: FieldId,
756 key: Expr,
757 },
758 SetInsert {
759 field: FieldId,
760 value: Expr,
761 },
762 SetRemove {
763 field: FieldId,
764 value: Expr,
765 },
766 SeqAppend {
767 field: FieldId,
768 value: Expr,
769 },
770 SeqPrepend {
771 field: FieldId,
772 values: Expr,
773 },
774 SeqPopFront {
775 field: FieldId,
776 },
777 SeqRemoveValue {
778 field: FieldId,
779 value: Expr,
780 },
781 SeqRemoveAll {
782 field: FieldId,
783 values: Expr,
784 },
785 Conditional {
786 condition: Expr,
787 then_updates: Vec<Update>,
788 else_updates: Vec<Update>,
789 },
790 ForEach {
791 binding: String,
792 over: Expr,
793 updates: Vec<Update>,
794 },
795}
796
797impl Update {
798 #[allow(clippy::too_many_arguments)]
799 fn validate(
800 &self,
801 phase_names: &IndexSet<&str>,
802 field_names: &IndexSet<&str>,
803 input_variants: &IndexSet<&str>,
804 signal_variants: &IndexSet<&str>,
805 effect_variants: &IndexSet<&str>,
806 helper_names: &IndexSet<&str>,
807 bindings: &IndexSet<&str>,
808 ) -> Result<(), MachineSchemaError> {
809 match self {
810 Self::Assign { field, .. }
811 | Self::Increment { field, .. }
812 | Self::Decrement { field, .. }
813 | Self::SeqPopFront { field } => {
814 if !field_names.contains(field.as_str()) {
815 return Err(MachineSchemaError::UnknownField {
816 field: field.as_str().to_owned(),
817 });
818 }
819 if let Self::Assign { expr, .. } = self {
820 expr.validate(
821 phase_names,
822 field_names,
823 input_variants,
824 signal_variants,
825 effect_variants,
826 helper_names,
827 bindings,
828 )?;
829 }
830 }
831 Self::MapInsert { field, key, value } => {
832 if !field_names.contains(field.as_str()) {
833 return Err(MachineSchemaError::UnknownField {
834 field: field.as_str().to_owned(),
835 });
836 }
837 key.validate(
838 phase_names,
839 field_names,
840 input_variants,
841 signal_variants,
842 effect_variants,
843 helper_names,
844 bindings,
845 )?;
846 value.validate(
847 phase_names,
848 field_names,
849 input_variants,
850 signal_variants,
851 effect_variants,
852 helper_names,
853 bindings,
854 )?;
855 }
856 Self::MapRemove { field, key } => {
857 if !field_names.contains(field.as_str()) {
858 return Err(MachineSchemaError::UnknownField {
859 field: field.as_str().to_owned(),
860 });
861 }
862 key.validate(
863 phase_names,
864 field_names,
865 input_variants,
866 signal_variants,
867 effect_variants,
868 helper_names,
869 bindings,
870 )?;
871 }
872 Self::MapIncrement { field, key, .. } | Self::MapDecrement { field, key, .. } => {
873 if !field_names.contains(field.as_str()) {
874 return Err(MachineSchemaError::UnknownField {
875 field: field.as_str().to_owned(),
876 });
877 }
878 key.validate(
879 phase_names,
880 field_names,
881 input_variants,
882 signal_variants,
883 effect_variants,
884 helper_names,
885 bindings,
886 )?;
887 }
888 Self::SetInsert { field, value }
889 | Self::SetRemove { field, value }
890 | Self::SeqAppend { field, value }
891 | Self::SeqRemoveValue { field, value } => {
892 if !field_names.contains(field.as_str()) {
893 return Err(MachineSchemaError::UnknownField {
894 field: field.as_str().to_owned(),
895 });
896 }
897 value.validate(
898 phase_names,
899 field_names,
900 input_variants,
901 signal_variants,
902 effect_variants,
903 helper_names,
904 bindings,
905 )?;
906 }
907 Self::SeqPrepend { field, values } | Self::SeqRemoveAll { field, values } => {
908 if !field_names.contains(field.as_str()) {
909 return Err(MachineSchemaError::UnknownField {
910 field: field.as_str().to_owned(),
911 });
912 }
913 values.validate(
914 phase_names,
915 field_names,
916 input_variants,
917 signal_variants,
918 effect_variants,
919 helper_names,
920 bindings,
921 )?;
922 }
923 Self::ForEach {
924 binding,
925 over,
926 updates,
927 } => {
928 over.validate(
929 phase_names,
930 field_names,
931 input_variants,
932 signal_variants,
933 effect_variants,
934 helper_names,
935 bindings,
936 )?;
937 let mut nested_bindings = bindings.clone();
938 nested_bindings.insert(binding.as_str());
939 for update in updates {
940 update.validate(
941 phase_names,
942 field_names,
943 input_variants,
944 signal_variants,
945 effect_variants,
946 helper_names,
947 &nested_bindings,
948 )?;
949 }
950 }
951 Self::Conditional {
952 condition,
953 then_updates,
954 else_updates,
955 } => {
956 condition.validate(
957 phase_names,
958 field_names,
959 input_variants,
960 signal_variants,
961 effect_variants,
962 helper_names,
963 bindings,
964 )?;
965 for update in then_updates {
966 update.validate(
967 phase_names,
968 field_names,
969 input_variants,
970 signal_variants,
971 effect_variants,
972 helper_names,
973 bindings,
974 )?;
975 }
976 for update in else_updates {
977 update.validate(
978 phase_names,
979 field_names,
980 input_variants,
981 signal_variants,
982 effect_variants,
983 helper_names,
984 bindings,
985 )?;
986 }
987 }
988 }
989 Ok(())
990 }
991}
992
993#[derive(Debug, Clone, PartialEq, Eq)]
994pub enum Quantifier {
995 Any,
996 All,
997}
998
999#[derive(Debug, Clone, PartialEq, Eq)]
1000pub enum Expr {
1001 Bool(bool),
1002 U64(u64),
1003 U64Max,
1004 String(String),
1005 NamedVariant {
1006 enum_name: EnumTypeId,
1007 variant: EnumVariantId,
1008 },
1009 FieldAccess {
1010 base: Box<Expr>,
1011 field: FieldId,
1012 },
1013 EnumVariantIs {
1014 value: Box<Expr>,
1015 enum_name: EnumTypeId,
1016 variant: EnumVariantId,
1017 },
1018 EnumStringSetPayload {
1019 value: Box<Expr>,
1020 enum_name: EnumTypeId,
1021 variant: EnumVariantId,
1022 field: FieldId,
1023 },
1024 EmptySet,
1025 EmptyMap,
1026 SeqLiteral(Vec<Expr>),
1027 CurrentPhase,
1028 Phase(PhaseId),
1029 Field(FieldId),
1030 Binding(String),
1031 Variant(String),
1032 None,
1033 IfElse {
1034 condition: Box<Expr>,
1035 then_expr: Box<Expr>,
1036 else_expr: Box<Expr>,
1037 },
1038 Not(Box<Expr>),
1039 And(Vec<Expr>),
1040 Or(Vec<Expr>),
1041 Eq(Box<Expr>, Box<Expr>),
1042 Neq(Box<Expr>, Box<Expr>),
1043 Add(Box<Expr>, Box<Expr>),
1044 Sub(Box<Expr>, Box<Expr>),
1045 Gt(Box<Expr>, Box<Expr>),
1046 Gte(Box<Expr>, Box<Expr>),
1047 Lt(Box<Expr>, Box<Expr>),
1048 Lte(Box<Expr>, Box<Expr>),
1049 Contains {
1050 collection: Box<Expr>,
1051 value: Box<Expr>,
1052 },
1053 MapContainsKey {
1054 map: Box<Expr>,
1055 key: Box<Expr>,
1056 },
1057 SeqStartsWith {
1058 seq: Box<Expr>,
1059 prefix: Box<Expr>,
1060 },
1061 SeqElements(Box<Expr>),
1062 Len(Box<Expr>),
1063 Count {
1064 collection: Box<Expr>,
1065 value: Box<Expr>,
1066 },
1067 Head(Box<Expr>),
1068 MapKeys(Box<Expr>),
1069 MapGet {
1070 map: Box<Expr>,
1071 key: Box<Expr>,
1072 },
1073 Some(Box<Expr>),
1074 Call {
1075 helper: String,
1076 args: Vec<Expr>,
1077 },
1078 Quantified {
1079 quantifier: Quantifier,
1080 binding: String,
1081 over: Box<Expr>,
1082 body: Box<Expr>,
1083 },
1084}
1085
1086impl Expr {
1087 #[allow(clippy::too_many_arguments)]
1088 fn validate(
1089 &self,
1090 phase_names: &IndexSet<&str>,
1091 field_names: &IndexSet<&str>,
1092 input_variants: &IndexSet<&str>,
1093 signal_variants: &IndexSet<&str>,
1094 effect_variants: &IndexSet<&str>,
1095 helper_names: &IndexSet<&str>,
1096 bindings: &IndexSet<&str>,
1097 ) -> Result<(), MachineSchemaError> {
1098 match self {
1099 Self::Bool(_)
1100 | Self::U64(_)
1101 | Self::U64Max
1102 | Self::String(_)
1103 | Self::NamedVariant { .. }
1104 | Self::EmptySet
1105 | Self::EmptyMap
1106 | Self::None
1107 | Self::CurrentPhase => {}
1108 Self::SeqLiteral(items) => {
1109 for item in items {
1110 item.validate(
1111 phase_names,
1112 field_names,
1113 input_variants,
1114 signal_variants,
1115 effect_variants,
1116 helper_names,
1117 bindings,
1118 )?;
1119 }
1120 }
1121 Self::Phase(phase) => {
1122 if !phase_names.contains(phase.as_str()) {
1123 return Err(MachineSchemaError::UnknownPhase {
1124 phase: phase.as_str().to_owned(),
1125 });
1126 }
1127 }
1128 Self::Field(field) => {
1129 if !field_names.contains(field.as_str()) {
1130 return Err(MachineSchemaError::UnknownField {
1131 field: field.as_str().to_owned(),
1132 });
1133 }
1134 }
1135 Self::Binding(binding) => {
1136 if !bindings.contains(binding.as_str()) {
1137 return Err(MachineSchemaError::UnknownBinding {
1138 binding: binding.clone(),
1139 });
1140 }
1141 }
1142 Self::Variant(variant) => {
1143 if !input_variants.contains(variant.as_str())
1144 && !signal_variants.contains(variant.as_str())
1145 && !effect_variants.contains(variant.as_str())
1146 {
1147 return Err(MachineSchemaError::UnknownVariant {
1148 variant: variant.clone(),
1149 });
1150 }
1151 }
1152 Self::FieldAccess { base, .. }
1153 | Self::EnumVariantIs { value: base, .. }
1154 | Self::EnumStringSetPayload { value: base, .. } => {
1155 base.validate(
1156 phase_names,
1157 field_names,
1158 input_variants,
1159 signal_variants,
1160 effect_variants,
1161 helper_names,
1162 bindings,
1163 )?;
1164 }
1165 Self::IfElse {
1166 condition,
1167 then_expr,
1168 else_expr,
1169 } => {
1170 condition.validate(
1171 phase_names,
1172 field_names,
1173 input_variants,
1174 signal_variants,
1175 effect_variants,
1176 helper_names,
1177 bindings,
1178 )?;
1179 then_expr.validate(
1180 phase_names,
1181 field_names,
1182 input_variants,
1183 signal_variants,
1184 effect_variants,
1185 helper_names,
1186 bindings,
1187 )?;
1188 else_expr.validate(
1189 phase_names,
1190 field_names,
1191 input_variants,
1192 signal_variants,
1193 effect_variants,
1194 helper_names,
1195 bindings,
1196 )?;
1197 }
1198 Self::Not(inner)
1199 | Self::Len(inner)
1200 | Self::Head(inner)
1201 | Self::MapKeys(inner)
1202 | Self::Some(inner) => inner.validate(
1203 phase_names,
1204 field_names,
1205 input_variants,
1206 signal_variants,
1207 effect_variants,
1208 helper_names,
1209 bindings,
1210 )?,
1211 Self::And(items) | Self::Or(items) => {
1212 for item in items {
1213 item.validate(
1214 phase_names,
1215 field_names,
1216 input_variants,
1217 signal_variants,
1218 effect_variants,
1219 helper_names,
1220 bindings,
1221 )?;
1222 }
1223 }
1224 Self::Eq(left, right)
1225 | Self::Neq(left, right)
1226 | Self::Add(left, right)
1227 | Self::Sub(left, right)
1228 | Self::Gt(left, right)
1229 | Self::Gte(left, right)
1230 | Self::Lt(left, right)
1231 | Self::Lte(left, right) => {
1232 left.validate(
1233 phase_names,
1234 field_names,
1235 input_variants,
1236 signal_variants,
1237 effect_variants,
1238 helper_names,
1239 bindings,
1240 )?;
1241 right.validate(
1242 phase_names,
1243 field_names,
1244 input_variants,
1245 signal_variants,
1246 effect_variants,
1247 helper_names,
1248 bindings,
1249 )?;
1250 }
1251 Self::Contains { collection, value } => {
1252 collection.validate(
1253 phase_names,
1254 field_names,
1255 input_variants,
1256 signal_variants,
1257 effect_variants,
1258 helper_names,
1259 bindings,
1260 )?;
1261 value.validate(
1262 phase_names,
1263 field_names,
1264 input_variants,
1265 signal_variants,
1266 effect_variants,
1267 helper_names,
1268 bindings,
1269 )?;
1270 }
1271 Self::Count { collection, value } => {
1272 collection.validate(
1273 phase_names,
1274 field_names,
1275 input_variants,
1276 signal_variants,
1277 effect_variants,
1278 helper_names,
1279 bindings,
1280 )?;
1281 value.validate(
1282 phase_names,
1283 field_names,
1284 input_variants,
1285 signal_variants,
1286 effect_variants,
1287 helper_names,
1288 bindings,
1289 )?;
1290 }
1291 Self::MapContainsKey { map, key } => {
1292 map.validate(
1293 phase_names,
1294 field_names,
1295 input_variants,
1296 signal_variants,
1297 effect_variants,
1298 helper_names,
1299 bindings,
1300 )?;
1301 key.validate(
1302 phase_names,
1303 field_names,
1304 input_variants,
1305 signal_variants,
1306 effect_variants,
1307 helper_names,
1308 bindings,
1309 )?;
1310 }
1311 Self::SeqStartsWith { seq, prefix } => {
1312 seq.validate(
1313 phase_names,
1314 field_names,
1315 input_variants,
1316 signal_variants,
1317 effect_variants,
1318 helper_names,
1319 bindings,
1320 )?;
1321 prefix.validate(
1322 phase_names,
1323 field_names,
1324 input_variants,
1325 signal_variants,
1326 effect_variants,
1327 helper_names,
1328 bindings,
1329 )?;
1330 }
1331 Self::SeqElements(inner) => {
1332 inner.validate(
1333 phase_names,
1334 field_names,
1335 input_variants,
1336 signal_variants,
1337 effect_variants,
1338 helper_names,
1339 bindings,
1340 )?;
1341 }
1342 Self::MapGet { map, key } => {
1343 map.validate(
1344 phase_names,
1345 field_names,
1346 input_variants,
1347 signal_variants,
1348 effect_variants,
1349 helper_names,
1350 bindings,
1351 )?;
1352 key.validate(
1353 phase_names,
1354 field_names,
1355 input_variants,
1356 signal_variants,
1357 effect_variants,
1358 helper_names,
1359 bindings,
1360 )?;
1361 }
1362 Self::Call { helper, args } => {
1363 if !helper_names.contains(helper.as_str())
1364 && !NATIVE_MOB_MACHINE_HELPERS.contains(&helper.as_str())
1365 {
1366 return Err(MachineSchemaError::UnknownHelper {
1367 helper: helper.clone(),
1368 });
1369 }
1370 for arg in args {
1371 arg.validate(
1372 phase_names,
1373 field_names,
1374 input_variants,
1375 signal_variants,
1376 effect_variants,
1377 helper_names,
1378 bindings,
1379 )?;
1380 }
1381 }
1382 Self::Quantified {
1383 binding,
1384 over,
1385 body,
1386 ..
1387 } => {
1388 over.validate(
1389 phase_names,
1390 field_names,
1391 input_variants,
1392 signal_variants,
1393 effect_variants,
1394 helper_names,
1395 bindings,
1396 )?;
1397 let mut nested_bindings = bindings.clone();
1398 nested_bindings.insert(binding.as_str());
1399 body.validate(
1400 phase_names,
1401 field_names,
1402 input_variants,
1403 signal_variants,
1404 effect_variants,
1405 helper_names,
1406 &nested_bindings,
1407 )?;
1408 }
1409 }
1410 Ok(())
1411 }
1412}
1413
1414fn validate_named_type_binding_payload(
1415 binding: &NamedTypeBinding,
1416) -> Result<(), MachineSchemaError> {
1417 match &binding.rust {
1418 RustTypeAtom::StringEnum { variants } => {
1419 validate_named_variant_domain(binding.name.as_str(), variants)
1420 }
1421 RustTypeAtom::TypePathEnum {
1422 unit_variants,
1423 structural_variants,
1424 ..
1425 } => {
1426 let variants = unit_variants
1427 .iter()
1428 .chain(structural_variants.iter().map(|variant| &variant.variant))
1429 .cloned()
1430 .collect::<Vec<_>>();
1431 validate_named_variant_domain(binding.name.as_str(), &variants)?;
1432 validate_type_path_enum_structural_variants(binding.name.as_str(), structural_variants)
1433 }
1434 RustTypeAtom::TypePathFieldPresenceSet { fields, .. } => {
1435 validate_type_path_field_presence_set(binding.name.as_str(), fields)
1436 }
1437 RustTypeAtom::TypePathStruct { fields, .. } => {
1438 validate_type_path_struct(binding.name.as_str(), fields)
1439 }
1440 _ => Ok(()),
1441 }
1442}
1443
1444fn validate_type_path_field_presence_set(
1445 name: &str,
1446 fields: &[FieldId],
1447) -> Result<(), MachineSchemaError> {
1448 if fields.is_empty() {
1449 return Err(MachineSchemaError::InvalidStringEnumBinding {
1450 name: name.to_owned(),
1451 reason: "field-presence named-type binding must define at least one field".to_owned(),
1452 });
1453 }
1454 let mut seen: IndexSet<&str> = IndexSet::new();
1455 for field in fields {
1456 if !seen.insert(field.as_str()) {
1457 return Err(MachineSchemaError::InvalidStringEnumBinding {
1458 name: name.to_owned(),
1459 reason: format!("field-presence binding defines duplicate field `{field}`"),
1460 });
1461 }
1462 }
1463 Ok(())
1464}
1465
1466fn validate_type_path_struct(
1467 name: &str,
1468 fields: &[crate::TypePathStructField],
1469) -> Result<(), MachineSchemaError> {
1470 if fields.is_empty() {
1471 return Err(MachineSchemaError::InvalidStringEnumBinding {
1472 name: name.to_owned(),
1473 reason: "struct named-type binding must define at least one field".to_owned(),
1474 });
1475 }
1476 let mut seen: IndexSet<&str> = IndexSet::new();
1477 for field in fields {
1478 if !seen.insert(field.name.as_str()) {
1479 return Err(MachineSchemaError::InvalidStringEnumBinding {
1480 name: name.to_owned(),
1481 reason: format!("struct binding defines duplicate field `{}`", field.name),
1482 });
1483 }
1484 }
1485 Ok(())
1486}
1487
1488fn validate_type_path_enum_structural_variants(
1489 name: &str,
1490 variants: &[crate::TypePathEnumStructuralVariant],
1491) -> Result<(), MachineSchemaError> {
1492 for variant in variants {
1493 if variant.fields.is_empty() {
1494 return Err(MachineSchemaError::InvalidStringEnumBinding {
1495 name: name.to_owned(),
1496 reason: format!(
1497 "structural variant `{}` must define at least one payload field",
1498 variant.variant
1499 ),
1500 });
1501 }
1502 let mut fields: IndexSet<&str> = IndexSet::new();
1503 for field in &variant.fields {
1504 if !fields.insert(field.name.as_str()) {
1505 return Err(MachineSchemaError::InvalidStringEnumBinding {
1506 name: name.to_owned(),
1507 reason: format!(
1508 "structural variant `{}` defines duplicate payload field `{}`",
1509 variant.variant, field.name
1510 ),
1511 });
1512 }
1513 }
1514 }
1515 Ok(())
1516}
1517
1518fn validate_named_variant_domain(
1519 name: &str,
1520 variants: &[EnumVariantId],
1521) -> Result<(), MachineSchemaError> {
1522 if variants.is_empty() {
1523 return Err(MachineSchemaError::InvalidStringEnumBinding {
1524 name: name.to_owned(),
1525 reason: "must define at least one variant".to_owned(),
1526 });
1527 }
1528
1529 let mut seen_values: IndexSet<&str> = IndexSet::new();
1530 let mut seen_rust_idents: IndexMap<String, &str> = IndexMap::new();
1531 for variant in variants {
1532 let raw = variant.as_str();
1533 if !seen_values.insert(raw) {
1534 return Err(MachineSchemaError::InvalidStringEnumBinding {
1535 name: name.to_owned(),
1536 reason: format!("defines duplicate variant `{raw}`"),
1537 });
1538 }
1539
1540 let rust_identifier = string_enum_variant_rust_ident(raw);
1541 if let Some(first) = seen_rust_idents.get(&rust_identifier) {
1542 return Err(MachineSchemaError::InvalidStringEnumBinding {
1543 name: name.to_owned(),
1544 reason: format!(
1545 "variants `{first}` and `{raw}` sanitize to duplicate Rust identifier `{rust_identifier}`"
1546 ),
1547 });
1548 }
1549 seen_rust_idents.insert(rust_identifier, raw);
1550 }
1551
1552 Ok(())
1553}
1554
1555fn string_enum_variant_rust_ident(value: &str) -> String {
1556 value
1557 .chars()
1558 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
1559 .collect()
1560}
1561
1562fn collect_named_type_references_type(ty: &TypeRef, out: &mut IndexSet<String>) {
1564 match ty {
1565 TypeRef::Bool | TypeRef::U32 | TypeRef::U64 | TypeRef::String | TypeRef::Enum(_) => {}
1566 TypeRef::Named(id) => {
1567 out.insert(id.as_str().to_owned());
1568 }
1569 TypeRef::Option(inner) | TypeRef::Set(inner) | TypeRef::Seq(inner) => {
1570 collect_named_type_references_type(inner, out);
1571 }
1572 TypeRef::Map(key, value) => {
1573 collect_named_type_references_type(key, out);
1574 collect_named_type_references_type(value, out);
1575 }
1576 }
1577}
1578
1579pub(crate) fn collect_named_type_references_machine(
1583 schema: &MachineSchema,
1584 out: &mut IndexSet<String>,
1585) {
1586 for field in &schema.state.fields {
1587 collect_named_type_references_type(&field.ty, out);
1588 }
1589 for enum_schema in [&schema.inputs, &schema.signals, &schema.effects] {
1590 for variant in &enum_schema.variants {
1591 for field in &variant.fields {
1592 collect_named_type_references_type(&field.ty, out);
1593 }
1594 }
1595 }
1596 for helper in schema.helpers.iter().chain(schema.derived.iter()) {
1597 for param in &helper.params {
1598 collect_named_type_references_type(¶m.ty, out);
1599 }
1600 collect_named_type_references_type(&helper.returns, out);
1601 }
1602}
1603
1604fn collect_named_type_references_binding(binding: &NamedTypeBinding, out: &mut IndexSet<String>) {
1605 if let RustTypeAtom::TypePathStruct { fields, .. } = &binding.rust {
1606 for field in fields {
1607 match &field.atom {
1608 crate::TypePathStructFieldAtom::Named(name)
1609 | crate::TypePathStructFieldAtom::OptionalNamed(name) => {
1610 out.insert(name.as_str().to_owned());
1611 }
1612 crate::TypePathStructFieldAtom::String => {}
1613 }
1614 }
1615 }
1616}
1617
1618fn collect_enum_type_references_type(ty: &TypeRef, out: &mut IndexSet<String>) {
1619 match ty {
1620 TypeRef::Enum(id) => {
1621 out.insert(id.as_str().to_owned());
1622 }
1623 TypeRef::Option(inner) | TypeRef::Set(inner) | TypeRef::Seq(inner) => {
1624 collect_enum_type_references_type(inner, out);
1625 }
1626 TypeRef::Map(key, value) => {
1627 collect_enum_type_references_type(key, out);
1628 collect_enum_type_references_type(value, out);
1629 }
1630 TypeRef::Bool | TypeRef::U32 | TypeRef::U64 | TypeRef::String | TypeRef::Named(_) => {}
1631 }
1632}
1633
1634fn collect_enum_type_references_machine(schema: &MachineSchema, out: &mut IndexSet<String>) {
1635 for field in &schema.state.fields {
1636 collect_enum_type_references_type(&field.ty, out);
1637 }
1638 for enum_schema in [&schema.inputs, &schema.signals, &schema.effects] {
1639 for variant in &enum_schema.variants {
1640 for field in &variant.fields {
1641 collect_enum_type_references_type(&field.ty, out);
1642 }
1643 }
1644 }
1645 for helper in schema.helpers.iter().chain(schema.derived.iter()) {
1646 for param in &helper.params {
1647 collect_enum_type_references_type(¶m.ty, out);
1648 }
1649 collect_enum_type_references_type(&helper.returns, out);
1650 }
1651}
1652
1653fn validate_string_enum_named_variants_machine(
1654 schema: &MachineSchema,
1655) -> Result<(), MachineSchemaError> {
1656 for init in &schema.state.init.fields {
1657 validate_string_enum_named_variants_expr(schema, &init.expr)?;
1658 }
1659 for invariant in &schema.invariants {
1660 validate_string_enum_named_variants_expr(schema, &invariant.expr)?;
1661 }
1662 for helper in schema.helpers.iter().chain(schema.derived.iter()) {
1663 validate_string_enum_named_variants_expr(schema, &helper.body)?;
1664 }
1665 for transition in &schema.transitions {
1666 for guard in &transition.guards {
1667 validate_string_enum_named_variants_expr(schema, &guard.expr)?;
1668 }
1669 for update in &transition.updates {
1670 validate_string_enum_named_variants_update(schema, update)?;
1671 }
1672 for effect in &transition.emit {
1673 for expr in effect.fields.values() {
1674 validate_string_enum_named_variants_expr(schema, expr)?;
1675 }
1676 }
1677 }
1678 Ok(())
1679}
1680
1681fn validate_string_enum_named_variants_update(
1682 schema: &MachineSchema,
1683 update: &Update,
1684) -> Result<(), MachineSchemaError> {
1685 match update {
1686 Update::Assign { expr, .. } => validate_string_enum_named_variants_expr(schema, expr)?,
1687 Update::Increment { .. } | Update::Decrement { .. } | Update::SeqPopFront { .. } => {}
1688 Update::MapInsert { key, value, .. } => {
1689 validate_string_enum_named_variants_expr(schema, key)?;
1690 validate_string_enum_named_variants_expr(schema, value)?;
1691 }
1692 Update::MapIncrement { key, .. }
1693 | Update::MapDecrement { key, .. }
1694 | Update::MapRemove { key, .. } => validate_string_enum_named_variants_expr(schema, key)?,
1695 Update::SetInsert { value, .. }
1696 | Update::SetRemove { value, .. }
1697 | Update::SeqAppend { value, .. }
1698 | Update::SeqRemoveValue { value, .. } => {
1699 validate_string_enum_named_variants_expr(schema, value)?;
1700 }
1701 Update::SeqPrepend { values, .. } | Update::SeqRemoveAll { values, .. } => {
1702 validate_string_enum_named_variants_expr(schema, values)?;
1703 }
1704 Update::Conditional {
1705 condition,
1706 then_updates,
1707 else_updates,
1708 } => {
1709 validate_string_enum_named_variants_expr(schema, condition)?;
1710 for nested in then_updates.iter().chain(else_updates.iter()) {
1711 validate_string_enum_named_variants_update(schema, nested)?;
1712 }
1713 }
1714 Update::ForEach { over, updates, .. } => {
1715 validate_string_enum_named_variants_expr(schema, over)?;
1716 for nested in updates {
1717 validate_string_enum_named_variants_update(schema, nested)?;
1718 }
1719 }
1720 }
1721 Ok(())
1722}
1723
1724fn validate_string_enum_named_variants_expr(
1725 schema: &MachineSchema,
1726 expr: &Expr,
1727) -> Result<(), MachineSchemaError> {
1728 match expr {
1729 Expr::NamedVariant { enum_name, variant } => {
1730 let named_type_name = NamedTypeId::parse(enum_name.as_str()).map_err(|_| {
1731 MachineSchemaError::InvalidStringEnumBinding {
1732 name: enum_name.as_str().to_owned(),
1733 reason: "Expr::NamedVariant enum domains must be valid NamedTypeId entries"
1734 .to_owned(),
1735 }
1736 })?;
1737 let Some(binding) = schema.named_type_binding(&named_type_name) else {
1738 return Err(MachineSchemaError::MissingStringEnumBinding {
1739 name: enum_name.as_str().to_owned(),
1740 });
1741 };
1742 match &binding.rust {
1743 RustTypeAtom::StringEnum { variants } => {
1744 if !variants.iter().any(|allowed| allowed == variant) {
1745 return Err(MachineSchemaError::UnknownStringEnumVariant {
1746 enum_name: enum_name.as_str().to_owned(),
1747 variant: variant.as_str().to_owned(),
1748 });
1749 }
1750 }
1751 RustTypeAtom::TypePathEnum { unit_variants, .. } => {
1752 if !unit_variants.iter().any(|allowed| allowed == variant) {
1753 return Err(MachineSchemaError::UnknownStringEnumVariant {
1754 enum_name: enum_name.as_str().to_owned(),
1755 variant: variant.as_str().to_owned(),
1756 });
1757 }
1758 }
1759 _ => {
1760 return Err(MachineSchemaError::InvalidStringEnumBinding {
1761 name: enum_name.as_str().to_owned(),
1762 reason:
1763 "Expr::NamedVariant domains must use RustTypeAtom::StringEnum or RustTypeAtom::TypePathEnum"
1764 .to_owned(),
1765 });
1766 }
1767 }
1768 }
1769 Expr::SeqLiteral(items) | Expr::And(items) | Expr::Or(items) => {
1770 for item in items {
1771 validate_string_enum_named_variants_expr(schema, item)?;
1772 }
1773 }
1774 Expr::IfElse {
1775 condition,
1776 then_expr,
1777 else_expr,
1778 } => {
1779 validate_string_enum_named_variants_expr(schema, condition)?;
1780 validate_string_enum_named_variants_expr(schema, then_expr)?;
1781 validate_string_enum_named_variants_expr(schema, else_expr)?;
1782 }
1783 Expr::Not(inner)
1784 | Expr::Len(inner)
1785 | Expr::Head(inner)
1786 | Expr::MapKeys(inner)
1787 | Expr::SeqElements(inner)
1788 | Expr::Some(inner)
1789 | Expr::FieldAccess { base: inner, .. }
1790 | Expr::EnumVariantIs { value: inner, .. }
1791 | Expr::EnumStringSetPayload { value: inner, .. } => {
1792 validate_string_enum_named_variants_expr(schema, inner)?;
1793 }
1794 Expr::Eq(left, right)
1795 | Expr::Neq(left, right)
1796 | Expr::Add(left, right)
1797 | Expr::Sub(left, right)
1798 | Expr::Gt(left, right)
1799 | Expr::Gte(left, right)
1800 | Expr::Lt(left, right)
1801 | Expr::Lte(left, right) => {
1802 validate_string_enum_named_variants_expr(schema, left)?;
1803 validate_string_enum_named_variants_expr(schema, right)?;
1804 }
1805 Expr::Contains { collection, value } => {
1806 validate_string_enum_named_variants_expr(schema, collection)?;
1807 validate_string_enum_named_variants_expr(schema, value)?;
1808 }
1809 Expr::Count { collection, value } => {
1810 validate_string_enum_named_variants_expr(schema, collection)?;
1811 validate_string_enum_named_variants_expr(schema, value)?;
1812 }
1813 Expr::MapContainsKey { map, key } | Expr::MapGet { map, key } => {
1814 validate_string_enum_named_variants_expr(schema, map)?;
1815 validate_string_enum_named_variants_expr(schema, key)?;
1816 }
1817 Expr::SeqStartsWith { seq, prefix } => {
1818 validate_string_enum_named_variants_expr(schema, seq)?;
1819 validate_string_enum_named_variants_expr(schema, prefix)?;
1820 }
1821 Expr::Call { args, .. } => {
1822 for arg in args {
1823 validate_string_enum_named_variants_expr(schema, arg)?;
1824 }
1825 }
1826 Expr::Quantified { over, body, .. } => {
1827 validate_string_enum_named_variants_expr(schema, over)?;
1828 validate_string_enum_named_variants_expr(schema, body)?;
1829 }
1830 Expr::Bool(_)
1831 | Expr::U64(_)
1832 | Expr::U64Max
1833 | Expr::String(_)
1834 | Expr::EmptySet
1835 | Expr::EmptyMap
1836 | Expr::CurrentPhase
1837 | Expr::Phase(_)
1838 | Expr::Field(_)
1839 | Expr::Binding(_)
1840 | Expr::Variant(_)
1841 | Expr::None => {}
1842 }
1843 Ok(())
1844}
1845
1846fn unique_names<'a>(
1847 names: impl IntoIterator<Item = &'a str>,
1848 kind: &'static str,
1849) -> Result<IndexSet<&'a str>, MachineSchemaError> {
1850 let mut seen = IndexSet::new();
1851 for name in names {
1852 if name.is_empty() {
1853 return Err(MachineSchemaError::EmptyName(kind));
1854 }
1855 if !seen.insert(name) {
1856 return Err(MachineSchemaError::DuplicateName {
1857 kind,
1858 name: name.to_owned(),
1859 });
1860 }
1861 }
1862 Ok(seen)
1863}
1864
1865#[derive(Debug, PartialEq, Eq)]
1866pub enum MachineSchemaError {
1867 DuplicateName { kind: &'static str, name: String },
1868 EmptyName(&'static str),
1869 UnknownPhase { phase: String },
1870 UnknownField { field: String },
1871 MissingInitializer { field: String },
1872 UnknownInputVariant { variant: String },
1873 UnknownSurfaceOnlyInputVariant { variant: String },
1874 UnknownRuntimeInternalInputVariant { variant: String },
1875 UnknownSignalVariant { variant: String },
1876 UnknownEffectVariant { variant: String },
1877 UnknownHelper { helper: String },
1878 UnknownBinding { binding: String },
1879 UnknownVariant { variant: String },
1880 UnknownVariantField { variant: String, field: String },
1881 UnknownEffectDispositionVariant { variant: String },
1882 DuplicateEffectDisposition { variant: String },
1883 MissingEffectDisposition { variant: String },
1884 HandoffProtocolOnRoutedEffect { variant: String },
1885 SurfaceOnlyInputHasTransition { variant: String, transition: String },
1886 DuplicateNamedTypeBinding { name: String },
1887 MissingNamedTypeBinding { name: String },
1888 MissingStringEnumBinding { name: String },
1889 UnknownStringEnumVariant { enum_name: String, variant: String },
1890 InvalidStringEnumBinding { name: String, reason: String },
1891 UnknownCommandPlanInput { plan: String, input: String },
1892 UnknownCommandPlanSignal { plan: String, signal: String },
1893 UnknownCommandPlanTransition { plan: String, transition: String },
1894 UnknownCommandPlanEffect { plan: String, effect: String },
1895 UnknownCommandPlanClosureEffect { plan: String, effect: String },
1896}
1897
1898impl fmt::Display for MachineSchemaError {
1899 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1900 match self {
1901 Self::DuplicateName { kind, name } => write!(f, "duplicate {kind} name `{name}`"),
1902 Self::EmptyName(kind) => write!(f, "empty {kind} name"),
1903 Self::UnknownPhase { phase } => write!(f, "unknown phase `{phase}`"),
1904 Self::UnknownField { field } => write!(f, "unknown field `{field}`"),
1905 Self::MissingInitializer { field } => write!(
1906 f,
1907 "state field `{field}` has no `state.init.fields` initializer; \
1908 declare a typed initial fact (it must not rely on the runtime \
1909 type-generic auto-seed / `_Unset` sentinel)"
1910 ),
1911 Self::UnknownInputVariant { variant } => {
1912 write!(f, "unknown input variant `{variant}`")
1913 }
1914 Self::UnknownSurfaceOnlyInputVariant { variant } => {
1915 write!(f, "unknown surface-only input variant `{variant}`")
1916 }
1917 Self::UnknownRuntimeInternalInputVariant { variant } => {
1918 write!(f, "unknown runtime-internal input variant `{variant}`")
1919 }
1920 Self::UnknownSignalVariant { variant } => {
1921 write!(f, "unknown signal variant `{variant}`")
1922 }
1923 Self::UnknownEffectVariant { variant } => {
1924 write!(f, "unknown effect variant `{variant}`")
1925 }
1926 Self::UnknownHelper { helper } => write!(f, "unknown helper `{helper}`"),
1927 Self::UnknownBinding { binding } => write!(f, "unknown binding `{binding}`"),
1928 Self::UnknownVariant { variant } => write!(f, "unknown variant `{variant}`"),
1929 Self::UnknownVariantField { variant, field } => {
1930 write!(f, "unknown field `{field}` on variant `{variant}`")
1931 }
1932 Self::UnknownEffectDispositionVariant { variant } => {
1933 write!(
1934 f,
1935 "effect disposition references unknown effect variant `{variant}`"
1936 )
1937 }
1938 Self::DuplicateEffectDisposition { variant } => {
1939 write!(f, "duplicate effect disposition for variant `{variant}`")
1940 }
1941 Self::MissingEffectDisposition { variant } => {
1942 write!(f, "effect variant `{variant}` has no disposition rule")
1943 }
1944 Self::HandoffProtocolOnRoutedEffect { variant } => {
1945 write!(
1946 f,
1947 "effect variant `{variant}` has handoff_protocol set but disposition is Routed (use routes instead)"
1948 )
1949 }
1950 Self::SurfaceOnlyInputHasTransition {
1951 variant,
1952 transition,
1953 } => {
1954 write!(
1955 f,
1956 "surface-only input `{variant}` must not have transition `{transition}`"
1957 )
1958 }
1959 Self::DuplicateNamedTypeBinding { name } => {
1960 write!(f, "duplicate named-type binding for `{name}`")
1961 }
1962 Self::MissingNamedTypeBinding { name } => {
1963 write!(
1964 f,
1965 "named type `{name}` is referenced by this schema but has no NamedTypeBinding entry in `named_types`"
1966 )
1967 }
1968 Self::MissingStringEnumBinding { name } => {
1969 write!(
1970 f,
1971 "enum type `{name}` is referenced by this schema but has no StringEnum NamedTypeBinding entry in `named_types`"
1972 )
1973 }
1974 Self::UnknownStringEnumVariant { enum_name, variant } => {
1975 write!(
1976 f,
1977 "string enum `{enum_name}` does not define variant `{variant}`"
1978 )
1979 }
1980 Self::InvalidStringEnumBinding { name, reason } => {
1981 write!(
1982 f,
1983 "invalid string enum named-type binding `{name}`: {reason}"
1984 )
1985 }
1986 Self::UnknownCommandPlanInput { plan, input } => {
1987 write!(
1988 f,
1989 "command plan `{plan}` references unknown input `{input}`"
1990 )
1991 }
1992 Self::UnknownCommandPlanSignal { plan, signal } => {
1993 write!(
1994 f,
1995 "command plan `{plan}` references unknown signal `{signal}`"
1996 )
1997 }
1998 Self::UnknownCommandPlanTransition { plan, transition } => {
1999 write!(
2000 f,
2001 "command plan `{plan}` references unknown transition `{transition}`"
2002 )
2003 }
2004 Self::UnknownCommandPlanEffect { plan, effect } => {
2005 write!(
2006 f,
2007 "command plan `{plan}` references unknown effect `{effect}`"
2008 )
2009 }
2010 Self::UnknownCommandPlanClosureEffect { plan, effect } => {
2011 write!(
2012 f,
2013 "command plan `{plan}` declares closure for `{effect}` without listing it as a command effect"
2014 )
2015 }
2016 }
2017 }
2018}
2019
2020impl std::error::Error for MachineSchemaError {}
2021
2022#[cfg(test)]
2023#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
2024mod tests {
2025 use crate::identity::{EffectVariantId, EnumTypeId, EnumVariantId, NamedTypeId};
2026 use crate::{
2027 Expr, InvariantSchema, MachineSchema, MachineSchemaError, NamedTypeBinding, RustTypeAtom,
2028 Update, catalog::dsl::dsl_meerkat_machine as meerkat_machine,
2029 catalog::dsl::dsl_mob_machine as mob_machine,
2030 };
2031
2032 #[test]
2033 fn validates_meerkat_machine_schema() {
2034 let schema = meerkat_machine();
2035
2036 assert_eq!(schema.machine.as_str(), "MeerkatMachine");
2037 assert_eq!(schema.rust.crate_name, "self");
2041 assert_eq!(schema.rust.module, "catalog::dsl::meerkat_machine");
2042 assert_eq!(schema.state.phase.name, "MeerkatPhase");
2043 assert!(
2044 schema
2045 .transitions
2046 .iter()
2047 .any(|transition| transition.name.as_str() == "PrepareBindingsIdle")
2048 );
2049 assert!(
2050 schema
2051 .transitions
2052 .iter()
2053 .any(|transition| transition.name.as_str() == "Destroy")
2054 );
2055 assert_eq!(
2056 schema
2057 .state
2058 .terminal_phases
2059 .iter()
2060 .map(|phase| phase.as_str().to_owned())
2061 .collect::<Vec<_>>(),
2062 vec!["Destroyed".to_owned()]
2063 );
2064 assert_eq!(schema.validate(), Ok(()));
2065 }
2066
2067 #[test]
2068 fn meerkat_queue_to_run_command_plans_are_schema_owned() {
2069 let schema = meerkat_machine();
2070 let plan_names = schema
2071 .command_plans
2072 .iter()
2073 .map(|plan| plan.name.as_str())
2074 .collect::<Vec<_>>();
2075 assert_eq!(
2076 plan_names,
2077 vec![
2078 "AuthorizedAcceptedInputMaterialization",
2079 "AuthorizeRuntimeLoopBatch",
2080 "AuthorizedStageForRun",
2081 "AuthorizedRuntimeLoopRunCommit",
2082 "AuthorizedRuntimeCompletionResultClosure"
2083 ]
2084 );
2085 let stage_plan = schema
2086 .command_plans
2087 .iter()
2088 .find(|plan| plan.name == "AuthorizedStageForRun")
2089 .expect("stage command plan");
2090 let stage_transitions = stage_plan
2091 .transitions
2092 .iter()
2093 .map(|transition| transition.as_str())
2094 .collect::<Vec<_>>();
2095 assert_eq!(
2096 stage_transitions,
2097 vec![
2098 "StageForRunIdle",
2099 "StageForRunAttached",
2100 "StageForRunRunning",
2101 "StageForRunRetired",
2102 "StageForRunStopped"
2103 ]
2104 );
2105 let guard_names = schema
2106 .transitions
2107 .iter()
2108 .find(|transition| transition.name.as_str() == "StageForRunIdle")
2109 .expect("StageForRunIdle transition")
2110 .guards
2111 .iter()
2112 .map(|guard| guard.name.as_str())
2113 .collect::<Vec<_>>();
2114 for required in [
2115 "input_queued",
2116 "input_lane_bound",
2117 "input_sequence_bound",
2118 "input_recovery_lane_bound",
2119 "input_not_run_associated",
2120 "current_run_matches",
2121 ] {
2122 assert!(
2123 guard_names.contains(&required),
2124 "StageForRun command-plan guard expansion must include {required}; got {guard_names:?}"
2125 );
2126 }
2127
2128 let run_commit_plan = schema
2129 .command_plans
2130 .iter()
2131 .find(|plan| plan.name == "AuthorizedRuntimeLoopRunCommit")
2132 .expect("runtime-loop run commit command plan");
2133 let run_commit_transitions = run_commit_plan
2134 .transitions
2135 .iter()
2136 .map(|transition| transition.as_str())
2137 .collect::<Vec<_>>();
2138 for required in [
2139 "RunCompleted",
2140 "RunFailed",
2141 "RunCancelled",
2142 "CommitRunningToIdle",
2143 "CommitRunningToAttached",
2144 "CommitRunningToRetired",
2145 "FailRunningToIdle",
2146 "CancelRunningToIdle",
2147 "RollbackRunRunningToIdle",
2148 ] {
2149 assert!(
2150 run_commit_transitions.contains(&required),
2151 "run-commit command plan must include {required}; got {run_commit_transitions:?}"
2152 );
2153 }
2154 let run_commit_effects = run_commit_plan
2155 .effects
2156 .iter()
2157 .map(|effect| effect.as_str())
2158 .collect::<Vec<_>>();
2159 assert_eq!(
2160 run_commit_effects,
2161 vec!["TurnRunCompleted", "TurnRunFailed", "TurnRunCancelled"]
2162 );
2163 let run_commit_closures = run_commit_plan
2164 .effect_closures
2165 .iter()
2166 .map(|closure| closure.effect.as_str())
2167 .collect::<Vec<_>>();
2168 assert_eq!(
2169 run_commit_closures,
2170 vec!["TurnRunCompleted", "TurnRunFailed", "TurnRunCancelled"]
2171 );
2172 for closure in &run_commit_plan.effect_closures {
2173 assert_eq!(closure.authority_type, "AuthorizedRuntimeLoopRunCommit");
2174 assert_eq!(closure.closure_policy, "RuntimeLoopRunCommitEffect");
2175 assert_eq!(
2176 closure.lifecycle,
2177 vec![
2178 "Authorized",
2179 "Attempted",
2180 "Realized",
2181 "Failed",
2182 "Cancelled",
2183 "Abandoned"
2184 ]
2185 );
2186 }
2187
2188 let completion_closure_plan = schema
2189 .command_plans
2190 .iter()
2191 .find(|plan| plan.name == "AuthorizedRuntimeCompletionResultClosure")
2192 .expect("runtime completion result closure command plan");
2193 assert_eq!(
2194 completion_closure_plan.effects,
2195 vec![EffectVariantId::parse("RuntimeCompletionResultResolved").unwrap()]
2196 );
2197 assert_eq!(completion_closure_plan.effect_closures.len(), 1);
2198 let closure = &completion_closure_plan.effect_closures[0];
2199 assert_eq!(closure.effect.as_str(), "RuntimeCompletionResultResolved");
2200 assert_eq!(closure.authority_type, "RuntimeCompletionResultAuthority");
2201 assert_eq!(closure.closure_policy, "LocalSurfaceResultAlignment");
2202 assert_eq!(
2203 closure.lifecycle,
2204 vec![
2205 "Authorized",
2206 "Attempted",
2207 "Realized",
2208 "Failed",
2209 "Cancelled",
2210 "Abandoned"
2211 ]
2212 );
2213 }
2214
2215 #[test]
2216 fn mob_spawn_command_plan_is_schema_owned() {
2217 let schema = mob_machine();
2218 let command_plan_names = schema
2219 .command_plans
2220 .iter()
2221 .map(|plan| plan.name.as_str())
2222 .collect::<Vec<_>>();
2223 for required in [
2224 "AuthorizedMobSpawnStart",
2225 "CanStartSpawn",
2226 "SpawnStarted",
2227 "SpawnEffect",
2228 "FailSpawn",
2229 ] {
2230 assert!(
2231 command_plan_names.contains(&required),
2232 "mob spawn command plans must include {required}; got {command_plan_names:?}"
2233 );
2234 }
2235 let spawn_plan = schema
2236 .command_plans
2237 .iter()
2238 .find(|plan| plan.name == "AuthorizedMobSpawnStart")
2239 .expect("mob spawn command plan");
2240
2241 assert_eq!(
2242 spawn_plan.authority_type,
2243 "PendingSpawnOperationOwnerAuthorized"
2244 );
2245 assert_eq!(
2246 spawn_plan
2247 .source_signals
2248 .iter()
2249 .map(|signal| signal.as_str())
2250 .collect::<Vec<_>>(),
2251 vec!["StageSpawn", "CompleteSpawn"]
2252 );
2253 assert_eq!(
2254 spawn_plan
2255 .source_inputs
2256 .iter()
2257 .map(|input| input.as_str())
2258 .collect::<Vec<_>>(),
2259 vec!["CancelPendingSpawn"]
2260 );
2261
2262 let transitions = spawn_plan
2263 .transitions
2264 .iter()
2265 .map(|transition| transition.as_str())
2266 .collect::<Vec<_>>();
2267 for required in [
2268 "StageSpawnRunning",
2269 "CompleteSpawnRunning",
2270 "CompleteSpawnLateArrivalRunning",
2271 "CompleteSpawnLateArrivalStopped",
2272 "CompleteSpawnLateArrivalCompleted",
2273 "CompleteSpawnDestroyed",
2274 "CancelPendingSpawnPresentRunning",
2275 "CancelPendingSpawnPresentStopped",
2276 "CancelPendingSpawnPresentCompleted",
2277 "CancelPendingSpawnAbsentRunning",
2278 "CancelPendingSpawnAbsentStopped",
2279 "CancelPendingSpawnAbsentCompleted",
2280 "CancelPendingSpawnDestroyed",
2281 ] {
2282 assert!(
2283 transitions.contains(&required),
2284 "mob spawn command plan must include {required}; got {transitions:?}"
2285 );
2286 }
2287
2288 let effects = spawn_plan
2289 .effects
2290 .iter()
2291 .map(|effect| effect.as_str())
2292 .collect::<Vec<_>>();
2293 assert_eq!(
2294 effects,
2295 vec![
2296 "PendingSpawnOperationOwnerAuthorized",
2297 "ExposePendingSpawn",
2298 "EmitMemberLifecycleNotice"
2299 ]
2300 );
2301 assert_eq!(spawn_plan.effect_closures.len(), 2);
2302 for closure in &spawn_plan.effect_closures {
2303 assert_eq!(
2304 closure.lifecycle,
2305 vec![
2306 "Authorized",
2307 "Attempted",
2308 "Realized",
2309 "Failed",
2310 "Cancelled",
2311 "Abandoned"
2312 ]
2313 );
2314 }
2315 assert!(
2316 spawn_plan
2317 .effect_closures
2318 .iter()
2319 .any(
2320 |closure| closure.effect.as_str() == "PendingSpawnOperationOwnerAuthorized"
2321 && closure.authority_type == "PendingSpawnOperationOwnerAuthorized"
2322 && closure.closure_policy == "LocalPendingSpawnOwner"
2323 ),
2324 "mob spawn plan must declare pending-owner closure metadata"
2325 );
2326 assert!(
2327 spawn_plan
2328 .effect_closures
2329 .iter()
2330 .any(
2331 |closure| closure.effect.as_str() == "EmitMemberLifecycleNotice"
2332 && closure.authority_type == "CompleteSpawn"
2333 && closure.closure_policy == "LocalSpawnCompletion"
2334 ),
2335 "mob spawn plan must declare completion closure metadata"
2336 );
2337 let start_plan = schema
2338 .command_plans
2339 .iter()
2340 .find(|plan| plan.name == "CanStartSpawn")
2341 .expect("CanStartSpawn command plan");
2342 assert_eq!(start_plan.authority_type, "CanStartSpawn");
2343 assert_eq!(
2344 start_plan
2345 .source_signals
2346 .iter()
2347 .map(|signal| signal.as_str())
2348 .collect::<Vec<_>>(),
2349 vec!["StageSpawn"]
2350 );
2351 let started_plan = schema
2352 .command_plans
2353 .iter()
2354 .find(|plan| plan.name == "SpawnStarted")
2355 .expect("SpawnStarted command plan");
2356 assert_eq!(started_plan.authority_type, "SpawnStarted");
2357 assert_eq!(
2358 started_plan
2359 .effects
2360 .iter()
2361 .map(|effect| effect.as_str())
2362 .collect::<Vec<_>>(),
2363 vec!["ExposePendingSpawn"]
2364 );
2365 let spawn_effect_plan = schema
2366 .command_plans
2367 .iter()
2368 .find(|plan| plan.name == "SpawnEffect")
2369 .expect("SpawnEffect command plan");
2370 assert_eq!(spawn_effect_plan.authority_type, "SpawnEffect");
2371 let fail_spawn_plan = schema
2372 .command_plans
2373 .iter()
2374 .find(|plan| plan.name == "FailSpawn")
2375 .expect("FailSpawn command plan");
2376 assert_eq!(fail_spawn_plan.authority_type, "FailSpawn");
2377 assert_eq!(
2378 fail_spawn_plan
2379 .source_inputs
2380 .iter()
2381 .map(|input| input.as_str())
2382 .collect::<Vec<_>>(),
2383 vec!["CancelPendingSpawn"]
2384 );
2385 }
2386
2387 #[test]
2388 fn validate_rejects_struct_binding_with_missing_nested_named_binding() {
2389 let mut schema = meerkat_machine();
2390 schema
2391 .named_types
2392 .retain(|binding| binding.name.as_str() != "ToolSourceKind");
2393
2394 assert_eq!(
2395 schema.validate(),
2396 Err(MachineSchemaError::MissingNamedTypeBinding {
2397 name: "ToolSourceKind".into(),
2398 })
2399 );
2400 }
2401
2402 #[test]
2403 fn validate_rejects_effect_declaring_machine_with_empty_dispositions() {
2404 let mut schema = meerkat_machine();
2410 assert_eq!(schema.validate(), Ok(()));
2411 schema.effect_dispositions.clear();
2412 assert!(
2413 matches!(
2414 schema.validate(),
2415 Err(MachineSchemaError::MissingEffectDisposition { .. })
2416 ),
2417 "empty dispositions on an effect-declaring machine must fail closed"
2418 );
2419 }
2420
2421 #[test]
2422 fn validate_rejects_state_field_without_initializer() {
2423 let mut schema = meerkat_machine();
2430 assert_eq!(schema.validate(), Ok(()));
2431
2432 let dropped = schema
2433 .state
2434 .fields
2435 .first()
2436 .expect("MeerkatMachine has at least one semantic state field")
2437 .name
2438 .as_str()
2439 .to_owned();
2440 schema
2441 .state
2442 .init
2443 .fields
2444 .retain(|initializer| initializer.field.as_str() != dropped);
2445
2446 assert_eq!(
2447 schema.validate(),
2448 Err(MachineSchemaError::MissingInitializer {
2449 field: dropped.clone(),
2450 }),
2451 "a declared state field with no initializer must fail closed (field `{dropped}`)"
2452 );
2453 }
2454
2455 fn replace_register_op_status_update(schema: &mut MachineSchema, status: &str) {
2456 let transition = schema
2457 .transitions
2458 .iter_mut()
2459 .find(|transition| transition.name.as_str() == "RegisterOpAcceptedIdle")
2460 .expect("RegisterOpAcceptedIdle transition");
2461 let update = transition
2462 .updates
2463 .iter_mut()
2464 .find(|update| {
2465 matches!(
2466 update,
2467 Update::MapInsert { field, .. } if field.as_str() == "op_statuses"
2468 )
2469 })
2470 .expect("op_statuses update");
2471 let Update::MapInsert { value, .. } = update else {
2472 panic!("op_statuses update must be a map insert");
2473 };
2474 *value = Expr::NamedVariant {
2475 enum_name: EnumTypeId::parse("OperationStatus").expect("enum type slug"),
2476 variant: EnumVariantId::parse(status).expect("enum variant slug"),
2477 };
2478 }
2479
2480 fn string_enum_binding(name: &str, variants: &[&str]) -> NamedTypeBinding {
2481 NamedTypeBinding {
2482 name: NamedTypeId::parse(name).expect("named type slug"),
2483 rust: RustTypeAtom::StringEnum {
2484 variants: variants
2485 .iter()
2486 .map(|variant| EnumVariantId::parse(*variant).expect("enum variant slug"))
2487 .collect(),
2488 },
2489 }
2490 }
2491
2492 #[test]
2493 fn validate_rejects_unknown_string_enum_named_variant_in_transition_update() {
2494 let mut schema = meerkat_machine();
2495 replace_register_op_status_update(&mut schema, "Launched");
2496
2497 let err = schema
2498 .validate()
2499 .expect_err("unknown OperationStatus variant must be rejected");
2500 let message = err.to_string();
2501 assert!(
2502 message.contains("OperationStatus") && message.contains("Launched"),
2503 "error should identify the invalid string enum variant, got: {message}"
2504 );
2505 }
2506
2507 #[test]
2508 fn validate_rejects_empty_string_enum_named_type_binding() {
2509 let mut schema = meerkat_machine();
2510 schema
2511 .named_types
2512 .push(string_enum_binding("SyntheticStatus", &[]));
2513
2514 let err = schema
2515 .validate()
2516 .expect_err("empty StringEnum bindings must be rejected");
2517 let message = err.to_string();
2518 assert!(
2519 message.contains("SyntheticStatus") && message.contains("at least one variant"),
2520 "error should identify the empty StringEnum binding, got: {message}"
2521 );
2522 }
2523
2524 #[test]
2525 fn validate_rejects_duplicate_string_enum_named_type_variants() {
2526 let mut schema = meerkat_machine();
2527 schema
2528 .named_types
2529 .push(string_enum_binding("SyntheticStatus", &["Ready", "Ready"]));
2530
2531 let err = schema
2532 .validate()
2533 .expect_err("duplicate StringEnum variants must be rejected");
2534 let message = err.to_string();
2535 assert!(
2536 message.contains("SyntheticStatus")
2537 && message.contains("Ready")
2538 && message.contains("duplicate"),
2539 "error should identify the duplicate StringEnum variant, got: {message}"
2540 );
2541 }
2542
2543 #[test]
2544 fn validate_rejects_string_enum_named_type_variant_ident_collisions() {
2545 let mut schema = meerkat_machine();
2546 schema.named_types.push(string_enum_binding(
2547 "SyntheticStatus",
2548 &["foo-bar", "foo_bar"],
2549 ));
2550
2551 let err = schema
2552 .validate()
2553 .expect_err("StringEnum variant Rust identifier collisions must be rejected");
2554 let message = err.to_string();
2555 assert!(
2556 message.contains("SyntheticStatus")
2557 && message.contains("foo-bar")
2558 && message.contains("foo_bar"),
2559 "error should identify colliding StringEnum variants, got: {message}"
2560 );
2561 }
2562
2563 #[test]
2564 fn validate_rejects_enum_type_without_string_enum_binding() {
2565 let mut schema = meerkat_machine();
2566 schema
2567 .named_types
2568 .retain(|binding| binding.name.as_str() != "OperationStatus");
2569
2570 assert_eq!(
2571 schema.validate(),
2572 Err(MachineSchemaError::MissingNamedTypeBinding {
2573 name: "OperationStatus".into(),
2574 })
2575 );
2576 }
2577
2578 #[test]
2579 fn validate_rejects_enum_type_with_unconstrained_string_binding() {
2580 let mut schema = meerkat_machine();
2581 let binding = schema
2582 .named_types
2583 .iter_mut()
2584 .find(|binding| binding.name.as_str() == "OperationStatus")
2585 .expect("OperationStatus binding");
2586 binding.rust = RustTypeAtom::String;
2587
2588 assert_eq!(
2589 schema.validate(),
2590 Err(MachineSchemaError::InvalidStringEnumBinding {
2591 name: "OperationStatus".into(),
2592 reason: "TypeRef::Enum domains must use RustTypeAtom::StringEnum".into(),
2593 })
2594 );
2595 }
2596
2597 #[test]
2598 fn validate_rejects_unbound_named_variant_literal_without_typed_surface_reference() {
2599 let mut schema = meerkat_machine();
2600 schema.invariants.push(InvariantSchema {
2601 name: "synthetic_unbound_enum_literal".to_owned(),
2602 expr: Expr::NamedVariant {
2603 enum_name: EnumTypeId::parse("SyntheticStatus").expect("enum type slug"),
2604 variant: EnumVariantId::parse("Ready").expect("enum variant slug"),
2605 },
2606 });
2607
2608 assert_eq!(
2609 schema.validate(),
2610 Err(MachineSchemaError::MissingStringEnumBinding {
2611 name: "SyntheticStatus".into(),
2612 })
2613 );
2614 }
2615
2616 #[test]
2617 fn validate_rejects_named_variant_literal_with_unconstrained_string_binding() {
2618 let mut schema = meerkat_machine();
2619 schema.invariants.push(InvariantSchema {
2620 name: "synthetic_string_bound_enum_literal".to_owned(),
2621 expr: Expr::NamedVariant {
2622 enum_name: EnumTypeId::parse("AgentRuntimeId").expect("enum type slug"),
2623 variant: EnumVariantId::parse("Ready").expect("enum variant slug"),
2624 },
2625 });
2626
2627 assert_eq!(
2628 schema.validate(),
2629 Err(MachineSchemaError::InvalidStringEnumBinding {
2630 name: "AgentRuntimeId".into(),
2631 reason: "Expr::NamedVariant domains must use RustTypeAtom::StringEnum or RustTypeAtom::TypePathEnum".into(),
2632 })
2633 );
2634 }
2635
2636 #[test]
2637 fn validate_rejects_unknown_type_path_enum_named_variant_literal() {
2638 let mut schema = meerkat_machine();
2639 schema.invariants.push(InvariantSchema {
2640 name: "synthetic_unknown_tool_filter_literal".to_owned(),
2641 expr: Expr::NamedVariant {
2642 enum_name: EnumTypeId::parse("ToolFilter").expect("enum type slug"),
2643 variant: EnumVariantId::parse("Bogus").expect("enum variant slug"),
2644 },
2645 });
2646
2647 assert_eq!(
2648 schema.validate(),
2649 Err(MachineSchemaError::UnknownStringEnumVariant {
2650 enum_name: "ToolFilter".into(),
2651 variant: "Bogus".into(),
2652 })
2653 );
2654 }
2655
2656 #[test]
2657 fn validates_meerkat_machine_without_peer_directory_region() {
2658 let schema = meerkat_machine();
2659
2660 assert!(
2662 !schema
2663 .transitions
2664 .iter()
2665 .any(|transition| transition.name.as_str() == "RecordSendFailedAttached")
2666 );
2667 assert_eq!(schema.validate(), Ok(()));
2668 }
2669
2670 #[test]
2671 fn rejects_unknown_surface_only_inputs() {
2672 let mut schema = meerkat_machine();
2673 schema
2674 .surface_only_inputs
2675 .push(crate::identity::InputVariantId::parse("DoesNotExist").expect("slug"));
2676
2677 assert_eq!(
2678 schema.validate(),
2679 Err(MachineSchemaError::UnknownSurfaceOnlyInputVariant {
2680 variant: "DoesNotExist".into(),
2681 })
2682 );
2683 }
2684
2685 #[test]
2686 fn rejects_surface_only_inputs_with_transitions() {
2687 let mut schema = meerkat_machine();
2688 schema
2689 .surface_only_inputs
2690 .push(crate::identity::InputVariantId::parse("RegisterSession").expect("slug"));
2691 let transition = schema
2692 .transitions
2693 .iter()
2694 .find(|transition| transition.on.variant_str() == "RegisterSession")
2695 .map(|transition| transition.name.as_str().to_owned())
2696 .unwrap_or_default();
2697 assert!(
2698 !transition.is_empty(),
2699 "register session transition should exist"
2700 );
2701
2702 assert_eq!(
2703 schema.validate(),
2704 Err(MachineSchemaError::SurfaceOnlyInputHasTransition {
2705 variant: "RegisterSession".into(),
2706 transition,
2707 })
2708 );
2709 }
2710}