Skip to main content

macp_modes/mode/
multi_round.rs

1use crate::mode::util::validate_commitment_payload_for_session;
2use crate::mode::{Mode, ModeResponse};
3use macp_core::error::MacpError;
4use macp_core::session::Session;
5use macp_pb::pb::Envelope;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9/// Internal state tracked across rounds.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct MultiRoundState {
12    pub round: u64,
13    pub participants: Vec<String>,
14    pub contributions: BTreeMap<String, String>,
15    #[serde(default)]
16    pub convergence_type: String,
17    #[serde(default)]
18    pub converged: bool,
19}
20
21/// Legacy JSON shape for Contribute messages (pre-proto wire format).
22#[derive(Debug, Clone, Deserialize)]
23struct ContributeJson {
24    value: String,
25}
26
27/// Parse a Contribute payload: canonical protobuf
28/// (`macp.modes.multi_round.v1.ContributePayload`) or the legacy JSON
29/// `{"value": "..."}`.
30///
31/// JSON is tried FIRST, permanently. Every payload accepted before the proto
32/// encoding existed was JSON, and replay must parse those bytes identically
33/// forever (RFC-MACP-0003 §1). Trying proto first would let pathological JSON
34/// bytes decode as a *valid* proto message with a different value (e.g. `{`
35/// opens a proto group that a later `|` byte closes) and silently change a
36/// replayed contribution. A proto payload never parses as a JSON object, so
37/// this order is deterministic and costs proto senders one failed JSON parse.
38fn parse_contribute_value(payload: &[u8]) -> Result<String, MacpError> {
39    // Empty payloads were always rejected in the JSON era (and canonical
40    // proto3 encoding cannot produce a non-empty encoding for value "");
41    // keep rejecting them rather than accepting an empty contribution.
42    if payload.is_empty() {
43        return Err(MacpError::InvalidPayload);
44    }
45    if let Ok(text) = std::str::from_utf8(payload) {
46        if let Ok(c) = serde_json::from_str::<ContributeJson>(text) {
47            return Ok(c.value);
48        }
49    }
50    <macp_pb::multi_round_pb::ContributePayload as prost::Message>::decode(payload)
51        .map(|c| c.value)
52        .map_err(|_| MacpError::InvalidPayload)
53}
54
55/// Resolution payload emitted on convergence.
56#[derive(Debug, Serialize)]
57struct ResolutionPayload {
58    converged_value: String,
59    round: u64,
60    #[serde(rename = "final")]
61    final_values: BTreeMap<String, String>,
62}
63
64pub struct MultiRoundMode;
65
66impl MultiRoundMode {
67    fn encode_state(state: &MultiRoundState) -> Vec<u8> {
68        crate::mode::util::encode_mode_state(state)
69    }
70
71    fn decode_state(data: &[u8]) -> Result<MultiRoundState, MacpError> {
72        crate::mode::util::decode_mode_state(data)
73    }
74
75    fn check_convergence(state: &MultiRoundState) -> bool {
76        let all_contributed = state
77            .participants
78            .iter()
79            .all(|p| state.contributions.contains_key(p));
80
81        if !all_contributed {
82            return false;
83        }
84
85        let values: Vec<&String> = state.contributions.values().collect();
86        values.windows(2).all(|w| w[0] == w[1])
87    }
88}
89
90impl Mode for MultiRoundMode {
91    fn on_session_start(
92        &self,
93        session: &Session,
94        _env: &Envelope,
95    ) -> Result<ModeResponse, MacpError> {
96        let participants = session.participants.clone();
97
98        if participants.is_empty() {
99            return Err(MacpError::InvalidPayload);
100        }
101
102        let state = MultiRoundState {
103            round: 0,
104            participants,
105            contributions: BTreeMap::new(),
106            convergence_type: "all_equal".into(),
107            converged: false,
108        };
109
110        Ok(ModeResponse::PersistState(Self::encode_state(&state)))
111    }
112
113    fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
114        match env.message_type.as_str() {
115            "Contribute" => self.handle_contribute(session, env),
116            "Commitment" => self.handle_commitment(session, env),
117            _ => Err(MacpError::InvalidPayload),
118        }
119    }
120
121    fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
122        if env.message_type == "Commitment" {
123            // Only the initiator can emit Commitment
124            if env.sender != session.initiator_sender {
125                return Err(MacpError::Forbidden);
126            }
127            return Ok(());
128        }
129        // Default: must be a declared participant
130        if !session.participants.is_empty() && !session.participants.contains(&env.sender) {
131            return Err(MacpError::Forbidden);
132        }
133        Ok(())
134    }
135}
136
137impl MultiRoundMode {
138    fn handle_contribute(
139        &self,
140        session: &Session,
141        env: &Envelope,
142    ) -> Result<ModeResponse, MacpError> {
143        let mut state = Self::decode_state(&session.mode_state)?;
144
145        if state.converged {
146            return Err(MacpError::InvalidPayload);
147        }
148
149        let value = parse_contribute_value(&env.payload)?;
150
151        let previous = state.contributions.get(&env.sender);
152        let value_changed = previous.is_none_or(|prev| *prev != value);
153
154        if value_changed {
155            state.round += 1;
156            state.contributions.insert(env.sender.clone(), value);
157        }
158
159        if Self::check_convergence(&state) {
160            state.converged = true;
161        }
162
163        Ok(ModeResponse::PersistState(Self::encode_state(&state)))
164    }
165
166    fn handle_commitment(
167        &self,
168        session: &Session,
169        env: &Envelope,
170    ) -> Result<ModeResponse, MacpError> {
171        let state = Self::decode_state(&session.mode_state)?;
172
173        if !state.converged {
174            return Err(MacpError::InvalidPayload);
175        }
176
177        validate_commitment_payload_for_session(session, &env.payload)?;
178
179        let converged_value = state
180            .contributions
181            .values()
182            .next()
183            .cloned()
184            .unwrap_or_default();
185        let resolution = ResolutionPayload {
186            converged_value,
187            round: state.round,
188            final_values: state.contributions.clone(),
189        };
190        let resolution_bytes =
191            serde_json::to_vec(&resolution).expect("ResolutionPayload is always serializable");
192
193        Ok(ModeResponse::PersistAndResolve {
194            state: Self::encode_state(&state),
195            resolution: resolution_bytes,
196        })
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    use macp_pb::pb::CommitmentPayload;
205    use prost::Message;
206
207    fn base_session() -> Session {
208        Session::builder("s1", "ext.multi_round.v1", "coordinator")
209            .ttl_ms(60_000)
210            .mode_version("1.0.0")
211            .configuration_version("cfg-1")
212            .build()
213    }
214
215    fn session_start_env() -> Envelope {
216        Envelope {
217            macp_version: "1.0".into(),
218            mode: "ext.multi_round.v1".into(),
219            message_type: "SessionStart".into(),
220            message_id: "m0".into(),
221            session_id: "s1".into(),
222            sender: "coordinator".into(),
223            timestamp_unix_ms: 1_700_000_000_000,
224            payload: vec![],
225        }
226    }
227
228    fn contribute_env_with_payload(sender: &str, payload: Vec<u8>) -> Envelope {
229        Envelope {
230            macp_version: "1.0".into(),
231            mode: "ext.multi_round.v1".into(),
232            message_type: "Contribute".into(),
233            message_id: format!("m_{}", sender),
234            session_id: "s1".into(),
235            sender: sender.into(),
236            timestamp_unix_ms: 1_700_000_000_000,
237            payload,
238        }
239    }
240
241    /// Canonical proto encoding — the primary wire format.
242    fn contribute_env(sender: &str, value: &str) -> Envelope {
243        let payload = macp_pb::multi_round_pb::ContributePayload {
244            value: value.into(),
245        }
246        .encode_to_vec();
247        contribute_env_with_payload(sender, payload)
248    }
249
250    /// Legacy JSON encoding — kept accepted for replay compatibility.
251    fn contribute_env_json(sender: &str, value: &str) -> Envelope {
252        let payload = serde_json::json!({"value": value}).to_string();
253        contribute_env_with_payload(sender, payload.into_bytes())
254    }
255
256    fn commitment_env(sender: &str) -> Envelope {
257        let payload = CommitmentPayload {
258            commitment_id: "c1".into(),
259            action: "multi_round.converged".into(),
260            authority_scope: "test".into(),
261            reason: "converged".into(),
262            mode_version: "1.0.0".into(),
263            policy_version: String::new(),
264            configuration_version: "cfg-1".into(),
265            outcome_positive: true,
266            supersedes: None,
267        }
268        .encode_to_vec();
269        Envelope {
270            macp_version: "1.0".into(),
271            mode: "ext.multi_round.v1".into(),
272            message_type: "Commitment".into(),
273            message_id: "m_commit".into(),
274            session_id: "s1".into(),
275            sender: sender.into(),
276            timestamp_unix_ms: 1_700_000_000_000,
277            payload,
278        }
279    }
280
281    fn session_with_state(state: &MultiRoundState) -> Session {
282        let mut s = base_session();
283        s.mode_state = MultiRoundMode::encode_state(state);
284        s.participants = state.participants.clone();
285        s
286    }
287
288    #[test]
289    fn session_start_parses_valid_config() {
290        let mode = MultiRoundMode;
291        let mut session = base_session();
292        session.participants = vec!["alice".into(), "bob".into()];
293        let env = session_start_env();
294
295        let result = mode.on_session_start(&session, &env).unwrap();
296        match result {
297            ModeResponse::PersistState(data) => {
298                let state: MultiRoundState = serde_json::from_slice(&data).unwrap();
299                assert_eq!(state.round, 0);
300                assert_eq!(state.participants, vec!["alice", "bob"]);
301                assert!(state.contributions.is_empty());
302                assert!(!state.converged);
303            }
304            _ => panic!("Expected PersistState"),
305        }
306    }
307
308    #[test]
309    fn session_start_rejects_empty_participants() {
310        let mode = MultiRoundMode;
311        let session = base_session();
312        let env = session_start_env();
313
314        let err = mode.on_session_start(&session, &env).unwrap_err();
315        assert_eq!(err.to_string(), "InvalidPayload");
316    }
317
318    #[test]
319    fn contribute_first_value_increments_round() {
320        let mode = MultiRoundMode;
321        let state = MultiRoundState {
322            round: 0,
323            participants: vec!["alice".into(), "bob".into()],
324            contributions: BTreeMap::new(),
325            convergence_type: "all_equal".into(),
326            converged: false,
327        };
328        let session = session_with_state(&state);
329        let env = contribute_env("alice", "option_a");
330
331        let result = mode.on_message(&session, &env).unwrap();
332        match result {
333            ModeResponse::PersistState(data) => {
334                let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
335                assert_eq!(new_state.round, 1);
336                assert_eq!(new_state.contributions.get("alice").unwrap(), "option_a");
337                assert!(!new_state.converged);
338            }
339            _ => panic!("Expected PersistState"),
340        }
341    }
342
343    #[test]
344    fn resubmit_same_value_does_not_increment_round() {
345        let mode = MultiRoundMode;
346        let mut contributions = BTreeMap::new();
347        contributions.insert("alice".to_string(), "option_a".to_string());
348        let state = MultiRoundState {
349            round: 1,
350            participants: vec!["alice".into(), "bob".into()],
351            contributions,
352            convergence_type: "all_equal".into(),
353            converged: false,
354        };
355        let session = session_with_state(&state);
356        let env = contribute_env("alice", "option_a");
357
358        let result = mode.on_message(&session, &env).unwrap();
359        match result {
360            ModeResponse::PersistState(data) => {
361                let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
362                assert_eq!(new_state.round, 1);
363            }
364            _ => panic!("Expected PersistState"),
365        }
366    }
367
368    #[test]
369    fn revise_value_increments_round() {
370        let mode = MultiRoundMode;
371        let mut contributions = BTreeMap::new();
372        contributions.insert("alice".to_string(), "option_a".to_string());
373        let state = MultiRoundState {
374            round: 1,
375            participants: vec!["alice".into(), "bob".into()],
376            contributions,
377            convergence_type: "all_equal".into(),
378            converged: false,
379        };
380        let session = session_with_state(&state);
381        let env = contribute_env("alice", "option_b");
382
383        let result = mode.on_message(&session, &env).unwrap();
384        match result {
385            ModeResponse::PersistState(data) => {
386                let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
387                assert_eq!(new_state.round, 2);
388                assert_eq!(new_state.contributions.get("alice").unwrap(), "option_b");
389            }
390            _ => panic!("Expected PersistState"),
391        }
392    }
393
394    #[test]
395    fn convergence_sets_converged_flag() {
396        let mode = MultiRoundMode;
397        let mut contributions = BTreeMap::new();
398        contributions.insert("alice".to_string(), "option_a".to_string());
399        let state = MultiRoundState {
400            round: 1,
401            participants: vec!["alice".into(), "bob".into()],
402            contributions,
403            convergence_type: "all_equal".into(),
404            converged: false,
405        };
406        let session = session_with_state(&state);
407        let env = contribute_env("bob", "option_a");
408
409        let result = mode.on_message(&session, &env).unwrap();
410        match result {
411            ModeResponse::PersistState(data) => {
412                let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
413                assert_eq!(new_state.round, 2);
414                assert!(new_state.converged);
415            }
416            _ => panic!("Expected PersistState (convergence tracked, not auto-resolved)"),
417        }
418    }
419
420    #[test]
421    fn commitment_after_convergence_resolves() {
422        let mode = MultiRoundMode;
423        let mut contributions = BTreeMap::new();
424        contributions.insert("alice".to_string(), "option_a".to_string());
425        contributions.insert("bob".to_string(), "option_a".to_string());
426        let state = MultiRoundState {
427            round: 2,
428            participants: vec!["alice".into(), "bob".into()],
429            contributions,
430            convergence_type: "all_equal".into(),
431            converged: true,
432        };
433        let session = session_with_state(&state);
434        let env = commitment_env("coordinator");
435
436        let result = mode.on_message(&session, &env).unwrap();
437        match result {
438            ModeResponse::PersistAndResolve { resolution, .. } => {
439                let res: serde_json::Value = serde_json::from_slice(&resolution).unwrap();
440                assert_eq!(res["converged_value"], "option_a");
441                assert_eq!(res["round"], 2);
442            }
443            _ => panic!("Expected PersistAndResolve"),
444        }
445    }
446
447    #[test]
448    fn commitment_before_convergence_rejected() {
449        let mode = MultiRoundMode;
450        let state = MultiRoundState {
451            round: 0,
452            participants: vec!["alice".into(), "bob".into()],
453            contributions: BTreeMap::new(),
454            convergence_type: "all_equal".into(),
455            converged: false,
456        };
457        let session = session_with_state(&state);
458        let env = commitment_env("coordinator");
459
460        let err = mode.on_message(&session, &env).unwrap_err();
461        assert_eq!(err.to_string(), "InvalidPayload");
462    }
463
464    #[test]
465    fn contribute_after_convergence_rejected() {
466        let mode = MultiRoundMode;
467        let mut contributions = BTreeMap::new();
468        contributions.insert("alice".to_string(), "option_a".to_string());
469        contributions.insert("bob".to_string(), "option_a".to_string());
470        let state = MultiRoundState {
471            round: 2,
472            participants: vec!["alice".into(), "bob".into()],
473            contributions,
474            convergence_type: "all_equal".into(),
475            converged: true,
476        };
477        let session = session_with_state(&state);
478        let env = contribute_env("alice", "option_b");
479
480        let err = mode.on_message(&session, &env).unwrap_err();
481        assert_eq!(err.to_string(), "InvalidPayload");
482    }
483
484    #[test]
485    fn non_initiator_commitment_rejected() {
486        let mode = MultiRoundMode;
487        let mut contributions = BTreeMap::new();
488        contributions.insert("alice".to_string(), "option_a".to_string());
489        contributions.insert("bob".to_string(), "option_a".to_string());
490        let state = MultiRoundState {
491            round: 2,
492            participants: vec!["alice".into(), "bob".into()],
493            contributions,
494            convergence_type: "all_equal".into(),
495            converged: true,
496        };
497        let session = session_with_state(&state);
498        let env = commitment_env("alice"); // not the initiator
499
500        let err = mode.authorize_sender(&session, &env).unwrap_err();
501        assert_eq!(err.to_string(), "Forbidden");
502    }
503
504    #[test]
505    fn no_convergence_when_values_differ() {
506        let mode = MultiRoundMode;
507        let mut contributions = BTreeMap::new();
508        contributions.insert("alice".to_string(), "option_a".to_string());
509        let state = MultiRoundState {
510            round: 1,
511            participants: vec!["alice".into(), "bob".into()],
512            contributions,
513            convergence_type: "all_equal".into(),
514            converged: false,
515        };
516        let session = session_with_state(&state);
517        let env = contribute_env("bob", "option_b");
518
519        let result = mode.on_message(&session, &env).unwrap();
520        match result {
521            ModeResponse::PersistState(data) => {
522                let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
523                assert!(!new_state.converged);
524            }
525            _ => panic!("Expected PersistState"),
526        }
527    }
528
529    #[test]
530    fn no_convergence_when_not_all_contributed() {
531        let mode = MultiRoundMode;
532        let state = MultiRoundState {
533            round: 0,
534            participants: vec!["alice".into(), "bob".into(), "carol".into()],
535            contributions: BTreeMap::new(),
536            convergence_type: "all_equal".into(),
537            converged: false,
538        };
539        let session = session_with_state(&state);
540        let env = contribute_env("alice", "option_a");
541
542        let result = mode.on_message(&session, &env).unwrap();
543        assert!(matches!(result, ModeResponse::PersistState(_)));
544    }
545
546    #[test]
547    fn non_contribute_message_rejected() {
548        let mode = MultiRoundMode;
549        let state = MultiRoundState {
550            round: 0,
551            participants: vec!["alice".into()],
552            contributions: BTreeMap::new(),
553            convergence_type: "all_equal".into(),
554            converged: false,
555        };
556        let session = session_with_state(&state);
557        let env = Envelope {
558            macp_version: "1.0".into(),
559            mode: "ext.multi_round.v1".into(),
560            message_type: "Message".into(),
561            message_id: "m1".into(),
562            session_id: "s1".into(),
563            sender: "alice".into(),
564            timestamp_unix_ms: 1_700_000_000_000,
565            payload: b"hello".to_vec(),
566        };
567
568        let err = mode.on_message(&session, &env).unwrap_err();
569        assert_eq!(err.error_code(), "INVALID_ENVELOPE");
570    }
571
572    #[test]
573    fn contribute_invalid_payload_returns_error() {
574        let mode = MultiRoundMode;
575        let state = MultiRoundState {
576            round: 0,
577            participants: vec!["alice".into()],
578            contributions: BTreeMap::new(),
579            convergence_type: "all_equal".into(),
580            converged: false,
581        };
582        let session = session_with_state(&state);
583        let env = Envelope {
584            macp_version: "1.0".into(),
585            mode: "ext.multi_round.v1".into(),
586            message_type: "Contribute".into(),
587            message_id: "m1".into(),
588            session_id: "s1".into(),
589            sender: "alice".into(),
590            timestamp_unix_ms: 1_700_000_000_000,
591            payload: b"not json".to_vec(),
592        };
593
594        let err = mode.on_message(&session, &env).unwrap_err();
595        assert_eq!(err.to_string(), "InvalidPayload");
596    }
597
598    /// Replay compatibility: pre-proto histories carry JSON Contribute
599    /// payloads, and they must keep parsing to the identical value forever
600    /// (RFC-MACP-0003 §1).
601    #[test]
602    fn contribute_json_fallback_still_accepted() {
603        let mode = MultiRoundMode;
604        let state = MultiRoundState {
605            round: 0,
606            participants: vec!["alice".into(), "bob".into()],
607            contributions: BTreeMap::new(),
608            convergence_type: "all_equal".into(),
609            converged: false,
610        };
611        let session = session_with_state(&state);
612
613        let result = mode
614            .on_message(&session, &contribute_env_json("alice", "option_a"))
615            .unwrap();
616        match result {
617            ModeResponse::PersistState(data) => {
618                let state: MultiRoundState = serde_json::from_slice(&data).unwrap();
619                assert_eq!(state.contributions["alice"], "option_a");
620                assert_eq!(state.round, 1);
621            }
622            _ => panic!("Expected PersistState"),
623        }
624    }
625
626    /// The two encodings must be interchangeable mid-session: a JSON
627    /// contribution revised via proto (same value) counts as unchanged.
628    #[test]
629    fn proto_and_json_contributions_are_equivalent() {
630        let mode = MultiRoundMode;
631        let state = MultiRoundState {
632            round: 0,
633            participants: vec!["alice".into(), "bob".into()],
634            contributions: BTreeMap::new(),
635            convergence_type: "all_equal".into(),
636            converged: false,
637        };
638        let session = session_with_state(&state);
639
640        let after_json = match mode
641            .on_message(&session, &contribute_env_json("alice", "option_a"))
642            .unwrap()
643        {
644            ModeResponse::PersistState(data) => data,
645            _ => panic!("Expected PersistState"),
646        };
647        let session = {
648            let state: MultiRoundState = serde_json::from_slice(&after_json).unwrap();
649            session_with_state(&state)
650        };
651
652        // Same value re-sent as proto: no round advance (value unchanged).
653        match mode
654            .on_message(&session, &contribute_env("alice", "option_a"))
655            .unwrap()
656        {
657            ModeResponse::PersistState(data) => {
658                let state: MultiRoundState = serde_json::from_slice(&data).unwrap();
659                assert_eq!(state.round, 1, "unchanged value must not advance the round");
660                assert_eq!(state.contributions["alice"], "option_a");
661            }
662            _ => panic!("Expected PersistState"),
663        }
664    }
665
666    /// Empty payloads were always rejected in the JSON era; the proto path
667    /// must not turn them into an accepted empty contribution.
668    #[test]
669    fn contribute_empty_payload_rejected() {
670        let mode = MultiRoundMode;
671        let state = MultiRoundState {
672            round: 0,
673            participants: vec!["alice".into()],
674            contributions: BTreeMap::new(),
675            convergence_type: "all_equal".into(),
676            converged: false,
677        };
678        let session = session_with_state(&state);
679        let env = contribute_env_with_payload("alice", vec![]);
680
681        let err = mode.on_message(&session, &env).unwrap_err();
682        assert_eq!(err.to_string(), "InvalidPayload");
683    }
684
685    #[test]
686    fn encode_decode_round_trip() {
687        let mut contributions = BTreeMap::new();
688        contributions.insert("alice".into(), "value_a".into());
689        let original = MultiRoundState {
690            round: 5,
691            participants: vec!["alice".into(), "bob".into()],
692            contributions,
693            convergence_type: "all_equal".into(),
694            converged: true,
695        };
696
697        let encoded = MultiRoundMode::encode_state(&original);
698        let decoded = MultiRoundMode::decode_state(&encoded).unwrap();
699
700        assert_eq!(decoded.round, original.round);
701        assert_eq!(decoded.participants, original.participants);
702        assert_eq!(decoded.contributions, original.contributions);
703        assert_eq!(decoded.converged, original.converged);
704    }
705
706    #[test]
707    fn decode_invalid_state_returns_error() {
708        let err = MultiRoundMode::decode_state(b"garbage").unwrap_err();
709        assert_eq!(err.to_string(), "InvalidModeState");
710    }
711
712    #[test]
713    fn three_participant_convergence() {
714        let mode = MultiRoundMode;
715
716        let mut contributions = BTreeMap::new();
717        contributions.insert("alice".to_string(), "option_a".to_string());
718        contributions.insert("bob".to_string(), "option_a".to_string());
719        let state = MultiRoundState {
720            round: 2,
721            participants: vec!["alice".into(), "bob".into(), "carol".into()],
722            contributions,
723            convergence_type: "all_equal".into(),
724            converged: false,
725        };
726        let session = session_with_state(&state);
727        let env = contribute_env("carol", "option_a");
728
729        let result = mode.on_message(&session, &env).unwrap();
730        match result {
731            ModeResponse::PersistState(data) => {
732                let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
733                assert!(new_state.converged);
734            }
735            _ => panic!("Expected PersistState with converged=true"),
736        }
737    }
738
739    #[test]
740    fn unknown_message_type_rejected() {
741        let mode = MultiRoundMode;
742        let state = MultiRoundState {
743            round: 0,
744            participants: vec!["alice".into(), "bob".into()],
745            contributions: BTreeMap::new(),
746            convergence_type: "all_equal".into(),
747            converged: false,
748        };
749        let session = session_with_state(&state);
750        let env = Envelope {
751            macp_version: "1.0".into(),
752            mode: "ext.multi_round.v1".into(),
753            message_type: "UnknownType".into(),
754            message_id: "msg-unknown".into(),
755            session_id: "s1".into(),
756            sender: "alice".into(),
757            timestamp_unix_ms: 0,
758            payload: vec![],
759        };
760        let err = mode.on_message(&session, &env).unwrap_err();
761        assert_eq!(err.error_code(), "INVALID_ENVELOPE");
762    }
763}