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::model::{
31    Arch, CachePolicy, HostId, HostLabel, Label, NonEmpty, Os, PolicyId, ScaleTarget,
32    ValidationError,
33};
34use crate::path::LocalAbsolutePath;
35use crate::workspace::{WorkspaceError, WorkspaceKind, WorkspacePolicy};
36
37// ---------------------------------------------------------------------------
38// Errors
39// ---------------------------------------------------------------------------
40
41#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
42pub enum PolicyError {
43    #[error(transparent)]
44    Invalid(#[from] ValidationError),
45
46    /// A workspace configuration this policy's target cannot hold, or a stored
47    /// pair of workspace columns this crate cannot have written (D4, D7).
48    #[error(transparent)]
49    Workspace(#[from] WorkspaceError),
50
51    #[error(
52        "an Autoscale policy requires routing labels; a policy with none is a \
53         MonitorOnly policy (D19)"
54    )]
55    AutoscaleWithoutRoutingLabels,
56
57    #[error(
58        "an Autoscale policy requires max_capacity; without a ceiling it could \
59         oversubscribe the host (D7, D19)"
60    )]
61    AutoscaleWithoutMaxCapacity,
62
63    // There was a `MonitorOnlyWithRoutingLabels` variant here, meaning "this
64    // *stored row* has an illegal shape". It is deleted rather than kept for
65    // `b2`, because nothing can construct it and nothing can construct it later
66    // either: `PolicyMode::from_persisted` matches exhaustively across four arms
67    // and never returns it, and `PersistedPolicy` -- the only shape `b2` loads
68    // through -- has no `mode` field, so no schema reachable from here can
69    // express "monitor-only *with* labels" in the first place. A row carrying
70    // routing labels is autoscale-shaped by definition, and
71    // labels-without-`max_capacity` is caught as `AutoscaleWithoutMaxCapacity`
72    // first. Keeping it would have had `f2` write a match arm and a user-facing
73    // message for a condition that cannot occur and that no test can cover.
74    #[error(
75        "a MonitorOnly policy must not carry a non-zero min_capacity ({min}); it \
76         never starts a runner (D19)"
77    )]
78    MonitorOnlyWithMinCapacity { min: u16 },
79
80    #[error("min_capacity ({min}) must not exceed max_capacity ({max})")]
81    InvertedCapacityRange { min: u16, max: u16 },
82
83    #[error("{to} is not a legal transition from {from}")]
84    IllegalTransition { from: PolicyState, to: PolicyState },
85
86    #[error(
87        "only a MonitorOnly policy can be promoted to Autoscale; this one is already Autoscale"
88    )]
89    AlreadyAutoscale,
90
91    #[error(
92        "this operation needs an Autoscale policy; a MonitorOnly policy has no \
93         capacity and no routing labels to change (D19)"
94    )]
95    NotAutoscale,
96
97    #[error("the host label {label} is the routing identity of this policy and cannot be removed")]
98    HostLabelNotRemovable { label: Label },
99}
100
101// ---------------------------------------------------------------------------
102// Routing labels
103// ---------------------------------------------------------------------------
104
105/// A policy's routing label set: the token `runs-on` targets.
106///
107/// `04-subsystem-contracts.md` types this as `Option<NonEmpty<Label>>`. This
108/// type is the `NonEmpty<Label>` half, and it is deliberately *stronger* than a
109/// non-empty vector, because the contract has two separate requirements that a
110/// bare `NonEmpty` only covers one of:
111///
112/// * **Non-empty.** Guaranteed by [`RoutingLabels::host_label`] always existing.
113///   `generate-jitconfig` rejects `labels: []` with `422`
114///   (`docs/spikes/d18-org-jit-verification.md`, Point 3), so an empty set is
115///   not a case to handle downstream.
116/// * **The derived host label may not be dropped.** `b1`: "Optional descriptive
117///   labels may be added to the set; the derived host label may not be dropped
118///   from it." A `Vec<Label>` cannot express that. Here the host label is a
119///   separate field with no removal path, so dropping it is not a rule anyone
120///   has to remember.
121///
122/// The `Option` half of `Option<NonEmpty<Label>>` is carried by [`PolicyMode`]:
123/// `MonitorOnly` has no routing labels because the variant has no field for
124/// them, not because the field happens to be `None`.
125///
126/// **Why the default is host-scoped.** With no `AcquireJobs`, nothing reserves a
127/// queued job for one host. Two hosts whose policies carry the same label will
128/// both start a runner for the same job, and the loser pays a capacity slot and
129/// a cold start (`01-current-architecture.md`, edge case 6). The host identity
130/// baked into the derived label is the only control that prevents that by
131/// default — the capacity ceilings only bound it once it happens.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(from = "RoutingLabelsRepr", into = "RoutingLabelsRepr")]
134pub struct RoutingLabels {
135    host_label: Label,
136    additional: BTreeSet<Label>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140struct RoutingLabelsRepr {
141    host_label: Label,
142    #[serde(default)]
143    additional: BTreeSet<Label>,
144}
145
146impl From<RoutingLabelsRepr> for RoutingLabels {
147    fn from(repr: RoutingLabelsRepr) -> Self {
148        // Normalising on the way in rather than erroring: a stored set that
149        // happens to repeat the host label among the optional labels is not
150        // corrupt, it is redundant, and silently de-duplicating it keeps
151        // `count()` honest.
152        Self::from_parts(repr.host_label, repr.additional)
153    }
154}
155
156impl From<RoutingLabels> for RoutingLabelsRepr {
157    fn from(value: RoutingLabels) -> Self {
158        Self {
159            host_label: value.host_label,
160            additional: value.additional,
161        }
162    }
163}
164
165impl RoutingLabels {
166    /// The product prefix in a derived label.
167    pub const PREFIX: &'static str = "rm";
168
169    /// Derive the host-scoped default label, `rm-<host>-<os>-<arch>`.
170    ///
171    /// `02-target-architecture.md`: "The label set … encodes the product, host
172    /// identity, and host OS — for example `rm-home-win-x64`." Read against that
173    /// sentence the four segments are product / host identity / OS /
174    /// architecture, so `--host-label home` on a Windows x64 host derives
175    /// `rm-home-win-x64`.
176    ///
177    /// Note that the worked command in `03-control-flows.md` step 3 passes
178    /// `--host-label home-win`, which under this rule derives
179    /// `rm-home-win-win-x64`. That is a redundant example rather than a
180    /// different rule — `b1`'s Scope names the three inputs explicitly — but it
181    /// is recorded here because it is the obvious thing for a reader to trip on.
182    #[must_use]
183    pub fn derive(host_label: &HostLabel, os: Os, arch: Arch) -> Self {
184        let derived = format!(
185            "{}-{}-{}-{}",
186            Self::PREFIX,
187            host_label.as_str(),
188            os.label_token(),
189            arch.label_token()
190        );
191        Self {
192            host_label: Label::new(derived).expect(
193                "a HostLabel is ASCII alphanumeric plus `-`/`_` and the other three \
194                 segments are fixed tokens, so the concatenation is always a valid Label",
195            ),
196            additional: BTreeSet::new(),
197        }
198    }
199
200    /// Build from an explicit host label, for the operator override `f2`
201    /// supports, and for `b2` reloading a stored set.
202    ///
203    /// **This accepts any [`Label`] as the host label, including one that is not
204    /// host-scoped at all.** "Host-scoped by construction" is a property of
205    /// [`Self::derive`], not of this type: `f2` deliberately supports an
206    /// operator override, so a hard rejection here would break a supported
207    /// workflow, and this is also the serde path, so `b2` reaches it for every
208    /// stored row. A hand-edited row can therefore set `host_label` to
209    /// `self-hosted`, and [`Self::remove`] will then defend *that* as immovable
210    /// while two hosts happily serve each other's jobs. Ask
211    /// [`Self::is_derived_shape`] before trusting the collision control.
212    #[must_use]
213    pub fn from_parts(host_label: Label, additional: impl IntoIterator<Item = Label>) -> Self {
214        let additional = additional
215            .into_iter()
216            .filter(|l| *l != host_label)
217            .collect();
218        Self {
219            host_label,
220            additional,
221        }
222    }
223
224    /// Build from an explicit host label with no optional labels.
225    #[must_use]
226    pub fn from_host_label(host_label: Label) -> Self {
227        Self::from_parts(host_label, Vec::new())
228    }
229
230    /// The one label that carries host identity and cannot be removed.
231    #[must_use]
232    pub fn host_label(&self) -> &Label {
233        &self.host_label
234    }
235
236    /// Whether the host label still has the shape [`Self::derive`] produces:
237    /// `rm-<host>-<os>-<arch>`, with the OS and architecture segments being
238    /// tokens this crate actually emits.
239    ///
240    /// **This is a warning predicate, not a validation rule.** It is `false` for
241    /// an operator override, and an override is supported — `f2` offers one on
242    /// purpose. What it detects is that the *collision control has been turned
243    /// off*: the derived shape is what keeps two hosts from answering each
244    /// other's jobs, so a policy whose host label is `self-hosted` or
245    /// `ubuntu-latest` will route work that belongs to another machine, and
246    /// [`Self::remove`] will refuse to remove that label because it cannot tell
247    /// the difference. `f2` and `g2` should say so rather than fail; nothing
248    /// here rejects it.
249    ///
250    /// Matching is structural rather than a check against a known host label,
251    /// because the host label this was derived from is not stored — only the
252    /// concatenation is.
253    ///
254    /// **The middle segments are not inspected, only counted.** An earlier
255    /// version also required every one of them to be non-empty, meaning to
256    /// reject an empty host segment — but a [`HostLabel`] cannot be empty, so
257    /// that condition never rejected anything [`Self::derive`] could produce and
258    /// only ever produced false negatives: `HostLabel::new("home--pc")` is legal
259    /// (only a *leading* or *trailing* `-` is refused), derives
260    /// `rm-home--pc-win-x64`, and was reported as not derived. The consequence
261    /// was `f2`/`g2` warning an operator that their collision control was off
262    /// when they had done nothing wrong, which is worse than the residual it
263    /// leaves: a hand-edited `rm--win-x64` now reads as derived. That row is
264    /// still host-scoped in shape, so it does not mislead in the direction this
265    /// predicate exists to catch.
266    #[must_use]
267    pub fn is_derived_shape(&self) -> bool {
268        let segments: Vec<&str> = self.host_label.as_str().split('-').collect();
269        // `rm` / host / os / arch. The host segment is a HostLabel, which never
270        // contains `-`... except that it may: `--host-label home-win` is legal
271        // and derives `rm-home-win-win-x64`. So the fixed ends are what is
272        // checked, and everything between them is the host identity.
273        let [prefix, middle @ .., os, arch] = segments.as_slice() else {
274            return false;
275        };
276        // `Os::ALL` / `Arch::ALL` rather than a literal list, so a fourth OS or
277        // architecture is recognised here the moment it exists.
278        *prefix == Self::PREFIX
279            && !middle.is_empty()
280            && Os::ALL
281                .iter()
282                .any(|candidate| candidate.label_token() == *os)
283            && Arch::ALL
284                .iter()
285                .any(|candidate| candidate.label_token() == *arch)
286    }
287
288    /// The optional descriptive labels, in sorted order.
289    pub fn additional(&self) -> impl Iterator<Item = &Label> {
290        self.additional.iter()
291    }
292
293    /// Add an optional descriptive label. Returns `false` if it was already in
294    /// the set (including as the host label).
295    pub fn add(&mut self, label: Label) -> bool {
296        if label == self.host_label {
297            return false;
298        }
299        self.additional.insert(label)
300    }
301
302    /// Remove an optional descriptive label.
303    ///
304    /// # Errors
305    /// [`PolicyError::HostLabelNotRemovable`] when asked to remove the host
306    /// label. There is deliberately no override: this is the routing identity
307    /// that keeps two hosts from serving each other's jobs.
308    pub fn remove(&mut self, label: &Label) -> Result<bool, PolicyError> {
309        if *label == self.host_label {
310            return Err(PolicyError::HostLabelNotRemovable {
311                label: label.clone(),
312            });
313        }
314        Ok(self.additional.remove(label))
315    }
316
317    #[must_use]
318    pub fn contains(&self, label: &Label) -> bool {
319        self.host_label == *label || self.additional.contains(label)
320    }
321
322    /// Every label, host label first.
323    pub fn iter(&self) -> impl Iterator<Item = &Label> {
324        std::iter::once(&self.host_label).chain(self.additional.iter())
325    }
326
327    /// Never zero.
328    #[must_use]
329    pub fn count(&self) -> NonZeroUsize {
330        NonZeroUsize::new(1 + self.additional.len()).expect("the host label is always present")
331    }
332
333    /// The same set in the shape `04-subsystem-contracts.md` names.
334    #[must_use]
335    pub fn to_non_empty(&self) -> NonEmpty<Label> {
336        let mut out = NonEmpty::of(self.host_label.clone());
337        for label in &self.additional {
338            out.push(label.clone());
339        }
340        out
341    }
342
343    /// The `labels` array for `generate-jitconfig`
344    /// (`04-subsystem-contracts.md`, "Generate JIT configuration").
345    ///
346    /// `c4` sends exactly this. It matters that it is exactly this: the `v1`
347    /// spike established that **no labels are added implicitly** — the `201`
348    /// carries the requested labels and nothing else, so a runner registered
349    /// from this array does not answer `runs-on: self-hosted` unless
350    /// `self-hosted` is in it (`docs/spikes/d18-org-jit-verification.md`,
351    /// Point 3, findings 1 and 2).
352    #[must_use]
353    pub fn as_registration_labels(&self) -> Vec<String> {
354        self.iter().map(|l| l.as_str().to_string()).collect()
355    }
356
357    /// Decide whether this policy should serve a queued job.
358    ///
359    /// GitHub assigns a job to a runner whose label set is a **superset** of the
360    /// job's required labels, so the predicate is subset-in-the-other-direction:
361    /// the job's required labels must all be present here.
362    #[must_use]
363    pub fn matches(&self, runs_on: &RunsOn) -> RunsOnMatch {
364        let required = match runs_on.required_labels() {
365            Ok(required) => required,
366            Err(unresolvable) => return RunsOnMatch::Unresolvable(unresolvable),
367        };
368
369        let missing: Vec<Label> = required
370            .iter()
371            .filter(|label| !self.contains(label))
372            .cloned()
373            .collect();
374
375        if missing.is_empty() {
376            RunsOnMatch::Match {
377                runner_group: runs_on.runner_group().map(str::to_string),
378            }
379        } else {
380            RunsOnMatch::NoMatch { missing }
381        }
382    }
383
384    /// Tally a poll's worth of queued jobs into a demand signal.
385    ///
386    /// The three counts are kept apart on purpose. An unresolvable `runs-on` is
387    /// neither counted as demand nor dropped: `b1` requires it be "reported as
388    /// unresolvable rather than silently counted or silently dropped", because
389    /// counting it would start a runner for a job that may not be ours and
390    /// dropping it would hide a workflow this host can never serve.
391    #[must_use]
392    pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
393        let mut tally = DemandTally::default();
394        for job in jobs {
395            match self.matches(job) {
396                RunsOnMatch::Match { .. } => tally.matched += 1,
397                RunsOnMatch::NoMatch { .. } => tally.not_matched += 1,
398                RunsOnMatch::Unresolvable(reason) => tally.unresolvable.push(reason),
399            }
400        }
401        tally
402    }
403}
404
405impl fmt::Display for RoutingLabels {
406    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407        let joined: Vec<&str> = self.iter().map(Label::as_str).collect();
408        f.write_str(&joined.join(","))
409    }
410}
411
412/// The result of one poll's label matching.
413#[derive(Debug, Clone, Default, PartialEq, Eq)]
414pub struct DemandTally {
415    /// Jobs this policy should serve. This is the demand signal `e1` clamps.
416    pub matched: u32,
417    /// Jobs whose required labels this policy does not carry.
418    pub not_matched: u32,
419    /// Jobs whose `runs-on` could not be resolved statically. Never demand,
420    /// never discarded — `g2` surfaces these so an operator can see that a
421    /// workflow this host will never serve is sitting in the queue.
422    pub unresolvable: Vec<UnresolvableRunsOn>,
423}
424
425impl DemandTally {
426    #[must_use]
427    pub fn demand(&self) -> u32 {
428        self.matched
429    }
430
431    #[must_use]
432    pub fn total_seen(&self) -> u32 {
433        self.matched + self.not_matched + self.unresolvable.len() as u32
434    }
435}
436
437/// The outcome of matching one job's `runs-on` against a policy's labels.
438#[derive(Debug, Clone, PartialEq, Eq)]
439pub enum RunsOnMatch {
440    /// The job's required labels are all present.
441    ///
442    /// `runner_group` carries the `group:` key when the map form named one. The
443    /// domain does not evaluate it — a policy has no runner-group field, and
444    /// `c4` resolves the group id at registration time — but it is returned
445    /// rather than discarded so that a caller which *can* evaluate it is not
446    /// forced to re-parse the `runs-on`.
447    Match { runner_group: Option<String> },
448    /// At least one required label is absent. `missing` is what to tell an
449    /// operator who expected this policy to pick the job up.
450    NoMatch { missing: Vec<Label> },
451    /// The `runs-on` cannot be resolved without evaluating the workflow.
452    Unresolvable(UnresolvableRunsOn),
453}
454
455impl RunsOnMatch {
456    #[must_use]
457    pub const fn is_match(&self) -> bool {
458        matches!(self, RunsOnMatch::Match { .. })
459    }
460
461    #[must_use]
462    pub const fn is_unresolvable(&self) -> bool {
463        matches!(self, RunsOnMatch::Unresolvable(_))
464    }
465}
466
467/// Why a `runs-on` could not be resolved statically.
468#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
469pub enum UnresolvableRunsOn {
470    /// A GitHub Actions expression, `${{ … }}`. Its value depends on the run's
471    /// context, which this process does not have.
472    #[error("`runs-on` contains an expression that only GitHub can evaluate: {raw}")]
473    Expression { raw: String },
474
475    /// `runs-on: {group: X}` with no `labels`. The job constrains the runner
476    /// *group*, and a policy records no group, so nothing here can decide it.
477    #[error("`runs-on` names runner group {group} but no labels, so no label predicate applies")]
478    RunnerGroupWithoutLabels { group: String },
479
480    /// A `runs-on` naming no labels at all.
481    #[error("`runs-on` names no labels")]
482    NoLabels,
483
484    /// A label that is not a label — a comma or a control character.
485    #[error("`runs-on` contains {raw:?}, which is not a usable label: {source}")]
486    InvalidLabel {
487        raw: String,
488        #[source]
489        source: ValidationError,
490    },
491}
492
493/// A queued job's `runs-on`, in each documented form.
494///
495/// GitHub's "List jobs for a workflow run" response gives a job's labels as a
496/// flat array, so in practice `c4` will build [`RunsOn::Many`]. The string and
497/// map forms are supported because they are what a workflow file contains and
498/// what `b1`'s Definition of Done enumerates.
499#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
500#[serde(untagged)]
501pub enum RunsOn {
502    /// `runs-on: ubuntu-latest`
503    Single(String),
504    /// `runs-on: [self-hosted, linux]`
505    Many(Vec<String>),
506    /// `runs-on: {group: g, labels: [a, b]}`
507    Grouped {
508        #[serde(default)]
509        group: Option<String>,
510        #[serde(default)]
511        labels: RunsOnLabels,
512    },
513}
514
515/// The `labels` key of the map form, which GitHub allows as a scalar or a list.
516#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
517#[serde(untagged)]
518pub enum RunsOnLabels {
519    One(String),
520    Many(Vec<String>),
521}
522
523impl Default for RunsOnLabels {
524    fn default() -> Self {
525        Self::Many(Vec::new())
526    }
527}
528
529impl RunsOnLabels {
530    fn as_slice(&self) -> &[String] {
531        match self {
532            RunsOnLabels::One(one) => std::slice::from_ref(one),
533            RunsOnLabels::Many(many) => many,
534        }
535    }
536}
537
538impl RunsOn {
539    /// The array form, which is what the jobs API returns.
540    #[must_use]
541    pub fn from_job_labels(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
542        Self::Many(labels.into_iter().map(Into::into).collect())
543    }
544
545    /// The runner group the map form named, if any.
546    #[must_use]
547    pub fn runner_group(&self) -> Option<&str> {
548        match self {
549            RunsOn::Grouped { group, .. } => group.as_deref(),
550            _ => None,
551        }
552    }
553
554    fn raw_labels(&self) -> &[String] {
555        match self {
556            RunsOn::Single(one) => std::slice::from_ref(one),
557            RunsOn::Many(many) => many,
558            RunsOn::Grouped { labels, .. } => labels.as_slice(),
559        }
560    }
561
562    /// The normalised labels this job requires.
563    ///
564    /// **Whitespace-only array elements are dropped, not rejected.** A
565    /// `runs-on: ["self-hosted", "", "linux"]` is a workflow that GitHub itself
566    /// accepts, and the empty element carries no routing meaning, so treating it
567    /// as an [`UnresolvableRunsOn::InvalidLabel`] would report a job as
568    /// unresolvable — and so exclude it from demand and surface it to an
569    /// operator — over a stray comma in someone's YAML. The elements that
570    /// remain are what the job actually requires. If *every* element is
571    /// whitespace the array resolves to nothing at all, and that **is**
572    /// reported, as [`UnresolvableRunsOn::NoLabels`] or
573    /// [`UnresolvableRunsOn::RunnerGroupWithoutLabels`].
574    ///
575    /// # Errors
576    /// Every reason the value cannot be turned into a label set — each of which
577    /// the caller reports rather than treating as "no demand".
578    pub fn required_labels(&self) -> Result<Vec<Label>, UnresolvableRunsOn> {
579        let raws = self.raw_labels();
580
581        if let Some(raw) = raws.iter().find(|r| is_expression(r)) {
582            return Err(UnresolvableRunsOn::Expression { raw: raw.clone() });
583        }
584
585        let usable: Vec<&String> = raws.iter().filter(|r| !r.trim().is_empty()).collect();
586
587        if usable.is_empty() {
588            return match self.runner_group() {
589                Some(group) => Err(UnresolvableRunsOn::RunnerGroupWithoutLabels {
590                    group: group.to_string(),
591                }),
592                None => Err(UnresolvableRunsOn::NoLabels),
593            };
594        }
595
596        usable
597            .into_iter()
598            .map(|raw| {
599                Label::new(raw).map_err(|source| UnresolvableRunsOn::InvalidLabel {
600                    raw: raw.clone(),
601                    source,
602                })
603            })
604            .collect()
605    }
606}
607
608fn is_expression(raw: &str) -> bool {
609    raw.contains("${{")
610}
611
612// ---------------------------------------------------------------------------
613// PolicyMode (D19)
614// ---------------------------------------------------------------------------
615
616/// The autoscale half of [`PolicyMode`].
617///
618/// Every field an `Autoscale` policy requires lives here, unconditionally. That
619/// is the whole trick: there is no `Option` to be `None` and no separate
620/// validator to forget, so "an autoscale policy with no capacity ceiling" is not
621/// a state this program can hold in memory, let alone persist.
622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
623#[serde(try_from = "AutoscaleConfigRepr")]
624pub struct AutoscaleConfig {
625    routing_labels: RoutingLabels,
626    min_capacity: u16,
627    max_capacity: NonZeroU16,
628}
629
630#[derive(Debug, Deserialize)]
631struct AutoscaleConfigRepr {
632    routing_labels: RoutingLabels,
633    min_capacity: u16,
634    max_capacity: NonZeroU16,
635}
636
637impl TryFrom<AutoscaleConfigRepr> for AutoscaleConfig {
638    type Error = PolicyError;
639
640    fn try_from(repr: AutoscaleConfigRepr) -> Result<Self, Self::Error> {
641        Self::new(repr.routing_labels, repr.min_capacity, repr.max_capacity)
642    }
643}
644
645impl AutoscaleConfig {
646    /// # Errors
647    /// [`PolicyError::InvertedCapacityRange`] when `min > max`. Validated here
648    /// so `clamp(demand, min, max)` in [`crate::capacity`] is always
649    /// well-defined — an inverted range makes `clamp` panic in Rust, so this is
650    /// not a stylistic check.
651    pub fn new(
652        routing_labels: RoutingLabels,
653        min_capacity: u16,
654        max_capacity: NonZeroU16,
655    ) -> Result<Self, PolicyError> {
656        if min_capacity > max_capacity.get() {
657            return Err(PolicyError::InvertedCapacityRange {
658                min: min_capacity,
659                max: max_capacity.get(),
660            });
661        }
662        Ok(Self {
663            routing_labels,
664            min_capacity,
665            max_capacity,
666        })
667    }
668
669    /// The v1 shape: `min_capacity` fixed at 0 (D7).
670    ///
671    /// # Errors
672    /// Never fails, because 0 cannot exceed a [`NonZeroU16`]; the `Result` is
673    /// kept so that lifting the D7 restriction later is not a signature change.
674    pub fn v1(
675        routing_labels: RoutingLabels,
676        max_capacity: NonZeroU16,
677    ) -> Result<Self, PolicyError> {
678        Self::new(routing_labels, 0, max_capacity)
679    }
680
681    #[must_use]
682    pub fn routing_labels(&self) -> &RoutingLabels {
683        &self.routing_labels
684    }
685
686    #[must_use]
687    pub fn routing_labels_mut(&mut self) -> &mut RoutingLabels {
688        &mut self.routing_labels
689    }
690
691    #[must_use]
692    pub const fn min_capacity(&self) -> u16 {
693        self.min_capacity
694    }
695
696    #[must_use]
697    pub const fn max_capacity(&self) -> NonZeroU16 {
698        self.max_capacity
699    }
700
701    /// # Errors
702    /// [`PolicyError::InvertedCapacityRange`] if the new ceiling is below the
703    /// existing floor.
704    pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
705        if self.min_capacity > max_capacity.get() {
706            return Err(PolicyError::InvertedCapacityRange {
707                min: self.min_capacity,
708                max: max_capacity.get(),
709            });
710        }
711        self.max_capacity = max_capacity;
712        Ok(())
713    }
714}
715
716/// D19, as an enforced invariant rather than a convention.
717///
718/// `04-subsystem-contracts.md`:
719///
720/// * "`MonitorOnly` requires `routing_labels` and `max_capacity` to be `None`."
721/// * "`Autoscale` requires both to be `Some`."
722///
723/// The contract writes those as three flat, independently-`Option`al fields on
724/// `ScalePolicy`, which admits four combinations of which two are illegal. This
725/// enum admits exactly the two legal ones, so the illegal pair has no
726/// representation — `b1` asks for that explicitly: "Prefer a representation
727/// where the illegal combination cannot be built at all over one that is merely
728/// validated on the way in."
729///
730/// The flat shape is still reachable: [`PolicyMode::routing_labels`],
731/// [`PolicyMode::min_capacity`], and [`PolicyMode::max_capacity`] return exactly
732/// the `Option`s the contract names, and [`PolicyMode::from_persisted`] rebuilds
733/// the mode from them. That is `b2`'s load path, and it is where a hand-edited
734/// database row is rejected.
735#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
736#[serde(tag = "mode", rename_all = "snake_case")]
737pub enum PolicyMode {
738    /// Contributes runners and workflow counts to the dashboard; owns no
739    /// routing label; skipped entirely by reconciliation.
740    MonitorOnly,
741    /// Starts runners, up to `max_capacity` and the host ceiling.
742    Autoscale(AutoscaleConfig),
743}
744
745impl PolicyMode {
746    #[must_use]
747    pub const fn monitor_only() -> Self {
748        Self::MonitorOnly
749    }
750
751    /// # Errors
752    /// [`PolicyError::InvertedCapacityRange`].
753    pub fn autoscale(
754        routing_labels: RoutingLabels,
755        min_capacity: u16,
756        max_capacity: NonZeroU16,
757    ) -> Result<Self, PolicyError> {
758        Ok(Self::Autoscale(AutoscaleConfig::new(
759            routing_labels,
760            min_capacity,
761            max_capacity,
762        )?))
763    }
764
765    /// Rebuild the mode from the flat persisted shape.
766    ///
767    /// This is the gate `b2` puts every load through. All four combinations of
768    /// `routing_labels`/`max_capacity` arrive here, and two of them are refused
769    /// with a named error rather than being coerced into something plausible.
770    ///
771    /// # Errors
772    /// Each illegal shape gets its own variant, so `b2` can say which column of
773    /// which row is wrong rather than "invalid policy".
774    pub fn from_persisted(
775        routing_labels: Option<RoutingLabels>,
776        min_capacity: u16,
777        max_capacity: Option<NonZeroU16>,
778    ) -> Result<Self, PolicyError> {
779        match (routing_labels, max_capacity) {
780            (None, None) => {
781                if min_capacity != 0 {
782                    // Not stated as a shape rule in `04`, but a MonitorOnly
783                    // policy never starts a runner, so a non-zero floor is data
784                    // that cannot mean anything. Refusing it loudly beats
785                    // loading it and silently ignoring it.
786                    return Err(PolicyError::MonitorOnlyWithMinCapacity { min: min_capacity });
787                }
788                Ok(Self::MonitorOnly)
789            }
790            (Some(_), None) => Err(PolicyError::AutoscaleWithoutMaxCapacity),
791            (None, Some(_)) => Err(PolicyError::AutoscaleWithoutRoutingLabels),
792            (Some(labels), Some(max)) => Self::autoscale(labels, min_capacity, max),
793        }
794    }
795
796    /// The contract's `routing_labels: Option<NonEmpty<Label>>`, in `Option`
797    /// form.
798    #[must_use]
799    pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
800        match self {
801            PolicyMode::MonitorOnly => None,
802            PolicyMode::Autoscale(cfg) => Some(&cfg.routing_labels),
803        }
804    }
805
806    #[must_use]
807    pub const fn min_capacity(&self) -> u16 {
808        match self {
809            PolicyMode::MonitorOnly => 0,
810            PolicyMode::Autoscale(cfg) => cfg.min_capacity,
811        }
812    }
813
814    #[must_use]
815    pub const fn max_capacity(&self) -> Option<NonZeroU16> {
816        match self {
817            PolicyMode::MonitorOnly => None,
818            PolicyMode::Autoscale(cfg) => Some(cfg.max_capacity),
819        }
820    }
821
822    #[must_use]
823    pub const fn autoscale_config(&self) -> Option<&AutoscaleConfig> {
824        match self {
825            PolicyMode::MonitorOnly => None,
826            PolicyMode::Autoscale(cfg) => Some(cfg),
827        }
828    }
829
830    #[must_use]
831    pub const fn is_autoscale(&self) -> bool {
832        matches!(self, PolicyMode::Autoscale(_))
833    }
834
835    #[must_use]
836    pub const fn is_monitor_only(&self) -> bool {
837        matches!(self, PolicyMode::MonitorOnly)
838    }
839}
840
841// ---------------------------------------------------------------------------
842// PolicyState
843// ---------------------------------------------------------------------------
844
845/// The policy lifecycle, exactly as `04-subsystem-contracts.md` draws it:
846///
847/// ```text
848/// pending -> active | repair_required
849/// active  -> draining -> disabled -> pending
850/// any     -> authentication_failed        (recoverable by re-authentication)
851/// ```
852///
853/// **Every transition outside that diagram is rejected**, which `b1`'s
854/// Definition of Done requires. Two consequences are worth stating because they
855/// are surprising, and both are recorded as findings rather than papered over:
856///
857/// * `RepairRequired` has no outgoing edge except the `any` rule. A policy that
858///   enters it can never return to `Active` through this state machine.
859/// * `Disabled -> Pending` begins a fresh lifecycle when an operator re-enables
860///   a policy that previously finished draining. Activation remains a separate
861///   `Pending -> Active` transition, preserving the normal entry-state checks.
862///
863/// The one edge here that the diagram does not draw as an arrow is
864/// `AuthenticationFailed -> Pending`, which is the parenthetical "(recoverable
865/// by re-authentication)" made executable; `pending` is where it lands because
866/// that is the diagram's only entry state.
867#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
868#[serde(rename_all = "snake_case")]
869pub enum PolicyState {
870    Pending,
871    Active,
872    Draining,
873    Disabled,
874    RepairRequired,
875    AuthenticationFailed,
876}
877
878impl PolicyState {
879    pub const ALL: [PolicyState; 6] = [
880        PolicyState::Pending,
881        PolicyState::Active,
882        PolicyState::Draining,
883        PolicyState::Disabled,
884        PolicyState::RepairRequired,
885        PolicyState::AuthenticationFailed,
886    ];
887
888    /// The complete legal transition list. Nothing outside it is permitted, and
889    /// a self-transition is not in it either.
890    pub const LEGAL: &'static [(PolicyState, PolicyState)] = &[
891        (PolicyState::Pending, PolicyState::Active),
892        (PolicyState::Pending, PolicyState::RepairRequired),
893        (PolicyState::Active, PolicyState::Draining),
894        (PolicyState::Draining, PolicyState::Disabled),
895        (PolicyState::Disabled, PolicyState::Pending),
896        // `any -> authentication_failed`.
897        (PolicyState::Pending, PolicyState::AuthenticationFailed),
898        (PolicyState::Active, PolicyState::AuthenticationFailed),
899        (PolicyState::Draining, PolicyState::AuthenticationFailed),
900        (PolicyState::Disabled, PolicyState::AuthenticationFailed),
901        (
902            PolicyState::RepairRequired,
903            PolicyState::AuthenticationFailed,
904        ),
905        // "(recoverable by re-authentication)".
906        (PolicyState::AuthenticationFailed, PolicyState::Pending),
907    ];
908
909    #[must_use]
910    pub fn can_transition_to(self, next: PolicyState) -> bool {
911        Self::LEGAL.contains(&(self, next))
912    }
913
914    /// True while the policy is allowed to be the reason a runner starts.
915    #[must_use]
916    pub const fn admits_new_runners(self) -> bool {
917        matches!(self, PolicyState::Active)
918    }
919}
920
921impl fmt::Display for PolicyState {
922    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
923        f.write_str(match self {
924            PolicyState::Pending => "pending",
925            PolicyState::Active => "active",
926            PolicyState::Draining => "draining",
927            PolicyState::Disabled => "disabled",
928            PolicyState::RepairRequired => "repair_required",
929            PolicyState::AuthenticationFailed => "authentication_failed",
930        })
931    }
932}
933
934// ---------------------------------------------------------------------------
935// ScalePolicy
936// ---------------------------------------------------------------------------
937
938/// One target, scaled (or merely watched) by one host.
939///
940/// `mode`, `enabled`, `state`, `workspace_policy`, and `revision` are private:
941/// each is governed by an invariant that a direct assignment would bypass. `id`,
942/// `target`, `installation_id`, `host_id`, and `cache_policy` are public because
943/// they are either immutable identity or self-validating values.
944#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
945pub struct ScalePolicy {
946    pub id: PolicyId,
947    pub target: ScaleTarget,
948    pub installation_id: u64,
949    pub host_id: HostId,
950    /// Operator-chosen host identity retained even for MonitorOnly policies.
951    pub requested_host_label: HostLabel,
952    mode: PolicyMode,
953    enabled: bool,
954    state: PolicyState,
955    pub cache_policy: CachePolicy,
956    /// D4: whether this repository's job workspace survives an attempt, and
957    /// where.
958    ///
959    /// Private because of D7: a persistent value is legal for a repository
960    /// target and corrupt state for an organization one, and `target` is right
961    /// here to check it against. [`Self::set_workspace_policy`] is the only
962    /// writer, so the pair cannot be made inconsistent by assignment.
963    workspace_policy: WorkspacePolicy,
964    revision: u64,
965}
966
967/// Every stored column of one policy, named rather than positional.
968///
969/// **Why this is a struct.** [`ScalePolicy::from_persisted`] took eleven
970/// positional arguments under `#[allow(clippy::too_many_arguments)]`, and two of
971/// them — `installation_id` and `revision` — are both bare `u64`. Transposing
972/// them type-checked and compiled: the policy would then have authenticated
973/// against an installation id of `0` or `1` while presenting its installation id
974/// as an optimistic-concurrency token, so every write would have raced and every
975/// GitHub call would have failed to authenticate, with nothing in either
976/// signature to catch it.
977///
978/// `b2` maps database columns onto this type. With a struct that mapping is
979/// checked by name at compile time; positionally it was checked by nothing.
980///
981/// **That guarantee covers the Rust side of the mapping and no more.** It is the
982/// *field* names that the compiler checks, not the column names they are read
983/// from: `PersistedPolicy { installation_id: row.get("revision")?, … }` compiles
984/// exactly as happily as the correct version, and reintroduces the very
985/// transposition described above. `b2` still owes a test that loads a row whose
986/// columns hold distinguishable values and asserts each landed in the field of
987/// the same name; this type does not supply one.
988///
989/// Construct it with a struct literal so every field is written down at the call
990/// site — that is the whole point, and a builder or a `Default` would give the
991/// omission back.
992#[derive(Debug, Clone, PartialEq, Eq)]
993pub struct PersistedPolicy {
994    pub id: PolicyId,
995    pub target: ScaleTarget,
996    /// The GitHub App installation this policy authenticates through.
997    pub installation_id: u64,
998    pub host_id: HostId,
999    pub requested_host_label: HostLabel,
1000    /// `Some` for an Autoscale policy, `None` for a MonitorOnly one (D19).
1001    pub routing_labels: Option<RoutingLabels>,
1002    pub min_capacity: u16,
1003    pub max_capacity: Option<NonZeroU16>,
1004    /// Operator intent, independent of `state`.
1005    pub enabled: bool,
1006    pub state: PolicyState,
1007    pub cache_policy: CachePolicy,
1008    /// `ephemeral` or `persistent`, stored beside the root below (D4). The two
1009    /// are separate columns rather than one because that is what SQLite holds;
1010    /// [`WorkspacePolicy::from_persisted`] is what refuses the combinations this
1011    /// crate cannot have written.
1012    pub workspace_kind: WorkspaceKind,
1013    /// The configured persistent root: `Some` exactly when `workspace_kind` is
1014    /// `persistent`.
1015    pub workspace_root: Option<LocalAbsolutePath>,
1016    /// Optimistic-concurrency token. Not an identifier of anything.
1017    pub revision: u64,
1018}
1019
1020impl ScalePolicy {
1021    /// A newly added policy.
1022    ///
1023    /// D20: `add` never arms a host. The policy starts `Pending` with
1024    /// `enabled == false`, and only an explicit `set-scale` moves it on. That is
1025    /// true for a policy created with `--max-capacity` too, which is why this is
1026    /// a property of the constructor rather than of the caller.
1027    ///
1028    /// **This is the `add` path, not the load path.** It resets `state` to
1029    /// `Pending`, `enabled` to `false` and `revision` to `0`, so calling it on a
1030    /// row read back from storage silently disarms a live policy and resets its
1031    /// concurrency token. [`Self::from_persisted`] is the one that reloads a
1032    /// stored policy; it sits directly below this and takes all three.
1033    #[must_use]
1034    pub fn new(
1035        id: PolicyId,
1036        target: ScaleTarget,
1037        installation_id: u64,
1038        host_id: HostId,
1039        mode: PolicyMode,
1040        cache_policy: CachePolicy,
1041    ) -> Self {
1042        Self::new_for_host_label(
1043            id,
1044            target,
1045            installation_id,
1046            host_id,
1047            HostLabel::new("host").expect("the compatibility host label is valid"),
1048            mode,
1049            cache_policy,
1050        )
1051    }
1052
1053    /// New policy retaining the exact operator-requested host identity.
1054    #[must_use]
1055    pub fn new_for_host_label(
1056        id: PolicyId,
1057        target: ScaleTarget,
1058        installation_id: u64,
1059        host_id: HostId,
1060        requested_host_label: HostLabel,
1061        mode: PolicyMode,
1062        cache_policy: CachePolicy,
1063    ) -> Self {
1064        Self {
1065            id,
1066            target,
1067            installation_id,
1068            host_id,
1069            requested_host_label,
1070            mode,
1071            enabled: false,
1072            state: PolicyState::Pending,
1073            cache_policy,
1074            // D3/D4: `repo add` never configures a workspace. Persistence is a
1075            // separate, explicit `repo set-workspace`, so a policy this
1076            // constructor produced is always disposable.
1077            workspace_policy: WorkspacePolicy::Ephemeral,
1078            revision: 0,
1079        }
1080    }
1081
1082    /// Rebuild a stored policy, re-validating D19's shape.
1083    ///
1084    /// This is the load path. Unlike [`Self::new`] it preserves `state`,
1085    /// `enabled` and `revision` exactly as stored.
1086    ///
1087    /// # Errors
1088    /// Any illegal `PolicyMode` shape, per [`PolicyMode::from_persisted`].
1089    pub fn from_persisted(fields: PersistedPolicy) -> Result<Self, PolicyError> {
1090        let PersistedPolicy {
1091            id,
1092            target,
1093            installation_id,
1094            host_id,
1095            requested_host_label,
1096            routing_labels,
1097            min_capacity,
1098            max_capacity,
1099            enabled,
1100            state,
1101            cache_policy,
1102            workspace_kind,
1103            workspace_root,
1104            revision,
1105        } = fields;
1106
1107        let mode = PolicyMode::from_persisted(routing_labels, min_capacity, max_capacity)?;
1108        // D7 is re-run on every load and not only at the CLI. A row that claims
1109        // an organization retains a job workspace is corrupt state, not a
1110        // configuration this build should honour.
1111        let workspace_policy =
1112            WorkspacePolicy::from_persisted(workspace_kind, workspace_root, target.scope())?;
1113        Ok(Self {
1114            id,
1115            target,
1116            installation_id,
1117            host_id,
1118            requested_host_label,
1119            mode,
1120            enabled,
1121            state,
1122            cache_policy,
1123            workspace_policy,
1124            revision,
1125        })
1126    }
1127
1128    /// Every stored column of this policy, for `b2` to write back.
1129    ///
1130    /// The exact inverse of [`Self::from_persisted`], so a round trip through
1131    /// storage is expressible without this type exposing `mode`, `enabled`,
1132    /// `state` and `revision` for writing.
1133    #[must_use]
1134    pub fn to_persisted(&self) -> PersistedPolicy {
1135        PersistedPolicy {
1136            id: self.id,
1137            target: self.target.clone(),
1138            installation_id: self.installation_id,
1139            host_id: self.host_id,
1140            requested_host_label: self.requested_host_label.clone(),
1141            routing_labels: self.routing_labels().cloned(),
1142            min_capacity: self.min_capacity(),
1143            max_capacity: self.max_capacity(),
1144            enabled: self.enabled,
1145            state: self.state,
1146            cache_policy: self.cache_policy,
1147            workspace_kind: self.workspace_policy.kind(),
1148            workspace_root: self.workspace_policy.root().cloned(),
1149            revision: self.revision,
1150        }
1151    }
1152
1153    #[must_use]
1154    pub const fn mode(&self) -> &PolicyMode {
1155        &self.mode
1156    }
1157
1158    #[must_use]
1159    pub const fn state(&self) -> PolicyState {
1160        self.state
1161    }
1162
1163    /// Operator intent, independent of `state`
1164    /// (`04-subsystem-contracts.md`: "`enabled` records operator intent;
1165    /// `state` records observed lifecycle").
1166    #[must_use]
1167    pub const fn enabled(&self) -> bool {
1168        self.enabled
1169    }
1170
1171    /// Optimistic-concurrency token. Every successful mutation below bumps it;
1172    /// `b2` rejects a write against a stale value.
1173    #[must_use]
1174    pub const fn revision(&self) -> u64 {
1175        self.revision
1176    }
1177
1178    #[must_use]
1179    pub const fn routing_labels(&self) -> Option<&RoutingLabels> {
1180        self.mode.routing_labels()
1181    }
1182
1183    /// D4: this repository's configured workspace behaviour.
1184    #[must_use]
1185    pub const fn workspace_policy(&self) -> &WorkspacePolicy {
1186        &self.workspace_policy
1187    }
1188
1189    /// `repo set-workspace --mode …` (D4).
1190    ///
1191    /// The refusal of a persistent workspace for an organization target is D7,
1192    /// and it lives here rather than in the command layer because
1193    /// [`Self::from_persisted`] has to apply the identical rule to a stored row:
1194    /// one place, one message, one test.
1195    ///
1196    /// Like every other mutation on this type it bumps the revision, so `a2`'s
1197    /// optimistic guard rejects a write built from a stale read. It does **not**
1198    /// check for active attempts — D9's "a path change is refused while affected
1199    /// attempts are active" needs the uncleaned-attempt count in the same write
1200    /// transaction, which is `a2`'s fence and not something the domain can see.
1201    ///
1202    /// # Errors
1203    /// [`PolicyError::Workspace`] wrapping
1204    /// [`WorkspaceError::PersistentRequiresRepositoryScope`] for an organization
1205    /// target.
1206    pub fn set_workspace_policy(&mut self, workspace: WorkspacePolicy) -> Result<(), PolicyError> {
1207        // `WorkspacePolicy::Persistent` is a public variant, so a caller can
1208        // build one without going through `WorkspacePolicy::persistent`. The
1209        // rule is re-run here on the value actually handed in, through the one
1210        // predicate that owns it.
1211        workspace.permitted_for(self.target.scope())?;
1212        if self.workspace_policy != workspace {
1213            self.workspace_policy = workspace;
1214            self.revision = self.revision.saturating_add(1);
1215        }
1216        Ok(())
1217    }
1218
1219    #[must_use]
1220    pub const fn min_capacity(&self) -> u16 {
1221        self.mode.min_capacity()
1222    }
1223
1224    #[must_use]
1225    pub const fn max_capacity(&self) -> Option<NonZeroU16> {
1226        self.mode.max_capacity()
1227    }
1228
1229    /// Ownership rule 1: a policy's `host_id` and its host-scoped
1230    /// `routing_labels` determine ownership.
1231    #[must_use]
1232    pub fn is_owned_by(&self, host_id: HostId) -> bool {
1233        self.host_id == host_id
1234    }
1235
1236    /// Ownership rule 1, second half: "A `MonitorOnly` policy owns nothing and
1237    /// can never be the reason a runner starts."
1238    ///
1239    /// `e1` is required to assert this directly rather than to rely on
1240    /// `max_capacity` being absent, which is why it is a predicate on the mode
1241    /// and not an arithmetic accident.
1242    #[must_use]
1243    pub const fn owns_runners(&self) -> bool {
1244        self.mode.is_autoscale()
1245    }
1246
1247    /// Whether reconciliation may start a runner for this policy right now.
1248    ///
1249    /// All three conditions matter: monitor-only owns nothing (D19), a disabled
1250    /// or draining policy takes no new work (`03-control-flows.md`, flow 5), and
1251    /// a user-requested disable beats demand (precedence rule 4).
1252    #[must_use]
1253    pub const fn may_start_runners(&self) -> bool {
1254        self.mode.is_autoscale() && self.enabled && self.state.admits_new_runners()
1255    }
1256
1257    /// # Errors
1258    /// [`PolicyError::IllegalTransition`] for anything outside
1259    /// [`PolicyState::LEGAL`].
1260    pub fn transition_to(&mut self, next: PolicyState) -> Result<(), PolicyError> {
1261        if !self.state.can_transition_to(next) {
1262            return Err(PolicyError::IllegalTransition {
1263                from: self.state,
1264                to: next,
1265            });
1266        }
1267        self.state = next;
1268        self.revision = self.revision.saturating_add(1);
1269        Ok(())
1270    }
1271
1272    /// Whether [`Self::activate`] would succeed right now.
1273    ///
1274    /// Exposed so `f2` can implement the idempotent CLI behaviour described on
1275    /// [`Self::activate`] without either duplicating the state table or calling
1276    /// and discarding an error.
1277    #[must_use]
1278    pub fn can_activate(&self) -> bool {
1279        self.state.can_transition_to(PolicyState::Active)
1280    }
1281
1282    /// Whether [`Self::request_disable`] would succeed right now.
1283    #[must_use]
1284    pub fn can_request_disable(&self) -> bool {
1285        self.state.can_transition_to(PolicyState::Draining)
1286    }
1287
1288    /// `set-scale --enabled true` on a `Pending` policy (`03-control-flows.md`,
1289    /// flow 1.6).
1290    ///
1291    /// **Not idempotent, and that is intended.** This is a *transition*
1292    /// operation, not a desired-state one: it reports what the state machine
1293    /// permits and never silently accepts a call the diagram has no edge for.
1294    /// Calling it on an already-`Active` policy is [`PolicyError::IllegalTransition`],
1295    /// not a no-op.
1296    ///
1297    /// The idempotent reading — "make this policy enabled, whatever it is now" —
1298    /// is a *command-level* behaviour, and it belongs to `f2` because the answer
1299    /// depends on what `set-scale --enabled true` should mean for a `draining`,
1300    /// `disabled`, `repair_required` or `authentication_failed` policy, and each
1301    /// of those is a product decision rather than a domain one. Collapsing them
1302    /// here would make the domain answer them by accident. `f2` should branch on
1303    /// [`Self::can_activate`] and report the already-satisfied case as success
1304    /// without calling this at all.
1305    ///
1306    /// # Errors
1307    /// [`PolicyError::IllegalTransition`] when the policy is not `Pending`.
1308    pub fn activate(&mut self) -> Result<(), PolicyError> {
1309        self.transition_to(PolicyState::Active)?;
1310        self.enabled = true;
1311        Ok(())
1312    }
1313
1314    /// `set-scale --enabled false` (`03-control-flows.md`, flow 5.2).
1315    ///
1316    /// Precedence rule 4: a user-requested disable beats demand. `enabled` drops
1317    /// immediately — which alone is enough to stop new runners, because
1318    /// [`Self::may_start_runners`] reads it — and the observed state moves to
1319    /// `Draining`, where busy runners are left to finish.
1320    ///
1321    /// **Not idempotent, for the reason given on [`Self::activate`].** In
1322    /// particular `set-scale --enabled false` on a `pending` policy is
1323    /// `IllegalTransition { from: pending, to: draining }` rather than a no-op,
1324    /// even though a `pending` policy is already `enabled == false` and so is
1325    /// already starting nothing. `f2` translates that through
1326    /// [`Self::can_request_disable`]: a policy that cannot legally drain and is
1327    /// already not enabled has nothing to do, which is a successful outcome for
1328    /// the command and not an error to print.
1329    ///
1330    /// # Errors
1331    /// [`PolicyError::IllegalTransition`] when the policy is not `Active`.
1332    pub fn request_disable(&mut self) -> Result<PolicyState, PolicyError> {
1333        self.transition_to(PolicyState::Draining)?;
1334        self.enabled = false;
1335        Ok(self.state)
1336    }
1337
1338    /// Flow 5.3: "When active local runners reach zero … the policy becomes
1339    /// `disabled`."
1340    ///
1341    /// Returns the state after the call, unchanged when runners remain — a
1342    /// draining policy with work in flight is not an error, it is the normal
1343    /// case for the duration of the last job.
1344    ///
1345    /// # Errors
1346    /// [`PolicyError::IllegalTransition`] when the policy is not `Draining`.
1347    pub fn drain_completed(&mut self, active_attempts: u16) -> Result<PolicyState, PolicyError> {
1348        if self.state != PolicyState::Draining {
1349            return Err(PolicyError::IllegalTransition {
1350                from: self.state,
1351                to: PolicyState::Disabled,
1352            });
1353        }
1354        if active_attempts == 0 {
1355            self.transition_to(PolicyState::Disabled)?;
1356        }
1357        Ok(self.state)
1358    }
1359
1360    /// Any state -> `AuthenticationFailed` (flow 4.5).
1361    ///
1362    /// # Errors
1363    /// Only when already in `AuthenticationFailed`; re-reporting the same
1364    /// failure is not a transition.
1365    pub fn authentication_failed(&mut self) -> Result<(), PolicyError> {
1366        self.transition_to(PolicyState::AuthenticationFailed)
1367    }
1368
1369    /// "(recoverable by re-authentication)".
1370    ///
1371    /// # Errors
1372    /// [`PolicyError::IllegalTransition`] unless the policy is in
1373    /// `AuthenticationFailed`.
1374    pub fn reauthenticated(&mut self) -> Result<(), PolicyError> {
1375        self.transition_to(PolicyState::Pending)
1376    }
1377
1378    /// Flow 1.4: a local transaction that did not complete.
1379    ///
1380    /// # Errors
1381    /// [`PolicyError::IllegalTransition`] unless the policy is `Pending`.
1382    pub fn repair_required(&mut self) -> Result<(), PolicyError> {
1383        self.transition_to(PolicyState::RepairRequired)
1384    }
1385
1386    /// D19 promotion: `set-capacity` on a monitor-only policy.
1387    ///
1388    /// The routing label is derived at this point and not before, because a
1389    /// monitor-only policy reserves none (`f2`).
1390    ///
1391    /// # Errors
1392    /// [`PolicyError::AlreadyAutoscale`] when the policy already autoscales, or
1393    /// [`PolicyError::InvertedCapacityRange`].
1394    pub fn promote_to_autoscale(
1395        &mut self,
1396        routing_labels: RoutingLabels,
1397        min_capacity: u16,
1398        max_capacity: NonZeroU16,
1399    ) -> Result<(), PolicyError> {
1400        if self.mode.is_autoscale() {
1401            return Err(PolicyError::AlreadyAutoscale);
1402        }
1403        self.mode = PolicyMode::autoscale(routing_labels, min_capacity, max_capacity)?;
1404        self.revision = self.revision.saturating_add(1);
1405        Ok(())
1406    }
1407
1408    /// `repo set-capacity` / `org set-capacity` on a policy that already
1409    /// autoscales.
1410    ///
1411    /// # Errors
1412    /// [`PolicyError::NotAutoscale`] when the policy is monitor-only (use
1413    /// [`Self::promote_to_autoscale`]), or
1414    /// [`PolicyError::InvertedCapacityRange`].
1415    pub fn set_max_capacity(&mut self, max_capacity: NonZeroU16) -> Result<(), PolicyError> {
1416        match &mut self.mode {
1417            PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1418            PolicyMode::Autoscale(cfg) => {
1419                cfg.set_max_capacity(max_capacity)?;
1420                self.revision = self.revision.saturating_add(1);
1421                Ok(())
1422            }
1423        }
1424    }
1425
1426    /// Add an optional descriptive routing label.
1427    ///
1428    /// # Errors
1429    /// [`PolicyError::NotAutoscale`] for a monitor-only policy, which owns no
1430    /// label set to add to. This once reported a `MonitorOnlyWithRoutingLabels`
1431    /// variant, which said that a *stored row* had an illegal shape — a
1432    /// different claim from "this operation needs an autoscale policy", and one
1433    /// that had `f2` rendering a validation failure for an ordinary wrong-mode
1434    /// refusal. That variant is now gone entirely; see the note where it stood.
1435    pub fn add_routing_label(&mut self, label: Label) -> Result<bool, PolicyError> {
1436        match &mut self.mode {
1437            PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1438            PolicyMode::Autoscale(cfg) => {
1439                let added = cfg.routing_labels_mut().add(label);
1440                if added {
1441                    self.revision = self.revision.saturating_add(1);
1442                }
1443                Ok(added)
1444            }
1445        }
1446    }
1447
1448    /// Remove an optional descriptive routing label.
1449    ///
1450    /// # Errors
1451    /// [`PolicyError::HostLabelNotRemovable`] for the derived host label, or
1452    /// [`PolicyError::NotAutoscale`] for a monitor-only policy — see
1453    /// [`Self::add_routing_label`] for why that variant.
1454    pub fn remove_routing_label(&mut self, label: &Label) -> Result<bool, PolicyError> {
1455        match &mut self.mode {
1456            PolicyMode::MonitorOnly => Err(PolicyError::NotAutoscale),
1457            PolicyMode::Autoscale(cfg) => {
1458                let removed = cfg.routing_labels_mut().remove(label)?;
1459                if removed {
1460                    self.revision = self.revision.saturating_add(1);
1461                }
1462                Ok(removed)
1463            }
1464        }
1465    }
1466
1467    /// The demand signal for this policy, given one poll's queued jobs.
1468    ///
1469    /// A monitor-only policy has no routing labels, so it has no demand at all —
1470    /// not "demand that is then ignored". D19: it "is skipped entirely by
1471    /// reconciliation".
1472    #[must_use]
1473    pub fn tally<'a>(&self, jobs: impl IntoIterator<Item = &'a RunsOn>) -> DemandTally {
1474        match self.routing_labels() {
1475            Some(labels) => labels.tally(jobs),
1476            None => DemandTally::default(),
1477        }
1478    }
1479}
1480
1481#[cfg(test)]
1482mod tests {
1483    use super::*;
1484    use crate::model::{HostId, PolicyId, TargetScope};
1485
1486    fn nz(v: u16) -> NonZeroU16 {
1487        NonZeroU16::new(v).expect("test capacity is non-zero")
1488    }
1489
1490    fn label(s: &str) -> Label {
1491        Label::new(s).expect("test label is valid")
1492    }
1493
1494    fn host_labels(host: &str) -> RoutingLabels {
1495        RoutingLabels::derive(&HostLabel::new(host).unwrap(), Os::Windows, Arch::X64)
1496    }
1497
1498    fn autoscale_policy(target: ScaleTarget, host: HostId, max: u16) -> ScalePolicy {
1499        ScalePolicy::new(
1500            PolicyId::from_u128(1),
1501            target,
1502            42,
1503            host,
1504            PolicyMode::autoscale(host_labels("home"), 0, nz(max)).unwrap(),
1505            CachePolicy::default(),
1506        )
1507    }
1508
1509    // =======================================================================
1510    // Workspace policy (D4, D7)
1511    // =======================================================================
1512
1513    fn workspace_root() -> LocalAbsolutePath {
1514        LocalAbsolutePath::parse_for("/srv/rman/acme", crate::path::PathPlatform::Unix)
1515            .expect("a valid persistent root")
1516    }
1517
1518    fn repository_policy() -> ScalePolicy {
1519        autoscale_policy(
1520            ScaleTarget::repository("acme/api").unwrap(),
1521            HostId::from_u128(1),
1522            4,
1523        )
1524    }
1525
1526    fn organization_policy() -> ScalePolicy {
1527        autoscale_policy(
1528            ScaleTarget::organization("acme").unwrap(),
1529            HostId::from_u128(1),
1530            4,
1531        )
1532    }
1533
1534    #[test]
1535    fn every_constructor_produces_an_ephemeral_workspace() {
1536        // D3: `repo add` and `org add` never arm persistence, so a policy this
1537        // build creates behaves exactly as it did before D4 existed.
1538        for policy in [repository_policy(), organization_policy()] {
1539            assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
1540            assert!(!policy.workspace_policy().retains_job_workspace());
1541            assert_eq!(
1542                policy.to_persisted().workspace_kind,
1543                WorkspaceKind::Ephemeral
1544            );
1545            assert_eq!(policy.to_persisted().workspace_root, None);
1546        }
1547
1548        let monitor_only = ScalePolicy::new(
1549            PolicyId::from_u128(2),
1550            ScaleTarget::repository("acme/api").unwrap(),
1551            42,
1552            HostId::from_u128(1),
1553            PolicyMode::MonitorOnly,
1554            CachePolicy::default(),
1555        );
1556        assert_eq!(monitor_only.workspace_policy(), &WorkspacePolicy::Ephemeral);
1557    }
1558
1559    #[test]
1560    fn a_repository_policy_can_opt_into_a_persistent_workspace() {
1561        let mut policy = repository_policy();
1562        let before = policy.revision();
1563
1564        policy
1565            .set_workspace_policy(
1566                WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
1567                    .expect("a repository may be persistent"),
1568            )
1569            .expect("a repository policy accepts persistence");
1570
1571        assert!(policy.workspace_policy().is_persistent());
1572        assert_eq!(policy.workspace_policy().root(), Some(&workspace_root()));
1573        assert_eq!(
1574            policy.revision(),
1575            before + 1,
1576            "a workspace change must bump the optimistic token, or `a2`'s guard \
1577             cannot refuse a write built from a stale read"
1578        );
1579
1580        // Setting the same value again is not a change and must not consume a
1581        // revision, which would make an idempotent CLI call race the next writer.
1582        let unchanged = policy.revision();
1583        policy
1584            .set_workspace_policy(policy.workspace_policy().clone())
1585            .expect("re-setting the same policy is accepted");
1586        assert_eq!(policy.revision(), unchanged);
1587    }
1588
1589    #[test]
1590    fn an_organization_policy_cannot_be_made_persistent() {
1591        // D7: an organization runner can accept jobs from more than one
1592        // repository, so a retained `_work` would cross a repository boundary.
1593        let mut policy = organization_policy();
1594        let before = policy.revision();
1595
1596        assert_eq!(
1597            policy.set_workspace_policy(WorkspacePolicy::Persistent {
1598                root: workspace_root()
1599            }),
1600            Err(PolicyError::Workspace(
1601                WorkspaceError::PersistentRequiresRepositoryScope
1602            ))
1603        );
1604        assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
1605        assert_eq!(
1606            policy.revision(),
1607            before,
1608            "a refused write consumes nothing"
1609        );
1610
1611        // The constructor refuses the same thing, so there is no way to build the
1612        // value and hand it in already-made.
1613        assert_eq!(
1614            WorkspacePolicy::persistent(workspace_root(), TargetScope::Organization),
1615            Err(WorkspaceError::PersistentRequiresRepositoryScope)
1616        );
1617    }
1618
1619    #[test]
1620    fn a_workspace_policy_round_trips_through_the_persisted_struct() {
1621        let mut policy = repository_policy();
1622        policy
1623            .set_workspace_policy(
1624                WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
1625                    .expect("a repository may be persistent"),
1626            )
1627            .expect("a repository policy accepts persistence");
1628
1629        let restored = ScalePolicy::from_persisted(policy.to_persisted())
1630            .expect("a policy this crate wrote must load");
1631        assert_eq!(restored, policy);
1632        assert_eq!(restored.workspace_policy(), policy.workspace_policy());
1633
1634        let ephemeral = repository_policy();
1635        assert_eq!(
1636            ScalePolicy::from_persisted(ephemeral.to_persisted()).expect("must load"),
1637            ephemeral
1638        );
1639    }
1640
1641    #[test]
1642    fn an_organization_row_claiming_persistence_fails_closed_on_load() {
1643        let mut fields = organization_policy().to_persisted();
1644        fields.workspace_kind = WorkspaceKind::Persistent;
1645        fields.workspace_root = Some(workspace_root());
1646
1647        assert_eq!(
1648            ScalePolicy::from_persisted(fields),
1649            Err(PolicyError::Workspace(
1650                WorkspaceError::PersistentRequiresRepositoryScope
1651            ))
1652        );
1653    }
1654
1655    #[test]
1656    fn a_row_whose_workspace_columns_disagree_fails_closed_on_load() {
1657        let base = repository_policy().to_persisted();
1658
1659        let mut without_root = base.clone();
1660        without_root.workspace_kind = WorkspaceKind::Persistent;
1661        assert_eq!(
1662            ScalePolicy::from_persisted(without_root),
1663            Err(PolicyError::Workspace(
1664                WorkspaceError::PersistentWithoutRoot
1665            ))
1666        );
1667
1668        let mut stale_root = base;
1669        stale_root.workspace_root = Some(workspace_root());
1670        assert!(matches!(
1671            ScalePolicy::from_persisted(stale_root),
1672            Err(PolicyError::Workspace(
1673                WorkspaceError::EphemeralWithRoot { .. }
1674            ))
1675        ));
1676    }
1677
1678    #[test]
1679    fn workspace_retention_is_not_the_runner_package_cache_policy() {
1680        // `02-target-architecture.md`: "`WorkspacePolicy` is separate from
1681        // `CachePolicy`: runner-package retention and job-workspace retention
1682        // answer different questions and have different cleanup paths."
1683        let mut policy = repository_policy();
1684        policy.cache_policy = CachePolicy::DiscardRunnerPackage;
1685        policy
1686            .set_workspace_policy(
1687                WorkspacePolicy::persistent(workspace_root(), TargetScope::Repository)
1688                    .expect("a repository may be persistent"),
1689            )
1690            .expect("a repository policy accepts persistence");
1691
1692        assert!(policy.workspace_policy().retains_job_workspace());
1693        assert!(!policy.cache_policy.retains_runner_package());
1694        // The v1 constant is unchanged and still answers only for the package
1695        // cache; D4's decision is spelled on the other type on purpose.
1696        assert!(!policy.cache_policy.retains_job_workspace());
1697    }
1698
1699    // =======================================================================
1700    // Routing-label derivation
1701    // =======================================================================
1702
1703    #[test]
1704    fn the_derived_label_has_the_shape_the_architecture_gives() {
1705        // `02-target-architecture.md`: "for example `rm-home-win-x64`".
1706        let labels =
1707            RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
1708        assert_eq!(labels.host_label().as_str(), "rm-home-win-x64");
1709        assert_eq!(labels.count().get(), 1);
1710    }
1711
1712    #[test]
1713    fn the_derived_label_is_host_scoped_by_construction() {
1714        // `b1`: "the derived label ... is the only control that stops two hosts
1715        // from starting a runner for the same queued job". Same target, same OS,
1716        // same architecture, two hosts -> two different labels.
1717        let a = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::Windows, Arch::X64);
1718        let b = RoutingLabels::derive(&HostLabel::new("office").unwrap(), Os::Windows, Arch::X64);
1719
1720        assert_ne!(
1721            a.host_label(),
1722            b.host_label(),
1723            "two hosts must not derive the same routing label; with no job \
1724             reservation, a shared label means both hosts start a runner for one job"
1725        );
1726        assert_eq!(a.host_label().as_str(), "rm-home-win-x64");
1727        assert_eq!(b.host_label().as_str(), "rm-office-win-x64");
1728
1729        // The OS and architecture segments are host facts too.
1730        let mac = RoutingLabels::derive(&HostLabel::new("home").unwrap(), Os::MacOs, Arch::Arm64);
1731        assert_eq!(mac.host_label().as_str(), "rm-home-osx-arm64");
1732        assert_ne!(a.host_label(), mac.host_label());
1733    }
1734
1735    #[test]
1736    fn a_mixed_case_host_label_still_derives_a_lower_case_routing_label() {
1737        // GitHub lower-cases labels on registration
1738        // (`docs/spikes/d18-org-jit-verification.md`, Point 3, finding 3), so a
1739        // derived label that kept case would not match what comes back.
1740        let labels =
1741            RoutingLabels::derive(&HostLabel::new("Home-PC").unwrap(), Os::Linux, Arch::X64);
1742        assert_eq!(labels.host_label().as_str(), "rm-home-pc-linux-x64");
1743    }
1744
1745    #[test]
1746    fn optional_labels_can_be_added_and_removed_but_the_host_label_cannot() {
1747        let mut labels = host_labels("home");
1748        let derived = labels.host_label().clone();
1749
1750        assert!(labels.add(label("gpu")));
1751        assert!(labels.add(label("self-hosted")));
1752        assert!(
1753            !labels.add(label("GPU")),
1754            "adding a label that differs only in case must be a no-op, not a duplicate"
1755        );
1756        assert_eq!(labels.count().get(), 3);
1757
1758        assert!(labels.remove(&label("gpu")).unwrap());
1759        assert_eq!(labels.count().get(), 2);
1760        assert!(
1761            !labels.remove(&label("never-added")).unwrap(),
1762            "removing an absent optional label is a no-op, not an error"
1763        );
1764
1765        // The invariant.
1766        assert!(
1767            matches!(
1768                labels.remove(&derived),
1769                Err(PolicyError::HostLabelNotRemovable { .. })
1770            ),
1771            "the derived host label must not be removable; it is the only thing \
1772             keeping two hosts from serving each other's jobs"
1773        );
1774        assert!(labels.contains(&derived));
1775
1776        // And not by case-dodging either, since Label folds case.
1777        assert!(matches!(
1778            labels.remove(&label("RM-HOME-WIN-X64")),
1779            Err(PolicyError::HostLabelNotRemovable { .. })
1780        ));
1781    }
1782
1783    #[test]
1784    fn adding_the_host_label_as_an_optional_label_does_not_duplicate_it() {
1785        let mut labels = host_labels("home");
1786        let derived = labels.host_label().clone();
1787        assert!(!labels.add(derived));
1788        assert_eq!(labels.count().get(), 1);
1789
1790        // Nor via a stored set that repeats it.
1791        let rebuilt = RoutingLabels::from_parts(
1792            labels.host_label().clone(),
1793            vec![labels.host_label().clone(), label("gpu")],
1794        );
1795        assert_eq!(rebuilt.count().get(), 2);
1796        assert_eq!(
1797            rebuilt.as_registration_labels(),
1798            vec!["rm-home-win-x64", "gpu"]
1799        );
1800    }
1801
1802    #[test]
1803    fn the_registration_array_is_exactly_the_label_set_and_adds_nothing() {
1804        // `docs/spikes/d18-org-jit-verification.md`, Point 3, finding 1: "No
1805        // labels are added implicitly." So this array is the whole contract with
1806        // GitHub, and it must not quietly gain `self-hosted`, the OS, or the
1807        // architecture.
1808        let mut labels = host_labels("home");
1809        labels.add(label("gpu"));
1810        assert_eq!(
1811            labels.as_registration_labels(),
1812            vec!["rm-home-win-x64", "gpu"]
1813        );
1814        assert!(!labels.contains(&label("self-hosted")));
1815    }
1816
1817    #[test]
1818    fn routing_labels_round_trip_through_serde_with_the_host_label_intact() {
1819        let mut labels = host_labels("home");
1820        labels.add(label("gpu"));
1821        let json = serde_json::to_string(&labels).unwrap();
1822        let back: RoutingLabels = serde_json::from_str(&json).unwrap();
1823        assert_eq!(labels, back);
1824        assert_eq!(back.host_label().as_str(), "rm-home-win-x64");
1825    }
1826
1827    #[test]
1828    fn a_non_empty_view_of_the_label_set_is_available_in_the_contract_shape() {
1829        // `04-subsystem-contracts.md` types this as `Option<NonEmpty<Label>>`.
1830        let labels = host_labels("home");
1831        let non_empty = labels.to_non_empty();
1832        assert_eq!(non_empty.count().get(), 1);
1833        assert_eq!(non_empty.first().as_str(), "rm-home-win-x64");
1834    }
1835
1836    // =======================================================================
1837    // `runs-on` matching -- the table
1838    // =======================================================================
1839
1840    /// One row of the `runs-on` table. `expect` is asserted exactly, so a case
1841    /// that starts silently returning `Unresolvable` instead of `NoMatch` fails.
1842    struct Row {
1843        name: &'static str,
1844        runs_on: RunsOn,
1845        expect: Expect,
1846    }
1847
1848    #[derive(Debug, PartialEq, Eq)]
1849    enum Expect {
1850        Match,
1851        NoMatch,
1852        Unresolvable,
1853    }
1854
1855    fn classify(m: &RunsOnMatch) -> Expect {
1856        match m {
1857            RunsOnMatch::Match { .. } => Expect::Match,
1858            RunsOnMatch::NoMatch { .. } => Expect::NoMatch,
1859            RunsOnMatch::Unresolvable(_) => Expect::Unresolvable,
1860        }
1861    }
1862
1863    fn table_policy() -> RoutingLabels {
1864        // rm-home-win-x64 plus two optional labels.
1865        let mut labels = host_labels("home");
1866        labels.add(label("self-hosted"));
1867        labels.add(label("gpu"));
1868        labels
1869    }
1870
1871    fn table() -> Vec<Row> {
1872        vec![
1873            // ---- single string form -------------------------------------
1874            Row {
1875                name: "string: the derived host label",
1876                runs_on: RunsOn::Single("rm-home-win-x64".into()),
1877                expect: Expect::Match,
1878            },
1879            Row {
1880                name: "string: the derived host label in the wrong case",
1881                runs_on: RunsOn::Single("RM-Home-Win-X64".into()),
1882                expect: Expect::Match,
1883            },
1884            Row {
1885                name: "string: an optional label alone",
1886                runs_on: RunsOn::Single("gpu".into()),
1887                expect: Expect::Match,
1888            },
1889            Row {
1890                name: "string: another host's label",
1891                runs_on: RunsOn::Single("rm-office-win-x64".into()),
1892                expect: Expect::NoMatch,
1893            },
1894            Row {
1895                name: "string: a GitHub-hosted runner label",
1896                runs_on: RunsOn::Single("ubuntu-latest".into()),
1897                expect: Expect::NoMatch,
1898            },
1899            // ---- array form ---------------------------------------------
1900            Row {
1901                name: "array: a strict subset of the policy's labels",
1902                runs_on: RunsOn::Many(vec!["self-hosted".into(), "rm-home-win-x64".into()]),
1903                expect: Expect::Match,
1904            },
1905            Row {
1906                name: "array: the whole set, out of order and mixed case",
1907                runs_on: RunsOn::Many(vec![
1908                    "GPU".into(),
1909                    "Rm-Home-Win-X64".into(),
1910                    "Self-Hosted".into(),
1911                ]),
1912                expect: Expect::Match,
1913            },
1914            Row {
1915                name: "array: one label the policy does not carry",
1916                runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "arm64".into()]),
1917                expect: Expect::NoMatch,
1918            },
1919            Row {
1920                name: "array: an empty array names no labels",
1921                runs_on: RunsOn::Many(vec![]),
1922                expect: Expect::Unresolvable,
1923            },
1924            // ---- group/labels map form ----------------------------------
1925            Row {
1926                name: "map: labels only",
1927                runs_on: RunsOn::Grouped {
1928                    group: None,
1929                    labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into()]),
1930                },
1931                expect: Expect::Match,
1932            },
1933            Row {
1934                name: "map: a group plus labels the policy carries",
1935                runs_on: RunsOn::Grouped {
1936                    group: Some("Default".into()),
1937                    labels: RunsOnLabels::Many(vec!["rm-home-win-x64".into(), "gpu".into()]),
1938                },
1939                expect: Expect::Match,
1940            },
1941            Row {
1942                name: "map: labels as a scalar",
1943                runs_on: RunsOn::Grouped {
1944                    group: Some("Default".into()),
1945                    labels: RunsOnLabels::One("rm-home-win-x64".into()),
1946                },
1947                expect: Expect::Match,
1948            },
1949            Row {
1950                name: "map: a group plus a label the policy does not carry",
1951                runs_on: RunsOn::Grouped {
1952                    group: Some("Default".into()),
1953                    labels: RunsOnLabels::Many(vec!["macos".into()]),
1954                },
1955                expect: Expect::NoMatch,
1956            },
1957            Row {
1958                name: "map: a group with no labels constrains something we cannot read",
1959                runs_on: RunsOn::Grouped {
1960                    group: Some("Default".into()),
1961                    labels: RunsOnLabels::Many(vec![]),
1962                },
1963                expect: Expect::Unresolvable,
1964            },
1965            // ---- unresolvable -------------------------------------------
1966            Row {
1967                name: "expression: the whole value",
1968                runs_on: RunsOn::Single("${{ matrix.runner }}".into()),
1969                expect: Expect::Unresolvable,
1970            },
1971            Row {
1972                name: "expression: one element of an array",
1973                runs_on: RunsOn::Many(vec!["rm-home-win-x64".into(), "${{ inputs.extra }}".into()]),
1974                expect: Expect::Unresolvable,
1975            },
1976            Row {
1977                name: "expression: inside the map form",
1978                runs_on: RunsOn::Grouped {
1979                    group: None,
1980                    labels: RunsOnLabels::One("${{ vars.LABEL }}".into()),
1981                },
1982                expect: Expect::Unresolvable,
1983            },
1984            Row {
1985                name: "not a usable label at all",
1986                runs_on: RunsOn::Single("rm-home,win-x64".into()),
1987                expect: Expect::Unresolvable,
1988            },
1989        ]
1990    }
1991
1992    #[test]
1993    fn runs_on_matching_covers_every_documented_form() {
1994        let policy = table_policy();
1995        for row in table() {
1996            let got = policy.matches(&row.runs_on);
1997            assert_eq!(
1998                classify(&got),
1999                row.expect,
2000                "row {:?}: {:?} produced {got:?}",
2001                row.name,
2002                row.runs_on
2003            );
2004        }
2005    }
2006
2007    #[test]
2008    fn self_hosted_is_not_implicit_and_must_be_carried_to_be_matched() {
2009        // `docs/spikes/d18-org-jit-verification.md`, Point 3, finding 1: "A
2010        // workflow written as `runs-on: self-hosted` will not match a runner
2011        // registered without that label."
2012        let without = host_labels("home");
2013        assert!(
2014            !without
2015                .matches(&RunsOn::Single("self-hosted".into()))
2016                .is_match(),
2017            "a policy that does not carry `self-hosted` must not claim a job that asks for it"
2018        );
2019
2020        let mut with = host_labels("home");
2021        with.add(label("self-hosted"));
2022        assert!(
2023            with.matches(&RunsOn::Single("self-hosted".into()))
2024                .is_match(),
2025            "and it must claim it once the operator adds the label explicitly"
2026        );
2027    }
2028
2029    #[test]
2030    fn a_no_match_names_the_labels_that_were_missing() {
2031        let policy = host_labels("home");
2032        let got = policy.matches(&RunsOn::Many(vec![
2033            "rm-home-win-x64".into(),
2034            "self-hosted".into(),
2035            "GPU".into(),
2036        ]));
2037        match got {
2038            RunsOnMatch::NoMatch { missing } => {
2039                assert_eq!(
2040                    missing,
2041                    vec![label("self-hosted"), label("gpu")],
2042                    "the operator needs to know which labels to add"
2043                );
2044            }
2045            other => panic!("expected NoMatch, got {other:?}"),
2046        }
2047    }
2048
2049    #[test]
2050    fn a_matching_map_form_carries_its_runner_group_through_rather_than_dropping_it() {
2051        let policy = host_labels("home");
2052        let got = policy.matches(&RunsOn::Grouped {
2053            group: Some("Default".into()),
2054            labels: RunsOnLabels::One("rm-home-win-x64".into()),
2055        });
2056        assert_eq!(
2057            got,
2058            RunsOnMatch::Match {
2059                runner_group: Some("Default".into())
2060            },
2061            "a policy has no runner-group field, so the domain cannot evaluate \
2062             `group:`; returning it lets `c4`, which can, do so without re-parsing"
2063        );
2064    }
2065
2066    #[test]
2067    fn each_unresolvable_reason_is_distinct_rather_than_one_catch_all() {
2068        let policy = host_labels("home");
2069
2070        let expr = policy.matches(&RunsOn::Single("${{ matrix.os }}".into()));
2071        assert!(matches!(
2072            expr,
2073            RunsOnMatch::Unresolvable(UnresolvableRunsOn::Expression { .. })
2074        ));
2075
2076        let group = policy.matches(&RunsOn::Grouped {
2077            group: Some("g".into()),
2078            labels: RunsOnLabels::Many(vec![]),
2079        });
2080        assert!(matches!(
2081            group,
2082            RunsOnMatch::Unresolvable(UnresolvableRunsOn::RunnerGroupWithoutLabels { .. })
2083        ));
2084
2085        let none = policy.matches(&RunsOn::Many(vec![]));
2086        assert!(matches!(
2087            none,
2088            RunsOnMatch::Unresolvable(UnresolvableRunsOn::NoLabels)
2089        ));
2090
2091        let invalid = policy.matches(&RunsOn::Single("a,b".into()));
2092        assert!(matches!(
2093            invalid,
2094            RunsOnMatch::Unresolvable(UnresolvableRunsOn::InvalidLabel { .. })
2095        ));
2096    }
2097
2098    #[test]
2099    fn an_unresolvable_runs_on_is_neither_counted_as_demand_nor_dropped() {
2100        // `b1`: "treat a `runs-on` that cannot be resolved statically ... as
2101        // **not** demand, reported as unresolvable rather than silently counted
2102        // or silently dropped."
2103        let policy = table_policy();
2104        let jobs = vec![
2105            RunsOn::Single("rm-home-win-x64".into()),      // matched
2106            RunsOn::Single("ubuntu-latest".into()),        // not matched
2107            RunsOn::Single("${{ matrix.runner }}".into()), // unresolvable
2108            RunsOn::Single("${{ inputs.pool }}".into()),   // unresolvable
2109        ];
2110        let tally = policy.tally(&jobs);
2111
2112        assert_eq!(tally.demand(), 1, "an expression must not inflate demand");
2113        assert_eq!(tally.not_matched, 1);
2114        assert_eq!(
2115            tally.unresolvable.len(),
2116            2,
2117            "and it must not vanish either -- `g2` shows these to the operator"
2118        );
2119        assert_eq!(
2120            tally.total_seen(),
2121            jobs.len() as u32,
2122            "every job seen is accounted for in exactly one bucket"
2123        );
2124    }
2125
2126    #[test]
2127    fn runs_on_deserialises_from_each_json_shape_github_and_workflow_files_use() {
2128        let single: RunsOn = serde_json::from_str(r#""ubuntu-latest""#).unwrap();
2129        assert_eq!(single, RunsOn::Single("ubuntu-latest".into()));
2130
2131        let many: RunsOn = serde_json::from_str(r#"["self-hosted","linux"]"#).unwrap();
2132        assert_eq!(
2133            many,
2134            RunsOn::Many(vec!["self-hosted".into(), "linux".into()])
2135        );
2136
2137        let grouped: RunsOn = serde_json::from_str(r#"{"group":"g","labels":["a","b"]}"#).unwrap();
2138        assert_eq!(
2139            grouped,
2140            RunsOn::Grouped {
2141                group: Some("g".into()),
2142                labels: RunsOnLabels::Many(vec!["a".into(), "b".into()]),
2143            }
2144        );
2145
2146        let scalar_labels: RunsOn = serde_json::from_str(r#"{"labels":"a"}"#).unwrap();
2147        assert_eq!(
2148            scalar_labels,
2149            RunsOn::Grouped {
2150                group: None,
2151                labels: RunsOnLabels::One("a".into()),
2152            }
2153        );
2154
2155        let group_only: RunsOn = serde_json::from_str(r#"{"group":"g"}"#).unwrap();
2156        assert_eq!(
2157            group_only,
2158            RunsOn::Grouped {
2159                group: Some("g".into()),
2160                labels: RunsOnLabels::Many(vec![]),
2161            }
2162        );
2163
2164        // The array form is what the jobs API actually returns.
2165        assert_eq!(
2166            RunsOn::from_job_labels(["rm-home-win-x64", "gpu"]),
2167            RunsOn::Many(vec!["rm-home-win-x64".into(), "gpu".into()])
2168        );
2169    }
2170
2171    // =======================================================================
2172    // PolicyMode (D19)
2173    // =======================================================================
2174
2175    #[test]
2176    fn an_autoscale_policy_without_a_ceiling_or_a_label_cannot_be_persisted() {
2177        let labels = host_labels("home");
2178
2179        // Autoscale requires both. Neither illegal combination survives the
2180        // load path.
2181        assert!(matches!(
2182            PolicyMode::from_persisted(Some(labels.clone()), 0, None),
2183            Err(PolicyError::AutoscaleWithoutMaxCapacity)
2184        ));
2185        assert!(matches!(
2186            PolicyMode::from_persisted(None, 0, Some(nz(1))),
2187            Err(PolicyError::AutoscaleWithoutRoutingLabels)
2188        ));
2189
2190        // And both legal shapes do.
2191        assert!(
2192            PolicyMode::from_persisted(None, 0, None)
2193                .unwrap()
2194                .is_monitor_only()
2195        );
2196        assert!(
2197            PolicyMode::from_persisted(Some(labels), 0, Some(nz(2)))
2198                .unwrap()
2199                .is_autoscale()
2200        );
2201    }
2202
2203    #[test]
2204    fn the_illegal_policy_mode_combinations_have_no_in_memory_representation() {
2205        // The strongest form of `b1`'s D19 requirement: not merely that the
2206        // illegal shapes are rejected on the way in, but that they cannot be
2207        // built. `PolicyMode::Autoscale` holds an `AutoscaleConfig` whose
2208        // `routing_labels` and `max_capacity` are unconditional, so there is no
2209        // constructor, field assignment, or `Default` that produces an autoscale
2210        // policy missing either.
2211        let autoscale = PolicyMode::autoscale(host_labels("home"), 0, nz(3)).unwrap();
2212        assert!(autoscale.routing_labels().is_some());
2213        assert!(autoscale.max_capacity().is_some());
2214
2215        let monitor = PolicyMode::monitor_only();
2216        assert!(monitor.routing_labels().is_none());
2217        assert!(monitor.max_capacity().is_none());
2218        assert_eq!(monitor.min_capacity(), 0);
2219    }
2220
2221    #[test]
2222    fn a_monitor_only_row_carrying_capacity_or_labels_is_refused_by_name() {
2223        // `b2` loads a hand-edited database through `from_persisted`, and needs
2224        // to say which column is wrong.
2225        let err = PolicyMode::from_persisted(None, 2, None).unwrap_err();
2226        assert!(matches!(
2227            err,
2228            PolicyError::MonitorOnlyWithMinCapacity { min: 2 }
2229        ));
2230        assert!(
2231            err.to_string().contains("MonitorOnly"),
2232            "the message must name the shape rule, got: {err}"
2233        );
2234    }
2235
2236    #[test]
2237    fn an_inverted_capacity_range_is_rejected_so_clamp_is_always_well_defined() {
2238        // `04-subsystem-contracts.md`: "`min_capacity <= max_capacity` is
2239        // validated on every write of an `Autoscale` policy, so
2240        // `clamp(demand, min_capacity, max_capacity)` is always well-defined."
2241        // In Rust an inverted `clamp` panics, so this is the guard that keeps
2242        // `crate::capacity` total.
2243        assert!(matches!(
2244            PolicyMode::autoscale(host_labels("home"), 5, nz(2)),
2245            Err(PolicyError::InvertedCapacityRange { min: 5, max: 2 })
2246        ));
2247        assert!(PolicyMode::autoscale(host_labels("home"), 2, nz(2)).is_ok());
2248        assert!(PolicyMode::autoscale(host_labels("home"), 0, nz(1)).is_ok());
2249
2250        // Including when raising the floor past the ceiling later.
2251        let mut cfg = AutoscaleConfig::new(host_labels("home"), 2, nz(4)).unwrap();
2252        assert!(matches!(
2253            cfg.set_max_capacity(nz(1)),
2254            Err(PolicyError::InvertedCapacityRange { min: 2, max: 1 })
2255        ));
2256        assert_eq!(
2257            cfg.max_capacity().get(),
2258            4,
2259            "a refused write changes nothing"
2260        );
2261    }
2262
2263    #[test]
2264    fn a_policy_mode_round_trips_through_serde_and_the_gate_holds_on_the_way_back() {
2265        for mode in [
2266            PolicyMode::monitor_only(),
2267            PolicyMode::autoscale(host_labels("home"), 0, nz(4)).unwrap(),
2268        ] {
2269            let json = serde_json::to_string(&mode).unwrap();
2270            let back: PolicyMode = serde_json::from_str(&json).unwrap();
2271            assert_eq!(mode, back, "{json} did not round-trip");
2272        }
2273
2274        // A hand-written autoscale payload with an inverted range is refused at
2275        // deserialisation, not after it.
2276        let hostile = r#"{"mode":"autoscale","routing_labels":{"host_label":"rm-home-win-x64","additional":[]},"min_capacity":9,"max_capacity":1}"#;
2277        let err = serde_json::from_str::<PolicyMode>(hostile).unwrap_err();
2278        assert!(
2279            err.to_string().contains("min_capacity"),
2280            "expected the shape error to survive into serde's message, got: {err}"
2281        );
2282    }
2283
2284    #[test]
2285    fn a_policy_round_trips_through_its_persisted_form() {
2286        // `PersistedPolicy` exists to stop `installation_id` and `revision` --
2287        // two bare `u64`s -- being transposed on the way to and from storage.
2288        // Nothing exercised that: `PersistedPolicy` was constructed nowhere
2289        // outside `to_persisted`, `ScalePolicy::from_persisted` had no caller
2290        // and no test, and transposing the two fields inside `to_persisted` left
2291        // the whole suite green. The defect the type was introduced to prevent
2292        // was closed for attempts and left open for policies.
2293        let mut policy = autoscale_policy(
2294            ScaleTarget::repository("o/r").unwrap(),
2295            HostId::from_u128(7),
2296            3,
2297        );
2298        policy.add_routing_label(label("gpu")).unwrap();
2299        // `activate` is what makes the round trip worth asserting: it moves
2300        // `state` off `Pending`, `enabled` off `false`, and `revision` off `0`,
2301        // so all three are non-default and a field that failed to survive the
2302        // trip shows up as an inequality rather than as a default that happens
2303        // to match.
2304        policy.activate().unwrap();
2305        assert_eq!(policy.state(), PolicyState::Active);
2306        assert!(policy.enabled());
2307
2308        let stored = policy.to_persisted();
2309        assert_ne!(
2310            stored.installation_id, stored.revision,
2311            "the fixture must distinguish the two u64 columns, or transposing \
2312             them is unobservable and this test proves nothing"
2313        );
2314        assert_eq!(stored.installation_id, 42);
2315        assert_eq!(stored.revision, policy.revision());
2316
2317        let restored =
2318            ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
2319        assert_eq!(restored, policy);
2320        assert_eq!(restored.installation_id, 42);
2321        assert_eq!(restored.revision(), policy.revision());
2322        assert_eq!(restored.state(), PolicyState::Active);
2323        assert!(restored.enabled());
2324        assert_eq!(
2325            restored.routing_labels().unwrap().count().get(),
2326            2,
2327            "the optional label survives alongside the host label"
2328        );
2329    }
2330
2331    #[test]
2332    fn a_monitor_only_policy_round_trips_through_its_persisted_form() {
2333        // The other half of the mode inference. `to_persisted` writes a
2334        // MonitorOnly policy out through `min_capacity() == 0`,
2335        // `max_capacity() == None` and `routing_labels() == None`, and
2336        // `PolicyMode::from_persisted` reads the mode back *from those three
2337        // columns* rather than from a stored discriminant. That inference is the
2338        // reason `PolicyError::MonitorOnlyWithRoutingLabels` is unconstructible,
2339        // and until now it was never exercised end to end.
2340        let mut policy = ScalePolicy::new(
2341            PolicyId::from_u128(2),
2342            ScaleTarget::organization("acme").unwrap(),
2343            9,
2344            HostId::from_u128(7),
2345            PolicyMode::monitor_only(),
2346            CachePolicy::default(),
2347        );
2348        policy.activate().unwrap();
2349
2350        let stored = policy.to_persisted();
2351        assert!(stored.routing_labels.is_none());
2352        assert_eq!(stored.min_capacity, 0);
2353        assert!(stored.max_capacity.is_none());
2354        assert_ne!(stored.installation_id, stored.revision);
2355
2356        let restored =
2357            ScalePolicy::from_persisted(stored).expect("a row this crate produced must load");
2358        assert_eq!(restored, policy);
2359        assert!(
2360            restored.mode().is_monitor_only(),
2361            "the mode is inferred back from the three columns, not stored"
2362        );
2363        assert!(!restored.owns_runners());
2364        assert_eq!(restored.installation_id, 9);
2365        assert_eq!(restored.revision(), 1);
2366    }
2367
2368    // =======================================================================
2369    // PolicyState
2370    // =======================================================================
2371
2372    /// The diagram from `04-subsystem-contracts.md`, transcribed by hand.
2373    ///
2374    /// **Deliberately a second copy of [`PolicyState::LEGAL`].** A test that
2375    /// derives its expectation from the constant it is testing asserts only that
2376    /// the constant equals itself: adding `disabled -> active` to `LEGAL` would
2377    /// make such a test expect the new edge and pass, which is exactly what
2378    /// happened to the first version of this test and is why it was rewritten.
2379    /// Here the two lists must be edited together, so a one-sided change to
2380    /// either fails.
2381    ///
2382    /// ```text
2383    /// pending -> active | repair_required
2384    /// active  -> draining -> disabled -> pending
2385    /// any     -> authentication_failed        (recoverable by re-authentication)
2386    /// ```
2387    fn diagram_edges() -> Vec<(PolicyState, PolicyState)> {
2388        use PolicyState::*;
2389        let mut edges = vec![
2390            (Pending, Active),
2391            (Pending, RepairRequired),
2392            (Active, Draining),
2393            (Draining, Disabled),
2394            (Disabled, Pending),
2395            // The recovery edge for "(recoverable by re-authentication)".
2396            (AuthenticationFailed, Pending),
2397        ];
2398        // `any -> authentication_failed`, which is every state except itself.
2399        for from in PolicyState::ALL {
2400            if from != AuthenticationFailed {
2401                edges.push((from, AuthenticationFailed));
2402            }
2403        }
2404        edges
2405    }
2406
2407    #[test]
2408    fn every_policy_state_transition_is_legal_exactly_where_the_diagram_says() {
2409        // Both directions, over the full 6x6 product: each of the 11 legal pairs
2410        // succeeds, each of the other 25 is rejected.
2411        let expected = diagram_edges();
2412        assert_eq!(
2413            expected.len(),
2414            11,
2415            "the transcription itself changed; check it against the diagram"
2416        );
2417
2418        let mut legal_seen = 0usize;
2419        let mut illegal_seen = 0usize;
2420
2421        for from in PolicyState::ALL {
2422            for to in PolicyState::ALL {
2423                let expected_legal = expected.contains(&(from, to));
2424                let mut policy = autoscale_policy(
2425                    ScaleTarget::repository("o/r").unwrap(),
2426                    HostId::from_u128(7),
2427                    1,
2428                );
2429                // Force the starting state without going through the machine,
2430                // which is only possible from inside the module -- exactly the
2431                // reason this test lives here.
2432                policy.state = from;
2433
2434                let result = policy.transition_to(to);
2435                if expected_legal {
2436                    legal_seen += 1;
2437                    assert!(
2438                        result.is_ok(),
2439                        "{from} -> {to} is in the diagram and must be accepted"
2440                    );
2441                    assert_eq!(policy.state(), to);
2442                } else {
2443                    illegal_seen += 1;
2444                    assert!(
2445                        matches!(result, Err(PolicyError::IllegalTransition { .. })),
2446                        "{from} -> {to} is not in the diagram and must be rejected"
2447                    );
2448                    assert_eq!(policy.state(), from, "a refused transition changes nothing");
2449                }
2450            }
2451        }
2452
2453        assert_eq!(legal_seen, 11);
2454        assert_eq!(illegal_seen, 36 - 11);
2455
2456        // And the published constant matches the transcription, so a caller
2457        // reading `PolicyState::LEGAL` sees the same machine the tests exercise.
2458        let mut published = PolicyState::LEGAL.to_vec();
2459        let mut transcribed = expected;
2460        published.sort_unstable();
2461        transcribed.sort_unstable();
2462        assert_eq!(published, transcribed);
2463    }
2464
2465    #[test]
2466    fn a_policy_state_cannot_transition_to_itself() {
2467        for state in PolicyState::ALL {
2468            assert!(
2469                !state.can_transition_to(state),
2470                "{state} -> {state} is not an edge in the diagram; treating it as \
2471                 one would let a repeated authentication failure look like progress"
2472            );
2473        }
2474    }
2475
2476    #[test]
2477    fn the_documented_happy_path_walks_pending_to_disabled() {
2478        let mut policy = autoscale_policy(
2479            ScaleTarget::repository("o/r").unwrap(),
2480            HostId::from_u128(7),
2481            2,
2482        );
2483
2484        // D20: `add` never arms a host.
2485        assert_eq!(policy.state(), PolicyState::Pending);
2486        assert!(!policy.enabled());
2487        assert!(!policy.may_start_runners());
2488
2489        policy.activate().unwrap();
2490        assert_eq!(policy.state(), PolicyState::Active);
2491        assert!(policy.enabled());
2492        assert!(policy.may_start_runners());
2493
2494        // Flow 5.2 and 5.3.
2495        assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
2496        assert_eq!(
2497            policy.drain_completed(1).unwrap(),
2498            PolicyState::Draining,
2499            "a policy with a runner still in flight stays draining"
2500        );
2501        assert_eq!(policy.drain_completed(0).unwrap(), PolicyState::Disabled);
2502    }
2503
2504    #[test]
2505    fn a_disable_during_demand_yields_draining_and_beats_demand_immediately() {
2506        // `b1`: "Disable-during-demand yields draining". Precedence rule 4: "A
2507        // user-requested disable beats demand and starts draining."
2508        let mut policy = autoscale_policy(
2509            ScaleTarget::repository("o/r").unwrap(),
2510            HostId::from_u128(7),
2511            5,
2512        );
2513        policy.activate().unwrap();
2514
2515        // Demand exists and is unchanged by the disable -- the queue is left
2516        // visible (flow 5.2) -- but the policy stops being a reason to start.
2517        let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 4];
2518        assert_eq!(policy.tally(&jobs).demand(), 4);
2519
2520        assert_eq!(policy.request_disable().unwrap(), PolicyState::Draining);
2521        assert!(!policy.enabled());
2522        assert!(
2523            !policy.may_start_runners(),
2524            "a draining policy must not be the reason a new runner starts, even \
2525             with four jobs queued for its labels"
2526        );
2527        assert_eq!(
2528            policy.tally(&jobs).demand(),
2529            4,
2530            "queued demand stays visible while draining (flow 5.2)"
2531        );
2532    }
2533
2534    #[test]
2535    fn re_authentication_is_the_only_way_out_of_authentication_failed() {
2536        for from in [
2537            PolicyState::Pending,
2538            PolicyState::Active,
2539            PolicyState::Draining,
2540            PolicyState::Disabled,
2541            PolicyState::RepairRequired,
2542        ] {
2543            let mut policy = autoscale_policy(
2544                ScaleTarget::repository("o/r").unwrap(),
2545                HostId::from_u128(7),
2546                1,
2547            );
2548            policy.state = from;
2549            policy.authentication_failed().unwrap();
2550            assert_eq!(policy.state(), PolicyState::AuthenticationFailed);
2551
2552            // Reporting the same failure twice is not a transition.
2553            assert!(matches!(
2554                policy.authentication_failed(),
2555                Err(PolicyError::IllegalTransition { .. })
2556            ));
2557
2558            policy.reauthenticated().unwrap();
2559            assert_eq!(policy.state(), PolicyState::Pending);
2560        }
2561    }
2562
2563    #[test]
2564    fn mutant_disabling_revoked_eligibility_gate_is_detected() {
2565        let mut policy = autoscale_policy(
2566            ScaleTarget::repository("o/r").unwrap(),
2567            HostId::from_u128(7),
2568            1,
2569        );
2570        policy.activate().unwrap();
2571        policy.authentication_failed().unwrap();
2572        assert!(!policy.may_start_runners());
2573
2574        // Test-local mutant omits only the state gate while retaining the
2575        // other production eligibility conditions. It cannot exist outside
2576        // this #[cfg(test)] module.
2577        let mutant_may_start = policy.mode.is_autoscale() && policy.enabled;
2578        assert!(
2579            mutant_may_start,
2580            "omitting revoked state must make the eligibility gate red"
2581        );
2582    }
2583
2584    #[test]
2585    fn a_refused_transition_leaves_the_revision_untouched() {
2586        let mut policy = autoscale_policy(
2587            ScaleTarget::repository("o/r").unwrap(),
2588            HostId::from_u128(7),
2589            1,
2590        );
2591        assert_eq!(policy.revision(), 0);
2592        policy.activate().unwrap();
2593        assert_eq!(policy.revision(), 1);
2594
2595        assert!(policy.activate().is_err());
2596        assert_eq!(
2597            policy.revision(),
2598            1,
2599            "a rejected write must not bump the optimistic-concurrency token, or \
2600             `b2`'s stale-revision check would reject the next honest write"
2601        );
2602    }
2603
2604    // =======================================================================
2605    // Monitor-only, promotion, ownership
2606    // =======================================================================
2607
2608    #[test]
2609    fn a_monitor_only_policy_owns_nothing_and_can_never_start_a_runner() {
2610        let mut policy = ScalePolicy::new(
2611            PolicyId::from_u128(2),
2612            ScaleTarget::organization("acme").unwrap(),
2613            9,
2614            HostId::from_u128(7),
2615            PolicyMode::monitor_only(),
2616            CachePolicy::default(),
2617        );
2618        policy.activate().unwrap();
2619
2620        assert!(!policy.owns_runners());
2621        assert!(
2622            !policy.may_start_runners(),
2623            "an active, enabled monitor-only policy still starts nothing (D19)"
2624        );
2625        assert!(policy.routing_labels().is_none());
2626        assert!(policy.max_capacity().is_none());
2627
2628        // Maximum demand changes nothing, because it has no labels to match on.
2629        let jobs = vec![RunsOn::Single("rm-home-win-x64".into()); 50];
2630        assert_eq!(
2631            policy.tally(&jobs).demand(),
2632            0,
2633            "a monitor-only policy has no demand at all, rather than demand that \
2634             is computed and then ignored"
2635        );
2636
2637        // And it owns no label set to edit. This is a wrong-mode refusal: the
2638        // caller asked for the wrong thing, the stored row is not malformed.
2639        assert!(matches!(
2640            policy.add_routing_label(label("gpu")),
2641            Err(PolicyError::NotAutoscale)
2642        ));
2643        assert!(matches!(
2644            policy.remove_routing_label(&label("gpu")),
2645            Err(PolicyError::NotAutoscale)
2646        ));
2647        // The load path reports a *shape* problem, and it is a different one: a
2648        // row carrying routing labels is autoscale-shaped by definition, so the
2649        // mode is never in doubt and "monitor-only with labels" has no error to
2650        // report because it cannot be expressed. This assertion is what pins
2651        // that -- it fails loudly if `PolicyMode::from_persisted` ever starts
2652        // reading a stored discriminant instead of inferring the mode, which is
2653        // the change that would make the deleted variant reachable again.
2654        assert!(matches!(
2655            PolicyMode::from_persisted(Some(host_labels("home")), 0, None),
2656            Err(PolicyError::AutoscaleWithoutMaxCapacity)
2657        ));
2658    }
2659
2660    #[test]
2661    fn set_capacity_promotes_a_monitor_only_policy_and_derives_its_label_then() {
2662        // D19 / `f2`: "`set-capacity` later promotes it to `autoscale`, which is
2663        // also when its routing label is derived."
2664        let mut policy = ScalePolicy::new(
2665            PolicyId::from_u128(2),
2666            ScaleTarget::repository("o/r").unwrap(),
2667            9,
2668            HostId::from_u128(7),
2669            PolicyMode::monitor_only(),
2670            CachePolicy::default(),
2671        );
2672        assert!(policy.routing_labels().is_none());
2673
2674        policy
2675            .promote_to_autoscale(host_labels("home"), 0, nz(3))
2676            .unwrap();
2677
2678        assert!(policy.owns_runners());
2679        assert_eq!(
2680            policy.routing_labels().unwrap().host_label().as_str(),
2681            "rm-home-win-x64"
2682        );
2683        assert_eq!(policy.max_capacity().unwrap().get(), 3);
2684
2685        // Promotion is one-way; a second promotion is a mistake, not a resize.
2686        assert!(matches!(
2687            policy.promote_to_autoscale(host_labels("home"), 0, nz(4)),
2688            Err(PolicyError::AlreadyAutoscale)
2689        ));
2690        policy.set_max_capacity(nz(4)).unwrap();
2691        assert_eq!(policy.max_capacity().unwrap().get(), 4);
2692    }
2693
2694    #[test]
2695    fn a_policy_is_owned_by_exactly_one_host() {
2696        let mine = HostId::from_u128(7);
2697        let theirs = HostId::from_u128(8);
2698        let policy = autoscale_policy(ScaleTarget::repository("o/r").unwrap(), mine, 1);
2699        assert!(policy.is_owned_by(mine));
2700        assert!(!policy.is_owned_by(theirs));
2701    }
2702
2703    #[test]
2704    fn an_overridden_host_label_is_detectable_without_being_rejected() {
2705        // `f2` supports an operator override, so `from_parts` must accept any
2706        // label -- but "host-scoped by construction" then holds only for
2707        // `derive`, and the difference has to be visible to something.
2708        assert!(host_labels("home").is_derived_shape());
2709        assert!(
2710            RoutingLabels::derive(&HostLabel::new("home-win").unwrap(), Os::Linux, Arch::Arm64)
2711                .is_derived_shape(),
2712            "a host label containing `-` still derives a four-plus-segment name"
2713        );
2714        // `HostLabel::new` refuses only a leading or trailing `-`, so `home--pc`
2715        // is a legal host label an operator can really type. It derives
2716        // `rm-home--pc-win-x64`, and reporting that as *not* derived told an
2717        // operator who had done nothing wrong that their collision control was
2718        // off.
2719        assert_eq!(
2720            host_labels("home--pc").host_label().as_str(),
2721            "rm-home--pc-win-x64"
2722        );
2723        assert!(
2724            host_labels("home--pc").is_derived_shape(),
2725            "consecutive dashes are legal inside a host label; the empty middle \
2726             segment they produce is not evidence of an override"
2727        );
2728
2729        // The case the predicate exists for: a hand-edited row that has quietly
2730        // disabled the collision control. Not an error -- `remove` will still
2731        // defend it as immovable -- but `f2`/`g2` can now say so.
2732        for raw in [
2733            "self-hosted",
2734            "ubuntu-latest",
2735            "rm-home-win",
2736            "rm-home-win-x64-extra",
2737        ] {
2738            assert!(
2739                !RoutingLabels::from_host_label(label(raw)).is_derived_shape(),
2740                "{raw:?} is not the derived shape"
2741            );
2742        }
2743        // Right segment count, wrong tokens.
2744        assert!(!RoutingLabels::from_host_label(label("rm-home-bsd-x64")).is_derived_shape());
2745        assert!(!RoutingLabels::from_host_label(label("rm-home-win-riscv")).is_derived_shape());
2746        assert!(!RoutingLabels::from_host_label(label("xx-home-win-x64")).is_derived_shape());
2747
2748        // The additional labels play no part: only host identity is at stake.
2749        let mut overridden = RoutingLabels::from_host_label(label("self-hosted"));
2750        overridden.add(label("rm-home-win-x64"));
2751        assert!(!overridden.is_derived_shape());
2752    }
2753
2754    #[test]
2755    fn the_lifecycle_commands_are_transitions_not_desired_state_requests() {
2756        // Documented as intended: `activate` and `request_disable` report what
2757        // the diagram permits, and `f2` translates that into an idempotent CLI
2758        // using the two predicates rather than by calling and discarding errors.
2759        let mut policy = autoscale_policy(
2760            ScaleTarget::repository("o/r").unwrap(),
2761            HostId::from_u128(7),
2762            3,
2763        );
2764
2765        assert!(policy.can_activate());
2766        assert!(
2767            !policy.can_request_disable(),
2768            "a pending policy cannot drain; it is also already not enabled, \
2769             which is what makes the command a no-op rather than a failure"
2770        );
2771        assert!(matches!(
2772            policy.request_disable(),
2773            Err(PolicyError::IllegalTransition {
2774                from: PolicyState::Pending,
2775                to: PolicyState::Draining,
2776            })
2777        ));
2778
2779        policy.activate().unwrap();
2780        assert!(!policy.can_activate(), "already active");
2781        assert!(policy.can_request_disable());
2782        assert!(matches!(
2783            policy.activate(),
2784            Err(PolicyError::IllegalTransition { .. })
2785        ));
2786
2787        policy.request_disable().unwrap();
2788        assert!(!policy.can_request_disable(), "already draining");
2789        assert!(!policy.enabled());
2790    }
2791
2792    // =======================================================================
2793    // D18 target equivalence -- one body, both variants
2794    // =======================================================================
2795
2796    /// Everything `04-subsystem-contracts.md` says is identical between the two
2797    /// scopes: "ownership, capacity, and lifecycle rules are identical".
2798    ///
2799    /// This is deliberately one function rather than two tests. `b1`'s
2800    /// Definition of Done asks for the equivalence to be "proven by a shared
2801    /// test body, not by two copies of the same assertions", and the reason is
2802    /// not tidiness: two copies drift, and the moment they drift the domain has
2803    /// quietly acquired a scope-dependent rule that D18 says does not exist.
2804    ///
2805    /// **The trace must cover every rule the contract names, not every rule
2806    /// that happens to live in this file.** `04-subsystem-contracts.md` names
2807    /// three — "ownership, capacity, and lifecycle rules are identical" — and
2808    /// two of them are implemented in other modules. A version of this trace
2809    /// that only exercised `policy.rs` proved lifecycle and left the other two
2810    /// unguarded: a scope branch in `HostAllocator::allocate` firing only on
2811    /// `demand > 0`, and one in `attempt::authorize` returning `ForeignHost` for
2812    /// every organization policy, both left the whole suite green. Anything
2813    /// added to the contract's list belongs in this body, wherever it is
2814    /// implemented.
2815    fn assert_target_behaves_identically(target: ScaleTarget) -> Vec<String> {
2816        let host = HostId::from_u128(7);
2817        let mut trace = Vec::new();
2818
2819        let mut policy = autoscale_policy(target.clone(), host, 3);
2820        trace.push(format!("owns_runners={}", policy.owns_runners()));
2821        trace.push(format!("owned_by_host={}", policy.is_owned_by(host)));
2822        trace.push(format!(
2823            "owned_by_other={}",
2824            policy.is_owned_by(HostId::from_u128(8))
2825        ));
2826        trace.push(format!("initial_state={}", policy.state()));
2827        trace.push(format!("initial_enabled={}", policy.enabled()));
2828        trace.push(format!(
2829            "may_start_initially={}",
2830            policy.may_start_runners()
2831        ));
2832        trace.push(format!("labels={}", policy.routing_labels().unwrap()));
2833        trace.push(format!("max_capacity={}", policy.max_capacity().unwrap()));
2834        trace.push(format!("min_capacity={}", policy.min_capacity()));
2835
2836        // Lifecycle.
2837        policy.activate().unwrap();
2838        trace.push(format!("after_activate={}", policy.state()));
2839        trace.push(format!("may_start_active={}", policy.may_start_runners()));
2840
2841        // Demand.
2842        let jobs = vec![
2843            RunsOn::Single("rm-home-win-x64".into()),
2844            RunsOn::Single("ubuntu-latest".into()),
2845            RunsOn::Single("${{ matrix.os }}".into()),
2846        ];
2847        let tally = policy.tally(&jobs);
2848        trace.push(format!(
2849            "demand={} not_matched={} unresolvable={}",
2850            tally.demand(),
2851            tally.not_matched,
2852            tally.unresolvable.len()
2853        ));
2854
2855        // Capacity (`crate::capacity`). D9's host ceiling and D7's per-policy
2856        // one are the contract's "capacity rules"; without these lines a scope
2857        // branch inside `HostAllocator::allocate` is invisible to the suite.
2858        let host_record = crate::model::Host::new(
2859            host,
2860            "home-pc",
2861            Os::Windows,
2862            Arch::X64,
2863            nz(4),
2864            crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
2865        )
2866        .unwrap();
2867        let mut allocator = crate::capacity::HostAllocator::from_attempts(&host_record, &[]);
2868        let allocation = allocator.allocate(&policy, 3);
2869        trace.push(format!(
2870            "alloc demand={} desired={} active_owned={} headroom_before={} \
2871             to_start={} limiting={}",
2872            allocation.demand,
2873            allocation.desired,
2874            allocation.active_owned,
2875            allocation.headroom_before,
2876            allocation.to_start,
2877            allocation.limiting_factor
2878        ));
2879        trace.push(format!("headroom_after={}", allocator.headroom()));
2880        // Zero demand as well, so a branch keyed on `demand > 0` cannot hide in
2881        // the gap between the two.
2882        trace.push(format!(
2883            "alloc_zero_to_start={}",
2884            allocator.allocate(&policy, 0).to_start
2885        ));
2886
2887        // Ownership (`crate::attempt`). Ownership rules 1 and 2 are the
2888        // contract's "ownership rules", and `authorize` is where they are
2889        // enforced -- `is_owned_by` above only covers rule 2's policy half.
2890        let attempt = crate::attempt::RunnerAttempt::allocate(
2891            crate::model::AttemptId::from_u128(11),
2892            policy.id,
2893            "C:/runners/eq",
2894            crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
2895        );
2896        trace.push(format!(
2897            "authorize_own_host={:?}",
2898            crate::attempt::authorize(host, &policy, &attempt).is_ok()
2899        ));
2900        trace.push(format!(
2901            "authorize_other_host={}",
2902            crate::attempt::authorize(HostId::from_u128(8), &policy, &attempt)
2903                .expect_err("an agent on another host must be refused")
2904        ));
2905        let foreign_attempt = crate::attempt::RunnerAttempt::allocate(
2906            crate::model::AttemptId::from_u128(12),
2907            PolicyId::from_u128(999),
2908            "C:/runners/eq-other",
2909            crate::model::Timestamp::from_timestamp(0, 0).unwrap(),
2910        );
2911        trace.push(format!(
2912            "authorize_other_policy={}",
2913            crate::attempt::authorize(host, &policy, &foreign_attempt)
2914                .expect_err("an attempt under another policy must be refused")
2915        ));
2916
2917        // Drain.
2918        trace.push(format!("disable={}", policy.request_disable().unwrap()));
2919        trace.push(format!(
2920            "drain_with_1={}",
2921            policy.drain_completed(1).unwrap()
2922        ));
2923        trace.push(format!(
2924            "drain_with_0={}",
2925            policy.drain_completed(0).unwrap()
2926        ));
2927
2928        // Illegal transition, both scopes.
2929        trace.push(format!(
2930            "reactivate_err={}",
2931            policy.transition_to(PolicyState::Active).is_err()
2932        ));
2933
2934        // The registration label array `c4` would send.
2935        trace.push(format!(
2936            "registration_labels={:?}",
2937            autoscale_policy(target, host, 3)
2938                .routing_labels()
2939                .unwrap()
2940                .as_registration_labels()
2941        ));
2942
2943        trace
2944    }
2945
2946    #[test]
2947    fn repository_and_organization_targets_are_equivalent() {
2948        let repository = assert_target_behaves_identically(ScaleTarget::repository("o/r").unwrap());
2949        let organization =
2950            assert_target_behaves_identically(ScaleTarget::organization("o").unwrap());
2951
2952        assert_eq!(
2953            repository, organization,
2954            "D18: the two scopes differ only in which GitHub endpoint and which \
2955             App permission the gateway uses. Any difference here is a \
2956             scope-dependent domain rule that must not exist."
2957        );
2958
2959        // The one thing that *is* allowed to differ.
2960        assert_ne!(
2961            ScaleTarget::repository("o/r").unwrap().scope(),
2962            ScaleTarget::organization("o").unwrap().scope()
2963        );
2964    }
2965}