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