Skip to main content

runner_manager_domain/
policy.rs

1// owner: b1-domain-core
2
3//! Policies: routing identity, mode, lifecycle state, and ownership.
4//!
5//! Three things live here, and after D4 they are the whole of the product's
6//! routing identity and configuration safety:
7//!
8//! 1. [`RoutingLabels`] — the routing token that replaced the scale-set name,
9//!    with derivation and `runs-on` matching.
10//! 2. [`PolicyMode`] — D19's monitor-only/autoscale split, expressed so that the
11//!    illegal combinations cannot be constructed rather than being rejected on
12//!    the way in.
13//! 3. [`PolicyState`] — the lifecycle state machine, which rejects every
14//!    transition outside the diagram in `04-subsystem-contracts.md`.
15//!
16//! **There is no reservation here, and none may be added.** `AcquireJobs` has no
17//! REST equivalent (`01-current-architecture.md`, edge case 6), so demand is
18//! advisory and a surplus runner is an accepted, bounded cost. The bounding
19//! controls are the host-scoped default label derived below, plus the two
20//! capacity ceilings in [`crate::capacity`]. A lease, claim, or local
21//! reservation table added here would not fix the surplus case; it would only
22//! hide it from the tests that measure it (`h1` scenario 8).
23
24use std::collections::BTreeSet;
25use std::fmt;
26use std::num::{NonZeroU16, NonZeroUsize};
27
28use serde::{Deserialize, Serialize};
29
30use crate::execution::{ExecutionError, ExecutionPolicy};
31use crate::model::{
32    Arch, CachePolicy, HostId, HostLabel, Label, NonEmpty, Os, PolicyId, ProfileName, ScaleTarget,
33    ValidationError,
34};
35use crate::path::LocalAbsolutePath;
36use crate::workspace::{WorkspaceError, WorkspaceKind, WorkspacePolicy};
37
38// ---------------------------------------------------------------------------
39// Errors
40// ---------------------------------------------------------------------------
41
42#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
43pub enum PolicyError {
44    #[error(transparent)]
45    Invalid(#[from] ValidationError),
46
47    /// A workspace configuration this policy's target cannot hold, or a stored
48    /// pair of workspace columns this crate cannot have written (D4, D7).
49    #[error(transparent)]
50    Workspace(#[from] WorkspaceError),
51
52    #[error(transparent)]
53    Execution(#[from] ExecutionError),
54
55    #[error(
56        "an Autoscale policy requires routing labels; a policy with none is a \
57         MonitorOnly policy (D19)"
58    )]
59    AutoscaleWithoutRoutingLabels,
60
61    #[error(
62        "an Autoscale policy requires max_capacity; without a ceiling it could \
63         oversubscribe the host (D7, D19)"
64    )]
65    AutoscaleWithoutMaxCapacity,
66
67    // There was a `MonitorOnlyWithRoutingLabels` variant here, meaning "this
68    // *stored row* has an illegal shape". It is deleted rather than kept for
69    // `b2`, because nothing can construct it and nothing can construct it later
70    // either: `PolicyMode::from_persisted` matches exhaustively across four arms
71    // and never returns it, and `PersistedPolicy` -- the only shape `b2` loads
72    // through -- has no `mode` field, so no schema reachable from here can
73    // express "monitor-only *with* labels" in the first place. A row carrying
74    // routing labels is autoscale-shaped by definition, and
75    // labels-without-`max_capacity` is caught as `AutoscaleWithoutMaxCapacity`
76    // first. Keeping it would have had `f2` write a match arm and a user-facing
77    // message for a condition that cannot occur and that no test can cover.
78    #[error(
79        "a MonitorOnly policy must not carry a non-zero min_capacity ({min}); it \
80         never starts a runner (D19)"
81    )]
82    MonitorOnlyWithMinCapacity { min: u16 },
83
84    #[error("min_capacity ({min}) must not exceed max_capacity ({max})")]
85    InvertedCapacityRange { min: u16, max: u16 },
86
87    #[error("{to} is not a legal transition from {from}")]
88    IllegalTransition { from: PolicyState, to: PolicyState },
89
90    #[error(
91        "only a MonitorOnly policy can be promoted to Autoscale; this one is already Autoscale"
92    )]
93    AlreadyAutoscale,
94
95    #[error(
96        "this operation needs an Autoscale policy; a MonitorOnly policy has no \
97         capacity and no routing labels to change (D19)"
98    )]
99    NotAutoscale,
100
101    #[error("the host label {label} is the routing identity of this policy and cannot be removed")]
102    HostLabelNotRemovable { label: Label },
103
104    #[error("a named profile's stored selector is not its derived selector")]
105    InvalidProfileSelector,
106
107    #[error("the native default profile must retain its compatibility selector")]
108    NamedConstructorForDefault,
109
110    #[error("a named profile must derive its selector when promoted to Autoscale")]
111    NamedProfileNeedsDerivedSelector,
112
113    #[error(
114        "{label} is a shared pool label for another operating system; this host's pool labels \
115         are {expected} and rm-pool-any (labels starting `rm-pool-` are reserved for the \
116         coordinator's pools)"
117    )]
118    PoolLabelMismatch { label: Label, expected: Label },
119
120    #[error(
121        "{label} names no label to exclude: an exclusion is `not-` and one ordinary label, \
122         as in `not-wsl`"
123    )]
124    InvalidExclusion { label: Label },
125
126    #[error(
127        "{label} contradicts {carried}, which this profile carries: a profile cannot both \
128         carry a label and exclude it"
129    )]
130    ContradictoryExclusion { label: Label, carried: Label },
131}
132
133// ---------------------------------------------------------------------------
134// Shared pool labels (coordinator stage 3, D10)
135// ---------------------------------------------------------------------------
136
137/// The reserved prefix of the coordinator's shared pool labels.
138///
139/// A workflow that writes `runs-on: rm-pool-linux` lets the optional
140/// coordinator choose which host starts its runner. Four exist: one per
141/// operating system ([`pool_label`]) and one for any operating system
142/// ([`ANY_POOL_LABEL`]); the prefix is reserved so a label a host cannot
143/// honour (another OS's pool, or a pool class no build knows) is refused
144/// rather than silently advertised.
145pub const POOL_LABEL_PREFIX: &str = "rm-pool-";
146
147/// The OS-agnostic pool (0.4.52): `runs-on: rm-pool-any` lets the
148/// coordinator pick a host of any operating system, so a job whose steps run
149/// anywhere goes to whichever host its statistics say is best. Any host may
150/// carry it, beside or instead of its own OS pool label.
151pub const ANY_POOL_LABEL: &str = "rm-pool-any";
152
153/// [`ANY_POOL_LABEL`] as a label.
154#[must_use]
155pub fn any_pool_label() -> Label {
156    Label::new(ANY_POOL_LABEL).expect("`rm-pool-any` is a valid label")
157}
158
159/// Whether `label` is [`ANY_POOL_LABEL`], case-insensitively.
160#[must_use]
161pub fn is_any_pool_label(label: &str) -> bool {
162    label.eq_ignore_ascii_case(ANY_POOL_LABEL)
163}
164
165/// Whether `label` is in the reserved pool namespace, compared the way GitHub
166/// compares labels: case-insensitively. Only for refusing labels; what
167/// makes a job or a profile a pool one is [`is_pool_label`].
168#[must_use]
169pub fn in_pool_namespace(label: &str) -> bool {
170    label
171        .get(..POOL_LABEL_PREFIX.len())
172        .is_some_and(|prefix| prefix.eq_ignore_ascii_case(POOL_LABEL_PREFIX))
173}
174
175/// Whether `label` is one of the four shared pool labels, case-insensitively.
176/// An exact match: a host label derived from `--host-label pool`
177/// (`rm-pool-linux-x64`) is that host's own selector, not a pool.
178#[must_use]
179pub fn is_pool_label(label: &str) -> bool {
180    is_any_pool_label(label)
181        || Os::ALL
182            .iter()
183            .any(|os| pool_label(*os).as_str().eq_ignore_ascii_case(label))
184}
185
186/// The one pool label a host running `os` may carry: `rm-pool-windows`,
187/// `rm-pool-linux` or `rm-pool-macos`.
188#[must_use]
189pub fn pool_label(os: Os) -> Label {
190    Label::new(format!("{POOL_LABEL_PREFIX}{}", os.pool_token()))
191        .expect("the pool prefix and a fixed OS token form a valid label")
192}
193
194/// Whether a host running `os` may carry pool label `label`: its own OS
195/// pool label or [`ANY_POOL_LABEL`].
196#[must_use]
197pub fn may_carry_pool(label: &Label, os: Os) -> bool {
198    *label == pool_label(os) || is_any_pool_label(label.as_str())
199}
200
201/// Refuses a label in the pool namespace that is neither `os`'s own pool label
202/// nor [`ANY_POOL_LABEL`].
203///
204/// # Errors
205/// [`PolicyError::PoolLabelMismatch`].
206pub fn check_pool_label(label: &Label, os: Os) -> Result<(), PolicyError> {
207    let expected = pool_label(os);
208    if in_pool_namespace(label.as_str()) && !may_carry_pool(label, os) {
209        return Err(PolicyError::PoolLabelMismatch {
210            label: label.clone(),
211            expected,
212        });
213    }
214    Ok(())
215}
216
217// ---------------------------------------------------------------------------
218// Label exclusions (`not-<label>`)
219// ---------------------------------------------------------------------------
220
221/// The prefix of an exclusion label.
222///
223/// GitHub matches `runs-on` positively only: a runner takes a job when its
224/// labels include every label the job names. A job that must avoid machines
225/// carrying `wsl` therefore names `not-wsl`, and this product treats it as a
226/// constraint rather than as a label to have: a profile serves it only when it
227/// does **not** carry `wsl`, and the runner it registers for it carries
228/// `not-wsl`, so GitHub hands it the job. A profile that carries `wsl` never
229/// satisfies `not-wsl`, even when `not-wsl` is among its own labels.
230pub const EXCLUSION_PREFIX: &str = "not-";
231
232/// What an exclusion label excludes. See [`excluded_label`].
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum Exclusion {
235    /// `not-wsl` excludes `wsl`.
236    Of(Label),
237    /// `not-` alone, or an exclusion of another exclusion (`not-not-wsl`),
238    /// which would read as a double negative nobody means.
239    Invalid,
240}
241
242/// What `label` excludes when it is an exclusion: `None` for an ordinary
243/// label.
244#[must_use]
245pub fn excluded_label(label: &Label) -> Option<Exclusion> {
246    let rest = label.as_str().strip_prefix(EXCLUSION_PREFIX)?;
247    Some(match Label::new(rest) {
248        // `not- wsl` is not `not-wsl`: the runner would register a label the
249        // job does not name.
250        Ok(excluded)
251            if excluded.as_str() == rest && !excluded.as_str().starts_with(EXCLUSION_PREFIX) =>
252        {
253            Exclusion::Of(excluded)
254        }
255        _ => Exclusion::Invalid,
256    })
257}
258
259/// The exclusion label for `label`: `wsl` gives `not-wsl`. `None` when the
260/// result would be longer than GitHub allows.
261#[must_use]
262pub fn exclusion_of(label: &Label) -> Option<Label> {
263    Label::new(format!("{EXCLUSION_PREFIX}{label}")).ok()
264}
265
266/// Whether a runner offering the labels `offers` says it has can take a job
267/// requiring `required`: an ordinary label must be offered, and an exclusion
268/// `not-X` holds when `X` is not.
269///
270/// The one rule both the host ([`RoutingLabels::matches`]) and the
271/// coordinator apply.
272#[must_use]
273pub fn label_satisfied(offers: impl Fn(&Label) -> bool, required: &Label) -> bool {
274    match excluded_label(required) {
275        Some(Exclusion::Of(excluded)) => !offers(&excluded),
276        Some(Exclusion::Invalid) => false,
277        None => offers(required),
278    }
279}
280
281/// The labels a job requiring `required` excludes: `wsl` for `not-wsl`.
282pub fn exclusions_in(required: &[Label]) -> impl Iterator<Item = Label> + '_ {
283    required
284        .iter()
285        .filter_map(|label| match excluded_label(label) {
286            Some(Exclusion::Of(excluded)) => Some(excluded),
287            _ => None,
288        })
289}
290
291/// Refuses `label` as a profile's label when it contradicts one the profile
292/// already carries: `not-wsl` beside `wsl`, either way round, or an exclusion
293/// that names nothing usable.
294///
295/// # Errors
296/// [`PolicyError::InvalidExclusion`] and [`PolicyError::ContradictoryExclusion`].
297pub fn check_exclusion_label(labels: &RoutingLabels, label: &Label) -> Result<(), PolicyError> {
298    match excluded_label(label) {
299        Some(Exclusion::Invalid) => Err(PolicyError::InvalidExclusion {
300            label: label.clone(),
301        }),
302        Some(Exclusion::Of(excluded)) if labels.contains(&excluded) => {
303            Err(PolicyError::ContradictoryExclusion {
304                label: label.clone(),
305                carried: excluded,
306            })
307        }
308        Some(Exclusion::Of(_)) => Ok(()),
309        None => match exclusion_of(label).filter(|exclusion| labels.contains(exclusion)) {
310            Some(exclusion) => Err(PolicyError::ContradictoryExclusion {
311                label: label.clone(),
312                carried: exclusion,
313            }),
314            None => Ok(()),
315        },
316    }
317}
318
319/// The label a runner running inside WSL carries without being configured to:
320/// `runs-on: [self-hosted, linux, not-wsl]` keeps a job off WSL machines.
321pub const WSL_LABEL: &str = "wsl";
322
323/// What a host is, as labels nobody configures: derived at runtime and never
324/// stored, so every profile of the host carries them.
325///
326/// Today that is whether the host runs inside WSL: such a host's runners carry
327/// [`WSL_LABEL`], every other host's carry `not-wsl`. Carrying `not-wsl`
328/// outright, rather than only for a job that asks for it, means a runner
329/// already waiting can take such a job, and a coordinator older than
330/// exclusions places it correctly by plain label matching.
331#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
332pub struct HostTraits {
333    /// `None`: unknown, and no label is derived (tests, and builds that never
334    /// ask).
335    wsl: Option<bool>,
336}
337
338impl HostTraits {
339    /// A host known to run inside WSL, or known not to.
340    #[must_use]
341    pub const fn wsl(inside: bool) -> Self {
342        Self { wsl: Some(inside) }
343    }
344
345    /// The labels these traits give every profile of the host.
346    #[must_use]
347    pub fn labels(&self) -> Vec<Label> {
348        let wsl = Label::new(WSL_LABEL).expect("`wsl` is a valid label");
349        match self.wsl {
350            None => Vec::new(),
351            Some(true) => vec![wsl],
352            Some(false) => exclusion_of(&wsl).into_iter().collect(),
353        }
354    }
355}
356
357// ---------------------------------------------------------------------------
358// Routing labels
359// ---------------------------------------------------------------------------
360
361/// A policy's routing label set: the token `runs-on` targets.
362///
363/// `04-subsystem-contracts.md` types this as `Option<NonEmpty<Label>>`. This
364/// type is the `NonEmpty<Label>` half, and it is deliberately *stronger* than a
365/// non-empty vector, because the contract has two separate requirements that a
366/// bare `NonEmpty` only covers one of:
367///
368/// * **Non-empty.** Guaranteed by [`RoutingLabels::host_label`] always existing.
369///   `generate-jitconfig` rejects `labels: []` with `422`
370///   (`docs/spikes/d18-org-jit-verification.md`, Point 3), so an empty set is
371///   not a case to handle downstream.
372/// * **The derived host label may not be dropped.** `b1`: "Optional descriptive
373///   labels may be added to the set; the derived host label may not be dropped
374///   from it." A `Vec<Label>` cannot express that. Here the host label is a
375///   separate field with no removal path, so dropping it is not a rule anyone
376///   has to remember.
377///
378/// The `Option` half of `Option<NonEmpty<Label>>` is carried by [`PolicyMode`]:
379/// `MonitorOnly` has no routing labels because the variant has no field for
380/// them, not because the field happens to be `None`.
381///
382/// **Why the default is host-scoped.** With no `AcquireJobs`, nothing reserves a
383/// queued job for one host. Two hosts whose policies carry the same label will
384/// both start a runner for the same job, and the loser pays a capacity slot and
385/// a cold start (`01-current-architecture.md`, edge case 6). The host identity
386/// baked into the derived label is the only control that prevents that by
387/// default — the capacity ceilings only bound it once it happens.
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[serde(from = "RoutingLabelsRepr", into = "RoutingLabelsRepr")]
390pub struct RoutingLabels {
391    host_label: Label,
392    additional: BTreeSet<Label>,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
396struct RoutingLabelsRepr {
397    host_label: Label,
398    #[serde(default)]
399    additional: BTreeSet<Label>,
400}
401
402impl From<RoutingLabelsRepr> for RoutingLabels {
403    fn from(repr: RoutingLabelsRepr) -> Self {
404        // Normalising on the way in rather than erroring: a stored set that
405        // happens to repeat the host label among the optional labels is not
406        // corrupt, it is redundant, and silently de-duplicating it keeps
407        // `count()` honest.
408        Self::from_parts(repr.host_label, repr.additional)
409    }
410}
411
412impl From<RoutingLabels> for RoutingLabelsRepr {
413    fn from(value: RoutingLabels) -> Self {
414        Self {
415            host_label: value.host_label,
416            additional: value.additional,
417        }
418    }
419}
420
421impl RoutingLabels {
422    /// The product prefix in a derived label.
423    pub const PREFIX: &'static str = "rm";
424
425    /// Derive the host-scoped default label, `rm-<host>-<os>-<arch>`.
426    ///
427    /// `02-target-architecture.md`: "The label set … encodes the product, host
428    /// identity, and host OS — for example `rm-home-win-x64`." Read against that
429    /// sentence the four segments are product / host identity / OS /
430    /// architecture, so `--host-label home` on a Windows x64 host derives
431    /// `rm-home-win-x64`.
432    ///
433    /// Note that the worked command in `03-control-flows.md` step 3 passes
434    /// `--host-label home-win`, which under this rule derives
435    /// `rm-home-win-win-x64`. That is a redundant example rather than a
436    /// different rule — `b1`'s Scope names the three inputs explicitly — but it
437    /// is recorded here because it is the obvious thing for a reader to trip on.
438    #[must_use]
439    pub fn derive(host_label: &HostLabel, os: Os, arch: Arch) -> Self {
440        let derived = format!(
441            "{}-{}-{}-{}",
442            Self::PREFIX,
443            host_label.as_str(),
444            os.label_token(),
445            arch.label_token()
446        );
447        Self {
448            host_label: Label::new(derived).expect(
449                "a HostLabel is ASCII alphanumeric plus `-`/`_` and the other three \
450                 segments are fixed tokens, so the concatenation is always a valid Label",
451            ),
452            additional: BTreeSet::new(),
453        }
454    }
455
456    /// Named profiles have a selector derived from host identity and profile.
457    /// The native `default` profile retains its historical label through the
458    /// compatibility constructor instead.
459    #[must_use]
460    pub fn derive_for_profile(
461        host_label: &HostLabel,
462        os: Os,
463        arch: Arch,
464        profile: &ProfileName,
465    ) -> Self {
466        let label = format!(
467            "{}-{}-{}-{}-{}",
468            Self::PREFIX,
469            host_label.as_str(),
470            os.label_token(),
471            arch.label_token(),
472            profile.as_str()
473        );
474        Self::from_host_label(Label::new(label).expect("validated selector segments form a label"))
475    }
476
477    /// Build from an explicit host label, for the operator override `f2`
478    /// supports, and for `b2` reloading a stored set.
479    ///
480    /// **This accepts any [`Label`] as the host label, including one that is not
481    /// host-scoped at all.** "Host-scoped by construction" is a property of
482    /// [`Self::derive`], not of this type: `f2` deliberately supports an
483    /// operator override, so a hard rejection here would break a supported
484    /// workflow, and this is also the serde path, so `b2` reaches it for every
485    /// stored row. A hand-edited row can therefore set `host_label` to
486    /// `self-hosted`, and [`Self::remove`] will then defend *that* as immovable
487    /// while two hosts happily serve each other's jobs. Ask
488    /// [`Self::is_derived_shape`] before trusting the collision control.
489    #[must_use]
490    pub fn from_parts(host_label: Label, additional: impl IntoIterator<Item = Label>) -> Self {
491        let additional = additional
492            .into_iter()
493            .filter(|l| *l != host_label)
494            .collect();
495        Self {
496            host_label,
497            additional,
498        }
499    }
500
501    /// Build from an explicit host label with no optional labels.
502    #[must_use]
503    pub fn from_host_label(host_label: Label) -> Self {
504        Self::from_parts(host_label, Vec::new())
505    }
506
507    /// The one label that carries host identity and cannot be removed.
508    #[must_use]
509    pub fn host_label(&self) -> &Label {
510        &self.host_label
511    }
512
513    /// Whether the host label still has the shape [`Self::derive`] produces:
514    /// `rm-<host>-<os>-<arch>`, with the OS and architecture segments being
515    /// tokens this crate actually emits.
516    ///
517    /// **This is a warning predicate, not a validation rule.** It is `false` for
518    /// an operator override, and an override is supported — `f2` offers one on
519    /// purpose. What it detects is that the *collision control has been turned
520    /// off*: the derived shape is what keeps two hosts from answering each
521    /// other's jobs, so a policy whose host label is `self-hosted` or
522    /// `ubuntu-latest` will route work that belongs to another machine, and
523    /// [`Self::remove`] will refuse to remove that label because it cannot tell
524    /// the difference. `f2` and `g2` should say so rather than fail; nothing
525    /// here rejects it.
526    ///
527    /// Matching is structural rather than a check against a known host label,
528    /// because the host label this was derived from is not stored — only the
529    /// concatenation is.
530    ///
531    /// **The middle segments are not inspected, only counted.** An earlier
532    /// version also required every one of them to be non-empty, meaning to
533    /// reject an empty host segment — but a [`HostLabel`] cannot be empty, so
534    /// that condition never rejected anything [`Self::derive`] could produce and
535    /// only ever produced false negatives: `HostLabel::new("home--pc")` is legal
536    /// (only a *leading* or *trailing* `-` is refused), derives
537    /// `rm-home--pc-win-x64`, and was reported as not derived. The consequence
538    /// was `f2`/`g2` warning an operator that their collision control was off
539    /// when they had done nothing wrong, which is worse than the residual it
540    /// leaves: a hand-edited `rm--win-x64` now reads as derived. That row is
541    /// still host-scoped in shape, so it does not mislead in the direction this
542    /// predicate exists to catch.
543    #[must_use]
544    pub fn is_derived_shape(&self) -> bool {
545        let segments: Vec<&str> = self.host_label.as_str().split('-').collect();
546        // `rm` / host / os / arch. The host segment is a HostLabel, which never
547        // contains `-`... except that it may: `--host-label home-win` is legal
548        // and derives `rm-home-win-win-x64`. So the fixed ends are what is
549        // checked, and everything between them is the host identity.
550        let [prefix, middle @ .., os, arch] = segments.as_slice() else {
551            return false;
552        };
553        // `Os::ALL` / `Arch::ALL` rather than a literal list, so a fourth OS or
554        // architecture is recognised here the moment it exists.
555        *prefix == Self::PREFIX
556            && !middle.is_empty()
557            && Os::ALL
558                .iter()
559                .any(|candidate| candidate.label_token() == *os)
560            && Arch::ALL
561                .iter()
562                .any(|candidate| candidate.label_token() == *arch)
563    }
564
565    /// The optional descriptive labels, in sorted order.
566    pub fn additional(&self) -> impl Iterator<Item = &Label> {
567        self.additional.iter()
568    }
569
570    /// Add an optional descriptive label. Returns `false` if it was already in
571    /// the set (including as the host label).
572    pub fn add(&mut self, label: Label) -> bool {
573        if label == self.host_label {
574            return false;
575        }
576        self.additional.insert(label)
577    }
578
579    /// Remove an optional descriptive label.
580    ///
581    /// # Errors
582    /// [`PolicyError::HostLabelNotRemovable`] when asked to remove the host
583    /// label. There is deliberately no override: this is the routing identity
584    /// that keeps two hosts from serving each other's jobs.
585    pub fn remove(&mut self, label: &Label) -> Result<bool, PolicyError> {
586        if *label == self.host_label {
587            return Err(PolicyError::HostLabelNotRemovable {
588                label: label.clone(),
589            });
590        }
591        Ok(self.additional.remove(label))
592    }
593
594    #[must_use]
595    pub fn contains(&self, label: &Label) -> bool {
596        self.host_label == *label || self.additional.contains(label)
597    }
598
599    /// Whether a runner with this set can take a job requiring `label`: it
600    /// carries it, or `label` is `not-X` and it does not carry `X`.
601    #[must_use]
602    pub fn satisfies(&self, label: &Label) -> bool {
603        label_satisfied(|offered| self.contains(offered), label)
604    }
605
606    /// The shared pool labels this set carries (see [`POOL_LABEL_PREFIX`]):
607    /// at most its OS pool label and [`ANY_POOL_LABEL`] once checked. The
608    /// host label is never one: it is derived from the host's identity.
609    pub fn pool_labels(&self) -> impl Iterator<Item = &Label> {
610        self.additional
611            .iter()
612            .filter(|label| is_pool_label(label.as_str()))
613    }
614
615    /// Every label, host label first.
616    pub fn iter(&self) -> impl Iterator<Item = &Label> {
617        std::iter::once(&self.host_label).chain(self.additional.iter())
618    }
619
620    /// Never zero.
621    #[must_use]
622    pub fn count(&self) -> NonZeroUsize {
623        NonZeroUsize::new(1 + self.additional.len()).expect("the host label is always present")
624    }
625
626    /// The same set in the shape `04-subsystem-contracts.md` names.
627    #[must_use]
628    pub fn to_non_empty(&self) -> NonEmpty<Label> {
629        let mut out = NonEmpty::of(self.host_label.clone());
630        for label in &self.additional {
631            out.push(label.clone());
632        }
633        out
634    }
635
636    /// The `labels` array for `generate-jitconfig`
637    /// (`04-subsystem-contracts.md`, "Generate JIT configuration").
638    ///
639    /// `c4` sends exactly this. It matters that it is exactly this: the `v1`
640    /// spike established that **no labels are added implicitly** — the `201`
641    /// carries the requested labels and nothing else, so a runner registered
642    /// from this array does not answer `runs-on: self-hosted` unless
643    /// `self-hosted` is in it (`docs/spikes/d18-org-jit-verification.md`,
644    /// Point 3, findings 1 and 2).
645    ///
646    /// An exclusion this set contradicts (`not-wsl` beside `wsl`) is left
647    /// out: GitHub would otherwise hand such a runner the very jobs that
648    /// exclude it.
649    #[must_use]
650    pub fn as_registration_labels(&self) -> Vec<String> {
651        self.iter()
652            .filter(|label| self.satisfies(label))
653            .map(|l| l.as_str().to_string())
654            .collect()
655    }
656
657    /// This set with `extra` labels added: a host's [`HostTraits`], or the
658    /// exclusions a runner registers with for the jobs it is started for.
659    #[must_use]
660    pub fn with_labels(&self, extra: impl IntoIterator<Item = Label>) -> Self {
661        let mut labels = self.clone();
662        for label in extra {
663            labels.add(label);
664        }
665        labels
666    }
667
668    /// This set with `not-X` for every `X` in `excluded` it does not carry:
669    /// the runner then answers jobs that exclude those labels. One it carries
670    /// is never added, so no runner claims to lack a label it has.
671    #[must_use]
672    pub fn excluding<'a>(&self, excluded: impl IntoIterator<Item = &'a Label>) -> Self {
673        let exclusions: Vec<Label> = excluded
674            .into_iter()
675            .filter(|label| !self.contains(label))
676            .filter_map(exclusion_of)
677            .collect();
678        self.with_labels(exclusions)
679    }
680
681    /// Decide whether this policy should serve a queued job.
682    ///
683    /// GitHub assigns a job to a runner whose label set is a **superset** of the
684    /// job's required labels, so the predicate is subset-in-the-other-direction:
685    /// the job's required labels must all be present here. The immutable selector
686    /// is not mandatory. Reconciliation separately refuses overlapping profiles.
687    ///
688    /// An exclusion the job names (`not-wsl`) holds when this set does not
689    /// carry what it excludes (see [`EXCLUSION_PREFIX`]); `missing` then lists
690    /// the exclusion itself.
691    #[must_use]
692    pub fn matches(&self, runs_on: &RunsOn) -> RunsOnMatch {
693        let required = match runs_on.required_labels() {
694            Ok(required) => required,
695            Err(unresolvable) => return RunsOnMatch::Unresolvable(unresolvable),
696        };
697
698        let missing: Vec<Label> = required
699            .iter()
700            .filter(|label| !self.satisfies(label))
701            .cloned()
702            .collect();
703
704        if missing.is_empty() {
705            RunsOnMatch::Match {
706                runner_group: runs_on.runner_group().map(str::to_string),
707            }
708        } else {
709            RunsOnMatch::NoMatch { missing }
710        }
711    }
712
713    /// Tally a poll's worth of queued jobs into a demand signal.
714    ///
715    /// The three counts are kept apart on purpose. An unresolvable `runs-on` is
716    /// neither counted as demand nor dropped: `b1` requires it be "reported as
717    /// unresolvable rather than silently counted or silently dropped", because
718    /// counting it would start a runner for a job that may not be ours and
719    /// dropping it would hide a workflow this host can never serve.
720    #[must_use]
721    pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
722        let mut tally = DemandTally::default();
723        for job in jobs {
724            match self.matches(job) {
725                RunsOnMatch::Match { .. } => {
726                    tally.matched += 1;
727                    if let Ok(required) = job.required_labels() {
728                        tally.excluded.extend(exclusions_in(&required));
729                    }
730                }
731                RunsOnMatch::NoMatch { .. } => tally.not_matched += 1,
732                RunsOnMatch::Unresolvable(reason) => tally.unresolvable.push(reason),
733            }
734        }
735        tally
736    }
737}
738
739impl fmt::Display for RoutingLabels {
740    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
741        let joined: Vec<&str> = self.iter().map(Label::as_str).collect();
742        f.write_str(&joined.join(","))
743    }
744}
745
746/// The result of one poll's label matching.
747#[derive(Debug, Clone, Default, PartialEq, Eq)]
748pub struct DemandTally {
749    /// Jobs this policy should serve. This is the demand signal `e1` clamps.
750    pub matched: u32,
751    /// Jobs whose required labels this policy does not carry.
752    pub not_matched: u32,
753    /// Jobs whose `runs-on` could not be resolved statically. Never demand,
754    /// never discarded — `g2` surfaces these so an operator can see that a
755    /// workflow this host will never serve is sitting in the queue.
756    pub unresolvable: Vec<UnresolvableRunsOn>,
757    /// What the matched jobs exclude (`wsl` for `runs-on: [.., not-wsl]`):
758    /// a runner started for them registers with `not-X` for each, or GitHub
759    /// would never hand it those jobs.
760    pub excluded: BTreeSet<Label>,
761}
762
763impl DemandTally {
764    #[must_use]
765    pub fn demand(&self) -> u32 {
766        self.matched
767    }
768
769    #[must_use]
770    pub fn total_seen(&self) -> u32 {
771        self.matched + self.not_matched + self.unresolvable.len() as u32
772    }
773}
774
775/// The outcome of matching one job's `runs-on` against a policy's labels.
776#[derive(Debug, Clone, PartialEq, Eq)]
777pub enum RunsOnMatch {
778    /// The job's required labels are all present.
779    ///
780    /// `runner_group` carries the `group:` key when the map form named one. The
781    /// domain does not evaluate it — a policy has no runner-group field, and
782    /// `c4` resolves the group id at registration time — but it is returned
783    /// rather than discarded so that a caller which *can* evaluate it is not
784    /// forced to re-parse the `runs-on`.
785    Match { runner_group: Option<String> },
786    /// At least one required label is absent. `missing` is what to tell an
787    /// operator who expected this policy to pick the job up.
788    NoMatch { missing: Vec<Label> },
789    /// The `runs-on` cannot be resolved without evaluating the workflow.
790    Unresolvable(UnresolvableRunsOn),
791}
792
793impl RunsOnMatch {
794    #[must_use]
795    pub const fn is_match(&self) -> bool {
796        matches!(self, RunsOnMatch::Match { .. })
797    }
798
799    #[must_use]
800    pub const fn is_unresolvable(&self) -> bool {
801        matches!(self, RunsOnMatch::Unresolvable(_))
802    }
803}
804
805/// Why a `runs-on` could not be resolved statically.
806#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
807pub enum UnresolvableRunsOn {
808    /// A GitHub Actions expression, `${{ … }}`. Its value depends on the run's
809    /// context, which this process does not have.
810    #[error("`runs-on` contains an expression that only GitHub can evaluate: {raw}")]
811    Expression { raw: String },
812
813    /// `runs-on: {group: X}` with no `labels`. The job constrains the runner
814    /// *group*, and a policy records no group, so nothing here can decide it.
815    #[error("`runs-on` names runner group {group} but no labels, so no label predicate applies")]
816    RunnerGroupWithoutLabels { group: String },
817
818    /// A `runs-on` naming no labels at all.
819    #[error("`runs-on` names no labels")]
820    NoLabels,
821
822    /// A label that is not a label — a comma or a control character.
823    #[error("`runs-on` contains {raw:?}, which is not a usable label: {source}")]
824    InvalidLabel {
825        raw: String,
826        #[source]
827        source: ValidationError,
828    },
829
830    /// An exclusion that names nothing usable: `not-` alone, or `not-not-X`.
831    /// No runner can satisfy it.
832    #[error(
833        "`runs-on` contains {label}, which excludes no usable label (write `not-` and one \
834         ordinary label, as in `not-wsl`); no runner can take this job"
835    )]
836    InvalidExclusion { label: Label },
837
838    /// A job asking for a label and for its exclusion (`wsl` and `not-wsl`).
839    /// No runner can satisfy it.
840    #[error(
841        "`runs-on` asks for {label} and for not-{label}; no runner can carry a label and \
842         lack it, so none can take this job"
843    )]
844    ConflictingExclusion { label: Label },
845}
846
847impl UnresolvableRunsOn {
848    /// Whether this is an exclusion no runner can satisfy, as opposed to a
849    /// `runs-on` only GitHub can evaluate.
850    #[must_use]
851    pub const fn is_unsatisfiable_exclusion(&self) -> bool {
852        self.exclusion_kind().is_some()
853    }
854
855    /// A fixed name for an unsatisfiable exclusion, for logs that must not
856    /// carry the job's own labels.
857    #[must_use]
858    pub const fn exclusion_kind(&self) -> Option<&'static str> {
859        match self {
860            Self::InvalidExclusion { .. } => Some("invalid_exclusion"),
861            Self::ConflictingExclusion { .. } => Some("conflicting_exclusion"),
862            _ => None,
863        }
864    }
865}
866
867/// A queued job's `runs-on`, in each documented form.
868///
869/// GitHub's "List jobs for a workflow run" response gives a job's labels as a
870/// flat array, so in practice `c4` will build [`RunsOn::Many`]. The string and
871/// map forms are supported because they are what a workflow file contains and
872/// what `b1`'s Definition of Done enumerates.
873#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
874#[serde(untagged)]
875pub enum RunsOn {
876    /// `runs-on: ubuntu-latest`
877    Single(String),
878    /// `runs-on: [self-hosted, linux]`
879    Many(Vec<String>),
880    /// `runs-on: {group: g, labels: [a, b]}`
881    Grouped {
882        #[serde(default)]
883        group: Option<String>,
884        #[serde(default)]
885        labels: RunsOnLabels,
886    },
887}
888
889/// The `labels` key of the map form, which GitHub allows as a scalar or a list.
890#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
891#[serde(untagged)]
892pub enum RunsOnLabels {
893    One(String),
894    Many(Vec<String>),
895}
896
897impl Default for RunsOnLabels {
898    fn default() -> Self {
899        Self::Many(Vec::new())
900    }
901}
902
903impl RunsOnLabels {
904    fn as_slice(&self) -> &[String] {
905        match self {
906            RunsOnLabels::One(one) => std::slice::from_ref(one),
907            RunsOnLabels::Many(many) => many,
908        }
909    }
910}
911
912impl RunsOn {
913    /// The array form, which is what the jobs API returns.
914    #[must_use]
915    pub fn from_job_labels(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
916        Self::Many(labels.into_iter().map(Into::into).collect())
917    }
918
919    /// The runner group the map form named, if any.
920    #[must_use]
921    pub fn runner_group(&self) -> Option<&str> {
922        match self {
923            RunsOn::Grouped { group, .. } => group.as_deref(),
924            _ => None,
925        }
926    }
927
928    /// The usable ordinary labels this job names, exclusions and anything
929    /// unusable left out: which machines it is meant for, even when
930    /// [`Self::required_labels`] refuses it.
931    #[must_use]
932    pub fn plain_labels(&self) -> Vec<Label> {
933        self.raw_labels()
934            .iter()
935            .filter_map(|raw| Label::new(raw).ok())
936            .filter(|label| excluded_label(label).is_none())
937            .collect()
938    }
939
940    fn raw_labels(&self) -> &[String] {
941        match self {
942            RunsOn::Single(one) => std::slice::from_ref(one),
943            RunsOn::Many(many) => many,
944            RunsOn::Grouped { labels, .. } => labels.as_slice(),
945        }
946    }
947
948    /// The normalised labels this job requires.
949    ///
950    /// **Whitespace-only array elements are dropped, not rejected.** A
951    /// `runs-on: ["self-hosted", "", "linux"]` is a workflow that GitHub itself
952    /// accepts, and the empty element carries no routing meaning, so treating it
953    /// as an [`UnresolvableRunsOn::InvalidLabel`] would report a job as
954    /// unresolvable — and so exclude it from demand and surface it to an
955    /// operator — over a stray comma in someone's YAML. The elements that
956    /// remain are what the job actually requires. If *every* element is
957    /// whitespace the array resolves to nothing at all, and that **is**
958    /// reported, as [`UnresolvableRunsOn::NoLabels`] or
959    /// [`UnresolvableRunsOn::RunnerGroupWithoutLabels`].
960    ///
961    /// **An exclusion no runner can satisfy is refused here too**
962    /// ([`UnresolvableRunsOn::InvalidExclusion`],
963    /// [`UnresolvableRunsOn::ConflictingExclusion`]), so the host and the
964    /// coordinator, which both start from this, never count such a job as
965    /// demand.
966    ///
967    /// # Errors
968    /// Every reason the value cannot be turned into a label set — each of which
969    /// the caller reports rather than treating as "no demand".
970    pub fn required_labels(&self) -> Result<Vec<Label>, UnresolvableRunsOn> {
971        let raws = self.raw_labels();
972
973        if let Some(raw) = raws.iter().find(|r| is_expression(r)) {
974            return Err(UnresolvableRunsOn::Expression { raw: raw.clone() });
975        }
976
977        let usable: Vec<&String> = raws.iter().filter(|r| !r.trim().is_empty()).collect();
978
979        if usable.is_empty() {
980            return match self.runner_group() {
981                Some(group) => Err(UnresolvableRunsOn::RunnerGroupWithoutLabels {
982                    group: group.to_string(),
983                }),
984                None => Err(UnresolvableRunsOn::NoLabels),
985            };
986        }
987
988        let labels: Vec<Label> = usable
989            .into_iter()
990            .map(|raw| {
991                Label::new(raw).map_err(|source| UnresolvableRunsOn::InvalidLabel {
992                    raw: raw.clone(),
993                    source,
994                })
995            })
996            .collect::<Result<_, _>>()?;
997
998        for label in &labels {
999            match excluded_label(label) {
1000                Some(Exclusion::Invalid) => {
1001                    return Err(UnresolvableRunsOn::InvalidExclusion {
1002                        label: label.clone(),
1003                    });
1004                }
1005                Some(Exclusion::Of(excluded)) if labels.contains(&excluded) => {
1006                    return Err(UnresolvableRunsOn::ConflictingExclusion { label: excluded });
1007                }
1008                Some(Exclusion::Of(_)) | None => {}
1009            }
1010        }
1011        Ok(labels)
1012    }
1013}
1014
1015fn is_expression(raw: &str) -> bool {
1016    raw.contains("${{")
1017}
1018
1019// ---------------------------------------------------------------------------
1020// PolicyMode (D19)
1021// ---------------------------------------------------------------------------
1022
1023/// The autoscale half of [`PolicyMode`].
1024///
1025/// Every field an `Autoscale` policy requires lives here, unconditionally. That
1026/// is the whole trick: there is no `Option` to be `None` and no separate
1027/// validator to forget, so "an autoscale policy with no capacity ceiling" is not
1028/// a state this program can hold in memory, let alone persist.
1029#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1030#[serde(try_from = "AutoscaleConfigRepr")]
1031pub struct AutoscaleConfig {
1032    routing_labels: RoutingLabels,
1033    min_capacity: u16,
1034    max_capacity: NonZeroU16,
1035}
1036
1037#[derive(Debug, Deserialize)]
1038struct AutoscaleConfigRepr {
1039    routing_labels: RoutingLabels,
1040    min_capacity: u16,
1041    max_capacity: NonZeroU16,
1042}
1043
1044impl TryFrom<AutoscaleConfigRepr> for AutoscaleConfig {
1045    type Error = PolicyError;
1046
1047    fn try_from(repr: AutoscaleConfigRepr) -> Result<Self, Self::Error> {
1048        Self::new(repr.routing_labels, repr.min_capacity, repr.max_capacity)
1049    }
1050}
1051
1052impl AutoscaleConfig {
1053    /// # Errors
1054    /// [`PolicyError::InvertedCapacityRange`] when `min > max`. Validated here
1055    /// so `clamp(demand, min, max)` in [`crate::capacity`] is always
1056    /// well-defined — an inverted range makes `clamp` panic in Rust, so this is
1057    /// not a stylistic check.
1058    pub fn new(
1059        routing_labels: RoutingLabels,
1060        min_capacity: u16,
1061        max_capacity: NonZeroU16,
1062    ) -> Result<Self, PolicyError> {
1063        if min_capacity > max_capacity.get() {
1064            return Err(PolicyError::InvertedCapacityRange {
1065                min: min_capacity,
1066                max: max_capacity.get(),
1067            });
1068        }
1069        Ok(Self {
1070            routing_labels,
1071            min_capacity,
1072            max_capacity,
1073        })
1074    }
1075
1076    /// The v1 shape: `min_capacity` fixed at 0 (D7).
1077    ///
1078    /// # Errors
1079    /// Never fails, because 0 cannot exceed a [`NonZeroU16`]; the `Result` is
1080    /// kept so that lifting the D7 restriction later is not a signature change.
1081    pub fn v1(
1082        routing_labels: RoutingLabels,
1083        max_capacity: NonZeroU16,
1084    ) -> Result<Self, PolicyError> {
1085        Self::new(routing_labels, 0, max_capacity)
1086    }
1087
1088    #[must_use]
1089    pub fn routing_labels(&self) -> &RoutingLabels {
1090        &self.routing_labels
1091    }
1092
1093    #[must_use]
1094    pub fn routing_labels_mut(&mut self) -> &mut RoutingLabels {
1095        &mut self.routing_labels
1096    }
1097
1098    #[must_use]
1099    pub const fn min_capacity(&self) -> u16 {
1100        self.min_capacity
1101    }
1102
1103    #[must_use]
1104    pub const fn max_capacity(&self) -> NonZeroU16 {
1105        self.max_capacity
1106    }
1107
1108    /// # Errors
1109    /// [`PolicyError::InvertedCapacityRange`] if the new ceiling is below the
1110    /// existing floor.
1111    pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
1112        if self.min_capacity > max_capacity.get() {
1113            return Err(PolicyError::InvertedCapacityRange {
1114                min: self.min_capacity,
1115                max: max_capacity.get(),
1116            });
1117        }
1118        self.max_capacity = max_capacity;
1119        Ok(())
1120    }
1121}
1122
1123/// D19, as an enforced invariant rather than a convention.
1124///
1125/// `04-subsystem-contracts.md`:
1126///
1127/// * "`MonitorOnly` requires `routing_labels` and `max_capacity` to be `None`."
1128/// * "`Autoscale` requires both to be `Some`."
1129///
1130/// The contract writes those as three flat, independently-`Option`al fields on
1131/// `ScalePolicy`, which admits four combinations of which two are illegal. This
1132/// enum admits exactly the two legal ones, so the illegal pair has no
1133/// representation — `b1` asks for that explicitly: "Prefer a representation
1134/// where the illegal combination cannot be built at all over one that is merely
1135/// validated on the way in."
1136///
1137/// The flat shape is still reachable: [`PolicyMode::routing_labels`],
1138/// [`PolicyMode::min_capacity`], and [`PolicyMode::max_capacity`] return exactly
1139/// the `Option`s the contract names, and [`PolicyMode::from_persisted`] rebuilds
1140/// the mode from them. That is `b2`'s load path, and it is where a hand-edited
1141/// database row is rejected.
1142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1143#[serde(tag = "mode", rename_all = "snake_case")]
1144pub enum PolicyMode {
1145    /// Contributes runners and workflow counts to the dashboard; owns no
1146    /// routing label; skipped entirely by reconciliation.
1147    MonitorOnly,
1148    /// Starts runners, up to `max_capacity` and the host ceiling.
1149    Autoscale(AutoscaleConfig),
1150}
1151
1152impl PolicyMode {
1153    #[must_use]
1154    pub const fn monitor_only() -> Self {
1155        Self::MonitorOnly
1156    }
1157
1158    /// # Errors
1159    /// [`PolicyError::InvertedCapacityRange`].
1160    pub fn autoscale(
1161        routing_labels: RoutingLabels,
1162        min_capacity: u16,
1163        max_capacity: NonZeroU16,
1164    ) -> Result<Self, PolicyError> {
1165        Ok(Self::Autoscale(AutoscaleConfig::new(
1166            routing_labels,
1167            min_capacity,
1168            max_capacity,
1169        )?))
1170    }
1171
1172    /// Rebuild the mode from the flat persisted shape.
1173    ///
1174    /// This is the gate `b2` puts every load through. All four combinations of
1175    /// `routing_labels`/`max_capacity` arrive here, and two of them are refused
1176    /// with a named error rather than being coerced into something plausible.
1177    ///
1178    /// # Errors
1179    /// Each illegal shape gets its own variant, so `b2` can say which column of
1180    /// which row is wrong rather than "invalid policy".
1181    pub fn from_persisted(
1182        routing_labels: Option<RoutingLabels>,
1183        min_capacity: u16,
1184        max_capacity: Option<NonZeroU16>,
1185    ) -> Result<Self, PolicyError> {
1186        match (routing_labels, max_capacity) {
1187            (None, None) => {
1188                if min_capacity != 0 {
1189                    // Not stated as a shape rule in `04`, but a MonitorOnly
1190                    // policy never starts a runner, so a non-zero floor is data
1191                    // that cannot mean anything. Refusing it loudly beats
1192                    // loading it and silently ignoring it.
1193                    return Err(PolicyError::MonitorOnlyWithMinCapacity { min: min_capacity });
1194                }
1195                Ok(Self::MonitorOnly)
1196            }
1197            (Some(_), None) => Err(PolicyError::AutoscaleWithoutMaxCapacity),
1198            (None, Some(_)) => Err(PolicyError::AutoscaleWithoutRoutingLabels),
1199            (Some(labels), Some(max)) => Self::autoscale(labels, min_capacity, max),
1200        }
1201    }
1202
1203    /// The contract's `routing_labels: Option<NonEmpty<Label>>`, in `Option`
1204    /// form.
1205    #[must_use]
1206    pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
1207        match self {
1208            PolicyMode::MonitorOnly => None,
1209            PolicyMode::Autoscale(cfg) => Some(&cfg.routing_labels),
1210        }
1211    }
1212
1213    #[must_use]
1214    pub const fn min_capacity(&self) -> u16 {
1215        match self {
1216            PolicyMode::MonitorOnly => 0,
1217            PolicyMode::Autoscale(cfg) => cfg.min_capacity,
1218        }
1219    }
1220
1221    #[must_use]
1222    pub const fn max_capacity(&self) -> Option<NonZeroU16> {
1223        match self {
1224            PolicyMode::MonitorOnly => None,
1225            PolicyMode::Autoscale(cfg) => Some(cfg.max_capacity),
1226        }
1227    }
1228
1229    #[must_use]
1230    pub const fn autoscale_config(&self) -> Option<&AutoscaleConfig> {
1231        match self {
1232            PolicyMode::MonitorOnly => None,
1233            PolicyMode::Autoscale(cfg) => Some(cfg),
1234        }
1235    }
1236
1237    #[must_use]
1238    pub const fn is_autoscale(&self) -> bool {
1239        matches!(self, PolicyMode::Autoscale(_))
1240    }
1241
1242    #[must_use]
1243    pub const fn is_monitor_only(&self) -> bool {
1244        matches!(self, PolicyMode::MonitorOnly)
1245    }
1246}
1247
1248// ---------------------------------------------------------------------------
1249// PolicyState
1250// ---------------------------------------------------------------------------
1251
1252/// The policy lifecycle, exactly as `04-subsystem-contracts.md` draws it:
1253///
1254/// ```text
1255/// pending -> active | repair_required
1256/// active  -> draining -> disabled -> pending
1257/// any     -> authentication_failed        (recoverable by re-authentication)
1258/// ```
1259///
1260/// **Every transition outside that diagram is rejected**, which `b1`'s
1261/// Definition of Done requires. Two consequences are worth stating because they
1262/// are surprising, and both are recorded as findings rather than papered over:
1263///
1264/// * `RepairRequired` has no outgoing edge except the `any` rule. A policy that
1265///   enters it can never return to `Active` through this state machine.
1266/// * `Disabled -> Pending` begins a fresh lifecycle when an operator re-enables
1267///   a policy that previously finished draining. Activation remains a separate
1268///   `Pending -> Active` transition, preserving the normal entry-state checks.
1269///
1270/// The one edge here that the diagram does not draw as an arrow is
1271/// `AuthenticationFailed -> Pending`, which is the parenthetical "(recoverable
1272/// by re-authentication)" made executable; `pending` is where it lands because
1273/// that is the diagram's only entry state.
1274#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1275#[serde(rename_all = "snake_case")]
1276pub enum PolicyState {
1277    Pending,
1278    Active,
1279    Draining,
1280    Disabled,
1281    RepairRequired,
1282    AuthenticationFailed,
1283}
1284
1285impl PolicyState {
1286    pub const ALL: [PolicyState; 6] = [
1287        PolicyState::Pending,
1288        PolicyState::Active,
1289        PolicyState::Draining,
1290        PolicyState::Disabled,
1291        PolicyState::RepairRequired,
1292        PolicyState::AuthenticationFailed,
1293    ];
1294
1295    /// The complete legal transition list. Nothing outside it is permitted, and
1296    /// a self-transition is not in it either.
1297    pub const LEGAL: &'static [(PolicyState, PolicyState)] = &[
1298        (PolicyState::Pending, PolicyState::Active),
1299        (PolicyState::Pending, PolicyState::RepairRequired),
1300        (PolicyState::Active, PolicyState::Draining),
1301        (PolicyState::Draining, PolicyState::Disabled),
1302        (PolicyState::Disabled, PolicyState::Pending),
1303        // `any -> authentication_failed`.
1304        (PolicyState::Pending, PolicyState::AuthenticationFailed),
1305        (PolicyState::Active, PolicyState::AuthenticationFailed),
1306        (PolicyState::Draining, PolicyState::AuthenticationFailed),
1307        (PolicyState::Disabled, PolicyState::AuthenticationFailed),
1308        (
1309            PolicyState::RepairRequired,
1310            PolicyState::AuthenticationFailed,
1311        ),
1312        // "(recoverable by re-authentication)".
1313        (PolicyState::AuthenticationFailed, PolicyState::Pending),
1314    ];
1315
1316    #[must_use]
1317    pub fn can_transition_to(self, next: PolicyState) -> bool {
1318        Self::LEGAL.contains(&(self, next))
1319    }
1320
1321    /// True while the policy is allowed to be the reason a runner starts.
1322    #[must_use]
1323    pub const fn admits_new_runners(self) -> bool {
1324        matches!(self, PolicyState::Active)
1325    }
1326}
1327
1328impl fmt::Display for PolicyState {
1329    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1330        f.write_str(match self {
1331            PolicyState::Pending => "pending",
1332            PolicyState::Active => "active",
1333            PolicyState::Draining => "draining",
1334            PolicyState::Disabled => "disabled",
1335            PolicyState::RepairRequired => "repair_required",
1336            PolicyState::AuthenticationFailed => "authentication_failed",
1337        })
1338    }
1339}
1340
1341// ---------------------------------------------------------------------------
1342// ScalePolicy
1343// ---------------------------------------------------------------------------
1344
1345/// One target, scaled (or merely watched) by one host.
1346///
1347/// `mode`, `enabled`, `state`, `workspace_policy`, and `revision` are private:
1348/// each is governed by an invariant that a direct assignment would bypass. `id`,
1349/// `target`, `installation_id`, `host_id`, and `cache_policy` are public because
1350/// they are either immutable identity or self-validating values.
1351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1352pub struct ScalePolicy {
1353    pub id: PolicyId,
1354    pub target: ScaleTarget,
1355    pub installation_id: u64,
1356    pub host_id: HostId,
1357    /// Operator-chosen host identity retained even for MonitorOnly policies.
1358    pub requested_host_label: HostLabel,
1359    /// Immutable identity within one repository target on this host.
1360    profile_name: ProfileName,
1361    mode: PolicyMode,
1362    enabled: bool,
1363    state: PolicyState,
1364    pub cache_policy: CachePolicy,
1365    /// D4: whether this repository's job workspace survives an attempt, and
1366    /// where.
1367    ///
1368    /// Private because of D7: a persistent value is legal for a repository
1369    /// target and corrupt state for an organization one, and `target` is right
1370    /// here to check it against. [`Self::set_workspace_policy`] is the only
1371    /// writer, so the pair cannot be made inconsistent by assignment.
1372    workspace_policy: WorkspacePolicy,
1373    execution_policy: ExecutionPolicy,
1374    revision: u64,
1375}
1376
1377/// Every stored column of one policy, named rather than positional.
1378///
1379/// **Why this is a struct.** [`ScalePolicy::from_persisted`] took eleven
1380/// positional arguments under `#[allow(clippy::too_many_arguments)]`, and two of
1381/// them — `installation_id` and `revision` — are both bare `u64`. Transposing
1382/// them type-checked and compiled: the policy would then have authenticated
1383/// against an installation id of `0` or `1` while presenting its installation id
1384/// as an optimistic-concurrency token, so every write would have raced and every
1385/// GitHub call would have failed to authenticate, with nothing in either
1386/// signature to catch it.
1387///
1388/// `b2` maps database columns onto this type. With a struct that mapping is
1389/// checked by name at compile time; positionally it was checked by nothing.
1390///
1391/// **That guarantee covers the Rust side of the mapping and no more.** It is the
1392/// *field* names that the compiler checks, not the column names they are read
1393/// from: `PersistedPolicy { installation_id: row.get("revision")?, … }` compiles
1394/// exactly as happily as the correct version, and reintroduces the very
1395/// transposition described above. `b2` still owes a test that loads a row whose
1396/// columns hold distinguishable values and asserts each landed in the field of
1397/// the same name; this type does not supply one.
1398///
1399/// Construct it with a struct literal so every field is written down at the call
1400/// site — that is the whole point, and a builder or a `Default` would give the
1401/// omission back.
1402#[derive(Debug, Clone, PartialEq, Eq)]
1403pub struct PersistedPolicy {
1404    pub id: PolicyId,
1405    pub target: ScaleTarget,
1406    /// The GitHub App installation this policy authenticates through.
1407    pub installation_id: u64,
1408    pub host_id: HostId,
1409    pub requested_host_label: HostLabel,
1410    pub profile_name: ProfileName,
1411    /// `Some` for an Autoscale policy, `None` for a MonitorOnly one (D19).
1412    pub routing_labels: Option<RoutingLabels>,
1413    pub min_capacity: u16,
1414    pub max_capacity: Option<NonZeroU16>,
1415    /// Operator intent, independent of `state`.
1416    pub enabled: bool,
1417    pub state: PolicyState,
1418    pub cache_policy: CachePolicy,
1419    /// `ephemeral` or `persistent`, stored beside the root below (D4). The two
1420    /// are separate columns rather than one because that is what SQLite holds;
1421    /// [`WorkspacePolicy::from_persisted`] is what refuses the combinations this
1422    /// crate cannot have written.
1423    pub workspace_kind: WorkspaceKind,
1424    /// The configured persistent root: `Some` exactly when `workspace_kind` is
1425    /// `persistent`.
1426    pub workspace_root: Option<LocalAbsolutePath>,
1427    pub execution_policy: ExecutionPolicy,
1428    /// Optimistic-concurrency token. Not an identifier of anything.
1429    pub revision: u64,
1430}
1431
1432/// Inputs from which a new named profile's immutable selector is derived.
1433#[derive(Debug, Clone, PartialEq, Eq)]
1434pub struct NamedProfileSpec {
1435    pub requested_host_label: HostLabel,
1436    pub os: Os,
1437    pub arch: Arch,
1438    pub profile_name: ProfileName,
1439    pub min_capacity: u16,
1440    pub max_capacity: NonZeroU16,
1441}
1442
1443impl ScalePolicy {
1444    /// The immutable profile identity within this policy's host and target.
1445    #[must_use]
1446    pub const fn profile_name(&self) -> &ProfileName {
1447        &self.profile_name
1448    }
1449
1450    /// A newly added policy.
1451    ///
1452    /// D20: `add` never arms a host. The policy starts `Pending` with
1453    /// `enabled == false`, and only an explicit `set-scale` moves it on. That is
1454    /// true for a policy created with `--max-capacity` too, which is why this is
1455    /// a property of the constructor rather than of the caller.
1456    ///
1457    /// **This is the `add` path, not the load path.** It resets `state` to
1458    /// `Pending`, `enabled` to `false` and `revision` to `0`, so calling it on a
1459    /// row read back from storage silently disarms a live policy and resets its
1460    /// concurrency token. [`Self::from_persisted`] is the one that reloads a
1461    /// stored policy; it sits directly below this and takes all three.
1462    #[must_use]
1463    pub fn new(
1464        id: PolicyId,
1465        target: ScaleTarget,
1466        installation_id: u64,
1467        host_id: HostId,
1468        mode: PolicyMode,
1469        cache_policy: CachePolicy,
1470    ) -> Self {
1471        Self::new_for_host_label(
1472            id,
1473            target,
1474            installation_id,
1475            host_id,
1476            HostLabel::new("host").expect("the compatibility host label is valid"),
1477            mode,
1478            cache_policy,
1479        )
1480    }
1481
1482    /// New policy retaining the exact operator-requested host identity.
1483    #[must_use]
1484    pub fn new_for_host_label(
1485        id: PolicyId,
1486        target: ScaleTarget,
1487        installation_id: u64,
1488        host_id: HostId,
1489        requested_host_label: HostLabel,
1490        mode: PolicyMode,
1491        cache_policy: CachePolicy,
1492    ) -> Self {
1493        Self {
1494            id,
1495            target,
1496            installation_id,
1497            host_id,
1498            requested_host_label,
1499            profile_name: ProfileName::default_profile(),
1500            mode,
1501            enabled: false,
1502            state: PolicyState::Pending,
1503            cache_policy,
1504            // D3/D4: `repo add` never configures a workspace. Persistence is a
1505            // separate, explicit `repo set-workspace`, so a policy this
1506            // constructor produced is always disposable.
1507            workspace_policy: WorkspacePolicy::Ephemeral,
1508            execution_policy: ExecutionPolicy::Native,
1509            revision: 0,
1510        }
1511    }
1512
1513    /// Construct a named autoscale policy with an immutable, derived selector.
1514    /// A selector is derived from the host and profile; no caller supplies it.
1515    pub fn new_named(
1516        id: PolicyId,
1517        target: ScaleTarget,
1518        installation_id: u64,
1519        host_id: HostId,
1520        spec: NamedProfileSpec,
1521        cache_policy: CachePolicy,
1522    ) -> Result<Self, PolicyError> {
1523        let NamedProfileSpec {
1524            requested_host_label,
1525            os,
1526            arch,
1527            profile_name,
1528            min_capacity,
1529            max_capacity,
1530        } = spec;
1531        if profile_name.is_default() {
1532            return Err(PolicyError::NamedConstructorForDefault);
1533        }
1534        let labels =
1535            RoutingLabels::derive_for_profile(&requested_host_label, os, arch, &profile_name);
1536        let mode = PolicyMode::autoscale(labels, min_capacity, max_capacity)?;
1537        let mut policy = Self::new_for_host_label(
1538            id,
1539            target,
1540            installation_id,
1541            host_id,
1542            requested_host_label,
1543            mode,
1544            cache_policy,
1545        );
1546        policy.profile_name = profile_name;
1547        Ok(policy)
1548    }
1549
1550    /// A named monitor-only profile has identity but no routing selector until
1551    /// it is explicitly promoted to autoscale.
1552    pub fn new_named_monitor(
1553        id: PolicyId,
1554        target: ScaleTarget,
1555        installation_id: u64,
1556        host_id: HostId,
1557        requested_host_label: HostLabel,
1558        profile_name: ProfileName,
1559        cache_policy: CachePolicy,
1560    ) -> Result<Self, PolicyError> {
1561        if profile_name.is_default() {
1562            return Err(PolicyError::NamedConstructorForDefault);
1563        }
1564        let mut policy = Self::new_for_host_label(
1565            id,
1566            target,
1567            installation_id,
1568            host_id,
1569            requested_host_label,
1570            PolicyMode::monitor_only(),
1571            cache_policy,
1572        );
1573        policy.profile_name = profile_name;
1574        Ok(policy)
1575    }
1576
1577    /// Rebuild a stored policy, re-validating D19's shape.
1578    ///
1579    /// This is the load path. Unlike [`Self::new`] it preserves `state`,
1580    /// `enabled` and `revision` exactly as stored.
1581    ///
1582    /// # Errors
1583    /// Any illegal `PolicyMode` shape, per [`PolicyMode::from_persisted`].
1584    pub fn from_persisted(fields: PersistedPolicy) -> Result<Self, PolicyError> {
1585        let PersistedPolicy {
1586            id,
1587            target,
1588            installation_id,
1589            host_id,
1590            requested_host_label,
1591            profile_name,
1592            routing_labels,
1593            min_capacity,
1594            max_capacity,
1595            enabled,
1596            state,
1597            cache_policy,
1598            workspace_kind,
1599            workspace_root,
1600            execution_policy,
1601            revision,
1602        } = fields;
1603
1604        let mode = PolicyMode::from_persisted(routing_labels, min_capacity, max_capacity)?;
1605        if !profile_name.is_default()
1606            && let Some(labels) = mode.routing_labels()
1607        {
1608            // Exact host OS/architecture validation runs in the store using
1609            // the authoritative host row. Monitor-only has no selector.
1610            let suffix = format!("-{}", profile_name.as_str());
1611            if !labels.host_label().as_str().ends_with(&suffix) {
1612                return Err(PolicyError::InvalidProfileSelector);
1613            }
1614        }
1615        // D7 is re-run on every load and not only at the CLI. A row that claims
1616        // an organization retains a job workspace is corrupt state, not a
1617        // configuration this build should honour.
1618        let workspace_policy =
1619            WorkspacePolicy::from_persisted(workspace_kind, workspace_root, target.scope())?;
1620        execution_policy.validate(target.scope(), &workspace_policy)?;
1621        Ok(Self {
1622            id,
1623            target,
1624            installation_id,
1625            host_id,
1626            requested_host_label,
1627            profile_name,
1628            mode,
1629            enabled,
1630            state,
1631            cache_policy,
1632            workspace_policy,
1633            execution_policy,
1634            revision,
1635        })
1636    }
1637
1638    /// Every stored column of this policy, for `b2` to write back.
1639    ///
1640    /// The exact inverse of [`Self::from_persisted`], so a round trip through
1641    /// storage is expressible without this type exposing `mode`, `enabled`,
1642    /// `state` and `revision` for writing.
1643    #[must_use]
1644    pub fn to_persisted(&self) -> PersistedPolicy {
1645        PersistedPolicy {
1646            id: self.id,
1647            target: self.target.clone(),
1648            installation_id: self.installation_id,
1649            host_id: self.host_id,
1650            requested_host_label: self.requested_host_label.clone(),
1651            profile_name: self.profile_name.clone(),
1652            routing_labels: self.routing_labels().cloned(),
1653            min_capacity: self.min_capacity(),
1654            max_capacity: self.max_capacity(),
1655            enabled: self.enabled,
1656            state: self.state,
1657            cache_policy: self.cache_policy,
1658            workspace_kind: self.workspace_policy.kind(),
1659            workspace_root: self.workspace_policy.root().cloned(),
1660            execution_policy: self.execution_policy.clone(),
1661            revision: self.revision,
1662        }
1663    }
1664
1665    #[must_use]
1666    pub const fn mode(&self) -> &PolicyMode {
1667        &self.mode
1668    }
1669
1670    #[must_use]
1671    pub const fn state(&self) -> PolicyState {
1672        self.state
1673    }
1674
1675    /// Operator intent, independent of `state`
1676    /// (`04-subsystem-contracts.md`: "`enabled` records operator intent;
1677    /// `state` records observed lifecycle").
1678    #[must_use]
1679    pub const fn enabled(&self) -> bool {
1680        self.enabled
1681    }
1682
1683    /// Optimistic-concurrency token. Every successful mutation below bumps it;
1684    /// `b2` rejects a write against a stale value.
1685    #[must_use]
1686    pub const fn revision(&self) -> u64 {
1687        self.revision
1688    }
1689
1690    #[must_use]
1691    pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
1692        self.mode.routing_labels()
1693    }
1694
1695    /// A copy whose routing labels are `labels(current)`, for the runners of
1696    /// one pass only: a host's [`HostTraits`], a job's exclusions. Never
1697    /// stored, and the revision is unchanged. A monitor-only policy has no
1698    /// labels and is returned as it is.
1699    #[must_use]
1700    pub fn with_runtime_labels(
1701        &self,
1702        labels: impl FnOnce(&RoutingLabels) -> RoutingLabels,
1703    ) -> Self {
1704        let mut copy = self.clone();
1705        if let PolicyMode::Autoscale(cfg) = &mut copy.mode {
1706            let changed = labels(cfg.routing_labels());
1707            *cfg.routing_labels_mut() = changed;
1708        }
1709        copy
1710    }
1711
1712    /// The shared pool labels this profile answers (coordinator stage 3).
1713    pub fn pool_labels(&self) -> impl Iterator<Item = &Label> {
1714        self.routing_labels()
1715            .into_iter()
1716            .flat_map(RoutingLabels::pool_labels)
1717    }
1718
1719    /// D4: this repository's configured workspace behaviour.
1720    #[must_use]
1721    pub const fn workspace_policy(&self) -> &WorkspacePolicy {
1722        &self.workspace_policy
1723    }
1724
1725    #[must_use]
1726    pub const fn execution_policy(&self) -> &ExecutionPolicy {
1727        &self.execution_policy
1728    }
1729
1730    /// The store's uncleaned-attempt guard must fence this mutation on write.
1731    pub fn set_execution_policy(&mut self, execution: ExecutionPolicy) -> Result<(), PolicyError> {
1732        execution.validate(self.target.scope(), &self.workspace_policy)?;
1733        if self.execution_policy != execution {
1734            self.execution_policy = execution;
1735            self.revision = self.revision.saturating_add(1);
1736        }
1737        Ok(())
1738    }
1739
1740    /// `repo set-workspace --mode …` (D4).
1741    ///
1742    /// The refusal of a persistent workspace for an organization target is D7,
1743    /// and it lives here rather than in the command layer because
1744    /// [`Self::from_persisted`] has to apply the identical rule to a stored row:
1745    /// one place, one message, one test.
1746    ///
1747    /// Like every other mutation on this type it bumps the revision, so `a2`'s
1748    /// optimistic guard rejects a write built from a stale read. It does **not**
1749    /// check for active attempts — D9's "a path change is refused while affected
1750    /// attempts are active" needs the uncleaned-attempt count in the same write
1751    /// transaction, which is `a2`'s fence and not something the domain can see.
1752    ///
1753    /// # Errors
1754    /// [`PolicyError::Workspace`] wrapping
1755    /// [`WorkspaceError::PersistentRequiresRepositoryScope`] for an organization
1756    /// target.
1757    pub fn set_workspace_policy(&mut self, workspace: WorkspacePolicy) -> Result<(), PolicyError> {
1758        // `WorkspacePolicy::Persistent` is a public variant, so a caller can
1759        // build one without going through `WorkspacePolicy::persistent`. The
1760        // rule is re-run here on the value actually handed in, through the one
1761        // predicate that owns it.
1762        workspace.permitted_for(self.target.scope())?;
1763        self.execution_policy
1764            .validate(self.target.scope(), &workspace)?;
1765        if self.workspace_policy != workspace {
1766            self.workspace_policy = workspace;
1767            self.revision = self.revision.saturating_add(1);
1768        }
1769        Ok(())
1770    }
1771
1772    #[must_use]
1773    pub const fn min_capacity(&self) -> u16 {
1774        self.mode.min_capacity()
1775    }
1776
1777    #[must_use]
1778    pub const fn max_capacity(&self) -> Option<NonZeroU16> {
1779        self.mode.max_capacity()
1780    }
1781
1782    /// Ownership rule 1: a policy's `host_id` and its host-scoped
1783    /// `routing_labels` determine ownership.
1784    #[must_use]
1785    pub fn is_owned_by(&self, host_id: HostId) -> bool {
1786        self.host_id == host_id
1787    }
1788
1789    /// Ownership rule 1, second half: "A `MonitorOnly` policy owns nothing and
1790    /// can never be the reason a runner starts."
1791    ///
1792    /// `e1` is required to assert this directly rather than to rely on
1793    /// `max_capacity` being absent, which is why it is a predicate on the mode
1794    /// and not an arithmetic accident.
1795    #[must_use]
1796    pub const fn owns_runners(&self) -> bool {
1797        self.mode.is_autoscale()
1798    }
1799
1800    /// Whether reconciliation may start a runner for this policy right now.
1801    ///
1802    /// All three conditions matter: monitor-only owns nothing (D19), a disabled
1803    /// or draining policy takes no new work (`03-control-flows.md`, flow 5), and
1804    /// a user-requested disable beats demand (precedence rule 4).
1805    #[must_use]
1806    pub const fn may_start_runners(&self) -> bool {
1807        self.mode.is_autoscale() && self.enabled && self.state.admits_new_runners()
1808    }
1809
1810    /// # Errors
1811    /// [`PolicyError::IllegalTransition`] for anything outside
1812    /// [`PolicyState::LEGAL`].
1813    pub fn transition_to(&mut self, next: PolicyState) -> Result<(), PolicyError> {
1814        if !self.state.can_transition_to(next) {
1815            return Err(PolicyError::IllegalTransition {
1816                from: self.state,
1817                to: next,
1818            });
1819        }
1820        self.state = next;
1821        self.revision = self.revision.saturating_add(1);
1822        Ok(())
1823    }
1824
1825    /// Whether [`Self::activate`] would succeed right now.
1826    ///
1827    /// Exposed so `f2` can implement the idempotent CLI behaviour described on
1828    /// [`Self::activate`] without either duplicating the state table or calling
1829    /// and discarding an error.
1830    #[must_use]
1831    pub fn can_activate(&self) -> bool {
1832        self.state.can_transition_to(PolicyState::Active)
1833    }
1834
1835    /// Whether [`Self::request_disable`] would succeed right now.
1836    #[must_use]
1837    pub fn can_request_disable(&self) -> bool {
1838        self.state.can_transition_to(PolicyState::Draining)
1839    }
1840
1841    /// `set-scale --enabled true` on a `Pending` policy (`03-control-flows.md`,
1842    /// flow 1.6).
1843    ///
1844    /// **Not idempotent, and that is intended.** This is a *transition*
1845    /// operation, not a desired-state one: it reports what the state machine
1846    /// permits and never silently accepts a call the diagram has no edge for.
1847    /// Calling it on an already-`Active` policy is [`PolicyError::IllegalTransition`],
1848    /// not a no-op.
1849    ///
1850    /// The idempotent reading — "make this policy enabled, whatever it is now" —
1851    /// is a *command-level* behaviour, and it belongs to `f2` because the answer
1852    /// depends on what `set-scale --enabled true` should mean for a `draining`,
1853    /// `disabled`, `repair_required` or `authentication_failed` policy, and each
1854    /// of those is a product decision rather than a domain one. Collapsing them
1855    /// here would make the domain answer them by accident. `f2` should branch on
1856    /// [`Self::can_activate`] and report the already-satisfied case as success
1857    /// without calling this at all.
1858    ///
1859    /// # Errors
1860    /// [`PolicyError::IllegalTransition`] when the policy is not `Pending`.
1861    pub fn activate(&mut self) -> Result<(), PolicyError> {
1862        self.transition_to(PolicyState::Active)?;
1863        self.enabled = true;
1864        Ok(())
1865    }
1866
1867    /// `set-scale --enabled false` (`03-control-flows.md`, flow 5.2).
1868    ///
1869    /// Precedence rule 4: a user-requested disable beats demand. `enabled` drops
1870    /// immediately — which alone is enough to stop new runners, because
1871    /// [`Self::may_start_runners`] reads it — and the observed state moves to
1872    /// `Draining`, where busy runners are left to finish.
1873    ///
1874    /// **Not idempotent, for the reason given on [`Self::activate`].** In
1875    /// particular `set-scale --enabled false` on a `pending` policy is
1876    /// `IllegalTransition { from: pending, to: draining }` rather than a no-op,
1877    /// even though a `pending` policy is already `enabled == false` and so is
1878    /// already starting nothing. `f2` translates that through
1879    /// [`Self::can_request_disable`]: a policy that cannot legally drain and is
1880    /// already not enabled has nothing to do, which is a successful outcome for
1881    /// the command and not an error to print.
1882    ///
1883    /// # Errors
1884    /// [`PolicyError::IllegalTransition`] when the policy is not `Active`.
1885    pub fn request_disable(&mut self) -> Result<PolicyState, PolicyError> {
1886        self.transition_to(PolicyState::Draining)?;
1887        self.enabled = false;
1888        Ok(self.state)
1889    }
1890
1891    /// Flow 5.3: "When active local runners reach zero … the policy becomes
1892    /// `disabled`."
1893    ///
1894    /// Returns the state after the call, unchanged when runners remain — a
1895    /// draining policy with work in flight is not an error, it is the normal
1896    /// case for the duration of the last job.
1897    ///
1898    /// # Errors
1899    /// [`PolicyError::IllegalTransition`] when the policy is not `Draining`.
1900    pub fn drain_completed(&mut self, active_attempts: u16) -> Result<PolicyState, PolicyError> {
1901        if self.state != PolicyState::Draining {
1902            return Err(PolicyError::IllegalTransition {
1903                from: self.state,
1904                to: PolicyState::Disabled,
1905            });
1906        }
1907        if active_attempts == 0 {
1908            self.transition_to(PolicyState::Disabled)?;
1909        }
1910        Ok(self.state)
1911    }
1912
1913    /// Any state -> `AuthenticationFailed` (flow 4.5).
1914    ///
1915    /// # Errors
1916    /// Only when already in `AuthenticationFailed`; re-reporting the same
1917    /// failure is not a transition.
1918    pub fn authentication_failed(&mut self) -> Result<(), PolicyError> {
1919        self.transition_to(PolicyState::AuthenticationFailed)
1920    }
1921
1922    /// "(recoverable by re-authentication)".
1923    ///
1924    /// # Errors
1925    /// [`PolicyError::IllegalTransition`] unless the policy is in
1926    /// `AuthenticationFailed`.
1927    pub fn reauthenticated(&mut self) -> Result<(), PolicyError> {
1928        self.transition_to(PolicyState::Pending)
1929    }
1930
1931    /// Flow 1.4: a local transaction that did not complete.
1932    ///
1933    /// # Errors
1934    /// [`PolicyError::IllegalTransition`] unless the policy is `Pending`.
1935    pub fn repair_required(&mut self) -> Result<(), PolicyError> {
1936        self.transition_to(PolicyState::RepairRequired)
1937    }
1938
1939    /// D19 promotion: `set-capacity` on a monitor-only policy.
1940    ///
1941    /// The routing label is derived at this point and not before, because a
1942    /// monitor-only policy reserves none (`f2`).
1943    ///
1944    /// # Errors
1945    /// [`PolicyError::AlreadyAutoscale`] when the policy already autoscales, or
1946    /// [`PolicyError::InvertedCapacityRange`].
1947    pub fn promote_to_autoscale(
1948        &mut self,
1949        routing_labels: RoutingLabels,
1950        min_capacity: u16,
1951        max_capacity: NonZeroU16,
1952    ) -> Result<(), PolicyError> {
1953        if !self.profile_name.is_default() {
1954            return Err(PolicyError::NamedProfileNeedsDerivedSelector);
1955        }
1956        if self.mode.is_autoscale() {
1957            return Err(PolicyError::AlreadyAutoscale);
1958        }
1959        self.mode = PolicyMode::autoscale(routing_labels, min_capacity, max_capacity)?;
1960        self.revision = self.revision.saturating_add(1);
1961        Ok(())
1962    }
1963
1964    /// Promote a named monitor profile with a selector derived from its host
1965    /// identity, platform and immutable profile name.
1966    pub fn promote_named_to_autoscale(
1967        &mut self,
1968        os: Os,
1969        arch: Arch,
1970        min_capacity: u16,
1971        max_capacity: NonZeroU16,
1972    ) -> Result<(), PolicyError> {
1973        if self.profile_name.is_default() {
1974            return Err(PolicyError::NamedConstructorForDefault);
1975        }
1976        if self.mode.is_autoscale() {
1977            return Err(PolicyError::AlreadyAutoscale);
1978        }
1979        let labels = RoutingLabels::derive_for_profile(
1980            &self.requested_host_label,
1981            os,
1982            arch,
1983            &self.profile_name,
1984        );
1985        self.mode = PolicyMode::autoscale(labels, min_capacity, max_capacity)?;
1986        self.revision = self.revision.saturating_add(1);
1987        Ok(())
1988    }
1989
1990    /// `repo set-capacity` / `org set-capacity` on a policy that already
1991    /// autoscales.
1992    ///
1993    /// # Errors
1994    /// [`PolicyError::NotAutoscale`] when the policy is monitor-only (use
1995    /// [`Self::promote_to_autoscale`]), or
1996    /// [`PolicyError::InvertedCapacityRange`].
1997    pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
1998        match &mut self.mode {
1999            PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
2000            PolicyMode::Autoscale(cfg) => {
2001                cfg.set_max_capacity(max_capacity)?;
2002                self.revision = self.revision.saturating_add(1);
2003                Ok(())
2004            }
2005        }
2006    }
2007
2008    /// Add an optional descriptive routing label.
2009    ///
2010    /// # Errors
2011    /// [`PolicyError::NotAutoscale`] for a monitor-only policy, which owns no
2012    /// label set to add to. This once reported a `MonitorOnlyWithRoutingLabels`
2013    /// variant, which said that a *stored row* had an illegal shape — a
2014    /// different claim from "this operation needs an autoscale policy", and one
2015    /// that had `f2` rendering a validation failure for an ordinary wrong-mode
2016    /// refusal. That variant is now gone entirely; see the note where it stood.
2017    ///
2018    /// Also [`PolicyError::InvalidExclusion`] and
2019    /// [`PolicyError::ContradictoryExclusion`]: see [`check_exclusion_label`].
2020    pub fn add_routing_label(&mut self, label: Label) -> Result<bool, PolicyError> {
2021        match &mut self.mode {
2022            PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
2023            PolicyMode::Autoscale(cfg) => {
2024                check_exclusion_label(cfg.routing_labels(), &label)?;
2025                let added = cfg.routing_labels_mut().add(label);
2026                if added {
2027                    self.revision = self.revision.saturating_add(1);
2028                }
2029                Ok(added)
2030            }
2031        }
2032    }
2033
2034    /// Remove an optional descriptive routing label.
2035    ///
2036    /// # Errors
2037    /// [`PolicyError::HostLabelNotRemovable`] for the derived host label, or
2038    /// [`PolicyError::NotAutoscale`] for a monitor-only policy — see
2039    /// [`Self::add_routing_label`] for why that variant.
2040    pub fn remove_routing_label(&mut self, label: &Label) -> Result<bool, PolicyError> {
2041        match &mut self.mode {
2042            PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
2043            PolicyMode::Autoscale(cfg) => {
2044                let removed = cfg.routing_labels_mut().remove(label)?;
2045                if removed {
2046                    self.revision = self.revision.saturating_add(1);
2047                }
2048                Ok(removed)
2049            }
2050        }
2051    }
2052
2053    /// The demand signal for this policy, given one poll's queued jobs.
2054    ///
2055    /// A monitor-only policy has no routing labels, so it has no demand at all —
2056    /// not "demand that is then ignored". D19: it "is skipped entirely by
2057    /// reconciliation".
2058    #[must_use]
2059    pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
2060        match self.routing_labels() {
2061            Some(labels) => labels.tally(jobs),
2062            None => DemandTally::default(),
2063        }
2064    }
2065}
2066
2067#[cfg(test)]
2068mod tests {
2069    use super::*;
2070    use crate::model::{HostId, PolicyId, TargetScope};
2071
2072    fn nz(v: u16) -> NonZeroU16 {
2073        NonZeroU16::new(v).expect("test capacity is non-zero")
2074    }
2075
2076    fn label(s: &str) -> Label {
2077        Label::new(s).expect("test label is valid")
2078    }
2079
2080    fn host_labels(host: &str) -> RoutingLabels {
2081        RoutingLabels::derive(&HostLabel::new(host).unwrap(), Os::Windows, Arch::X64)
2082    }
2083
2084    fn autoscale_policy(target: ScaleTarget, host: HostId, max: u16) -> ScalePolicy {
2085        ScalePolicy::new(
2086            PolicyId::from_u128(1),
2087            target,
2088            42,
2089            host,
2090            PolicyMode::autoscale(host_labels("home"), 0, nz(max)).unwrap(),
2091            CachePolicy::default(),
2092        )
2093    }
2094
2095    #[test]
2096    fn each_os_has_one_pool_label_and_the_namespace_is_reserved() {
2097        assert_eq!(pool_label(Os::Windows).as_str(), "rm-pool-windows");
2098        assert_eq!(pool_label(Os::Linux).as_str(), "rm-pool-linux");
2099        assert_eq!(pool_label(Os::MacOs).as_str(), "rm-pool-macos");
2100        assert!(is_pool_label("RM-POOL-linux"));
2101        assert!(!is_pool_label("rm-home-linux-x64"));
2102        assert!(!is_pool_label("rm-pool"));
2103        // A selector derived from `--host-label pool` is not a pool.
2104        assert!(!is_pool_label("rm-pool-linux-x64"));
2105        assert!(in_pool_namespace("rm-pool-linux-x64"));
2106        assert!(check_pool_label(&label("rm-pool-linux"), Os::Linux).is_ok());
2107        assert!(check_pool_label(&label("self-hosted"), Os::Linux).is_ok());
2108        assert!(matches!(
2109            check_pool_label(&label("rm-pool-windows"), Os::Linux),
2110            Err(PolicyError::PoolLabelMismatch { expected, .. }) if expected.as_str() == "rm-pool-linux"
2111        ));
2112        assert!(check_pool_label(&label("rm-pool-linux-heavy"), Os::Linux).is_err());
2113
2114        let mut labels = host_labels("home");
2115        assert_eq!(labels.pool_labels().next(), None);
2116        labels.add(label("linux"));
2117        labels.add(label("rm-pool-windows"));
2118        assert_eq!(
2119            labels.pool_labels().collect::<Vec<_>>(),
2120            [&label("rm-pool-windows")]
2121        );
2122    }
2123
2124    #[test]
2125    fn the_any_pool_is_a_pool_every_os_may_carry_beside_its_own() {
2126        assert_eq!(any_pool_label().as_str(), ANY_POOL_LABEL);
2127        assert!(is_pool_label("rm-pool-any") && is_pool_label("RM-Pool-Any"));
2128        assert!(is_any_pool_label("RM-POOL-ANY"));
2129        assert!(!is_any_pool_label("rm-pool-linux"));
2130        assert!(!is_pool_label("rm-pool-any-heavy"));
2131        for os in Os::ALL {
2132            assert!(check_pool_label(&label("rm-pool-any"), os).is_ok());
2133        }
2134        assert!(check_pool_label(&label("rm-pool-any-heavy"), Os::Linux).is_err());
2135
2136        let mut labels = host_labels("home");
2137        labels.add(label("rm-pool-linux"));
2138        labels.add(label("rm-pool-any"));
2139        labels.add(label("heavy"));
2140        assert_eq!(labels.pool_labels().count(), 2);
2141    }
2142
2143    // =======================================================================
2144    // Workspace policy (D4, D7)
2145    // =======================================================================
2146
2147    fn workspace_root() -> LocalAbsolutePath {
2148        LocalAbsolutePath::parse_for("/srv/rman/acme", crate::path::PathPlatform::Unix)
2149            .expect("a valid persistent root")
2150    }
2151
2152    fn repository_policy() -> ScalePolicy {
2153        autoscale_policy(
2154            ScaleTarget::repository("acme/api").unwrap(),
2155            HostId::from_u128(1),
2156            4,
2157        )
2158    }
2159
2160    fn organization_policy() -> ScalePolicy {
2161        autoscale_policy(
2162            ScaleTarget::organization("acme").unwrap(),
2163            HostId::from_u128(1),
2164            4,
2165        )
2166    }
2167
2168    #[test]
2169    fn every_constructor_produces_an_ephemeral_workspace() {
2170        // D3: `repo add` and `org add` never arm persistence, so a policy this
2171        // build creates behaves exactly as it did before D4 existed.
2172        for policy in [repository_policy(), organization_policy()] {
2173            assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
2174            assert!(!policy.workspace_policy().retains_job_workspace());
2175            assert_eq!(
2176                policy.to_persisted().workspace_kind,
2177                WorkspaceKind::Ephemeral
2178            );
2179            assert_eq!(policy.to_persisted().workspace_root, None);
2180        }
2181
2182        let monitor_only = ScalePolicy::new(
2183            PolicyId::from_u128(2),
2184            ScaleTarget::repository("acme/api").unwrap(),
2185            42,
2186            HostId::from_u128(1),
2187            PolicyMode::MonitorOnly,
2188            CachePolicy::default(),
2189        );
2190        assert_eq!(monitor_only.workspace_policy(), &WorkspacePolicy::Ephemeral);
2191    }
2192
2193    #[test]
2194    fn a_repository_policy_can_opt_into_a_persistent_workspace() {
2195        let mut policy = repository_policy();
2196        let before = policy.revision();
2197
2198        policy
2199            .set_workspace_policy(
2200                WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
2201                    .expect("a repository may be persistent"),
2202            )
2203            .expect("a repository policy accepts persistence");
2204
2205        assert!(policy.workspace_policy().is_persistent());
2206        assert_eq!(policy.workspace_policy().root(), Some(&workspace_root()));
2207        assert_eq!(
2208            policy.revision(),
2209            before + 1,
2210            "a workspace change must bump the optimistic token, or `a2`'s guard \
2211             cannot refuse a write built from a stale read"
2212        );
2213
2214        // Setting the same value again is not a change and must not consume a
2215        // revision, which would make an idempotent CLI call race the next writer.
2216        let unchanged = policy.revision();
2217        policy
2218            .set_workspace_policy(policy.workspace_policy().clone())
2219            .expect("re-setting the same policy is accepted");
2220        assert_eq!(policy.revision(), unchanged);
2221    }
2222
2223    #[test]
2224    fn an_organization_policy_cannot_be_made_persistent() {
2225        // D7: an organization runner can accept jobs from more than one
2226        // repository, so a retained `_work` would cross a repository boundary.
2227        let mut policy = organization_policy();
2228        let before = policy.revision();
2229
2230        assert_eq!(
2231            policy.set_workspace_policy(WorkspacePolicy::Persistent {
2232                root: workspace_root()
2233            }),
2234            Err(PolicyError::Workspace(
2235                WorkspaceError::PersistentRequiresRepositoryScope
2236            ))
2237        );
2238        assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
2239        assert_eq!(
2240            policy.revision(),
2241            before,
2242            "a refused write consumes nothing"
2243        );
2244
2245        // The constructor refuses the same thing, so there is no way to build the
2246        // value and hand it in already-made.
2247        assert_eq!(
2248            WorkspacePolicy::persistent(workspace_root(), TargetScope::Organization),
2249            Err(WorkspaceError::PersistentRequiresRepositoryScope)
2250        );
2251    }
2252
2253    #[test]
2254    fn a_workspace_policy_round_trips_through_the_persisted_struct() {
2255        let mut policy = repository_policy();
2256        policy
2257            .set_workspace_policy(
2258                WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
2259                    .expect("a repository may be persistent"),
2260            )
2261            .expect("a repository policy accepts persistence");
2262
2263        let restored = ScalePolicy::from_persisted(policy.to_persisted())
2264            .expect("a policy this crate wrote must load");
2265        assert_eq!(restored, policy);
2266        assert_eq!(restored.workspace_policy(), policy.workspace_policy());
2267
2268        let ephemeral = repository_policy();
2269        assert_eq!(
2270            ScalePolicy::from_persisted(ephemeral.to_persisted()).expect("must load"),
2271            ephemeral
2272        );
2273    }
2274
2275    #[test]
2276    fn an_organization_row_claiming_persistence_fails_closed_on_load() {
2277        let mut fields = organization_policy().to_persisted();
2278        fields.workspace_kind = WorkspaceKind::Persistent;
2279        fields.workspace_root = Some(workspace_root());
2280
2281        assert_eq!(
2282            ScalePolicy::from_persisted(fields),
2283            Err(PolicyError::Workspace(
2284                WorkspaceError::PersistentRequiresRepositoryScope
2285            ))
2286        );
2287    }
2288
2289    #[test]
2290    fn a_row_whose_workspace_columns_disagree_fails_closed_on_load() {
2291        let base = repository_policy().to_persisted();
2292
2293        let mut without_root = base.clone();
2294        without_root.workspace_kind = WorkspaceKind::Persistent;
2295        assert_eq!(
2296            ScalePolicy::from_persisted(without_root),
2297            Err(PolicyError::Workspace(
2298                WorkspaceError::PersistentWithoutRoot
2299            ))
2300        );
2301
2302        let mut stale_root = base;
2303        stale_root.workspace_root = Some(workspace_root());
2304        assert!(matches!(
2305            ScalePolicy::from_persisted(stale_root),
2306            Err(PolicyError::Workspace(
2307                WorkspaceError::EphemeralWithRoot { .. }
2308            ))
2309        ));
2310    }
2311
2312    #[test]
2313    fn workspace_retention_is_not_the_runner_package_cache_policy() {
2314        // `02-target-architecture.md`: "`WorkspacePolicy` is separate from
2315        // `CachePolicy`: runner-package retention and job-workspace retention
2316        // answer different questions and have different cleanup paths."
2317        let mut policy = repository_policy();
2318        policy.cache_policy = CachePolicy::DiscardRunnerPackage;
2319        policy
2320            .set_workspace_policy(
2321                WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
2322                    .expect("a repository may be persistent"),
2323            )
2324            .expect("a repository policy accepts persistence");
2325
2326        assert!(policy.workspace_policy().retains_job_workspace());
2327        assert!(!policy.cache_policy.retains_runner_package());
2328        // The v1 constant is unchanged and still answers only for the package
2329        // cache; D4's decision is spelled on the other type on purpose.
2330        assert!(!policy.cache_policy.retains_job_workspace());
2331    }
2332
2333    // =======================================================================
2334    // Routing-label derivation
2335    // =======================================================================
2336
2337    #[test]
2338    fn the_derived_label_has_the_shape_the_architecture_gives() {
2339        // `02-target-architecture.md`: "for example `rm-home-win-x64`".
2340        let labels =
2341            RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
2342        assert_eq!(labels.host_label().as_str(), "rm-home-win-x64");
2343        assert_eq!(labels.count().get(), 1);
2344    }
2345
2346    #[test]
2347    fn the_derived_label_is_host_scoped_by_construction() {
2348        // `b1`: "the derived label ... is the only control that stops two hosts
2349        // from starting a runner for the same queued job". Same target, same OS,
2350        // same architecture, two hosts -> two different labels.
2351        let a = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
2352        let b = RoutingLabels::derive(&HostLabel::new("office").unwrap(), Os::Windows, Arch::X64);
2353
2354        assert_ne!(
2355            a.host_label(),
2356            b.host_label(),
2357            "two hosts must not derive the same routing label; with no job \
2358             reservation, a shared label means both hosts start a runner for one job"
2359        );
2360        assert_eq!(a.host_label().as_str(), "rm-home-win-x64");
2361        assert_eq!(b.host_label().as_str(), "rm-office-win-x64");
2362
2363        // The OS and architecture segments are host facts too.
2364        let mac = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::MacOs, Arch::Arm64);
2365        assert_eq!(mac.host_label().as_str(), "rm-home-osx-arm64");
2366        assert_ne!(a.host_label(), mac.host_label());
2367    }
2368
2369    #[test]
2370    fn a_mixed_case_host_label_still_derives_a_lower_case_routing_label() {
2371        // GitHub lower-cases labels on registration
2372        // (`docs/spikes/d18-org-jit-verification.md`, Point 3, finding 3), so a
2373        // derived label that kept case would not match what comes back.
2374        let labels =
2375            RoutingLabels::derive(&HostLabel::new("Home-PC").unwrap(), Os::Linux, Arch::X64);
2376        assert_eq!(labels.host_label().as_str(), "rm-home-pc-linux-x64");
2377    }
2378
2379    #[test]
2380    fn optional_labels_can_be_added_and_removed_but_the_host_label_cannot() {
2381        let mut labels = host_labels("home");
2382        let derived = labels.host_label().clone();
2383
2384        assert!(labels.add(label("gpu")));
2385        assert!(labels.add(label("self-hosted")));
2386        assert!(
2387            !labels.add(label("GPU")),
2388            "adding a label that differs only in case must be a no-op, not a duplicate"
2389        );
2390        assert_eq!(labels.count().get(), 3);
2391
2392        assert!(labels.remove(&label("gpu")).unwrap());
2393        assert_eq!(labels.count().get(), 2);
2394        assert!(
2395            !labels.remove(&label("never-added")).unwrap(),
2396            "removing an absent optional label is a no-op, not an error"
2397        );
2398
2399        // The invariant.
2400        assert!(
2401            matches!(
2402                labels.remove(&derived),
2403                Err(PolicyError::HostLabelNotRemovable { .. })
2404            ),
2405            "the derived host label must not be removable; it is the only thing \
2406             keeping two hosts from serving each other's jobs"
2407        );
2408        assert!(labels.contains(&derived));
2409
2410        // And not by case-dodging either, since Label folds case.
2411        assert!(matches!(
2412            labels.remove(&label("RM-HOME-WIN-X64")),
2413            Err(PolicyError::HostLabelNotRemovable { .. })
2414        ));
2415    }
2416
2417    #[test]
2418    fn adding_the_host_label_as_an_optional_label_does_not_duplicate_it() {
2419        let mut labels = host_labels("home");
2420        let derived = labels.host_label().clone();
2421        assert!(!labels.add(derived));
2422        assert_eq!(labels.count().get(), 1);
2423
2424        // Nor via a stored set that repeats it.
2425        let rebuilt = RoutingLabels::from_parts(
2426            labels.host_label().clone(),
2427            vec![labels.host_label().clone(), label("gpu")],
2428        );
2429        assert_eq!(rebuilt.count().get(), 2);
2430        assert_eq!(
2431            rebuilt.as_registration_labels(),
2432            vec!["rm-home-win-x64", "gpu"]
2433        );
2434    }
2435
2436    #[test]
2437    fn the_registration_array_is_exactly_the_label_set_and_adds_nothing() {
2438        // `docs/spikes/d18-org-jit-verification.md`, Point 3, finding 1: "No
2439        // labels are added implicitly." So this array is the whole contract with
2440        // GitHub, and it must not quietly gain `self-hosted`, the OS, or the
2441        // architecture.
2442        let mut labels = host_labels("home");
2443        labels.add(label("gpu"));
2444        assert_eq!(
2445            labels.as_registration_labels(),
2446            vec!["rm-home-win-x64", "gpu"]
2447        );
2448        assert!(!labels.contains(&label("self-hosted")));
2449    }
2450
2451    #[test]
2452    fn routing_labels_round_trip_through_serde_with_the_host_label_intact() {
2453        let mut labels = host_labels("home");
2454        labels.add(label("gpu"));
2455        let json = serde_json::to_string(&labels).unwrap();
2456        let back: RoutingLabels = serde_json::from_str(&json).unwrap();
2457        assert_eq!(labels, back);
2458        assert_eq!(back.host_label().as_str(), "rm-home-win-x64");
2459    }
2460
2461    #[test]
2462    fn a_non_empty_view_of_the_label_set_is_available_in_the_contract_shape() {
2463        // `04-subsystem-contracts.md` types this as `Option<NonEmpty<Label>>`.
2464        let labels = host_labels("home");
2465        let non_empty = labels.to_non_empty();
2466        assert_eq!(non_empty.count().get(), 1);
2467        assert_eq!(non_empty.first().as_str(), "rm-home-win-x64");
2468    }
2469
2470    // =======================================================================
2471    // `runs-on` matching -- the table
2472    // =======================================================================
2473
2474    /// One row of the `runs-on` table. `expect` is asserted exactly, so a case
2475    /// that starts silently returning `Unresolvable` instead of `NoMatch` fails.
2476    struct Row {
2477        name: &'static str,
2478        runs_on: RunsOn,
2479        expect: Expect,
2480    }
2481
2482    #[derive(Debug, PartialEq, Eq)]
2483    enum Expect {
2484        Match,
2485        NoMatch,
2486        Unresolvable,
2487    }
2488
2489    fn classify(m: &RunsOnMatch) -> Expect {
2490        match m {
2491            RunsOnMatch::Match { .. } => Expect::Match,
2492            RunsOnMatch::NoMatch { .. } => Expect::NoMatch,
2493            RunsOnMatch::Unresolvable(_) => Expect::Unresolvable,
2494        }
2495    }
2496
2497    fn table_policy() -> RoutingLabels {
2498        // rm-home-win-x64 plus two optional labels.
2499        let mut labels = host_labels("home");
2500        labels.add(label("self-hosted"));
2501        labels.add(label("gpu"));
2502        labels
2503    }
2504
2505    fn table() -> Vec<Row> {
2506        vec![
2507            // ---- single string form -------------------------------------
2508            Row {
2509                name: "string: the derived host label",
2510                runs_on: RunsOn::Single("rm-home-win-x64".into()),
2511                expect: Expect::Match,
2512            },
2513            Row {
2514                name: "string: the derived host label in the wrong case",
2515                runs_on: RunsOn::Single("RM-Home-Win-X64".into()),
2516                expect: Expect::Match,
2517            },
2518            Row {
2519                name: "string: an optional label alone",
2520                runs_on: RunsOn::Single("gpu".into()),
2521                expect: Expect::Match,
2522            },
2523            Row {
2524                name: "string: another host's label",
2525                runs_on: RunsOn::Single("rm-office-win-x64".into()),
2526                expect: Expect::NoMatch,
2527            },
2528            Row {
2529                name: "string: a GitHub-hosted runner label",
2530                runs_on: RunsOn::Single("ubuntu-latest".into()),
2531                expect: Expect::NoMatch,
2532            },
2533            // ---- array form ---------------------------------------------
2534            Row {
2535                name: "array: a strict subset of the policy's labels",
2536                runs_on: RunsOn::Many(vec!["self-hosted".into(), "rm-home-win-x64".into()]),
2537                expect: Expect::Match,
2538            },
2539            Row {
2540                name: "array: the whole set, out of order and mixed case",
2541                runs_on: RunsOn::Many(vec![
2542                    "GPU".into(),
2543                    "Rm-Home-Win-X64".into(),
2544                    "Self-Hosted".into(),
2545                ]),
2546                expect: Expect::Match,
2547            },
2548            Row {
2549                name: "array: one label the policy does not carry",
2550                runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "arm64".into()]),
2551                expect: Expect::NoMatch,
2552            },
2553            Row {
2554                name: "array: an empty array names no labels",
2555                runs_on: RunsOn::Many(vec![]),
2556                expect: Expect::Unresolvable,
2557            },
2558            // ---- group/labels map form ----------------------------------
2559            Row {
2560                name: "map: labels only",
2561                runs_on: RunsOn::Grouped {
2562                    group: None,
2563                    labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into()]),
2564                },
2565                expect: Expect::Match,
2566            },
2567            Row {
2568                name: "map: a group plus labels the policy carries",
2569                runs_on: RunsOn::Grouped {
2570                    group: Some("Default".into()),
2571                    labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into(), "gpu".into()]),
2572                },
2573                expect: Expect::Match,
2574            },
2575            Row {
2576                name: "map: labels as a scalar",
2577                runs_on: RunsOn::Grouped {
2578                    group: Some("Default".into()),
2579                    labels: RunsOnLabels::One("rm-home-win-x64".into()),
2580                },
2581                expect: Expect::Match,
2582            },
2583            Row {
2584                name: "map: a group plus a label the policy does not carry",
2585                runs_on: RunsOn::Grouped {
2586                    group: Some("Default".into()),
2587                    labels: RunsOnLabels::Many(vec!["macos".into()]),
2588                },
2589                expect: Expect::NoMatch,
2590            },
2591            Row {
2592                name: "map: a group with no labels constrains something we cannot read",
2593                runs_on: RunsOn::Grouped {
2594                    group: Some("Default".into()),
2595                    labels: RunsOnLabels::Many(vec![]),
2596                },
2597                expect: Expect::Unresolvable,
2598            },
2599            // ---- unresolvable -------------------------------------------
2600            Row {
2601                name: "expression: the whole value",
2602                runs_on: RunsOn::Single("${{ matrix.runner }}".into()),
2603                expect: Expect::Unresolvable,
2604            },
2605            Row {
2606                name: "expression: one element of an array",
2607                runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "${{ inputs.extra }}".into()]),
2608                expect: Expect::Unresolvable,
2609            },
2610            Row {
2611                name: "expression: inside the map form",
2612                runs_on: RunsOn::Grouped {
2613                    group: None,
2614                    labels: RunsOnLabels::One("${{ vars.LABEL }}".into()),
2615                },
2616                expect: Expect::Unresolvable,
2617            },
2618            Row {
2619                name: "not a usable label at all",
2620                runs_on: RunsOn::Single("rm-home,win-x64".into()),
2621                expect: Expect::Unresolvable,
2622            },
2623        ]
2624    }
2625
2626    #[test]
2627    fn runs_on_matching_covers_every_documented_form() {
2628        let policy = table_policy();
2629        for row in table() {
2630            let got = policy.matches(&row.runs_on);
2631            assert_eq!(
2632                classify(&got),
2633                row.expect,
2634                "row {:?}: {:?} produced {got:?}",
2635                row.name,
2636                row.runs_on
2637            );
2638        }
2639    }
2640
2641    #[test]
2642    fn github_job_labels_are_a_case_insensitive_subset_without_a_selector() {
2643        let labels = RoutingLabels::from_parts(
2644            label("rm-macmini-osx-arm64"),
2645            [
2646                label("arm64"),
2647                label("macos"),
2648                label("self-hosted"),
2649                label("extra"),
2650            ],
2651        );
2652        assert!(
2653            labels
2654                .matches(&RunsOn::Many(vec![
2655                    "self-hosted".into(),
2656                    "macOS".into(),
2657                    "ARM64".into(),
2658                ]))
2659                .is_match()
2660        );
2661        for missing in ["X64", "Windows", "Linux", "gpu", "rm-other-osx-arm64"] {
2662            let result = labels.matches(&RunsOn::Many(vec![
2663                "self-hosted".into(),
2664                "macOS".into(),
2665                "ARM64".into(),
2666                missing.into(),
2667            ]));
2668            assert_eq!(
2669                result,
2670                RunsOnMatch::NoMatch {
2671                    missing: vec![label(missing)]
2672                }
2673            );
2674        }
2675    }
2676
2677    #[test]
2678    fn self_hosted_is_not_implicit_and_must_be_carried_to_be_matched() {
2679        // `docs/spikes/d18-org-jit-verification.md`, Point 3, finding 1: "A
2680        // workflow written as `runs-on: self-hosted` will not match a runner
2681        // registered without that label."
2682        let without = host_labels("home");
2683        assert!(
2684            !without
2685                .matches(&RunsOn::Single("self-hosted".into()))
2686                .is_match(),
2687            "a policy that does not carry `self-hosted` must not claim a job that asks for it"
2688        );
2689
2690        let mut with = host_labels("home");
2691        with.add(label("self-hosted"));
2692        assert!(
2693            with.matches(&RunsOn::Single("self-hosted".into()))
2694                .is_match()
2695        );
2696        assert!(
2697            with.matches(&RunsOn::Many(vec![
2698                "rm-home-win-x64".into(),
2699                "self-hosted".into()
2700            ]))
2701            .is_match()
2702        );
2703    }
2704
2705    #[test]
2706    fn a_no_match_names_the_labels_that_were_missing() {
2707        let policy = host_labels("home");
2708        let got = policy.matches(&RunsOn::Many(vec![
2709            "rm-home-win-x64".into(),
2710            "self-hosted".into(),
2711            "GPU".into(),
2712        ]));
2713        match got {
2714            RunsOnMatch::NoMatch { missing } => {
2715                assert_eq!(
2716                    missing,
2717                    vec![label("self-hosted"), label("gpu")],
2718                    "the operator needs to know which labels to add"
2719                );
2720            }
2721            other => panic!("expected NoMatch, got {other:?}"),
2722        }
2723    }
2724
2725    #[test]
2726    fn a_matching_map_form_carries_its_runner_group_through_rather_than_dropping_it() {
2727        let policy = host_labels("home");
2728        let got = policy.matches(&RunsOn::Grouped {
2729            group: Some("Default".into()),
2730            labels: RunsOnLabels::One("rm-home-win-x64".into()),
2731        });
2732        assert_eq!(
2733            got,
2734            RunsOnMatch::Match {
2735                runner_group: Some("Default".into())
2736            },
2737            "a policy has no runner-group field, so the domain cannot evaluate \
2738             `group:`; returning it lets `c4`, which can, do so without re-parsing"
2739        );
2740    }
2741
2742    #[test]
2743    fn each_unresolvable_reason_is_distinct_rather_than_one_catch_all() {
2744        let policy = host_labels("home");
2745
2746        let expr = policy.matches(&RunsOn::Single("${{ matrix.os }}".into()));
2747        assert!(matches!(
2748            expr,
2749            RunsOnMatch::Unresolvable(UnresolvableRunsOn::Expression { .. })
2750        ));
2751
2752        let group = policy.matches(&RunsOn::Grouped {
2753            group: Some("g".into()),
2754            labels: RunsOnLabels::Many(vec![]),
2755        });
2756        assert!(matches!(
2757            group,
2758            RunsOnMatch::Unresolvable(UnresolvableRunsOn::RunnerGroupWithoutLabels { .. })
2759        ));
2760
2761        let none = policy.matches(&RunsOn::Many(vec![]));
2762        assert!(matches!(
2763            none,
2764            RunsOnMatch::Unresolvable(UnresolvableRunsOn::NoLabels)
2765        ));
2766
2767        let invalid = policy.matches(&RunsOn::Single("a,b".into()));
2768        assert!(matches!(
2769            invalid,
2770            RunsOnMatch::Unresolvable(UnresolvableRunsOn::InvalidLabel { .. })
2771        ));
2772    }
2773
2774    #[test]
2775    fn an_unresolvable_runs_on_is_neither_counted_as_demand_nor_dropped() {
2776        // `b1`: "treat a `runs-on` that cannot be resolved statically ... as
2777        // **not** demand, reported as unresolvable rather than silently counted
2778        // or silently dropped."
2779        let policy = table_policy();
2780        let jobs = vec![
2781            RunsOn::Single("rm-home-win-x64".into()),      // matched
2782            RunsOn::Single("ubuntu-latest".into()),        // not matched
2783            RunsOn::Single("${{ matrix.runner }}".into()), // unresolvable
2784            RunsOn::Single("${{ inputs.pool }}".into()),   // unresolvable
2785        ];
2786        let tally = policy.tally(&jobs);
2787
2788        assert_eq!(tally.demand(), 1, "an expression must not inflate demand");
2789        assert_eq!(tally.not_matched, 1);
2790        assert_eq!(
2791            tally.unresolvable.len(),
2792            2,
2793            "and it must not vanish either -- `g2` shows these to the operator"
2794        );
2795        assert_eq!(
2796            tally.total_seen(),
2797            jobs.len() as u32,
2798            "every job seen is accounted for in exactly one bucket"
2799        );
2800    }
2801
2802    #[test]
2803    fn runs_on_deserialises_from_each_json_shape_github_and_workflow_files_use() {
2804        let single: RunsOn = serde_json::from_str(r#""ubuntu-latest""#).unwrap();
2805        assert_eq!(single, RunsOn::Single("ubuntu-latest".into()));
2806
2807        let many: RunsOn = serde_json::from_str(r#"["self-hosted","linux"]"#).unwrap();
2808        assert_eq!(
2809            many,
2810            RunsOn::Many(vec!["self-hosted".into(), "linux".into()])
2811        );
2812
2813        let grouped: RunsOn = serde_json::from_str(r#"{"group":"g","labels":["a","b"]}"#).unwrap();
2814        assert_eq!(
2815            grouped,
2816            RunsOn::Grouped {
2817                group: Some("g".into()),
2818                labels: RunsOnLabels::Many(vec!["a".into(), "b".into()]),
2819            }
2820        );
2821
2822        let scalar_labels: RunsOn = serde_json::from_str(r#"{"labels":"a"}"#).unwrap();
2823        assert_eq!(
2824            scalar_labels,
2825            RunsOn::Grouped {
2826                group: None,
2827                labels: RunsOnLabels::One("a".into()),
2828            }
2829        );
2830
2831        let group_only: RunsOn = serde_json::from_str(r#"{"group":"g"}"#).unwrap();
2832        assert_eq!(
2833            group_only,
2834            RunsOn::Grouped {
2835                group: Some("g".into()),
2836                labels: RunsOnLabels::Many(vec![]),
2837            }
2838        );
2839
2840        // The array form is what the jobs API actually returns.
2841        assert_eq!(
2842            RunsOn::from_job_labels(["rm-home-win-x64", "gpu"]),
2843            RunsOn::Many(vec!["rm-home-win-x64".into(), "gpu".into()])
2844        );
2845    }
2846
2847    // =======================================================================
2848    // PolicyMode (D19)
2849    // =======================================================================
2850
2851    #[test]
2852    fn an_autoscale_policy_without_a_ceiling_or_a_label_cannot_be_persisted() {
2853        let labels = host_labels("home");
2854
2855        // Autoscale requires both. Neither illegal combination survives the
2856        // load path.
2857        assert!(matches!(
2858            PolicyMode::from_persisted(Some(labels.clone()), 0, None),
2859            Err(PolicyError::AutoscaleWithoutMaxCapacity)
2860        ));
2861        assert!(matches!(
2862            PolicyMode::from_persisted(None, 0, Some(nz(1))),
2863            Err(PolicyError::AutoscaleWithoutRoutingLabels)
2864        ));
2865
2866        // And both legal shapes do.
2867        assert!(
2868            PolicyMode::from_persisted(None, 0, None)
2869                .unwrap()
2870                .is_monitor_only()
2871        );
2872        assert!(
2873            PolicyMode::from_persisted(Some(labels), 0, Some(nz(2)))
2874                .unwrap()
2875                .is_autoscale()
2876        );
2877    }
2878
2879    #[test]
2880    fn the_illegal_policy_mode_combinations_have_no_in_memory_representation() {
2881        // The strongest form of `b1`'s D19 requirement: not merely that the
2882        // illegal shapes are rejected on the way in, but that they cannot be
2883        // built. `PolicyMode::Autoscale` holds an `AutoscaleConfig` whose
2884        // `routing_labels` and `max_capacity` are unconditional, so there is no
2885        // constructor, field assignment, or `Default` that produces an autoscale
2886        // policy missing either.
2887        let autoscale = PolicyMode::autoscale(host_labels("home"), 0, nz(3)).unwrap();
2888        assert!(autoscale.routing_labels().is_some());
2889        assert!(autoscale.max_capacity().is_some());
2890
2891        let monitor = PolicyMode::monitor_only();
2892        assert!(monitor.routing_labels().is_none());
2893        assert!(monitor.max_capacity().is_none());
2894        assert_eq!(monitor.min_capacity(), 0);
2895    }
2896
2897    #[test]
2898    fn a_monitor_only_row_carrying_capacity_or_labels_is_refused_by_name() {
2899        // `b2` loads a hand-edited database through `from_persisted`, and needs
2900        // to say which column is wrong.
2901        let err = PolicyMode::from_persisted(None, 2, None).unwrap_err();
2902        assert!(matches!(
2903            err,
2904            PolicyError::MonitorOnlyWithMinCapacity { min: 2 }
2905        ));
2906        assert!(
2907            err.to_string().contains("MonitorOnly"),
2908            "the message must name the shape rule, got: {err}"
2909        );
2910    }
2911
2912    #[test]
2913    fn an_inverted_capacity_range_is_rejected_so_clamp_is_always_well_defined() {
2914        // `04-subsystem-contracts.md`: "`min_capacity <= max_capacity` is
2915        // validated on every write of an `Autoscale` policy, so
2916        // `clamp(demand, min_capacity, max_capacity)` is always well-defined."
2917        // In Rust an inverted `clamp` panics, so this is the guard that keeps
2918        // `crate::capacity` total.
2919        assert!(matches!(
2920            PolicyMode::autoscale(host_labels("home"), 5, nz(2)),
2921            Err(PolicyError::InvertedCapacityRange { min: 5, max: 2 })
2922        ));
2923        assert!(PolicyMode::autoscale(host_labels("home"), 2, nz(2)).is_ok());
2924        assert!(PolicyMode::autoscale(host_labels("home"), 0, nz(1)).is_ok());
2925
2926        // Including when raising the floor past the ceiling later.
2927        let mut cfg = AutoscaleConfig::new(host_labels("home"), 2, nz(4)).unwrap();
2928        assert!(matches!(
2929            cfg.set_max_capacity(nz(1)),
2930            Err(PolicyError::InvertedCapacityRange { min: 2, max: 1 })
2931        ));
2932        assert_eq!(
2933            cfg.max_capacity().get(),
2934            4,
2935            "a refused write changes nothing"
2936        );
2937    }
2938
2939    #[test]
2940    fn a_policy_mode_round_trips_through_serde_and_the_gate_holds_on_the_way_back() {
2941        for mode in [
2942            PolicyMode::monitor_only(),
2943            PolicyMode::autoscale(host_labels("home"), 0, nz(4)).unwrap(),
2944        ] {
2945            let json = serde_json::to_string(&mode).unwrap();
2946            let back: PolicyMode = serde_json::from_str(&json).unwrap();
2947            assert_eq!(mode, back, "{json} did not round-trip");
2948        }
2949
2950        // A hand-written autoscale payload with an inverted range is refused at
2951        // deserialisation, not after it.
2952        let hostile = r#"{"mode":"autoscale","routing_labels":{"host_label":"rm-home-win-x64","additional":[]},"min_capacity":9,"max_capacity":1}"#;
2953        let err = serde_json::from_str::<PolicyMode>(hostile).unwrap_err();
2954        assert!(
2955            err.to_string().contains("min_capacity"),
2956            "expected the shape error to survive into serde's message, got: {err}"
2957        );
2958    }
2959
2960    #[test]
2961    fn a_policy_round_trips_through_its_persisted_form() {
2962        // `PersistedPolicy` exists to stop `installation_id` and `revision` --
2963        // two bare `u64`s -- being transposed on the way to and from storage.
2964        // Nothing exercised that: `PersistedPolicy` was constructed nowhere
2965        // outside `to_persisted`, `ScalePolicy::from_persisted` had no caller
2966        // and no test, and transposing the two fields inside `to_persisted` left
2967        // the whole suite green. The defect the type was introduced to prevent
2968        // was closed for attempts and left open for policies.
2969        let mut policy = autoscale_policy(
2970            ScaleTarget::repository("o/r").unwrap(),
2971            HostId::from_u128(7),
2972            3,
2973        );
2974        policy.add_routing_label(label("gpu")).unwrap();
2975        // `activate` is what makes the round trip worth asserting: it moves
2976        // `state` off `Pending`, `enabled` off `false`, and `revision` off `0`,
2977        // so all three are non-default and a field that failed to survive the
2978        // trip shows up as an inequality rather than as a default that happens
2979        // to match.
2980        policy.activate().unwrap();
2981        assert_eq!(policy.state(), PolicyState::Active);
2982        assert!(policy.enabled());
2983
2984        let stored = policy.to_persisted();
2985        assert_ne!(
2986            stored.installation_id, stored.revision,
2987            "the fixture must distinguish the two u64 columns, or transposing \
2988             them is unobservable and this test proves nothing"
2989        );
2990        assert_eq!(stored.installation_id, 42);
2991        assert_eq!(stored.revision, policy.revision());
2992
2993        let restored =
2994            ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
2995        assert_eq!(restored, policy);
2996        assert_eq!(restored.installation_id, 42);
2997        assert_eq!(restored.revision(), policy.revision());
2998        assert_eq!(restored.state(), PolicyState::Active);
2999        assert!(restored.enabled());
3000        assert_eq!(
3001            restored.routing_labels().unwrap().count().get(),
3002            2,
3003            "the optional label survives alongside the host label"
3004        );
3005    }
3006
3007    #[test]
3008    fn a_monitor_only_policy_round_trips_through_its_persisted_form() {
3009        // The other half of the mode inference. `to_persisted` writes a
3010        // MonitorOnly policy out through `min_capacity() == 0`,
3011        // `max_capacity() == None` and `routing_labels() == None`, and
3012        // `PolicyMode::from_persisted` reads the mode back *from those three
3013        // columns* rather than from a stored discriminant. That inference is the
3014        // reason `PolicyError::MonitorOnlyWithRoutingLabels` is unconstructible,
3015        // and until now it was never exercised end to end.
3016        let mut policy = ScalePolicy::new(
3017            PolicyId::from_u128(2),
3018            ScaleTarget::organization("acme").unwrap(),
3019            9,
3020            HostId::from_u128(7),
3021            PolicyMode::monitor_only(),
3022            CachePolicy::default(),
3023        );
3024        policy.activate().unwrap();
3025
3026        let stored = policy.to_persisted();
3027        assert!(stored.routing_labels.is_none());
3028        assert_eq!(stored.min_capacity, 0);
3029        assert!(stored.max_capacity.is_none());
3030        assert_ne!(stored.installation_id, stored.revision);
3031
3032        let restored =
3033            ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
3034        assert_eq!(restored, policy);
3035        assert!(
3036            restored.mode().is_monitor_only(),
3037            "the mode is inferred back from the three columns, not stored"
3038        );
3039        assert!(!restored.owns_runners());
3040        assert_eq!(restored.installation_id, 9);
3041        assert_eq!(restored.revision(), 1);
3042    }
3043
3044    // =======================================================================
3045    // PolicyState
3046    // =======================================================================
3047
3048    /// The diagram from `04-subsystem-contracts.md`, transcribed by hand.
3049    ///
3050    /// **Deliberately a second copy of [`PolicyState::LEGAL`].** A test that
3051    /// derives its expectation from the constant it is testing asserts only that
3052    /// the constant equals itself: adding `disabled -> active` to `LEGAL` would
3053    /// make such a test expect the new edge and pass, which is exactly what
3054    /// happened to the first version of this test and is why it was rewritten.
3055    /// Here the two lists must be edited together, so a one-sided change to
3056    /// either fails.
3057    ///
3058    /// ```text
3059    /// pending -> active | repair_required
3060    /// active  -> draining -> disabled -> pending
3061    /// any     -> authentication_failed        (recoverable by re-authentication)
3062    /// ```
3063    fn diagram_edges() -> Vec<(PolicyState, PolicyState)> {
3064        use PolicyState::*;
3065        let mut edges = vec![
3066            (Pending, Active),
3067            (Pending, RepairRequired),
3068            (Active, Draining),
3069            (Draining, Disabled),
3070            (Disabled, Pending),
3071            // The recovery edge for "(recoverable by re-authentication)".
3072            (AuthenticationFailed, Pending),
3073        ];
3074        // `any -> authentication_failed`, which is every state except itself.
3075        for from in PolicyState::ALL {
3076            if from != AuthenticationFailed {
3077                edges.push((from, AuthenticationFailed));
3078            }
3079        }
3080        edges
3081    }
3082
3083    #[test]
3084    fn every_policy_state_transition_is_legal_exactly_where_the_diagram_says() {
3085        // Both directions, over the full 6x6 product: each of the 11 legal pairs
3086        // succeeds, each of the other 25 is rejected.
3087        let expected = diagram_edges();
3088        assert_eq!(
3089            expected.len(),
3090            11,
3091            "the transcription itself changed; check it against the diagram"
3092        );
3093
3094        let mut legal_seen = 0usize;
3095        let mut illegal_seen = 0usize;
3096
3097        for from in PolicyState::ALL {
3098            for to in PolicyState::ALL {
3099                let expected_legal = expected.contains(&(from, to));
3100                let mut policy = autoscale_policy(
3101                    ScaleTarget::repository("o/r").unwrap(),
3102                    HostId::from_u128(7),
3103                    1,
3104                );
3105                // Force the starting state without going through the machine,
3106                // which is only possible from inside the module -- exactly the
3107                // reason this test lives here.
3108                policy.state = from;
3109
3110                let result = policy.transition_to(to);
3111                if expected_legal {
3112                    legal_seen += 1;
3113                    assert!(
3114                        result.is_ok(),
3115                        "{from} -> {to} is in the diagram and must be accepted"
3116                    );
3117                    assert_eq!(policy.state(), to);
3118                } else {
3119                    illegal_seen += 1;
3120                    assert!(
3121                        matches!(result, Err(PolicyError::IllegalTransition { .. })),
3122                        "{from} -> {to} is not in the diagram and must be rejected"
3123                    );
3124                    assert_eq!(policy.state(), from, "a refused transition changes nothing");
3125                }
3126            }
3127        }
3128
3129        assert_eq!(legal_seen, 11);
3130        assert_eq!(illegal_seen, 36 - 11);
3131
3132        // And the published constant matches the transcription, so a caller
3133        // reading `PolicyState::LEGAL` sees the same machine the tests exercise.
3134        let mut published = PolicyState::LEGAL.to_vec();
3135        let mut transcribed = expected;
3136        published.sort_unstable();
3137        transcribed.sort_unstable();
3138        assert_eq!(published, transcribed);
3139    }
3140
3141    #[test]
3142    fn a_policy_state_cannot_transition_to_itself() {
3143        for state in PolicyState::ALL {
3144            assert!(
3145                !state.can_transition_to(state),
3146                "{state} -> {state} is not an edge in the diagram; treating it as \
3147                 one would let a repeated authentication failure look like progress"
3148            );
3149        }
3150    }
3151
3152    #[test]
3153    fn the_documented_happy_path_walks_pending_to_disabled() {
3154        let mut policy = autoscale_policy(
3155            ScaleTarget::repository("o/r").unwrap(),
3156            HostId::from_u128(7),
3157            2,
3158        );
3159
3160        // D20: `add` never arms a host.
3161        assert_eq!(policy.state(), PolicyState::Pending);
3162        assert!(!policy.enabled());
3163        assert!(!policy.may_start_runners());
3164
3165        policy.activate().unwrap();
3166        assert_eq!(policy.state(), PolicyState::Active);
3167        assert!(policy.enabled());
3168        assert!(policy.may_start_runners());
3169
3170        // Flow 5.2 and 5.3.
3171        assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
3172        assert_eq!(
3173            policy.drain_completed(1).unwrap(),
3174            PolicyState::Draining,
3175            "a policy with a runner still in flight stays draining"
3176        );
3177        assert_eq!(policy.drain_completed(0).unwrap(), PolicyState::Disabled);
3178    }
3179
3180    #[test]
3181    fn a_disable_during_demand_yields_draining_and_beats_demand_immediately() {
3182        // `b1`: "Disable-during-demand yields draining". Precedence rule 4: "A
3183        // user-requested disable beats demand and starts draining."
3184        let mut policy = autoscale_policy(
3185            ScaleTarget::repository("o/r").unwrap(),
3186            HostId::from_u128(7),
3187            5,
3188        );
3189        policy.activate().unwrap();
3190
3191        // Demand exists and is unchanged by the disable -- the queue is left
3192        // visible (flow 5.2) -- but the policy stops being a reason to start.
3193        let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 4];
3194        assert_eq!(policy.tally(&jobs).demand(), 4);
3195
3196        assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
3197        assert!(!policy.enabled());
3198        assert!(
3199            !policy.may_start_runners(),
3200            "a draining policy must not be the reason a new runner starts, even \
3201             with four jobs queued for its labels"
3202        );
3203        assert_eq!(
3204            policy.tally(&jobs).demand(),
3205            4,
3206            "queued demand stays visible while draining (flow 5.2)"
3207        );
3208    }
3209
3210    #[test]
3211    fn re_authentication_is_the_only_way_out_of_authentication_failed() {
3212        for from in [
3213            PolicyState::Pending,
3214            PolicyState::Active,
3215            PolicyState::Draining,
3216            PolicyState::Disabled,
3217            PolicyState::RepairRequired,
3218        ] {
3219            let mut policy = autoscale_policy(
3220                ScaleTarget::repository("o/r").unwrap(),
3221                HostId::from_u128(7),
3222                1,
3223            );
3224            policy.state = from;
3225            policy.authentication_failed().unwrap();
3226            assert_eq!(policy.state(), PolicyState::AuthenticationFailed);
3227
3228            // Reporting the same failure twice is not a transition.
3229            assert!(matches!(
3230                policy.authentication_failed(),
3231                Err(PolicyError::IllegalTransition { .. })
3232            ));
3233
3234            policy.reauthenticated().unwrap();
3235            assert_eq!(policy.state(), PolicyState::Pending);
3236        }
3237    }
3238
3239    #[test]
3240    fn mutant_disabling_revoked_eligibility_gate_is_detected() {
3241        let mut policy = autoscale_policy(
3242            ScaleTarget::repository("o/r").unwrap(),
3243            HostId::from_u128(7),
3244            1,
3245        );
3246        policy.activate().unwrap();
3247        policy.authentication_failed().unwrap();
3248        assert!(!policy.may_start_runners());
3249
3250        // Test-local mutant omits only the state gate while retaining the
3251        // other production eligibility conditions. It cannot exist outside
3252        // this #[cfg(test)] module.
3253        let mutant_may_start = policy.mode.is_autoscale() && policy.enabled;
3254        assert!(
3255            mutant_may_start,
3256            "omitting revoked state must make the eligibility gate red"
3257        );
3258    }
3259
3260    #[test]
3261    fn a_refused_transition_leaves_the_revision_untouched() {
3262        let mut policy = autoscale_policy(
3263            ScaleTarget::repository("o/r").unwrap(),
3264            HostId::from_u128(7),
3265            1,
3266        );
3267        assert_eq!(policy.revision(), 0);
3268        policy.activate().unwrap();
3269        assert_eq!(policy.revision(), 1);
3270
3271        assert!(policy.activate().is_err());
3272        assert_eq!(
3273            policy.revision(),
3274            1,
3275            "a rejected write must not bump the optimistic-concurrency token, or \
3276             `b2`'s stale-revision check would reject the next honest write"
3277        );
3278    }
3279
3280    // =======================================================================
3281    // Monitor-only, promotion, ownership
3282    // =======================================================================
3283
3284    #[test]
3285    fn a_monitor_only_policy_owns_nothing_and_can_never_start_a_runner() {
3286        let mut policy = ScalePolicy::new(
3287            PolicyId::from_u128(2),
3288            ScaleTarget::organization("acme").unwrap(),
3289            9,
3290            HostId::from_u128(7),
3291            PolicyMode::monitor_only(),
3292            CachePolicy::default(),
3293        );
3294        policy.activate().unwrap();
3295
3296        assert!(!policy.owns_runners());
3297        assert!(
3298            !policy.may_start_runners(),
3299            "an active, enabled monitor-only policy still starts nothing (D19)"
3300        );
3301        assert!(policy.routing_labels().is_none());
3302        assert!(policy.max_capacity().is_none());
3303
3304        // Maximum demand changes nothing, because it has no labels to match on.
3305        let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 50];
3306        assert_eq!(
3307            policy.tally(&jobs).demand(),
3308            0,
3309            "a monitor-only policy has no demand at all, rather than demand that \
3310             is computed and then ignored"
3311        );
3312
3313        // And it owns no label set to edit. This is a wrong-mode refusal: the
3314        // caller asked for the wrong thing, the stored row is not malformed.
3315        assert!(matches!(
3316            policy.add_routing_label(label("gpu")),
3317            Err(PolicyError::NotAutoscale)
3318        ));
3319        assert!(matches!(
3320            policy.remove_routing_label(&label("gpu")),
3321            Err(PolicyError::NotAutoscale)
3322        ));
3323        // The load path reports a *shape* problem, and it is a different one: a
3324        // row carrying routing labels is autoscale-shaped by definition, so the
3325        // mode is never in doubt and "monitor-only with labels" has no error to
3326        // report because it cannot be expressed. This assertion is what pins
3327        // that -- it fails loudly if `PolicyMode::from_persisted` ever starts
3328        // reading a stored discriminant instead of inferring the mode, which is
3329        // the change that would make the deleted variant reachable again.
3330        assert!(matches!(
3331            PolicyMode::from_persisted(Some(host_labels("home")), 0, None),
3332            Err(PolicyError::AutoscaleWithoutMaxCapacity)
3333        ));
3334    }
3335
3336    #[test]
3337    fn set_capacity_promotes_a_monitor_only_policy_and_derives_its_label_then() {
3338        // D19 / `f2`: "`set-capacity` later promotes it to `autoscale`, which is
3339        // also when its routing label is derived."
3340        let mut policy = ScalePolicy::new(
3341            PolicyId::from_u128(2),
3342            ScaleTarget::repository("o/r").unwrap(),
3343            9,
3344            HostId::from_u128(7),
3345            PolicyMode::monitor_only(),
3346            CachePolicy::default(),
3347        );
3348        assert!(policy.routing_labels().is_none());
3349
3350        policy
3351            .promote_to_autoscale(host_labels("home"), 0, nz(3))
3352            .unwrap();
3353
3354        assert!(policy.owns_runners());
3355        assert_eq!(
3356            policy.routing_labels().unwrap().host_label().as_str(),
3357            "rm-home-win-x64"
3358        );
3359        assert_eq!(policy.max_capacity().unwrap().get(), 3);
3360
3361        // Promotion is one-way; a second promotion is a mistake, not a resize.
3362        assert!(matches!(
3363            policy.promote_to_autoscale(host_labels("home"), 0, nz(4)),
3364            Err(PolicyError::AlreadyAutoscale)
3365        ));
3366        policy.set_max_capacity(nz(4)).unwrap();
3367        assert_eq!(policy.max_capacity().unwrap().get(), 4);
3368    }
3369
3370    #[test]
3371    fn a_named_monitor_can_only_be_promoted_with_its_derived_selector() {
3372        let mut named = ScalePolicy::new_named_monitor(
3373            PolicyId::from_u128(3),
3374            ScaleTarget::repository("o/r").unwrap(),
3375            9,
3376            HostId::from_u128(7),
3377            HostLabel::new("home").unwrap(),
3378            ProfileName::new("Py-Isolated").unwrap(),
3379            CachePolicy::default(),
3380        )
3381        .unwrap();
3382        assert!(matches!(
3383            named.promote_to_autoscale(host_labels("other"), 0, nz(3)),
3384            Err(PolicyError::NamedProfileNeedsDerivedSelector)
3385        ));
3386        assert!(named.routing_labels().is_none());
3387        named
3388            .promote_named_to_autoscale(Os::Windows, Arch::X64, 0, nz(3))
3389            .unwrap();
3390        assert_eq!(
3391            named.routing_labels().unwrap().host_label().as_str(),
3392            "rm-home-win-x64-py-isolated"
3393        );
3394    }
3395
3396    #[test]
3397    fn a_policy_is_owned_by_exactly_one_host() {
3398        let mine = HostId::from_u128(7);
3399        let theirs = HostId::from_u128(8);
3400        let policy = autoscale_policy(ScaleTarget::repository("o/r").unwrap(), mine, 1);
3401        assert!(policy.is_owned_by(mine));
3402        assert!(!policy.is_owned_by(theirs));
3403    }
3404
3405    #[test]
3406    fn an_overridden_host_label_is_detectable_without_being_rejected() {
3407        // `f2` supports an operator override, so `from_parts` must accept any
3408        // label -- but "host-scoped by construction" then holds only for
3409        // `derive`, and the difference has to be visible to something.
3410        assert!(host_labels("home").is_derived_shape());
3411        assert!(
3412            RoutingLabels::derive(&HostLabel::new("home-win").unwrap(), Os::Linux, Arch::Arm64)
3413                .is_derived_shape(),
3414            "a host label containing `-` still derives a four-plus-segment name"
3415        );
3416        // `HostLabel::new` refuses only a leading or trailing `-`, so `home--pc`
3417        // is a legal host label an operator can really type. It derives
3418        // `rm-home--pc-win-x64`, and reporting that as *not* derived told an
3419        // operator who had done nothing wrong that their collision control was
3420        // off.
3421        assert_eq!(
3422            host_labels("home--pc").host_label().as_str(),
3423            "rm-home--pc-win-x64"
3424        );
3425        assert!(
3426            host_labels("home--pc").is_derived_shape(),
3427            "consecutive dashes are legal inside a host label; the empty middle \
3428             segment they produce is not evidence of an override"
3429        );
3430
3431        // The case the predicate exists for: a hand-edited row that has quietly
3432        // disabled the collision control. Not an error -- `remove` will still
3433        // defend it as immovable -- but `f2`/`g2` can now say so.
3434        for raw in [
3435            "self-hosted",
3436            "ubuntu-latest",
3437            "rm-home-win",
3438            "rm-home-win-x64-extra",
3439        ] {
3440            assert!(
3441                !RoutingLabels::from_host_label(label(raw)).is_derived_shape(),
3442                "{raw:?} is not the derived shape"
3443            );
3444        }
3445        // Right segment count, wrong tokens.
3446        assert!(!RoutingLabels::from_host_label(label("rm-home-bsd-x64")).is_derived_shape());
3447        assert!(!RoutingLabels::from_host_label(label("rm-home-win-riscv")).is_derived_shape());
3448        assert!(!RoutingLabels::from_host_label(label("xx-home-win-x64")).is_derived_shape());
3449
3450        // The additional labels play no part: only host identity is at stake.
3451        let mut overridden = RoutingLabels::from_host_label(label("self-hosted"));
3452        overridden.add(label("rm-home-win-x64"));
3453        assert!(!overridden.is_derived_shape());
3454    }
3455
3456    #[test]
3457    fn the_lifecycle_commands_are_transitions_not_desired_state_requests() {
3458        // Documented as intended: `activate` and `request_disable` report what
3459        // the diagram permits, and `f2` translates that into an idempotent CLI
3460        // using the two predicates rather than by calling and discarding errors.
3461        let mut policy = autoscale_policy(
3462            ScaleTarget::repository("o/r").unwrap(),
3463            HostId::from_u128(7),
3464            3,
3465        );
3466
3467        assert!(policy.can_activate());
3468        assert!(
3469            !policy.can_request_disable(),
3470            "a pending policy cannot drain; it is also already not enabled, \
3471             which is what makes the command a no-op rather than a failure"
3472        );
3473        assert!(matches!(
3474            policy.request_disable(),
3475            Err(PolicyError::IllegalTransition {
3476                from: PolicyState::Pending,
3477                to: PolicyState::Draining,
3478            })
3479        ));
3480
3481        policy.activate().unwrap();
3482        assert!(!policy.can_activate(), "already active");
3483        assert!(policy.can_request_disable());
3484        assert!(matches!(
3485            policy.activate(),
3486            Err(PolicyError::IllegalTransition { .. })
3487        ));
3488
3489        policy.request_disable().unwrap();
3490        assert!(!policy.can_request_disable(), "already draining");
3491        assert!(!policy.enabled());
3492    }
3493
3494    // =======================================================================
3495    // D18 target equivalence -- one body, both variants
3496    // =======================================================================
3497
3498    /// Everything `04-subsystem-contracts.md` says is identical between the two
3499    /// scopes: "ownership, capacity, and lifecycle rules are identical".
3500    ///
3501    /// This is deliberately one function rather than two tests. `b1`'s
3502    /// Definition of Done asks for the equivalence to be "proven by a shared
3503    /// test body, not by two copies of the same assertions", and the reason is
3504    /// not tidiness: two copies drift, and the moment they drift the domain has
3505    /// quietly acquired a scope-dependent rule that D18 says does not exist.
3506    ///
3507    /// **The trace must cover every rule the contract names, not every rule
3508    /// that happens to live in this file.** `04-subsystem-contracts.md` names
3509    /// three — "ownership, capacity, and lifecycle rules are identical" — and
3510    /// two of them are implemented in other modules. A version of this trace
3511    /// that only exercised `policy.rs` proved lifecycle and left the other two
3512    /// unguarded: a scope branch in `HostAllocator::allocate` firing only on
3513    /// `demand > 0`, and one in `attempt::authorize` returning `ForeignHost` for
3514    /// every organization policy, both left the whole suite green. Anything
3515    /// added to the contract's list belongs in this body, wherever it is
3516    /// implemented.
3517    fn assert_target_behaves_identically(target: ScaleTarget) -> Vec<String> {
3518        let host = HostId::from_u128(7);
3519        let mut trace = Vec::new();
3520
3521        let mut policy = autoscale_policy(target.clone(), host, 3);
3522        trace.push(format!("owns_runners={}", policy.owns_runners()));
3523        trace.push(format!("owned_by_host={}", policy.is_owned_by(host)));
3524        trace.push(format!(
3525            "owned_by_other={}",
3526            policy.is_owned_by(HostId::from_u128(8))
3527        ));
3528        trace.push(format!("initial_state={}", policy.state()));
3529        trace.push(format!("initial_enabled={}", policy.enabled()));
3530        trace.push(format!(
3531            "may_start_initially={}",
3532            policy.may_start_runners()
3533        ));
3534        trace.push(format!("labels={}", policy.routing_labels().unwrap()));
3535        trace.push(format!("max_capacity={}", policy.max_capacity().unwrap()));
3536        trace.push(format!("min_capacity={}", policy.min_capacity()));
3537
3538        // Lifecycle.
3539        policy.activate().unwrap();
3540        trace.push(format!("after_activate={}", policy.state()));
3541        trace.push(format!("may_start_active={}", policy.may_start_runners()));
3542
3543        // Demand.
3544        let jobs = vec![
3545            RunsOn::Single("rm-home-win-x64".into()),
3546            RunsOn::Single("ubuntu-latest".into()),
3547            RunsOn::Single("${{ matrix.os }}".into()),
3548        ];
3549        let tally = policy.tally(&jobs);
3550        trace.push(format!(
3551            "demand={} not_matched={} unresolvable={}",
3552            tally.demand(),
3553            tally.not_matched,
3554            tally.unresolvable.len()
3555        ));
3556
3557        // Capacity (`crate::capacity`). D9's host ceiling and D7's per-policy
3558        // one are the contract's "capacity rules"; without these lines a scope
3559        // branch inside `HostAllocator::allocate` is invisible to the suite.
3560        let host_record = crate::model::Host::new(
3561            host,
3562            "home-pc",
3563            Os::Windows,
3564            Arch::X64,
3565            nz(4),
3566            crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3567        )
3568        .unwrap();
3569        let mut allocator = crate::capacity::HostAllocator::from_attempts(&host_record, &[]);
3570        let allocation = allocator.allocate(&policy, 3);
3571        trace.push(format!(
3572            "alloc demand={} desired={} active_owned={} headroom_before={} \
3573             to_start={} limiting={}",
3574            allocation.demand,
3575            allocation.desired,
3576            allocation.active_owned,
3577            allocation.headroom_before,
3578            allocation.to_start,
3579            allocation.limiting_factor
3580        ));
3581        trace.push(format!("headroom_after={}", allocator.headroom()));
3582        // Zero demand as well, so a branch keyed on `demand > 0` cannot hide in
3583        // the gap between the two.
3584        trace.push(format!(
3585            "alloc_zero_to_start={}",
3586            allocator.allocate(&policy, 0).to_start
3587        ));
3588
3589        // Ownership (`crate::attempt`). Ownership rules 1 and 2 are the
3590        // contract's "ownership rules", and `authorize` is where they are
3591        // enforced -- `is_owned_by` above only covers rule 2's policy half.
3592        let attempt = crate::attempt::RunnerAttempt::allocate(
3593            crate::model::AttemptId::from_u128(11),
3594            policy.id,
3595            "C:/runners/eq",
3596            crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3597        );
3598        trace.push(format!(
3599            "authorize_own_host={:?}",
3600            crate::attempt::authorize(host, &policy, &attempt).is_ok()
3601        ));
3602        trace.push(format!(
3603            "authorize_other_host={}",
3604            crate::attempt::authorize(HostId::from_u128(8), &policy, &attempt)
3605                .expect_err("an agent on another host must be refused")
3606        ));
3607        let foreign_attempt = crate::attempt::RunnerAttempt::allocate(
3608            crate::model::AttemptId::from_u128(12),
3609            PolicyId::from_u128(999),
3610            "C:/runners/eq-other",
3611            crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
3612        );
3613        trace.push(format!(
3614            "authorize_other_policy={}",
3615            crate::attempt::authorize(host, &policy, &foreign_attempt)
3616                .expect_err("an attempt under another policy must be refused")
3617        ));
3618
3619        // Drain.
3620        trace.push(format!("disable={}", policy.request_disable().unwrap()));
3621        trace.push(format!(
3622            "drain_with_1={}",
3623            policy.drain_completed(1).unwrap()
3624        ));
3625        trace.push(format!(
3626            "drain_with_0={}",
3627            policy.drain_completed(0).unwrap()
3628        ));
3629
3630        // Illegal transition, both scopes.
3631        trace.push(format!(
3632            "reactivate_err={}",
3633            policy.transition_to(PolicyState::Active).is_err()
3634        ));
3635
3636        // The registration label array `c4` would send.
3637        trace.push(format!(
3638            "registration_labels={:?}",
3639            autoscale_policy(target, host, 3)
3640                .routing_labels()
3641                .unwrap()
3642                .as_registration_labels()
3643        ));
3644
3645        trace
3646    }
3647
3648    #[test]
3649    fn repository_and_organization_targets_are_equivalent() {
3650        let repository = assert_target_behaves_identically(ScaleTarget::repository("o/r").unwrap());
3651        let organization =
3652            assert_target_behaves_identically(ScaleTarget::organization("o").unwrap());
3653
3654        assert_eq!(
3655            repository, organization,
3656            "D18: the two scopes differ only in which GitHub endpoint and which \
3657             App permission the gateway uses. Any difference here is a \
3658             scope-dependent domain rule that must not exist."
3659        );
3660
3661        // The one thing that *is* allowed to differ.
3662        assert_ne!(
3663            ScaleTarget::repository("o/r").unwrap().scope(),
3664            ScaleTarget::organization("o").unwrap().scope()
3665        );
3666    }
3667
3668    // -----------------------------------------------------------------------
3669    // Label exclusions
3670    // -----------------------------------------------------------------------
3671
3672    fn job(labels: &[&str]) -> RunsOn {
3673        RunsOn::from_job_labels(labels.iter().map(|label| (*label).to_string()))
3674    }
3675
3676    fn linux_profile(extra: &[&str]) -> RoutingLabels {
3677        RoutingLabels::from_parts(
3678            label("rm-box-linux-x64"),
3679            ["self-hosted", "linux", "x64"]
3680                .iter()
3681                .chain(extra)
3682                .map(|value| label(value)),
3683        )
3684    }
3685
3686    #[test]
3687    fn an_exclusion_is_satisfied_by_a_profile_that_lacks_what_it_excludes() {
3688        let native = linux_profile(&[]);
3689        let wsl = linux_profile(&["wsl"]);
3690        let wanted = job(&["self-hosted", "linux", "x64", "not-wsl"]);
3691        assert!(native.matches(&wanted).is_match());
3692        assert_eq!(
3693            wsl.matches(&wanted),
3694            RunsOnMatch::NoMatch {
3695                missing: vec![label("not-wsl")]
3696            }
3697        );
3698        // Case-insensitive like every label, and a host label can be excluded.
3699        assert!(!wsl.matches(&job(&["Linux", "NOT-WSL"])).is_match());
3700        assert!(
3701            !native
3702                .matches(&job(&["linux", "not-rm-box-linux-x64"]))
3703                .is_match()
3704        );
3705        assert!(
3706            native
3707                .matches(&job(&["linux", "not-rm-other-linux-x64"]))
3708                .is_match()
3709        );
3710    }
3711
3712    #[test]
3713    fn a_profile_listing_both_a_label_and_its_exclusion_never_satisfies_the_exclusion() {
3714        // Stored before validation existed, or by hand: `wsl` wins.
3715        let both = linux_profile(&["wsl", "not-wsl"]);
3716        assert!(!both.matches(&job(&["linux", "not-wsl"])).is_match());
3717        assert!(both.matches(&job(&["linux", "wsl"])).is_match());
3718        // GitHub must not hand it such a job either.
3719        let registered = both.as_registration_labels();
3720        assert!(registered.contains(&"wsl".to_string()));
3721        assert!(!registered.contains(&"not-wsl".to_string()));
3722    }
3723
3724    #[test]
3725    fn a_job_without_exclusions_matches_exactly_as_before() {
3726        let profile = linux_profile(&["gpu"]);
3727        assert!(profile.matches(&job(&["self-hosted", "gpu"])).is_match());
3728        assert!(!profile.matches(&job(&["self-hosted", "arm64"])).is_match());
3729        assert_eq!(
3730            profile.as_registration_labels(),
3731            vec!["rm-box-linux-x64", "gpu", "linux", "self-hosted", "x64"]
3732        );
3733        let tally = profile.tally(&[job(&["self-hosted", "gpu"])]);
3734        assert!(tally.excluded.is_empty());
3735    }
3736
3737    #[test]
3738    fn an_unusable_or_conflicting_exclusion_is_unsatisfiable_not_demand() {
3739        for bad in ["not-", "not-not-wsl", "NOT-Not-x", "not- wsl"] {
3740            let got = job(&["linux", bad]).required_labels();
3741            assert!(
3742                matches!(&got, Err(UnresolvableRunsOn::InvalidExclusion { .. })),
3743                "{bad}: {got:?}"
3744            );
3745            assert!(got.unwrap_err().is_unsatisfiable_exclusion());
3746        }
3747        assert_eq!(
3748            job(&["linux", "not- wsl", "not-gpu"]).plain_labels(),
3749            vec![label("linux")]
3750        );
3751        let conflict = job(&["linux", "WSL", "not-wsl"]).required_labels();
3752        assert_eq!(
3753            conflict,
3754            Err(UnresolvableRunsOn::ConflictingExclusion {
3755                label: label("wsl")
3756            })
3757        );
3758        let tally = linux_profile(&[]).tally(&[job(&["linux", "wsl", "not-wsl"])]);
3759        assert_eq!((tally.matched, tally.unresolvable.len()), (0, 1));
3760        // An expression is not an exclusion problem.
3761        assert!(
3762            !job(&["${{ matrix.os }}"])
3763                .required_labels()
3764                .unwrap_err()
3765                .is_unsatisfiable_exclusion()
3766        );
3767    }
3768
3769    #[test]
3770    fn a_tally_collects_what_its_matched_jobs_exclude() {
3771        let profile = linux_profile(&[]);
3772        let tally = profile.tally(&[
3773            job(&["linux", "not-wsl"]),
3774            job(&["linux", "not-gpu", "not-wsl"]),
3775            job(&["linux"]),
3776            // Not matched: its exclusion is not this runner's to register.
3777            job(&["windows", "not-docker"]),
3778        ]);
3779        assert_eq!(tally.matched, 3);
3780        assert_eq!(
3781            tally.excluded.iter().map(Label::as_str).collect::<Vec<_>>(),
3782            vec!["gpu", "wsl"]
3783        );
3784    }
3785
3786    #[test]
3787    fn excluding_adds_only_exclusions_of_labels_the_profile_lacks() {
3788        let profile = linux_profile(&["gpu"]);
3789        let excluded = [label("wsl"), label("gpu")];
3790        let registered = profile.excluding(&excluded).as_registration_labels();
3791        assert!(registered.contains(&"not-wsl".to_string()));
3792        assert!(!registered.contains(&"not-gpu".to_string()));
3793        // The stored profile is unchanged.
3794        assert!(!profile.contains(&label("not-wsl")));
3795    }
3796
3797    #[test]
3798    fn host_traits_give_wsl_or_not_wsl_and_nothing_when_unknown() {
3799        assert!(HostTraits::default().labels().is_empty());
3800        assert_eq!(HostTraits::wsl(true).labels(), vec![label("wsl")]);
3801        assert_eq!(HostTraits::wsl(false).labels(), vec![label("not-wsl")]);
3802        let wanted = job(&["linux", "not-wsl"]);
3803        let base = linux_profile(&[]);
3804        assert!(
3805            !base
3806                .with_labels(HostTraits::wsl(true).labels())
3807                .matches(&wanted)
3808                .is_match()
3809        );
3810        let native = base.with_labels(HostTraits::wsl(false).labels());
3811        assert!(native.matches(&wanted).is_match());
3812        assert!(
3813            native
3814                .as_registration_labels()
3815                .contains(&"not-wsl".to_string())
3816        );
3817    }
3818
3819    #[test]
3820    fn a_profile_label_that_contradicts_another_is_refused() {
3821        let mut policy = autoscale_policy(
3822            ScaleTarget::repository("o/r").unwrap(),
3823            HostId::from_u128(9),
3824            2,
3825        );
3826        policy.add_routing_label(label("wsl")).unwrap();
3827        assert_eq!(
3828            policy.add_routing_label(label("not-wsl")),
3829            Err(PolicyError::ContradictoryExclusion {
3830                label: label("not-wsl"),
3831                carried: label("wsl"),
3832            })
3833        );
3834        let mut other = autoscale_policy(
3835            ScaleTarget::repository("o/r").unwrap(),
3836            HostId::from_u128(9),
3837            2,
3838        );
3839        other.add_routing_label(label("not-gpu")).unwrap();
3840        assert!(matches!(
3841            other.add_routing_label(label("GPU")),
3842            Err(PolicyError::ContradictoryExclusion { .. })
3843        ));
3844        assert!(matches!(
3845            other.add_routing_label(label("not-")),
3846            Err(PolicyError::InvalidExclusion { .. })
3847        ));
3848        assert_eq!(other.add_routing_label(label("docker")), Ok(true));
3849    }
3850
3851    #[test]
3852    fn runtime_labels_change_a_copy_and_leave_the_revision() {
3853        let policy = autoscale_policy(
3854            ScaleTarget::repository("o/r").unwrap(),
3855            HostId::from_u128(9),
3856            2,
3857        );
3858        let copy = policy.with_runtime_labels(|labels| labels.with_labels([label("wsl")]));
3859        assert!(copy.routing_labels().unwrap().contains(&label("wsl")));
3860        assert!(!policy.routing_labels().unwrap().contains(&label("wsl")));
3861        assert_eq!(copy.revision(), policy.revision());
3862        assert_eq!(copy.id, policy.id);
3863    }
3864}