Skip to main content

kopiur_api/
gates.rs

1//! The **structural-gate registry**: the one enumeration of the condition
2//! `type`/`status`/`reason` triples that mean "this object is blocked on
3//! something only a human can change".
4//!
5//! A *structural* gate is not a transient retry. It never self-heals: the
6//! reconciler parks the object (usually at `phase: Pending`) and waits for an
7//! out-of-band change — a namespace opt-in annotation, a credentials `Secret`,
8//! an acknowledgement timestamp. Because the phase itself stays unremarkable,
9//! anything that diagnoses a cluster by phase alone reports all-green while the
10//! work is wedged (issue #359: `kubectl kopiur doctor` passed all checks with a
11//! `Snapshot` stuck on `MoverPermitted=False`).
12//!
13//! The fix is to make the gate set **shared by construction**. The controller
14//! writes conditions from these rows and the CLI's `doctor` iterates the same
15//! rows, so a gate added on the server side cannot be invisible to the client
16//! side: there is exactly one list, in `kopiur-api`, which both depend on.
17//!
18//! This module is pure data + pure functions — no `kube::Client`, no `tokio` —
19//! per the `api` ↔ `controller` split.
20
21use crate::consts;
22
23/// Which CR kinds a structural gate's condition is written on.
24///
25/// Deliberately coarse (kinds, not selectors): a gate row answers "when I look
26/// at an object of THIS kind, is this condition meaningful?", which is all a
27/// diagnostic needs to avoid hunting for a condition that can never appear.
28///
29/// ```
30/// use kopiur_api::gates::GateScope;
31///
32/// // A privileged-mover refusal is written on both work kinds.
33/// assert!(GateScope::SnapshotOrRestore.covers_snapshot());
34/// assert!(GateScope::SnapshotOrRestore.covers_restore());
35/// // The mass-deletion breaker's per-Snapshot hold is Snapshot-only.
36/// assert!(GateScope::Snapshot.covers_snapshot());
37/// assert!(!GateScope::Snapshot.covers_restore());
38/// // Repository gates live on Repository/ClusterRepository.
39/// assert!(GateScope::Repository.covers_repository());
40/// assert!(!GateScope::Repository.covers_snapshot());
41/// // A schedule-level block lives on the SnapshotSchedule itself.
42/// assert!(GateScope::SnapshotSchedule.covers_snapshot_schedule());
43/// assert!(!GateScope::Snapshot.covers_snapshot_schedule());
44/// // A recipe-level block lives on the SnapshotPolicy itself.
45/// assert!(GateScope::SnapshotPolicy.covers_snapshot_policy());
46/// assert!(!GateScope::SnapshotPolicy.covers_snapshot());
47/// ```
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
49pub enum GateScope {
50    /// Written on both `Snapshot` and `Restore` CRs (the two "work" kinds that
51    /// launch mover Jobs, and whose reconcilers share the gate).
52    SnapshotOrRestore,
53    /// Written on `Snapshot` CRs only.
54    Snapshot,
55    /// Written on `Repository` and `ClusterRepository` CRs.
56    Repository,
57    /// Written on `SnapshotSchedule` CRs only — the schedule itself is blocked,
58    /// as opposed to any individual run being blocked.
59    SnapshotSchedule,
60    /// Written on `SnapshotPolicy` CRs only — the recipe itself is (partially)
61    /// blocked, as opposed to any individual run being blocked.
62    SnapshotPolicy,
63}
64
65impl GateScope {
66    /// Whether a `Snapshot` can carry a gate of this scope. Exhaustive.
67    pub fn covers_snapshot(self) -> bool {
68        match self {
69            Self::SnapshotOrRestore | Self::Snapshot => true,
70            Self::Repository | Self::SnapshotSchedule | Self::SnapshotPolicy => false,
71        }
72    }
73
74    /// Whether a `Restore` can carry a gate of this scope. Exhaustive.
75    pub fn covers_restore(self) -> bool {
76        match self {
77            Self::SnapshotOrRestore => true,
78            Self::Snapshot | Self::Repository | Self::SnapshotSchedule | Self::SnapshotPolicy => {
79                false
80            }
81        }
82    }
83
84    /// Whether a `Repository`/`ClusterRepository` can carry a gate of this
85    /// scope. Exhaustive.
86    pub fn covers_repository(self) -> bool {
87        match self {
88            Self::Repository => true,
89            Self::SnapshotOrRestore
90            | Self::Snapshot
91            | Self::SnapshotSchedule
92            | Self::SnapshotPolicy => false,
93        }
94    }
95
96    /// Whether a `SnapshotSchedule` can carry a gate of this scope. Exhaustive.
97    pub fn covers_snapshot_schedule(self) -> bool {
98        match self {
99            Self::SnapshotSchedule => true,
100            Self::SnapshotOrRestore | Self::Snapshot | Self::Repository | Self::SnapshotPolicy => {
101                false
102            }
103        }
104    }
105
106    /// Whether a `SnapshotPolicy` can carry a gate of this scope. Exhaustive.
107    pub fn covers_snapshot_policy(self) -> bool {
108        match self {
109            Self::SnapshotPolicy => true,
110            Self::SnapshotOrRestore
111            | Self::Snapshot
112            | Self::Repository
113            | Self::SnapshotSchedule => false,
114        }
115    }
116}
117
118/// How loudly a tripped gate should be reported.
119///
120/// `Fail` means "this will never complete until a human acts"; `Warn` means
121/// "this is blocked, but the block may well be the configuration you asked
122/// for" — so it must not, on its own, turn a diagnostic red.
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum GateSeverity {
125    /// Work is wedged and cannot progress without an out-of-band change.
126    Fail,
127    /// Work is refused, but the refusal is a plausible deliberate choice.
128    Warn,
129}
130
131impl GateSeverity {
132    /// Stable display string (exhaustive `match`), for CLI output and tests.
133    pub fn label(self) -> &'static str {
134        match self {
135            Self::Fail => "Fail",
136            Self::Warn => "Warn",
137        }
138    }
139}
140
141/// The Kubernetes condition `status` string `"True"`. Named so a gate row's
142/// polarity is spelled out rather than being a bare literal at the row.
143pub const CONDITION_TRUE: &str = "True";
144/// The Kubernetes condition `status` string `"False"`.
145pub const CONDITION_FALSE: &str = "False";
146
147/// One human-actionable structural gate: a condition `type`, the `status` that
148/// means BLOCKED, the `reason` the writer stamps, the CR kinds it appears on,
149/// and how loudly to report it.
150///
151/// Polarity is per-row rather than implied, because kopiur has gates of both
152/// shapes: `MoverPermitted=False` blocks, and so does `DeletionHeld=True`.
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub struct StructuralGate {
155    /// The CR kinds this gate's condition is written on.
156    pub applies_to: GateScope,
157    /// The condition `type` (a `consts` string, never a literal at the call site).
158    pub condition: &'static str,
159    /// The condition `status` that means BLOCKED — [`CONDITION_FALSE`] or
160    /// [`CONDITION_TRUE`].
161    pub blocked_status: &'static str,
162    /// The `reason` the reconciler stamps when it writes the blocked condition.
163    pub reason: &'static str,
164    /// How loudly a tripped gate is reported.
165    pub severity: GateSeverity,
166}
167
168impl StructuralGate {
169    /// Whether a live condition **is** this gate: `type`, `status`, AND `reason`
170    /// all match.
171    ///
172    /// This is the matcher consumers should reach for. `condition` + `status`
173    /// alone do not identify a row — `CredentialsAvailable=False` is written
174    /// with several different reasons and therefore has several rows — so
175    /// filtering the registry on [`trips`](Self::trips) alone yields multiple
176    /// hits for one live condition and double-reports it. `matches` selects exactly one row per
177    /// live condition, which is the property
178    /// `gate_rows_are_unique_and_internally_consistent` pins.
179    ///
180    /// ```
181    /// use kopiur_api::gates::STRUCTURAL_GATES;
182    ///
183    /// // One live condition off a wedged Snapshot ⇒ exactly one registry row.
184    /// let hits: Vec<_> = STRUCTURAL_GATES
185    ///     .iter()
186    ///     .filter(|g| g.matches("CredentialsAvailable", "False", "MissingCredentialsSecret"))
187    ///     .collect();
188    /// assert_eq!(hits.len(), 1);
189    /// assert_eq!(hits[0].reason, "MissingCredentialsSecret");
190    /// // The other reason selects the other row, never both.
191    /// assert!(!hits[0].matches("CredentialsAvailable", "False", "MissingServiceAccount"));
192    /// ```
193    pub fn matches(&self, condition_type: &str, status: &str, reason: &str) -> bool {
194        self.trips(condition_type, status) && reason == self.reason
195    }
196
197    /// The **reason-agnostic** coarse filter: whether a live condition's
198    /// `type`/`status` pair is the blocked polarity of this gate's condition.
199    ///
200    /// The polarity comparison lives here so no consumer re-derives it (the
201    /// `!= "True"` / `== "True"` mix-ups this registry exists to prevent). Use
202    /// it to answer "is this condition one of the ones we gate on at all?" —
203    /// notably when a live condition carries a reason NO row covers (a newer
204    /// operator's reason string), where [`matches`](Self::matches) would
205    /// silently report nothing. To identify WHICH row a condition is, use
206    /// `matches`: `trips` can match several rows sharing a condition+polarity.
207    ///
208    /// ```
209    /// use kopiur_api::gates::{STRUCTURAL_GATES, GateScope};
210    ///
211    /// let mover = STRUCTURAL_GATES
212    ///     .iter()
213    ///     .find(|g| g.condition == "MoverPermitted")
214    ///     .expect("the privileged-mover gate is registered");
215    /// assert!(mover.trips("MoverPermitted", "False"));
216    /// assert!(!mover.trips("MoverPermitted", "True"));
217    /// assert!(!mover.trips("Ready", "False"));
218    /// assert_eq!(mover.applies_to, GateScope::SnapshotOrRestore);
219    ///
220    /// // Coarse by design: an unregistered reason still flags the condition.
221    /// assert!(mover.trips("MoverPermitted", "False"));
222    /// ```
223    pub fn trips(&self, condition_type: &str, status: &str) -> bool {
224        condition_type == self.condition && status == self.blocked_status
225    }
226
227    /// [`blocked_status`](Self::blocked_status) as the `bool` a condition writer
228    /// passes when it writes this gate as BLOCKED — `true` for
229    /// [`CONDITION_TRUE`], `false` for [`CONDITION_FALSE`].
230    ///
231    /// The controller's `upsert_condition` takes a `bool`, so without this every
232    /// registry-driven writer would hand-translate the status string at its own
233    /// call site — exactly the per-site re-derivation this registry exists to
234    /// remove. Any other string reads as `false`; the
235    /// `every_gate_row_is_well_formed` tripwire makes that unreachable.
236    ///
237    /// ```
238    /// use kopiur_api::gates::STRUCTURAL_GATES;
239    ///
240    /// let mover = STRUCTURAL_GATES
241    ///     .iter()
242    ///     .find(|g| g.condition == "MoverPermitted")
243    ///     .expect("registered");
244    /// assert!(!mover.blocked_is_true()); // MoverPermitted=False blocks
245    ///
246    /// let held = STRUCTURAL_GATES
247    ///     .iter()
248    ///     .find(|g| g.condition == "DeletionHeld")
249    ///     .expect("registered");
250    /// assert!(held.blocked_is_true()); // DeletionHeld=True blocks
251    /// ```
252    pub fn blocked_is_true(&self) -> bool {
253        self.blocked_status == CONDITION_TRUE
254    }
255}
256
257/// An elevated mover in a namespace that has not opted in. The admin adds the
258/// `privileged-movers` annotation out-of-band; until then the object sits at
259/// `phase: Pending` — the exact shape of #359.
260pub const PRIVILEGED_MOVER_GATE: StructuralGate = StructuralGate {
261    applies_to: GateScope::SnapshotOrRestore,
262    condition: consts::MOVER_PERMITTED_CONDITION,
263    blocked_status: CONDITION_FALSE,
264    reason: consts::PRIVILEGED_MOVER_NOT_PERMITTED_REASON,
265    severity: GateSeverity::Fail,
266};
267
268/// The mover's credential `Secret` is not in the workload namespace. Parks at
269/// `phase: Pending` until the user creates it (or enables projection).
270pub const MISSING_CREDENTIALS_GATE: StructuralGate = StructuralGate {
271    applies_to: GateScope::SnapshotOrRestore,
272    condition: consts::CREDENTIALS_AVAILABLE_CONDITION,
273    blocked_status: CONDITION_FALSE,
274    reason: consts::MISSING_CREDENTIALS_REASON,
275    severity: GateSeverity::Fail,
276};
277
278/// Same condition as [`MISSING_CREDENTIALS_GATE`], different missing
279/// dependency: the workload-identity `ServiceAccount` the backend names.
280/// kopiur never creates it.
281pub const MISSING_SERVICE_ACCOUNT_GATE: StructuralGate = StructuralGate {
282    applies_to: GateScope::SnapshotOrRestore,
283    condition: consts::CREDENTIALS_AVAILABLE_CONDITION,
284    blocked_status: CONDITION_FALSE,
285    reason: consts::MISSING_SERVICE_ACCOUNT_REASON,
286    severity: GateSeverity::Fail,
287};
288
289/// Same condition as [`MISSING_CREDENTIALS_GATE`], a third missing
290/// dependency: the backend's `tls.caBundleRef` ConfigMap (the PEM CA bundle
291/// for a private-CA S3 endpoint). kopiur never creates it, and a ConfigMap
292/// that never appears never self-heals — the exact phase-invisible park shape
293/// of #359.
294pub const MISSING_CA_BUNDLE_GATE: StructuralGate = StructuralGate {
295    applies_to: GateScope::SnapshotOrRestore,
296    condition: consts::CREDENTIALS_AVAILABLE_CONDITION,
297    blocked_status: CONDITION_FALSE,
298    reason: consts::MISSING_CA_BUNDLE_REASON,
299    severity: GateSeverity::Fail,
300};
301
302/// Inverted polarity: the per-`Snapshot` hold the mass-deletion breaker
303/// applies. Released only by the `allow-mass-deletion` acknowledgement on the
304/// repository, so it is squarely "needs a human".
305pub const DELETION_HELD_GATE: StructuralGate = StructuralGate {
306    applies_to: GateScope::Snapshot,
307    condition: consts::DELETION_HELD_CONDITION,
308    blocked_status: CONDITION_TRUE,
309    reason: consts::MASS_DELETION_BREAKER_REASON,
310    severity: GateSeverity::Fail,
311};
312
313/// The repository-level view of the same breaker: a whole wave is held.
314pub const MASS_DELETION_HELD_GATE: StructuralGate = StructuralGate {
315    applies_to: GateScope::Repository,
316    condition: consts::MASS_DELETION_HELD_CONDITION,
317    blocked_status: CONDITION_TRUE,
318    reason: consts::MASS_DELETION_THRESHOLD_EXCEEDED_REASON,
319    severity: GateSeverity::Fail,
320};
321
322/// A backup refused because its repository is `mode: ReadOnly`.
323///
324/// Unlike the other rows this is NOT an invisible park: the writer
325/// (`snapshot/mod.rs`) sets `phase: Failed` in the same status patch, so a
326/// phase-based check already sees the object. It is registered for EXPLANATORY
327/// value — it turns "this backup failed" into "this backup was refused because
328/// the repository is read-only" without a second lookup. Hence WARN, not Fail:
329/// a read-only repository is a legitimate, deliberate configuration (a
330/// replication target, an archived repo served for restores only), so a
331/// green/red verdict must not hinge on it.
332pub const REPOSITORY_READ_ONLY_GATE: StructuralGate = StructuralGate {
333    applies_to: GateScope::Snapshot,
334    condition: consts::REPOSITORY_WRITABLE_CONDITION,
335    blocked_status: CONDITION_FALSE,
336    reason: consts::REPOSITORY_READ_ONLY_REASON,
337    severity: GateSeverity::Warn,
338};
339
340/// Version skew, one kind removed: a previous run of this schedule sits at a
341/// phase string this build cannot interpret, so it can never be observed to
342/// finish. Under the default `concurrencyPolicy: Forbid` the schedule stops
343/// firing FOREVER while looking perfectly healthy — the concurrency gate is
344/// doing exactly what it was asked to. The out-of-band change that clears it is
345/// finishing the operator rollout (or deleting the wedged `Snapshot`), which is
346/// squarely "needs a human", so: Fail.
347pub const SCHEDULE_BLOCKED_GATE: StructuralGate = StructuralGate {
348    applies_to: GateScope::SnapshotSchedule,
349    condition: consts::SCHEDULE_RUNNABLE_CONDITION,
350    blocked_status: CONDITION_FALSE,
351    reason: consts::BLOCKED_ON_UNREADABLE_RUN_REASON,
352    severity: GateSeverity::Fail,
353};
354
355/// A `SnapshotPolicy` referencing at least one repository that is not `Ready`.
356/// The policy reconciler keeps processing the READY subset (multi-repository
357/// fan-out, #368) but defers backups/retention/adoption/verification against
358/// the rest, and requeues until every repository recovers. Warn, not Fail: a
359/// repository deliberately taken down (a migration, a powered-off NAS target)
360/// is a plausible operator choice, the ready subset keeps working, and the
361/// per-child parks behind the same outage already carry their own reporting
362/// (`kopiur_snapshot_gated`, `KopiurRepositoryNotReady`) — so this gate must
363/// explain, not independently turn a diagnostic red.
364pub const POLICY_REPOSITORY_NOT_READY_GATE: StructuralGate = StructuralGate {
365    applies_to: GateScope::SnapshotPolicy,
366    condition: consts::REPOSITORIES_READY_CONDITION,
367    blocked_status: CONDITION_FALSE,
368    reason: consts::REPOSITORY_NOT_READY_REASON,
369    severity: GateSeverity::Warn,
370};
371
372/// A `SnapshotSchedule` whose members × repositories cross-product exceeds the
373/// fan-out cap: the fired slot was SKIPPED, and every future slot will keep
374/// skipping until the selector is narrowed or the policy's repository list
375/// shrunk — no backups run while everything else looks healthy, the exact
376/// silent-wedge shape of #359. The writer asserts BOTH polarities each fire
377/// pass, so the gate self-clears the moment a slot mints fully. Promoted into
378/// the registry by the #368 M10 gates/doctor checklist (previously a
379/// controller-internal condition doctor could not see).
380pub const SCHEDULE_FANOUT_CAPPED_GATE: StructuralGate = StructuralGate {
381    applies_to: GateScope::SnapshotSchedule,
382    condition: consts::SCHEDULE_FANOUT_CAPPED_CONDITION,
383    blocked_status: CONDITION_TRUE,
384    reason: consts::FANOUT_TOO_LARGE_REASON,
385    severity: GateSeverity::Fail,
386};
387
388/// A backup whose DIRECT source PVC (`spec.sources[].pvc`) does not exist at
389/// launch time. The `Snapshot` parks at `phase: Pending` on the slow
390/// structural cadence and, once the controller's missing-source deadline
391/// passes, flips terminally `Failed` — a PVC that never reappears never
392/// self-heals, and only recreating it (or repointing the policy's sources) can
393/// clear the block. Written on the Snapshot only for the direct-source case: a
394/// vanished operator-staged claim (copyMethod Snapshot/Clone) is a restage
395/// race and stays a plain transient retry, never this gate.
396pub const SOURCE_PVC_MISSING_GATE: StructuralGate = StructuralGate {
397    applies_to: GateScope::Snapshot,
398    condition: consts::SOURCE_PVC_AVAILABLE_CONDITION,
399    blocked_status: CONDITION_FALSE,
400    reason: consts::SOURCE_PVC_MISSING_REASON,
401    severity: GateSeverity::Fail,
402};
403
404/// A `spec.seed` in migrate mode whose SOURCE repository is missing or not
405/// `Ready` (issue #380). The repository parks `Pending` with `Seeded=False`
406/// and re-checks; nothing in kopiur can bring the source up, so it is squarely
407/// "needs a human" — and the park is phase-INVISIBLE in the worst way: a
408/// brand-new repository sitting at `Pending` looks identical to one that is
409/// simply still bootstrapping, and it will sit there for as long as the source
410/// is down.
411///
412/// WARN rather than Fail, for two reasons that point the same way. The
413/// registry pins one severity per condition+scope
414/// (`gate_rows_are_unique_and_internally_consistent`), and the sibling
415/// [`SEEDING_GATE`] on this same condition *must* be Warn — a seed legitimately
416/// runs for hours, and a healthy copy must not turn a diagnostic red. And on
417/// its own merits this park is the same shape as
418/// [`POLICY_REPOSITORY_NOT_READY_GATE`]: a source repository that is still
419/// bootstrapping alongside this one (the ordinary DR bring-up) comes up by
420/// itself, and one deliberately taken down is a plausible operator choice. The
421/// block is reported either way, with the writer's message — which is what
422/// makes it worth registering.
423pub const SEED_SOURCE_NOT_READY_GATE: StructuralGate = StructuralGate {
424    applies_to: GateScope::Repository,
425    condition: consts::SEEDED_CONDITION,
426    blocked_status: CONDITION_FALSE,
427    reason: consts::WAITING_FOR_SEED_SOURCE_REASON,
428    severity: GateSeverity::Warn,
429};
430
431/// A migrate-mode `spec.seed` whose LOCAL backend and resolved SOURCE
432/// repository disagree on workload identity (issue #380).
433///
434/// The blob arm of this rule is refused at admission
435/// (`validate_replication_auth`), but a `seed.from.repository` reference hides
436/// the source's backend from a spec-only validator, so the controller
437/// re-applies the same rule once it has resolved the source and parks here
438/// instead. Without the park the CR is admitted, a Job is launched, and the
439/// failure surfaces as a bare cloud auth error from whichever side the pod's
440/// single ServiceAccount is not.
441///
442/// WARN for the same reason every other `Seeded` row is: the registry pins one
443/// severity per condition+scope, and the in-flight [`SEEDING_GATE`] must not
444/// turn a diagnostic red. Nothing is lost — the message names both
445/// ServiceAccounts and the two ways out, and the repository is not `Ready`,
446/// which a diagnostic fails on independently.
447pub const SEED_SOURCE_AUTH_CONFLICT_GATE: StructuralGate = StructuralGate {
448    applies_to: GateScope::Repository,
449    condition: consts::SEEDED_CONDITION,
450    blocked_status: CONDITION_FALSE,
451    reason: consts::SEED_SOURCE_AUTH_CONFLICT_REASON,
452    severity: GateSeverity::Warn,
453};
454
455/// A seeding bootstrap Job is in flight (issue #380): the repository is
456/// copying a whole repository across and is legitimately not `Ready` yet.
457///
458/// Registered for EXPLANATORY value, like [`REPOSITORY_READ_ONLY_GATE`]: a seed
459/// runs for hours by design (its Job deadline defaults to 24h), so a
460/// diagnostic must be able to say "seeding" rather than "stuck at Pending".
461/// Hence WARN — progress is not a fault, and this row must never on its own
462/// turn a diagnostic red.
463pub const SEEDING_GATE: StructuralGate = StructuralGate {
464    applies_to: GateScope::Repository,
465    condition: consts::SEEDED_CONDITION,
466    blocked_status: CONDITION_FALSE,
467    reason: consts::SEEDING_REASON,
468    severity: GateSeverity::Warn,
469};
470
471/// A `spec.seed` whose SOURCE answered but holds no kopia repository. Retried
472/// automatically every ~2 minutes, so it clears itself the moment the source
473/// exists — but until then the repository never becomes `Ready`, and the fix
474/// (repoint `spec.seed.from`) is squarely out-of-band.
475pub const SEED_SOURCE_NOT_FOUND_GATE: StructuralGate = StructuralGate {
476    applies_to: GateScope::Repository,
477    condition: consts::SEEDED_CONDITION,
478    blocked_status: CONDITION_FALSE,
479    reason: consts::SEED_SOURCE_NOT_FOUND_REASON,
480    severity: GateSeverity::Warn,
481};
482
483/// A `spec.seed` whose source is a real repository holding zero snapshots, with
484/// `allowEmptySource` at its `false` default. Blocking `Ready` is the point: a
485/// valid-but-empty mirror is nearly always mis-pointed.
486pub const SEED_SOURCE_EMPTY_GATE: StructuralGate = StructuralGate {
487    applies_to: GateScope::Repository,
488    condition: consts::SEEDED_CONDITION,
489    blocked_status: CONDITION_FALSE,
490    reason: consts::SEED_SOURCE_EMPTY_REASON,
491    severity: GateSeverity::Warn,
492};
493
494/// A migrate-mode seed whose post-verify found snapshots missing. The next
495/// attempt resumes the copy, so this converges on its own — but a seed that
496/// keeps being cut short needs a human to raise its deadline.
497pub const SEED_INCOMPLETE_GATE: StructuralGate = StructuralGate {
498    applies_to: GateScope::Repository,
499    condition: consts::SEEDED_CONDITION,
500    blocked_status: CONDITION_FALSE,
501    reason: consts::SEED_INCOMPLETE_REASON,
502    severity: GateSeverity::Warn,
503};
504
505/// A seed that was armed and left the repository holding ZERO snapshots. Like
506/// [`SEED_INCOMPLETE_GATE`], the next attempt resumes the copy.
507pub const SEED_LEFT_EMPTY_GATE: StructuralGate = StructuralGate {
508    applies_to: GateScope::Repository,
509    condition: consts::SEEDED_CONDITION,
510    blocked_status: CONDITION_FALSE,
511    reason: consts::SEED_LEFT_EMPTY_REASON,
512    severity: GateSeverity::Warn,
513};
514
515/// The mover-skew guard: the running mover image predates `spec.seed`, ignored
516/// it, and initialized an EMPTY repository. Genuinely terminal and genuinely
517/// human-actionable (upgrade the image, delete the empty repository and the
518/// terminal bootstrap Job) — the ONE row here that would earn `Fail` on its own
519/// merits. It is `Warn` because the registry pins one severity per
520/// condition+scope and its siblings must not turn a healthy in-progress seed
521/// red; nothing is lost, because a repository in this state is also not `Ready`,
522/// which `doctor`'s repository check fails on independently.
523pub const SEED_MOVER_TOO_OLD_GATE: StructuralGate = StructuralGate {
524    applies_to: GateScope::Repository,
525    condition: consts::SEEDED_CONDITION,
526    blocked_status: CONDITION_FALSE,
527    reason: consts::SEED_MOVER_TOO_OLD_REASON,
528    severity: GateSeverity::Warn,
529};
530
531/// A `Restore` whose repository referent does not exist (issue #393): the
532/// explicit `spec.repository` object, or the `source.fromPolicy`
533/// `SnapshotPolicy` the repository ref is derived from.
534///
535/// The readiness gate cannot verify a repository it cannot even look up, so the
536/// restore parks at `phase: Pending` and — critically — its
537/// `policy.waitTimeout` window is NOT opened (`status.waitStartedAt` stays
538/// unstamped) until the referent appears and its repository becomes `Ready`.
539/// Before #393 the gate fell through unverified here, so a slow-arriving
540/// referent silently spent the window; for a `fromPolicy` source, whose
541/// `onMissingSnapshot` defaults to `Continue`, a spent window means an EMPTY
542/// volume.
543///
544/// WARN, not Fail, for the reason [`POLICY_REPOSITORY_NOT_READY_GATE`] is:
545/// referents applied moments apart by GitOps are the ordinary case and resolve
546/// themselves within a requeue or two, so this must explain the park rather
547/// than independently turn a diagnostic red. What it must never be is INVISIBLE
548/// — a restore parked on a `SnapshotPolicy` that was never applied looks
549/// identical, by phase, to one that is simply still resolving.
550pub const RESTORE_REFERENT_MISSING_GATE: StructuralGate = StructuralGate {
551    applies_to: GateScope::SnapshotOrRestore,
552    condition: consts::RESTORE_REFERENT_AVAILABLE_CONDITION,
553    blocked_status: CONDITION_FALSE,
554    reason: consts::RESTORE_REFERENT_MISSING_REASON,
555    severity: GateSeverity::Warn,
556};
557
558/// Every human-actionable structural gate kopiur's reconcilers can park an
559/// object on.
560///
561/// Adding a gate to a reconciler means adding a row here; the CLI picks it up
562/// with no change, which is the whole point (#359). A condition that merely
563/// *reports* health (`IndexBlobHealth`, `BackendReachable`,
564/// `SecurityContextCompatible`) is NOT a gate — it blocks nothing — and a
565/// time-bounded wait (`SourceStaged`, `PreflightFailed`) is not one either,
566/// because it resolves itself into a terminal phase on its own.
567///
568/// Most rows are phase-INVISIBLE parks (the object sits at `phase: Pending`
569/// looking unremarkable), which is what makes the registry load-bearing. One
570/// row — [`REPOSITORY_READ_ONLY_GATE`] — is phase-visible and registered
571/// anyway, for the explanation it adds to an already-visible failure.
572///
573/// Each row is also a named `const` so a reconciler can write its condition
574/// FROM the row (`io::upsert_gate`) instead of restating the triple at the call
575/// site. A row nobody writes, or a writer nobody registered, is caught by the
576/// controller's `every_registered_gate_has_a_writer` drift test.
577pub const STRUCTURAL_GATES: &[StructuralGate] = &[
578    PRIVILEGED_MOVER_GATE,
579    MISSING_CREDENTIALS_GATE,
580    MISSING_SERVICE_ACCOUNT_GATE,
581    MISSING_CA_BUNDLE_GATE,
582    DELETION_HELD_GATE,
583    MASS_DELETION_HELD_GATE,
584    REPOSITORY_READ_ONLY_GATE,
585    SCHEDULE_BLOCKED_GATE,
586    SCHEDULE_FANOUT_CAPPED_GATE,
587    POLICY_REPOSITORY_NOT_READY_GATE,
588    SOURCE_PVC_MISSING_GATE,
589    RESTORE_REFERENT_MISSING_GATE,
590    SEED_SOURCE_NOT_READY_GATE,
591    SEED_SOURCE_AUTH_CONFLICT_GATE,
592    SEEDING_GATE,
593    SEED_SOURCE_NOT_FOUND_GATE,
594    SEED_SOURCE_EMPTY_GATE,
595    SEED_INCOMPLETE_GATE,
596    SEED_LEFT_EMPTY_GATE,
597    SEED_MOVER_TOO_OLD_GATE,
598];
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use std::collections::HashSet;
604
605    #[test]
606    fn every_gate_row_is_well_formed() {
607        // Tripwire: a row added with an empty/typo'd string, or a polarity that
608        // is neither True nor False, would make the gate silently unmatchable
609        // on both sides of the contract.
610        assert!(
611            !STRUCTURAL_GATES.is_empty(),
612            "the registry is the shared gate list; an empty one means doctor checks nothing"
613        );
614        for g in STRUCTURAL_GATES {
615            assert!(
616                !g.condition.is_empty(),
617                "{g:?}: condition must be non-empty"
618            );
619            assert!(!g.reason.is_empty(), "{g:?}: reason must be non-empty");
620            assert!(
621                g.blocked_status == CONDITION_TRUE || g.blocked_status == CONDITION_FALSE,
622                "{g:?}: blocked_status must be a Kubernetes condition status"
623            );
624            // The row must match itself and reject the opposite polarity.
625            assert!(g.trips(g.condition, g.blocked_status), "{g:?}: self-match");
626            let opposite = if g.blocked_status == CONDITION_TRUE {
627                CONDITION_FALSE
628            } else {
629                CONDITION_TRUE
630            };
631            assert!(
632                !g.trips(g.condition, opposite),
633                "{g:?}: must not trip on the opposite polarity"
634            );
635            assert!(
636                !g.trips("SomeOtherCondition", g.blocked_status),
637                "{g:?}: must not trip on another condition type"
638            );
639        }
640    }
641
642    #[test]
643    fn a_live_condition_selects_exactly_one_row() {
644        // The whole registry must be a FUNCTION of a live condition: one
645        // (type, status, reason) triple in, at most one row out. Rows 2 and 3
646        // share condition+status+scope and differ only by reason, so a
647        // consumer filtering on `trips` alone would double-report a single
648        // wedged Snapshot. `matches` is the matcher that cannot.
649        for g in STRUCTURAL_GATES {
650            let hits: Vec<_> = STRUCTURAL_GATES
651                .iter()
652                .filter(|c| c.matches(g.condition, g.blocked_status, g.reason))
653                .collect();
654            assert_eq!(hits.len(), 1, "{g:?}: must select exactly one row");
655            assert_eq!(hits[0], g, "{g:?}: must select ITSELF");
656        }
657
658        // The concrete case the reviewer called out, spelled out end to end.
659        let creds_secret: Vec<_> = STRUCTURAL_GATES
660            .iter()
661            .filter(|g| {
662                g.matches(
663                    consts::CREDENTIALS_AVAILABLE_CONDITION,
664                    CONDITION_FALSE,
665                    consts::MISSING_CREDENTIALS_REASON,
666                )
667            })
668            .collect();
669        assert_eq!(creds_secret.len(), 1);
670        assert_eq!(creds_secret[0].reason, consts::MISSING_CREDENTIALS_REASON);
671
672        // ...and the coarse filter deliberately still matches BOTH rows, which
673        // is why it must never be used to identify a row.
674        let coarse = STRUCTURAL_GATES
675            .iter()
676            .filter(|g| g.trips(consts::CREDENTIALS_AVAILABLE_CONDITION, CONDITION_FALSE))
677            .count();
678        assert_eq!(coarse, 3, "`trips` is reason-agnostic by design");
679
680        // An unregistered reason for a gated condition: `matches` finds
681        // nothing, `trips` still flags it. That asymmetry is the reason
682        // `trips` is kept rather than removed.
683        assert!(!STRUCTURAL_GATES.iter().any(|g| g.matches(
684            consts::CREDENTIALS_AVAILABLE_CONDITION,
685            CONDITION_FALSE,
686            "SomeFutureReason"
687        )));
688        assert!(
689            STRUCTURAL_GATES
690                .iter()
691                .any(|g| g.trips(consts::CREDENTIALS_AVAILABLE_CONDITION, CONDITION_FALSE))
692        );
693    }
694
695    #[test]
696    fn blocked_is_true_mirrors_the_status_string() {
697        // The bool a registry-driven `upsert_condition` writer passes. Derived
698        // from the row, never hand-translated per call site.
699        for g in STRUCTURAL_GATES {
700            assert_eq!(
701                g.blocked_is_true(),
702                g.blocked_status == CONDITION_TRUE,
703                "{g:?}"
704            );
705        }
706        // Both polarities are actually exercised by the live registry, so this
707        // can never rot into a one-sided assertion.
708        assert!(STRUCTURAL_GATES.iter().any(|g| g.blocked_is_true()));
709        assert!(STRUCTURAL_GATES.iter().any(|g| !g.blocked_is_true()));
710    }
711
712    #[test]
713    fn gate_rows_are_unique_and_internally_consistent() {
714        // A copy-paste duplicate would double-report the same block. Two rows
715        // MAY share a condition+scope when the writer stamps different reasons
716        // for it (CredentialsAvailable: missing Secret vs missing SA) — but
717        // then they must agree on polarity and severity, or a diagnostic's
718        // verdict would depend on which row it happened to match first.
719        let mut seen: HashSet<(&str, &str, GateScope)> = HashSet::new();
720        for g in STRUCTURAL_GATES {
721            assert!(
722                seen.insert((g.condition, g.reason, g.applies_to)),
723                "{g:?}: duplicate condition+reason+scope row"
724            );
725        }
726        for a in STRUCTURAL_GATES {
727            for b in STRUCTURAL_GATES {
728                if a.condition == b.condition && a.applies_to == b.applies_to {
729                    assert_eq!(
730                        a.blocked_status, b.blocked_status,
731                        "{a:?} / {b:?}: same condition+scope, different polarity"
732                    );
733                    assert_eq!(
734                        a.severity, b.severity,
735                        "{a:?} / {b:?}: same condition+scope, different severity"
736                    );
737                }
738            }
739        }
740    }
741
742    #[test]
743    fn every_seeded_false_reason_this_build_writes_has_a_row() {
744        // The `Seeded` condition is the one gated condition with a WIDE reason
745        // set, and a partial registration is worse than none: a reason with no
746        // row still trips `StructuralGate::trips`, so `kubectl kopiur doctor`
747        // classifies it as UNREGISTERED and tells the operator "the operator is
748        // newer than the plugin — upgrade the plugin". That would be a false
749        // diagnosis handed to someone mid-disaster-recovery, whose actual
750        // problem is a mis-pointed seed source.
751        //
752        // So: every reason this build can stamp on `Seeded=False` must select
753        // exactly one row. The list is assembled from the three park/progress
754        // reasons plus the shared failure set, so a new reason added to
755        // `consts::SEED_FAILURE_REASONS` fails here until it is registered.
756        let mut reasons: Vec<&str> = vec![
757            consts::WAITING_FOR_SEED_SOURCE_REASON,
758            consts::SEED_SOURCE_AUTH_CONFLICT_REASON,
759            consts::SEEDING_REASON,
760        ];
761        reasons.extend_from_slice(consts::SEED_FAILURE_REASONS);
762        for reason in reasons {
763            let hits: Vec<_> = STRUCTURAL_GATES
764                .iter()
765                .filter(|g| g.matches(consts::SEEDED_CONDITION, CONDITION_FALSE, reason))
766                .collect();
767            assert_eq!(
768                hits.len(),
769                1,
770                "Seeded=False reason `{reason}` selects {} rows, not 1 — doctor would report it \
771                 as an unknown reason from a newer operator",
772                hits.len()
773            );
774            assert!(hits[0].applies_to.covers_repository());
775        }
776        // Guard against the reverse rot: every registered `Seeded` row must be
777        // one of those reasons, so a row nobody can write cannot linger.
778        for g in STRUCTURAL_GATES
779            .iter()
780            .filter(|g| g.condition == consts::SEEDED_CONDITION)
781        {
782            assert!(
783                g.reason == consts::WAITING_FOR_SEED_SOURCE_REASON
784                    || g.reason == consts::SEED_SOURCE_AUTH_CONFLICT_REASON
785                    || g.reason == consts::SEEDING_REASON
786                    || consts::SEED_FAILURE_REASONS.contains(&g.reason),
787                "{g:?}: registered but not a reason this build writes"
788            );
789        }
790    }
791
792    /// Every `Seeded=False` failure reason must be EXPLAINED in the docs, not
793    /// just registered (#380).
794    ///
795    /// The registry above guarantees `kubectl kopiur doctor` recognizes a
796    /// reason; it says nothing about whether a human who reads it can find out
797    /// what to do. These reasons surface in exactly one situation — a disaster
798    /// recovery that is not going well — so an unexplained one is worse here
799    /// than almost anywhere else in kopiur. The two pages are the two places
800    /// someone actually looks: the troubleshooting table (symptom → fix) and the
801    /// DR scenario (the reason table plus the retry/terminal contract).
802    ///
803    /// A reason added to `consts::SEED_FAILURE_REASONS` — or to the
804    /// terminal-until-edited park set the body extends it with — fails here
805    /// until both pages mention it by name.
806    #[test]
807    fn every_seed_failure_reason_is_documented_on_both_user_facing_pages() {
808        // CARGO_MANIFEST_DIR = crates/api; the docs tree is at the repo root.
809        // Same relative-path approach `crates/api/tests/examples_match_crd_shapes.rs`
810        // uses to reach `deploy/examples`.
811        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
812        for page in [
813            "docs/troubleshooting.md",
814            "docs/scenarios/dr-with-replicated-repository.md",
815        ] {
816            let path = root.join(page);
817            let text = std::fs::read_to_string(&path)
818                .unwrap_or_else(|e| panic!("{page} must exist and be readable: {e}"));
819            // The mover's failure classes, PLUS the one park reason that is
820            // equally terminal-until-edited: a migrate-mode workload-identity
821            // conflict is re-checked forever and never clears by itself, so
822            // doctor prints it at someone who needs the same lookup. The two
823            // progress/park reasons that DO clear on their own
824            // (`Seeding`, `WaitingForSeedSource`) are deliberately not required
825            // here — they describe motion, not a thing to look up.
826            let documented: Vec<&str> = consts::SEED_FAILURE_REASONS
827                .iter()
828                .copied()
829                .chain(std::iter::once(consts::SEED_SOURCE_AUTH_CONFLICT_REASON))
830                .collect();
831            for reason in documented {
832                assert!(
833                    text.contains(reason),
834                    "{page} never mentions the `Seeded=False` reason `{reason}` — doctor will \
835                     print it at a user who then has nowhere to look it up"
836                );
837            }
838        }
839    }
840
841    #[test]
842    fn expected_gates_are_registered_with_expected_scope_and_severity() {
843        // Pins the exact contract M3's doctor consumes. A row removed, or its
844        // severity/scope quietly changed, fails here rather than silently
845        // changing what a cluster diagnostic reports.
846        let expected: &[(&str, &str, &str, GateScope, GateSeverity)] = &[
847            (
848                consts::MOVER_PERMITTED_CONDITION,
849                CONDITION_FALSE,
850                consts::PRIVILEGED_MOVER_NOT_PERMITTED_REASON,
851                GateScope::SnapshotOrRestore,
852                GateSeverity::Fail,
853            ),
854            (
855                consts::CREDENTIALS_AVAILABLE_CONDITION,
856                CONDITION_FALSE,
857                consts::MISSING_CREDENTIALS_REASON,
858                GateScope::SnapshotOrRestore,
859                GateSeverity::Fail,
860            ),
861            (
862                consts::CREDENTIALS_AVAILABLE_CONDITION,
863                CONDITION_FALSE,
864                consts::MISSING_SERVICE_ACCOUNT_REASON,
865                GateScope::SnapshotOrRestore,
866                GateSeverity::Fail,
867            ),
868            (
869                consts::CREDENTIALS_AVAILABLE_CONDITION,
870                CONDITION_FALSE,
871                consts::MISSING_CA_BUNDLE_REASON,
872                GateScope::SnapshotOrRestore,
873                GateSeverity::Fail,
874            ),
875            (
876                consts::DELETION_HELD_CONDITION,
877                CONDITION_TRUE,
878                consts::MASS_DELETION_BREAKER_REASON,
879                GateScope::Snapshot,
880                GateSeverity::Fail,
881            ),
882            (
883                consts::MASS_DELETION_HELD_CONDITION,
884                CONDITION_TRUE,
885                consts::MASS_DELETION_THRESHOLD_EXCEEDED_REASON,
886                GateScope::Repository,
887                GateSeverity::Fail,
888            ),
889            (
890                consts::REPOSITORY_WRITABLE_CONDITION,
891                CONDITION_FALSE,
892                consts::REPOSITORY_READ_ONLY_REASON,
893                GateScope::Snapshot,
894                GateSeverity::Warn,
895            ),
896            (
897                consts::SCHEDULE_RUNNABLE_CONDITION,
898                CONDITION_FALSE,
899                consts::BLOCKED_ON_UNREADABLE_RUN_REASON,
900                GateScope::SnapshotSchedule,
901                GateSeverity::Fail,
902            ),
903            (
904                consts::SCHEDULE_FANOUT_CAPPED_CONDITION,
905                CONDITION_TRUE,
906                consts::FANOUT_TOO_LARGE_REASON,
907                GateScope::SnapshotSchedule,
908                GateSeverity::Fail,
909            ),
910            (
911                consts::REPOSITORIES_READY_CONDITION,
912                CONDITION_FALSE,
913                consts::REPOSITORY_NOT_READY_REASON,
914                GateScope::SnapshotPolicy,
915                GateSeverity::Warn,
916            ),
917            (
918                consts::SOURCE_PVC_AVAILABLE_CONDITION,
919                CONDITION_FALSE,
920                consts::SOURCE_PVC_MISSING_REASON,
921                GateScope::Snapshot,
922                GateSeverity::Fail,
923            ),
924            (
925                consts::RESTORE_REFERENT_AVAILABLE_CONDITION,
926                CONDITION_FALSE,
927                consts::RESTORE_REFERENT_MISSING_REASON,
928                GateScope::SnapshotOrRestore,
929                GateSeverity::Warn,
930            ),
931            (
932                consts::SEEDED_CONDITION,
933                CONDITION_FALSE,
934                consts::WAITING_FOR_SEED_SOURCE_REASON,
935                GateScope::Repository,
936                GateSeverity::Warn,
937            ),
938            (
939                consts::SEEDED_CONDITION,
940                CONDITION_FALSE,
941                consts::SEED_SOURCE_AUTH_CONFLICT_REASON,
942                GateScope::Repository,
943                GateSeverity::Warn,
944            ),
945            (
946                consts::SEEDED_CONDITION,
947                CONDITION_FALSE,
948                consts::SEEDING_REASON,
949                GateScope::Repository,
950                GateSeverity::Warn,
951            ),
952            (
953                consts::SEEDED_CONDITION,
954                CONDITION_FALSE,
955                consts::SEED_SOURCE_NOT_FOUND_REASON,
956                GateScope::Repository,
957                GateSeverity::Warn,
958            ),
959            (
960                consts::SEEDED_CONDITION,
961                CONDITION_FALSE,
962                consts::SEED_SOURCE_EMPTY_REASON,
963                GateScope::Repository,
964                GateSeverity::Warn,
965            ),
966            (
967                consts::SEEDED_CONDITION,
968                CONDITION_FALSE,
969                consts::SEED_INCOMPLETE_REASON,
970                GateScope::Repository,
971                GateSeverity::Warn,
972            ),
973            (
974                consts::SEEDED_CONDITION,
975                CONDITION_FALSE,
976                consts::SEED_LEFT_EMPTY_REASON,
977                GateScope::Repository,
978                GateSeverity::Warn,
979            ),
980            (
981                consts::SEEDED_CONDITION,
982                CONDITION_FALSE,
983                consts::SEED_MOVER_TOO_OLD_REASON,
984                GateScope::Repository,
985                GateSeverity::Warn,
986            ),
987        ];
988        assert_eq!(
989            STRUCTURAL_GATES.len(),
990            expected.len(),
991            "a gate row was added or removed — update the pinned expectations \
992             (and M3's doctor coverage) deliberately"
993        );
994        for (condition, status, reason, scope, severity) in expected {
995            let row = STRUCTURAL_GATES
996                .iter()
997                .find(|g| g.condition == *condition && g.reason == *reason)
998                .unwrap_or_else(|| panic!("{condition}/{reason} must be registered"));
999            assert_eq!(row.blocked_status, *status, "{condition}/{reason} polarity");
1000            assert_eq!(row.applies_to, *scope, "{condition}/{reason} scope");
1001            assert_eq!(row.severity, *severity, "{condition}/{reason} severity");
1002        }
1003    }
1004
1005    /// The #393 park must be visible to `doctor` on a `Restore` — and must not
1006    /// make any OTHER condition read as a gate.
1007    ///
1008    /// The registry's coarse [`StructuralGate::trips`] filter is
1009    /// reason-agnostic, so a row registered on a condition other reconcilers
1010    /// also write (`Ready`, `Resolved`, …) would make every unrelated reason on
1011    /// that condition report as "a gate from a newer operator". This row's
1012    /// condition is therefore written by exactly one gate and one reason.
1013    #[test]
1014    fn the_restore_referent_gate_is_restore_scoped_and_owns_its_condition() {
1015        let row = STRUCTURAL_GATES
1016            .iter()
1017            .find(|g| g.reason == consts::RESTORE_REFERENT_MISSING_REASON)
1018            .expect("the #393 referent-missing park must be registered");
1019        // Reachable from a Restore (the only scope whose `covers_restore` is true).
1020        assert!(row.applies_to.covers_restore());
1021        assert_eq!(row.applies_to, GateScope::SnapshotOrRestore);
1022        // Warn: referents applied moments apart by GitOps resolve themselves.
1023        assert_eq!(row.severity, GateSeverity::Warn);
1024        assert_eq!(row.condition, consts::RESTORE_REFERENT_AVAILABLE_CONDITION);
1025        assert!(row.trips(consts::RESTORE_REFERENT_AVAILABLE_CONDITION, "False"));
1026        assert!(!row.trips(consts::RESTORE_REFERENT_AVAILABLE_CONDITION, "True"));
1027        // Exactly one row owns this condition, so the coarse filter can only
1028        // fire for the one reason this build writes on it.
1029        assert_eq!(
1030            STRUCTURAL_GATES
1031                .iter()
1032                .filter(|g| g.condition == consts::RESTORE_REFERENT_AVAILABLE_CONDITION)
1033                .count(),
1034            1
1035        );
1036        // ...and it is NOT the `Ready` condition, which every Restore writes with
1037        // many reasons — registering there would misreport all of them.
1038        assert_ne!(row.condition, consts::READY_CONDITION);
1039        assert!(
1040            !STRUCTURAL_GATES
1041                .iter()
1042                .any(|g| g.condition == consts::READY_CONDITION)
1043        );
1044    }
1045
1046    #[test]
1047    fn every_scope_classifier_is_consistent() {
1048        // Each scope covers exactly one of the four kind families a consumer
1049        // reads from separate lists (work CRs, repositories, schedules,
1050        // policies), so a diagnostic can dispatch on scope without
1051        // double-listing a gate.
1052        for scope in [
1053            GateScope::SnapshotOrRestore,
1054            GateScope::Snapshot,
1055            GateScope::Repository,
1056            GateScope::SnapshotSchedule,
1057            GateScope::SnapshotPolicy,
1058        ] {
1059            let work = scope.covers_snapshot() || scope.covers_restore();
1060            let families = [
1061                work,
1062                scope.covers_repository(),
1063                scope.covers_snapshot_schedule(),
1064                scope.covers_snapshot_policy(),
1065            ];
1066            assert_eq!(
1067                families.iter().filter(|f| **f).count(),
1068                1,
1069                "{scope:?} must cover exactly one kind family, got {families:?}"
1070            );
1071        }
1072    }
1073
1074    #[test]
1075    fn severity_labels_are_stable_and_distinct() {
1076        assert_eq!(GateSeverity::Fail.label(), "Fail");
1077        assert_eq!(GateSeverity::Warn.label(), "Warn");
1078    }
1079}