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//!
40//! # Reading a live cluster against these declarations
41//!
42//! Because only the role gates a join, **the declared group and the raft
43//! membership are different sets, and the gap between them is the rule working
44//! rather than drift.** A `non-voter` is never joined, so it is absent from
45//! `/raft/status`'s `members` map *by construction*. us-west-003 declaring
46//! `sovereign_group = "prod"` while appearing nowhere in prod's membership is
47//! the correct and expected observation — it is a prod worker, inside the blast
48//! radius, holding no seat.
49//!
50//! This is written down because the comparison invites a false alarm: the
51//! obvious reading of "declared prod" against a three-node `members` map is
52//! "declared-vs-actual drift", and it is wrong. Group membership was never a
53//! claim about quorum membership. What the two sets share is only the voters.
54//!
55//! The observations that **are** worth an alarm, none of which the above is:
56//!
57//! - a **`voter`** in group G absent from G's raft membership — it was declared
58//!   quorum-eligible and never joined, so either a join failed or the group
59//!   stamp is aspirational;
60//! - a **`non-voter`** *present* in a raft membership — the guarantee is broken,
61//!   which means a join gate was bypassed rather than merely misconfigured;
62//! - a node in a membership whose declared group differs from the cluster's —
63//!   the cross-group join [`join_permitted`] exists to refuse.
64//!
65//! Note also that a node's *running* daemon is the authority on what it
66//! declares, not its TOML: `/raft/status` reporting `sovereign_group: null` on a
67//! box whose file says `prod` means the binary predates the field, so read it as
68//! "this build cannot tell you" rather than as a contradiction.
69
70use serde::{Deserialize, Serialize};
71
72/// Whether a node in a sovereign group may hold a seat in that group's quorum
73/// — R605-F12.
74///
75/// This is **not** a placement input and not a taint. It narrows a
76/// [`Membership`], and the single thing that reads it is [`join_permitted`].
77///
78/// [`Self::Voter`] is the default because it is what every already-stamped node
79/// means today: before this enum existed, declaring a group *was* declaring
80/// quorum eligibility, so absence has to keep meaning that or the field would
81/// silently retire six live voters. The permissiveness is bounded by the group
82/// still being mandatory — a box cannot drift into a quorum without an operator
83/// naming the group first — and the camp lints the omission at the layer that
84/// can see the whole fleet (`cloud::validate::check_unroled_sovereign_members`),
85/// rather than here, where refusing to deserialize would break every node that
86/// predates the field.
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
88#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
89#[serde(rename_all = "kebab-case")]
90pub enum SovereignRole {
91    /// Quorum-eligible: may be joined into its group's cluster.
92    #[default]
93    Voter,
94    /// In the group's blast radius — shares its upgrade cadence, its secrets,
95    /// its destruction — but never a quorum seat. Refused by [`join_permitted`]
96    /// on either side of a join.
97    ///
98    /// Consequently a node stamped this way is **absent from its group's
99    /// `/raft/status` `members` map, and that absence is correct** — see the
100    /// module docs' "Reading a live cluster" section before reporting it as
101    /// declared-vs-actual drift. A prod worker holding no seat is the whole
102    /// point of the variant.
103    NonVoter,
104}
105
106impl SovereignRole {
107    /// The wire/TOML spelling: `"voter"` / `"non-voter"`. Matches the serde
108    /// rename so the CLI flag, the TOML value and the `/raft/status` JSON can
109    /// never disagree about how the value is written.
110    pub fn as_str(&self) -> &'static str {
111        match self {
112            Self::Voter => "voter",
113            Self::NonVoter => "non-voter",
114        }
115    }
116
117    /// True for [`Self::Voter`]. Named rather than matched at call sites so the
118    /// join rule reads as one predicate.
119    pub fn is_voter(&self) -> bool {
120        matches!(self, Self::Voter)
121    }
122}
123
124impl std::fmt::Display for SovereignRole {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.write_str(self.as_str())
127    }
128}
129
130impl std::str::FromStr for SovereignRole {
131    type Err = String;
132
133    /// Parses the two legal spellings and nothing else. The error names both,
134    /// because this is reached from a CLI flag where the operator has a typo in
135    /// hand and no schema to consult.
136    fn from_str(s: &str) -> Result<Self, Self::Err> {
137        match s {
138            "voter" => Ok(Self::Voter),
139            "non-voter" => Ok(Self::NonVoter),
140            other => Err(format!(
141                "unknown sovereign role {other:?} — expected \"voter\" or \"non-voter\""
142            )),
143        }
144    }
145}
146
147/// What one node declares about its place in a sovereign group.
148///
149/// `group` is `None` for a standalone node — **in no group**, which is a
150/// declaration and not a gap; see [`join_permitted`]. `role` only means anything
151/// when `group` is `Some`: a standalone box has no quorum to be eligible for,
152/// so its role is never consulted.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub struct Membership<'a> {
155    /// The declared group label, or `None` for standalone.
156    pub group: Option<&'a str>,
157    /// Quorum eligibility within that group.
158    pub role: SovereignRole,
159}
160
161impl<'a> Membership<'a> {
162    /// A declared member of `group` with the given role.
163    pub fn new(group: &'a str, role: SovereignRole) -> Self {
164        Self {
165            group: Some(group),
166            role,
167        }
168    }
169
170    /// A node in no group at all. Distinct from a non-voting member: standalone
171    /// asserts no blast-radius relationship to anything, where a non-voter
172    /// shares the group's fate and only declines its quorum.
173    pub fn standalone() -> Self {
174        Self {
175            group: None,
176            role: SovereignRole::default(),
177        }
178    }
179}
180
181/// May a node declaring `joiner` join a cluster whose nodes declare `target`?
182///
183/// **Permitted iff both sides declare the same, non-`None` group *and* both are
184/// [`SovereignRole::Voter`].** One rule, no special cases.
185///
186/// The case it exists for is two *different* declared groups — joining a dev Pi
187/// into prod is refused rather than trusted, where the only prior guard was a
188/// comment in a TOML saying not to. But an undeclared side is refused too, and
189/// that is the deliberate half: **`None` means "in no group", not "unknown"**,
190/// so growing prod with an unstamped box is exactly as much a cross-group join
191/// as the dev case is. Failing open there would leave the operator believing a
192/// guarantee that was never evaluated.
193///
194/// The distinction that word carries matters most at the *node* boundary. A
195/// `MachineConfig` with no `sovereign_group` has genuinely declared standalone.
196/// A daemon started without `--sovereign-group` has declared nothing — the
197/// declaration never reached the box — and a caller that cannot tell those
198/// apart must not pass `None` here and read the answer as "standalone". Resolve
199/// the unknown first; this function only judges declarations.
200///
201/// # Why the role is checked on both sides
202///
203/// A join grows a quorum, and it takes two nodes to do it. Refusing a
204/// non-voting *joiner* is the case R605-F12 was opened for. Refusing a
205/// non-voting *target* is the same assertion read from the other end: a box
206/// declared non-voting should not be holding a raft seat to be joined *into*,
207/// so if one is, the operator has a contradiction between the declaration and
208/// the running cluster, and a permit here would paper over it. Neither side is
209/// a special case — both are asked the one question the role exists to answer.
210pub fn join_permitted(joiner: Membership<'_>, target: Membership<'_>) -> bool {
211    matches!((joiner.group, target.group), (Some(a), Some(b)) if a == b)
212        && joiner.role.is_voter()
213        && target.role.is_voter()
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn voter(group: &str) -> Membership<'_> {
221        Membership::new(group, SovereignRole::Voter)
222    }
223
224    fn non_voter(group: &str) -> Membership<'_> {
225        Membership::new(group, SovereignRole::NonVoter)
226    }
227
228    #[test]
229    fn one_group_joins_itself() {
230        assert!(join_permitted(voter("dev"), voter("dev")));
231        assert!(join_permitted(voter("prod"), voter("prod")));
232    }
233
234    /// The refusal the field was added for.
235    #[test]
236    fn two_groups_do_not_merge() {
237        assert!(!join_permitted(voter("dev"), voter("prod")));
238    }
239
240    /// `None` is a declaration, not a gap — so it never matches, including
241    /// against itself. Two unstamped boxes forming a group nobody declared is
242    /// the shape that leaves nothing to reason about later.
243    #[test]
244    fn undeclared_never_joins_anything() {
245        assert!(!join_permitted(Membership::standalone(), voter("prod")));
246        assert!(!join_permitted(voter("dev"), Membership::standalone()));
247        assert!(!join_permitted(
248            Membership::standalone(),
249            Membership::standalone()
250        ));
251    }
252
253    /// Group labels are compared exactly. A `"Dev"`/`"dev"` typo mints a
254    /// phantom group rather than silently joining the real one, which is the
255    /// safe direction: the refusal names both values, so the typo is visible
256    /// at the moment it bites.
257    #[test]
258    fn labels_are_compared_exactly() {
259        assert!(!join_permitted(voter("Dev"), voter("dev")));
260        assert!(!join_permitted(voter("dev "), voter("dev")));
261    }
262
263    /// R605-F12, the whole point: same group, and still refused. us-west-003 is
264    /// *in* prod's blast radius and must never hold a prod raft seat, and this
265    /// is the assertion that enforces it — as opposed to the absent stamp that
266    /// used to.
267    #[test]
268    fn a_non_voting_member_does_not_join_its_own_group() {
269        assert!(!join_permitted(non_voter("prod"), voter("prod")));
270    }
271
272    /// Read from the other end: a box declared non-voting has no quorum seat to
273    /// grow, so it cannot be the target of a join either.
274    #[test]
275    fn a_non_voting_target_has_no_quorum_to_join() {
276        assert!(!join_permitted(voter("prod"), non_voter("prod")));
277        assert!(!join_permitted(non_voter("prod"), non_voter("prod")));
278    }
279
280    /// Absence of the role means what declaring a group has always meant, so
281    /// the six nodes already stamped `prod`/`dev` stay joinable across this
282    /// change without an edit.
283    #[test]
284    fn the_default_role_is_the_pre_r605_f12_meaning() {
285        assert_eq!(SovereignRole::default(), SovereignRole::Voter);
286        assert!(join_permitted(
287            Membership::new("prod", SovereignRole::default()),
288            Membership::new("prod", SovereignRole::default())
289        ));
290    }
291
292    /// The TOML value, the CLI flag and the `/raft/status` JSON are all this
293    /// one spelling; a round-trip is what keeps them from drifting apart.
294    #[test]
295    fn roles_round_trip_through_their_one_spelling() {
296        for role in [SovereignRole::Voter, SovereignRole::NonVoter] {
297            assert_eq!(role.as_str().parse::<SovereignRole>().unwrap(), role);
298            assert_eq!(
299                serde_json::to_string(&role).unwrap(),
300                format!("\"{}\"", role.as_str())
301            );
302            assert_eq!(
303                serde_json::from_str::<SovereignRole>(&format!("\"{}\"", role.as_str())).unwrap(),
304                role
305            );
306        }
307    }
308
309    /// `"nonvoter"` / `"no-voter"` are the near-misses an operator actually
310    /// types — `no-voter` especially, since that was the retired taint key this
311    /// field replaces. Refusing them by name beats accepting one as a synonym
312    /// and leaving two spellings in the fleet.
313    #[test]
314    fn a_near_miss_role_spelling_is_an_error_naming_both_legal_values() {
315        for wrong in ["nonvoter", "no-voter", "Voter", "learner", ""] {
316            let err = wrong.parse::<SovereignRole>().unwrap_err();
317            assert!(err.contains("voter") && err.contains("non-voter"), "{err}");
318        }
319    }
320}