Skip to main content

macp_modes/
step.rs

1//! The per-message coordination step — the pure, I/O-free kernel invariants.
2//!
3//! Every accepted MACP message passes the same per-message invariants: dedup
4//! (RFC-MACP-0001 §8 idempotency), mode-binding, TTL, and the monotonic OPEN
5//! gate (§7.2/§7.3), then mode validation, then commit. Historically these
6//! lived welded into the gRPC server's `process_message`, so any other consumer
7//! of the coordination core (e.g. an embedding library) had to re-implement
8//! them and risk drift. This module hosts them once — synchronous and free of
9//! tokio, storage, transport, and the wall clock (the caller injects `now_ms`).
10//!
11//! Two ways to drive it:
12//! - [`step`] — all-in-one, for in-memory consumers that do not interpose
13//!   durable storage between validation and commit.
14//! - [`check_preconditions`] + [`validate_message`] + [`commit`] — the phases,
15//!   for a durable consumer (the runtime) that must write the message to its
16//!   append-only log *between* validation and commit, so a failed write never
17//!   consumes a dedup slot.
18
19use crate::mode::{Mode, ModeResponse};
20use macp_core::error::MacpError;
21use macp_core::session::{Session, SessionState};
22use macp_pb::pb::Envelope;
23
24/// Outcome of the mode-independent precondition checks.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Precheck {
27    /// `message_id` already accepted — idempotent no-op.
28    Duplicate,
29    /// The session's TTL has elapsed; the caller must expire the session.
30    Expired,
31    /// Preconditions satisfied — proceed to mode validation.
32    Proceed,
33}
34
35/// Mode-independent per-message invariants. Pure: no mutation, no I/O, no clock.
36///
37/// Order mirrors the runtime's `process_message` exactly: dedup → mode-binding
38/// → TTL → the monotonic OPEN gate. `now_ms` is the injected clock (the
39/// envelope/replay timestamp). The TTL check uses a strict `>` and is guarded
40/// on `Open`, matching the runtime's `maybe_expire_session`: a message arriving
41/// exactly at `ttl_expiry` does not expire, and a non-`Open` session is never
42/// re-expired (it falls through to [`MacpError::SessionNotOpen`]).
43pub fn check_preconditions(
44    session: &Session,
45    env: &Envelope,
46    now_ms: i64,
47) -> Result<Precheck, MacpError> {
48    if session.seen_message_ids.contains(&env.message_id) {
49        return Ok(Precheck::Duplicate);
50    }
51    if env.mode != session.mode {
52        return Err(MacpError::InvalidEnvelope);
53    }
54    if session.state == SessionState::Open && now_ms > session.ttl_expiry {
55        return Ok(Precheck::Expired);
56    }
57    if session.state != SessionState::Open {
58        return Err(MacpError::SessionNotOpen);
59    }
60    Ok(Precheck::Proceed)
61}
62
63/// Mode-dependent validation: sender authorization + mode rules. Pure — returns
64/// the [`ModeResponse`] to apply and mutates nothing. Call only after
65/// [`check_preconditions`] returns [`Precheck::Proceed`].
66pub fn validate_message(
67    session: &Session,
68    env: &Envelope,
69    mode: &dyn Mode,
70) -> Result<ModeResponse, MacpError> {
71    mode.authorize_sender(session, env)?;
72    mode.on_message(session, env)
73}
74
75/// Commit a validated message into the session: consume the dedup slot, record
76/// participant activity, and apply the mode response. Returns the resulting
77/// session state.
78///
79/// A durable consumer MUST call this only after the message has been durably
80/// recorded, so a failed write never consumes a dedup slot. Because nothing
81/// here mutates the session until validation has already succeeded, a rejected
82/// message likewise leaves `seen_message_ids` untouched.
83pub fn commit(
84    session: &mut Session,
85    env: &Envelope,
86    response: ModeResponse,
87    now_ms: i64,
88) -> SessionState {
89    session.seen_message_ids.insert(env.message_id.clone());
90    session.record_participant_activity(&env.sender, now_ms);
91    session.apply_mode_response(response);
92    session.state.clone()
93}
94
95/// Outcome of [`step`].
96#[derive(Debug, Clone, PartialEq)]
97pub enum StepOutcome {
98    /// `message_id` already accepted — nothing changed.
99    Duplicate,
100    /// Message validated, committed, and applied; carries the resulting state.
101    Accepted { state: SessionState },
102}
103
104/// All-in-one per-message step for in-memory consumers: preconditions → mode
105/// validation → commit, mirroring the runtime's external contract. Expiry marks
106/// the session `Expired` and returns [`MacpError::TtlExpired`]; a duplicate is
107/// reported as [`StepOutcome::Duplicate`]; any other rejection returns its error
108/// without consuming a dedup slot or applying state.
109///
110/// A durable consumer should instead use [`check_preconditions`],
111/// [`validate_message`], and [`commit`] so it can interpose its append-only
112/// write between validation and commit (see the runtime's `process_message`).
113pub fn step(
114    session: &mut Session,
115    env: &Envelope,
116    mode: &dyn Mode,
117    now_ms: i64,
118) -> Result<StepOutcome, MacpError> {
119    match check_preconditions(session, env, now_ms)? {
120        Precheck::Duplicate => Ok(StepOutcome::Duplicate),
121        Precheck::Expired => {
122            session.state = SessionState::Expired;
123            Err(MacpError::TtlExpired)
124        }
125        Precheck::Proceed => {
126            let response = validate_message(session, env, mode)?;
127            let state = commit(session, env, response, now_ms);
128            Ok(StepOutcome::Accepted { state })
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use std::collections::{HashMap, HashSet};
137
138    const MODE: &str = "macp.mode.test.v1";
139
140    // A trivial mode: every participant may send; a `Commitment` resolves the
141    // session, anything else just persists. Lets us exercise the step invariants
142    // without policy or protobuf payloads.
143    struct TestMode;
144    impl Mode for TestMode {
145        fn on_session_start(&self, _s: &Session, _e: &Envelope) -> Result<ModeResponse, MacpError> {
146            Ok(ModeResponse::PersistState(vec![]))
147        }
148        fn on_message(&self, _s: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
149            if env.message_type == "Commitment" {
150                Ok(ModeResponse::PersistAndResolve {
151                    state: vec![1],
152                    resolution: vec![2],
153                })
154            } else {
155                Ok(ModeResponse::PersistState(vec![1]))
156            }
157        }
158        // default authorize_sender: sender must be a declared participant.
159    }
160
161    fn session() -> Session {
162        Session {
163            session_id: "11111111-1111-4111-8111-111111111111".into(),
164            state: SessionState::Open,
165            ttl_expiry: 10_000,
166            ttl_ms: 10_000,
167            started_at_unix_ms: 0,
168            resolution: None,
169            mode: MODE.into(),
170            mode_state: vec![],
171            participants: vec!["agent://a".into(), "agent://b".into()],
172            seen_message_ids: HashSet::new(),
173            intent: String::new(),
174            mode_version: "1.0.0".into(),
175            configuration_version: "cfg-1".into(),
176            policy_version: String::new(),
177            context_id: String::new(),
178            extensions: HashMap::new(),
179            roots: vec![],
180            initiator_sender: "agent://a".into(),
181            participant_message_counts: HashMap::new(),
182            participant_last_seen: HashMap::new(),
183            policy_definition: None,
184            suspended_at_ms: None,
185            accumulated_suspended_ms: 0,
186        }
187    }
188
189    fn env(sender: &str, message_type: &str, message_id: &str) -> Envelope {
190        Envelope {
191            macp_version: "1.0".into(),
192            mode: MODE.into(),
193            message_type: message_type.into(),
194            message_id: message_id.into(),
195            session_id: "11111111-1111-4111-8111-111111111111".into(),
196            sender: sender.into(),
197            timestamp_unix_ms: 0,
198            payload: vec![],
199        }
200    }
201
202    #[test]
203    fn duplicate_is_reported_and_changes_nothing() {
204        let mut s = session();
205        s.seen_message_ids.insert("m1".into());
206        let before = s.seen_message_ids.len();
207        let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 1).unwrap();
208        assert_eq!(out, StepOutcome::Duplicate);
209        assert_eq!(s.seen_message_ids.len(), before);
210        assert_eq!(s.state, SessionState::Open);
211    }
212
213    #[test]
214    fn mode_binding_mismatch_rejected() {
215        let mut s = session();
216        let mut e = env("agent://a", "Msg", "m1");
217        e.mode = "macp.mode.other.v1".into();
218        assert!(matches!(
219            step(&mut s, &e, &TestMode, 1).unwrap_err(),
220            MacpError::InvalidEnvelope
221        ));
222        assert!(s.seen_message_ids.is_empty());
223    }
224
225    #[test]
226    fn ttl_strict_boundary_does_not_expire_but_past_does() {
227        // now == ttl_expiry: NOT expired (strict `>`), message is accepted.
228        let mut s = session();
229        let deadline = s.ttl_expiry;
230        let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, deadline).unwrap();
231        assert_eq!(
232            out,
233            StepOutcome::Accepted {
234                state: SessionState::Open
235            }
236        );
237
238        // now > ttl_expiry: expired, session marked Expired, dedup untouched.
239        let mut s2 = session();
240        let past = s2.ttl_expiry + 1;
241        let err = step(&mut s2, &env("agent://a", "Msg", "m2"), &TestMode, past).unwrap_err();
242        assert!(matches!(err, MacpError::TtlExpired));
243        assert_eq!(s2.state, SessionState::Expired);
244        assert!(s2.seen_message_ids.is_empty());
245    }
246
247    #[test]
248    fn ttl_does_not_re_expire_a_resolved_session() {
249        // A resolved session past its original ttl must report SessionNotOpen,
250        // not flip to Expired or return TtlExpired (matches maybe_expire_session).
251        let mut s = session();
252        s.state = SessionState::Resolved;
253        let past = s.ttl_expiry + 5_000;
254        let err = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, past).unwrap_err();
255        assert!(matches!(err, MacpError::SessionNotOpen));
256        assert_eq!(s.state, SessionState::Resolved);
257    }
258
259    #[test]
260    fn non_open_session_rejected() {
261        for st in [SessionState::Resolved, SessionState::Expired] {
262            let mut s = session();
263            s.state = st.clone();
264            assert!(matches!(
265                step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 1).unwrap_err(),
266                MacpError::SessionNotOpen
267            ));
268        }
269    }
270
271    #[test]
272    fn accepted_consumes_dedup_records_activity_and_applies_state() {
273        let mut s = session();
274        let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 42).unwrap();
275        assert_eq!(
276            out,
277            StepOutcome::Accepted {
278                state: SessionState::Open
279            }
280        );
281        assert!(s.seen_message_ids.contains("m1"));
282        assert_eq!(s.mode_state, vec![1]);
283        assert_eq!(s.participant_last_seen.get("agent://a"), Some(&42));
284    }
285
286    #[test]
287    fn commitment_resolves() {
288        let mut s = session();
289        let out = step(&mut s, &env("agent://a", "Commitment", "c1"), &TestMode, 1).unwrap();
290        assert_eq!(
291            out,
292            StepOutcome::Accepted {
293                state: SessionState::Resolved
294            }
295        );
296        assert_eq!(s.state, SessionState::Resolved);
297        assert_eq!(s.resolution, Some(vec![2]));
298    }
299
300    #[test]
301    fn rejected_validation_does_not_consume_dedup_slot() {
302        // Dedup invariant (CLAUDE.md §8): a message rejected by mode validation
303        // must NOT consume its dedup slot — a later valid message with the same
304        // id is accepted normally.
305        let mut s = session();
306        let err = step(&mut s, &env("agent://stranger", "Msg", "m1"), &TestMode, 1).unwrap_err();
307        assert!(matches!(err, MacpError::Forbidden));
308        assert!(!s.seen_message_ids.contains("m1"));
309        // Same id, now from an authorized participant: accepted.
310        let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 1).unwrap();
311        assert_eq!(
312            out,
313            StepOutcome::Accepted {
314                state: SessionState::Open
315            }
316        );
317        assert!(s.seen_message_ids.contains("m1"));
318    }
319
320    #[test]
321    fn clock_is_injected_not_wall_clock() {
322        // Expiry is decided purely by the injected now_ms, independent of the
323        // wall clock — a far-future deadline never expires, a past one does.
324        let mut s = session();
325        s.ttl_expiry = i64::MAX;
326        assert!(matches!(
327            check_preconditions(&s, &env("agent://a", "Msg", "m1"), i64::MAX - 1),
328            Ok(Precheck::Proceed)
329        ));
330        let mut s2 = session();
331        s2.ttl_expiry = 0;
332        assert!(matches!(
333            check_preconditions(&s2, &env("agent://a", "Msg", "m1"), 1),
334            Ok(Precheck::Expired)
335        ));
336    }
337
338    #[test]
339    fn phases_compose_like_step_for_durable_consumers() {
340        // The runtime path: check_preconditions -> validate_message -> commit.
341        let mut s = session();
342        let e = env("agent://b", "Msg", "m1");
343        assert_eq!(check_preconditions(&s, &e, 5).unwrap(), Precheck::Proceed);
344        let resp = validate_message(&s, &e, &TestMode).unwrap();
345        // Nothing applied until commit.
346        assert!(s.seen_message_ids.is_empty());
347        let state = commit(&mut s, &e, resp, 5);
348        assert_eq!(state, SessionState::Open);
349        assert!(s.seen_message_ids.contains("m1"));
350    }
351}