Skip to main content

macp_runtime/
runtime.rs

1use chrono::Utc;
2use std::sync::Arc;
3
4use crate::error::MacpError;
5use crate::extensions::ExtensionProviderRegistry;
6use crate::log_store::{EntryKind, LogEntry, LogStore};
7use crate::metrics::RuntimeMetrics;
8use crate::mode_registry::ModeRegistry;
9use crate::pb::{Envelope, ModeDescriptor};
10use crate::policy::registry::PolicyRegistry;
11use crate::policy::PolicyDefinition;
12use crate::registry::SessionRegistry;
13use crate::session::{
14    extract_ttl_ms, parse_session_start_payload, validate_canonical_session_start_payload,
15    validate_session_id_for_acceptance, Session, SessionState,
16};
17use crate::storage::StorageBackend;
18use crate::stream_bus::SessionStreamBus;
19
20#[derive(Debug)]
21pub struct ProcessResult {
22    pub session_state: SessionState,
23    pub duplicate: bool,
24}
25
26#[derive(Clone, Debug)]
27pub enum SessionLifecycleEvent {
28    Created { session_id: String },
29    Resolved { session_id: String },
30    Expired { session_id: String },
31    Suspended { session_id: String },
32    Resumed { session_id: String },
33    Cancelled { session_id: String },
34}
35
36pub struct Runtime {
37    pub storage: Arc<dyn StorageBackend>,
38    pub registry: Arc<SessionRegistry>,
39    pub log_store: Arc<LogStore>,
40    stream_bus: Arc<SessionStreamBus>,
41    signal_bus: tokio::sync::broadcast::Sender<Envelope>,
42    session_lifecycle_bus: tokio::sync::broadcast::Sender<SessionLifecycleEvent>,
43    mode_registry: Arc<ModeRegistry>,
44    policy_registry: Arc<PolicyRegistry>,
45    #[allow(dead_code)] // plumbed for future session-extension providers; register API TBD
46    extensions: Arc<ExtensionProviderRegistry>,
47    metrics: Arc<RuntimeMetrics>,
48    checkpoint_interval: usize,
49}
50
51impl Runtime {
52    pub fn new(
53        storage: Arc<dyn StorageBackend>,
54        registry: Arc<SessionRegistry>,
55        log_store: Arc<LogStore>,
56    ) -> Self {
57        Self::with_mode_registry(
58            storage,
59            registry,
60            log_store,
61            Arc::new(ModeRegistry::build_default(std::sync::Arc::new(
62                macp_policy::DefaultPolicyEvaluator,
63            ))),
64        )
65    }
66
67    pub fn with_mode_registry(
68        storage: Arc<dyn StorageBackend>,
69        registry: Arc<SessionRegistry>,
70        log_store: Arc<LogStore>,
71        mode_registry: Arc<ModeRegistry>,
72    ) -> Self {
73        Self::with_registries(
74            storage,
75            registry,
76            log_store,
77            mode_registry,
78            Arc::new(PolicyRegistry::new()),
79        )
80    }
81
82    pub fn with_registries(
83        storage: Arc<dyn StorageBackend>,
84        registry: Arc<SessionRegistry>,
85        log_store: Arc<LogStore>,
86        mode_registry: Arc<ModeRegistry>,
87        policy_registry: Arc<PolicyRegistry>,
88    ) -> Self {
89        let checkpoint_interval = std::env::var("MACP_CHECKPOINT_INTERVAL")
90            .ok()
91            .and_then(|v| v.parse().ok())
92            .unwrap_or(0); // 0 = disabled by default
93        let (signal_tx, _) = tokio::sync::broadcast::channel(256);
94        let (session_lifecycle_tx, _) = tokio::sync::broadcast::channel(64);
95        Self {
96            storage,
97            registry,
98            log_store,
99            stream_bus: Arc::new(SessionStreamBus::default()),
100            signal_bus: signal_tx,
101            session_lifecycle_bus: session_lifecycle_tx,
102            mode_registry,
103            policy_registry,
104            extensions: Arc::new(ExtensionProviderRegistry::new()),
105            metrics: Arc::new(RuntimeMetrics::new()),
106            checkpoint_interval,
107        }
108    }
109
110    /// Returns all mode names the runtime can handle (standards-track + extensions).
111    /// Used by Initialize and GetManifest to advertise full capability.
112    pub fn registered_mode_names(&self) -> Vec<String> {
113        self.mode_registry.all_mode_names()
114    }
115
116    /// Returns only standards-track mode descriptors for ListModes.
117    pub fn standard_mode_descriptors(&self) -> Vec<ModeDescriptor> {
118        self.mode_registry.standard_mode_descriptors()
119    }
120
121    /// Returns only extension mode descriptors for ListExtModes.
122    pub fn extension_mode_descriptors(&self) -> Vec<ModeDescriptor> {
123        self.mode_registry.extension_mode_descriptors()
124    }
125
126    pub fn register_extension(&self, descriptor: ModeDescriptor) -> Result<(), String> {
127        self.mode_registry.register_extension(descriptor)
128    }
129
130    pub fn unregister_extension(&self, mode: &str) -> Result<(), String> {
131        self.mode_registry.unregister_extension(mode)
132    }
133
134    pub fn promote_mode(&self, mode: &str, new_name: Option<&str>) -> Result<String, String> {
135        self.mode_registry.promote_mode(mode, new_name)
136    }
137
138    pub fn subscribe_mode_changes(&self) -> tokio::sync::broadcast::Receiver<()> {
139        self.mode_registry.subscribe_changes()
140    }
141
142    pub fn mode_registry(&self) -> &Arc<ModeRegistry> {
143        &self.mode_registry
144    }
145
146    // ── Policy registry delegation ──────────────────────────────────
147
148    pub fn register_policy(&self, definition: PolicyDefinition) -> Result<(), String> {
149        self.policy_registry.register(definition)
150    }
151
152    pub fn unregister_policy(&self, policy_id: &str) -> Result<(), String> {
153        self.policy_registry.unregister(policy_id)
154    }
155
156    pub fn get_policy(&self, policy_id: &str) -> Option<PolicyDefinition> {
157        self.policy_registry.get(policy_id)
158    }
159
160    pub fn list_policies(&self, mode_filter: Option<&str>) -> Vec<PolicyDefinition> {
161        self.policy_registry.list(mode_filter)
162    }
163
164    pub fn subscribe_policy_changes(&self) -> tokio::sync::broadcast::Receiver<()> {
165        self.policy_registry.subscribe_changes()
166    }
167
168    pub fn policy_registry(&self) -> &Arc<PolicyRegistry> {
169        &self.policy_registry
170    }
171
172    pub fn metrics(&self) -> &Arc<RuntimeMetrics> {
173        &self.metrics
174    }
175
176    pub fn subscribe_session_stream(
177        &self,
178        session_id: &str,
179    ) -> tokio::sync::broadcast::Receiver<Envelope> {
180        self.stream_bus.subscribe(session_id)
181    }
182
183    pub fn subscribe_signals(&self) -> tokio::sync::broadcast::Receiver<Envelope> {
184        self.signal_bus.subscribe()
185    }
186
187    pub fn subscribe_session_lifecycle(
188        &self,
189    ) -> tokio::sync::broadcast::Receiver<SessionLifecycleEvent> {
190        self.session_lifecycle_bus.subscribe()
191    }
192
193    /// RFC-MACP-0006 §3.2: Replay accepted envelopes from the session log for
194    /// passive subscribe, strictly after `after_sequence` (1-based accepted
195    /// ordinal, exclusive; 0 = from the start). `Err(base)` when the
196    /// requested range was discarded by log compaction — the caller must
197    /// surface an explicit error, not silently skip missing history.
198    pub async fn get_session_envelopes_after(
199        &self,
200        session_id: &str,
201        after_sequence: u64,
202    ) -> Result<Vec<Envelope>, u64> {
203        Ok(self
204            .log_store
205            .get_incoming_after(session_id, after_sequence)
206            .await?
207            .into_iter()
208            .map(|(_idx, entry)| Envelope {
209                macp_version: if entry.macp_version.is_empty() {
210                    "1.0".into()
211                } else {
212                    entry.macp_version
213                },
214                mode: entry.mode,
215                message_type: entry.message_type,
216                message_id: entry.message_id,
217                session_id: entry.session_id,
218                sender: entry.sender,
219                timestamp_unix_ms: if entry.timestamp_unix_ms != 0 {
220                    entry.timestamp_unix_ms
221                } else {
222                    entry.received_at_ms
223                },
224                payload: entry.raw_payload,
225            })
226            .collect())
227    }
228
229    fn publish_accepted_envelope(&self, env: &Envelope) {
230        if !env.session_id.is_empty() {
231            self.stream_bus.publish(&env.session_id, env.clone());
232        }
233    }
234
235    /// Whether the session's bound policy requests info-level per-message
236    /// audit lines (`rules.audit.level == "info"`).
237    fn audit_verbose(session: &Session) -> bool {
238        session
239            .policy_definition
240            .as_ref()
241            .and_then(|p| p.rules.get("audit"))
242            .and_then(|a| a.get("level"))
243            .and_then(|l| l.as_str())
244            == Some("info")
245    }
246
247    fn make_incoming_entry(env: &Envelope, received_at_ms: i64) -> LogEntry {
248        LogEntry {
249            message_id: env.message_id.clone(),
250            received_at_ms,
251            sender: env.sender.clone(),
252            message_type: env.message_type.clone(),
253            raw_payload: env.payload.clone(),
254            entry_kind: EntryKind::Incoming,
255            session_id: env.session_id.clone(),
256            mode: env.mode.clone(),
257            macp_version: env.macp_version.clone(),
258            timestamp_unix_ms: env.timestamp_unix_ms,
259            bound_mode_version: None,
260            semantics_rev: 0,
261            bound_max_suspend_ms: None,
262            compacted_incoming_ordinals: 0,
263        }
264    }
265
266    fn make_internal_entry(
267        message_type: &str,
268        payload: &[u8],
269        session_id: &str,
270        mode: &str,
271    ) -> LogEntry {
272        let now = Utc::now().timestamp_millis();
273        LogEntry {
274            message_id: String::new(),
275            received_at_ms: now,
276            sender: "_runtime".into(),
277            message_type: message_type.into(),
278            raw_payload: payload.to_vec(),
279            entry_kind: EntryKind::Internal,
280            session_id: session_id.into(),
281            mode: mode.into(),
282            macp_version: "1.0".into(),
283            timestamp_unix_ms: now,
284            bound_mode_version: None,
285            semantics_rev: 0,
286            bound_max_suspend_ms: None,
287            compacted_incoming_ordinals: 0,
288        }
289    }
290
291    async fn save_session_to_storage(&self, session: &Session) {
292        if let Err(err) = self.storage.save_session(session).await {
293            tracing::warn!(
294                session_id = %session.session_id,
295                error = %err,
296                "failed to persist session snapshot"
297            );
298        }
299    }
300
301    async fn maybe_expire_session(
302        &self,
303        session_id: &str,
304        session: &mut Session,
305    ) -> Result<bool, MacpError> {
306        let now = Utc::now().timestamp_millis();
307        // An Open session past its deadline, or a Suspended session that has
308        // exceeded the MAX_SUSPEND_MS cap (RFC-MACP-0001 §7.5), expires.
309        let expires = (session.state == SessionState::Open && now > session.ttl_expiry)
310            || (session.state == SessionState::Suspended && session.suspend_cap_exceeded(now));
311        if expires {
312            let entry = Self::make_internal_entry("TtlExpired", b"", session_id, &session.mode);
313            self.storage
314                .append_log_entry(session_id, &entry)
315                .await
316                .map_err(|_| MacpError::StorageFailed)?;
317            self.log_store.append(session_id, entry).await;
318            session.state = SessionState::Expired;
319            session.suspended_at_ms = None;
320            self.metrics.record_session_expired(&session.mode);
321            tracing::info!(session_id, "session expired via TTL");
322            let _ = self
323                .session_lifecycle_bus
324                .send(SessionLifecycleEvent::Expired {
325                    session_id: session_id.to_string(),
326                });
327            return Ok(true);
328        }
329        Ok(false)
330    }
331
332    pub async fn process(
333        &self,
334        env: &Envelope,
335        max_open_sessions: Option<usize>,
336    ) -> Result<ProcessResult, MacpError> {
337        match env.message_type.as_str() {
338            "SessionStart" => self.process_session_start(env, max_open_sessions).await,
339            "Signal" | "Progress" => self.process_signal(env).await,
340            _ => self.process_message(env).await,
341        }
342    }
343
344    async fn process_session_start(
345        &self,
346        env: &Envelope,
347        max_open_sessions: Option<usize>,
348    ) -> Result<ProcessResult, MacpError> {
349        if env.mode.trim().is_empty() {
350            return Err(MacpError::InvalidEnvelope);
351        }
352        validate_session_id_for_acceptance(&env.session_id)?;
353        let mode_name = env.mode.as_str();
354        let mode = self
355            .mode_registry
356            .get_mode(mode_name)
357            .ok_or(MacpError::UnknownMode)?;
358
359        let start_payload = parse_session_start_payload(&env.payload)?;
360        let require_complete_start = self.mode_registry.requires_strict_session_start(mode_name);
361        if require_complete_start {
362            validate_canonical_session_start_payload(&start_payload)?;
363        }
364
365        // Validate mode_version matches the registered descriptor's version.
366        // When the payload omits mode_version (only possible for non-strict
367        // extension modes), bind the descriptor's version instead of leaving the
368        // session bound to "" — an empty binding makes the Commitment version
369        // check vacuous (any commitment with mode_version "" would match).
370        // The bound value is recorded on the SessionStart log entry so replay
371        // uses the recorded binding, never the live registry.
372        let descriptor_version = self.mode_registry.get_mode_version(mode_name);
373        if let Some(descriptor_version) = &descriptor_version {
374            if !start_payload.mode_version.is_empty()
375                && &start_payload.mode_version != descriptor_version
376            {
377                tracing::warn!(
378                    mode = mode_name,
379                    payload_version = %start_payload.mode_version,
380                    descriptor_version = %descriptor_version,
381                    "mode_version mismatch"
382                );
383                return Err(MacpError::InvalidEnvelope);
384            }
385        }
386        let bound_mode_version: Option<String> = if start_payload.mode_version.is_empty() {
387            descriptor_version
388        } else {
389            None
390        };
391        let effective_mode_version = bound_mode_version
392            .clone()
393            .unwrap_or_else(|| start_payload.mode_version.clone());
394
395        let ttl_ms = extract_ttl_ms(&start_payload)?;
396
397        // Existing-session path: duplicate SessionStart handling. Take the
398        // shared handle under a brief map read, then check dedup under the
399        // session's own mutex (never await a session mutex while holding the
400        // map lock).
401        if let Some(existing) = self.registry.get_shared(&env.session_id).await {
402            let existing = existing.lock().await;
403            if existing.seen_message_ids.contains(&env.message_id) {
404                return Ok(ProcessResult {
405                    session_state: existing.state.clone(),
406                    duplicate: true,
407                });
408            }
409            return Err(MacpError::SessionAlreadyExists);
410        }
411
412        // Resolve the governance policy for this session.
413        // RFC-MACP-0012 §6.1: policy_version is resolved at SessionStart; empty
414        // resolves to "policy.default". The resolved PolicyDescriptor is stored
415        // immutably on the session for deterministic replay (RFC-MACP-0003 §3).
416        let effective_policy_version = if start_payload.policy_version.is_empty() {
417            crate::policy::defaults::DEFAULT_POLICY_ID.to_string()
418        } else {
419            start_payload.policy_version.clone()
420        };
421        let policy_definition = match self.policy_registry.resolve(&effective_policy_version) {
422            Ok(policy) => {
423                // RFC 6.1: reject if policy mode doesn't match session mode
424                if policy.mode != "*" && policy.mode != mode_name {
425                    return Err(MacpError::InvalidPolicyDefinition);
426                }
427                Some(policy)
428            }
429            Err(_) => {
430                return Err(MacpError::UnknownPolicyVersion);
431            }
432        };
433
434        let accepted_at = Utc::now().timestamp_millis();
435        // RFC-MACP-0003 §2: TTL deadline is computed from the SessionStart
436        // envelope's timestamp_unix_ms, not wall-clock time. This ensures
437        // deterministic replay. Fall back to accepted_at if envelope has no timestamp.
438        let ttl_base = if env.timestamp_unix_ms > 0 {
439            env.timestamp_unix_ms
440        } else {
441            accepted_at
442        };
443        let ttl_expiry = ttl_base.saturating_add(ttl_ms);
444        // Resolve the suspension cap (RFC-MACP-0001 §7.5): the payload's
445        // positive value, else the runtime default. The RESOLVED value is
446        // bound on the session and recorded on the SessionStart log entry so
447        // replay uses it — never live configuration (RFC-MACP-0003 §2).
448        let bound_max_suspend_ms = if start_payload.max_suspend_ms > 0 {
449            start_payload.max_suspend_ms
450        } else {
451            macp_core::session::MAX_SUSPEND_MS
452        };
453        let session = Session::builder(env.session_id.clone(), mode_name, env.sender.clone())
454            .ttl_expiry(ttl_expiry)
455            .ttl_ms(ttl_ms)
456            .max_suspend_ms(bound_max_suspend_ms)
457            .started_at_unix_ms(accepted_at)
458            .participants(start_payload.participants.clone())
459            .intent(start_payload.intent.clone())
460            .mode_version(effective_mode_version)
461            .configuration_version(start_payload.configuration_version.clone())
462            .policy_version(effective_policy_version)
463            .context_id(start_payload.context_id.clone())
464            .extensions(start_payload.extensions.clone())
465            .roots(start_payload.roots.clone())
466            .policy_definition(policy_definition)
467            .build();
468
469        let response = mode.on_session_start(&session, env)?;
470        let semantics_rev = session.semantics_rev;
471
472        // Reserve the session id atomically (dedup + max_open TOCTOU safety),
473        // then do the storage I/O with the map lock RELEASED and only this
474        // session's mutex held — a slow fsync on one SessionStart no longer
475        // stalls every other session.
476        let shared = std::sync::Arc::new(tokio::sync::Mutex::new(session));
477        // Lock our own reservation BEFORE publishing it, so any concurrent
478        // access to this session id blocks until start completes or rolls back.
479        let mut session_guard = shared
480            .clone()
481            .try_lock_owned()
482            .expect("freshly created mutex is uncontended");
483        {
484            let mut map = self.registry.sessions.write().await;
485            if map.contains_key(&env.session_id) {
486                // Lost a same-id race after the earlier existence check.
487                return Err(MacpError::SessionAlreadyExists);
488            }
489            if let Some(max_open) = max_open_sessions {
490                let now = Utc::now().timestamp_millis();
491                let mut count = 0usize;
492                for arc in map.values() {
493                    // Never await a session mutex under the map lock: a
494                    // locked entry is in-flight and therefore Open —
495                    // counting it is the conservative direction for a
496                    // rate limit.
497                    let counts = match arc.try_lock() {
498                        Ok(s) => {
499                            s.initiator_sender == env.sender
500                                && s.state == SessionState::Open
501                                && now <= s.ttl_expiry
502                        }
503                        Err(_) => true,
504                    };
505                    if counts {
506                        count += 1;
507                    }
508                }
509                if count >= max_open {
510                    return Err(MacpError::RateLimited);
511                }
512            }
513            map.insert(env.session_id.clone(), std::sync::Arc::clone(&shared));
514        }
515
516        // Roll back the reservation on any storage failure: poison the
517        // placeholder (non-Open) BEFORE removing it so a waiter that already
518        // cloned the Arc fails the OPEN gate instead of processing a message
519        // for a session whose SessionStart never committed.
520        let rollback = |runtime: &Self, session_guard: &mut Session| {
521            session_guard.state = SessionState::Expired;
522            let registry = std::sync::Arc::clone(&runtime.registry);
523            let sid = env.session_id.clone();
524            async move {
525                let mut map = registry.sessions.write().await;
526                map.remove(&sid);
527            }
528        };
529
530        // 1. Create storage directory and write log entry (COMMIT POINT)
531        if self
532            .storage
533            .create_session_storage(&env.session_id)
534            .await
535            .is_err()
536        {
537            rollback(self, &mut session_guard).await;
538            return Err(MacpError::StorageFailed);
539        }
540        let mut incoming_entry = Self::make_incoming_entry(env, accepted_at);
541        incoming_entry.bound_mode_version = bound_mode_version;
542        incoming_entry.semantics_rev = semantics_rev;
543        incoming_entry.bound_max_suspend_ms = Some(bound_max_suspend_ms);
544        if self
545            .storage
546            .append_log_entry(&env.session_id, &incoming_entry)
547            .await
548            .is_err()
549        {
550            rollback(self, &mut session_guard).await;
551            return Err(MacpError::StorageFailed);
552        }
553
554        // 2. Update in-memory caches
555        self.log_store.create_session_log(&env.session_id).await;
556        self.log_store.append(&env.session_id, incoming_entry).await;
557
558        session_guard
559            .seen_message_ids
560            .insert(env.message_id.clone());
561        session_guard.apply_mode_response(response);
562
563        let result_state = session_guard.state.clone();
564        // 3. Session snapshot — best-effort AFTER the durable append. The log
565        // entry above is the COMMIT POINT: once it is durable, the session
566        // exists and replay reconstructs it, so a snapshot failure must NOT
567        // fail (or roll back) the start. The previous fatal+rollback here was
568        // incoherent past the commit point — it could not un-append the
569        // durable SessionStart, so the "failed" session resurrected on
570        // restart, and a same-id client retry appended a SECOND SessionStart
571        // that made the log unreplayable.
572        if let Err(err) = self.storage.save_session(&session_guard).await {
573            tracing::warn!(
574                session_id = %session_guard.session_id,
575                error = %err,
576                "failed to persist session snapshot at SessionStart (recoverable via replay)"
577            );
578        }
579        self.metrics.record_session_start(mode_name);
580        tracing::info!(
581            session_id = %env.session_id,
582            mode = mode_name,
583            sender = %env.sender,
584            "session started"
585        );
586        // Publish while still holding the session mutex — publish order must
587        // equal acceptance order (process_message publishes under the mutex
588        // too). Publishing after the drop let a subscriber observe a later
589        // message's broadcast BEFORE this SessionStart's, breaking the FIFO
590        // premise the subscribe-window dedupe relies on.
591        self.publish_accepted_envelope(env);
592        drop(session_guard);
593        let _ = self
594            .session_lifecycle_bus
595            .send(SessionLifecycleEvent::Created {
596                session_id: env.session_id.clone(),
597            });
598
599        Ok(ProcessResult {
600            session_state: result_state,
601            duplicate: false,
602        })
603    }
604
605    /// Process a session-scoped message following the RFC-MACP-0001 Section 7.3
606    /// terminal-state transition order:
607    /// 1. Check session OPEN
608    /// 2. Validate message (mode.authorize_sender + mode.on_message)
609    /// 3. Accept into history (log_store.append)
610    /// 4. Transition to RESOLVED (session.apply_mode_response)
611    /// 5. Reject subsequent messages (enforced by step 1 on next call)
612    async fn process_message(&self, env: &Envelope) -> Result<ProcessResult, MacpError> {
613        // Per-session serialization (RFC-0001 §8.1): clone the shared handle
614        // under a brief map read, then hold ONLY this session's mutex across
615        // validate + append (fsync) + commit. Different sessions' appends
616        // proceed in parallel; the same session's appends stay strictly
617        // ordered (which also keeps RocksDB's per-session next_seq
618        // read-modify-write safe).
619        let shared = self
620            .registry
621            .get_shared(&env.session_id)
622            .await
623            .ok_or(MacpError::UnknownSession)?;
624        let mut session_guard = shared.lock().await;
625        let session = &mut *session_guard;
626
627        // Per-message kernel invariants (dedup, mode-binding, TTL, the monotonic
628        // OPEN gate) live in `macp_modes::step` so any consumer of the
629        // coordination core runs the identical checks. The runtime is the first
630        // caller: it drives the phases here so it can interpose its append-only
631        // write between validation and commit (a failed write must not consume a
632        // dedup slot) — which a single all-in-one step could not preserve.
633        let now_ms = chrono::Utc::now().timestamp_millis();
634        match macp_modes::step::check_preconditions(session, env, now_ms)? {
635            macp_modes::step::Precheck::Duplicate => {
636                return Ok(ProcessResult {
637                    session_state: session.state.clone(),
638                    duplicate: true,
639                });
640            }
641            macp_modes::step::Precheck::Expired => {
642                // Durable expiry via the existing path: it appends the
643                // `TtlExpired` log entry, updates metrics/lifecycle, and marks
644                // the session Expired. `check_preconditions` and
645                // `maybe_expire_session` share the same strict `>`, OPEN-guarded
646                // rule, so this always expires.
647                let expired = self.maybe_expire_session(&env.session_id, session).await?;
648                debug_assert!(expired, "check_preconditions reported Expired");
649                self.save_session_to_storage(session).await;
650                return Err(MacpError::TtlExpired);
651            }
652            macp_modes::step::Precheck::Proceed => {}
653        }
654
655        let mode = self
656            .mode_registry
657            .get_mode(&session.mode)
658            .ok_or(MacpError::UnknownMode)?;
659        mode.authorize_sender(session, env)?;
660        // One acceptance clock for both the mode call and the log entry, so
661        // replay (which re-reads received_at_ms) observes the identical time.
662        let accepted_at_ms = Utc::now().timestamp_millis();
663        let response = mode.on_message_at(
664            session,
665            env,
666            &macp_core::mode::MessageContext::new(accepted_at_ms),
667        )?;
668
669        // 1. COMMIT POINT: write log entry to disk
670        let incoming_entry = Self::make_incoming_entry(env, accepted_at_ms);
671        self.storage
672            .append_log_entry(&env.session_id, &incoming_entry)
673            .await
674            .map_err(|_| MacpError::StorageFailed)?;
675
676        // 2. Update in-memory state via the shared commit phase (consume dedup
677        //    slot, record participant activity, apply mode response) — the exact
678        //    sequence a library consumer runs through `macp_modes::step`.
679        self.log_store.append(&env.session_id, incoming_entry).await;
680        let result_state = macp_modes::step::commit(session, env, response, now_ms);
681
682        self.metrics.record_message_accepted(&session.mode);
683        if env.message_type == "Commitment" {
684            self.metrics.record_commitment_accepted(&session.mode);
685        }
686
687        // Policy-driven audit verbosity (E3b): a bound policy may request
688        // per-message audit lines at info level via an `audit.level` rules
689        // block ("info"); default stays debug. Mode rule schemas ignore
690        // unknown blocks, so `audit` composes with any mode's rules.
691        if Self::audit_verbose(session) {
692            tracing::info!(
693                session_id = %env.session_id,
694                message_type = %env.message_type,
695                sender = %env.sender,
696                state = ?result_state,
697                "message accepted (audit)"
698            );
699        } else {
700            tracing::debug!(
701                session_id = %env.session_id,
702                message_type = %env.message_type,
703                sender = %env.sender,
704                state = ?result_state,
705                "message accepted"
706            );
707        }
708
709        if result_state == SessionState::Resolved {
710            self.metrics.record_session_resolved(&session.mode);
711            tracing::info!(session_id = %env.session_id, mode = %session.mode, "session resolved");
712            let _ = self
713                .session_lifecycle_bus
714                .send(SessionLifecycleEvent::Resolved {
715                    session_id: env.session_id.clone(),
716                });
717        }
718
719        // 3. Best-effort session save + checkpoint
720        self.save_session_to_storage(session).await;
721        if result_state == SessionState::Resolved {
722            if !self.maybe_compact_log(&env.session_id, session).await {
723                self.force_insert_checkpoint(&env.session_id, session).await;
724            }
725        } else {
726            self.maybe_insert_checkpoint(&env.session_id, session).await;
727        }
728        self.publish_accepted_envelope(env);
729
730        Ok(ProcessResult {
731            session_state: result_state,
732            duplicate: false,
733        })
734    }
735
736    /// Process a Signal or Progress envelope. Signals are informational out-of-band
737    /// notifications. Progress messages carry structured ProgressPayload.
738    /// Neither mutates session state — both are broadcast to subscribers.
739    async fn process_signal(&self, env: &Envelope) -> Result<ProcessResult, MacpError> {
740        // RFC-MACP-0001 §4 / RFC-MACP-0010: validate SignalPayload structure.
741        // signal_type must be non-empty when a payload is present.
742        if env.message_type == "Signal" && !env.payload.is_empty() {
743            let signal: crate::pb::SignalPayload =
744                prost::Message::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
745            if signal.signal_type.trim().is_empty() {
746                return Err(MacpError::InvalidPayload);
747            }
748        }
749        // RFC-MACP-0001: validate ProgressPayload structure for Progress messages.
750        if env.message_type == "Progress" && !env.payload.is_empty() {
751            let _: crate::pb::ProgressPayload =
752                prost::Message::decode(&*env.payload).map_err(|_| MacpError::InvalidPayload)?;
753        }
754        tracing::debug!(
755            sender = %env.sender,
756            message_id = %env.message_id,
757            message_type = %env.message_type,
758            "signal received"
759        );
760        let _ = self.signal_bus.send(env.clone());
761        Ok(ProcessResult {
762            session_state: SessionState::Open,
763            duplicate: false,
764        })
765    }
766
767    pub async fn get_session_checked(&self, session_id: &str) -> Option<Session> {
768        let shared = self.registry.get_shared(session_id).await?;
769        let mut session = shared.lock().await;
770        let changed = self
771            .maybe_expire_session(session_id, &mut session)
772            .await
773            .unwrap_or(false);
774        if changed {
775            self.save_session_to_storage(&session).await;
776        }
777        Some(session.clone())
778    }
779
780    /// Cancel a session. The `cancelled_by` parameter MUST be the authenticated
781    /// sender of the CancelSession RPC (RFC-MACP-0001 Section 7.3: CancelSession
782    /// is a Core control-plane message; mode authorization does not apply).
783    pub async fn cancel_session(
784        &self,
785        session_id: &str,
786        reason: &str,
787        cancelled_by: &str,
788    ) -> Result<ProcessResult, MacpError> {
789        let shared = self
790            .registry
791            .get_shared(session_id)
792            .await
793            .ok_or(MacpError::UnknownSession)?;
794        let mut session_guard = shared.lock().await;
795        let session = &mut *session_guard;
796
797        self.maybe_expire_session(session_id, session).await?;
798
799        // Already terminal (Resolved/Expired/Cancelled): nothing to do. An Open
800        // or Suspended session can still be cancelled (RFC-MACP-0001 §7.2/§7.3).
801        if session.state.is_terminal() {
802            let result_state = session.state.clone();
803            self.save_session_to_storage(session).await;
804            return Ok(ProcessResult {
805                session_state: result_state,
806                duplicate: false,
807            });
808        }
809
810        // RFC-MACP-0001: runtime encodes a proper SessionCancelPayload with
811        // `cancelled_by` set to the authenticated sender identity.
812        let cancel_payload = crate::pb::SessionCancelPayload {
813            reason: reason.to_string(),
814            cancelled_by: cancelled_by.to_string(),
815        };
816        let cancel_entry = Self::make_internal_entry(
817            "SessionCancel",
818            &prost::Message::encode_to_vec(&cancel_payload),
819            session_id,
820            &session.mode,
821        );
822        self.storage
823            .append_log_entry(session_id, &cancel_entry)
824            .await
825            .map_err(|_| MacpError::StorageFailed)?;
826        self.log_store.append(session_id, cancel_entry).await;
827        // RFC-MACP-0001 §7.3: cancellation terminates as CANCELLED (distinct
828        // from EXPIRED) — `cancel()` also clears any suspension marker.
829        let _ = session.cancel();
830        self.save_session_to_storage(session).await;
831        if !self.maybe_compact_log(session_id, session).await {
832            self.force_insert_checkpoint(session_id, session).await;
833        }
834        self.metrics.record_session_cancelled(&session.mode);
835        tracing::info!(session_id, reason, "session cancelled");
836        let _ = self
837            .session_lifecycle_bus
838            .send(SessionLifecycleEvent::Cancelled {
839                session_id: session_id.to_string(),
840            });
841
842        Ok(ProcessResult {
843            session_state: SessionState::Cancelled,
844            duplicate: false,
845        })
846    }
847
848    /// Suspend an `Open` session (RFC-MACP-0001 §7.5). Appends a `SessionSuspend`
849    /// annotation, transitions Open -> Suspended, and emits a lifecycle event.
850    /// The session's TTL is banked and restored on resume.
851    pub async fn suspend_session(
852        &self,
853        session_id: &str,
854        reason: &str,
855        suspended_by: &str,
856    ) -> Result<ProcessResult, MacpError> {
857        let shared = self
858            .registry
859            .get_shared(session_id)
860            .await
861            .ok_or(MacpError::UnknownSession)?;
862        let mut session_guard = shared.lock().await;
863        let session = &mut *session_guard;
864
865        self.maybe_expire_session(session_id, session).await?;
866        if session.state != SessionState::Open {
867            return Err(MacpError::SessionNotOpen);
868        }
869
870        let now_ms = chrono::Utc::now().timestamp_millis();
871        let payload = crate::pb::SessionSuspendPayload {
872            reason: reason.to_string(),
873            suspended_by: suspended_by.to_string(),
874        };
875        let entry = Self::make_internal_entry(
876            "SessionSuspend",
877            &prost::Message::encode_to_vec(&payload),
878            session_id,
879            &session.mode,
880        );
881        self.storage
882            .append_log_entry(session_id, &entry)
883            .await
884            .map_err(|_| MacpError::StorageFailed)?;
885        self.log_store.append(session_id, entry).await;
886        session.suspend(now_ms)?;
887        self.save_session_to_storage(session).await;
888        self.metrics.record_session_suspended(&session.mode);
889        tracing::info!(session_id, reason, "session suspended");
890        let _ = self
891            .session_lifecycle_bus
892            .send(SessionLifecycleEvent::Suspended {
893                session_id: session_id.to_string(),
894            });
895
896        Ok(ProcessResult {
897            session_state: SessionState::Suspended,
898            duplicate: false,
899        })
900    }
901
902    /// Resume a `Suspended` session (RFC-MACP-0001 §7.5), banking the suspended
903    /// duration into the TTL deadline. If the `MAX_SUSPEND_MS` cap is exceeded,
904    /// the session is force-expired instead.
905    pub async fn resume_session(
906        &self,
907        session_id: &str,
908        reason: &str,
909        resumed_by: &str,
910    ) -> Result<ProcessResult, MacpError> {
911        let shared = self
912            .registry
913            .get_shared(session_id)
914            .await
915            .ok_or(MacpError::UnknownSession)?;
916        let mut session_guard = shared.lock().await;
917        let session = &mut *session_guard;
918
919        if session.state != SessionState::Suspended {
920            return Err(MacpError::SessionNotOpen);
921        }
922
923        let now_ms = chrono::Utc::now().timestamp_millis();
924        let banked_before = session
925            .suspended_at_ms
926            .map(|at| (now_ms - at).max(0))
927            .unwrap_or(0);
928        let payload = crate::pb::SessionResumePayload {
929            reason: reason.to_string(),
930            resumed_by: resumed_by.to_string(),
931            banked_ms: banked_before,
932        };
933        let entry = Self::make_internal_entry(
934            "SessionResume",
935            &prost::Message::encode_to_vec(&payload),
936            session_id,
937            &session.mode,
938        );
939        self.storage
940            .append_log_entry(session_id, &entry)
941            .await
942            .map_err(|_| MacpError::StorageFailed)?;
943        self.log_store.append(session_id, entry).await;
944
945        // `resume` banks the TTL; if the suspend cap is exceeded it force-expires.
946        match session.resume(now_ms) {
947            Ok(()) => {
948                self.save_session_to_storage(session).await;
949                self.metrics.record_session_resumed(&session.mode);
950                tracing::info!(session_id, reason, "session resumed");
951                let _ = self
952                    .session_lifecycle_bus
953                    .send(SessionLifecycleEvent::Resumed {
954                        session_id: session_id.to_string(),
955                    });
956                Ok(ProcessResult {
957                    session_state: SessionState::Open,
958                    duplicate: false,
959                })
960            }
961            Err(_) => {
962                // MAX_SUSPEND_MS exceeded: the session is now Expired.
963                self.save_session_to_storage(session).await;
964                self.metrics.record_session_expired(&session.mode);
965                let _ = self
966                    .session_lifecycle_bus
967                    .send(SessionLifecycleEvent::Expired {
968                        session_id: session_id.to_string(),
969                    });
970                Err(MacpError::TtlExpired)
971            }
972        }
973    }
974
975    /// Best-effort log compaction for terminal sessions.
976    /// Returns `true` if compaction succeeded, `false` if skipped or failed.
977    async fn maybe_compact_log(&self, session_id: &str, session: &Session) -> bool {
978        // Ordinal accounting for the sequence contract: the checkpoint must
979        // record every accepted ordinal it discards, including any base from
980        // a prior compaction recorded in the current log.
981        let discarded = match self.log_store.get_log(session_id).await {
982            Some(entries) => {
983                let prior_base: u64 = entries
984                    .iter()
985                    .filter(|e| e.entry_kind == EntryKind::Checkpoint)
986                    .map(|e| e.compacted_incoming_ordinals)
987                    .max()
988                    .unwrap_or(0);
989                prior_base
990                    + entries
991                        .iter()
992                        .filter(|e| e.entry_kind == EntryKind::Incoming)
993                        .count() as u64
994            }
995            None => 0,
996        };
997        match crate::storage::compaction::compact_session_log(
998            &*self.storage,
999            session_id,
1000            session,
1001            discarded,
1002        )
1003        .await
1004        {
1005            Ok(checkpoint) => {
1006                // Keep the in-memory log in step with storage — previously
1007                // only disk was rewritten, so memory and disk diverged and
1008                // post-restart passive-subscribe history vanished silently.
1009                self.log_store
1010                    .replace_session_log(session_id, vec![checkpoint])
1011                    .await;
1012                true
1013            }
1014            Err(e) => {
1015                tracing::debug!(
1016                    session_id,
1017                    error = %e,
1018                    "log compaction skipped (backend may not support it)"
1019                );
1020                false
1021            }
1022        }
1023    }
1024
1025    /// Force a checkpoint entry regardless of interval settings.
1026    /// Used as a fallback when compaction fails on terminal sessions.
1027    async fn force_insert_checkpoint(&self, session_id: &str, session: &Session) {
1028        let persisted = crate::registry::PersistedSession::from(session);
1029        let raw_payload = match serde_json::to_vec(&persisted) {
1030            Ok(bytes) => bytes,
1031            Err(e) => {
1032                tracing::warn!(session_id, error = %e, "failed to serialize forced checkpoint");
1033                return;
1034            }
1035        };
1036        let now = Utc::now().timestamp_millis();
1037        let checkpoint = LogEntry {
1038            message_id: String::new(),
1039            received_at_ms: now,
1040            sender: "_runtime".into(),
1041            message_type: "Checkpoint".into(),
1042            raw_payload,
1043            entry_kind: EntryKind::Checkpoint,
1044            session_id: session_id.into(),
1045            mode: session.mode.clone(),
1046            macp_version: String::new(),
1047            timestamp_unix_ms: now,
1048            bound_mode_version: None,
1049            semantics_rev: 0,
1050            bound_max_suspend_ms: None,
1051            compacted_incoming_ordinals: 0,
1052        };
1053        if let Err(e) = self.storage.append_log_entry(session_id, &checkpoint).await {
1054            tracing::warn!(session_id, error = %e, "failed to write forced checkpoint");
1055            return;
1056        }
1057        self.log_store.append(session_id, checkpoint).await;
1058        tracing::debug!(
1059            session_id,
1060            "forced checkpoint inserted for terminal session"
1061        );
1062    }
1063
1064    /// Insert a checkpoint entry if the log has reached the configured interval.
1065    async fn maybe_insert_checkpoint(&self, session_id: &str, session: &Session) {
1066        if self.checkpoint_interval == 0 {
1067            return;
1068        }
1069        let log_len = self
1070            .log_store
1071            .get_log(session_id)
1072            .await
1073            .map(|l| l.len())
1074            .unwrap_or(0);
1075        // Only checkpoint at interval boundaries, and not on the first entry
1076        if log_len < self.checkpoint_interval || log_len % self.checkpoint_interval != 0 {
1077            return;
1078        }
1079        self.force_insert_checkpoint(session_id, session).await;
1080        tracing::debug!(session_id, log_len, "checkpoint inserted at interval");
1081    }
1082
1083    /// Expire all sessions that have exceeded their TTL.
1084    /// Called by the background cleanup task to proactively transition
1085    /// stale sessions without waiting for the next incoming message.
1086    pub async fn cleanup_expired_sessions(&self) {
1087        let now = Utc::now().timestamp_millis();
1088        // Snapshot the shared handles under a brief map read; never hold the
1089        // map lock across per-session locks or storage I/O. Each session is
1090        // re-checked under its own mutex (it may have been touched since the
1091        // snapshot).
1092        let candidates: Vec<(String, crate::registry::SharedSession)> = {
1093            let guard = self.registry.sessions.read().await;
1094            guard
1095                .iter()
1096                .map(|(id, arc)| (id.clone(), std::sync::Arc::clone(arc)))
1097                .collect()
1098        };
1099
1100        let mut expired_count = 0usize;
1101        for (session_id, shared) in candidates {
1102            let mut session = shared.lock().await;
1103            if session.state != SessionState::Open || now <= session.ttl_expiry {
1104                continue;
1105            }
1106            let entry = Self::make_internal_entry("TtlExpired", b"", &session_id, &session.mode);
1107            if let Err(e) = self.storage.append_log_entry(&session_id, &entry).await {
1108                tracing::warn!(
1109                    session_id,
1110                    error = %e,
1111                    "failed to write TTL expiry during cleanup"
1112                );
1113                continue;
1114            }
1115            self.log_store.append(&session_id, entry).await;
1116            session.state = SessionState::Expired;
1117            self.metrics.record_session_expired(&session.mode);
1118            self.save_session_to_storage(&session).await;
1119            if !self.maybe_compact_log(&session_id, &session).await {
1120                self.force_insert_checkpoint(&session_id, &session).await;
1121            }
1122            expired_count += 1;
1123            tracing::info!(session_id = %session_id, "session expired via background cleanup");
1124            let _ = self
1125                .session_lifecycle_bus
1126                .send(SessionLifecycleEvent::Expired {
1127                    session_id: session_id.clone(),
1128                });
1129        }
1130
1131        if expired_count > 0 {
1132            tracing::info!(count = expired_count, "background cleanup expired sessions");
1133        }
1134    }
1135
1136    /// Delete terminal sessions' durable data older than `retention_secs`
1137    /// (opt-in via `MACP_SESSION_DISK_RETENTION_SECS`). Before this existed,
1138    /// `storage.delete_session` had no callers at all: disk grew without
1139    /// bound and every restart reloaded every session ever completed.
1140    /// Enumerates STORAGE (not memory — eviction may already have dropped the
1141    /// registry entry), deletes the session's snapshot+log, and clears any
1142    /// in-memory remnants. Returns the number of sessions deleted.
1143    pub async fn gc_disk_sessions(&self, retention_secs: u64) -> usize {
1144        let now = Utc::now().timestamp_millis();
1145        let cutoff = now - (retention_secs as i64 * 1000);
1146        let ids = match self.storage.list_session_ids().await {
1147            Ok(ids) => ids,
1148            Err(e) => {
1149                tracing::warn!(error = %e, "disk GC: cannot list sessions");
1150                return 0;
1151            }
1152        };
1153        let mut removed = 0usize;
1154        for id in ids {
1155            // Prefer the in-memory state when present (cheap + current);
1156            // fall back to the stored snapshot for evicted sessions.
1157            let eligible = if let Some(shared) = self.registry.get_shared(&id).await {
1158                let s = shared.lock().await;
1159                s.state.is_terminal() && s.started_at_unix_ms < cutoff
1160            } else {
1161                match self.storage.load_session(&id).await {
1162                    Ok(Some(s)) => s.state.is_terminal() && s.started_at_unix_ms < cutoff,
1163                    // No snapshot (or unreadable): leave it for operator
1164                    // inspection rather than guessing.
1165                    _ => false,
1166                }
1167            };
1168            if !eligible {
1169                continue;
1170            }
1171            match self.storage.delete_session(&id).await {
1172                Ok(()) => {
1173                    {
1174                        let mut guard = self.registry.sessions.write().await;
1175                        guard.remove(&id);
1176                    }
1177                    self.log_store.remove_session_log(&id).await;
1178                    let _ = self.stream_bus.remove_if_unused(&id);
1179                    removed += 1;
1180                }
1181                Err(e) => {
1182                    tracing::warn!(session_id = %id, error = %e, "disk GC: delete failed");
1183                }
1184            }
1185        }
1186        if removed > 0 {
1187            tracing::info!(count = removed, "disk GC removed terminal sessions");
1188        }
1189        removed
1190    }
1191
1192    /// Evict resolved/expired sessions older than `retention_secs` from
1193    /// memory: the registry entry, the in-memory log cache, AND the stream
1194    /// broadcast channel (all three previously grew for the process lifetime;
1195    /// the log cache and stream bus were never evicted at all). Sessions
1196    /// remain queryable from durable storage after eviction.
1197    pub async fn evict_stale_sessions(&self, retention_secs: u64) {
1198        let now = Utc::now().timestamp_millis();
1199        let cutoff = now - (retention_secs as i64 * 1000);
1200
1201        let candidates: Vec<(String, crate::registry::SharedSession)> = {
1202            let guard = self.registry.sessions.read().await;
1203            guard
1204                .iter()
1205                .map(|(id, arc)| (id.clone(), std::sync::Arc::clone(arc)))
1206                .collect()
1207        };
1208        let mut evict_ids = Vec::new();
1209        for (id, shared) in candidates {
1210            let session = shared.lock().await;
1211            if matches!(
1212                session.state,
1213                SessionState::Resolved | SessionState::Expired | SessionState::Cancelled
1214            ) && session.started_at_unix_ms < cutoff
1215            {
1216                evict_ids.push(id);
1217            }
1218        }
1219
1220        if evict_ids.is_empty() {
1221            return;
1222        }
1223        {
1224            let mut guard = self.registry.sessions.write().await;
1225            for id in &evict_ids {
1226                guard.remove(id);
1227            }
1228        }
1229        for id in &evict_ids {
1230            self.log_store.remove_session_log(id).await;
1231            // Left in place if a subscriber is still attached; retried on the
1232            // next sweep once receivers drop.
1233            let _ = self.stream_bus.remove_if_unused(id);
1234        }
1235        tracing::info!(
1236            count = evict_ids.len(),
1237            "evicted stale sessions from memory (registry + log cache + stream bus)"
1238        );
1239    }
1240}
1241
1242#[cfg(test)]
1243mod tests {
1244    use super::*;
1245    use crate::decision_pb::ProposalPayload;
1246    use crate::pb::{CommitmentPayload, SessionStartPayload};
1247    use prost::Message;
1248
1249    fn new_sid() -> String {
1250        uuid::Uuid::new_v4().as_hyphenated().to_string()
1251    }
1252
1253    fn make_runtime() -> Runtime {
1254        let storage: Arc<dyn StorageBackend> = Arc::new(crate::storage::MemoryBackend);
1255        let registry = Arc::new(SessionRegistry::new());
1256        let log_store = Arc::new(LogStore::new());
1257        Runtime::new(storage, registry, log_store)
1258    }
1259
1260    fn session_start(participants: Vec<String>) -> Vec<u8> {
1261        SessionStartPayload {
1262            intent: "intent".into(),
1263            participants,
1264            mode_version: "1.0.0".into(),
1265            configuration_version: "cfg-1".into(),
1266            policy_version: String::new(),
1267            ttl_ms: 1_000,
1268            context_id: String::new(),
1269            extensions: std::collections::HashMap::new(),
1270            roots: vec![],
1271            max_suspend_ms: 0,
1272        }
1273        .encode_to_vec()
1274    }
1275
1276    fn env(
1277        mode: &str,
1278        message_type: &str,
1279        message_id: &str,
1280        session_id: &str,
1281        sender: &str,
1282        payload: Vec<u8>,
1283    ) -> Envelope {
1284        Envelope {
1285            macp_version: "1.0".into(),
1286            mode: mode.into(),
1287            message_type: message_type.into(),
1288            message_id: message_id.into(),
1289            session_id: session_id.into(),
1290            sender: sender.into(),
1291            timestamp_unix_ms: Utc::now().timestamp_millis(),
1292            payload,
1293        }
1294    }
1295
1296    #[tokio::test]
1297    async fn standard_session_start_is_strict() {
1298        let rt = make_runtime();
1299        let sid = new_sid();
1300        let bad = SessionStartPayload {
1301            ttl_ms: 0,
1302            ..Default::default()
1303        }
1304        .encode_to_vec();
1305        let err = rt
1306            .process(
1307                &env(
1308                    "macp.mode.decision.v1",
1309                    "SessionStart",
1310                    "m1",
1311                    &sid,
1312                    "agent://orchestrator",
1313                    bad,
1314                ),
1315                None,
1316            )
1317            .await
1318            .unwrap_err();
1319        assert!(matches!(
1320            err,
1321            MacpError::InvalidPayload | MacpError::InvalidTtl
1322        ));
1323    }
1324
1325    #[tokio::test]
1326    async fn empty_mode_is_rejected() {
1327        let rt = make_runtime();
1328        let sid = new_sid();
1329        let err = rt
1330            .process(
1331                &env(
1332                    "",
1333                    "SessionStart",
1334                    "m1",
1335                    &sid,
1336                    "agent://orchestrator",
1337                    session_start(vec!["agent://fraud".into()]),
1338                ),
1339                None,
1340            )
1341            .await
1342            .unwrap_err();
1343        assert_eq!(err.to_string(), "InvalidEnvelope");
1344    }
1345
1346    #[tokio::test]
1347    async fn rejected_messages_do_not_enter_dedup_state() {
1348        let rt = make_runtime();
1349        let sid = new_sid();
1350        rt.process(
1351            &env(
1352                "macp.mode.decision.v1",
1353                "SessionStart",
1354                "m1",
1355                &sid,
1356                "agent://orchestrator",
1357                session_start(vec!["agent://orchestrator".into(), "agent://fraud".into()]),
1358            ),
1359            None,
1360        )
1361        .await
1362        .unwrap();
1363
1364        let bad = rt
1365            .process(
1366                &env(
1367                    "macp.mode.decision.v1",
1368                    "Proposal",
1369                    "m2",
1370                    &sid,
1371                    "agent://fraud",
1372                    b"not-protobuf".to_vec(),
1373                ),
1374                None,
1375            )
1376            .await
1377            .unwrap_err();
1378        assert_eq!(bad.to_string(), "InvalidPayload");
1379
1380        let good = ProposalPayload {
1381            proposal_id: "p1".into(),
1382            option: "step-up".into(),
1383            rationale: "risk".into(),
1384            supporting_data: vec![],
1385        }
1386        .encode_to_vec();
1387        let result = rt
1388            .process(
1389                &env(
1390                    "macp.mode.decision.v1",
1391                    "Proposal",
1392                    "m2",
1393                    &sid,
1394                    "agent://orchestrator",
1395                    good,
1396                ),
1397                None,
1398            )
1399            .await
1400            .unwrap();
1401        assert!(!result.duplicate);
1402    }
1403
1404    #[tokio::test]
1405    async fn get_session_transitions_expired_sessions() {
1406        let rt = make_runtime();
1407        let sid = new_sid();
1408        let payload = SessionStartPayload {
1409            intent: "intent".into(),
1410            participants: vec!["agent://fraud".into()],
1411            mode_version: "1.0.0".into(),
1412            configuration_version: "cfg-1".into(),
1413            policy_version: String::new(),
1414            ttl_ms: 1,
1415            context_id: String::new(),
1416            extensions: std::collections::HashMap::new(),
1417            roots: vec![],
1418            max_suspend_ms: 0,
1419        }
1420        .encode_to_vec();
1421        rt.process(
1422            &env(
1423                "macp.mode.decision.v1",
1424                "SessionStart",
1425                "m1",
1426                &sid,
1427                "agent://orchestrator",
1428                payload,
1429            ),
1430            None,
1431        )
1432        .await
1433        .unwrap();
1434        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1435        let session = rt.get_session_checked(&sid).await.unwrap();
1436        assert_eq!(session.state, SessionState::Expired);
1437    }
1438
1439    #[tokio::test]
1440    async fn multi_round_requires_standard_session_start() {
1441        let rt = make_runtime();
1442        let sid = new_sid();
1443        // multi-round is now standards-track: empty mode_version should fail
1444        let payload = SessionStartPayload {
1445            participants: vec!["creator".into(), "other".into()],
1446            ..Default::default()
1447        }
1448        .encode_to_vec();
1449        let err = rt
1450            .process(
1451                &env(
1452                    "ext.multi_round.v1",
1453                    "SessionStart",
1454                    "m1",
1455                    &sid,
1456                    "creator",
1457                    payload,
1458                ),
1459                None,
1460            )
1461            .await
1462            .unwrap_err();
1463        assert!(matches!(
1464            err,
1465            MacpError::InvalidPayload | MacpError::InvalidTtl
1466        ));
1467    }
1468
1469    #[tokio::test]
1470    async fn multi_round_valid_session_start() {
1471        let rt = make_runtime();
1472        let sid = new_sid();
1473        let payload = session_start(vec!["alice".into(), "bob".into()]);
1474        rt.process(
1475            &env(
1476                "ext.multi_round.v1",
1477                "SessionStart",
1478                "m1",
1479                &sid,
1480                "coordinator",
1481                payload,
1482            ),
1483            None,
1484        )
1485        .await
1486        .unwrap();
1487        let session = rt.get_session_checked(&sid).await.unwrap();
1488        assert_eq!(session.mode, "ext.multi_round.v1");
1489        assert_eq!(session.participants, vec!["alice", "bob"]);
1490    }
1491
1492    #[tokio::test]
1493    async fn duplicate_session_start_message_id_returns_duplicate() {
1494        let rt = make_runtime();
1495        let sid = new_sid();
1496        let payload = session_start(vec!["agent://fraud".into()]);
1497        rt.process(
1498            &env(
1499                "macp.mode.decision.v1",
1500                "SessionStart",
1501                "m1",
1502                &sid,
1503                "agent://orchestrator",
1504                payload.clone(),
1505            ),
1506            None,
1507        )
1508        .await
1509        .unwrap();
1510
1511        let result = rt
1512            .process(
1513                &env(
1514                    "macp.mode.decision.v1",
1515                    "SessionStart",
1516                    "m1",
1517                    &sid,
1518                    "agent://orchestrator",
1519                    payload,
1520                ),
1521                None,
1522            )
1523            .await
1524            .unwrap();
1525        assert!(result.duplicate);
1526    }
1527
1528    #[tokio::test]
1529    async fn non_start_mode_mismatch_rejected() {
1530        let rt = make_runtime();
1531        let sid = new_sid();
1532        rt.process(
1533            &env(
1534                "macp.mode.decision.v1",
1535                "SessionStart",
1536                "m1",
1537                &sid,
1538                "agent://orchestrator",
1539                session_start(vec!["agent://fraud".into()]),
1540            ),
1541            None,
1542        )
1543        .await
1544        .unwrap();
1545
1546        let proposal = ProposalPayload {
1547            proposal_id: "p1".into(),
1548            option: "step-up".into(),
1549            rationale: "risk".into(),
1550            supporting_data: vec![],
1551        }
1552        .encode_to_vec();
1553        let err = rt
1554            .process(
1555                &env(
1556                    "macp.mode.task.v1",
1557                    "Proposal",
1558                    "m2",
1559                    &sid,
1560                    "agent://orchestrator",
1561                    proposal,
1562                ),
1563                None,
1564            )
1565            .await
1566            .unwrap_err();
1567        assert_eq!(err.to_string(), "InvalidEnvelope");
1568    }
1569
1570    #[tokio::test]
1571    async fn cancel_idempotent_on_already_expired() {
1572        let rt = make_runtime();
1573        let sid = new_sid();
1574        let payload = SessionStartPayload {
1575            intent: "intent".into(),
1576            participants: vec!["agent://fraud".into()],
1577            mode_version: "1.0.0".into(),
1578            configuration_version: "cfg-1".into(),
1579            policy_version: String::new(),
1580            ttl_ms: 1,
1581            context_id: String::new(),
1582            extensions: std::collections::HashMap::new(),
1583            roots: vec![],
1584            max_suspend_ms: 0,
1585        }
1586        .encode_to_vec();
1587        rt.process(
1588            &env(
1589                "macp.mode.decision.v1",
1590                "SessionStart",
1591                "m1",
1592                &sid,
1593                "agent://orchestrator",
1594                payload,
1595            ),
1596            None,
1597        )
1598        .await
1599        .unwrap();
1600        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1601        let result = rt
1602            .cancel_session(&sid, "cleanup", "agent://orchestrator")
1603            .await
1604            .unwrap();
1605        assert_eq!(result.session_state, SessionState::Expired);
1606    }
1607
1608    #[tokio::test]
1609    async fn accepted_envelopes_are_published_in_order() {
1610        let rt = make_runtime();
1611        let sid = new_sid();
1612        let mut events = rt.subscribe_session_stream(&sid);
1613
1614        let start = env(
1615            "macp.mode.decision.v1",
1616            "SessionStart",
1617            "m1",
1618            &sid,
1619            "agent://orchestrator",
1620            session_start(vec!["agent://orchestrator".into(), "agent://fraud".into()]),
1621        );
1622        rt.process(&start, None).await.unwrap();
1623        let first = events.recv().await.unwrap();
1624        assert_eq!(first.message_id, "m1");
1625        assert_eq!(first.message_type, "SessionStart");
1626
1627        let proposal = ProposalPayload {
1628            proposal_id: "p1".into(),
1629            option: "step-up".into(),
1630            rationale: "risk".into(),
1631            supporting_data: vec![],
1632        }
1633        .encode_to_vec();
1634        let proposal_env = env(
1635            "macp.mode.decision.v1",
1636            "Proposal",
1637            "m2",
1638            &sid,
1639            "agent://orchestrator",
1640            proposal,
1641        );
1642        rt.process(&proposal_env, None).await.unwrap();
1643        let second = events.recv().await.unwrap();
1644        assert_eq!(second.message_id, "m2");
1645        assert_eq!(second.message_type, "Proposal");
1646    }
1647
1648    #[tokio::test]
1649    async fn commitment_versions_are_carried_into_resolution() {
1650        let rt = make_runtime();
1651        let sid = new_sid();
1652        rt.process(
1653            &env(
1654                "macp.mode.proposal.v1",
1655                "SessionStart",
1656                "m1",
1657                &sid,
1658                "agent://buyer",
1659                session_start(vec!["agent://buyer".into(), "agent://seller".into()]),
1660            ),
1661            None,
1662        )
1663        .await
1664        .unwrap();
1665
1666        let proposal = crate::proposal_pb::ProposalPayload {
1667            proposal_id: "p1".into(),
1668            title: "offer".into(),
1669            summary: "summary".into(),
1670            details: vec![],
1671            tags: vec![],
1672        }
1673        .encode_to_vec();
1674        rt.process(
1675            &env(
1676                "macp.mode.proposal.v1",
1677                "Proposal",
1678                "m2",
1679                &sid,
1680                "agent://seller",
1681                proposal,
1682            ),
1683            None,
1684        )
1685        .await
1686        .unwrap();
1687        let accept = crate::proposal_pb::AcceptPayload {
1688            proposal_id: "p1".into(),
1689            reason: String::new(),
1690        }
1691        .encode_to_vec();
1692        rt.process(
1693            &env(
1694                "macp.mode.proposal.v1",
1695                "Accept",
1696                "m3",
1697                &sid,
1698                "agent://seller",
1699                accept.clone(),
1700            ),
1701            None,
1702        )
1703        .await
1704        .unwrap();
1705        rt.process(
1706            &env(
1707                "macp.mode.proposal.v1",
1708                "Accept",
1709                "m4",
1710                &sid,
1711                "agent://buyer",
1712                accept,
1713            ),
1714            None,
1715        )
1716        .await
1717        .unwrap();
1718        let commitment = CommitmentPayload {
1719            commitment_id: "c1".into(),
1720            action: "proposal.accepted".into(),
1721            authority_scope: "commercial".into(),
1722            reason: "bound".into(),
1723            mode_version: "1.0.0".into(),
1724            policy_version: "policy.default".into(),
1725            configuration_version: "cfg-1".into(),
1726            outcome_positive: true,
1727            supersedes: None,
1728        }
1729        .encode_to_vec();
1730        let result = rt
1731            .process(
1732                &env(
1733                    "macp.mode.proposal.v1",
1734                    "Commitment",
1735                    "m5",
1736                    &sid,
1737                    "agent://buyer",
1738                    commitment,
1739                ),
1740                None,
1741            )
1742            .await
1743            .unwrap();
1744        assert_eq!(result.session_state, SessionState::Resolved);
1745    }
1746
1747    #[tokio::test]
1748    async fn max_open_sessions_enforced_under_write_lock() {
1749        let rt = make_runtime();
1750        let sid1 = new_sid();
1751        let sid2 = new_sid();
1752        let sid3 = new_sid();
1753        rt.process(
1754            &env(
1755                "macp.mode.decision.v1",
1756                "SessionStart",
1757                "m1",
1758                &sid1,
1759                "agent://orchestrator",
1760                session_start(vec!["agent://fraud".into()]),
1761            ),
1762            Some(1),
1763        )
1764        .await
1765        .unwrap();
1766
1767        let err = rt
1768            .process(
1769                &env(
1770                    "macp.mode.decision.v1",
1771                    "SessionStart",
1772                    "m2",
1773                    &sid2,
1774                    "agent://orchestrator",
1775                    session_start(vec!["agent://fraud".into()]),
1776                ),
1777                Some(1),
1778            )
1779            .await
1780            .unwrap_err();
1781        assert!(matches!(err, MacpError::RateLimited));
1782
1783        rt.process(
1784            &env(
1785                "macp.mode.decision.v1",
1786                "SessionStart",
1787                "m3",
1788                &sid3,
1789                "agent://other",
1790                session_start(vec!["agent://fraud".into()]),
1791            ),
1792            Some(1),
1793        )
1794        .await
1795        .unwrap();
1796    }
1797
1798    #[tokio::test]
1799    async fn weak_session_id_rejected() {
1800        let rt = make_runtime();
1801        let err = rt
1802            .process(
1803                &env(
1804                    "macp.mode.decision.v1",
1805                    "SessionStart",
1806                    "m1",
1807                    "s1",
1808                    "agent://orchestrator",
1809                    session_start(vec!["agent://fraud".into()]),
1810                ),
1811                None,
1812            )
1813            .await
1814            .unwrap_err();
1815        assert_eq!(err.to_string(), "InvalidSessionId");
1816    }
1817
1818    #[tokio::test]
1819    async fn log_append_failure_rejects_session_start() {
1820        use std::io;
1821        struct FailingBackend;
1822        #[async_trait::async_trait]
1823        impl StorageBackend for FailingBackend {
1824            async fn save_session(&self, _: &Session) -> io::Result<()> {
1825                Ok(())
1826            }
1827            async fn load_session(&self, _: &str) -> io::Result<Option<Session>> {
1828                Ok(None)
1829            }
1830            async fn load_all_sessions(&self) -> io::Result<Vec<Session>> {
1831                Ok(vec![])
1832            }
1833            async fn delete_session(&self, _: &str) -> io::Result<()> {
1834                Ok(())
1835            }
1836            async fn list_session_ids(&self) -> io::Result<Vec<String>> {
1837                Ok(vec![])
1838            }
1839            async fn append_log_entry(&self, _: &str, _: &LogEntry) -> io::Result<()> {
1840                Err(io::Error::other("disk full"))
1841            }
1842            async fn load_log(&self, _: &str) -> io::Result<Vec<LogEntry>> {
1843                Ok(vec![])
1844            }
1845            async fn create_session_storage(&self, _: &str) -> io::Result<()> {
1846                Ok(())
1847            }
1848        }
1849
1850        let storage: Arc<dyn StorageBackend> = Arc::new(FailingBackend);
1851        let registry = Arc::new(SessionRegistry::new());
1852        let log_store = Arc::new(LogStore::new());
1853        let rt = Runtime::new(storage, registry, log_store);
1854        let sid = new_sid();
1855
1856        let err = rt
1857            .process(
1858                &env(
1859                    "macp.mode.decision.v1",
1860                    "SessionStart",
1861                    "m1",
1862                    &sid,
1863                    "agent://orchestrator",
1864                    session_start(vec!["agent://fraud".into()]),
1865                ),
1866                None,
1867            )
1868            .await
1869            .unwrap_err();
1870        assert_eq!(err.to_string(), "StorageFailed");
1871    }
1872
1873    #[tokio::test]
1874    async fn log_append_failure_rejects_in_session_message() {
1875        use std::io;
1876        use std::sync::atomic::{AtomicUsize, Ordering};
1877
1878        struct FailOnSecondAppend {
1879            count: AtomicUsize,
1880        }
1881        #[async_trait::async_trait]
1882        impl StorageBackend for FailOnSecondAppend {
1883            async fn save_session(&self, _: &Session) -> io::Result<()> {
1884                Ok(())
1885            }
1886            async fn load_session(&self, _: &str) -> io::Result<Option<Session>> {
1887                Ok(None)
1888            }
1889            async fn load_all_sessions(&self) -> io::Result<Vec<Session>> {
1890                Ok(vec![])
1891            }
1892            async fn delete_session(&self, _: &str) -> io::Result<()> {
1893                Ok(())
1894            }
1895            async fn list_session_ids(&self) -> io::Result<Vec<String>> {
1896                Ok(vec![])
1897            }
1898            async fn append_log_entry(&self, _: &str, _: &LogEntry) -> io::Result<()> {
1899                let n = self.count.fetch_add(1, Ordering::SeqCst);
1900                if n >= 1 {
1901                    Err(io::Error::other("disk full"))
1902                } else {
1903                    Ok(())
1904                }
1905            }
1906            async fn load_log(&self, _: &str) -> io::Result<Vec<LogEntry>> {
1907                Ok(vec![])
1908            }
1909            async fn create_session_storage(&self, _: &str) -> io::Result<()> {
1910                Ok(())
1911            }
1912        }
1913
1914        let storage: Arc<dyn StorageBackend> = Arc::new(FailOnSecondAppend {
1915            count: AtomicUsize::new(0),
1916        });
1917        let registry = Arc::new(SessionRegistry::new());
1918        let log_store = Arc::new(LogStore::new());
1919        let rt = Runtime::new(storage, registry, log_store);
1920        let sid = new_sid();
1921
1922        // SessionStart succeeds (first append)
1923        rt.process(
1924            &env(
1925                "macp.mode.decision.v1",
1926                "SessionStart",
1927                "m1",
1928                &sid,
1929                "agent://orchestrator",
1930                session_start(vec!["agent://orchestrator".into(), "agent://fraud".into()]),
1931            ),
1932            None,
1933        )
1934        .await
1935        .unwrap();
1936
1937        // Proposal fails (second append)
1938        let proposal = ProposalPayload {
1939            proposal_id: "p1".into(),
1940            option: "step-up".into(),
1941            rationale: "risk".into(),
1942            supporting_data: vec![],
1943        }
1944        .encode_to_vec();
1945        let err = rt
1946            .process(
1947                &env(
1948                    "macp.mode.decision.v1",
1949                    "Proposal",
1950                    "m2",
1951                    &sid,
1952                    "agent://orchestrator",
1953                    proposal,
1954                ),
1955                None,
1956            )
1957            .await
1958            .unwrap_err();
1959        assert_eq!(err.to_string(), "StorageFailed");
1960
1961        // Verify the message was not added to dedup state
1962        let session = rt.get_session_checked(&sid).await.unwrap();
1963        assert!(!session.seen_message_ids.contains("m2"));
1964    }
1965
1966    #[tokio::test]
1967    async fn cancel_session_fails_if_log_append_fails() {
1968        use std::io;
1969        use std::sync::atomic::{AtomicUsize, Ordering};
1970
1971        struct FailOnSecondAppend {
1972            count: AtomicUsize,
1973        }
1974        #[async_trait::async_trait]
1975        impl StorageBackend for FailOnSecondAppend {
1976            async fn save_session(&self, _: &Session) -> io::Result<()> {
1977                Ok(())
1978            }
1979            async fn load_session(&self, _: &str) -> io::Result<Option<Session>> {
1980                Ok(None)
1981            }
1982            async fn load_all_sessions(&self) -> io::Result<Vec<Session>> {
1983                Ok(vec![])
1984            }
1985            async fn delete_session(&self, _: &str) -> io::Result<()> {
1986                Ok(())
1987            }
1988            async fn list_session_ids(&self) -> io::Result<Vec<String>> {
1989                Ok(vec![])
1990            }
1991            async fn append_log_entry(&self, _: &str, _: &LogEntry) -> io::Result<()> {
1992                let n = self.count.fetch_add(1, Ordering::SeqCst);
1993                if n >= 1 {
1994                    Err(io::Error::other("disk full"))
1995                } else {
1996                    Ok(())
1997                }
1998            }
1999            async fn load_log(&self, _: &str) -> io::Result<Vec<LogEntry>> {
2000                Ok(vec![])
2001            }
2002            async fn create_session_storage(&self, _: &str) -> io::Result<()> {
2003                Ok(())
2004            }
2005        }
2006
2007        let storage: Arc<dyn StorageBackend> = Arc::new(FailOnSecondAppend {
2008            count: AtomicUsize::new(0),
2009        });
2010        let registry = Arc::new(SessionRegistry::new());
2011        let log_store = Arc::new(LogStore::new());
2012        let rt = Runtime::new(storage, registry, log_store);
2013        let sid = new_sid();
2014
2015        rt.process(
2016            &env(
2017                "macp.mode.decision.v1",
2018                "SessionStart",
2019                "m1",
2020                &sid,
2021                "agent://orchestrator",
2022                session_start(vec!["agent://fraud".into()]),
2023            ),
2024            None,
2025        )
2026        .await
2027        .unwrap();
2028
2029        let err = rt
2030            .cancel_session(&sid, "test cancel", "agent://orchestrator")
2031            .await
2032            .unwrap_err();
2033        assert_eq!(err.to_string(), "StorageFailed");
2034    }
2035
2036    #[tokio::test]
2037    async fn ttl_expiration_rejects_message() {
2038        let rt = make_runtime();
2039        let sid = new_sid();
2040        let payload = SessionStartPayload {
2041            intent: "intent".into(),
2042            participants: vec!["agent://orchestrator".into(), "agent://fraud".into()],
2043            mode_version: "1.0.0".into(),
2044            configuration_version: "cfg-1".into(),
2045            policy_version: String::new(),
2046            ttl_ms: 1,
2047            context_id: String::new(),
2048            extensions: std::collections::HashMap::new(),
2049            roots: vec![],
2050            max_suspend_ms: 0,
2051        }
2052        .encode_to_vec();
2053        rt.process(
2054            &env(
2055                "macp.mode.decision.v1",
2056                "SessionStart",
2057                "m1",
2058                &sid,
2059                "agent://orchestrator",
2060                payload,
2061            ),
2062            None,
2063        )
2064        .await
2065        .unwrap();
2066        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2067        let proposal = ProposalPayload {
2068            proposal_id: "p1".into(),
2069            option: "step-up".into(),
2070            rationale: "risk".into(),
2071            supporting_data: vec![],
2072        }
2073        .encode_to_vec();
2074        let err = rt
2075            .process(
2076                &env(
2077                    "macp.mode.decision.v1",
2078                    "Proposal",
2079                    "m2",
2080                    &sid,
2081                    "agent://orchestrator",
2082                    proposal,
2083                ),
2084                None,
2085            )
2086            .await
2087            .unwrap_err();
2088        assert_eq!(err.to_string(), "TtlExpired");
2089    }
2090
2091    #[tokio::test]
2092    async fn cleanup_expired_sessions_marks_expired() {
2093        let rt = make_runtime();
2094        let sid = new_sid();
2095        let payload = SessionStartPayload {
2096            intent: "intent".into(),
2097            participants: vec!["agent://fraud".into()],
2098            mode_version: "1.0.0".into(),
2099            configuration_version: "cfg-1".into(),
2100            policy_version: String::new(),
2101            ttl_ms: 1,
2102            context_id: String::new(),
2103            extensions: std::collections::HashMap::new(),
2104            roots: vec![],
2105            max_suspend_ms: 0,
2106        }
2107        .encode_to_vec();
2108        rt.process(
2109            &env(
2110                "macp.mode.decision.v1",
2111                "SessionStart",
2112                "m1",
2113                &sid,
2114                "agent://orchestrator",
2115                payload,
2116            ),
2117            None,
2118        )
2119        .await
2120        .unwrap();
2121        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2122        rt.cleanup_expired_sessions().await;
2123        let session = rt.get_session_checked(&sid).await.unwrap();
2124        assert_eq!(session.state, SessionState::Expired);
2125    }
2126
2127    #[tokio::test]
2128    async fn evict_stale_sessions_removes_resolved() {
2129        let rt = make_runtime();
2130        let sid = new_sid();
2131        // Start a decision session
2132        rt.process(
2133            &env(
2134                "macp.mode.decision.v1",
2135                "SessionStart",
2136                "m1",
2137                &sid,
2138                "agent://orchestrator",
2139                session_start(vec!["agent://orchestrator".into(), "agent://fraud".into()]),
2140            ),
2141            None,
2142        )
2143        .await
2144        .unwrap();
2145        // Send a Proposal
2146        let proposal = ProposalPayload {
2147            proposal_id: "p1".into(),
2148            option: "step-up".into(),
2149            rationale: "risk".into(),
2150            supporting_data: vec![],
2151        }
2152        .encode_to_vec();
2153        rt.process(
2154            &env(
2155                "macp.mode.decision.v1",
2156                "Proposal",
2157                "m2",
2158                &sid,
2159                "agent://orchestrator",
2160                proposal,
2161            ),
2162            None,
2163        )
2164        .await
2165        .unwrap();
2166        // Commit to resolve the session
2167        let commitment = CommitmentPayload {
2168            commitment_id: "c1".into(),
2169            action: "decision.selected".into(),
2170            authority_scope: "payments".into(),
2171            reason: "bound".into(),
2172            mode_version: "1.0.0".into(),
2173            policy_version: "policy.default".into(),
2174            configuration_version: "cfg-1".into(),
2175            outcome_positive: true,
2176            supersedes: None,
2177        }
2178        .encode_to_vec();
2179        let result = rt
2180            .process(
2181                &env(
2182                    "macp.mode.decision.v1",
2183                    "Commitment",
2184                    "m3",
2185                    &sid,
2186                    "agent://orchestrator",
2187                    commitment,
2188                ),
2189                None,
2190            )
2191            .await
2192            .unwrap();
2193        assert_eq!(result.session_state, SessionState::Resolved);
2194        // Wait a moment so the session's started_at_unix_ms is strictly in the past
2195        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2196        // Evict with retention = 0 (evict immediately)
2197        rt.evict_stale_sessions(0).await;
2198        // Session should no longer be in the in-memory registry
2199        assert!(rt.registry.get_session(&sid).await.is_none());
2200    }
2201
2202    #[tokio::test]
2203    async fn session_start_with_wrong_mode_version_rejected() {
2204        let rt = make_runtime();
2205        let sid = new_sid();
2206        let payload = SessionStartPayload {
2207            intent: "test".into(),
2208            participants: vec!["agent://orchestrator".into(), "agent://worker".into()],
2209            mode_version: "99.0.0".into(), // wrong version
2210            configuration_version: "cfg-1".into(),
2211            policy_version: String::new(),
2212            ttl_ms: 60_000,
2213            context_id: String::new(),
2214            extensions: std::collections::HashMap::new(),
2215            roots: vec![],
2216            max_suspend_ms: 0,
2217        }
2218        .encode_to_vec();
2219
2220        let err = rt
2221            .process(
2222                &env(
2223                    "macp.mode.decision.v1",
2224                    "SessionStart",
2225                    "m1",
2226                    &sid,
2227                    "agent://orchestrator",
2228                    payload,
2229                ),
2230                None,
2231            )
2232            .await
2233            .unwrap_err();
2234        assert_eq!(err.error_code(), "INVALID_ENVELOPE");
2235    }
2236
2237    #[tokio::test]
2238    async fn signal_empty_signal_type_rejected() {
2239        let rt = make_runtime();
2240        // Use non-default data so proto3 serializes a non-empty payload
2241        let signal_payload = crate::pb::SignalPayload {
2242            signal_type: String::new(),
2243            data: b"some data".to_vec(),
2244            confidence: 0.0,
2245            correlation_session_id: String::new(),
2246        }
2247        .encode_to_vec();
2248        let signal = Envelope {
2249            macp_version: "1.0".into(),
2250            mode: String::new(),
2251            message_type: "Signal".into(),
2252            message_id: "sig-1".into(),
2253            session_id: String::new(),
2254            sender: "agent://a".into(),
2255            timestamp_unix_ms: 0,
2256            payload: signal_payload,
2257        };
2258        let err = rt.process_signal(&signal).await.unwrap_err();
2259        assert_eq!(err.error_code(), "INVALID_ENVELOPE");
2260    }
2261
2262    #[tokio::test]
2263    async fn signal_valid_payload_accepted() {
2264        let rt = make_runtime();
2265        let signal_payload = crate::pb::SignalPayload {
2266            signal_type: "heartbeat".into(),
2267            data: vec![],
2268            confidence: 0.8,
2269            correlation_session_id: String::new(),
2270        }
2271        .encode_to_vec();
2272        let signal = Envelope {
2273            macp_version: "1.0".into(),
2274            mode: String::new(),
2275            message_type: "Signal".into(),
2276            message_id: "sig-2".into(),
2277            session_id: String::new(),
2278            sender: "agent://a".into(),
2279            timestamp_unix_ms: 0,
2280            payload: signal_payload,
2281        };
2282        rt.process_signal(&signal).await.unwrap();
2283    }
2284
2285    #[tokio::test]
2286    async fn signal_empty_payload_accepted() {
2287        let rt = make_runtime();
2288        let signal = Envelope {
2289            macp_version: "1.0".into(),
2290            mode: String::new(),
2291            message_type: "Signal".into(),
2292            message_id: "sig-3".into(),
2293            session_id: String::new(),
2294            sender: "agent://a".into(),
2295            timestamp_unix_ms: 0,
2296            payload: vec![],
2297        };
2298        rt.process_signal(&signal).await.unwrap();
2299    }
2300
2301    /// Freeze invariant: CommitmentPayload version fields must match the
2302    /// session-bound versions — for extension modes too. When a non-strict ext
2303    /// mode's SessionStart omits mode_version, the runtime binds the registered
2304    /// descriptor's version; a Commitment carrying "" must no longer match
2305    /// vacuously.
2306    #[tokio::test]
2307    async fn ext_mode_empty_version_binds_descriptor_version() {
2308        let rt = make_runtime();
2309        rt.register_extension(ModeDescriptor {
2310            mode: "ext.dyn.v1".into(),
2311            mode_version: "2.5.0".into(),
2312            message_types: vec!["SessionStart".into(), "Note".into(), "Commitment".into()],
2313            terminal_message_types: vec!["Commitment".into()],
2314            ..Default::default()
2315        })
2316        .unwrap();
2317
2318        let sid = new_sid();
2319        let payload = SessionStartPayload {
2320            participants: vec!["alice".into()],
2321            configuration_version: "cfg-1".into(),
2322            ttl_ms: 60_000,
2323            ..Default::default()
2324        }
2325        .encode_to_vec();
2326        rt.process(
2327            &env("ext.dyn.v1", "SessionStart", "m1", &sid, "alice", payload),
2328            None,
2329        )
2330        .await
2331        .unwrap();
2332
2333        // The session is bound to the descriptor's version, not "".
2334        let session = rt.get_session_checked(&sid).await.unwrap();
2335        assert_eq!(session.mode_version, "2.5.0");
2336
2337        // Commitment with empty mode_version: rejected (no vacuous match).
2338        let bad = CommitmentPayload {
2339            commitment_id: "c1".into(),
2340            action: "work.completed".into(),
2341            authority_scope: "test".into(),
2342            reason: "done".into(),
2343            mode_version: String::new(),
2344            policy_version: "policy.default".into(),
2345            configuration_version: "cfg-1".into(),
2346            outcome_positive: true,
2347            supersedes: None,
2348        }
2349        .encode_to_vec();
2350        let err = rt
2351            .process(
2352                &env("ext.dyn.v1", "Commitment", "m2", &sid, "alice", bad),
2353                None,
2354            )
2355            .await
2356            .unwrap_err();
2357        assert_eq!(err.to_string(), "InvalidPayload");
2358
2359        // Commitment echoing the bound descriptor version: accepted, resolves.
2360        let good = CommitmentPayload {
2361            commitment_id: "c1".into(),
2362            action: "work.completed".into(),
2363            authority_scope: "test".into(),
2364            reason: "done".into(),
2365            mode_version: "2.5.0".into(),
2366            policy_version: "policy.default".into(),
2367            configuration_version: "cfg-1".into(),
2368            outcome_positive: true,
2369            supersedes: None,
2370        }
2371        .encode_to_vec();
2372        let result = rt
2373            .process(
2374                &env("ext.dyn.v1", "Commitment", "m3", &sid, "alice", good),
2375                None,
2376            )
2377            .await
2378            .unwrap();
2379        assert_eq!(result.session_state, SessionState::Resolved);
2380    }
2381
2382    /// The binding must be recorded on the SessionStart log entry (replay reads
2383    /// it from there), and only when the payload actually omitted the version.
2384    #[tokio::test]
2385    async fn ext_mode_binding_recorded_on_session_start_log_entry() {
2386        let rt = make_runtime();
2387        rt.register_extension(ModeDescriptor {
2388            mode: "ext.dyn2.v1".into(),
2389            mode_version: "3.0.0".into(),
2390            message_types: vec!["SessionStart".into(), "Commitment".into()],
2391            terminal_message_types: vec!["Commitment".into()],
2392            ..Default::default()
2393        })
2394        .unwrap();
2395
2396        let sid = new_sid();
2397        let payload = SessionStartPayload {
2398            participants: vec!["alice".into()],
2399            configuration_version: "cfg-1".into(),
2400            ttl_ms: 60_000,
2401            ..Default::default()
2402        }
2403        .encode_to_vec();
2404        rt.process(
2405            &env("ext.dyn2.v1", "SessionStart", "m1", &sid, "alice", payload),
2406            None,
2407        )
2408        .await
2409        .unwrap();
2410
2411        let log = rt.log_store.get_log(&sid).await.unwrap();
2412        assert_eq!(log[0].message_type, "SessionStart");
2413        assert_eq!(log[0].bound_mode_version.as_deref(), Some("3.0.0"));
2414
2415        // A payload that carries the version explicitly records no binding.
2416        let sid2 = new_sid();
2417        let payload2 = SessionStartPayload {
2418            participants: vec!["alice".into()],
2419            mode_version: "3.0.0".into(),
2420            configuration_version: "cfg-1".into(),
2421            ttl_ms: 60_000,
2422            ..Default::default()
2423        }
2424        .encode_to_vec();
2425        rt.process(
2426            &env(
2427                "ext.dyn2.v1",
2428                "SessionStart",
2429                "m1",
2430                &sid2,
2431                "alice",
2432                payload2,
2433            ),
2434            None,
2435        )
2436        .await
2437        .unwrap();
2438        let log2 = rt.log_store.get_log(&sid2).await.unwrap();
2439        assert_eq!(log2[0].bound_mode_version, None);
2440    }
2441
2442    /// The RESOLVED suspension cap is bound on the session and recorded on
2443    /// the SessionStart log entry (RFC-MACP-0001 §7.5, RFC-MACP-0003 §2):
2444    /// the payload's positive value verbatim, or the runtime default when
2445    /// the payload carried 0 — never left unrecorded on new sessions.
2446    #[tokio::test]
2447    async fn session_start_binds_and_records_max_suspend_cap() {
2448        let rt = make_runtime();
2449
2450        // Explicit cap: recorded verbatim.
2451        let sid = new_sid();
2452        let payload = SessionStartPayload {
2453            participants: vec!["alice".into(), "bob".into()],
2454            mode_version: "1.0.0".into(),
2455            configuration_version: "cfg-1".into(),
2456            ttl_ms: 60_000,
2457            max_suspend_ms: 12_345,
2458            ..Default::default()
2459        }
2460        .encode_to_vec();
2461        rt.process(
2462            &env(
2463                "macp.mode.decision.v1",
2464                "SessionStart",
2465                "m1",
2466                &sid,
2467                "alice",
2468                payload,
2469            ),
2470            None,
2471        )
2472        .await
2473        .unwrap();
2474        let log = rt.log_store.get_log(&sid).await.unwrap();
2475        assert_eq!(log[0].bound_max_suspend_ms, Some(12_345));
2476
2477        // Payload 0: the runtime default is resolved and recorded.
2478        let sid2 = new_sid();
2479        let payload2 = SessionStartPayload {
2480            participants: vec!["alice".into(), "bob".into()],
2481            mode_version: "1.0.0".into(),
2482            configuration_version: "cfg-1".into(),
2483            ttl_ms: 60_000,
2484            max_suspend_ms: 0,
2485            ..Default::default()
2486        }
2487        .encode_to_vec();
2488        rt.process(
2489            &env(
2490                "macp.mode.decision.v1",
2491                "SessionStart",
2492                "m2",
2493                &sid2,
2494                "alice",
2495                payload2,
2496            ),
2497            None,
2498        )
2499        .await
2500        .unwrap();
2501        let log2 = rt.log_store.get_log(&sid2).await.unwrap();
2502        assert_eq!(
2503            log2[0].bound_max_suspend_ms,
2504            Some(macp_core::session::MAX_SUSPEND_MS)
2505        );
2506    }
2507
2508    #[test]
2509    fn audit_verbosity_reads_policy_rules() {
2510        let mut session = Session::builder("s1", "macp.mode.decision.v1", "a").build();
2511        assert!(!Runtime::audit_verbose(&session));
2512
2513        session.policy_definition = Some(macp_core::policy::PolicyDefinition {
2514            policy_id: "policy.test.audit".into(),
2515            mode: "*".into(),
2516            description: "audited".into(),
2517            rules: serde_json::json!({ "audit": { "level": "info" } }),
2518            schema_version: 1,
2519        });
2520        assert!(Runtime::audit_verbose(&session));
2521
2522        session.policy_definition.as_mut().unwrap().rules =
2523            serde_json::json!({ "audit": { "level": "debug" } });
2524        assert!(!Runtime::audit_verbose(&session));
2525    }
2526
2527    /// Post-commit-point coherence: once the SessionStart log entry is
2528    /// durable, a snapshot failure must NOT fail (or roll back) the start —
2529    /// the previous fatal path left the durable entry behind, so the
2530    /// "failed" session resurrected on restart and a same-id retry appended
2531    /// a second SessionStart that made the log unreplayable.
2532    #[tokio::test]
2533    async fn session_start_snapshot_failure_is_nonfatal_after_commit_point() {
2534        use std::io;
2535
2536        struct FailSnapshotBackend;
2537        #[async_trait::async_trait]
2538        impl StorageBackend for FailSnapshotBackend {
2539            async fn create_session_storage(&self, _s: &str) -> io::Result<()> {
2540                Ok(())
2541            }
2542            async fn save_session(&self, _s: &Session) -> io::Result<()> {
2543                Err(io::Error::other("snapshot disk full"))
2544            }
2545            async fn load_session(&self, _s: &str) -> io::Result<Option<Session>> {
2546                Ok(None)
2547            }
2548            async fn load_all_sessions(&self) -> io::Result<Vec<Session>> {
2549                Ok(vec![])
2550            }
2551            async fn delete_session(&self, _s: &str) -> io::Result<()> {
2552                Ok(())
2553            }
2554            async fn list_session_ids(&self) -> io::Result<Vec<String>> {
2555                Ok(vec![])
2556            }
2557            async fn append_log_entry(
2558                &self,
2559                _s: &str,
2560                _e: &crate::log_store::LogEntry,
2561            ) -> io::Result<()> {
2562                Ok(())
2563            }
2564            async fn load_log(&self, _s: &str) -> io::Result<Vec<crate::log_store::LogEntry>> {
2565                Ok(vec![])
2566            }
2567        }
2568
2569        let rt = Runtime::new(
2570            Arc::new(FailSnapshotBackend),
2571            Arc::new(SessionRegistry::new()),
2572            Arc::new(LogStore::new()),
2573        );
2574        let sid = new_sid();
2575        let result = rt
2576            .process(
2577                &env(
2578                    "macp.mode.decision.v1",
2579                    "SessionStart",
2580                    "m1",
2581                    &sid,
2582                    "agent://orchestrator",
2583                    session_start(vec!["agent://orchestrator".into()]),
2584                ),
2585                None,
2586            )
2587            .await
2588            .expect("start must succeed: the log append (commit point) succeeded");
2589        assert!(!result.duplicate);
2590        // The session exists and is usable.
2591        assert!(rt.get_session_checked(&sid).await.is_some());
2592    }
2593}