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