1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
//! The sovereign-group join rule — W305 / R742-F1, extended by R605-F12.
//!
//! One sentence of logic, deliberately given a home of its own because it is
//! asked in two crates that cannot see each other:
//!
//! - **camp-side**, `cloud::judge_join`, which reads two `MachineConfig`s and
//! answers "may these two boxes be in one quorum" while planning;
//! - **node-side**, `yubaba`'s `POST /raft/add-learner` gate, which reads its
//! own `--sovereign-group` and asks the joiner for its own, and refuses.
//!
//! `yubaba` deliberately does **not** depend on `cloud` (R374-F3 moved
//! `local-driver` out precisely to avoid that edge), so the rule cannot simply
//! live in one of them. It lives here for the same reason
//! [`PUBLIC_IP_TAINT`](crate::PUBLIC_IP_TAINT) does: this crate is the shared
//! vocabulary both the planner and the daemon already link, and it depends on
//! neither.
//!
//! What is **not** shared is the prose. A camp-side refusal points at
//! `.yah/infra/machines/<name>.toml`; a node-side refusal has no machine name
//! to interpolate and must also name `yubaba serve --sovereign-group`, because
//! editing the TOML alone does not change what the running daemon declares.
//! Two renderings, one predicate — which is the split that keeps them from
//! disagreeing about what counts as a refusal.
//!
//! # Two axes, because membership and eligibility are different questions
//!
//! A [`Membership`] is a group *and* a [`SovereignRole`]. Before R605-F12 it was
//! only the group, so membership was binary and the only way to express "this
//! box is inside prod's blast radius but must never hold a quorum seat" was to
//! leave it out of prod entirely — which says something else, and says it by
//! omission. us-west-003 is the case: a residential-uplink build box that the
//! operator considers part of prod, whose exclusion from the prod raft was
//! enforced by nothing but the absence of a stamp nobody had written. That is
//! the W305 failure mode that produced R742-T4 (`no-voter` sitting inert on
//! three nodes, asserting something no code read), reached by a different road.
//!
//! So the group answers *which blast radius*, and the role answers *may it
//! vote*. Only the second gates a join.
//!
//! # Reading a live cluster against these declarations
//!
//! Because only the role gates a join, **the declared group and the raft
//! membership are different sets, and the gap between them is the rule working
//! rather than drift.** A `non-voter` is never joined, so it is absent from
//! `/raft/status`'s `members` map *by construction*. us-west-003 declaring
//! `sovereign_group = "prod"` while appearing nowhere in prod's membership is
//! the correct and expected observation — it is a prod worker, inside the blast
//! radius, holding no seat.
//!
//! This is written down because the comparison invites a false alarm: the
//! obvious reading of "declared prod" against a three-node `members` map is
//! "declared-vs-actual drift", and it is wrong. Group membership was never a
//! claim about quorum membership. What the two sets share is only the voters.
//!
//! The observations that **are** worth an alarm, none of which the above is:
//!
//! - a **`voter`** in group G absent from G's raft membership — it was declared
//! quorum-eligible and never joined, so either a join failed or the group
//! stamp is aspirational;
//! - a **`non-voter`** *present* in a raft membership — the guarantee is broken,
//! which means a join gate was bypassed rather than merely misconfigured;
//! - a node in a membership whose declared group differs from the cluster's —
//! the cross-group join [`join_permitted`] exists to refuse.
//!
//! Note also that a node's *running* daemon is the authority on what it
//! declares, not its TOML: `/raft/status` reporting `sovereign_group: null` on a
//! box whose file says `prod` means the binary predates the field, so read it as
//! "this build cannot tell you" rather than as a contradiction.
use ;
/// Whether a node in a sovereign group may hold a seat in that group's quorum
/// — R605-F12.
///
/// This is **not** a placement input and not a taint. It narrows a
/// [`Membership`], and the single thing that reads it is [`join_permitted`].
///
/// [`Self::Voter`] is the default because it is what every already-stamped node
/// means today: before this enum existed, declaring a group *was* declaring
/// quorum eligibility, so absence has to keep meaning that or the field would
/// silently retire six live voters. The permissiveness is bounded by the group
/// still being mandatory — a box cannot drift into a quorum without an operator
/// naming the group first — and the camp lints the omission at the layer that
/// can see the whole fleet (`cloud::validate::check_unroled_sovereign_members`),
/// rather than here, where refusing to deserialize would break every node that
/// predates the field.
/// What one node declares about its place in a sovereign group.
///
/// `group` is `None` for a standalone node — **in no group**, which is a
/// declaration and not a gap; see [`join_permitted`]. `role` only means anything
/// when `group` is `Some`: a standalone box has no quorum to be eligible for,
/// so its role is never consulted.
/// May a node declaring `joiner` join a cluster whose nodes declare `target`?
///
/// **Permitted iff both sides declare the same, non-`None` group *and* both are
/// [`SovereignRole::Voter`].** One rule, no special cases.
///
/// The case it exists for is two *different* declared groups — joining a dev Pi
/// into prod is refused rather than trusted, where the only prior guard was a
/// comment in a TOML saying not to. But an undeclared side is refused too, and
/// that is the deliberate half: **`None` means "in no group", not "unknown"**,
/// so growing prod with an unstamped box is exactly as much a cross-group join
/// as the dev case is. Failing open there would leave the operator believing a
/// guarantee that was never evaluated.
///
/// The distinction that word carries matters most at the *node* boundary. A
/// `MachineConfig` with no `sovereign_group` has genuinely declared standalone.
/// A daemon started without `--sovereign-group` has declared nothing — the
/// declaration never reached the box — and a caller that cannot tell those
/// apart must not pass `None` here and read the answer as "standalone". Resolve
/// the unknown first; this function only judges declarations.
///
/// # Why the role is checked on both sides
///
/// A join grows a quorum, and it takes two nodes to do it. Refusing a
/// non-voting *joiner* is the case R605-F12 was opened for. Refusing a
/// non-voting *target* is the same assertion read from the other end: a box
/// declared non-voting should not be holding a raft seat to be joined *into*,
/// so if one is, the operator has a contradiction between the declaration and
/// the running cluster, and a permit here would paper over it. Neither side is
/// a special case — both are asked the one question the role exists to answer.