Skip to main content

meerkat_mobkit/unified_runtime/
mob_events.rs

1//! Projection layer for structural mob events.
2//!
3//! After PR #67 mobkit kept its own ring buffer and minted process-local
4//! cursors via `AtomicU64`. Following the absorption of meerkat #445 the
5//! single source of truth is the meerkat ledger: `MobEvent.cursor` is
6//! durable, monotonic, and shared by every subscriber. This module is
7//! reduced to the minimum projection seam: take a `MobEvent`, label-join
8//! it with the runtime's `RuntimeMetadataTable`, and broadcast the
9//! resulting `MobStructuralEventEnvelope` to in-process subscribers.
10//!
11//! Query and SSE paths now read the ledger directly (see
12//! `UnifiedRuntime::query_mob_events` and the
13//! `/mobkit/mob_events/stream` SSE route). The broadcast channel kept
14//! here serves in-process consumers (tests, embedded controllers); each
15//! external SSE client opens its own meerkat subscription so live tail
16//! and catch-up share the same ordered stream.
17
18use std::collections::BTreeMap;
19use std::sync::Arc;
20
21use meerkat_mob::MobError;
22use meerkat_mob::event::{AttributedEvent, MobEvent, MobEventKind};
23use meerkat_mob::runtime::MobEventsView;
24use serde_json::Value;
25use tokio::sync::broadcast;
26
27use crate::runtime::{MetadataScope, RuntimeMetadataTable};
28use crate::types::MobStructuralEventEnvelope;
29use crate::unified_runtime::EventQuery;
30
31/// Batch size used by the ledger-scanning query helpers. Matches the
32/// meerkat `MobEventsSubscriptionConfig::default().batch_limit`.
33pub(crate) const QUERY_BATCH_SIZE: usize = 128;
34
35/// Default per-call result cap when the caller does not supply `limit`.
36pub(crate) const DEFAULT_QUERY_LIMIT: usize = 256;
37
38/// Capacity of the broadcast channel used by in-process subscribers.
39const MOB_EVENTS_CHANNEL_CAP: usize = 512;
40
41/// Thin projection layer for structural mob events.
42///
43/// Public so integration tests can construct one directly. Internal
44/// callers should obtain the runtime's store via
45/// [`crate::unified_runtime::UnifiedRuntime::subscribe_mob_events`].
46#[derive(Clone)]
47pub struct MobEventsStore {
48    event_tx: broadcast::Sender<MobStructuralEventEnvelope>,
49    metadata_table: Option<Arc<RuntimeMetadataTable>>,
50}
51
52impl Default for MobEventsStore {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl MobEventsStore {
59    /// Create an empty store with no label provider attached. Events
60    /// projected through this store carry empty `mob_labels` /
61    /// `run_labels`. Use [`Self::with_metadata_table`] to wire in the
62    /// runtime's `RuntimeMetadataTable` so structural events are
63    /// label-enriched at projection time.
64    pub fn new() -> Self {
65        let (event_tx, _) = broadcast::channel(MOB_EVENTS_CHANNEL_CAP);
66        Self {
67            event_tx,
68            metadata_table: None,
69        }
70    }
71
72    /// Wire a label provider into the store. After this, every projected
73    /// structural envelope is enriched with the matching `mob_labels` and
74    /// (when the event has a `run_id`) `run_labels` snapshotted at
75    /// projection time. Returns the same store with the table attached so
76    /// callers can chain.
77    #[must_use]
78    pub fn with_metadata_table(mut self, table: Arc<RuntimeMetadataTable>) -> Self {
79        self.metadata_table = Some(table);
80        self
81    }
82
83    /// Subscribe to live structural mob events. Each receiver sees every
84    /// envelope projected after subscription. Receivers that fall behind
85    /// `MOB_EVENTS_CHANNEL_CAP` will see `RecvError::Lagged`; production
86    /// SSE clients should subscribe directly to the meerkat ledger via
87    /// the `/mobkit/mob_events/stream` route instead.
88    pub fn subscribe(&self) -> broadcast::Receiver<MobStructuralEventEnvelope> {
89        self.event_tx.subscribe()
90    }
91
92    /// Project an [`AttributedEvent`] into a structural envelope. The
93    /// `AttributedEvent` carries an agent-level `EventEnvelope`; we use it
94    /// only for the agent identity and timestamp fallback. The bulk of the
95    /// structural fields come from the corresponding [`MobEvent`] (see
96    /// [`Self::project_mob_event`]).
97    ///
98    /// Returns `None` because attributed agent events on their own do not
99    /// have structural mob fields — only the `MobEvent` stream does. Kept
100    /// here so callers wiring both streams have a symmetric API.
101    pub async fn project_attributed_event(
102        &self,
103        _event: &AttributedEvent,
104    ) -> Option<MobStructuralEventEnvelope> {
105        None
106    }
107
108    /// Project a [`MobEvent`] into a structural envelope and broadcast it
109    /// to in-process subscribers. The envelope's `cursor` is the meerkat
110    /// ledger cursor — durable across mobkit restarts.
111    pub async fn project_mob_event(&self, event: &MobEvent) -> MobStructuralEventEnvelope {
112        let envelope = self.build_envelope(event).await;
113        let _ = self.event_tx.send(envelope.clone());
114        envelope
115    }
116
117    /// Like [`Self::project_mob_event`] but does not broadcast. Used by
118    /// the query path which scans the ledger and projects events without
119    /// disturbing the live broadcast.
120    pub async fn project_event_for_query(&self, event: &MobEvent) -> MobStructuralEventEnvelope {
121        self.build_envelope(event).await
122    }
123
124    async fn build_envelope(&self, event: &MobEvent) -> MobStructuralEventEnvelope {
125        let cursor = event.cursor;
126        let mob_id = event.mob_id.as_str().to_string();
127        let timestamp_ms = event.timestamp.timestamp_millis().max(0) as u64;
128        let kind = event_kind_label(&event.kind).to_string();
129        let (run_id, step_id, agent_identity) = extract_structural_fields(&event.kind);
130        let data = serde_json::to_value(&event.kind).unwrap_or(Value::Null);
131        let (mob_labels, run_labels) = self.lookup_labels(&mob_id, run_id.as_deref()).await;
132        MobStructuralEventEnvelope {
133            event_id: format!("mob-evt-{cursor}"),
134            cursor,
135            mob_id,
136            timestamp_ms,
137            kind,
138            run_id,
139            step_id,
140            agent_identity,
141            mob_labels,
142            run_labels,
143            data,
144        }
145    }
146
147    async fn lookup_labels(
148        &self,
149        mob_id: &str,
150        run_id: Option<&str>,
151    ) -> (BTreeMap<String, String>, BTreeMap<String, String>) {
152        let Some(table) = &self.metadata_table else {
153            return (BTreeMap::new(), BTreeMap::new());
154        };
155        let mob_labels = table
156            .get_labels(&MetadataScope::Mob(mob_id.to_string()))
157            .await;
158        let run_labels = match run_id {
159            Some(run_id) => {
160                table
161                    .get_labels(&MetadataScope::Run(mob_id.to_string(), run_id.to_string()))
162                    .await
163            }
164            None => BTreeMap::new(),
165        };
166        (mob_labels, run_labels)
167    }
168}
169
170/// Path of the per-client structural-events SSE route.
171pub const MOB_EVENTS_STREAM_PATH: &str = "/mobkit/mob_events/stream";
172
173/// Build the continuation URL returned by `mobkit/mob_events/subscribe`.
174///
175/// `after_seq` (the cursor the SSE handler will resume from) is set to
176/// `next_after_seq` if the snapshot returned events, else the
177/// caller-supplied `after_seq`, else `latest_cursor` captured at
178/// handshake time. This closes the gap between the JSON-RPC snapshot
179/// response and the SSE handshake where new events would otherwise be
180/// missed. The original filters are echoed back so the SSE client
181/// applies the same predicate without restating them.
182pub(crate) fn build_subscribe_url(
183    query: &EventQuery,
184    next_after_seq: Option<u64>,
185    fallback_cursor: u64,
186) -> String {
187    let after_seq = next_after_seq
188        .or(query.after_seq)
189        .unwrap_or(fallback_cursor);
190    let mut serializer = form_urlencoded::Serializer::new(String::new());
191    serializer.append_pair("after_seq", &after_seq.to_string());
192    if let Some(value) = query.mob_id.as_deref() {
193        serializer.append_pair("mob_id", value);
194    }
195    if let Some(value) = query.run_id.as_deref() {
196        serializer.append_pair("run_id", value);
197    }
198    if let Some(value) = query.step_id.as_deref() {
199        serializer.append_pair("step_id", value);
200    }
201    if let Some(value) = query.identity.as_deref() {
202        serializer.append_pair("identity", value);
203    }
204    if let Some(value) = query.member_id.as_deref() {
205        serializer.append_pair("member_id", value);
206    }
207    if let Some(value) = query.since_ms {
208        serializer.append_pair("since_ms", &value.to_string());
209    }
210    if let Some(value) = query.until_ms {
211        serializer.append_pair("until_ms", &value.to_string());
212    }
213    if !query.event_types.is_empty() {
214        serializer.append_pair("event_types", &query.event_types.join(","));
215    }
216    format!("{MOB_EVENTS_STREAM_PATH}?{}", serializer.finish())
217}
218
219/// Errors raised when scanning the meerkat ledger to satisfy a
220/// structural-events query. `Stale` is the typed variant the JSON-RPC
221/// layer maps to `-32010` with `data: { after_cursor, latest_cursor }`.
222#[derive(Debug)]
223pub enum MobEventsQueryError {
224    /// Caller supplied an `after_seq` past the current ledger frontier.
225    Stale {
226        after_cursor: u64,
227        latest_cursor: u64,
228    },
229    /// Any other failure surfaced by the meerkat events view.
230    Backend(MobError),
231}
232
233impl std::fmt::Display for MobEventsQueryError {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        match self {
236            Self::Stale {
237                after_cursor,
238                latest_cursor,
239            } => write!(
240                f,
241                "stale mob event cursor: requested {after_cursor}, latest {latest_cursor}"
242            ),
243            Self::Backend(err) => write!(f, "{err}"),
244        }
245    }
246}
247
248impl std::error::Error for MobEventsQueryError {}
249
250impl From<MobError> for MobEventsQueryError {
251    fn from(err: MobError) -> Self {
252        if let MobError::StaleEventCursor {
253            after_cursor,
254            latest_cursor,
255        } = err
256        {
257            Self::Stale {
258                after_cursor,
259                latest_cursor,
260            }
261        } else {
262            Self::Backend(err)
263        }
264    }
265}
266
267/// Predicate matching a [`MobStructuralEventEnvelope`] against an
268/// [`EventQuery`]'s field filters. Cursor-bound and `limit` are handled
269/// by the scan loops, not by this function.
270pub(crate) fn envelope_matches(envelope: &MobStructuralEventEnvelope, query: &EventQuery) -> bool {
271    if let Some(since) = query.since_ms
272        && envelope.timestamp_ms < since
273    {
274        return false;
275    }
276    if let Some(until) = query.until_ms
277        && envelope.timestamp_ms >= until
278    {
279        return false;
280    }
281    if let Some(mob_id) = query.mob_id.as_deref()
282        && envelope.mob_id != mob_id
283    {
284        return false;
285    }
286    if let Some(run_id) = query.run_id.as_deref()
287        && envelope.run_id.as_deref() != Some(run_id)
288    {
289        return false;
290    }
291    if let Some(step_id) = query.step_id.as_deref()
292        && envelope.step_id.as_deref() != Some(step_id)
293    {
294        return false;
295    }
296    let identity_filter = query.identity.as_deref().or(query.member_id.as_deref());
297    if let Some(identity) = identity_filter
298        && envelope.agent_identity.as_deref() != Some(identity)
299    {
300        return false;
301    }
302    if !query.event_types.is_empty() && !query.event_types.iter().any(|ty| ty == &envelope.kind) {
303        return false;
304    }
305    true
306}
307
308/// Scan the ledger in batches of [`QUERY_BATCH_SIZE`], project each
309/// `MobEvent` via `store`, apply `query`'s field filters, and return
310/// results in cursor-ascending order.
311///
312/// Semantics:
313/// - With `after_seq`: scan **forward** from `after_seq`; on
314///   `StaleEventCursor` the typed [`MobEventsQueryError::Stale`] is
315///   returned so the JSON-RPC layer can surface code `-32010`.
316/// - Without `after_seq`: scan **backwards** from `latest_cursor`,
317///   accumulating the latest `limit` matching events, then return them
318///   in cursor-ascending order.
319///
320/// `limit` defaults to [`DEFAULT_QUERY_LIMIT`].
321pub(crate) async fn query_ledger_with_filter(
322    events: &MobEventsView,
323    store: &MobEventsStore,
324    query: &EventQuery,
325) -> Result<Vec<MobStructuralEventEnvelope>, MobEventsQueryError> {
326    let limit = query.limit.unwrap_or(DEFAULT_QUERY_LIMIT);
327    if limit == 0 {
328        return Ok(Vec::new());
329    }
330    if let Some(after_seq) = query.after_seq {
331        return scan_forward(events, store, query, after_seq, limit).await;
332    }
333    scan_backward(events, store, query, limit).await
334}
335
336async fn scan_forward(
337    events: &MobEventsView,
338    store: &MobEventsStore,
339    query: &EventQuery,
340    after_seq: u64,
341    limit: usize,
342) -> Result<Vec<MobStructuralEventEnvelope>, MobEventsQueryError> {
343    let mut results: Vec<MobStructuralEventEnvelope> =
344        Vec::with_capacity(limit.min(QUERY_BATCH_SIZE));
345    let mut cursor = after_seq;
346    loop {
347        let batch = events.poll_strict(cursor, QUERY_BATCH_SIZE).await?;
348        if batch.is_empty() {
349            break;
350        }
351        let cursor_before_batch = cursor;
352        for event in batch {
353            cursor = cursor.max(event.cursor);
354            let envelope = store.project_event_for_query(&event).await;
355            if envelope_matches(&envelope, query) {
356                results.push(envelope);
357                if results.len() >= limit {
358                    return Ok(results);
359                }
360            }
361        }
362        // Defensive non-progress guard. `poll_strict` is contracted to
363        // return events strictly after `cursor_before_batch` when the
364        // batch is non-empty, so this branch is unreachable — but
365        // bailing instead of looping forever keeps the failure mode
366        // bounded if the contract ever changes.
367        if cursor <= cursor_before_batch {
368            break;
369        }
370    }
371    Ok(results)
372}
373
374async fn scan_backward(
375    events: &MobEventsView,
376    store: &MobEventsStore,
377    query: &EventQuery,
378    limit: usize,
379) -> Result<Vec<MobStructuralEventEnvelope>, MobEventsQueryError> {
380    let latest = events.latest_cursor().await?;
381    if latest == 0 {
382        return Ok(Vec::new());
383    }
384    let batch_size = QUERY_BATCH_SIZE as u64;
385    let mut window_end = latest;
386    let mut accumulator: Vec<MobStructuralEventEnvelope> = Vec::new();
387    loop {
388        let from = window_end.saturating_sub(batch_size);
389        let take = (window_end - from) as usize;
390        if take == 0 {
391            break;
392        }
393        let batch = events.poll_strict(from, take).await?;
394        if batch.is_empty() {
395            break;
396        }
397        let mut window_matches: Vec<MobStructuralEventEnvelope> = Vec::with_capacity(batch.len());
398        for event in batch {
399            let envelope = store.project_event_for_query(&event).await;
400            if envelope_matches(&envelope, query) {
401                window_matches.push(envelope);
402            }
403        }
404        // Prepend (cursor-ascending order preserved across windows).
405        let mut combined = Vec::with_capacity(window_matches.len() + accumulator.len());
406        combined.append(&mut window_matches);
407        combined.append(&mut accumulator);
408        accumulator = combined;
409        if accumulator.len() >= limit || from == 0 {
410            break;
411        }
412        window_end = from;
413    }
414    if accumulator.len() > limit {
415        let drop = accumulator.len() - limit;
416        accumulator.drain(0..drop);
417    }
418    Ok(accumulator)
419}
420
421/// Snake-case label for a `MobEventKind` matching the `serde(tag="type",
422/// rename_all="snake_case")` wire form.
423fn event_kind_label(kind: &MobEventKind) -> &'static str {
424    match kind {
425        MobEventKind::MobCreated { .. } => "mob_created",
426        MobEventKind::MobOwnerBridgeSessionBound { .. } => "mob_owner_bridge_session_bound",
427        MobEventKind::MobCompleted => "mob_completed",
428        MobEventKind::MobDestroying => "mob_destroying",
429        MobEventKind::MobDestroyStorageFinalizing => "mob_destroy_storage_finalizing",
430        MobEventKind::MobReset => "mob_reset",
431        MobEventKind::MemberSpawned(_) => "member_spawned",
432        MobEventKind::MemberSessionBindingRecovered(_) => "member_session_binding_recovered",
433        MobEventKind::MemberRetired { .. } => "member_retired",
434        MobEventKind::MemberReset { .. } => "member_reset",
435        MobEventKind::MemberKickoffUpdated { .. } => "member_kickoff_updated",
436        MobEventKind::MembersWired { .. } => "members_wired",
437        MobEventKind::MembersWiredBatch { .. } => "members_wired_batch",
438        MobEventKind::MembersUnwired { .. } => "members_unwired",
439        MobEventKind::ExternalPeerWired { .. } => "external_peer_wired",
440        MobEventKind::ExternalPeerUnwired { .. } => "external_peer_unwired",
441        MobEventKind::FlowStarted { .. } => "flow_started",
442        MobEventKind::FlowCompleted { .. } => "flow_completed",
443        MobEventKind::FlowFailed { .. } => "flow_failed",
444        MobEventKind::FlowCanceled { .. } => "flow_canceled",
445        MobEventKind::StepDispatched { .. } => "step_dispatched",
446        MobEventKind::StepTargetCompleted { .. } => "step_target_completed",
447        MobEventKind::StepTargetFailed { .. } => "step_target_failed",
448        MobEventKind::StepCompleted { .. } => "step_completed",
449        MobEventKind::StepFailed { .. } => "step_failed",
450        MobEventKind::StepSkipped { .. } => "step_skipped",
451        MobEventKind::TopologyViolation { .. } => "topology_violation",
452        MobEventKind::SupervisorEscalation { .. } => "supervisor_escalation",
453        MobEventKind::OperatorActionRecorded { .. } => "operator_action_recorded",
454    }
455}
456
457/// Decode a raw mob-roster member id into the public alias space.
458///
459/// For identity-first members the roster id is the comms-safe encoding
460/// (`mk--rt_creview_csingleton_c0`), so every member-id slot surfaced on a
461/// projection boundary MUST be decoded before it reaches a console/SDK. This
462/// keeps the structural-event surface symmetric with every sibling projection
463/// (the agent-event SSE path, console aggregator, etc.) which all decode.
464fn decode_member_id(member_id: &str) -> String {
465    crate::member_comms_id::runtime_alias_str(member_id).into_owned()
466}
467
468/// Pull `(run_id, step_id, agent_identity)` out of variants that carry
469/// them. Variants without a given field return `None` for that slot.
470///
471/// Every `agent_identity` slot is decoded through [`decode_member_id`] so the
472/// projected envelope speaks the public alias space, not the comms-safe
473/// `mk--` roster encoding.
474pub(crate) fn extract_structural_fields(
475    kind: &MobEventKind,
476) -> (Option<String>, Option<String>, Option<String>) {
477    match kind {
478        MobEventKind::FlowStarted { run_id, .. }
479        | MobEventKind::FlowCompleted { run_id, .. }
480        | MobEventKind::FlowFailed { run_id, .. }
481        | MobEventKind::FlowCanceled { run_id, .. } => (Some(run_id.to_string()), None, None),
482        MobEventKind::StepDispatched {
483            run_id,
484            step_id,
485            target,
486        }
487        | MobEventKind::StepTargetCompleted {
488            run_id,
489            step_id,
490            target,
491        } => (
492            Some(run_id.to_string()),
493            Some(step_id.as_str().to_string()),
494            Some(decode_member_id(target.identity.as_str())),
495        ),
496        MobEventKind::StepTargetFailed {
497            run_id,
498            step_id,
499            target,
500            ..
501        } => (
502            Some(run_id.to_string()),
503            Some(step_id.as_str().to_string()),
504            Some(decode_member_id(target.identity.as_str())),
505        ),
506        MobEventKind::StepCompleted { run_id, step_id }
507        | MobEventKind::StepFailed {
508            run_id, step_id, ..
509        }
510        | MobEventKind::StepSkipped {
511            run_id, step_id, ..
512        } => (
513            Some(run_id.to_string()),
514            Some(step_id.as_str().to_string()),
515            None,
516        ),
517        MobEventKind::SupervisorEscalation {
518            run_id,
519            step_id,
520            escalated_to,
521        } => (
522            Some(run_id.to_string()),
523            Some(step_id.as_str().to_string()),
524            Some(decode_member_id(escalated_to.as_str())),
525        ),
526        MobEventKind::MemberSpawned(event) => (
527            None,
528            None,
529            Some(decode_member_id(event.agent_identity.as_str())),
530        ),
531        // Crash-recovery rebind fact for a member; attribute it to that member
532        // so console/SSE projection keys it under the right identity.
533        MobEventKind::MemberSessionBindingRecovered(event) => (
534            None,
535            None,
536            Some(decode_member_id(event.agent_identity.as_str())),
537        ),
538        MobEventKind::MemberRetired { agent_identity, .. }
539        | MobEventKind::MemberReset { agent_identity, .. } => {
540            (None, None, Some(decode_member_id(agent_identity.as_str())))
541        }
542        MobEventKind::MemberKickoffUpdated { member, .. } => {
543            (None, None, Some(decode_member_id(member.as_str())))
544        }
545        MobEventKind::ExternalPeerWired { local, .. }
546        | MobEventKind::ExternalPeerUnwired { local, .. } => {
547            (None, None, Some(decode_member_id(local.as_str())))
548        }
549        // MobOwnerBridgeSessionBound is mob-scoped (owner bridge binding):
550        // it carries no run/step/member structural fields.
551        MobEventKind::MobCreated { .. }
552        | MobEventKind::MobOwnerBridgeSessionBound { .. }
553        | MobEventKind::MobCompleted
554        | MobEventKind::MobDestroying
555        | MobEventKind::MobDestroyStorageFinalizing
556        | MobEventKind::MobReset
557        | MobEventKind::MembersWired { .. }
558        | MobEventKind::MembersWiredBatch { .. }
559        | MobEventKind::MembersUnwired { .. }
560        | MobEventKind::TopologyViolation { .. }
561        | MobEventKind::OperatorActionRecorded { .. } => (None, None, None),
562    }
563}
564
565#[cfg(test)]
566#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
567mod tests {
568    use super::*;
569    use chrono::Utc;
570    use meerkat_mob::event::{MemberSpawnedEvent, MemberWireEdge};
571    use meerkat_mob::ids::{
572        AgentIdentity, AgentRuntimeId, FenceToken, FlowId, Generation, MobId, ProfileName, RunId,
573        StepId,
574    };
575
576    fn mob_event(cursor: u64, kind: MobEventKind) -> MobEvent {
577        MobEvent {
578            cursor,
579            timestamp: Utc::now(),
580            mob_id: MobId::from("test-mob"),
581            kind,
582        }
583    }
584
585    #[tokio::test]
586    async fn projects_flow_started_with_run_id_and_upstream_cursor() {
587        let store = MobEventsStore::new();
588        let run_id = RunId::new();
589        let envelope = store
590            .project_mob_event(&mob_event(
591                42,
592                MobEventKind::FlowStarted {
593                    run_id: run_id.clone(),
594                    flow_id: FlowId::from("flow-a"),
595                    params: serde_json::json!({}),
596                },
597            ))
598            .await;
599        assert_eq!(envelope.kind, "flow_started");
600        assert_eq!(envelope.cursor, 42);
601        assert_eq!(envelope.event_id, "mob-evt-42");
602        assert_eq!(
603            envelope.run_id.as_deref(),
604            Some(run_id.to_string().as_str())
605        );
606        assert_eq!(envelope.step_id, None);
607        assert_eq!(envelope.mob_id, "test-mob");
608    }
609
610    #[tokio::test]
611    async fn projects_step_dispatched_with_run_step_target() {
612        let store = MobEventsStore::new();
613        let identity = AgentIdentity::from("worker-1");
614        let run_id = RunId::new();
615        let envelope = store
616            .project_mob_event(&mob_event(
617                7,
618                MobEventKind::StepDispatched {
619                    run_id: run_id.clone(),
620                    step_id: StepId::from("step-a"),
621                    target: AgentRuntimeId::initial(identity),
622                },
623            ))
624            .await;
625        assert_eq!(envelope.kind, "step_dispatched");
626        assert_eq!(envelope.cursor, 7);
627        assert_eq!(
628            envelope.run_id.as_deref(),
629            Some(run_id.to_string().as_str())
630        );
631        assert_eq!(envelope.step_id.as_deref(), Some("step-a"));
632        assert_eq!(envelope.agent_identity.as_deref(), Some("worker-1"));
633    }
634
635    #[tokio::test]
636    async fn projects_member_spawned_with_identity() {
637        let store = MobEventsStore::new();
638        let identity = AgentIdentity::from("researcher");
639        let envelope = store
640            .project_mob_event(&mob_event(
641                3,
642                MobEventKind::MemberSpawned(MemberSpawnedEvent::new(
643                    identity.clone(),
644                    Generation::INITIAL,
645                    FenceToken::new(1),
646                    AgentRuntimeId::initial(identity),
647                    ProfileName::from("worker"),
648                )),
649            ))
650            .await;
651        assert_eq!(envelope.kind, "member_spawned");
652        assert_eq!(envelope.agent_identity.as_deref(), Some("researcher"));
653    }
654
655    #[tokio::test]
656    async fn projects_identity_first_member_in_public_alias_space_and_filters_round_trip() {
657        // Identity-first members carry the comms-safe ENCODED roster id on the
658        // raw MobEvent (`mk--rt_creview_csingleton_c0`). The structural surface
659        // is a projection boundary, so the envelope's `agent_identity` MUST be
660        // decoded back to the public alias and a client filter keyed by that
661        // alias MUST match. Regression for the `mk--` leak / dropped-filter bug.
662        let alias = "rt:review:singleton:0";
663        let encoded = crate::member_comms_id::mob_member_id_str(alias).into_owned();
664        assert_ne!(encoded, alias, "alias must actually encode for this test");
665        let identity = AgentIdentity::from(encoded.as_str());
666
667        for kind in [
668            MobEventKind::MemberSpawned(MemberSpawnedEvent::new(
669                identity.clone(),
670                Generation::INITIAL,
671                FenceToken::new(1),
672                AgentRuntimeId::initial(identity.clone()),
673                ProfileName::from("review"),
674            )),
675            MobEventKind::MemberRetired {
676                agent_identity: identity.clone(),
677                generation: Generation::INITIAL,
678                role: ProfileName::from("review"),
679            },
680            MobEventKind::StepDispatched {
681                run_id: RunId::new(),
682                step_id: StepId::from("step-a"),
683                target: AgentRuntimeId::initial(identity.clone()),
684            },
685        ] {
686            let store = MobEventsStore::new();
687            let envelope = store.project_mob_event(&mob_event(1, kind)).await;
688
689            // The public surface speaks the alias, never the `mk--` encoding.
690            assert_eq!(
691                envelope.agent_identity.as_deref(),
692                Some(alias),
693                "structural envelope must decode the roster id to the alias"
694            );
695            assert!(
696                !envelope
697                    .agent_identity
698                    .as_deref()
699                    .unwrap()
700                    .starts_with("mk--"),
701                "encoded mk-- id must not leak onto the public surface"
702            );
703
704            // A client filtering by the public alias (identity or member_id)
705            // matches; the encoded form does not (it is never client-visible).
706            let query = EventQuery {
707                identity: Some(alias.to_string()),
708                ..EventQuery::default()
709            };
710            assert!(
711                envelope_matches(&envelope, &query),
712                "identity-filter by the public alias must match"
713            );
714            let member_query = EventQuery {
715                member_id: Some(alias.to_string()),
716                ..EventQuery::default()
717            };
718            assert!(
719                envelope_matches(&envelope, &member_query),
720                "member_id-filter by the public alias must match"
721            );
722        }
723    }
724
725    #[tokio::test]
726    async fn projects_members_wired_batch_as_compact_structural_event() {
727        let store = MobEventsStore::new();
728        let envelope = store
729            .project_mob_event(&mob_event(
730                8,
731                MobEventKind::MembersWiredBatch {
732                    edges: vec![MemberWireEdge {
733                        a: AgentIdentity::from("alpha"),
734                        b: AgentIdentity::from("beta"),
735                    }],
736                },
737            ))
738            .await;
739        assert_eq!(envelope.kind, "members_wired_batch");
740        assert_eq!(envelope.agent_identity, None);
741        assert_eq!(envelope.data["edges"][0]["a"], serde_json::json!("alpha"));
742        assert_eq!(envelope.data["edges"][0]["b"], serde_json::json!("beta"));
743    }
744
745    #[tokio::test]
746    async fn project_event_for_query_does_not_broadcast() {
747        let store = MobEventsStore::new();
748        let mut rx = store.subscribe();
749        let _ = store
750            .project_event_for_query(&mob_event(
751                1,
752                MobEventKind::FlowStarted {
753                    run_id: RunId::new(),
754                    flow_id: FlowId::from("flow-a"),
755                    params: serde_json::json!({}),
756                },
757            ))
758            .await;
759        // The query-projection variant is silent; the broadcast channel
760        // should not receive anything.
761        assert!(rx.try_recv().is_err());
762    }
763}