Skip to main content

macp_runtime/
replay.rs

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
12/// Rebuild a `Session` from its append-only log.
13///
14/// If the log contains `Checkpoint` entries, replay starts from the last
15/// checkpoint (restoring the serialized session state) and only replays
16/// subsequent entries. Otherwise, a full replay from `SessionStart` is
17/// performed.
18pub fn replay_session(
19    session_id: &str,
20    log_entries: &[LogEntry],
21    registry: &ModeRegistry,
22    policy_registry: Option<&PolicyRegistry>,
23) -> Result<Session, MacpError> {
24    // Try checkpoint-based fast path first
25    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
34/// Attempt to restore from the last checkpoint entry and replay remaining entries.
35/// Returns `Ok(None)` if no checkpoint exists.
36fn 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    // Re-resolve policy definition if policy_version is bound but missing from checkpoint.
58    // This can happen with legacy checkpoints. The resolved definition may differ from the
59    // original if the policy was modified since the session started (RFC-MACP-0012 Section 8).
60    // Policy definitions MUST be serialized in checkpoint entries. Any checkpoint
61    // missing a policy definition was created by a legacy version and cannot be
62    // trusted for deterministic replay — fall back to full replay from SessionStart.
63    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    // Replay entries after the checkpoint
77    for entry in &log_entries[idx + 1..] {
78        replay_entry(&mut session, session_id, entry, &mode)?;
79    }
80
81    Ok(Some(session))
82}
83
84/// Replay a single log entry onto a session.
85fn 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                // Use original envelope timestamp for replay determinism;
109                // fall back to received_at_ms for legacy log entries.
110                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            // RFC-MACP-0001 §7.3: cancellation replays to the terminal CANCELLED
137            // state (distinct from EXPIRED).
138            "SessionCancel" => {
139                let _ = session.cancel();
140            }
141            // RFC-MACP-0001 §7.5 / RFC-MACP-0003 §2: suspend/resume are on the
142            // replayed timeline; banking uses the recorded entry timestamp so a
143            // suspended-then-resumed session replays to the identical deadline.
144            "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            // Skip intermediate checkpoints when replaying from an earlier one
164        }
165    }
166    Ok(())
167}
168
169/// Full replay from the SessionStart entry.
170fn replay_from_start(
171    session_id: &str,
172    log_entries: &[LogEntry],
173    registry: &ModeRegistry,
174    policy_registry: Option<&PolicyRegistry>,
175) -> Result<Session, MacpError> {
176    // 1. Find the SessionStart entry
177    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    // Determine mode: prefer entry-level field, fall back to empty for legacy
183    let mode_name = if start_entry.mode.is_empty() {
184        // Legacy v2 entry — cannot determine mode from log entry alone;
185        // caller should skip or use directory heuristic
186        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    // 2. Parse SessionStartPayload
194    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        // Legacy experimental modes may have 0 ttl_ms
206        60_000i64
207    } else {
208        extract_ttl_ms(&start_payload)?
209    };
210
211    // 3. Construct base session — use original received_at_ms, never Utc::now()
212    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    // 4. Call mode.on_session_start(), apply response
265    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    // 5. Replay subsequent entries
270    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, &registry, 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, &registry, 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, &registry, 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, &registry, None).unwrap();
444        // RFC-MACP-0001 §7.3: cancellation now terminates as CANCELLED.
445        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, &registry, None).unwrap_err();
469        // The exact error variant depends on which check fails first (authorize_sender
470        // or on_message); what matters is that replay does NOT silently succeed.
471        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", &[], &registry, None);
482        assert!(result.is_err());
483    }
484
485    #[test]
486    fn backward_compat_old_log_entry_without_new_fields() {
487        // Simulate deserializing a v2 log entry without session_id/mode/macp_version
488        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        // Build a session via normal replay first
502        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, &registry, None).unwrap();
527
528        // Create a checkpoint from the replayed session state
529        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        // A vote after the checkpoint
545        let vote = VotePayload {
546            proposal_id: "p1".into(),
547            vote: "approve".into(),
548            reason: "lgtm".into(),
549        }
550        .encode_to_vec();
551
552        // Log: SessionStart, Proposal, Checkpoint, Vote
553        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, &registry, None).unwrap();
561        assert_eq!(session.state, SessionState::Open);
562        // Should have dedup from checkpoint (m1, m2) plus newly replayed m3
563        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        // Ensure logs without checkpoints replay correctly (backward compat)
571        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, &registry, None).unwrap();
580        assert_eq!(session.state, SessionState::Open);
581        assert!(session.seen_message_ids.contains("m1"));
582    }
583}