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/// A `stream` source in a namespace that has not opted in to
269/// [`STREAM_EXEC_ANNOTATION`](consts::STREAM_EXEC_ANNOTATION). Shares
270/// `MoverPermitted` with [`PRIVILEGED_MOVER_GATE`] — both answer "may this mover
271/// run here at all?" — but carries its own reason, because the fix is a different
272/// annotation and conflating them would send an admin to the wrong one.
273pub const STREAM_EXEC_GATE: StructuralGate = StructuralGate {
274    applies_to: GateScope::SnapshotOrRestore,
275    condition: consts::MOVER_PERMITTED_CONDITION,
276    blocked_status: CONDITION_FALSE,
277    reason: consts::STREAM_EXEC_NOT_PERMITTED_REASON,
278    severity: GateSeverity::Fail,
279};
280
281/// The mover's credential `Secret` is not in the workload namespace. Parks at
282/// `phase: Pending` until the user creates it (or enables projection).
283pub const MISSING_CREDENTIALS_GATE: StructuralGate = StructuralGate {
284    applies_to: GateScope::SnapshotOrRestore,
285    condition: consts::CREDENTIALS_AVAILABLE_CONDITION,
286    blocked_status: CONDITION_FALSE,
287    reason: consts::MISSING_CREDENTIALS_REASON,
288    severity: GateSeverity::Fail,
289};
290
291/// Same condition as [`MISSING_CREDENTIALS_GATE`], different missing
292/// dependency: the workload-identity `ServiceAccount` the backend names.
293/// kopiur never creates it.
294pub const MISSING_SERVICE_ACCOUNT_GATE: StructuralGate = StructuralGate {
295    applies_to: GateScope::SnapshotOrRestore,
296    condition: consts::CREDENTIALS_AVAILABLE_CONDITION,
297    blocked_status: CONDITION_FALSE,
298    reason: consts::MISSING_SERVICE_ACCOUNT_REASON,
299    severity: GateSeverity::Fail,
300};
301
302/// Same condition as [`MISSING_CREDENTIALS_GATE`], a third missing
303/// dependency: the backend's `tls.caBundleRef` ConfigMap (the PEM CA bundle
304/// for a private-CA S3 endpoint). kopiur never creates it, and a ConfigMap
305/// that never appears never self-heals — the exact phase-invisible park shape
306/// of #359.
307pub const MISSING_CA_BUNDLE_GATE: StructuralGate = StructuralGate {
308    applies_to: GateScope::SnapshotOrRestore,
309    condition: consts::CREDENTIALS_AVAILABLE_CONDITION,
310    blocked_status: CONDITION_FALSE,
311    reason: consts::MISSING_CA_BUNDLE_REASON,
312    severity: GateSeverity::Fail,
313};
314
315/// Inverted polarity: the per-`Snapshot` hold the mass-deletion breaker
316/// applies. Released only by the `allow-mass-deletion` acknowledgement on the
317/// repository, so it is squarely "needs a human".
318pub const DELETION_HELD_GATE: StructuralGate = StructuralGate {
319    applies_to: GateScope::Snapshot,
320    condition: consts::DELETION_HELD_CONDITION,
321    blocked_status: CONDITION_TRUE,
322    reason: consts::MASS_DELETION_BREAKER_REASON,
323    severity: GateSeverity::Fail,
324};
325
326/// The repository-level view of the same breaker: a whole wave is held.
327pub const MASS_DELETION_HELD_GATE: StructuralGate = StructuralGate {
328    applies_to: GateScope::Repository,
329    condition: consts::MASS_DELETION_HELD_CONDITION,
330    blocked_status: CONDITION_TRUE,
331    reason: consts::MASS_DELETION_THRESHOLD_EXCEEDED_REASON,
332    severity: GateSeverity::Fail,
333};
334
335/// A backup refused because its repository is `mode: ReadOnly`.
336///
337/// Unlike the other rows this is NOT an invisible park: the writer
338/// (`snapshot/mod.rs`) sets `phase: Failed` in the same status patch, so a
339/// phase-based check already sees the object. It is registered for EXPLANATORY
340/// value — it turns "this backup failed" into "this backup was refused because
341/// the repository is read-only" without a second lookup. Hence WARN, not Fail:
342/// a read-only repository is a legitimate, deliberate configuration (a
343/// replication target, an archived repo served for restores only), so a
344/// green/red verdict must not hinge on it.
345pub const REPOSITORY_READ_ONLY_GATE: StructuralGate = StructuralGate {
346    applies_to: GateScope::Snapshot,
347    condition: consts::REPOSITORY_WRITABLE_CONDITION,
348    blocked_status: CONDITION_FALSE,
349    reason: consts::REPOSITORY_READ_ONLY_REASON,
350    severity: GateSeverity::Warn,
351};
352
353/// Version skew, one kind removed: a previous run of this schedule sits at a
354/// phase string this build cannot interpret, so it can never be observed to
355/// finish. Under the default `concurrencyPolicy: Forbid` the schedule stops
356/// firing FOREVER while looking perfectly healthy — the concurrency gate is
357/// doing exactly what it was asked to. The out-of-band change that clears it is
358/// finishing the operator rollout (or deleting the wedged `Snapshot`), which is
359/// squarely "needs a human", so: Fail.
360pub const SCHEDULE_BLOCKED_GATE: StructuralGate = StructuralGate {
361    applies_to: GateScope::SnapshotSchedule,
362    condition: consts::SCHEDULE_RUNNABLE_CONDITION,
363    blocked_status: CONDITION_FALSE,
364    reason: consts::BLOCKED_ON_UNREADABLE_RUN_REASON,
365    severity: GateSeverity::Fail,
366};
367
368/// A `SnapshotPolicy` referencing at least one repository that is not `Ready`.
369/// The policy reconciler keeps processing the READY subset (multi-repository
370/// fan-out, #368) but defers backups/retention/adoption/verification against
371/// the rest, and requeues until every repository recovers. Warn, not Fail: a
372/// repository deliberately taken down (a migration, a powered-off NAS target)
373/// is a plausible operator choice, the ready subset keeps working, and the
374/// per-child parks behind the same outage already carry their own reporting
375/// (`kopiur_snapshot_gated`, `KopiurRepositoryNotReady`) — so this gate must
376/// explain, not independently turn a diagnostic red.
377pub const POLICY_REPOSITORY_NOT_READY_GATE: StructuralGate = StructuralGate {
378    applies_to: GateScope::SnapshotPolicy,
379    condition: consts::REPOSITORIES_READY_CONDITION,
380    blocked_status: CONDITION_FALSE,
381    reason: consts::REPOSITORY_NOT_READY_REASON,
382    severity: GateSeverity::Warn,
383};
384
385/// A `SnapshotSchedule` whose members × repositories cross-product exceeds the
386/// fan-out cap: the fired slot was SKIPPED, and every future slot will keep
387/// skipping until the selector is narrowed or the policy's repository list
388/// shrunk — no backups run while everything else looks healthy, the exact
389/// silent-wedge shape of #359. The writer asserts BOTH polarities each fire
390/// pass, so the gate self-clears the moment a slot mints fully. Promoted into
391/// the registry by the #368 M10 gates/doctor checklist (previously a
392/// controller-internal condition doctor could not see).
393pub const SCHEDULE_FANOUT_CAPPED_GATE: StructuralGate = StructuralGate {
394    applies_to: GateScope::SnapshotSchedule,
395    condition: consts::SCHEDULE_FANOUT_CAPPED_CONDITION,
396    blocked_status: CONDITION_TRUE,
397    reason: consts::FANOUT_TOO_LARGE_REASON,
398    severity: GateSeverity::Fail,
399};
400
401/// A backup whose DIRECT source PVC (`spec.sources[].pvc`) does not exist at
402/// launch time. The `Snapshot` parks at `phase: Pending` on the slow
403/// structural cadence and, once the controller's missing-source deadline
404/// passes, flips terminally `Failed` — a PVC that never reappears never
405/// self-heals, and only recreating it (or repointing the policy's sources) can
406/// clear the block. Written on the Snapshot only for the direct-source case: a
407/// vanished operator-staged claim (copyMethod Snapshot/Clone) is a restage
408/// race and stays a plain transient retry, never this gate.
409pub const SOURCE_PVC_MISSING_GATE: StructuralGate = StructuralGate {
410    applies_to: GateScope::Snapshot,
411    condition: consts::SOURCE_PVC_AVAILABLE_CONDITION,
412    blocked_status: CONDITION_FALSE,
413    reason: consts::SOURCE_PVC_MISSING_REASON,
414    severity: GateSeverity::Fail,
415};
416
417/// A `Snapshot` or `Restore` whose `mover.inheritSecurityContextFrom` names a
418/// LIVE-pod source that resolved nothing — no pod matched the selector (the
419/// workload is scaled to zero, which is precisely when a quiesced backup is
420/// most useful), the named container is absent, or the matched pod sets neither
421/// a container nor a pod-level `securityContext` — and whose recipe pins no
422/// fallback `runAsUser` to proceed on (#464).
423///
424/// The run parks at `phase: Pending` and re-checks on the slow structural
425/// cadence. Holding is deliberate: a backup taken as the wrong UID is worse
426/// than a backup that did not run. Nothing in kopiur can scale a workload up,
427/// fix a label selector, or invent the identity the data expects, so this is
428/// squarely "needs a human" — hence Fail, and hence registered: before #464 the
429/// hold surfaced as a generic transient `MissingDependency` with no condition
430/// naming the selector that matched nothing.
431///
432/// Its own condition rather than a reason on the advisory
433/// `SecurityContextInherited` — see
434/// [`consts::SECURITY_CONTEXT_RESOLVED_CONDITION`] for why that distinction is
435/// load-bearing for `kubectl kopiur doctor`.
436pub const INHERIT_SOURCE_MISSING_GATE: StructuralGate = StructuralGate {
437    applies_to: GateScope::SnapshotOrRestore,
438    condition: consts::SECURITY_CONTEXT_RESOLVED_CONDITION,
439    blocked_status: CONDITION_FALSE,
440    reason: consts::INHERIT_SOURCE_MISSING_REASON,
441    severity: GateSeverity::Fail,
442};
443
444/// A `spec.seed` in migrate mode whose SOURCE repository is missing or not
445/// `Ready` (issue #380). The repository parks `Pending` with `Seeded=False`
446/// and re-checks; nothing in kopiur can bring the source up, so it is squarely
447/// "needs a human" — and the park is phase-INVISIBLE in the worst way: a
448/// brand-new repository sitting at `Pending` looks identical to one that is
449/// simply still bootstrapping, and it will sit there for as long as the source
450/// is down.
451///
452/// WARN rather than Fail, for two reasons that point the same way. The
453/// registry pins one severity per condition+scope
454/// (`gate_rows_are_unique_and_internally_consistent`), and the sibling
455/// [`SEEDING_GATE`] on this same condition *must* be Warn — a seed legitimately
456/// runs for hours, and a healthy copy must not turn a diagnostic red. And on
457/// its own merits this park is the same shape as
458/// [`POLICY_REPOSITORY_NOT_READY_GATE`]: a source repository that is still
459/// bootstrapping alongside this one (the ordinary DR bring-up) comes up by
460/// itself, and one deliberately taken down is a plausible operator choice. The
461/// block is reported either way, with the writer's message — which is what
462/// makes it worth registering.
463pub const SEED_SOURCE_NOT_READY_GATE: StructuralGate = StructuralGate {
464    applies_to: GateScope::Repository,
465    condition: consts::SEEDED_CONDITION,
466    blocked_status: CONDITION_FALSE,
467    reason: consts::WAITING_FOR_SEED_SOURCE_REASON,
468    severity: GateSeverity::Warn,
469};
470
471/// A migrate-mode `spec.seed` whose LOCAL backend and resolved SOURCE
472/// repository disagree on workload identity (issue #380).
473///
474/// The blob arm of this rule is refused at admission
475/// (`validate_replication_auth`), but a `seed.from.repository` reference hides
476/// the source's backend from a spec-only validator, so the controller
477/// re-applies the same rule once it has resolved the source and parks here
478/// instead. Without the park the CR is admitted, a Job is launched, and the
479/// failure surfaces as a bare cloud auth error from whichever side the pod's
480/// single ServiceAccount is not.
481///
482/// WARN for the same reason every other `Seeded` row is: the registry pins one
483/// severity per condition+scope, and the in-flight [`SEEDING_GATE`] must not
484/// turn a diagnostic red. Nothing is lost — the message names both
485/// ServiceAccounts and the two ways out, and the repository is not `Ready`,
486/// which a diagnostic fails on independently.
487pub const SEED_SOURCE_AUTH_CONFLICT_GATE: StructuralGate = StructuralGate {
488    applies_to: GateScope::Repository,
489    condition: consts::SEEDED_CONDITION,
490    blocked_status: CONDITION_FALSE,
491    reason: consts::SEED_SOURCE_AUTH_CONFLICT_REASON,
492    severity: GateSeverity::Warn,
493};
494
495/// A seeding bootstrap Job is in flight (issue #380): the repository is
496/// copying a whole repository across and is legitimately not `Ready` yet.
497///
498/// Registered for EXPLANATORY value, like [`REPOSITORY_READ_ONLY_GATE`]: a seed
499/// runs for hours by design (its Job deadline defaults to 24h), so a
500/// diagnostic must be able to say "seeding" rather than "stuck at Pending".
501/// Hence WARN — progress is not a fault, and this row must never on its own
502/// turn a diagnostic red.
503pub const SEEDING_GATE: StructuralGate = StructuralGate {
504    applies_to: GateScope::Repository,
505    condition: consts::SEEDED_CONDITION,
506    blocked_status: CONDITION_FALSE,
507    reason: consts::SEEDING_REASON,
508    severity: GateSeverity::Warn,
509};
510
511/// A `spec.seed` whose SOURCE answered but holds no kopia repository. Retried
512/// automatically every ~2 minutes, so it clears itself the moment the source
513/// exists — but until then the repository never becomes `Ready`, and the fix
514/// (repoint `spec.seed.from`) is squarely out-of-band.
515pub const SEED_SOURCE_NOT_FOUND_GATE: StructuralGate = StructuralGate {
516    applies_to: GateScope::Repository,
517    condition: consts::SEEDED_CONDITION,
518    blocked_status: CONDITION_FALSE,
519    reason: consts::SEED_SOURCE_NOT_FOUND_REASON,
520    severity: GateSeverity::Warn,
521};
522
523/// A `spec.seed` whose source is a real repository holding zero snapshots, with
524/// `allowEmptySource` at its `false` default. Blocking `Ready` is the point: a
525/// valid-but-empty mirror is nearly always mis-pointed.
526pub const SEED_SOURCE_EMPTY_GATE: StructuralGate = StructuralGate {
527    applies_to: GateScope::Repository,
528    condition: consts::SEEDED_CONDITION,
529    blocked_status: CONDITION_FALSE,
530    reason: consts::SEED_SOURCE_EMPTY_REASON,
531    severity: GateSeverity::Warn,
532};
533
534/// A migrate-mode seed whose post-verify found snapshots missing. The next
535/// attempt resumes the copy, so this converges on its own — but a seed that
536/// keeps being cut short needs a human to raise its deadline.
537pub const SEED_INCOMPLETE_GATE: StructuralGate = StructuralGate {
538    applies_to: GateScope::Repository,
539    condition: consts::SEEDED_CONDITION,
540    blocked_status: CONDITION_FALSE,
541    reason: consts::SEED_INCOMPLETE_REASON,
542    severity: GateSeverity::Warn,
543};
544
545/// A seed that was armed and left the repository holding ZERO snapshots. Like
546/// [`SEED_INCOMPLETE_GATE`], the next attempt resumes the copy.
547pub const SEED_LEFT_EMPTY_GATE: StructuralGate = StructuralGate {
548    applies_to: GateScope::Repository,
549    condition: consts::SEEDED_CONDITION,
550    blocked_status: CONDITION_FALSE,
551    reason: consts::SEED_LEFT_EMPTY_REASON,
552    severity: GateSeverity::Warn,
553};
554
555/// The mover-skew guard: the running mover image predates `spec.seed`, ignored
556/// it, and initialized an EMPTY repository. Genuinely terminal and genuinely
557/// human-actionable (upgrade the image, delete the empty repository and the
558/// terminal bootstrap Job) — the ONE row here that would earn `Fail` on its own
559/// merits. It is `Warn` because the registry pins one severity per
560/// condition+scope and its siblings must not turn a healthy in-progress seed
561/// red; nothing is lost, because a repository in this state is also not `Ready`,
562/// which `doctor`'s repository check fails on independently.
563pub const SEED_MOVER_TOO_OLD_GATE: StructuralGate = StructuralGate {
564    applies_to: GateScope::Repository,
565    condition: consts::SEEDED_CONDITION,
566    blocked_status: CONDITION_FALSE,
567    reason: consts::SEED_MOVER_TOO_OLD_REASON,
568    severity: GateSeverity::Warn,
569};
570
571/// A `Restore` whose repository referent does not exist (issue #393): the
572/// explicit `spec.repository` object, or the `source.fromPolicy`
573/// `SnapshotPolicy` the repository ref is derived from.
574///
575/// The readiness gate cannot verify a repository it cannot even look up, so the
576/// restore parks at `phase: Pending` and — critically — its
577/// `policy.waitTimeout` window is NOT opened (`status.waitStartedAt` stays
578/// unstamped) until the referent appears and its repository becomes `Ready`.
579/// Before #393 the gate fell through unverified here, so a slow-arriving
580/// referent silently spent the window; for a `fromPolicy` source, whose
581/// `onMissingSnapshot` defaults to `Continue`, a spent window means an EMPTY
582/// volume.
583///
584/// WARN, not Fail, for the reason [`POLICY_REPOSITORY_NOT_READY_GATE`] is:
585/// referents applied moments apart by GitOps are the ordinary case and resolve
586/// themselves within a requeue or two, so this must explain the park rather
587/// than independently turn a diagnostic red. What it must never be is INVISIBLE
588/// — a restore parked on a `SnapshotPolicy` that was never applied looks
589/// identical, by phase, to one that is simply still resolving.
590pub const RESTORE_REFERENT_MISSING_GATE: StructuralGate = StructuralGate {
591    applies_to: GateScope::SnapshotOrRestore,
592    condition: consts::RESTORE_REFERENT_AVAILABLE_CONDITION,
593    blocked_status: CONDITION_FALSE,
594    reason: consts::RESTORE_REFERENT_MISSING_REASON,
595    severity: GateSeverity::Warn,
596};
597
598/// Every human-actionable structural gate kopiur's reconcilers can park an
599/// object on.
600///
601/// Adding a gate to a reconciler means adding a row here; the CLI picks it up
602/// with no change, which is the whole point (#359). A condition that merely
603/// *reports* health (`IndexBlobHealth`, `BackendReachable`,
604/// `SecurityContextCompatible`) is NOT a gate — it blocks nothing — and a
605/// time-bounded wait (`SourceStaged`, `PreflightFailed`) is not one either,
606/// because it resolves itself into a terminal phase on its own.
607///
608/// Most rows are phase-INVISIBLE parks (the object sits at `phase: Pending`
609/// looking unremarkable), which is what makes the registry load-bearing. One
610/// row — [`REPOSITORY_READ_ONLY_GATE`] — is phase-visible and registered
611/// anyway, for the explanation it adds to an already-visible failure.
612///
613/// Each row is also a named `const` so a reconciler can write its condition
614/// FROM the row (`io::upsert_gate`) instead of restating the triple at the call
615/// site. A row nobody writes, or a writer nobody registered, is caught by the
616/// controller's `every_registered_gate_has_a_writer` drift test.
617pub const STRUCTURAL_GATES: &[StructuralGate] = &[
618    PRIVILEGED_MOVER_GATE,
619    STREAM_EXEC_GATE,
620    MISSING_CREDENTIALS_GATE,
621    MISSING_SERVICE_ACCOUNT_GATE,
622    MISSING_CA_BUNDLE_GATE,
623    DELETION_HELD_GATE,
624    MASS_DELETION_HELD_GATE,
625    REPOSITORY_READ_ONLY_GATE,
626    SCHEDULE_BLOCKED_GATE,
627    SCHEDULE_FANOUT_CAPPED_GATE,
628    POLICY_REPOSITORY_NOT_READY_GATE,
629    SOURCE_PVC_MISSING_GATE,
630    INHERIT_SOURCE_MISSING_GATE,
631    RESTORE_REFERENT_MISSING_GATE,
632    SEED_SOURCE_NOT_READY_GATE,
633    SEED_SOURCE_AUTH_CONFLICT_GATE,
634    SEEDING_GATE,
635    SEED_SOURCE_NOT_FOUND_GATE,
636    SEED_SOURCE_EMPTY_GATE,
637    SEED_INCOMPLETE_GATE,
638    SEED_LEFT_EMPTY_GATE,
639    SEED_MOVER_TOO_OLD_GATE,
640];
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use std::collections::HashSet;
646
647    #[test]
648    fn every_gate_row_is_well_formed() {
649        // Tripwire: a row added with an empty/typo'd string, or a polarity that
650        // is neither True nor False, would make the gate silently unmatchable
651        // on both sides of the contract.
652        assert!(
653            !STRUCTURAL_GATES.is_empty(),
654            "the registry is the shared gate list; an empty one means doctor checks nothing"
655        );
656        for g in STRUCTURAL_GATES {
657            assert!(
658                !g.condition.is_empty(),
659                "{g:?}: condition must be non-empty"
660            );
661            assert!(!g.reason.is_empty(), "{g:?}: reason must be non-empty");
662            assert!(
663                g.blocked_status == CONDITION_TRUE || g.blocked_status == CONDITION_FALSE,
664                "{g:?}: blocked_status must be a Kubernetes condition status"
665            );
666            // The row must match itself and reject the opposite polarity.
667            assert!(g.trips(g.condition, g.blocked_status), "{g:?}: self-match");
668            let opposite = if g.blocked_status == CONDITION_TRUE {
669                CONDITION_FALSE
670            } else {
671                CONDITION_TRUE
672            };
673            assert!(
674                !g.trips(g.condition, opposite),
675                "{g:?}: must not trip on the opposite polarity"
676            );
677            assert!(
678                !g.trips("SomeOtherCondition", g.blocked_status),
679                "{g:?}: must not trip on another condition type"
680            );
681        }
682    }
683
684    #[test]
685    fn a_live_condition_selects_exactly_one_row() {
686        // The whole registry must be a FUNCTION of a live condition: one
687        // (type, status, reason) triple in, at most one row out. Rows 2 and 3
688        // share condition+status+scope and differ only by reason, so a
689        // consumer filtering on `trips` alone would double-report a single
690        // wedged Snapshot. `matches` is the matcher that cannot.
691        for g in STRUCTURAL_GATES {
692            let hits: Vec<_> = STRUCTURAL_GATES
693                .iter()
694                .filter(|c| c.matches(g.condition, g.blocked_status, g.reason))
695                .collect();
696            assert_eq!(hits.len(), 1, "{g:?}: must select exactly one row");
697            assert_eq!(hits[0], g, "{g:?}: must select ITSELF");
698        }
699
700        // The concrete case the reviewer called out, spelled out end to end.
701        let creds_secret: Vec<_> = STRUCTURAL_GATES
702            .iter()
703            .filter(|g| {
704                g.matches(
705                    consts::CREDENTIALS_AVAILABLE_CONDITION,
706                    CONDITION_FALSE,
707                    consts::MISSING_CREDENTIALS_REASON,
708                )
709            })
710            .collect();
711        assert_eq!(creds_secret.len(), 1);
712        assert_eq!(creds_secret[0].reason, consts::MISSING_CREDENTIALS_REASON);
713
714        // ...and the coarse filter deliberately still matches BOTH rows, which
715        // is why it must never be used to identify a row.
716        let coarse = STRUCTURAL_GATES
717            .iter()
718            .filter(|g| g.trips(consts::CREDENTIALS_AVAILABLE_CONDITION, CONDITION_FALSE))
719            .count();
720        assert_eq!(coarse, 3, "`trips` is reason-agnostic by design");
721
722        // An unregistered reason for a gated condition: `matches` finds
723        // nothing, `trips` still flags it. That asymmetry is the reason
724        // `trips` is kept rather than removed.
725        assert!(!STRUCTURAL_GATES.iter().any(|g| g.matches(
726            consts::CREDENTIALS_AVAILABLE_CONDITION,
727            CONDITION_FALSE,
728            "SomeFutureReason"
729        )));
730        assert!(
731            STRUCTURAL_GATES
732                .iter()
733                .any(|g| g.trips(consts::CREDENTIALS_AVAILABLE_CONDITION, CONDITION_FALSE))
734        );
735    }
736
737    #[test]
738    fn blocked_is_true_mirrors_the_status_string() {
739        // The bool a registry-driven `upsert_condition` writer passes. Derived
740        // from the row, never hand-translated per call site.
741        for g in STRUCTURAL_GATES {
742            assert_eq!(
743                g.blocked_is_true(),
744                g.blocked_status == CONDITION_TRUE,
745                "{g:?}"
746            );
747        }
748        // Both polarities are actually exercised by the live registry, so this
749        // can never rot into a one-sided assertion.
750        assert!(STRUCTURAL_GATES.iter().any(|g| g.blocked_is_true()));
751        assert!(STRUCTURAL_GATES.iter().any(|g| !g.blocked_is_true()));
752    }
753
754    #[test]
755    fn gate_rows_are_unique_and_internally_consistent() {
756        // A copy-paste duplicate would double-report the same block. Two rows
757        // MAY share a condition+scope when the writer stamps different reasons
758        // for it (CredentialsAvailable: missing Secret vs missing SA) — but
759        // then they must agree on polarity and severity, or a diagnostic's
760        // verdict would depend on which row it happened to match first.
761        let mut seen: HashSet<(&str, &str, GateScope)> = HashSet::new();
762        for g in STRUCTURAL_GATES {
763            assert!(
764                seen.insert((g.condition, g.reason, g.applies_to)),
765                "{g:?}: duplicate condition+reason+scope row"
766            );
767        }
768        for a in STRUCTURAL_GATES {
769            for b in STRUCTURAL_GATES {
770                if a.condition == b.condition && a.applies_to == b.applies_to {
771                    assert_eq!(
772                        a.blocked_status, b.blocked_status,
773                        "{a:?} / {b:?}: same condition+scope, different polarity"
774                    );
775                    assert_eq!(
776                        a.severity, b.severity,
777                        "{a:?} / {b:?}: same condition+scope, different severity"
778                    );
779                }
780            }
781        }
782    }
783
784    #[test]
785    fn every_seeded_false_reason_this_build_writes_has_a_row() {
786        // The `Seeded` condition is the one gated condition with a WIDE reason
787        // set, and a partial registration is worse than none: a reason with no
788        // row still trips `StructuralGate::trips`, so `kubectl kopiur doctor`
789        // classifies it as UNREGISTERED and tells the operator "the operator is
790        // newer than the plugin — upgrade the plugin". That would be a false
791        // diagnosis handed to someone mid-disaster-recovery, whose actual
792        // problem is a mis-pointed seed source.
793        //
794        // So: every reason this build can stamp on `Seeded=False` must select
795        // exactly one row. The list is assembled from the three park/progress
796        // reasons plus the shared failure set, so a new reason added to
797        // `consts::SEED_FAILURE_REASONS` fails here until it is registered.
798        let mut reasons: Vec<&str> = vec![
799            consts::WAITING_FOR_SEED_SOURCE_REASON,
800            consts::SEED_SOURCE_AUTH_CONFLICT_REASON,
801            consts::SEEDING_REASON,
802        ];
803        reasons.extend_from_slice(consts::SEED_FAILURE_REASONS);
804        for reason in reasons {
805            let hits: Vec<_> = STRUCTURAL_GATES
806                .iter()
807                .filter(|g| g.matches(consts::SEEDED_CONDITION, CONDITION_FALSE, reason))
808                .collect();
809            assert_eq!(
810                hits.len(),
811                1,
812                "Seeded=False reason `{reason}` selects {} rows, not 1 — doctor would report it \
813                 as an unknown reason from a newer operator",
814                hits.len()
815            );
816            assert!(hits[0].applies_to.covers_repository());
817        }
818        // Guard against the reverse rot: every registered `Seeded` row must be
819        // one of those reasons, so a row nobody can write cannot linger.
820        for g in STRUCTURAL_GATES
821            .iter()
822            .filter(|g| g.condition == consts::SEEDED_CONDITION)
823        {
824            assert!(
825                g.reason == consts::WAITING_FOR_SEED_SOURCE_REASON
826                    || g.reason == consts::SEED_SOURCE_AUTH_CONFLICT_REASON
827                    || g.reason == consts::SEEDING_REASON
828                    || consts::SEED_FAILURE_REASONS.contains(&g.reason),
829                "{g:?}: registered but not a reason this build writes"
830            );
831        }
832    }
833
834    /// Every `Seeded=False` failure reason must be EXPLAINED in the docs, not
835    /// just registered (#380).
836    ///
837    /// The registry above guarantees `kubectl kopiur doctor` recognizes a
838    /// reason; it says nothing about whether a human who reads it can find out
839    /// what to do. These reasons surface in exactly one situation — a disaster
840    /// recovery that is not going well — so an unexplained one is worse here
841    /// than almost anywhere else in kopiur. The two pages are the two places
842    /// someone actually looks: the troubleshooting table (symptom → fix) and the
843    /// DR scenario (the reason table plus the retry/terminal contract).
844    ///
845    /// A reason added to `consts::SEED_FAILURE_REASONS` — or to the
846    /// terminal-until-edited park set the body extends it with — fails here
847    /// until both pages mention it by name.
848    #[test]
849    fn every_seed_failure_reason_is_documented_on_both_user_facing_pages() {
850        // CARGO_MANIFEST_DIR = crates/api; the docs tree is at the repo root.
851        // Same relative-path approach `crates/api/tests/examples_match_crd_shapes.rs`
852        // uses to reach `deploy/examples`.
853        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
854        for page in [
855            "docs/src/content/docs/troubleshooting.mdx",
856            "docs/src/content/docs/scenarios/dr-with-replicated-repository.mdx",
857        ] {
858            let path = root.join(page);
859            let text = std::fs::read_to_string(&path)
860                .unwrap_or_else(|e| panic!("{page} must exist and be readable: {e}"));
861            // The mover's failure classes, PLUS the one park reason that is
862            // equally terminal-until-edited: a migrate-mode workload-identity
863            // conflict is re-checked forever and never clears by itself, so
864            // doctor prints it at someone who needs the same lookup. The two
865            // progress/park reasons that DO clear on their own
866            // (`Seeding`, `WaitingForSeedSource`) are deliberately not required
867            // here — they describe motion, not a thing to look up.
868            let documented: Vec<&str> = consts::SEED_FAILURE_REASONS
869                .iter()
870                .copied()
871                .chain(std::iter::once(consts::SEED_SOURCE_AUTH_CONFLICT_REASON))
872                .collect();
873            for reason in documented {
874                assert!(
875                    text.contains(reason),
876                    "{page} never mentions the `Seeded=False` reason `{reason}` — doctor will \
877                     print it at a user who then has nowhere to look it up"
878                );
879            }
880        }
881    }
882
883    #[test]
884    fn expected_gates_are_registered_with_expected_scope_and_severity() {
885        // Pins the exact contract M3's doctor consumes. A row removed, or its
886        // severity/scope quietly changed, fails here rather than silently
887        // changing what a cluster diagnostic reports.
888        let expected: &[(&str, &str, &str, GateScope, GateSeverity)] = &[
889            (
890                consts::MOVER_PERMITTED_CONDITION,
891                CONDITION_FALSE,
892                consts::PRIVILEGED_MOVER_NOT_PERMITTED_REASON,
893                GateScope::SnapshotOrRestore,
894                GateSeverity::Fail,
895            ),
896            (
897                consts::MOVER_PERMITTED_CONDITION,
898                CONDITION_FALSE,
899                consts::STREAM_EXEC_NOT_PERMITTED_REASON,
900                GateScope::SnapshotOrRestore,
901                GateSeverity::Fail,
902            ),
903            (
904                consts::CREDENTIALS_AVAILABLE_CONDITION,
905                CONDITION_FALSE,
906                consts::MISSING_CREDENTIALS_REASON,
907                GateScope::SnapshotOrRestore,
908                GateSeverity::Fail,
909            ),
910            (
911                consts::CREDENTIALS_AVAILABLE_CONDITION,
912                CONDITION_FALSE,
913                consts::MISSING_SERVICE_ACCOUNT_REASON,
914                GateScope::SnapshotOrRestore,
915                GateSeverity::Fail,
916            ),
917            (
918                consts::CREDENTIALS_AVAILABLE_CONDITION,
919                CONDITION_FALSE,
920                consts::MISSING_CA_BUNDLE_REASON,
921                GateScope::SnapshotOrRestore,
922                GateSeverity::Fail,
923            ),
924            (
925                consts::DELETION_HELD_CONDITION,
926                CONDITION_TRUE,
927                consts::MASS_DELETION_BREAKER_REASON,
928                GateScope::Snapshot,
929                GateSeverity::Fail,
930            ),
931            (
932                consts::MASS_DELETION_HELD_CONDITION,
933                CONDITION_TRUE,
934                consts::MASS_DELETION_THRESHOLD_EXCEEDED_REASON,
935                GateScope::Repository,
936                GateSeverity::Fail,
937            ),
938            (
939                consts::REPOSITORY_WRITABLE_CONDITION,
940                CONDITION_FALSE,
941                consts::REPOSITORY_READ_ONLY_REASON,
942                GateScope::Snapshot,
943                GateSeverity::Warn,
944            ),
945            (
946                consts::SCHEDULE_RUNNABLE_CONDITION,
947                CONDITION_FALSE,
948                consts::BLOCKED_ON_UNREADABLE_RUN_REASON,
949                GateScope::SnapshotSchedule,
950                GateSeverity::Fail,
951            ),
952            (
953                consts::SCHEDULE_FANOUT_CAPPED_CONDITION,
954                CONDITION_TRUE,
955                consts::FANOUT_TOO_LARGE_REASON,
956                GateScope::SnapshotSchedule,
957                GateSeverity::Fail,
958            ),
959            (
960                consts::REPOSITORIES_READY_CONDITION,
961                CONDITION_FALSE,
962                consts::REPOSITORY_NOT_READY_REASON,
963                GateScope::SnapshotPolicy,
964                GateSeverity::Warn,
965            ),
966            (
967                consts::SOURCE_PVC_AVAILABLE_CONDITION,
968                CONDITION_FALSE,
969                consts::SOURCE_PVC_MISSING_REASON,
970                GateScope::Snapshot,
971                GateSeverity::Fail,
972            ),
973            (
974                consts::SECURITY_CONTEXT_RESOLVED_CONDITION,
975                CONDITION_FALSE,
976                consts::INHERIT_SOURCE_MISSING_REASON,
977                GateScope::SnapshotOrRestore,
978                GateSeverity::Fail,
979            ),
980            (
981                consts::RESTORE_REFERENT_AVAILABLE_CONDITION,
982                CONDITION_FALSE,
983                consts::RESTORE_REFERENT_MISSING_REASON,
984                GateScope::SnapshotOrRestore,
985                GateSeverity::Warn,
986            ),
987            (
988                consts::SEEDED_CONDITION,
989                CONDITION_FALSE,
990                consts::WAITING_FOR_SEED_SOURCE_REASON,
991                GateScope::Repository,
992                GateSeverity::Warn,
993            ),
994            (
995                consts::SEEDED_CONDITION,
996                CONDITION_FALSE,
997                consts::SEED_SOURCE_AUTH_CONFLICT_REASON,
998                GateScope::Repository,
999                GateSeverity::Warn,
1000            ),
1001            (
1002                consts::SEEDED_CONDITION,
1003                CONDITION_FALSE,
1004                consts::SEEDING_REASON,
1005                GateScope::Repository,
1006                GateSeverity::Warn,
1007            ),
1008            (
1009                consts::SEEDED_CONDITION,
1010                CONDITION_FALSE,
1011                consts::SEED_SOURCE_NOT_FOUND_REASON,
1012                GateScope::Repository,
1013                GateSeverity::Warn,
1014            ),
1015            (
1016                consts::SEEDED_CONDITION,
1017                CONDITION_FALSE,
1018                consts::SEED_SOURCE_EMPTY_REASON,
1019                GateScope::Repository,
1020                GateSeverity::Warn,
1021            ),
1022            (
1023                consts::SEEDED_CONDITION,
1024                CONDITION_FALSE,
1025                consts::SEED_INCOMPLETE_REASON,
1026                GateScope::Repository,
1027                GateSeverity::Warn,
1028            ),
1029            (
1030                consts::SEEDED_CONDITION,
1031                CONDITION_FALSE,
1032                consts::SEED_LEFT_EMPTY_REASON,
1033                GateScope::Repository,
1034                GateSeverity::Warn,
1035            ),
1036            (
1037                consts::SEEDED_CONDITION,
1038                CONDITION_FALSE,
1039                consts::SEED_MOVER_TOO_OLD_REASON,
1040                GateScope::Repository,
1041                GateSeverity::Warn,
1042            ),
1043        ];
1044        assert_eq!(
1045            STRUCTURAL_GATES.len(),
1046            expected.len(),
1047            "a gate row was added or removed — update the pinned expectations \
1048             (and M3's doctor coverage) deliberately"
1049        );
1050        for (condition, status, reason, scope, severity) in expected {
1051            let row = STRUCTURAL_GATES
1052                .iter()
1053                .find(|g| g.condition == *condition && g.reason == *reason)
1054                .unwrap_or_else(|| panic!("{condition}/{reason} must be registered"));
1055            assert_eq!(row.blocked_status, *status, "{condition}/{reason} polarity");
1056            assert_eq!(row.applies_to, *scope, "{condition}/{reason} scope");
1057            assert_eq!(row.severity, *severity, "{condition}/{reason} severity");
1058        }
1059    }
1060
1061    /// The #393 park must be visible to `doctor` on a `Restore` — and must not
1062    /// make any OTHER condition read as a gate.
1063    ///
1064    /// The registry's coarse [`StructuralGate::trips`] filter is
1065    /// reason-agnostic, so a row registered on a condition other reconcilers
1066    /// also write (`Ready`, `Resolved`, …) would make every unrelated reason on
1067    /// that condition report as "a gate from a newer operator". This row's
1068    /// condition is therefore written by exactly one gate and one reason.
1069    #[test]
1070    fn the_restore_referent_gate_is_restore_scoped_and_owns_its_condition() {
1071        let row = STRUCTURAL_GATES
1072            .iter()
1073            .find(|g| g.reason == consts::RESTORE_REFERENT_MISSING_REASON)
1074            .expect("the #393 referent-missing park must be registered");
1075        // Reachable from a Restore (the only scope whose `covers_restore` is true).
1076        assert!(row.applies_to.covers_restore());
1077        assert_eq!(row.applies_to, GateScope::SnapshotOrRestore);
1078        // Warn: referents applied moments apart by GitOps resolve themselves.
1079        assert_eq!(row.severity, GateSeverity::Warn);
1080        assert_eq!(row.condition, consts::RESTORE_REFERENT_AVAILABLE_CONDITION);
1081        assert!(row.trips(consts::RESTORE_REFERENT_AVAILABLE_CONDITION, "False"));
1082        assert!(!row.trips(consts::RESTORE_REFERENT_AVAILABLE_CONDITION, "True"));
1083        // Exactly one row owns this condition, so the coarse filter can only
1084        // fire for the one reason this build writes on it.
1085        assert_eq!(
1086            STRUCTURAL_GATES
1087                .iter()
1088                .filter(|g| g.condition == consts::RESTORE_REFERENT_AVAILABLE_CONDITION)
1089                .count(),
1090            1
1091        );
1092        // ...and it is NOT the `Ready` condition, which every Restore writes with
1093        // many reasons — registering there would misreport all of them.
1094        assert_ne!(row.condition, consts::READY_CONDITION);
1095        assert!(
1096            !STRUCTURAL_GATES
1097                .iter()
1098                .any(|g| g.condition == consts::READY_CONDITION)
1099        );
1100    }
1101
1102    #[test]
1103    fn every_scope_classifier_is_consistent() {
1104        // Each scope covers exactly one of the four kind families a consumer
1105        // reads from separate lists (work CRs, repositories, schedules,
1106        // policies), so a diagnostic can dispatch on scope without
1107        // double-listing a gate.
1108        for scope in [
1109            GateScope::SnapshotOrRestore,
1110            GateScope::Snapshot,
1111            GateScope::Repository,
1112            GateScope::SnapshotSchedule,
1113            GateScope::SnapshotPolicy,
1114        ] {
1115            let work = scope.covers_snapshot() || scope.covers_restore();
1116            let families = [
1117                work,
1118                scope.covers_repository(),
1119                scope.covers_snapshot_schedule(),
1120                scope.covers_snapshot_policy(),
1121            ];
1122            assert_eq!(
1123                families.iter().filter(|f| **f).count(),
1124                1,
1125                "{scope:?} must cover exactly one kind family, got {families:?}"
1126            );
1127        }
1128    }
1129
1130    #[test]
1131    fn severity_labels_are_stable_and_distinct() {
1132        assert_eq!(GateSeverity::Fail.label(), "Fail");
1133        assert_eq!(GateSeverity::Warn.label(), "Warn");
1134    }
1135
1136    #[test]
1137    fn the_inherit_hold_is_registered_and_the_advisory_report_is_not() {
1138        // #464. The HOLD — a live-pod `inheritSecurityContextFrom` that resolved
1139        // nothing, with no pinned fallback — is a real park a human must clear,
1140        // so it must be visible to `kubectl kopiur doctor` on both work kinds.
1141        let hits: Vec<_> = STRUCTURAL_GATES
1142            .iter()
1143            .filter(|g| {
1144                g.matches(
1145                    consts::SECURITY_CONTEXT_RESOLVED_CONDITION,
1146                    CONDITION_FALSE,
1147                    consts::INHERIT_SOURCE_MISSING_REASON,
1148                )
1149            })
1150            .collect();
1151        assert_eq!(hits.len(), 1, "the inherit hold selects exactly one row");
1152        assert_eq!(hits[0].severity, GateSeverity::Fail);
1153        assert!(hits[0].applies_to.covers_snapshot());
1154        assert!(hits[0].applies_to.covers_restore());
1155
1156        // …and `SecurityContextInherited` must stay OUT of the registry. It is
1157        // written with five advisory reasons (InheritFallback,
1158        // InheritPinnedNoUid, InheritOverridden, MissingRecordedIdentity,
1159        // RecordedPinnedNoUid), all on runs that PROCEEDED. Registering any one
1160        // of them would make `doctor`'s `first_gate` report the other four as
1161        // "blocked with a reason this plugin does not know (the operator is
1162        // newer than the plugin)" — a false diagnosis handed to somebody
1163        // mid-incident. The string is spelled out because the const is
1164        // deliberately controller-side, not in this crate.
1165        assert!(
1166            !STRUCTURAL_GATES
1167                .iter()
1168                .any(|g| g.condition == "SecurityContextInherited"),
1169            "SecurityContextInherited is advisory; registering one of its reasons would make \
1170             doctor false-diagnose the rest as an operator/plugin skew (#464)"
1171        );
1172    }
1173}