1use crate::error::MacpError;
2use crate::log_store::{EntryKind, LogEntry};
3use crate::mode_registry::ModeRegistry;
4use crate::pb::Envelope;
5use crate::policy::registry::PolicyRegistry;
6use crate::registry::PersistedSession;
7use crate::session::{
8 extract_ttl_ms, parse_session_start_payload, validate_canonical_session_start_payload, Session,
9 SessionState,
10};
11
12pub fn replay_session(
19 session_id: &str,
20 log_entries: &[LogEntry],
21 registry: &ModeRegistry,
22 policy_registry: Option<&PolicyRegistry>,
23) -> Result<Session, MacpError> {
24 if let Some(session) =
26 try_replay_from_checkpoint(session_id, log_entries, registry, policy_registry)?
27 {
28 return Ok(session);
29 }
30
31 replay_from_start(session_id, log_entries, registry, policy_registry)
32}
33
34fn try_replay_from_checkpoint(
37 session_id: &str,
38 log_entries: &[LogEntry],
39 registry: &ModeRegistry,
40 _policy_registry: Option<&PolicyRegistry>,
41) -> Result<Option<Session>, MacpError> {
42 let checkpoint_idx = log_entries
43 .iter()
44 .rposition(|e| e.entry_kind == EntryKind::Checkpoint);
45
46 let idx = match checkpoint_idx {
47 Some(idx) => idx,
48 None => return Ok(None),
49 };
50
51 let checkpoint = &log_entries[idx];
52 let persisted: PersistedSession =
53 serde_json::from_slice(&checkpoint.raw_payload).map_err(|_| MacpError::InvalidPayload)?;
54 let mut session = Session::from(persisted);
55 session.session_id = session_id.into();
56
57 if !session.policy_version.is_empty() && session.policy_definition.is_none() {
64 tracing::warn!(
65 session_id,
66 policy_version = %session.policy_version,
67 "checkpoint missing policy_definition; falling back to full replay for deterministic policy resolution"
68 );
69 return Ok(None);
70 }
71
72 let mode = registry
73 .get_mode(&session.mode)
74 .ok_or(MacpError::UnknownMode)?;
75
76 for entry in &log_entries[idx + 1..] {
78 replay_entry(&mut session, session_id, entry, &mode)?;
79 }
80
81 Ok(Some(session))
82}
83
84fn replay_entry(
86 session: &mut Session,
87 session_id: &str,
88 entry: &LogEntry,
89 mode: &crate::mode_registry::ModeRef<'_>,
90) -> Result<(), MacpError> {
91 match entry.entry_kind {
92 EntryKind::Incoming => {
93 let replay_env = Envelope {
94 macp_version: if entry.macp_version.is_empty() {
95 "1.0".into()
96 } else {
97 entry.macp_version.clone()
98 },
99 mode: if entry.mode.is_empty() {
100 session.mode.clone()
101 } else {
102 entry.mode.clone()
103 },
104 message_type: entry.message_type.clone(),
105 message_id: entry.message_id.clone(),
106 session_id: session_id.into(),
107 sender: entry.sender.clone(),
108 timestamp_unix_ms: if entry.timestamp_unix_ms != 0 {
111 entry.timestamp_unix_ms
112 } else {
113 entry.received_at_ms
114 },
115 payload: entry.raw_payload.clone(),
116 };
117
118 if session.state != SessionState::Open {
119 if !replay_env.message_id.is_empty() {
120 session.seen_message_ids.insert(replay_env.message_id);
121 }
122 return Ok(());
123 }
124
125 mode.authorize_sender(session, &replay_env)?;
126 let response = mode.on_message(session, &replay_env)?;
127 session.apply_mode_response(response);
128 if !replay_env.message_id.is_empty() {
129 session.seen_message_ids.insert(replay_env.message_id);
130 }
131 }
132 EntryKind::Internal => match entry.message_type.as_str() {
133 "TtlExpired" => {
134 session.state = SessionState::Expired;
135 }
136 "SessionCancel" => {
139 let _ = session.cancel();
140 }
141 "SessionSuspend" => {
145 let at = if entry.received_at_ms != 0 {
146 entry.received_at_ms
147 } else {
148 entry.timestamp_unix_ms
149 };
150 let _ = session.suspend(at);
151 }
152 "SessionResume" => {
153 let at = if entry.received_at_ms != 0 {
154 entry.received_at_ms
155 } else {
156 entry.timestamp_unix_ms
157 };
158 let _ = session.resume(at);
159 }
160 _ => {}
161 },
162 EntryKind::Checkpoint => {
163 }
165 }
166 Ok(())
167}
168
169fn replay_from_start(
171 session_id: &str,
172 log_entries: &[LogEntry],
173 registry: &ModeRegistry,
174 policy_registry: Option<&PolicyRegistry>,
175) -> Result<Session, MacpError> {
176 let start_entry = log_entries
178 .iter()
179 .find(|e| e.entry_kind == EntryKind::Incoming && e.message_type == "SessionStart")
180 .ok_or(MacpError::InvalidPayload)?;
181
182 let mode_name = if start_entry.mode.is_empty() {
184 return Err(MacpError::InvalidPayload);
187 } else {
188 &start_entry.mode
189 };
190
191 let mode = registry.get_mode(mode_name).ok_or(MacpError::UnknownMode)?;
192
193 let require_complete_start = registry.requires_strict_session_start(mode_name);
195 let start_payload = if start_entry.raw_payload.is_empty() && !require_complete_start {
196 crate::pb::SessionStartPayload::default()
197 } else {
198 parse_session_start_payload(&start_entry.raw_payload)?
199 };
200 if require_complete_start {
201 validate_canonical_session_start_payload(&start_payload)?;
202 }
203
204 let ttl_ms = if !require_complete_start && start_payload.ttl_ms == 0 {
205 60_000i64
207 } else {
208 extract_ttl_ms(&start_payload)?
209 };
210
211 let started_at_unix_ms = start_entry.received_at_ms;
213 let ttl_expiry = started_at_unix_ms.saturating_add(ttl_ms);
214
215 let env = Envelope {
216 macp_version: if start_entry.macp_version.is_empty() {
217 "1.0".into()
218 } else {
219 start_entry.macp_version.clone()
220 },
221 mode: mode_name.to_string(),
222 message_type: "SessionStart".into(),
223 message_id: start_entry.message_id.clone(),
224 session_id: session_id.into(),
225 sender: start_entry.sender.clone(),
226 timestamp_unix_ms: if start_entry.timestamp_unix_ms != 0 {
227 start_entry.timestamp_unix_ms
228 } else {
229 start_entry.received_at_ms
230 },
231 payload: start_entry.raw_payload.clone(),
232 };
233
234 let mut session = Session {
235 session_id: session_id.into(),
236 state: SessionState::Open,
237 ttl_expiry,
238 ttl_ms,
239 started_at_unix_ms,
240 resolution: None,
241 mode: mode_name.to_string(),
242 mode_state: vec![],
243 participants: start_payload.participants.clone(),
244 seen_message_ids: std::collections::HashSet::new(),
245 intent: start_payload.intent.clone(),
246 mode_version: start_payload.mode_version.clone(),
247 configuration_version: start_payload.configuration_version.clone(),
248 policy_version: start_payload.policy_version.clone(),
249 context_id: start_payload.context_id.clone(),
250 extensions: start_payload.extensions.clone(),
251 roots: start_payload.roots.clone(),
252 initiator_sender: start_entry.sender.clone(),
253 participant_message_counts: std::collections::HashMap::new(),
254 participant_last_seen: std::collections::HashMap::new(),
255 policy_definition: if !start_payload.policy_version.is_empty() {
256 policy_registry.and_then(|pr| pr.resolve(&start_payload.policy_version).ok())
257 } else {
258 None
259 },
260 suspended_at_ms: None,
261 accumulated_suspended_ms: 0,
262 };
263
264 let response = mode.on_session_start(&session, &env)?;
266 session.seen_message_ids.insert(env.message_id.clone());
267 session.apply_mode_response(response);
268
269 for entry in log_entries.iter().skip(1) {
271 replay_entry(&mut session, session_id, entry, &mode)?;
272 }
273
274 Ok(session)
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::decision_pb::ProposalPayload;
281 use crate::decision_pb::VotePayload;
282 use crate::log_store::EntryKind;
283 use crate::pb::{CommitmentPayload, SessionStartPayload};
284 use prost::Message;
285
286 fn make_registry() -> ModeRegistry {
287 ModeRegistry::build_default(std::sync::Arc::new(macp_policy::DefaultPolicyEvaluator))
288 }
289
290 fn start_payload_bytes() -> Vec<u8> {
291 SessionStartPayload {
292 intent: "test".into(),
293 participants: vec!["agent://orchestrator".into(), "agent://fraud".into()],
294 mode_version: "1.0.0".into(),
295 configuration_version: "cfg-1".into(),
296 policy_version: "policy-1".into(),
297 ttl_ms: 60_000,
298 context_id: String::new(),
299 extensions: std::collections::HashMap::new(),
300 roots: vec![],
301 }
302 .encode_to_vec()
303 }
304
305 fn incoming_entry(
306 message_id: &str,
307 message_type: &str,
308 sender: &str,
309 payload: Vec<u8>,
310 received_at_ms: i64,
311 ) -> LogEntry {
312 LogEntry {
313 message_id: message_id.into(),
314 received_at_ms,
315 sender: sender.into(),
316 message_type: message_type.into(),
317 raw_payload: payload,
318 entry_kind: EntryKind::Incoming,
319 session_id: "s1".into(),
320 mode: "macp.mode.decision.v1".into(),
321 macp_version: "1.0".into(),
322 timestamp_unix_ms: received_at_ms,
323 }
324 }
325
326 fn internal_entry(message_type: &str, received_at_ms: i64) -> LogEntry {
327 LogEntry {
328 message_id: String::new(),
329 received_at_ms,
330 sender: "_runtime".into(),
331 message_type: message_type.into(),
332 raw_payload: vec![],
333 entry_kind: EntryKind::Internal,
334 session_id: "s1".into(),
335 mode: "macp.mode.decision.v1".into(),
336 macp_version: "1.0".into(),
337 timestamp_unix_ms: received_at_ms,
338 }
339 }
340
341 #[test]
342 fn replay_rebuilds_decision_session() {
343 let registry = make_registry();
344 let proposal = ProposalPayload {
345 proposal_id: "p1".into(),
346 option: "deploy".into(),
347 rationale: "ready".into(),
348 supporting_data: vec![],
349 }
350 .encode_to_vec();
351 let vote = VotePayload {
352 proposal_id: "p1".into(),
353 vote: "approve".into(),
354 reason: "lgtm".into(),
355 }
356 .encode_to_vec();
357 let commitment = CommitmentPayload {
358 commitment_id: "c1".into(),
359 action: "decision.selected".into(),
360 authority_scope: "payments".into(),
361 reason: "bound".into(),
362 mode_version: "1.0.0".into(),
363 policy_version: "policy-1".into(),
364 configuration_version: "cfg-1".into(),
365 outcome_positive: true,
366 supersedes: None,
367 }
368 .encode_to_vec();
369
370 let entries = vec![
371 incoming_entry(
372 "m1",
373 "SessionStart",
374 "agent://orchestrator",
375 start_payload_bytes(),
376 1000,
377 ),
378 incoming_entry("m2", "Proposal", "agent://orchestrator", proposal, 2000),
379 incoming_entry("m3", "Vote", "agent://fraud", vote, 3000),
380 incoming_entry("m4", "Commitment", "agent://orchestrator", commitment, 4000),
381 ];
382
383 let session = replay_session("s1", &entries, ®istry, None).unwrap();
384 assert_eq!(session.state, SessionState::Resolved);
385 assert_eq!(session.session_id, "s1");
386 assert!(session.seen_message_ids.contains("m1"));
387 assert!(session.seen_message_ids.contains("m2"));
388 assert!(session.seen_message_ids.contains("m3"));
389 assert!(session.seen_message_ids.contains("m4"));
390 assert!(session.resolution.is_some());
391 }
392
393 #[test]
394 fn replay_preserves_original_ttl() {
395 let registry = make_registry();
396 let original_time = 1_700_000_000_000i64;
397 let entries = vec![incoming_entry(
398 "m1",
399 "SessionStart",
400 "agent://orchestrator",
401 start_payload_bytes(),
402 original_time,
403 )];
404
405 let session = replay_session("s1", &entries, ®istry, None).unwrap();
406 assert_eq!(session.started_at_unix_ms, original_time);
407 assert_eq!(session.ttl_expiry, original_time + 60_000);
408 assert_eq!(session.ttl_ms, 60_000);
409 }
410
411 #[test]
412 fn replay_handles_ttl_expired() {
413 let registry = make_registry();
414 let entries = vec![
415 incoming_entry(
416 "m1",
417 "SessionStart",
418 "agent://orchestrator",
419 start_payload_bytes(),
420 1000,
421 ),
422 internal_entry("TtlExpired", 61001),
423 ];
424
425 let session = replay_session("s1", &entries, ®istry, None).unwrap();
426 assert_eq!(session.state, SessionState::Expired);
427 }
428
429 #[test]
430 fn replay_handles_session_cancel() {
431 let registry = make_registry();
432 let entries = vec![
433 incoming_entry(
434 "m1",
435 "SessionStart",
436 "agent://orchestrator",
437 start_payload_bytes(),
438 1000,
439 ),
440 internal_entry("SessionCancel", 5000),
441 ];
442
443 let session = replay_session("s1", &entries, ®istry, None).unwrap();
444 assert_eq!(session.state, SessionState::Cancelled);
446 }
447
448 #[test]
449 fn replay_fails_when_accepted_history_no_longer_applies() {
450 let registry = make_registry();
451 let vote = VotePayload {
452 proposal_id: "p1".into(),
453 vote: "approve".into(),
454 reason: String::new(),
455 }
456 .encode_to_vec();
457 let entries = vec![
458 incoming_entry(
459 "m1",
460 "SessionStart",
461 "agent://orchestrator",
462 start_payload_bytes(),
463 1000,
464 ),
465 incoming_entry("m2", "Vote", "agent://fraud", vote, 2000),
466 ];
467
468 let err = replay_session("s1", &entries, ®istry, None).unwrap_err();
469 let msg = err.to_string();
472 assert!(
473 msg == "InvalidTransition" || msg == "InvalidPayload" || msg == "Forbidden",
474 "unexpected error: {msg}"
475 );
476 }
477
478 #[test]
479 fn replay_empty_log_returns_error() {
480 let registry = make_registry();
481 let result = replay_session("s1", &[], ®istry, None);
482 assert!(result.is_err());
483 }
484
485 #[test]
486 fn backward_compat_old_log_entry_without_new_fields() {
487 let json = r#"{"message_id":"m1","received_at_ms":1000,"sender":"test","message_type":"Message","raw_payload":[],"entry_kind":"Incoming"}"#;
489 let entry: LogEntry = serde_json::from_str(json).unwrap();
490 assert_eq!(entry.session_id, "");
491 assert_eq!(entry.mode, "");
492 assert_eq!(entry.macp_version, "");
493 }
494
495 #[test]
496 fn replay_from_checkpoint_restores_state() {
497 use crate::registry::PersistedSession;
498
499 let registry = make_registry();
500
501 let proposal = ProposalPayload {
503 proposal_id: "p1".into(),
504 option: "deploy".into(),
505 rationale: "ready".into(),
506 supporting_data: vec![],
507 }
508 .encode_to_vec();
509
510 let full_entries = vec![
511 incoming_entry(
512 "m1",
513 "SessionStart",
514 "agent://orchestrator",
515 start_payload_bytes(),
516 1000,
517 ),
518 incoming_entry(
519 "m2",
520 "Proposal",
521 "agent://orchestrator",
522 proposal.clone(),
523 2000,
524 ),
525 ];
526 let full_session = replay_session("s1", &full_entries, ®istry, None).unwrap();
527
528 let persisted = PersistedSession::from(&full_session);
530 let checkpoint_payload = serde_json::to_vec(&persisted).unwrap();
531 let checkpoint = LogEntry {
532 message_id: String::new(),
533 received_at_ms: 3000,
534 sender: "_runtime".into(),
535 message_type: "Checkpoint".into(),
536 raw_payload: checkpoint_payload,
537 entry_kind: EntryKind::Checkpoint,
538 session_id: "s1".into(),
539 mode: "macp.mode.decision.v1".into(),
540 macp_version: "1.0".into(),
541 timestamp_unix_ms: 3000,
542 };
543
544 let vote = VotePayload {
546 proposal_id: "p1".into(),
547 vote: "approve".into(),
548 reason: "lgtm".into(),
549 }
550 .encode_to_vec();
551
552 let entries_with_checkpoint = vec![
554 full_entries[0].clone(),
555 full_entries[1].clone(),
556 checkpoint,
557 incoming_entry("m3", "Vote", "agent://fraud", vote, 4000),
558 ];
559
560 let session = replay_session("s1", &entries_with_checkpoint, ®istry, None).unwrap();
561 assert_eq!(session.state, SessionState::Open);
562 assert!(session.seen_message_ids.contains("m1"));
564 assert!(session.seen_message_ids.contains("m2"));
565 assert!(session.seen_message_ids.contains("m3"));
566 }
567
568 #[test]
569 fn replay_without_checkpoint_still_works() {
570 let registry = make_registry();
572 let entries = vec![incoming_entry(
573 "m1",
574 "SessionStart",
575 "agent://orchestrator",
576 start_payload_bytes(),
577 1000,
578 )];
579 let session = replay_session("s1", &entries, ®istry, None).unwrap();
580 assert_eq!(session.state, SessionState::Open);
581 assert!(session.seen_message_ids.contains("m1"));
582 }
583}