Skip to main content

meerkat_runtime/meerkat_machine/
composition.rs

1//! Wave-c C-6c — consumer side of the `meerkat_mob_seam` composition.
2//!
3//! Wave-c C-6p landed the producer side: the mob actor converts each
4//! emitted `MobMachineEffect::Request*` variant into a typed
5//! [`MobSeamEffect`][mse] and routes it through a
6//! [`CompositionDispatcher`][cd]. Until this module lands, that
7//! dispatcher resolves the typed route but fails with
8//! [`DispatchRefusal::UnwiredConsumer`][dr] because no
9//! [`ConsumerSurface`][cs] is registered for the `meerkat` target
10//! instance.
11//!
12//! C-6c closes that seam: [`MeerkatConsumerSurface`] is the typed
13//! consumer surface. The dispatcher invokes
14//! [`ConsumerSurface::apply_routed_input`] with the typed
15//! [`InputVariantId`] + projected field bindings declared by the
16//! `meerkat_mob_seam` composition schema
17//! (`meerkat-machine-schema/src/catalog/compositions.rs::meerkat_mob_seam_composition`).
18//! This surface translates each of the four routed variants —
19//! `PrepareBindings`, `Ingest`, `Retire`, `Destroy` — into the
20//! corresponding `MeerkatMachineInput` and applies it against the
21//! session's shared DSL authority.
22//!
23//! The route bindings declared in the schema are the sole source of
24//! truth for field projection shape. If a route binding references a
25//! producer field the effect body did not populate, the dispatcher
26//! returns [`DispatchRefusal::MissingProducerField`][dr] before this
27//! surface is reached, so [`apply_routed_input`][cs_apply] can assume
28//! the declared bindings are present.
29//!
30//! [mse]: meerkat_mob::runtime::composition::MobSeamEffect
31//! [cd]: crate::composition::CompositionDispatcher
32//! [cs]: crate::composition::ConsumerSurface
33//! [cs_apply]: crate::composition::ConsumerSurface::apply_routed_input
34//! [dr]: crate::composition::DispatchRefusal
35
36use std::sync::{Arc, OnceLock};
37
38use async_trait::async_trait;
39use meerkat_core::types::SessionId;
40use meerkat_machine_schema::identity::{
41    EffectVariantId, FieldId, InputVariantId, MachineInstanceId,
42};
43
44use crate::composition::{
45    CompositionSignalDispatcher, FieldValue, ProducerInstance, ProducerSignal, SignalPayload,
46};
47use crate::composition::{ConsumerSurface, OwnedFieldValue, SignalDispatchOutcome};
48use crate::generated::meerkat_mob_seam as seam_facts;
49use crate::meerkat_machine::{MeerkatMachine, dsl as mm_dsl};
50
51/// Consumer-side surface for the `meerkat_mob_seam` composition.
52///
53/// Implements [`ConsumerSurface`] for the `meerkat` target instance. The
54/// dispatcher hands the surface one routed input at a time; the surface
55/// translates the typed [`InputVariantId`] + projected-field tuple into
56/// the matching [`MeerkatMachineInput`][mmi] and applies it against the
57/// session's shared DSL authority on the owning [`MeerkatMachine`].
58///
59/// Session selection: routed effects prefer a projected `session_id`.
60/// `Ingest` may arrive without one, so the shared surface resolves its
61/// projected `runtime_id` through each registered session's DSL-owned
62/// `active_runtime_id`. Zero or multiple matches are refused rather than
63/// guessing.
64///
65/// [mmi]: crate::meerkat_machine::dsl::MeerkatMachineInput
66pub struct MeerkatConsumerSurface {
67    machine: Arc<MeerkatMachine>,
68    /// Optional pinned session id. `None` means the surface infers the
69    /// session from the `agent_runtime_id` field of each routed input;
70    /// `Some(id)` means every routed input is applied against that
71    /// session and the surface refuses variants whose projected
72    /// `agent_runtime_id` disagrees.
73    pinned_session: Option<SessionId>,
74}
75
76/// Producer-side signal source sum for MeerkatMachine lifecycle effects
77/// routed through the `meerkat_mob_seam` signal surface.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum MeerkatSeamSignal {
80    RuntimeBound {
81        agent_runtime_id: mm_dsl::AgentRuntimeId,
82        fence_token: mm_dsl::FenceToken,
83    },
84    RuntimeRetired {
85        agent_runtime_id: mm_dsl::AgentRuntimeId,
86        fence_token: mm_dsl::FenceToken,
87    },
88    RuntimeDestroyed {
89        agent_runtime_id: mm_dsl::AgentRuntimeId,
90        fence_token: mm_dsl::FenceToken,
91    },
92}
93
94impl MeerkatSeamSignal {
95    pub fn variant_id(&self) -> EffectVariantId {
96        match self {
97            Self::RuntimeBound { .. } => seam_facts::effects::meerkat::runtime_bound(),
98            Self::RuntimeRetired { .. } => seam_facts::effects::meerkat::runtime_retired(),
99            Self::RuntimeDestroyed { .. } => seam_facts::effects::meerkat::runtime_destroyed(),
100        }
101    }
102
103    pub fn generated_signal_route(&self) -> Option<seam_facts::TypedRoutedSignal> {
104        seam_facts::route_to_signal(
105            &seam_facts::producers::meerkat_instance_id(),
106            &self.variant_id(),
107        )
108    }
109
110    fn field(&self, id: &FieldId) -> Option<FieldValue<'_>> {
111        let (agent_runtime_id, fence_token) = match self {
112            Self::RuntimeBound {
113                agent_runtime_id,
114                fence_token,
115            }
116            | Self::RuntimeRetired {
117                agent_runtime_id,
118                fence_token,
119            }
120            | Self::RuntimeDestroyed {
121                agent_runtime_id,
122                fence_token,
123            } => (agent_runtime_id, fence_token),
124        };
125        if id == &seam_facts::fields::agent_runtime_id() {
126            Some(FieldValue::Str(agent_runtime_id.0.as_str()))
127        } else if id == &seam_facts::fields::fence_token() {
128            Some(FieldValue::U64(fence_token.0))
129        } else {
130            None
131        }
132    }
133}
134
135impl ProducerSignal for MeerkatSeamSignal {
136    fn variant_id(&self) -> EffectVariantId {
137        self.variant_id()
138    }
139
140    fn field(&self, id: &FieldId) -> Option<FieldValue<'_>> {
141        self.field(id)
142    }
143}
144
145pub type MeerkatCompositionSignalDispatcher =
146    Arc<dyn CompositionSignalDispatcher<Signal = MeerkatSeamSignal>>;
147
148pub fn meerkat_producer_instance() -> ProducerInstance {
149    let producer = seam_facts::producers::meerkat();
150    ProducerInstance {
151        composition: seam_facts::composition_id(),
152        instance_id: producer.instance_id,
153        machine: producer.machine,
154    }
155}
156
157pub fn lift_routed_signal(effect: &mm_dsl::MeerkatMachineEffect) -> Option<MeerkatSeamSignal> {
158    match effect {
159        mm_dsl::MeerkatMachineEffect::RuntimeBound {
160            agent_runtime_id,
161            fence_token,
162        } => Some(MeerkatSeamSignal::RuntimeBound {
163            agent_runtime_id: agent_runtime_id.clone(),
164            fence_token: *fence_token,
165        }),
166        mm_dsl::MeerkatMachineEffect::RuntimeRetired {
167            agent_runtime_id,
168            fence_token,
169        } => Some(MeerkatSeamSignal::RuntimeRetired {
170            agent_runtime_id: agent_runtime_id.clone(),
171            fence_token: *fence_token,
172        }),
173        mm_dsl::MeerkatMachineEffect::RuntimeDestroyed {
174            agent_runtime_id,
175            fence_token,
176        } => Some(MeerkatSeamSignal::RuntimeDestroyed {
177            agent_runtime_id: agent_runtime_id.clone(),
178            fence_token: *fence_token,
179        }),
180        _ => None,
181    }
182}
183
184pub async fn dispatch_routed_signal(
185    dispatcher: &MeerkatCompositionSignalDispatcher,
186    signal: MeerkatSeamSignal,
187) -> Result<SignalDispatchOutcome, String> {
188    let variant = signal.variant_id();
189    dispatcher
190        .dispatch_signal(
191            meerkat_producer_instance(),
192            SignalPayload::Emitted {
193                variant,
194                body: signal,
195            },
196        )
197        .await
198        .map_err(|refusal| refusal.to_string())
199}
200
201impl std::fmt::Debug for MeerkatConsumerSurface {
202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        f.debug_struct("MeerkatConsumerSurface")
204            .field("pinned_session", &self.pinned_session)
205            .finish_non_exhaustive()
206    }
207}
208
209impl MeerkatConsumerSurface {
210    /// Build a consumer surface backed by the given machine. The surface
211    /// resolves each routed input's target session from projected fields.
212    pub fn new(machine: Arc<MeerkatMachine>) -> Self {
213        Self {
214            machine,
215            pinned_session: None,
216        }
217    }
218
219    /// Build a consumer surface pinned to `session_id`. All routed
220    /// inputs are applied against this session; variants that carry a
221    /// `session_id` are additionally checked for agreement and refused on
222    /// mismatch.
223    pub fn pinned(machine: Arc<MeerkatMachine>, session_id: SessionId) -> Self {
224        Self {
225            machine,
226            pinned_session: Some(session_id),
227        }
228    }
229
230    async fn resolve_session(
231        &self,
232        _variant: &InputVariantId,
233        projected: &[(FieldId, OwnedFieldValue)],
234    ) -> Result<SessionId, String> {
235        // Typed session_id is the canonical source (Shape 4 — producer DSL
236        // emits `session_id: SessionId` alongside `agent_runtime_id`; see
237        // `MobMachineEffect::RequestRuntimeBinding` in
238        // `meerkat-machine-schema/src/catalog/dsl/mob_machine.rs:168`).
239        let projected_session_id = projected
240            .iter()
241            .find(|(id, _)| id == &seam_facts::fields::session_id())
242            .and_then(|(_, v)| match v {
243                OwnedFieldValue::Str(s) => Some(s.clone()),
244                _ => None,
245            });
246
247        match (&self.pinned_session, projected_session_id) {
248            (Some(pinned), Some(sid)) if sid != pinned.to_string() => Err(format!(
249                "routed session_id `{sid}` does not match pinned session `{pinned}`"
250            )),
251            (Some(pinned), _) => Ok(pinned.clone()),
252            (None, Some(sid)) => SessionId::parse(&sid)
253                .map_err(|e| format!("routed session_id `{sid}` is not a valid UUID: {e}")),
254            (None, None) => Err(
255                "routed input did not project `session_id` and surface is not pinned \
256                 to a session — no session can be resolved"
257                    .into(),
258            ),
259        }
260    }
261}
262
263#[allow(clippy::panic)]
264fn meerkat_instance_id() -> &'static MachineInstanceId {
265    static ID: OnceLock<MachineInstanceId> = OnceLock::new();
266    ID.get_or_init(seam_facts::producers::meerkat_instance_id)
267}
268
269fn project_u64(fields: &[(FieldId, OwnedFieldValue)], field: &FieldId) -> Result<u64, String> {
270    fields
271        .iter()
272        .find(|(id, _)| id == field)
273        .ok_or_else(|| format!("missing projected field `{}`", field.as_str()))
274        .and_then(|(_, v)| match v {
275            OwnedFieldValue::U64(n) => Ok(*n),
276            other => Err(format!(
277                "projected field `{}` is not U64: {other:?}",
278                field.as_str()
279            )),
280        })
281}
282
283fn project_optional_u64(
284    fields: &[(FieldId, OwnedFieldValue)],
285    field: &FieldId,
286) -> Result<Option<u64>, String> {
287    match fields.iter().find(|(id, _)| id == field) {
288        None => Ok(None),
289        Some((_, OwnedFieldValue::U64(n))) => Ok(Some(*n)),
290        Some((_, other)) => Err(format!(
291            "projected field `{}` is not U64: {other:?}",
292            field.as_str()
293        )),
294    }
295}
296
297fn project_str<'a>(
298    fields: &'a [(FieldId, OwnedFieldValue)],
299    field: &FieldId,
300) -> Result<&'a str, String> {
301    fields
302        .iter()
303        .find(|(id, _)| id == field)
304        .ok_or_else(|| format!("missing projected field `{}`", field.as_str()))
305        .and_then(|(_, v)| match v {
306            OwnedFieldValue::Str(s) => Ok(s.as_str()),
307            other => Err(format!(
308                "projected field `{}` is not Str: {other:?}",
309                field.as_str()
310            )),
311        })
312}
313
314fn project_work_origin(
315    fields: &[(FieldId, OwnedFieldValue)],
316    field: &FieldId,
317) -> Result<mm_dsl::WorkOrigin, String> {
318    fields
319        .iter()
320        .find(|(id, _)| id == field)
321        .ok_or_else(|| format!("missing projected field `{}`", field.as_str()))
322        .and_then(|(_, v)| match v {
323            OwnedFieldValue::Opaque(value) => value
324                .downcast_ref::<mm_dsl::WorkOrigin>()
325                .copied()
326                .ok_or_else(|| format!("projected field `{}` is not WorkOrigin", field.as_str())),
327            other => Err(format!(
328                "projected field `{}` is not WorkOrigin: {other:?}",
329                field.as_str()
330            )),
331        })
332}
333
334#[async_trait]
335impl ConsumerSurface for MeerkatConsumerSurface {
336    fn instance_id(&self) -> &MachineInstanceId {
337        meerkat_instance_id()
338    }
339
340    async fn apply_routed_input(
341        &self,
342        variant: InputVariantId,
343        projected: Vec<(FieldId, OwnedFieldValue)>,
344    ) -> Result<(), crate::composition::ConsumerError> {
345        let session_id = self.resolve_session(&variant, &projected).await?;
346        let input = if variant == seam_facts::inputs::prepare_bindings() {
347            let rt = project_str(&projected, &seam_facts::fields::agent_runtime_id())?;
348            let fence = project_u64(&projected, &seam_facts::fields::fence_token())?;
349            let generation = project_u64(&projected, &seam_facts::fields::generation())?;
350            let sid = project_str(&projected, &seam_facts::fields::session_id())?;
351            mm_dsl::MeerkatMachineInput::PrepareBindings {
352                agent_runtime_id: mm_dsl::AgentRuntimeId::from(rt.to_string()),
353                fence_token: mm_dsl::FenceToken(fence),
354                generation: Some(mm_dsl::Generation(generation)),
355                runtime_epoch_id: None,
356                session_id: mm_dsl::SessionId::from(sid.to_string()),
357            }
358        } else if variant == seam_facts::inputs::ingest() {
359            // Route binding `work_request_reaches_meerkat` delivers
360            // producer `agent_runtime_id` into the consumer's canonical
361            // `runtime_id` field and also carries the MobMachine-owned
362            // binding facts that MeerkatMachine validates before admission.
363            let sid = project_str(&projected, &seam_facts::fields::session_id())?;
364            let rt = project_str(&projected, &seam_facts::fields::runtime_id())?;
365            let fence = project_u64(&projected, &seam_facts::fields::fence_token())?;
366            let generation = project_optional_u64(&projected, &seam_facts::fields::generation())?;
367            let work_id = project_str(&projected, &seam_facts::fields::work_id())?;
368            let origin = project_work_origin(&projected, &seam_facts::fields::origin())?;
369            mm_dsl::MeerkatMachineInput::Ingest {
370                session_id: mm_dsl::SessionId::from(sid.to_string()),
371                runtime_id: mm_dsl::AgentRuntimeId::from(rt.to_string()),
372                fence_token: mm_dsl::FenceToken(fence),
373                generation: generation.map(mm_dsl::Generation),
374                runtime_epoch_id: None,
375                work_id: mm_dsl::WorkId::from(work_id.to_string()),
376                origin,
377            }
378        } else if variant == seam_facts::inputs::retire() {
379            let sid = project_str(&projected, &seam_facts::fields::session_id())?;
380            mm_dsl::MeerkatMachineInput::Retire {
381                session_id: mm_dsl::SessionId::from(sid.to_string()),
382            }
383        } else if variant == seam_facts::inputs::destroy() {
384            let sid = project_str(&projected, &seam_facts::fields::session_id())?;
385            mm_dsl::MeerkatMachineInput::Destroy {
386                session_id: mm_dsl::SessionId::from(sid.to_string()),
387            }
388        } else {
389            return Err(crate::composition::ConsumerError::new(
390                "meerkat_consumer_surface_unsupported_input",
391                format!(
392                    "meerkat consumer surface does not accept routed input `{}`; \
393                     only PrepareBindings/Ingest/Retire/Destroy are declared in the \
394                     `meerkat_mob_seam` schema",
395                    variant.as_str()
396                ),
397            ));
398        };
399
400        // Kernel→consumer leg stays typed: the per-variant stable
401        // discriminant minted by the generated-machine refusal (e.g.
402        // `dsl_guard_rejected`) crosses the seam verbatim instead of being
403        // collapsed under one generic projection code.
404        self.machine
405            .apply_routed_meerkat_input(&session_id, input)
406            .await
407            .map_err(|refusal| {
408                crate::composition::ConsumerError::new(refusal.error_code, refusal.message)
409            })
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::composition::{
417        CatalogCompositionSignalDispatcher, OwnedFieldValue, RouteTable, SignalConsumerSurface,
418    };
419    use meerkat_machine_schema::identity::SignalVariantId;
420
421    fn fld(slug: &str) -> FieldId {
422        FieldId::parse(slug).expect("field slug")
423    }
424
425    fn iv(slug: &str) -> InputVariantId {
426        InputVariantId::parse(slug).expect("input variant slug")
427    }
428
429    fn sid(slug: &str) -> SessionId {
430        SessionId::parse(slug).expect("session id")
431    }
432
433    async fn bind_runtime(
434        surface: &MeerkatConsumerSurface,
435        session_id: &SessionId,
436        runtime_id: &str,
437    ) {
438        surface
439            .apply_routed_input(
440                iv("PrepareBindings"),
441                vec![
442                    (
443                        fld("agent_runtime_id"),
444                        OwnedFieldValue::Str(runtime_id.into()),
445                    ),
446                    (fld("fence_token"), OwnedFieldValue::U64(1)),
447                    (fld("generation"), OwnedFieldValue::U64(0)),
448                    (
449                        fld("session_id"),
450                        OwnedFieldValue::Str(session_id.to_string()),
451                    ),
452                ],
453            )
454            .await
455            .expect("bind runtime");
456    }
457
458    #[tokio::test]
459    async fn prepare_bindings_requires_all_three_fields() {
460        let machine = Arc::new(MeerkatMachine::ephemeral());
461        let surface = MeerkatConsumerSurface::new(Arc::clone(&machine));
462        let err = surface
463            .apply_routed_input(
464                iv("PrepareBindings"),
465                vec![
466                    (fld("agent_runtime_id"), OwnedFieldValue::Str("rt-1".into())),
467                    // fence_token missing on purpose.
468                    (fld("generation"), OwnedFieldValue::U64(3)),
469                    (
470                        fld("session_id"),
471                        OwnedFieldValue::Str("00000000-0000-0000-0000-000000000001".into()),
472                    ),
473                ],
474            )
475            .await
476            .expect_err("missing fence_token");
477        assert!(err.message().contains("fence_token"), "{err}");
478    }
479
480    #[tokio::test]
481    async fn unknown_variant_is_refused_typed() {
482        let machine = Arc::new(MeerkatMachine::ephemeral());
483        // Pin the surface so resolve_session doesn't fail earlier on
484        // missing session_id — this test focuses on variant-rejection,
485        // not session resolution.
486        let pinned =
487            SessionId::parse("00000000-0000-0000-0000-000000000001").expect("uuid literal");
488        let surface = MeerkatConsumerSurface::pinned(Arc::clone(&machine), pinned);
489        let err = surface
490            .apply_routed_input(iv("Recycle"), vec![])
491            .await
492            .expect_err("Recycle is not a routed variant");
493        assert!(err.message().contains("Recycle"), "{err}");
494    }
495
496    #[tokio::test]
497    async fn machine_rejection_keeps_per_variant_typed_code_across_consumer_seam() {
498        // Row #14 gate (kernel→consumer leg): a generated-machine rejection of
499        // a routed input must cross the consumer seam under its per-variant
500        // stable discriminant, not be collapsed into one generic
501        // `consumer_projection_failed` code.
502        let machine = Arc::new(MeerkatMachine::ephemeral());
503        let session_id = sid("00000000-0000-0000-0000-000000000001");
504        machine
505            .register_session(session_id.clone())
506            .await
507            .expect("register session");
508        let surface = MeerkatConsumerSurface::pinned(Arc::clone(&machine), session_id.clone());
509
510        // Ingest before any runtime binding: the generated machine rejects
511        // the transition; the typed discriminant must survive verbatim.
512        let err = surface
513            .apply_routed_input(
514                iv("Ingest"),
515                vec![
516                    (fld("runtime_id"), OwnedFieldValue::Str("rt-none".into())),
517                    (fld("fence_token"), OwnedFieldValue::U64(1)),
518                    (fld("work_id"), OwnedFieldValue::Str("work-1".into())),
519                    (
520                        fld("origin"),
521                        OwnedFieldValue::Opaque(Arc::new(mm_dsl::WorkOrigin::Ingest)),
522                    ),
523                    (
524                        fld("session_id"),
525                        OwnedFieldValue::Str(session_id.to_string()),
526                    ),
527                ],
528            )
529            .await
530            .expect_err("ingest on an unbound session must be machine-rejected");
531        assert!(
532            err.error_code().starts_with("dsl_"),
533            "machine rejection must keep its per-variant typed code, got `{}`: {err}",
534            err.error_code()
535        );
536        assert_ne!(
537            err.error_code(),
538            "consumer_projection_failed",
539            "kernel rejections must not be collapsed under the generic projection code"
540        );
541    }
542
543    #[tokio::test]
544    async fn unpinned_surface_requires_projected_session_id_for_retire() {
545        let machine = Arc::new(MeerkatMachine::ephemeral());
546        let surface = MeerkatConsumerSurface::new(Arc::clone(&machine));
547        // Retire has no fields in the schema; an unpinned surface
548        // therefore cannot resolve a session and must refuse rather
549        // than pick arbitrarily.
550        let err = surface
551            .apply_routed_input(iv("Retire"), vec![])
552            .await
553            .expect_err("Retire without target");
554        assert!(err.message().contains("session_id"), "{err}");
555    }
556
557    #[tokio::test]
558    async fn pinned_surface_rejects_mismatched_session_id() {
559        let machine = Arc::new(MeerkatMachine::ephemeral());
560        let pinned =
561            SessionId::parse("00000000-0000-0000-0000-000000000001").expect("uuid literal");
562        let surface = MeerkatConsumerSurface::pinned(Arc::clone(&machine), pinned);
563        let err = surface
564            .apply_routed_input(
565                iv("PrepareBindings"),
566                vec![
567                    (
568                        fld("agent_runtime_id"),
569                        OwnedFieldValue::Str("rt-other".into()),
570                    ),
571                    (fld("fence_token"), OwnedFieldValue::U64(1)),
572                    (fld("generation"), OwnedFieldValue::U64(0)),
573                    (
574                        fld("session_id"),
575                        OwnedFieldValue::Str("00000000-0000-0000-0000-000000000002".into()),
576                    ),
577                ],
578            )
579            .await
580            .expect_err("session_id disagrees with pinned session");
581        assert!(err.message().contains("pinned"), "{err}");
582    }
583
584    #[tokio::test]
585    async fn ingest_prefers_projected_session_id() {
586        let machine = Arc::new(MeerkatMachine::ephemeral());
587        let surface = MeerkatConsumerSurface::new(Arc::clone(&machine));
588        let session_id = sid("00000000-0000-0000-0000-000000000001");
589        machine
590            .register_session(session_id.clone())
591            .await
592            .expect("register session");
593        bind_runtime(&surface, &session_id, "rt-other").await;
594
595        surface
596            .apply_routed_input(
597                iv("Ingest"),
598                vec![
599                    (fld("runtime_id"), OwnedFieldValue::Str("rt-other".into())),
600                    (fld("fence_token"), OwnedFieldValue::U64(1)),
601                    (fld("generation"), OwnedFieldValue::U64(0)),
602                    (fld("work_id"), OwnedFieldValue::Str("work-1".into())),
603                    (
604                        fld("origin"),
605                        OwnedFieldValue::Opaque(Arc::new(mm_dsl::WorkOrigin::Ingest)),
606                    ),
607                    (
608                        fld("session_id"),
609                        OwnedFieldValue::Str(session_id.to_string()),
610                    ),
611                ],
612            )
613            .await
614            .expect("session_id targets the routed input");
615    }
616
617    #[tokio::test]
618    async fn ingest_requires_projected_session_id() {
619        let machine = Arc::new(MeerkatMachine::ephemeral());
620        let surface = MeerkatConsumerSurface::new(Arc::clone(&machine));
621        let session_id = sid("00000000-0000-0000-0000-000000000001");
622        machine
623            .register_session(session_id.clone())
624            .await
625            .expect("register session");
626        bind_runtime(&surface, &session_id, "rt-match").await;
627
628        let err = surface
629            .apply_routed_input(
630                iv("Ingest"),
631                vec![
632                    (fld("runtime_id"), OwnedFieldValue::Str("rt-match".into())),
633                    (fld("fence_token"), OwnedFieldValue::U64(1)),
634                    (fld("generation"), OwnedFieldValue::U64(0)),
635                    (fld("work_id"), OwnedFieldValue::Str("work-1".into())),
636                    (
637                        fld("origin"),
638                        OwnedFieldValue::Opaque(Arc::new(mm_dsl::WorkOrigin::Ingest)),
639                    ),
640                ],
641            )
642            .await
643            .expect_err("session_id is required for routed ingest");
644        assert!(err.message().contains("session_id"), "{err}");
645    }
646
647    #[tokio::test]
648    async fn ingest_without_matching_generated_binding_is_refused() {
649        let machine = Arc::new(MeerkatMachine::ephemeral());
650        let surface = MeerkatConsumerSurface::new(Arc::clone(&machine));
651        let session_id = sid("00000000-0000-0000-0000-000000000001");
652        machine
653            .register_session(session_id.clone())
654            .await
655            .expect("register session");
656        bind_runtime(&surface, &session_id, "rt-current").await;
657
658        let err = surface
659            .apply_routed_input(
660                iv("Ingest"),
661                vec![
662                    (fld("runtime_id"), OwnedFieldValue::Str("rt-missing".into())),
663                    (fld("fence_token"), OwnedFieldValue::U64(1)),
664                    (fld("generation"), OwnedFieldValue::U64(0)),
665                    (fld("work_id"), OwnedFieldValue::Str("work-1".into())),
666                    (
667                        fld("origin"),
668                        OwnedFieldValue::Opaque(Arc::new(mm_dsl::WorkOrigin::Ingest)),
669                    ),
670                    (
671                        fld("session_id"),
672                        OwnedFieldValue::Str(session_id.to_string()),
673                    ),
674                ],
675            )
676            .await
677            .expect_err("generated binding guard rejects the routed input");
678        assert!(err.message().contains("Ingest"), "{err}");
679    }
680
681    #[derive(Default)]
682    struct RecordingSignalSurface {
683        log: tokio::sync::Mutex<Vec<(SignalVariantId, Vec<(FieldId, OwnedFieldValue)>)>>,
684    }
685
686    #[async_trait]
687    impl SignalConsumerSurface for RecordingSignalSurface {
688        fn instance_id(&self) -> &MachineInstanceId {
689            static ID: OnceLock<MachineInstanceId> = OnceLock::new();
690            ID.get_or_init(|| MachineInstanceId::parse("mob").expect("canonical instance id"))
691        }
692
693        async fn receive_signal(
694            &self,
695            variant: SignalVariantId,
696            projected_fields: Vec<(FieldId, OwnedFieldValue)>,
697        ) -> Result<(), crate::composition::ConsumerError> {
698            self.log.lock().await.push((variant, projected_fields));
699            Ok(())
700        }
701    }
702
703    #[tokio::test]
704    async fn routed_prepare_bindings_dispatches_runtime_bound_signal() {
705        let machine = Arc::new(MeerkatMachine::ephemeral());
706        let session_id = SessionId::new();
707        machine
708            .register_session(session_id.clone())
709            .await
710            .expect("register session");
711
712        let signal_surface = Arc::new(RecordingSignalSurface::default());
713        let schema = meerkat_machine_schema::catalog::meerkat_mob_seam_composition();
714        let table = RouteTable::from_schema(&schema).expect("catalog routes");
715        let dispatcher: CatalogCompositionSignalDispatcher<MeerkatSeamSignal> =
716            CatalogCompositionSignalDispatcher::new(schema.name.clone(), table)
717                .with_consumer(signal_surface.clone());
718        machine.set_composition_signal_dispatcher(Arc::new(dispatcher));
719
720        machine
721            .apply_routed_meerkat_input(
722                &session_id,
723                mm_dsl::MeerkatMachineInput::PrepareBindings {
724                    agent_runtime_id: mm_dsl::AgentRuntimeId("rt-1".into()),
725                    fence_token: mm_dsl::FenceToken(11),
726                    generation: Some(mm_dsl::Generation(0)),
727                    runtime_epoch_id: None,
728                    session_id: mm_dsl::SessionId(session_id.to_string()),
729                },
730            )
731            .await
732            .expect("routed input applies and emits signal");
733
734        let log = signal_surface.log.lock().await;
735        assert_eq!(log.len(), 1);
736        assert_eq!(log[0].0.as_str(), "ObserveRuntimeReady");
737        assert_eq!(log[0].1[0].0.as_str(), "agent_runtime_id");
738        assert!(matches!(&log[0].1[0].1, OwnedFieldValue::Str(value) if value == "rt-1"));
739        assert_eq!(log[0].1[1].0.as_str(), "fence_token");
740        assert!(matches!(log[0].1[1].1, OwnedFieldValue::U64(11)));
741    }
742
743    /// Schema-enumerated completeness gate (mirror of the mob seam's
744    /// `lift_covers_every_schema_declared_mob_effect_route`): every
745    /// schema-declared meerkat-producer route must have a lift arm in
746    /// `lift_routed_signal`, and the lift must not invent undeclared routes.
747    #[test]
748    fn lift_covers_every_schema_declared_meerkat_signal_route() {
749        use std::collections::BTreeSet;
750
751        let schema = meerkat_machine_schema::catalog::meerkat_mob_seam_composition();
752        let declared: BTreeSet<String> = schema
753            .routes
754            .iter()
755            .filter(|route| &route.from_machine == meerkat_instance_id())
756            .map(|route| route.effect_variant.as_str().to_string())
757            .collect();
758
759        let liftable_bodies = [
760            mm_dsl::MeerkatMachineEffect::RuntimeBound {
761                agent_runtime_id: mm_dsl::AgentRuntimeId("rt-1".into()),
762                fence_token: mm_dsl::FenceToken(1),
763            },
764            mm_dsl::MeerkatMachineEffect::RuntimeRetired {
765                agent_runtime_id: mm_dsl::AgentRuntimeId("rt-1".into()),
766                fence_token: mm_dsl::FenceToken(1),
767            },
768            mm_dsl::MeerkatMachineEffect::RuntimeDestroyed {
769                agent_runtime_id: mm_dsl::AgentRuntimeId("rt-1".into()),
770                fence_token: mm_dsl::FenceToken(1),
771            },
772        ];
773        let liftable: BTreeSet<String> = liftable_bodies
774            .iter()
775            .map(|effect| {
776                lift_routed_signal(effect)
777                    .expect("declared routed effect must lift")
778                    .variant_id()
779                    .as_str()
780                    .to_string()
781            })
782            .collect();
783
784        assert_eq!(
785            declared, liftable,
786            "every schema-declared meerkat signal route must have a lift arm in \
787             lift_routed_signal (and vice versa); update the lift AND this gate \
788             together when the composition changes"
789        );
790    }
791
792    #[test]
793    fn routed_meerkat_signal_projection_tracks_generated_route_facts() {
794        use crate::generated::meerkat_mob_seam as seam_facts;
795
796        let cases = vec![
797            (
798                MeerkatSeamSignal::RuntimeBound {
799                    agent_runtime_id: mm_dsl::AgentRuntimeId("rt-bound".into()),
800                    fence_token: mm_dsl::FenceToken(11),
801                },
802                seam_facts::route_runtime_bound_reaches_mob(),
803            ),
804            (
805                MeerkatSeamSignal::RuntimeRetired {
806                    agent_runtime_id: mm_dsl::AgentRuntimeId("rt-retired".into()),
807                    fence_token: mm_dsl::FenceToken(12),
808                },
809                seam_facts::route_runtime_retired_reaches_mob(),
810            ),
811            (
812                MeerkatSeamSignal::RuntimeDestroyed {
813                    agent_runtime_id: mm_dsl::AgentRuntimeId("rt-destroyed".into()),
814                    fence_token: mm_dsl::FenceToken(13),
815                },
816                seam_facts::route_runtime_destroyed_reaches_mob(),
817            ),
818        ];
819
820        for (signal, expected_route) in cases {
821            let route = signal.generated_signal_route().expect("generated route");
822            assert_eq!(route, expected_route);
823            for (producer_field, _) in &route.bindings {
824                assert!(
825                    signal.field(producer_field).is_some(),
826                    "generated route `{}` requires producer field `{}`",
827                    route.route_id.as_str(),
828                    producer_field.as_str()
829                );
830            }
831        }
832    }
833
834    #[tokio::test]
835    async fn local_session_bindings_do_not_dispatch_runtime_bound_signal() {
836        let machine = Arc::new(MeerkatMachine::ephemeral());
837        let session_id = SessionId::new();
838
839        let signal_surface = Arc::new(RecordingSignalSurface::default());
840        let schema = meerkat_machine_schema::catalog::meerkat_mob_seam_composition();
841        let table = RouteTable::from_schema(&schema).expect("catalog routes");
842        let dispatcher: CatalogCompositionSignalDispatcher<MeerkatSeamSignal> =
843            CatalogCompositionSignalDispatcher::new(schema.name.clone(), table)
844                .with_consumer(signal_surface.clone());
845        machine.set_composition_signal_dispatcher(Arc::new(dispatcher));
846
847        let bindings = machine
848            .prepare_local_session_bindings(session_id.clone())
849            .await
850            .expect("local bindings prepare");
851
852        assert_eq!(bindings.session_id(), &session_id);
853        assert!(
854            signal_surface.log.lock().await.is_empty(),
855            "local resource preparation must not publish cross-machine runtime readiness"
856        );
857        {
858            let sessions = machine.sessions.read().await;
859            let entry = sessions.get(&session_id).expect("session registered");
860            let authority = entry
861                .dsl_authority
862                .lock()
863                .unwrap_or_else(std::sync::PoisonError::into_inner);
864            assert!(
865                authority.state().active_runtime_id.is_none(),
866                "local resource preparation must leave binding identity unclaimed"
867            );
868            assert!(
869                authority.state().active_fence_token.is_none(),
870                "local resource preparation must leave binding fence unclaimed"
871            );
872        }
873
874        machine
875            .apply_routed_meerkat_input(
876                &session_id,
877                mm_dsl::MeerkatMachineInput::PrepareBindings {
878                    agent_runtime_id: mm_dsl::AgentRuntimeId("rt-authoritative".into()),
879                    fence_token: mm_dsl::FenceToken(13),
880                    generation: Some(mm_dsl::Generation(0)),
881                    runtime_epoch_id: None,
882                    session_id: mm_dsl::SessionId(session_id.to_string()),
883                },
884            )
885            .await
886            .expect("authoritative binding still applies after local resource prep");
887
888        let log = signal_surface.log.lock().await;
889        assert_eq!(log.len(), 1);
890        assert_eq!(log[0].0.as_str(), "ObserveRuntimeReady");
891        assert!(
892            matches!(&log[0].1[0].1, OwnedFieldValue::Str(value) if value == "rt-authoritative")
893        );
894        {
895            let sessions = machine.sessions.read().await;
896            let entry = sessions.get(&session_id).expect("session registered");
897            let authority = entry
898                .dsl_authority
899                .lock()
900                .unwrap_or_else(std::sync::PoisonError::into_inner);
901            assert!(
902                matches!(&authority.state().active_runtime_id, Some(value) if value.0 == "rt-authoritative")
903            );
904            assert!(matches!(
905                authority.state().active_fence_token,
906                Some(mm_dsl::FenceToken(13))
907            ));
908        }
909    }
910
911    #[tokio::test]
912    async fn session_owned_prepare_bindings_is_idempotent_without_reemitting_runtime_bound() {
913        let machine = Arc::new(MeerkatMachine::ephemeral());
914        let session_id = SessionId::new();
915
916        let signal_surface = Arc::new(RecordingSignalSurface::default());
917        let schema = meerkat_machine_schema::catalog::meerkat_mob_seam_composition();
918        let table = RouteTable::from_schema(&schema).expect("catalog routes");
919        let dispatcher: CatalogCompositionSignalDispatcher<MeerkatSeamSignal> =
920            CatalogCompositionSignalDispatcher::new(schema.name.clone(), table)
921                .with_consumer(signal_surface.clone());
922        machine.set_composition_signal_dispatcher(Arc::new(dispatcher));
923
924        machine
925            .prepare_bindings(session_id.clone())
926            .await
927            .expect("initial session-owned binding prepares");
928        machine
929            .prepare_bindings(session_id.clone())
930            .await
931            .expect("duplicate session-owned binding returns existing handles");
932
933        let log = signal_surface.log.lock().await;
934        assert_eq!(
935            log.len(),
936            1,
937            "duplicate handle resolution must not publish a second RuntimeBound signal"
938        );
939        assert_eq!(log[0].0.as_str(), "ObserveRuntimeReady");
940        assert!(
941            matches!(&log[0].1[0].1, OwnedFieldValue::Str(value) if value.starts_with("rt:session:"))
942        );
943    }
944
945    #[tokio::test]
946    async fn session_owned_prepare_bindings_rejects_conflicting_authoritative_runtime() {
947        let machine = Arc::new(MeerkatMachine::ephemeral());
948        let session_id = SessionId::new();
949        machine
950            .register_session(session_id.clone())
951            .await
952            .expect("register session");
953
954        let signal_surface = Arc::new(RecordingSignalSurface::default());
955        let schema = meerkat_machine_schema::catalog::meerkat_mob_seam_composition();
956        let table = RouteTable::from_schema(&schema).expect("catalog routes");
957        let dispatcher: CatalogCompositionSignalDispatcher<MeerkatSeamSignal> =
958            CatalogCompositionSignalDispatcher::new(schema.name.clone(), table)
959                .with_consumer(signal_surface.clone());
960        machine.set_composition_signal_dispatcher(Arc::new(dispatcher));
961
962        machine
963            .apply_routed_meerkat_input(
964                &session_id,
965                mm_dsl::MeerkatMachineInput::PrepareBindings {
966                    agent_runtime_id: mm_dsl::AgentRuntimeId("operator-rt:0".into()),
967                    fence_token: mm_dsl::FenceToken(17),
968                    generation: Some(mm_dsl::Generation(0)),
969                    runtime_epoch_id: None,
970                    session_id: mm_dsl::SessionId(session_id.to_string()),
971                },
972            )
973            .await
974            .expect("mob-owned authoritative binding applies");
975
976        let err = machine
977            .prepare_bindings(session_id.clone())
978            .await
979            .expect_err("session-owned binding must not overwrite mob-owned authority");
980        assert!(
981            err.to_string().contains("DSL authority (PrepareBindings)"),
982            "{err}"
983        );
984
985        let state = machine
986            .session_dsl_state(&session_id)
987            .await
988            .expect("session state remains available");
989        assert!(
990            matches!(&state.active_runtime_id, Some(value) if value.0 == "operator-rt:0"),
991            "conflicting prepare_bindings must not rewrite active_runtime_id: {:?}",
992            state.active_runtime_id
993        );
994        assert!(matches!(
995            state.active_fence_token,
996            Some(mm_dsl::FenceToken(17))
997        ));
998
999        let log = signal_surface.log.lock().await;
1000        assert_eq!(
1001            log.len(),
1002            1,
1003            "rejected session-owned binding must not publish a shadow RuntimeBound signal"
1004        );
1005        assert!(matches!(&log[0].1[0].1, OwnedFieldValue::Str(value) if value == "operator-rt:0"));
1006    }
1007}