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#[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#[derive(Debug, Clone, Deserialize)]
23struct ContributeJson {
24 value: String,
25}
26
27fn parse_contribute_value(payload: &[u8]) -> Result<String, MacpError> {
39 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#[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 if env.sender != session.initiator_sender {
125 return Err(MacpError::Forbidden);
126 }
127 return Ok(());
128 }
129 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 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 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"); 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 #[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 #[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 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 #[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}