1use 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
13pub const QUORUM_SCHEMA_VERSION: u32 = 1;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct QuorumStore {
19 root: PathBuf,
20}
21
22impl QuorumStore {
23 #[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 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 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 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 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 #[must_use]
83 pub fn result_artifact_path(&self, episode_id: &str) -> PathBuf {
84 self.result_path(episode_id)
85 }
86
87 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "snake_case")]
332pub enum QuorumEpisodeState {
333 Requested,
335 Enlisted,
337 IndependentRound,
339 CritiqueRound,
341 RevisionRound,
343 VoteRound,
345 Synthesized,
347 SubmittedToLibrarian,
349 Archived,
351 Quarantined,
353}
354
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
357pub struct QuorumParticipant {
358 pub id: String,
360 pub adapter: String,
362 pub model: Option<String>,
364 pub persona: String,
366 pub prompt_template_version: String,
368 pub runtime_surface: String,
370 pub tool_permissions: Vec<String>,
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
376#[serde(rename_all = "snake_case")]
377pub enum QuorumRound {
378 Independent,
380 Critique,
382 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398pub struct QuorumEpisode {
399 pub schema_version: u32,
401 pub id: String,
403 pub requested_at_unix_ms: u64,
405 pub requester: String,
407 pub question: String,
409 pub target_project: Option<String>,
411 pub target_scope: Option<String>,
413 pub evidence_policy: String,
415 pub state: QuorumEpisodeState,
417 pub participants: Vec<QuorumParticipant>,
419 pub provenance_uri: String,
421}
422
423#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425pub struct QuorumParticipantOutput {
426 pub schema_version: u32,
428 pub episode_id: String,
430 pub output_id: String,
432 pub participant_id: String,
434 pub round: QuorumRound,
436 pub submitted_at_unix_ms: u64,
438 pub prompt: String,
440 pub response: String,
442 pub visible_prior_output_ids: Vec<String>,
444 pub evidence_used: Vec<String>,
446}
447
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450pub struct QuorumAdapterRequest {
451 pub schema_version: u32,
453 pub episode_id: String,
455 pub participant: QuorumParticipant,
457 pub round: QuorumRound,
459 pub question: String,
461 pub target_project: Option<String>,
463 pub target_scope: Option<String>,
465 pub evidence_policy: String,
467 pub visible_prior_output_ids: Vec<String>,
469 pub visible_prior_outputs: Vec<QuorumParticipantOutput>,
471}
472
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
475#[serde(rename_all = "snake_case")]
476pub enum DecisionStatus {
477 Recommend,
479 Split,
481 NeedsEvidence,
483 Reject,
485 Unsafe,
487}
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
491#[serde(rename_all = "snake_case")]
492pub enum ConsensusLevel {
493 Unanimous,
495 StrongMajority,
497 WeakMajority,
499 Contested,
501 Abstained,
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
507#[serde(rename_all = "snake_case")]
508pub enum VoteChoice {
509 Agree,
511 Disagree,
513 Abstain,
515}
516
517#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
519pub struct ParticipantVote {
520 pub participant_id: String,
522 pub vote: VoteChoice,
524 pub confidence: f32,
526 pub rationale: String,
528}
529
530#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
532pub struct QuorumResult {
533 pub schema_version: u32,
535 pub episode_id: String,
537 pub question: String,
539 pub recommendation: String,
541 pub decision_status: DecisionStatus,
543 pub consensus_level: ConsensusLevel,
545 pub confidence: f32,
547 pub supporting_points: Vec<String>,
549 pub dissenting_points: Vec<String>,
551 pub unresolved_questions: Vec<String>,
553 pub evidence_used: Vec<String>,
555 pub participant_votes: Vec<ParticipantVote>,
557 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}