Skip to main content

meerkat_machine_schema/
machine.rs

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    // WAVE G2 machine folds (#181 respawn generation, #351 membership reconcile).
47    "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    /// Inputs that are part of the DSL alphabet but intentionally internal to
61    /// machine/composition drivers rather than public runtime command surfaces.
62    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    /// Generated command/capability plans that group existing inputs,
68    /// transitions, guards, and effects into an auditable authority surface.
69    pub command_plans: Vec<CommandPlanSchema>,
70    pub invariants: Vec<InvariantSchema>,
71    pub transitions: Vec<TransitionSchema>,
72    pub effect_dispositions: Vec<EffectDispositionRule>,
73    /// Authoritative Rust-type mapping for every `TypeRef::Named(NamedTypeId)`
74    /// referenced in this schema. The codegen consults this table rather
75    /// than matching on the type's name; a `NamedTypeId` referenced
76    /// without a matching binding is a schema-construction error.
77    pub named_types: Vec<NamedTypeBinding>,
78    /// Override the CI step_limit for individual machine TLC verification.
79    /// Machines with many state fields (e.g. a rich MobMachine flow/work region)
80    /// may need a lower limit to keep CI verification tractable. `None` uses the
81    /// codegen default (6 for CI, 8 for deep).
82    pub ci_step_limit: Option<u32>,
83}
84
85impl MachineSchema {
86    /// Look up the authoritative Rust binding for a named type referenced
87    /// by this schema. Returns `None` if the type is unbound.
88    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        // Every declared semantic state field MUST carry an explicit
145        // `state.init.fields` initializer (#174, fix (b)). Without this, a
146        // field with no declared initializer falls through to the runtime
147        // type-generic auto-seed (`GeneratedMachineKernel::initial_state`),
148        // and enum-typed fields in particular default to the reserved
149        // `_Unset` sentinel — an untyped, semantically-meaningless placeholder
150        // that launders an absent value into a live field. The fix is to fail
151        // closed at codegen-validation time: a machine that omits an
152        // initializer for any of its `state.fields` is rejected, forcing the
153        // author to declare the typed initial fact (a typed Option-shaped
154        // `None`, an `EmptySet`/`EmptyMap`, or a concrete enum variant) rather
155        // than relying on an unset sentinel. The machine's phase field is
156        // modelled separately (`state.init.phase`) and is excluded from
157        // `state.fields` by the codegen, so it is never subject to this rule.
158        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        // Validate named-type bindings: every `TypeRef::Named(id)` referenced
382        // anywhere in the schema must have a matching binding in `named_types`,
383        // and bindings must be unique by name. This is the authoritative
384        // single source of truth for how the codegen lowers named types —
385        // there is no name-based fallback.
386        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        // Validate effect dispositions: every rule must reference a known effect
432        // variant with no duplicates, and — unconditionally (#294) — when the
433        // machine declares any effect, every effect variant must carry a
434        // disposition. An empty `effect_dispositions` vector on a machine that
435        // has effects is a coverage gap, not a pass (the gate that RMAT treats
436        // as coverage truth must not be silently satisfiable by emptiness).
437        {
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    /// Handled internally by the owning shell/runtime. No cross-machine routing needed.
474    Local,
475    /// Observability/external side effect. No machine consumption expected.
476    External,
477    /// Must be routed to a consumer machine when both producer and consumer coexist in a composition.
478    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    /// When set, this effect participates in an owner-handoff protocol.
486    /// The named protocol must be declared as an `EffectHandoffProtocol`
487    /// in every composition that includes this machine.
488    /// Only meaningful for `Local` or `External` dispositions.
489    pub handoff_protocol: Option<ProtocolId>,
490    /// Schema-owned classification of this effect's ownership boundary.
491    /// Declared on the catalog DSL via the `seam <Classification>` clause and
492    /// read by the seam-inventory audit straight off the generated schema.
493    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/// Typed trigger match for a transition — the variant ID is carried at the
664/// right level of the sum discriminator.
665///
666/// Each transition fires on either an input or a signal; the `TriggerMatch`
667/// sum threads the correct typed identity through each arm so there is no
668/// stringly-typed `variant` field anywhere in the schema. The DSL macro
669/// emits the concrete arm at expansion time — `Input { variant:
670/// InputVariantId::parse(...) }` or `Signal { variant:
671/// SignalVariantId::parse(...) }`.
672#[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    /// Structural trigger kind (convenience projection for validators that
686    /// still consult the `kind`/`variant` split).
687    pub fn kind(&self) -> TriggerKind {
688        match self {
689            Self::Input { .. } => TriggerKind::Input,
690            Self::Signal { .. } => TriggerKind::Signal,
691        }
692    }
693
694    /// Untyped slug accessor for the matched variant. Use [`TriggerMatch`]
695    /// directly when you need the typed identity.
696    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    /// Typed bindings introduced by the destructure pattern.
704    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
1562/// Recursively collect every `NamedTypeId` slug referenced by a type tree.
1563fn 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
1579/// Collect every named-type reference anywhere in a `MachineSchema`'s typed
1580/// surface: state fields, variant fields (inputs/signals/effects),
1581/// helpers + derived parameters and returns.
1582pub(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(&param.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(&param.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        // DSL-generated schema uses `rust: "self" / "catalog::dsl::meerkat_machine"`
2038        // because it lives inside meerkat-machine-schema itself. The runtime
2039        // owner is anchored in meerkat-runtime via its own `machine!` invocation.
2040        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        // #294: effect-disposition coverage is unconditional. A machine that
2405        // declares effects but carries an empty `effect_dispositions` vector is
2406        // a coverage gap, not a pass — clearing the dispositions of an
2407        // otherwise-valid machine must fail closed with MissingEffectDisposition
2408        // (previously the `if !is_empty()` gate let zero coverage through).
2409        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        // #174 (fix (b)): every declared semantic state field MUST carry an
2424        // explicit `state.init.fields` initializer. A field left uninitialized
2425        // would otherwise fall through to the runtime type-generic auto-seed
2426        // (and, for enum-typed fields, the reserved `_Unset` sentinel). Dropping
2427        // the initializer for any declared `state.fields` entry must fail closed
2428        // with `MissingInitializer` rather than silently relying on the seed.
2429        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        // Peer directory region was removed (unimplemented).
2661        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}