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 prost::Message;
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10/// Internal state tracked across rounds.
11///
12/// **`#[non_exhaustive]`** (0.8.0, `DECISIONS.md` D7), for the same reason as
13/// the handoff, quorum, proposal and task records: this is runtime-produced
14/// coordination state, deserialized from accepted envelopes, that grows a
15/// field whenever the mode learns something new —
16/// [`convergence_type`](Self::convergence_type) and
17/// [`converged`](Self::converged) both carry `#[serde(default)]` because they
18/// were added after the fact, and each would be a
19/// `constructible_struct_adds_field` major today. `release-plz.toml`'s
20/// `semver_check = true` turns that into a blocked release PR across all seven
21/// lockstep crates. 0.8.0 is already being taken for the handoff field, so
22/// sealing the rest of the class here costs nothing extra and makes every
23/// future field additive.
24///
25/// Fields stay `pub` and readable; only construction by struct literal from
26/// another crate is refused, and nothing outside `macp-modes` constructs one —
27/// the mode mints it from accepted envelopes, which is why no constructor is
28/// offered in its place.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[non_exhaustive]
31pub struct MultiRoundState {
32    pub round: u64,
33    pub participants: Vec<String>,
34    pub contributions: BTreeMap<String, String>,
35    #[serde(default)]
36    pub convergence_type: String,
37    #[serde(default)]
38    pub converged: bool,
39}
40
41/// Legacy JSON shape for Contribute messages (pre-proto wire format).
42#[derive(Debug, Clone, Deserialize)]
43struct ContributeJson {
44    value: String,
45}
46
47/// Parse a Contribute payload: canonical protobuf
48/// (`macp.modes.multi_round.v1.ContributePayload`) or the legacy JSON
49/// `{"value": "..."}`.
50///
51/// JSON is tried FIRST, permanently. Every payload accepted before the proto
52/// encoding existed was JSON, and replay must parse those bytes identically
53/// forever (RFC-MACP-0003 §1). Trying proto first would let pathological JSON
54/// bytes decode as a *valid* proto message with a different value (e.g. `{`
55/// opens a proto group that a later `|` byte closes) and silently change a
56/// replayed contribution.
57///
58/// A canonical proto payload CAN also parse as a JSON object: at value
59/// byte-lengths 13, 32, and 123, the field-1 tag byte and/or the following
60/// length-varint byte are themselves insignificant JSON whitespace (or, at
61/// 123, the literal `{`), so `serde_json` skips them and misparses the
62/// value's own bytes as a `{"value":"..."}`-shaped object (issue #192). At
63/// `semantics_rev >= 3`, a successful JSON parse is therefore trusted only
64/// when the same bytes do NOT also round-trip byte-identically through the
65/// canonical proto encoding — see [`macp_core::session::CURRENT_SEMANTICS_REV`]
66/// revision 3. Revisions 0-2 keep the unconditional (and, at those three
67/// lengths, wrong) JSON-first reading, so already-persisted histories replay
68/// to the exact outcome they were originally accepted with.
69///
70/// `#[doc(hidden)] pub` solely so `tests/parity_contract.rs` (in the root
71/// `macp-runtime` crate) can assert this predicate directly, against
72/// `schemas/parity/contract.json`'s `contribute_payload`/`contribute_acceptance`
73/// vectors, instead of reimplementing it. Not a stability promise.
74#[doc(hidden)]
75pub fn parse_contribute_value(payload: &[u8], semantics_rev: u32) -> Result<String, MacpError> {
76    // Empty payloads were always rejected in the JSON era (and canonical
77    // proto3 encoding cannot produce a non-empty encoding for value "");
78    // keep rejecting them rather than accepting an empty contribution.
79    if payload.is_empty() {
80        return Err(MacpError::InvalidPayload);
81    }
82    let proto_decoded =
83        <macp_pb::multi_round_pb::ContributePayload as Message>::decode(payload).ok();
84    if let Ok(text) = std::str::from_utf8(payload) {
85        if let Ok(c) = serde_json::from_str::<ContributeJson>(text) {
86            let trust_proto = semantics_rev >= 3
87                && proto_decoded
88                    .as_ref()
89                    .is_some_and(|m| m.encode_to_vec() == payload);
90            if !trust_proto {
91                return Ok(c.value);
92            }
93            tracing::debug!(
94                value_len = payload.len(),
95                "Contribute payload is both valid JSON and canonical proto; preferring proto (issue #192 tie-break)"
96            );
97        }
98    }
99    proto_decoded
100        .map(|c| c.value)
101        .ok_or(MacpError::InvalidPayload)
102}
103
104/// Resolution payload emitted on convergence.
105#[derive(Debug, Serialize)]
106struct ResolutionPayload {
107    converged_value: String,
108    round: u64,
109    #[serde(rename = "final")]
110    final_values: BTreeMap<String, String>,
111}
112
113pub struct MultiRoundMode;
114
115impl MultiRoundMode {
116    fn encode_state(state: &MultiRoundState) -> Vec<u8> {
117        crate::mode::util::encode_mode_state(state)
118    }
119
120    fn decode_state(data: &[u8]) -> Result<MultiRoundState, MacpError> {
121        crate::mode::util::decode_mode_state(data)
122    }
123
124    fn check_convergence(state: &MultiRoundState) -> bool {
125        let all_contributed = state
126            .participants
127            .iter()
128            .all(|p| state.contributions.contains_key(p));
129
130        if !all_contributed {
131            return false;
132        }
133
134        let values: Vec<&String> = state.contributions.values().collect();
135        values.windows(2).all(|w| w[0] == w[1])
136    }
137}
138
139impl Mode for MultiRoundMode {
140    fn on_session_start(
141        &self,
142        session: &Session,
143        _env: &Envelope,
144    ) -> Result<ModeResponse, MacpError> {
145        let participants = session.participants.clone();
146
147        if participants.is_empty() {
148            return Err(MacpError::InvalidPayload);
149        }
150
151        let state = MultiRoundState {
152            round: 0,
153            participants,
154            contributions: BTreeMap::new(),
155            convergence_type: "all_equal".into(),
156            converged: false,
157        };
158
159        Ok(ModeResponse::PersistState(Self::encode_state(&state)))
160    }
161
162    fn on_message(&self, session: &Session, env: &Envelope) -> Result<ModeResponse, MacpError> {
163        match env.message_type.as_str() {
164            "Contribute" => self.handle_contribute(session, env),
165            "Commitment" => self.handle_commitment(session, env),
166            _ => Err(MacpError::InvalidPayload),
167        }
168    }
169
170    fn authorize_sender(&self, session: &Session, env: &Envelope) -> Result<(), MacpError> {
171        if env.message_type == "Commitment" {
172            // Only the initiator can emit Commitment
173            if env.sender != session.initiator_sender {
174                return Err(MacpError::Forbidden);
175            }
176            return Ok(());
177        }
178        // Default: must be a declared participant
179        if !session.participants.is_empty() && !session.participants.contains(&env.sender) {
180            return Err(MacpError::Forbidden);
181        }
182        Ok(())
183    }
184}
185
186impl MultiRoundMode {
187    fn handle_contribute(
188        &self,
189        session: &Session,
190        env: &Envelope,
191    ) -> Result<ModeResponse, MacpError> {
192        let mut state = Self::decode_state(&session.mode_state)?;
193
194        if state.converged {
195            return Err(MacpError::InvalidPayload);
196        }
197
198        let value = parse_contribute_value(&env.payload, session.semantics_rev)?;
199
200        let previous = state.contributions.get(&env.sender);
201        let value_changed = previous.is_none_or(|prev| *prev != value);
202
203        if value_changed {
204            state.round += 1;
205            state.contributions.insert(env.sender.clone(), value);
206        }
207
208        if Self::check_convergence(&state) {
209            state.converged = true;
210        }
211
212        Ok(ModeResponse::PersistState(Self::encode_state(&state)))
213    }
214
215    fn handle_commitment(
216        &self,
217        session: &Session,
218        env: &Envelope,
219    ) -> Result<ModeResponse, MacpError> {
220        let state = Self::decode_state(&session.mode_state)?;
221
222        if !state.converged {
223            return Err(MacpError::InvalidPayload);
224        }
225
226        validate_commitment_payload_for_session(session, &env.payload)?;
227
228        let converged_value = state
229            .contributions
230            .values()
231            .next()
232            .cloned()
233            .unwrap_or_default();
234        let resolution = ResolutionPayload {
235            converged_value,
236            round: state.round,
237            final_values: state.contributions.clone(),
238        };
239        let resolution_bytes =
240            serde_json::to_vec(&resolution).expect("ResolutionPayload is always serializable");
241
242        Ok(ModeResponse::PersistAndResolve {
243            state: Self::encode_state(&state),
244            resolution: resolution_bytes,
245        })
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    use macp_core::session::CURRENT_SEMANTICS_REV;
254    use macp_pb::pb::CommitmentPayload;
255
256    fn base_session() -> Session {
257        Session::builder("s1", "ext.multi_round.v1", "coordinator")
258            .ttl_ms(60_000)
259            .mode_version("1.0.0")
260            .configuration_version("cfg-1")
261            .build()
262    }
263
264    fn session_start_env() -> Envelope {
265        Envelope {
266            macp_version: "1.0".into(),
267            mode: "ext.multi_round.v1".into(),
268            message_type: "SessionStart".into(),
269            message_id: "m0".into(),
270            session_id: "s1".into(),
271            sender: "coordinator".into(),
272            timestamp_unix_ms: 1_700_000_000_000,
273            payload: vec![],
274        }
275    }
276
277    fn contribute_env_with_payload(sender: &str, payload: Vec<u8>) -> Envelope {
278        Envelope {
279            macp_version: "1.0".into(),
280            mode: "ext.multi_round.v1".into(),
281            message_type: "Contribute".into(),
282            message_id: format!("m_{}", sender),
283            session_id: "s1".into(),
284            sender: sender.into(),
285            timestamp_unix_ms: 1_700_000_000_000,
286            payload,
287        }
288    }
289
290    /// Canonical proto encoding — the primary wire format.
291    fn contribute_env(sender: &str, value: &str) -> Envelope {
292        let payload = macp_pb::multi_round_pb::ContributePayload {
293            value: value.into(),
294        }
295        .encode_to_vec();
296        contribute_env_with_payload(sender, payload)
297    }
298
299    /// Legacy JSON encoding — kept accepted for replay compatibility.
300    fn contribute_env_json(sender: &str, value: &str) -> Envelope {
301        let payload = serde_json::json!({"value": value}).to_string();
302        contribute_env_with_payload(sender, payload.into_bytes())
303    }
304
305    fn commitment_env(sender: &str) -> Envelope {
306        let payload = CommitmentPayload {
307            commitment_id: "c1".into(),
308            action: "multi_round.converged".into(),
309            authority_scope: "test".into(),
310            reason: "converged".into(),
311            mode_version: "1.0.0".into(),
312            policy_version: String::new(),
313            configuration_version: "cfg-1".into(),
314            outcome_positive: true,
315            supersedes: None,
316        }
317        .encode_to_vec();
318        Envelope {
319            macp_version: "1.0".into(),
320            mode: "ext.multi_round.v1".into(),
321            message_type: "Commitment".into(),
322            message_id: "m_commit".into(),
323            session_id: "s1".into(),
324            sender: sender.into(),
325            timestamp_unix_ms: 1_700_000_000_000,
326            payload,
327        }
328    }
329
330    fn session_with_state(state: &MultiRoundState) -> Session {
331        let mut s = base_session();
332        s.mode_state = MultiRoundMode::encode_state(state);
333        s.participants = state.participants.clone();
334        s
335    }
336
337    #[test]
338    fn session_start_parses_valid_config() {
339        let mode = MultiRoundMode;
340        let mut session = base_session();
341        session.participants = vec!["alice".into(), "bob".into()];
342        let env = session_start_env();
343
344        let result = mode.on_session_start(&session, &env).unwrap();
345        match result {
346            ModeResponse::PersistState(data) => {
347                let state: MultiRoundState = serde_json::from_slice(&data).unwrap();
348                assert_eq!(state.round, 0);
349                assert_eq!(state.participants, vec!["alice", "bob"]);
350                assert!(state.contributions.is_empty());
351                assert!(!state.converged);
352            }
353            _ => panic!("Expected PersistState"),
354        }
355    }
356
357    #[test]
358    fn session_start_rejects_empty_participants() {
359        let mode = MultiRoundMode;
360        let session = base_session();
361        let env = session_start_env();
362
363        let err = mode.on_session_start(&session, &env).unwrap_err();
364        assert_eq!(err.to_string(), "InvalidPayload");
365    }
366
367    #[test]
368    fn contribute_first_value_increments_round() {
369        let mode = MultiRoundMode;
370        let state = MultiRoundState {
371            round: 0,
372            participants: vec!["alice".into(), "bob".into()],
373            contributions: BTreeMap::new(),
374            convergence_type: "all_equal".into(),
375            converged: false,
376        };
377        let session = session_with_state(&state);
378        let env = contribute_env("alice", "option_a");
379
380        let result = mode.on_message(&session, &env).unwrap();
381        match result {
382            ModeResponse::PersistState(data) => {
383                let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
384                assert_eq!(new_state.round, 1);
385                assert_eq!(new_state.contributions.get("alice").unwrap(), "option_a");
386                assert!(!new_state.converged);
387            }
388            _ => panic!("Expected PersistState"),
389        }
390    }
391
392    #[test]
393    fn resubmit_same_value_does_not_increment_round() {
394        let mode = MultiRoundMode;
395        let mut contributions = BTreeMap::new();
396        contributions.insert("alice".to_string(), "option_a".to_string());
397        let state = MultiRoundState {
398            round: 1,
399            participants: vec!["alice".into(), "bob".into()],
400            contributions,
401            convergence_type: "all_equal".into(),
402            converged: false,
403        };
404        let session = session_with_state(&state);
405        let env = contribute_env("alice", "option_a");
406
407        let result = mode.on_message(&session, &env).unwrap();
408        match result {
409            ModeResponse::PersistState(data) => {
410                let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
411                assert_eq!(new_state.round, 1);
412            }
413            _ => panic!("Expected PersistState"),
414        }
415    }
416
417    #[test]
418    fn revise_value_increments_round() {
419        let mode = MultiRoundMode;
420        let mut contributions = BTreeMap::new();
421        contributions.insert("alice".to_string(), "option_a".to_string());
422        let state = MultiRoundState {
423            round: 1,
424            participants: vec!["alice".into(), "bob".into()],
425            contributions,
426            convergence_type: "all_equal".into(),
427            converged: false,
428        };
429        let session = session_with_state(&state);
430        let env = contribute_env("alice", "option_b");
431
432        let result = mode.on_message(&session, &env).unwrap();
433        match result {
434            ModeResponse::PersistState(data) => {
435                let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
436                assert_eq!(new_state.round, 2);
437                assert_eq!(new_state.contributions.get("alice").unwrap(), "option_b");
438            }
439            _ => panic!("Expected PersistState"),
440        }
441    }
442
443    #[test]
444    fn convergence_sets_converged_flag() {
445        let mode = MultiRoundMode;
446        let mut contributions = BTreeMap::new();
447        contributions.insert("alice".to_string(), "option_a".to_string());
448        let state = MultiRoundState {
449            round: 1,
450            participants: vec!["alice".into(), "bob".into()],
451            contributions,
452            convergence_type: "all_equal".into(),
453            converged: false,
454        };
455        let session = session_with_state(&state);
456        let env = contribute_env("bob", "option_a");
457
458        let result = mode.on_message(&session, &env).unwrap();
459        match result {
460            ModeResponse::PersistState(data) => {
461                let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
462                assert_eq!(new_state.round, 2);
463                assert!(new_state.converged);
464            }
465            _ => panic!("Expected PersistState (convergence tracked, not auto-resolved)"),
466        }
467    }
468
469    #[test]
470    fn commitment_after_convergence_resolves() {
471        let mode = MultiRoundMode;
472        let mut contributions = BTreeMap::new();
473        contributions.insert("alice".to_string(), "option_a".to_string());
474        contributions.insert("bob".to_string(), "option_a".to_string());
475        let state = MultiRoundState {
476            round: 2,
477            participants: vec!["alice".into(), "bob".into()],
478            contributions,
479            convergence_type: "all_equal".into(),
480            converged: true,
481        };
482        let session = session_with_state(&state);
483        let env = commitment_env("coordinator");
484
485        let result = mode.on_message(&session, &env).unwrap();
486        match result {
487            ModeResponse::PersistAndResolve { resolution, .. } => {
488                let res: serde_json::Value = serde_json::from_slice(&resolution).unwrap();
489                assert_eq!(res["converged_value"], "option_a");
490                assert_eq!(res["round"], 2);
491            }
492            _ => panic!("Expected PersistAndResolve"),
493        }
494    }
495
496    #[test]
497    fn commitment_before_convergence_rejected() {
498        let mode = MultiRoundMode;
499        let state = MultiRoundState {
500            round: 0,
501            participants: vec!["alice".into(), "bob".into()],
502            contributions: BTreeMap::new(),
503            convergence_type: "all_equal".into(),
504            converged: false,
505        };
506        let session = session_with_state(&state);
507        let env = commitment_env("coordinator");
508
509        let err = mode.on_message(&session, &env).unwrap_err();
510        assert_eq!(err.to_string(), "InvalidPayload");
511    }
512
513    #[test]
514    fn contribute_after_convergence_rejected() {
515        let mode = MultiRoundMode;
516        let mut contributions = BTreeMap::new();
517        contributions.insert("alice".to_string(), "option_a".to_string());
518        contributions.insert("bob".to_string(), "option_a".to_string());
519        let state = MultiRoundState {
520            round: 2,
521            participants: vec!["alice".into(), "bob".into()],
522            contributions,
523            convergence_type: "all_equal".into(),
524            converged: true,
525        };
526        let session = session_with_state(&state);
527        let env = contribute_env("alice", "option_b");
528
529        let err = mode.on_message(&session, &env).unwrap_err();
530        assert_eq!(err.to_string(), "InvalidPayload");
531    }
532
533    #[test]
534    fn non_initiator_commitment_rejected() {
535        let mode = MultiRoundMode;
536        let mut contributions = BTreeMap::new();
537        contributions.insert("alice".to_string(), "option_a".to_string());
538        contributions.insert("bob".to_string(), "option_a".to_string());
539        let state = MultiRoundState {
540            round: 2,
541            participants: vec!["alice".into(), "bob".into()],
542            contributions,
543            convergence_type: "all_equal".into(),
544            converged: true,
545        };
546        let session = session_with_state(&state);
547        let env = commitment_env("alice"); // not the initiator
548
549        let err = mode.authorize_sender(&session, &env).unwrap_err();
550        assert_eq!(err.to_string(), "Forbidden");
551    }
552
553    #[test]
554    fn no_convergence_when_values_differ() {
555        let mode = MultiRoundMode;
556        let mut contributions = BTreeMap::new();
557        contributions.insert("alice".to_string(), "option_a".to_string());
558        let state = MultiRoundState {
559            round: 1,
560            participants: vec!["alice".into(), "bob".into()],
561            contributions,
562            convergence_type: "all_equal".into(),
563            converged: false,
564        };
565        let session = session_with_state(&state);
566        let env = contribute_env("bob", "option_b");
567
568        let result = mode.on_message(&session, &env).unwrap();
569        match result {
570            ModeResponse::PersistState(data) => {
571                let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
572                assert!(!new_state.converged);
573            }
574            _ => panic!("Expected PersistState"),
575        }
576    }
577
578    #[test]
579    fn no_convergence_when_not_all_contributed() {
580        let mode = MultiRoundMode;
581        let state = MultiRoundState {
582            round: 0,
583            participants: vec!["alice".into(), "bob".into(), "carol".into()],
584            contributions: BTreeMap::new(),
585            convergence_type: "all_equal".into(),
586            converged: false,
587        };
588        let session = session_with_state(&state);
589        let env = contribute_env("alice", "option_a");
590
591        let result = mode.on_message(&session, &env).unwrap();
592        assert!(matches!(result, ModeResponse::PersistState(_)));
593    }
594
595    #[test]
596    fn non_contribute_message_rejected() {
597        let mode = MultiRoundMode;
598        let state = MultiRoundState {
599            round: 0,
600            participants: vec!["alice".into()],
601            contributions: BTreeMap::new(),
602            convergence_type: "all_equal".into(),
603            converged: false,
604        };
605        let session = session_with_state(&state);
606        let env = Envelope {
607            macp_version: "1.0".into(),
608            mode: "ext.multi_round.v1".into(),
609            message_type: "Message".into(),
610            message_id: "m1".into(),
611            session_id: "s1".into(),
612            sender: "alice".into(),
613            timestamp_unix_ms: 1_700_000_000_000,
614            payload: b"hello".to_vec(),
615        };
616
617        let err = mode.on_message(&session, &env).unwrap_err();
618        assert_eq!(err.error_code(), "INVALID_ENVELOPE");
619    }
620
621    #[test]
622    fn contribute_invalid_payload_returns_error() {
623        let mode = MultiRoundMode;
624        let state = MultiRoundState {
625            round: 0,
626            participants: vec!["alice".into()],
627            contributions: BTreeMap::new(),
628            convergence_type: "all_equal".into(),
629            converged: false,
630        };
631        let session = session_with_state(&state);
632        let env = Envelope {
633            macp_version: "1.0".into(),
634            mode: "ext.multi_round.v1".into(),
635            message_type: "Contribute".into(),
636            message_id: "m1".into(),
637            session_id: "s1".into(),
638            sender: "alice".into(),
639            timestamp_unix_ms: 1_700_000_000_000,
640            payload: b"not json".to_vec(),
641        };
642
643        let err = mode.on_message(&session, &env).unwrap_err();
644        assert_eq!(err.to_string(), "InvalidPayload");
645    }
646
647    /// Replay compatibility: pre-proto histories carry JSON Contribute
648    /// payloads, and they must keep parsing to the identical value forever
649    /// (RFC-MACP-0003 §1).
650    #[test]
651    fn contribute_json_fallback_still_accepted() {
652        let mode = MultiRoundMode;
653        let state = MultiRoundState {
654            round: 0,
655            participants: vec!["alice".into(), "bob".into()],
656            contributions: BTreeMap::new(),
657            convergence_type: "all_equal".into(),
658            converged: false,
659        };
660        let session = session_with_state(&state);
661
662        let result = mode
663            .on_message(&session, &contribute_env_json("alice", "option_a"))
664            .unwrap();
665        match result {
666            ModeResponse::PersistState(data) => {
667                let state: MultiRoundState = serde_json::from_slice(&data).unwrap();
668                assert_eq!(state.contributions["alice"], "option_a");
669                assert_eq!(state.round, 1);
670            }
671            _ => panic!("Expected PersistState"),
672        }
673    }
674
675    /// The two encodings must be interchangeable mid-session: a JSON
676    /// contribution revised via proto (same value) counts as unchanged.
677    #[test]
678    fn proto_and_json_contributions_are_equivalent() {
679        let mode = MultiRoundMode;
680        let state = MultiRoundState {
681            round: 0,
682            participants: vec!["alice".into(), "bob".into()],
683            contributions: BTreeMap::new(),
684            convergence_type: "all_equal".into(),
685            converged: false,
686        };
687        let session = session_with_state(&state);
688
689        let after_json = match mode
690            .on_message(&session, &contribute_env_json("alice", "option_a"))
691            .unwrap()
692        {
693            ModeResponse::PersistState(data) => data,
694            _ => panic!("Expected PersistState"),
695        };
696        let session = {
697            let state: MultiRoundState = serde_json::from_slice(&after_json).unwrap();
698            session_with_state(&state)
699        };
700
701        // Same value re-sent as proto: no round advance (value unchanged).
702        match mode
703            .on_message(&session, &contribute_env("alice", "option_a"))
704            .unwrap()
705        {
706            ModeResponse::PersistState(data) => {
707                let state: MultiRoundState = serde_json::from_slice(&data).unwrap();
708                assert_eq!(state.round, 1, "unchanged value must not advance the round");
709                assert_eq!(state.contributions["alice"], "option_a");
710            }
711            _ => panic!("Expected PersistState"),
712        }
713    }
714
715    /// Empty payloads were always rejected in the JSON era; the proto path
716    /// must not turn them into an accepted empty contribution.
717    #[test]
718    fn contribute_empty_payload_rejected() {
719        let mode = MultiRoundMode;
720        let state = MultiRoundState {
721            round: 0,
722            participants: vec!["alice".into()],
723            contributions: BTreeMap::new(),
724            convergence_type: "all_equal".into(),
725            converged: false,
726        };
727        let session = session_with_state(&state);
728        let env = contribute_env_with_payload("alice", vec![]);
729
730        let err = mode.on_message(&session, &env).unwrap_err();
731        assert_eq!(err.to_string(), "InvalidPayload");
732    }
733
734    #[test]
735    fn encode_decode_round_trip() {
736        let mut contributions = BTreeMap::new();
737        contributions.insert("alice".into(), "value_a".into());
738        let original = MultiRoundState {
739            round: 5,
740            participants: vec!["alice".into(), "bob".into()],
741            contributions,
742            convergence_type: "all_equal".into(),
743            converged: true,
744        };
745
746        let encoded = MultiRoundMode::encode_state(&original);
747        let decoded = MultiRoundMode::decode_state(&encoded).unwrap();
748
749        assert_eq!(decoded.round, original.round);
750        assert_eq!(decoded.participants, original.participants);
751        assert_eq!(decoded.contributions, original.contributions);
752        assert_eq!(decoded.converged, original.converged);
753    }
754
755    #[test]
756    fn decode_invalid_state_returns_error() {
757        let err = MultiRoundMode::decode_state(b"garbage").unwrap_err();
758        assert_eq!(err.to_string(), "InvalidModeState");
759    }
760
761    #[test]
762    fn three_participant_convergence() {
763        let mode = MultiRoundMode;
764
765        let mut contributions = BTreeMap::new();
766        contributions.insert("alice".to_string(), "option_a".to_string());
767        contributions.insert("bob".to_string(), "option_a".to_string());
768        let state = MultiRoundState {
769            round: 2,
770            participants: vec!["alice".into(), "bob".into(), "carol".into()],
771            contributions,
772            convergence_type: "all_equal".into(),
773            converged: false,
774        };
775        let session = session_with_state(&state);
776        let env = contribute_env("carol", "option_a");
777
778        let result = mode.on_message(&session, &env).unwrap();
779        match result {
780            ModeResponse::PersistState(data) => {
781                let new_state: MultiRoundState = serde_json::from_slice(&data).unwrap();
782                assert!(new_state.converged);
783            }
784            _ => panic!("Expected PersistState with converged=true"),
785        }
786    }
787
788    #[test]
789    fn unknown_message_type_rejected() {
790        let mode = MultiRoundMode;
791        let state = MultiRoundState {
792            round: 0,
793            participants: vec!["alice".into(), "bob".into()],
794            contributions: BTreeMap::new(),
795            convergence_type: "all_equal".into(),
796            converged: false,
797        };
798        let session = session_with_state(&state);
799        let env = Envelope {
800            macp_version: "1.0".into(),
801            mode: "ext.multi_round.v1".into(),
802            message_type: "UnknownType".into(),
803            message_id: "msg-unknown".into(),
804            session_id: "s1".into(),
805            sender: "alice".into(),
806            timestamp_unix_ms: 0,
807            payload: vec![],
808        };
809        let err = mode.on_message(&session, &env).unwrap_err();
810        assert_eq!(err.error_code(), "INVALID_ENVELOPE");
811    }
812
813    // --- issue #192: canonical-proto/JSON collision tie-break ---------------
814
815    /// The 123-byte, no-leading-brace value shape that collides at rev2 via
816    /// the length-byte-is-`{` mechanism (acceptance criterion 3). Shared
817    /// with `reverse_direction_residual_is_a_documented_trade_off_at_rev3`,
818    /// which reads the identical 125-byte proto encoding from the opposite
819    /// direction -- see that test's doc comment.
820    fn length_123_no_leading_brace_value() -> String {
821        format!(r#""value":"{}"}}"#, "a".repeat(112))
822    }
823
824    #[test]
825    fn canonical_proto_length_13_preserved_wrong_at_rev2() {
826        let payload = macp_pb::multi_round_pb::ContributePayload {
827            value: r#"{"value":"x"}"#.into(),
828        }
829        .encode_to_vec();
830        assert_eq!(payload.len(), 15);
831
832        let decoded = parse_contribute_value(&payload, 2).unwrap();
833        assert_eq!(decoded, "x");
834    }
835
836    #[test]
837    fn canonical_proto_length_13_corrected_at_rev3() {
838        let payload = macp_pb::multi_round_pb::ContributePayload {
839            value: r#"{"value":"x"}"#.into(),
840        }
841        .encode_to_vec();
842
843        let decoded = parse_contribute_value(&payload, 3).unwrap();
844        assert_eq!(decoded, r#"{"value":"x"}"#);
845    }
846
847    #[test]
848    fn canonical_proto_length_32_preserved_wrong_at_rev2() {
849        let filler = "a".repeat(20);
850        let value = format!(r#"{{"value":"{}"}}"#, filler);
851        assert_eq!(value.len(), 32);
852        let payload = macp_pb::multi_round_pb::ContributePayload {
853            value: value.clone(),
854        }
855        .encode_to_vec();
856
857        let decoded = parse_contribute_value(&payload, 2).unwrap();
858        assert_eq!(decoded, filler);
859        assert_ne!(decoded, value);
860    }
861
862    #[test]
863    fn canonical_proto_length_32_corrected_at_rev3() {
864        let filler = "a".repeat(20);
865        let value = format!(r#"{{"value":"{}"}}"#, filler);
866        let payload = macp_pb::multi_round_pb::ContributePayload {
867            value: value.clone(),
868        }
869        .encode_to_vec();
870
871        let decoded = parse_contribute_value(&payload, 3).unwrap();
872        assert_eq!(decoded, value);
873    }
874
875    #[test]
876    fn canonical_proto_length_123_preserved_wrong_at_rev2() {
877        let value = length_123_no_leading_brace_value();
878        assert_eq!(value.len(), 123);
879        let payload = macp_pb::multi_round_pb::ContributePayload {
880            value: value.clone(),
881        }
882        .encode_to_vec();
883
884        let decoded = parse_contribute_value(&payload, 2).unwrap();
885        assert_eq!(decoded, "a".repeat(112));
886        assert_ne!(decoded, value);
887    }
888
889    #[test]
890    fn canonical_proto_length_123_corrected_at_rev3() {
891        let value = length_123_no_leading_brace_value();
892        let payload = macp_pb::multi_round_pb::ContributePayload {
893            value: value.clone(),
894        }
895        .encode_to_vec();
896
897        let decoded = parse_contribute_value(&payload, 3).unwrap();
898        assert_eq!(decoded, value);
899    }
900
901    /// Acceptance criterion 5: this is the IDENTICAL 125-byte payload as
902    /// `canonical_proto_length_123_*` (`length_123_no_leading_brace_value`),
903    /// read from the opposite direction. `[0x0a]` prepended to the JSON
904    /// object `{"value":"<112 a's>"}` is simultaneously: (a) valid legacy
905    /// JSON with an incidental leading-whitespace byte, meaning "112 a's",
906    /// and (b) the canonical proto encoding of the 123-byte string
907    /// `"value":"<112 a's>"}`. Both readings are valid; no further tie-break
908    /// can distinguish them from the bytes alone. The tie-break's choice to
909    /// prefer proto is what makes the sibling `length_123` test a genuine
910    /// *fix* and makes this test's case a documented, accepted residual --
911    /// the identical trade-off `macp-sdk-python`'s PR #77 already accepted
912    /// for the same reason (see this plan's Context section).
913    #[test]
914    fn reverse_direction_residual_is_a_documented_trade_off_at_rev3() {
915        let content = "a".repeat(112);
916        let legacy_json_reading = format!(r#"{{"value":"{}"}}"#, content);
917        assert_eq!(legacy_json_reading.len(), 124);
918
919        let mut payload = vec![0x0Au8];
920        payload.extend_from_slice(legacy_json_reading.as_bytes());
921        assert_eq!(payload.len(), 125);
922
923        let decoded = parse_contribute_value(&payload, 3).expect("must decode");
924        assert_eq!(
925            decoded,
926            length_123_no_leading_brace_value(),
927            "proto reading must win -- the identical bytes as canonical_proto_length_123_*, \
928             read from the opposite direction"
929        );
930        assert_ne!(
931            decoded, content,
932            "the legacy-JSON reading (112 a's) is a real, accepted, documented residual, \
933             not what this returns"
934        );
935    }
936
937    /// Regression pin for the round-trip canonicality check itself: a
938    /// payload that decodes via prost -- because field 4 is unrecognized by
939    /// `ContributePayload` and silently skipped, per `crates/macp-pb/build.rs`'s
940    /// plain `tonic_prost_build::configure()` with no unknown-field
941    /// retention -- but is NOT the canonical encoding (re-encoding drops the
942    /// unknown field the original bytes carried) must still be read as
943    /// JSON. Confirms the round-trip check discriminates "parses as *some*
944    /// protobuf message" from "*is* the canonical encoding of this
945    /// message," which is what makes the tie-break safe without Python's
946    /// extra `DiscardUnknownFields()` step.
947    #[test]
948    fn whitespace_prefixed_legacy_json_with_unknown_proto_field_shape_still_decodes_as_json() {
949        // tag(field1,LEN)=0x0a, len=0x0d(13), the 13-byte legacy JSON value,
950        // then an unknown field 4 (varint, value 10) whose tag/value bytes
951        // (0x20, 0x0a) are also valid trailing JSON whitespace.
952        let mut payload = vec![0x0Au8, 0x0Du8];
953        payload.extend_from_slice(br#"{"value":"x"}"#);
954        payload.extend_from_slice(&[0x20u8, 0x0Au8]);
955        assert_eq!(payload.len(), 17);
956
957        // Sanity: this really does decode as *some* protobuf message (the
958        // unknown field 4 is silently skipped), just not canonically.
959        let proto_decoded =
960            <macp_pb::multi_round_pb::ContributePayload as Message>::decode(payload.as_slice())
961                .expect("prost must silently skip the unrecognized field 4 and decode field 1");
962        assert_eq!(proto_decoded.value, "{\"value\":\"x\"}");
963        assert_ne!(
964            proto_decoded.encode_to_vec(),
965            payload,
966            "re-encoding must drop the unknown field 4, proving this payload is not canonical"
967        );
968
969        let decoded = parse_contribute_value(&payload, 3).expect("must decode");
970        assert_eq!(
971            decoded, "x",
972            "non-canonical proto must not defeat the tie-break; JSON is correctly preferred"
973        );
974    }
975
976    /// A property-style, differential sweep across five value shapes and
977    /// every length 1..=300 (acceptance criterion 4): proves the rev-3 fix
978    /// is universally correct (not just at the three named reproducer
979    /// lengths) and that the pre-fix rev-2 bug is *exactly* characterized --
980    /// it mis-decodes at length 13 and 32 for every leading-brace-template
981    /// shape and at length 123 for the no-leading-brace shape, and nowhere
982    /// else in the swept range.
983    #[test]
984    fn canonical_proto_exhaustively_round_trips_at_rev3() {
985        fn cycle_fill(pattern: &[u8], len: usize) -> String {
986            (0..len)
987                .map(|i| pattern[i % pattern.len()] as char)
988                .collect()
989        }
990
991        // Leading-brace template: `{"value":"<filler>"}`. Skeleton (empty
992        // filler) is 12 bytes; below that, an arbitrary filler is used
993        // that cannot collide at any length (too short to be a valid
994        // object).
995        fn leading_brace(pattern: &[u8], len: usize) -> String {
996            const SKELETON: usize = 12;
997            if len < SKELETON {
998                return cycle_fill(pattern, len);
999            }
1000            format!(r#"{{"value":"{}"}}"#, cycle_fill(pattern, len - SKELETON))
1001        }
1002
1003        // No-leading-brace template: `"value":"<filler>"}`. Skeleton is 11
1004        // bytes; exercises the length-byte-is-`{` mechanism, only at
1005        // len=123.
1006        fn no_leading_brace(pattern: &[u8], len: usize) -> String {
1007            const SKELETON: usize = 11;
1008            if len < SKELETON {
1009                return cycle_fill(pattern, len);
1010            }
1011            format!(r#""value":"{}"}}"#, cycle_fill(pattern, len - SKELETON))
1012        }
1013
1014        let shapes: [(&str, fn(usize) -> String); 5] = [
1015            ("plain_ascii", |len| leading_brace(b"a", len)),
1016            ("all_digit", |len| leading_brace(b"7", len)),
1017            ("quoted_json_string_shaped", |len| {
1018                leading_brace(b"1a:", len)
1019            }),
1020            ("object_shaped", |len| leading_brace(b"{}", len)),
1021            ("no_leading_brace", |len| no_leading_brace(b"a", len)),
1022        ];
1023
1024        for (shape_name, make_value) in shapes {
1025            let expected_rev2_mismatches: &[usize] = if shape_name == "no_leading_brace" {
1026                &[123]
1027            } else {
1028                &[13, 32]
1029            };
1030
1031            for len in 1..=300usize {
1032                let value = make_value(len);
1033                assert_eq!(
1034                    value.len(),
1035                    len,
1036                    "shape {shape_name} generator produced the wrong length"
1037                );
1038                let payload = macp_pb::multi_round_pb::ContributePayload {
1039                    value: value.clone(),
1040                }
1041                .encode_to_vec();
1042
1043                let rev3 = parse_contribute_value(&payload, 3).unwrap_or_else(|e| {
1044                    panic!("shape {shape_name} len {len}: rev3 decode failed: {e:?}")
1045                });
1046                assert_eq!(
1047                    rev3, value,
1048                    "shape {shape_name} len {len}: rev3 must always recover the true value"
1049                );
1050
1051                let rev2 = parse_contribute_value(&payload, 2).unwrap_or_else(|e| {
1052                    panic!("shape {shape_name} len {len}: rev2 decode failed: {e:?}")
1053                });
1054                if expected_rev2_mismatches.contains(&len) {
1055                    assert_ne!(
1056                        rev2, value,
1057                        "shape {shape_name} len {len}: expected a known rev2 collision, \
1058                         but it decoded correctly"
1059                    );
1060                } else {
1061                    assert_eq!(
1062                        rev2, value,
1063                        "shape {shape_name} len {len}: unexpected rev2 mismatch outside \
1064                         the known collision set"
1065                    );
1066                }
1067            }
1068        }
1069    }
1070
1071    /// End-to-end (issue #192): a fresh session (current `semantics_rev`)
1072    /// must resolve the length-13 collision payload to its true value, not
1073    /// the substring `serde_json` would misread, proving the fix is wired
1074    /// into the live `on_message` dispatch path, not just the free
1075    /// function.
1076    #[test]
1077    fn handle_contribute_end_to_end_applies_the_rev3_fix() {
1078        let mode = MultiRoundMode;
1079        let state = MultiRoundState {
1080            round: 0,
1081            participants: vec!["alice".into()],
1082            contributions: BTreeMap::new(),
1083            convergence_type: "all_equal".into(),
1084            converged: false,
1085        };
1086        let session = session_with_state(&state);
1087        assert_eq!(
1088            session.semantics_rev, CURRENT_SEMANTICS_REV,
1089            "a freshly-built session must bind the current revision"
1090        );
1091
1092        let payload = macp_pb::multi_round_pb::ContributePayload {
1093            value: r#"{"value":"x"}"#.into(),
1094        }
1095        .encode_to_vec();
1096        let env = contribute_env_with_payload("alice", payload);
1097
1098        let result = mode.on_message(&session, &env).unwrap();
1099        match result {
1100            ModeResponse::PersistState(data) => {
1101                let state: MultiRoundState = serde_json::from_slice(&data).unwrap();
1102                assert_eq!(state.contributions["alice"], r#"{"value":"x"}"#);
1103            }
1104            _ => panic!("Expected PersistState"),
1105        }
1106    }
1107
1108    /// Sibling of the test above: an in-flight session bound to
1109    /// `semantics_rev` 2 (accepted before this fix shipped) must keep
1110    /// resolving the SAME payload to the historically-wrong value for the
1111    /// rest of its lifetime -- `semantics_rev` is fixed at `SessionStart`
1112    /// and never changes mid-session.
1113    #[test]
1114    fn handle_contribute_end_to_end_preserves_the_collision_at_rev2() {
1115        let mode = MultiRoundMode;
1116        let state = MultiRoundState {
1117            round: 0,
1118            participants: vec!["alice".into()],
1119            contributions: BTreeMap::new(),
1120            convergence_type: "all_equal".into(),
1121            converged: false,
1122        };
1123        let mut session = session_with_state(&state);
1124        session.semantics_rev = 2;
1125
1126        let payload = macp_pb::multi_round_pb::ContributePayload {
1127            value: r#"{"value":"x"}"#.into(),
1128        }
1129        .encode_to_vec();
1130        let env = contribute_env_with_payload("alice", payload);
1131
1132        let result = mode.on_message(&session, &env).unwrap();
1133        match result {
1134            ModeResponse::PersistState(data) => {
1135                let state: MultiRoundState = serde_json::from_slice(&data).unwrap();
1136                assert_eq!(state.contributions["alice"], "x");
1137            }
1138            _ => panic!("Expected PersistState"),
1139        }
1140    }
1141}