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)] 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); 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 pub fn registered_mode_names(&self) -> Vec<String> {
113 self.mode_registry.all_mode_names()
114 }
115
116 pub fn standard_mode_descriptors(&self) -> Vec<ModeDescriptor> {
118 self.mode_registry.standard_mode_descriptors()
119 }
120
121 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 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 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 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 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 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 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 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 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 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 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 let shared = std::sync::Arc::new(tokio::sync::Mutex::new(session));
477 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 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 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 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 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 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 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 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 async fn process_message(&self, env: &Envelope) -> Result<ProcessResult, MacpError> {
613 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 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 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 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 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 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 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 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 async fn process_signal(&self, env: &Envelope) -> Result<ProcessResult, MacpError> {
740 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 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 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 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 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 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 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 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 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 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 async fn maybe_compact_log(&self, session_id: &str, session: &Session) -> bool {
978 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 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 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 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 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 pub async fn cleanup_expired_sessions(&self) {
1087 let now = Utc::now().timestamp_millis();
1088 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 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 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 _ => 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 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 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 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 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 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 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 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 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 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 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2196 rt.evict_stale_sessions(0).await;
2198 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(), 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 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 #[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 let session = rt.get_session_checked(&sid).await.unwrap();
2335 assert_eq!(session.mode_version, "2.5.0");
2336
2337 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 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 #[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 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 #[tokio::test]
2447 async fn session_start_binds_and_records_max_suspend_cap() {
2448 let rt = make_runtime();
2449
2450 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 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 #[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 assert!(rt.get_session_checked(&sid).await.is_some());
2592 }
2593}