Skip to main content

runner_manager_domain/
policy.rs

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