Skip to main content

workload_spec/
sovereign.rs

1//! The sovereign-group join rule — W305 / R742-F1, extended by R605-F12.
2//!
3//! One sentence of logic, deliberately given a home of its own because it is
4//! asked in two crates that cannot see each other:
5//!
6//! - **camp-side**, `cloud::judge_join`, which reads two `MachineConfig`s and
7//!   answers "may these two boxes be in one quorum" while planning;
8//! - **node-side**, `yubaba`'s `POST /raft/add-learner` gate, which reads its
9//!   own `--sovereign-group` and asks the joiner for its own, and refuses.
10//!
11//! `yubaba` deliberately does **not** depend on `cloud` (R374-F3 moved
12//! `local-driver` out precisely to avoid that edge), so the rule cannot simply
13//! live in one of them. It lives here for the same reason
14//! [`PUBLIC_IP_TAINT`](crate::PUBLIC_IP_TAINT) does: this crate is the shared
15//! vocabulary both the planner and the daemon already link, and it depends on
16//! neither.
17//!
18//! What is **not** shared is the prose. A camp-side refusal points at
19//! `.yah/infra/machines/<name>.toml`; a node-side refusal has no machine name
20//! to interpolate and must also name `yubaba serve --sovereign-group`, because
21//! editing the TOML alone does not change what the running daemon declares.
22//! Two renderings, one predicate — which is the split that keeps them from
23//! disagreeing about what counts as a refusal.
24//!
25//! # Two axes, because membership and eligibility are different questions
26//!
27//! A [`Membership`] is a group *and* a [`SovereignRole`]. Before R605-F12 it was
28//! only the group, so membership was binary and the only way to express "this
29//! box is inside prod's blast radius but must never hold a quorum seat" was to
30//! leave it out of prod entirely — which says something else, and says it by
31//! omission. us-west-003 is the case: a residential-uplink build box that the
32//! operator considers part of prod, whose exclusion from the prod raft was
33//! enforced by nothing but the absence of a stamp nobody had written. That is
34//! the W305 failure mode that produced R742-T4 (`no-voter` sitting inert on
35//! three nodes, asserting something no code read), reached by a different road.
36//!
37//! So the group answers *which blast radius*, and the role answers *may it
38//! vote*. Only the second gates a join.
39
40use serde::{Deserialize, Serialize};
41
42/// Whether a node in a sovereign group may hold a seat in that group's quorum
43/// — R605-F12.
44///
45/// This is **not** a placement input and not a taint. It narrows a
46/// [`Membership`], and the single thing that reads it is [`join_permitted`].
47///
48/// [`Self::Voter`] is the default because it is what every already-stamped node
49/// means today: before this enum existed, declaring a group *was* declaring
50/// quorum eligibility, so absence has to keep meaning that or the field would
51/// silently retire six live voters. The permissiveness is bounded by the group
52/// still being mandatory — a box cannot drift into a quorum without an operator
53/// naming the group first — and the camp lints the omission at the layer that
54/// can see the whole fleet (`cloud::validate::check_unroled_sovereign_members`),
55/// rather than here, where refusing to deserialize would break every node that
56/// predates the field.
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
58#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
59#[serde(rename_all = "kebab-case")]
60pub enum SovereignRole {
61    /// Quorum-eligible: may be joined into its group's cluster.
62    #[default]
63    Voter,
64    /// In the group's blast radius — shares its upgrade cadence, its secrets,
65    /// its destruction — but never a quorum seat. Refused by [`join_permitted`]
66    /// on either side of a join.
67    NonVoter,
68}
69
70impl SovereignRole {
71    /// The wire/TOML spelling: `"voter"` / `"non-voter"`. Matches the serde
72    /// rename so the CLI flag, the TOML value and the `/raft/status` JSON can
73    /// never disagree about how the value is written.
74    pub fn as_str(&self) -> &'static str {
75        match self {
76            Self::Voter => "voter",
77            Self::NonVoter => "non-voter",
78        }
79    }
80
81    /// True for [`Self::Voter`]. Named rather than matched at call sites so the
82    /// join rule reads as one predicate.
83    pub fn is_voter(&self) -> bool {
84        matches!(self, Self::Voter)
85    }
86}
87
88impl std::fmt::Display for SovereignRole {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.write_str(self.as_str())
91    }
92}
93
94impl std::str::FromStr for SovereignRole {
95    type Err = String;
96
97    /// Parses the two legal spellings and nothing else. The error names both,
98    /// because this is reached from a CLI flag where the operator has a typo in
99    /// hand and no schema to consult.
100    fn from_str(s: &str) -> Result<Self, Self::Err> {
101        match s {
102            "voter" => Ok(Self::Voter),
103            "non-voter" => Ok(Self::NonVoter),
104            other => Err(format!(
105                "unknown sovereign role {other:?} — expected \"voter\" or \"non-voter\""
106            )),
107        }
108    }
109}
110
111/// What one node declares about its place in a sovereign group.
112///
113/// `group` is `None` for a standalone node — **in no group**, which is a
114/// declaration and not a gap; see [`join_permitted`]. `role` only means anything
115/// when `group` is `Some`: a standalone box has no quorum to be eligible for,
116/// so its role is never consulted.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub struct Membership<'a> {
119    /// The declared group label, or `None` for standalone.
120    pub group: Option<&'a str>,
121    /// Quorum eligibility within that group.
122    pub role: SovereignRole,
123}
124
125impl<'a> Membership<'a> {
126    /// A declared member of `group` with the given role.
127    pub fn new(group: &'a str, role: SovereignRole) -> Self {
128        Self {
129            group: Some(group),
130            role,
131        }
132    }
133
134    /// A node in no group at all. Distinct from a non-voting member: standalone
135    /// asserts no blast-radius relationship to anything, where a non-voter
136    /// shares the group's fate and only declines its quorum.
137    pub fn standalone() -> Self {
138        Self {
139            group: None,
140            role: SovereignRole::default(),
141        }
142    }
143}
144
145/// May a node declaring `joiner` join a cluster whose nodes declare `target`?
146///
147/// **Permitted iff both sides declare the same, non-`None` group *and* both are
148/// [`SovereignRole::Voter`].** One rule, no special cases.
149///
150/// The case it exists for is two *different* declared groups — joining a dev Pi
151/// into prod is refused rather than trusted, where the only prior guard was a
152/// comment in a TOML saying not to. But an undeclared side is refused too, and
153/// that is the deliberate half: **`None` means "in no group", not "unknown"**,
154/// so growing prod with an unstamped box is exactly as much a cross-group join
155/// as the dev case is. Failing open there would leave the operator believing a
156/// guarantee that was never evaluated.
157///
158/// The distinction that word carries matters most at the *node* boundary. A
159/// `MachineConfig` with no `sovereign_group` has genuinely declared standalone.
160/// A daemon started without `--sovereign-group` has declared nothing — the
161/// declaration never reached the box — and a caller that cannot tell those
162/// apart must not pass `None` here and read the answer as "standalone". Resolve
163/// the unknown first; this function only judges declarations.
164///
165/// # Why the role is checked on both sides
166///
167/// A join grows a quorum, and it takes two nodes to do it. Refusing a
168/// non-voting *joiner* is the case R605-F12 was opened for. Refusing a
169/// non-voting *target* is the same assertion read from the other end: a box
170/// declared non-voting should not be holding a raft seat to be joined *into*,
171/// so if one is, the operator has a contradiction between the declaration and
172/// the running cluster, and a permit here would paper over it. Neither side is
173/// a special case — both are asked the one question the role exists to answer.
174pub fn join_permitted(joiner: Membership<'_>, target: Membership<'_>) -> bool {
175    matches!((joiner.group, target.group), (Some(a), Some(b)) if a == b)
176        && joiner.role.is_voter()
177        && target.role.is_voter()
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    fn voter(group: &str) -> Membership<'_> {
185        Membership::new(group, SovereignRole::Voter)
186    }
187
188    fn non_voter(group: &str) -> Membership<'_> {
189        Membership::new(group, SovereignRole::NonVoter)
190    }
191
192    #[test]
193    fn one_group_joins_itself() {
194        assert!(join_permitted(voter("dev"), voter("dev")));
195        assert!(join_permitted(voter("prod"), voter("prod")));
196    }
197
198    /// The refusal the field was added for.
199    #[test]
200    fn two_groups_do_not_merge() {
201        assert!(!join_permitted(voter("dev"), voter("prod")));
202    }
203
204    /// `None` is a declaration, not a gap — so it never matches, including
205    /// against itself. Two unstamped boxes forming a group nobody declared is
206    /// the shape that leaves nothing to reason about later.
207    #[test]
208    fn undeclared_never_joins_anything() {
209        assert!(!join_permitted(Membership::standalone(), voter("prod")));
210        assert!(!join_permitted(voter("dev"), Membership::standalone()));
211        assert!(!join_permitted(
212            Membership::standalone(),
213            Membership::standalone()
214        ));
215    }
216
217    /// Group labels are compared exactly. A `"Dev"`/`"dev"` typo mints a
218    /// phantom group rather than silently joining the real one, which is the
219    /// safe direction: the refusal names both values, so the typo is visible
220    /// at the moment it bites.
221    #[test]
222    fn labels_are_compared_exactly() {
223        assert!(!join_permitted(voter("Dev"), voter("dev")));
224        assert!(!join_permitted(voter("dev "), voter("dev")));
225    }
226
227    /// R605-F12, the whole point: same group, and still refused. us-west-003 is
228    /// *in* prod's blast radius and must never hold a prod raft seat, and this
229    /// is the assertion that enforces it — as opposed to the absent stamp that
230    /// used to.
231    #[test]
232    fn a_non_voting_member_does_not_join_its_own_group() {
233        assert!(!join_permitted(non_voter("prod"), voter("prod")));
234    }
235
236    /// Read from the other end: a box declared non-voting has no quorum seat to
237    /// grow, so it cannot be the target of a join either.
238    #[test]
239    fn a_non_voting_target_has_no_quorum_to_join() {
240        assert!(!join_permitted(voter("prod"), non_voter("prod")));
241        assert!(!join_permitted(non_voter("prod"), non_voter("prod")));
242    }
243
244    /// Absence of the role means what declaring a group has always meant, so
245    /// the six nodes already stamped `prod`/`dev` stay joinable across this
246    /// change without an edit.
247    #[test]
248    fn the_default_role_is_the_pre_r605_f12_meaning() {
249        assert_eq!(SovereignRole::default(), SovereignRole::Voter);
250        assert!(join_permitted(
251            Membership::new("prod", SovereignRole::default()),
252            Membership::new("prod", SovereignRole::default())
253        ));
254    }
255
256    /// The TOML value, the CLI flag and the `/raft/status` JSON are all this
257    /// one spelling; a round-trip is what keeps them from drifting apart.
258    #[test]
259    fn roles_round_trip_through_their_one_spelling() {
260        for role in [SovereignRole::Voter, SovereignRole::NonVoter] {
261            assert_eq!(role.as_str().parse::<SovereignRole>().unwrap(), role);
262            assert_eq!(
263                serde_json::to_string(&role).unwrap(),
264                format!("\"{}\"", role.as_str())
265            );
266            assert_eq!(
267                serde_json::from_str::<SovereignRole>(&format!("\"{}\"", role.as_str())).unwrap(),
268                role
269            );
270        }
271    }
272
273    /// `"nonvoter"` / `"no-voter"` are the near-misses an operator actually
274    /// types — `no-voter` especially, since that was the retired taint key this
275    /// field replaces. Refusing them by name beats accepting one as a synonym
276    /// and leaving two spellings in the fleet.
277    #[test]
278    fn a_near_miss_role_spelling_is_an_error_naming_both_legal_values() {
279        for wrong in ["nonvoter", "no-voter", "Voter", "learner", ""] {
280            let err = wrong.parse::<SovereignRole>().unwrap_err();
281            assert!(err.contains("voter") && err.contains("non-voter"), "{err}");
282        }
283    }
284}