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