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