1use 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
51pub struct MeerkatConsumerSurface {
67 machine: Arc<MeerkatMachine>,
68 pinned_session: Option<SessionId>,
74}
75
76#[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 pub fn new(machine: Arc<MeerkatMachine>) -> Self {
213 Self {
214 machine,
215 pinned_session: None,
216 }
217 }
218
219 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 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 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 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 (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 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 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 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 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 #[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}