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}