Skip to main content

mimir_librarian/
quorum.rs

1//! File-backed consensus quorum episode/result/output envelopes.
2
3use std::collections::BTreeSet;
4use std::fs;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::LibrarianError;
12
13/// Current on-disk quorum episode/result schema version.
14pub const QUORUM_SCHEMA_VERSION: u32 = 1;
15
16/// File-backed quorum episode/result store.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct QuorumStore {
19    root: PathBuf,
20}
21
22impl QuorumStore {
23    /// Create a quorum store rooted at `root`.
24    #[must_use]
25    pub fn new(root: impl AsRef<Path>) -> Self {
26        Self {
27            root: root.as_ref().to_path_buf(),
28        }
29    }
30
31    /// Write an episode envelope.
32    ///
33    /// # Errors
34    ///
35    /// Returns I/O or JSON serialization errors.
36    pub fn create_episode(&self, episode: &QuorumEpisode) -> Result<PathBuf, LibrarianError> {
37        let path = self.episode_path(&episode.id);
38        if let Some(parent) = path.parent() {
39            fs::create_dir_all(parent)?;
40        }
41        let bytes = serde_json::to_vec_pretty(episode)?;
42        fs::write(&path, bytes)?;
43        Ok(path)
44    }
45
46    /// Load a quorum episode by id.
47    ///
48    /// # Errors
49    ///
50    /// Returns I/O or JSON decoding errors.
51    pub fn load_episode(&self, id: &str) -> Result<QuorumEpisode, LibrarianError> {
52        let bytes = fs::read(self.episode_path(id))?;
53        Ok(serde_json::from_slice(&bytes)?)
54    }
55
56    /// Write the synthesized result for an episode.
57    ///
58    /// # Errors
59    ///
60    /// Returns I/O or JSON serialization errors.
61    pub fn save_result(&self, result: &QuorumResult) -> Result<PathBuf, LibrarianError> {
62        let path = self.result_path(&result.episode_id);
63        if let Some(parent) = path.parent() {
64            fs::create_dir_all(parent)?;
65        }
66        let bytes = serde_json::to_vec_pretty(result)?;
67        fs::write(&path, bytes)?;
68        Ok(path)
69    }
70
71    /// Load the synthesized result for an episode.
72    ///
73    /// # Errors
74    ///
75    /// Returns I/O or JSON decoding errors.
76    pub fn load_result(&self, episode_id: &str) -> Result<QuorumResult, LibrarianError> {
77        let bytes = fs::read(self.result_path(episode_id))?;
78        Ok(serde_json::from_slice(&bytes)?)
79    }
80
81    /// Return the canonical result artifact path for an episode.
82    #[must_use]
83    pub fn result_artifact_path(&self, episode_id: &str) -> PathBuf {
84        self.result_path(episode_id)
85    }
86
87    /// Append one participant output for a deliberation round.
88    ///
89    /// # Errors
90    ///
91    /// Returns I/O, JSON serialization, duplicate-output, or protocol errors.
92    pub fn append_participant_output(
93        &self,
94        output: &QuorumParticipantOutput,
95    ) -> Result<PathBuf, LibrarianError> {
96        let episode = self.load_episode(&output.episode_id)?;
97        self.validate_participant_output(&episode, output)?;
98        let path = self.output_path(output);
99        if let Some(parent) = path.parent() {
100            fs::create_dir_all(parent)?;
101        }
102        let bytes = serde_json::to_vec_pretty(output)?;
103        match fs::OpenOptions::new()
104            .write(true)
105            .create_new(true)
106            .open(&path)
107        {
108            Ok(mut file) => file.write_all(&bytes)?,
109            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
110                return Err(LibrarianError::QuorumOutputAlreadyExists {
111                    episode_id: output.episode_id.clone(),
112                    round: output.round.as_str().to_string(),
113                    participant_id: output.participant_id.clone(),
114                });
115            }
116            Err(err) => return Err(err.into()),
117        }
118        Ok(path)
119    }
120
121    /// Load all participant outputs for one round.
122    ///
123    /// # Errors
124    ///
125    /// Returns I/O or JSON decoding errors.
126    pub fn load_round_outputs(
127        &self,
128        episode_id: &str,
129        round: QuorumRound,
130    ) -> Result<Vec<QuorumParticipantOutput>, LibrarianError> {
131        let dir = self.round_dir(episode_id, round);
132        let entries = match fs::read_dir(dir) {
133            Ok(entries) => entries,
134            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
135            Err(err) => return Err(err.into()),
136        };
137        let mut outputs = Vec::new();
138        for entry in entries {
139            let path = entry?.path();
140            if path.extension().is_some_and(|ext| ext == "json") {
141                let bytes = fs::read(path)?;
142                let output: QuorumParticipantOutput = serde_json::from_slice(&bytes)?;
143                if output.round == round {
144                    outputs.push(output);
145                }
146            }
147        }
148        outputs.sort_by(|left, right| {
149            left.participant_id
150                .cmp(&right.participant_id)
151                .then(left.output_id.cmp(&right.output_id))
152        });
153        Ok(outputs)
154    }
155
156    /// Return the prior outputs that may be shown to a participant in `round`.
157    ///
158    /// # Errors
159    ///
160    /// Returns a protocol error until the prior round is complete.
161    pub fn visible_outputs_for_round(
162        &self,
163        episode_id: &str,
164        round: QuorumRound,
165    ) -> Result<Vec<QuorumParticipantOutput>, LibrarianError> {
166        let episode = self.load_episode(episode_id)?;
167        match round {
168            QuorumRound::Independent => Ok(Vec::new()),
169            QuorumRound::Critique => {
170                self.require_round_complete(&episode, QuorumRound::Independent)
171            }
172            QuorumRound::Revision => {
173                let mut outputs =
174                    self.require_round_complete(&episode, QuorumRound::Independent)?;
175                outputs.extend(self.require_round_complete(&episode, QuorumRound::Critique)?);
176                Ok(outputs)
177            }
178        }
179    }
180
181    /// Build the JSON request contract a participant adapter should consume.
182    ///
183    /// # Errors
184    ///
185    /// Returns I/O, JSON decoding, or protocol errors when the participant
186    /// is unknown or prior-round visibility is not available yet.
187    pub fn build_adapter_request(
188        &self,
189        episode_id: &str,
190        participant_id: &str,
191        round: QuorumRound,
192    ) -> Result<QuorumAdapterRequest, LibrarianError> {
193        let episode = self.load_episode(episode_id)?;
194        let participant = episode
195            .participants
196            .iter()
197            .find(|candidate| candidate.id == participant_id)
198            .cloned()
199            .ok_or_else(|| {
200                quorum_protocol_violation(
201                    &episode.id,
202                    format!("unknown participant {participant_id}"),
203                )
204            })?;
205        let visible_prior_outputs = self.visible_outputs_for_round(episode_id, round)?;
206        let visible_prior_output_ids = visible_prior_outputs
207            .iter()
208            .map(|output| output.output_id.clone())
209            .collect();
210        Ok(QuorumAdapterRequest {
211            schema_version: QUORUM_SCHEMA_VERSION,
212            episode_id: episode.id,
213            participant,
214            round,
215            question: episode.question,
216            target_project: episode.target_project,
217            target_scope: episode.target_scope,
218            evidence_policy: episode.evidence_policy,
219            visible_prior_output_ids,
220            visible_prior_outputs,
221        })
222    }
223
224    fn episode_path(&self, id: &str) -> PathBuf {
225        self.episode_dir(id).join("episode.json")
226    }
227
228    fn result_path(&self, episode_id: &str) -> PathBuf {
229        self.episode_dir(episode_id).join("result.json")
230    }
231
232    fn episode_dir(&self, id: &str) -> PathBuf {
233        self.root.join("episodes").join(quorum_id_slug(id))
234    }
235
236    fn round_dir(&self, episode_id: &str, round: QuorumRound) -> PathBuf {
237        self.episode_dir(episode_id)
238            .join("outputs")
239            .join(round.as_str())
240    }
241
242    fn output_path(&self, output: &QuorumParticipantOutput) -> PathBuf {
243        self.round_dir(&output.episode_id, output.round)
244            .join(format!("{}.json", quorum_id_slug(&output.participant_id)))
245    }
246
247    fn validate_participant_output(
248        &self,
249        episode: &QuorumEpisode,
250        output: &QuorumParticipantOutput,
251    ) -> Result<(), LibrarianError> {
252        if output.schema_version != QUORUM_SCHEMA_VERSION {
253            return Err(quorum_protocol_violation(
254                &episode.id,
255                format!(
256                    "unsupported output schema version {}; expected {QUORUM_SCHEMA_VERSION}",
257                    output.schema_version
258                ),
259            ));
260        }
261        if output.output_id.trim().is_empty() {
262            return Err(quorum_protocol_violation(
263                &episode.id,
264                "participant output id must not be empty",
265            ));
266        }
267        if !episode
268            .participants
269            .iter()
270            .any(|participant| participant.id == output.participant_id)
271        {
272            return Err(quorum_protocol_violation(
273                &episode.id,
274                format!("unknown participant {}", output.participant_id),
275            ));
276        }
277        match output.round {
278            QuorumRound::Independent => {
279                if !output.visible_prior_output_ids.is_empty() {
280                    return Err(quorum_protocol_violation(
281                        &episode.id,
282                        "independent outputs must not reference prior visible outputs",
283                    ));
284                }
285            }
286            QuorumRound::Critique => {
287                let visible = self.require_round_complete(episode, QuorumRound::Independent)?;
288                require_exact_visible_prior_ids(output, &visible)?;
289            }
290            QuorumRound::Revision => {
291                let mut visible = self.require_round_complete(episode, QuorumRound::Independent)?;
292                visible.extend(self.require_round_complete(episode, QuorumRound::Critique)?);
293                require_exact_visible_prior_ids(output, &visible)?;
294            }
295        }
296        Ok(())
297    }
298
299    fn require_round_complete(
300        &self,
301        episode: &QuorumEpisode,
302        round: QuorumRound,
303    ) -> Result<Vec<QuorumParticipantOutput>, LibrarianError> {
304        let outputs = self.load_round_outputs(&episode.id, round)?;
305        let expected: BTreeSet<&str> = episode
306            .participants
307            .iter()
308            .map(|participant| participant.id.as_str())
309            .collect();
310        let actual: BTreeSet<&str> = outputs
311            .iter()
312            .map(|output| output.participant_id.as_str())
313            .collect();
314        let missing: Vec<&str> = expected.difference(&actual).copied().collect();
315        if !missing.is_empty() {
316            return Err(quorum_protocol_violation(
317                &episode.id,
318                format!(
319                    "{} round is incomplete; missing participant outputs: {}",
320                    round.as_str(),
321                    missing.join(", ")
322                ),
323            ));
324        }
325        Ok(outputs)
326    }
327}
328
329/// Lifecycle state for one quorum episode.
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "snake_case")]
332pub enum QuorumEpisodeState {
333    /// Episode has been requested but participants are not enlisted yet.
334    Requested,
335    /// Participant surfaces/personas have been selected.
336    Enlisted,
337    /// Participants are producing independent first-pass outputs.
338    IndependentRound,
339    /// Participants are critiquing prior outputs.
340    CritiqueRound,
341    /// Participants may revise or hold positions.
342    RevisionRound,
343    /// Participants vote with rationale.
344    VoteRound,
345    /// A result has been synthesized.
346    Synthesized,
347    /// Proposed memory drafts entered the librarian draft path.
348    SubmittedToLibrarian,
349    /// Episode retained for audit but not submitted.
350    Archived,
351    /// Episode requires review and must not be used as evidence.
352    Quarantined,
353}
354
355/// Participant identity captured for quorum auditability.
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357pub struct QuorumParticipant {
358    /// Stable participant id inside the episode.
359    pub id: String,
360    /// Adapter name, such as `claude` or `codex`.
361    pub adapter: String,
362    /// Concrete model name when available.
363    pub model: Option<String>,
364    /// Persona prompt lens used for this participant.
365    pub persona: String,
366    /// Prompt template version used for this participant.
367    pub prompt_template_version: String,
368    /// Runtime surface that executed the participant.
369    pub runtime_surface: String,
370    /// Explicit tool grants for the participant.
371    pub tool_permissions: Vec<String>,
372}
373
374/// Deliberation round that can receive participant outputs.
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
376#[serde(rename_all = "snake_case")]
377pub enum QuorumRound {
378    /// Independent first pass before seeing other answers.
379    Independent,
380    /// Critique of completed independent outputs.
381    Critique,
382    /// Revision after critique visibility.
383    Revision,
384}
385
386impl QuorumRound {
387    fn as_str(self) -> &'static str {
388        match self {
389            Self::Independent => "independent",
390            Self::Critique => "critique",
391            Self::Revision => "revision",
392        }
393    }
394}
395
396/// Requested quorum episode envelope.
397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398pub struct QuorumEpisode {
399    /// On-disk schema version.
400    pub schema_version: u32,
401    /// Stable quorum episode id.
402    pub id: String,
403    /// Request timestamp in Unix milliseconds.
404    pub requested_at_unix_ms: u64,
405    /// Human or agent that requested the quorum.
406    pub requester: String,
407    /// Question under deliberation.
408    pub question: String,
409    /// Project/workspace target, when project-bound.
410    pub target_project: Option<String>,
411    /// Governance scope target, when known.
412    pub target_scope: Option<String>,
413    /// Evidence policy for the episode.
414    pub evidence_policy: String,
415    /// Current protocol state.
416    pub state: QuorumEpisodeState,
417    /// Participants/personas selected for the episode.
418    pub participants: Vec<QuorumParticipant>,
419    /// Stable provenance URI for artifacts derived from this episode.
420    pub provenance_uri: String,
421}
422
423/// Participant prompt/response captured for a deliberation round.
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425pub struct QuorumParticipantOutput {
426    /// On-disk schema version.
427    pub schema_version: u32,
428    /// Episode this output belongs to.
429    pub episode_id: String,
430    /// Stable participant-output id within the episode.
431    pub output_id: String,
432    /// Participant id from the episode.
433    pub participant_id: String,
434    /// Deliberation round.
435    pub round: QuorumRound,
436    /// Submission timestamp in Unix milliseconds.
437    pub submitted_at_unix_ms: u64,
438    /// Prompt sent to the participant.
439    pub prompt: String,
440    /// Participant response.
441    pub response: String,
442    /// Prior output ids visible to the participant while producing this output.
443    pub visible_prior_output_ids: Vec<String>,
444    /// Evidence used by this participant output.
445    pub evidence_used: Vec<String>,
446}
447
448/// Request payload consumed by a future participant adapter.
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450pub struct QuorumAdapterRequest {
451    /// On-disk/wire schema version.
452    pub schema_version: u32,
453    /// Episode under deliberation.
454    pub episode_id: String,
455    /// Participant identity and adapter metadata.
456    pub participant: QuorumParticipant,
457    /// Round the adapter is being asked to answer.
458    pub round: QuorumRound,
459    /// Question under deliberation.
460    pub question: String,
461    /// Project/workspace target, when project-bound.
462    pub target_project: Option<String>,
463    /// Governance scope target, when known.
464    pub target_scope: Option<String>,
465    /// Evidence policy for the episode.
466    pub evidence_policy: String,
467    /// Prior output ids the adapter is allowed to see.
468    pub visible_prior_output_ids: Vec<String>,
469    /// Prior outputs the adapter is allowed to see.
470    pub visible_prior_outputs: Vec<QuorumParticipantOutput>,
471}
472
473/// Final quorum decision status.
474#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
475#[serde(rename_all = "snake_case")]
476pub enum DecisionStatus {
477    /// Strong enough to act, still subject to owner choice.
478    Recommend,
479    /// Material disagreement remains.
480    Split,
481    /// More source checking or experiments are needed.
482    NeedsEvidence,
483    /// Quorum recommends against the proposed direction.
484    Reject,
485    /// Trust, safety, or prompt-injection issue.
486    Unsafe,
487}
488
489/// Degree of agreement in a quorum result.
490#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
491#[serde(rename_all = "snake_case")]
492pub enum ConsensusLevel {
493    /// All non-abstaining participants agree.
494    Unanimous,
495    /// Most agree and dissent is weak or bounded.
496    StrongMajority,
497    /// Most agree but dissent remains important.
498    WeakMajority,
499    /// No stable consensus.
500    Contested,
501    /// Insufficient participation or evidence.
502    Abstained,
503}
504
505/// Individual participant vote.
506#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
507#[serde(rename_all = "snake_case")]
508pub enum VoteChoice {
509    /// Participant agrees with the recommendation.
510    Agree,
511    /// Participant disagrees with the recommendation.
512    Disagree,
513    /// Participant abstains.
514    Abstain,
515}
516
517/// Vote plus confidence and rationale.
518#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
519pub struct ParticipantVote {
520    /// Participant id from the episode.
521    pub participant_id: String,
522    /// Participant vote.
523    pub vote: VoteChoice,
524    /// Vote confidence, in the range 0.0 through 1.0.
525    pub confidence: f32,
526    /// Short vote rationale.
527    pub rationale: String,
528}
529
530/// Synthesized quorum result envelope.
531#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
532pub struct QuorumResult {
533    /// On-disk schema version.
534    pub schema_version: u32,
535    /// Episode this result belongs to.
536    pub episode_id: String,
537    /// Question under deliberation.
538    pub question: String,
539    /// Final recommendation text.
540    pub recommendation: String,
541    /// Decision status.
542    pub decision_status: DecisionStatus,
543    /// Consensus level.
544    pub consensus_level: ConsensusLevel,
545    /// Overall result confidence, in the range 0.0 through 1.0.
546    pub confidence: f32,
547    /// Main supporting points.
548    pub supporting_points: Vec<String>,
549    /// Dissenting points, retained as first-class evidence.
550    pub dissenting_points: Vec<String>,
551    /// Questions left unresolved by the quorum.
552    pub unresolved_questions: Vec<String>,
553    /// Evidence used by participants/synthesis.
554    pub evidence_used: Vec<String>,
555    /// Votes from participants.
556    pub participant_votes: Vec<ParticipantVote>,
557    /// Proposed raw memory drafts for the librarian path.
558    pub proposed_memory_drafts: Vec<String>,
559}
560
561fn quorum_id_slug(id: &str) -> String {
562    const HEX: &[u8; 16] = b"0123456789abcdef";
563    let digest = Sha256::digest(id.as_bytes());
564    let mut suffix = String::with_capacity(16);
565    for byte in digest.iter().take(8) {
566        suffix.push(char::from(HEX[usize::from(byte >> 4)]));
567        suffix.push(char::from(HEX[usize::from(byte & 0x0f)]));
568    }
569    let prefix: String = id
570        .chars()
571        .map(|ch| {
572            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
573                ch
574            } else {
575                '_'
576            }
577        })
578        .take(48)
579        .collect();
580    if prefix.is_empty() {
581        suffix
582    } else {
583        format!("{prefix}-{suffix}")
584    }
585}
586
587fn require_exact_visible_prior_ids(
588    output: &QuorumParticipantOutput,
589    visible: &[QuorumParticipantOutput],
590) -> Result<(), LibrarianError> {
591    let expected: BTreeSet<&str> = visible.iter().map(|item| item.output_id.as_str()).collect();
592    let actual: BTreeSet<&str> = output
593        .visible_prior_output_ids
594        .iter()
595        .map(String::as_str)
596        .collect();
597    if expected != actual || actual.len() != output.visible_prior_output_ids.len() {
598        return Err(quorum_protocol_violation(
599            &output.episode_id,
600            format!(
601                "{} output from {} must reference exactly the visible prior output ids",
602                output.round.as_str(),
603                output.participant_id
604            ),
605        ));
606    }
607    Ok(())
608}
609
610fn quorum_protocol_violation(episode_id: &str, message: impl Into<String>) -> LibrarianError {
611    LibrarianError::QuorumProtocolViolation {
612        episode_id: episode_id.to_string(),
613        message: message.into(),
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    fn participant(name: &str, persona: &str) -> QuorumParticipant {
622        QuorumParticipant {
623            id: name.to_string(),
624            adapter: name.to_string(),
625            model: Some(format!("{name}-model")),
626            persona: persona.to_string(),
627            prompt_template_version: "v1".to_string(),
628            runtime_surface: name.to_string(),
629            tool_permissions: vec!["read_memory".to_string()],
630        }
631    }
632
633    fn episode() -> QuorumEpisode {
634        QuorumEpisode {
635            schema_version: QUORUM_SCHEMA_VERSION,
636            id: "qr-2026-04-24-001".to_string(),
637            requested_at_unix_ms: 1_772_000_000_000,
638            requester: "operator:AlainDor".to_string(),
639            question: "Should Mimir keep remote sync explicit?".to_string(),
640            target_project: Some("buildepicshit/Mimir".to_string()),
641            target_scope: Some("project".to_string()),
642            evidence_policy: "source_backed_when_claiming_external_facts".to_string(),
643            state: QuorumEpisodeState::Requested,
644            participants: vec![
645                participant("claude", "architect"),
646                participant("codex", "implementation_engineer"),
647            ],
648            provenance_uri: "quorum://episode/qr-2026-04-24-001".to_string(),
649        }
650    }
651
652    fn output(
653        output_id: &str,
654        participant_id: &str,
655        round: QuorumRound,
656        visible_prior_output_ids: Vec<String>,
657    ) -> QuorumParticipantOutput {
658        QuorumParticipantOutput {
659            schema_version: QUORUM_SCHEMA_VERSION,
660            episode_id: "qr-2026-04-24-001".to_string(),
661            output_id: output_id.to_string(),
662            participant_id: participant_id.to_string(),
663            round,
664            submitted_at_unix_ms: 1_772_000_001_000,
665            prompt: format!("Prompt for {participant_id}"),
666            response: format!("Response from {participant_id}"),
667            visible_prior_output_ids,
668            evidence_used: vec!["docs/concepts/consensus-quorum.md".to_string()],
669        }
670    }
671
672    #[test]
673    fn quorum_store_creates_and_loads_episode() -> Result<(), Box<dyn std::error::Error>> {
674        let tmp = tempfile::tempdir()?;
675        let store = QuorumStore::new(tmp.path());
676        let episode = episode();
677
678        let path = store.create_episode(&episode)?;
679        assert!(path.ends_with("episode.json"));
680        let loaded = store.load_episode(&episode.id)?;
681        assert_eq!(loaded, episode);
682        Ok(())
683    }
684
685    #[test]
686    fn quorum_store_saves_result_with_dissent_and_votes() -> Result<(), Box<dyn std::error::Error>>
687    {
688        let tmp = tempfile::tempdir()?;
689        let store = QuorumStore::new(tmp.path());
690        let result = QuorumResult {
691            schema_version: QUORUM_SCHEMA_VERSION,
692            episode_id: "qr-2026-04-24-001".to_string(),
693            question: "Should Mimir keep remote sync explicit?".to_string(),
694            recommendation: "Keep sync explicit and expose refresh status.".to_string(),
695            decision_status: DecisionStatus::Recommend,
696            consensus_level: ConsensusLevel::StrongMajority,
697            confidence: 0.82,
698            supporting_points: vec!["Launch/capture stay transparent.".to_string()],
699            dissenting_points: vec!["Operator may forget to push.".to_string()],
700            unresolved_questions: vec!["Service adapter protocol remains open.".to_string()],
701            evidence_used: vec![
702                ".planning/planning/2026-04-24-transparent-agent-harness.md".to_string()
703            ],
704            participant_votes: vec![
705                ParticipantVote {
706                    participant_id: "claude".to_string(),
707                    vote: VoteChoice::Agree,
708                    confidence: 0.86,
709                    rationale: "Explicit sync protects native launch flow.".to_string(),
710                },
711                ParticipantVote {
712                    participant_id: "codex".to_string(),
713                    vote: VoteChoice::Disagree,
714                    confidence: 0.42,
715                    rationale: "A reminder surface may still be needed.".to_string(),
716                },
717            ],
718            proposed_memory_drafts: vec![
719                "Remote sync must remain explicit during launch and capture.".to_string(),
720            ],
721        };
722
723        store.save_result(&result)?;
724        let loaded = store.load_result(&result.episode_id)?;
725        assert_eq!(loaded, result);
726        assert_eq!(loaded.dissenting_points.len(), 1);
727        assert_eq!(loaded.participant_votes[1].vote, VoteChoice::Disagree);
728        Ok(())
729    }
730
731    #[test]
732    fn quorum_store_blocks_critique_until_independent_outputs_complete(
733    ) -> Result<(), Box<dyn std::error::Error>> {
734        let tmp = tempfile::tempdir()?;
735        let store = QuorumStore::new(tmp.path());
736        let episode = episode();
737        store.create_episode(&episode)?;
738
739        store.append_participant_output(&output(
740            "out-independent-claude",
741            "claude",
742            QuorumRound::Independent,
743            Vec::new(),
744        ))?;
745
746        let critique_before_complete = output(
747            "out-critique-claude",
748            "claude",
749            QuorumRound::Critique,
750            vec!["out-independent-claude".to_string()],
751        );
752        let err = match store.append_participant_output(&critique_before_complete) {
753            Ok(path) => {
754                return Err(std::io::Error::other(format!(
755                    "critique must wait for every independent first pass, wrote {}",
756                    path.display()
757                ))
758                .into());
759            }
760            Err(err) => err,
761        };
762        assert!(matches!(
763            err,
764            LibrarianError::QuorumProtocolViolation { .. }
765        ));
766        assert!(
767            store
768                .visible_outputs_for_round(&episode.id, QuorumRound::Critique)
769                .is_err(),
770            "critique visibility must stay closed until every independent output is present",
771        );
772
773        store.append_participant_output(&output(
774            "out-independent-codex",
775            "codex",
776            QuorumRound::Independent,
777            Vec::new(),
778        ))?;
779        let visible = store.visible_outputs_for_round(&episode.id, QuorumRound::Critique)?;
780        let visible_ids: Vec<_> = visible.iter().map(|item| item.output_id.clone()).collect();
781        assert_eq!(
782            visible_ids,
783            vec!["out-independent-claude", "out-independent-codex"]
784        );
785
786        store.append_participant_output(&output(
787            "out-critique-claude",
788            "claude",
789            QuorumRound::Critique,
790            visible_ids,
791        ))?;
792        let critiques = store.load_round_outputs(&episode.id, QuorumRound::Critique)?;
793        assert_eq!(critiques.len(), 1);
794        assert_eq!(critiques[0].participant_id, "claude");
795        Ok(())
796    }
797
798    #[test]
799    fn quorum_store_rejects_independent_output_with_prior_visibility(
800    ) -> Result<(), Box<dyn std::error::Error>> {
801        let tmp = tempfile::tempdir()?;
802        let store = QuorumStore::new(tmp.path());
803        store.create_episode(&episode())?;
804
805        let output = output(
806            "out-independent-claude",
807            "claude",
808            QuorumRound::Independent,
809            vec!["out-independent-codex".to_string()],
810        );
811        let err = match store.append_participant_output(&output) {
812            Ok(path) => {
813                return Err(std::io::Error::other(format!(
814                    "independent output cannot see prior answers, wrote {}",
815                    path.display()
816                ))
817                .into());
818            }
819            Err(err) => err,
820        };
821        assert!(matches!(
822            err,
823            LibrarianError::QuorumProtocolViolation { .. }
824        ));
825        Ok(())
826    }
827
828    #[test]
829    fn quorum_store_builds_adapter_request_with_visible_outputs(
830    ) -> Result<(), Box<dyn std::error::Error>> {
831        let tmp = tempfile::tempdir()?;
832        let store = QuorumStore::new(tmp.path());
833        let episode = episode();
834        store.create_episode(&episode)?;
835        store.append_participant_output(&output(
836            "out-independent-claude",
837            "claude",
838            QuorumRound::Independent,
839            Vec::new(),
840        ))?;
841        store.append_participant_output(&output(
842            "out-independent-codex",
843            "codex",
844            QuorumRound::Independent,
845            Vec::new(),
846        ))?;
847
848        let request = store.build_adapter_request(&episode.id, "codex", QuorumRound::Critique)?;
849        assert_eq!(request.episode_id, episode.id);
850        assert_eq!(request.participant.id, "codex");
851        assert_eq!(request.round, QuorumRound::Critique);
852        assert_eq!(
853            request.visible_prior_output_ids,
854            vec!["out-independent-claude", "out-independent-codex"]
855        );
856        assert_eq!(request.visible_prior_outputs.len(), 2);
857        Ok(())
858    }
859}