ai_memory/federation/quorum.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Quorum finalisation and error payload serialisation.
5
6use std::time::Instant;
7
8use serde::{Deserialize, Serialize};
9
10use crate::replication::{AckTracker, QuorumError, QuorumFailureReason};
11
12/// Classify an `AckTracker` into either a committed quorum (`Ok(n)`) or
13/// an error with a reason suitable for the `/503 quorum_not_met`
14/// payload. Consumes the tracker — call after the broadcast loop.
15///
16/// # Errors
17///
18/// Returns `QuorumError::QuorumNotMet` if the tracker did not meet
19/// its W threshold by the `now` tick.
20pub fn finalise_quorum(tracker: &AckTracker) -> Result<usize, QuorumError> {
21 tracker.finalise(Instant::now())
22}
23
24/// #1558 batch 5 wave 3 — `error` slug on the serialised 503 payload
25/// for failed quorum writes. File-local: only the three
26/// [`QuorumNotMetPayload::from_err`] arms emit it.
27const QUORUM_NOT_MET: &str = "quorum_not_met";
28
29/// Serialised 503 payload for failed quorum writes.
30#[derive(Debug, Serialize, Deserialize)]
31pub struct QuorumNotMetPayload {
32 pub error: &'static str,
33 pub got: usize,
34 pub needed: usize,
35 pub reason: String,
36}
37
38impl QuorumNotMetPayload {
39 #[must_use]
40 pub fn from_err(err: &QuorumError) -> Self {
41 match err {
42 QuorumError::QuorumNotMet {
43 got,
44 needed,
45 reason,
46 } => Self {
47 error: QUORUM_NOT_MET,
48 got: *got,
49 needed: *needed,
50 // InFlight shouldn't surface in the HTTP payload — the
51 // broadcast loop waits until the deadline before
52 // calling finalise(). If a caller somehow gets it here,
53 // we map to "timeout" for the operator-facing 503 so
54 // we don't leak a transient internal state as a fourth
55 // public string.
56 reason: match reason {
57 QuorumFailureReason::Unreachable => "unreachable".to_string(),
58 QuorumFailureReason::Timeout | QuorumFailureReason::InFlight => {
59 "timeout".to_string()
60 }
61 QuorumFailureReason::IdDrift => "id_drift".to_string(),
62 },
63 },
64 QuorumError::InvalidPolicy { detail } => Self {
65 error: QUORUM_NOT_MET,
66 got: 0,
67 needed: 0,
68 reason: format!("invalid_policy:{detail}"),
69 },
70 QuorumError::LocalWriteFailed { detail } => Self {
71 error: QUORUM_NOT_MET,
72 got: 0,
73 needed: 0,
74 reason: format!("local_write_failed:{detail}"),
75 },
76 }
77 }
78}