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
137    const MODE: &str = "macp.mode.test.v1";
138
139    // A trivial mode: every participant may send; a `Commitment` resolves the
140    // session, anything else just persists. Lets us exercise the step invariants
141    // without policy or protobuf payloads.
142    struct TestMode;
143    impl Mode for TestMode {
144        fn on_session_start(&self, _s: &Session, _e: &Envelope) -> Result<ModeResponse, MacpError> {
145            Ok(ModeResponse::PersistState(vec![]))
146        }
147        fn on_message(&self, _s: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
148            if env.message_type == "Commitment" {
149                Ok(ModeResponse::PersistAndResolve {
150                    state: vec![1],
151                    resolution: vec![2],
152                })
153            } else {
154                Ok(ModeResponse::PersistState(vec![1]))
155            }
156        }
157        // default authorize_sender: sender must be a declared participant.
158    }
159
160    fn session() -> Session {
161        Session::builder("11111111-1111-4111-8111-111111111111", MODE, "agent://a")
162            .ttl_expiry(10_000)
163            .ttl_ms(10_000)
164            .participants(vec!["agent://a".into(), "agent://b".into()])
165            .mode_version("1.0.0")
166            .configuration_version("cfg-1")
167            .build()
168    }
169
170    fn env(sender: &str, message_type: &str, message_id: &str) -> Envelope {
171        Envelope {
172            macp_version: "1.0".into(),
173            mode: MODE.into(),
174            message_type: message_type.into(),
175            message_id: message_id.into(),
176            session_id: "11111111-1111-4111-8111-111111111111".into(),
177            sender: sender.into(),
178            timestamp_unix_ms: 0,
179            payload: vec![],
180        }
181    }
182
183    #[test]
184    fn duplicate_is_reported_and_changes_nothing() {
185        let mut s = session();
186        s.seen_message_ids.insert("m1".into());
187        let before = s.seen_message_ids.len();
188        let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 1).unwrap();
189        assert_eq!(out, StepOutcome::Duplicate);
190        assert_eq!(s.seen_message_ids.len(), before);
191        assert_eq!(s.state, SessionState::Open);
192    }
193
194    #[test]
195    fn mode_binding_mismatch_rejected() {
196        let mut s = session();
197        let mut e = env("agent://a", "Msg", "m1");
198        e.mode = "macp.mode.other.v1".into();
199        assert!(matches!(
200            step(&mut s, &e, &TestMode, 1).unwrap_err(),
201            MacpError::InvalidEnvelope
202        ));
203        assert!(s.seen_message_ids.is_empty());
204    }
205
206    #[test]
207    fn ttl_strict_boundary_does_not_expire_but_past_does() {
208        // now == ttl_expiry: NOT expired (strict `>`), message is accepted.
209        let mut s = session();
210        let deadline = s.ttl_expiry;
211        let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, deadline).unwrap();
212        assert_eq!(
213            out,
214            StepOutcome::Accepted {
215                state: SessionState::Open
216            }
217        );
218
219        // now > ttl_expiry: expired, session marked Expired, dedup untouched.
220        let mut s2 = session();
221        let past = s2.ttl_expiry + 1;
222        let err = step(&mut s2, &env("agent://a", "Msg", "m2"), &TestMode, past).unwrap_err();
223        assert!(matches!(err, MacpError::TtlExpired));
224        assert_eq!(s2.state, SessionState::Expired);
225        assert!(s2.seen_message_ids.is_empty());
226    }
227
228    #[test]
229    fn ttl_does_not_re_expire_a_resolved_session() {
230        // A resolved session past its original ttl must report SessionNotOpen,
231        // not flip to Expired or return TtlExpired (matches maybe_expire_session).
232        let mut s = session();
233        s.state = SessionState::Resolved;
234        let past = s.ttl_expiry + 5_000;
235        let err = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, past).unwrap_err();
236        assert!(matches!(err, MacpError::SessionNotOpen));
237        assert_eq!(s.state, SessionState::Resolved);
238    }
239
240    #[test]
241    fn non_open_session_rejected() {
242        for st in [SessionState::Resolved, SessionState::Expired] {
243            let mut s = session();
244            s.state = st.clone();
245            assert!(matches!(
246                step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 1).unwrap_err(),
247                MacpError::SessionNotOpen
248            ));
249        }
250    }
251
252    #[test]
253    fn accepted_consumes_dedup_records_activity_and_applies_state() {
254        let mut s = session();
255        let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 42).unwrap();
256        assert_eq!(
257            out,
258            StepOutcome::Accepted {
259                state: SessionState::Open
260            }
261        );
262        assert!(s.seen_message_ids.contains("m1"));
263        assert_eq!(s.mode_state, vec![1]);
264        assert_eq!(s.participant_last_seen.get("agent://a"), Some(&42));
265    }
266
267    #[test]
268    fn commitment_resolves() {
269        let mut s = session();
270        let out = step(&mut s, &env("agent://a", "Commitment", "c1"), &TestMode, 1).unwrap();
271        assert_eq!(
272            out,
273            StepOutcome::Accepted {
274                state: SessionState::Resolved
275            }
276        );
277        assert_eq!(s.state, SessionState::Resolved);
278        assert_eq!(s.resolution, Some(vec![2]));
279    }
280
281    #[test]
282    fn rejected_validation_does_not_consume_dedup_slot() {
283        // Dedup invariant (CLAUDE.md §8): a message rejected by mode validation
284        // must NOT consume its dedup slot — a later valid message with the same
285        // id is accepted normally.
286        let mut s = session();
287        let err = step(&mut s, &env("agent://stranger", "Msg", "m1"), &TestMode, 1).unwrap_err();
288        assert!(matches!(err, MacpError::Forbidden));
289        assert!(!s.seen_message_ids.contains("m1"));
290        // Same id, now from an authorized participant: accepted.
291        let out = step(&mut s, &env("agent://a", "Msg", "m1"), &TestMode, 1).unwrap();
292        assert_eq!(
293            out,
294            StepOutcome::Accepted {
295                state: SessionState::Open
296            }
297        );
298        assert!(s.seen_message_ids.contains("m1"));
299    }
300
301    #[test]
302    fn clock_is_injected_not_wall_clock() {
303        // Expiry is decided purely by the injected now_ms, independent of the
304        // wall clock — a far-future deadline never expires, a past one does.
305        let mut s = session();
306        s.ttl_expiry = i64::MAX;
307        assert!(matches!(
308            check_preconditions(&s, &env("agent://a", "Msg", "m1"), i64::MAX - 1),
309            Ok(Precheck::Proceed)
310        ));
311        let mut s2 = session();
312        s2.ttl_expiry = 0;
313        assert!(matches!(
314            check_preconditions(&s2, &env("agent://a", "Msg", "m1"), 1),
315            Ok(Precheck::Expired)
316        ));
317    }
318
319    #[test]
320    fn phases_compose_like_step_for_durable_consumers() {
321        // The runtime path: check_preconditions -> validate_message -> commit.
322        let mut s = session();
323        let e = env("agent://b", "Msg", "m1");
324        assert_eq!(check_preconditions(&s, &e, 5).unwrap(), Precheck::Proceed);
325        let resp = validate_message(&s, &e, &TestMode).unwrap();
326        // Nothing applied until commit.
327        assert!(s.seen_message_ids.is_empty());
328        let state = commit(&mut s, &e, resp, 5);
329        assert_eq!(state, SessionState::Open);
330        assert!(s.seen_message_ids.contains("m1"));
331    }
332}