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}