Skip to main content

kanade_shared/
manifest.rs

1use serde::{Deserialize, Serialize};
2
3use crate::wire::{RunAs, Shell, Staleness};
4
5/// YAML job manifest (= registered "what to run", v0.18.0+).
6///
7/// Owns only script-intrinsic fields. **Who** (`target`), **how to
8/// phase fanout** (`rollout`), and **when to stagger start**
9/// (`jitter`) all moved to the Schedule / exec request side — same
10/// script can now be fired against different targets / rollouts
11/// without copying the script body.
12///
13/// `deny_unknown_fields` makes operators copy-pasting an older yaml
14/// that still has `target:` / `rollout:` see a clear parse error at
15/// `kanade job create` time instead of mysteriously losing it.
16#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
17#[serde(deny_unknown_fields)]
18pub struct Manifest {
19    pub id: String,
20    pub version: String,
21    #[serde(default)]
22    pub description: Option<String>,
23    pub execute: Execute,
24    #[serde(default)]
25    pub require_approval: bool,
26    /// Opt-in marker that this job produces a JSON inventory fact
27    /// payload on stdout. When present, the backend's results
28    /// projector parses `ExecResult.stdout` as JSON and upserts an
29    /// `inventory_facts` row keyed by `(pc_id, manifest.id)`. The
30    /// `display` sub-config drives the SPA's Inventory page render.
31    #[serde(default)]
32    pub inventory: Option<InventoryHint>,
33    /// Issue #246: opt-in marker that this job emits per-line
34    /// observability events on stdout (one JSON `ObsEvent` per
35    /// newline). When present, the agent — after the script exits
36    /// successfully — parses each non-empty stdout line as an
37    /// `ObsEvent`, publishes it on `obs.<pc_id>` via the
38    /// `obs_outbox`, and (intentionally) **omits the stdout from
39    /// the `ExecResult`** so the timeline data doesn't double up
40    /// in `execution_results.stdout` (which would multiply rows
41    /// by ~50/day/PC of noise).
42    ///
43    /// Distinct from `inventory:` (single JSON object → projector
44    /// upsert) — events are append-only timeline points consumed
45    /// by the dedicated `obs_events` table.
46    #[serde(default)]
47    pub emit: Option<EmitConfig>,
48    /// #290: opt-in marker that this job is an operator-defined
49    /// **health check** whose result feeds the Client App's Health
50    /// tab over KLP (`StateSnapshot.checks`). The script prints a
51    /// free-form JSON object on stdout (like any inventory job); the
52    /// agent reads the [`CheckHint::status_field`] value dynamically
53    /// into a [`crate::ipc::state::Check`] named `check.name`.
54    /// Cadence / windows / conditions come from
55    /// the job's Schedule (exactly like inventory) — there is
56    /// deliberately no interval here. **Composes with `inventory:`**:
57    /// the script's stdout is one JSON object, so a check can also
58    /// carry an `inventory:` block to project the rest of that object
59    /// (incl. `explode` sub-tables) for SPA fleet-querying. Only
60    /// `emit:` (NDJSON stdout) is incompatible.
61    #[serde(default)]
62    pub check: Option<CheckHint>,
63    /// v0.26: Layer 2 staleness policy (SPEC.md §2.6.2). Controls
64    /// what the agent does at fire time when it can't verify the
65    /// `script_current` / `script_status` KV values are fresh —
66    /// especially relevant for `runs_on: agent` schedules where
67    /// the agent may fire from cache while offline. Defaults to
68    /// `Staleness::Cached` (silently use cached values), which
69    /// matches every pre-v0.26 Manifest.
70    #[serde(default)]
71    pub staleness: Staleness,
72}
73
74/// "Who + how + when-to-stagger" — the fanout-plan side of an exec.
75/// Used both as the POST `/api/exec/{job_id}` body and as the embedded
76/// `target` / `rollout` / `jitter` slot on [`Schedule`]. Centralising
77/// here keeps the validation + serialisation logic in one place.
78#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
79pub struct FanoutPlan {
80    #[serde(default)]
81    pub target: Target,
82    /// Optional wave rollout — when present, the backend publishes
83    /// each wave's group subject on its own delay schedule instead
84    /// of fanning out the `target` block in one go. `target` then
85    /// only labels the deploy for the audit log.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub rollout: Option<Rollout>,
88    /// Optional humantime jitter; agent uses it to randomise
89    /// execution start. Lives here (not on the script) so different
90    /// schedules / ad-hoc fires of the same job can pick different
91    /// stagger windows.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub jitter: Option<String>,
94    /// Absolute time the scheduler stamps on each emitted Command
95    /// when this exec was driven by a [`Schedule`] with
96    /// `starting_deadline`. Agents receiving a Command after this
97    /// instant publish a synthetic skipped-result instead of
98    /// running the script. `None` (default) = no deadline / catch
99    /// up whenever delivered. Operators don't usually set this
100    /// directly — the scheduler computes it from `tick_at +
101    /// starting_deadline`.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub deadline_at: Option<chrono::DateTime<chrono::Utc>>,
104}
105
106/// Manifest sub-section: how the SPA should render the inventory
107/// facts this job produces. Each field name (`field`) is a top-level
108/// key in the stdout JSON, e.g. `hostname`, `ram_gb`.
109///
110/// Two render modes:
111///   * `display` — vertical "field / value" per PC, used by the
112///     `/inventory?pc=<id>` detail view. ALL columns the operator
113///     wants visible on the detail page.
114///   * `summary` — horizontal table across the fleet (row = PC,
115///     column = field) on `/inventory`. Optional; when omitted the
116///     SPA falls back to `display`, but operators usually want a
117///     trimmer "hostname / OS / CPU / RAM" set for the fleet view.
118#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
119pub struct InventoryHint {
120    /// Detail-view columns, in order.
121    pub display: Vec<DisplayField>,
122    /// Optional fleet-list columns (row = PC). Defaults to `display`
123    /// when omitted, but operators usually pick a 3-5 column subset.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub summary: Option<Vec<DisplayField>>,
126    /// v0.31 / #40: payload arrays that should be exploded into
127    /// per-element rows of a derived SQLite table. Lets operators
128    /// answer cross-PC questions ("which PCs still have Chrome <
129    /// 120?", "C: >90% full") with normal SQL filters + indexes
130    /// instead of grepping JSON. The projector creates the derived
131    /// table on register and replaces this PC's rows on each result
132    /// (DELETE WHERE pc_id=? AND job_id=? + bulk INSERT). See
133    /// [`ExplodeSpec`] for the per-spec schema.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub explode: Option<Vec<ExplodeSpec>>,
136    /// v0.35 / #93: top-level scalar fields whose changes the
137    /// projector logs to `inventory_history` (one event per
138    /// changed field per scan). Pairs with `explode[].track_history`
139    /// — that covers array elements; this covers single-valued
140    /// fields like `ram_bytes` / `os_version` / `cpu_model` /
141    /// `os_build` that operators want to track for "did the RAM
142    /// get upgraded?" / "when did Win 11 land on this PC?" /
143    /// "BIOS / firmware bumped?" questions. Field name = `field_path`
144    /// in the history row, `identity_json` is NULL, `before_json`
145    /// / `after_json` each carry `{"value": <prior or new value>}`.
146    /// First-ever observation of a scalar (no prior facts row)
147    /// emits `added`; subsequent value changes emit `changed`. No
148    /// `removed` events — a scalar disappearing from the payload
149    /// is rare and the operator can still see the last value via
150    /// the `before_json` of the most recent change.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub history_scalars: Option<Vec<String>>,
153}
154
155/// Manifest sub-section (#290): marks a job as an operator-defined
156/// **health check**. Parallel to [`InventoryHint`] / `EmitConfig`.
157/// The stdout contract is a free-form JSON object (same as any
158/// inventory job) from which the agent reads `status_field` /
159/// `detail_field` to build the KLP [`crate::ipc::state::Check`] shown
160/// on the Client App's Health tab.
161///
162/// There is deliberately **no timing field** — when / how often /
163/// in which window a check runs is driven by the job's Schedule,
164/// exactly like inventory jobs, so operators get the full `when:` /
165/// rollout / `runs_on` expressiveness for free.
166///
167/// A check's stdout is a **free-form inventory object** (arbitrary
168/// key/value pairs + arrays) — same as any inventory job — that also
169/// carries a status field. `check:` adds only the health semantics on
170/// top: which field is the ok/warn/fail/unknown status, an optional
171/// one-line summary field, and a remediation job. Everything else
172/// (rich per-PC detail, `explode` sub-tables like a software list) is
173/// driven by a co-present [`InventoryHint`] and rendered with the
174/// SAME display logic the SPA Inventory page uses — on the Client App
175/// too. This keeps checks maximally expressive without a bespoke
176/// payload type.
177#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
178#[serde(deny_unknown_fields)]
179pub struct CheckHint {
180    /// Stable check id → [`Check.name`](crate::ipc::state::Check),
181    /// the SPA/Client React key + analytics label. Unique within the
182    /// fleet's check set.
183    pub name: String,
184    /// Top-level stdout field whose string value
185    /// (`ok`/`warn`/`fail`/`unknown`) becomes the Health-tab light
186    /// ([`CheckStatus`](crate::ipc::state::CheckStatus)). Defaults to
187    /// `"status"`; a missing / unparseable value → `unknown`.
188    #[serde(default = "default_status_field")]
189    pub status_field: String,
190    /// Top-level stdout field used as the Health-tab row's one-line
191    /// summary. Defaults to `"detail"`; absent in the payload → no
192    /// detail line (the rich breakdown lives in the inventory view).
193    #[serde(default = "default_detail_field")]
194    pub detail_field: String,
195    /// Optional remediation job id →
196    /// [`Check.troubleshoot`](crate::ipc::state::Check). The Client
197    /// App shows a "修復する" button when present; that job must be
198    /// `user_invokable`.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub troubleshoot: Option<String>,
201}
202
203fn default_status_field() -> String {
204    "status".to_string()
205}
206
207fn default_detail_field() -> String {
208    "detail".to_string()
209}
210
211/// Issue #246 — `emit:` manifest block for jobs whose stdout is
212/// NDJSON observability events (one `ObsEvent` per line). Parallel
213/// to `inventory:` but for the append-only timeline pipeline; see
214/// `Manifest::emit` for the full contract.
215#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
216#[serde(deny_unknown_fields)]
217pub struct EmitConfig {
218    /// What kind of payload the agent should expect on stdout. Only
219    /// `events` is defined today (parses each non-empty line as
220    /// `ObsEvent` and publishes on `obs.<pc_id>`); future variants
221    /// (e.g. metrics streams, structured trace events) plug in here.
222    #[serde(rename = "type")]
223    pub kind: EmitKind,
224    /// Operator hint for where the script keeps its own state — the
225    /// watermark file the PowerShell / sh body reads + writes
226    /// between runs so it only emits NEW events since the last
227    /// poll. The agent doesn't read this; it's documentation that
228    /// the SPA (and `kanade job edit`) can surface to operators
229    /// reviewing the manifest. Optional; the script is allowed to
230    /// keep state anywhere (registry, env, etc.) — the field's
231    /// presence makes the convention discoverable.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub watermark_path: Option<String>,
234}
235
236/// `emit.type` enum. Lowercase serde so manifests read
237/// `type: events` rather than `Events`.
238#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
239#[serde(rename_all = "lowercase")]
240pub enum EmitKind {
241    /// Per-line `ObsEvent` JSON. Agent parses + publishes on
242    /// `obs.<pc_id>`, drops the stdout from the resulting
243    /// `ExecResult`.
244    Events,
245}
246
247/// v0.31 / #40: declarative "flatten this JSON array into a real
248/// SQLite table" spec on an inventory manifest. The projector
249/// creates the table on first registration (CREATE TABLE IF NOT
250/// EXISTS + indexes) and writes a row per element of
251/// `payload[field]` on every result, scoped by (pc_id, job_id) so
252/// each PC's rows replace cleanly without a per-PC schema.
253#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
254pub struct ExplodeSpec {
255    /// JSON array key under the payload to explode. E.g. `"apps"`
256    /// for `payload: { apps: [{...}, {...}] }`.
257    pub field: String,
258    /// Derived SQLite table name. Operators choose this — pick
259    /// something namespaced + stable (`inventory_sw_apps`, not
260    /// `apps`) so multiple inventory manifests don't collide on a
261    /// generic name.
262    pub table: String,
263    /// Element-level fields that uniquely identify a row inside one
264    /// PC's payload. The full PK is `(pc_id, job_id) + these
265    /// columns`. Required — operators must think about uniqueness
266    /// (e.g. `["name", "source"]` for installed apps because the
267    /// same name appears in multiple uninstall hives).
268    ///
269    /// v0.31 / #41: same tuple drives history identity. When
270    /// `track_history` is on, the projector serialises these
271    /// fields' values into `inventory_history.identity_json` for
272    /// every change event, so queries like "every PC that ever
273    /// installed Chrome (any source)" filter on identity_json
274    /// content without a per-manifest schema.
275    pub primary_key: Vec<String>,
276    /// Per-element fields that become columns in the derived table.
277    pub columns: Vec<ExplodeColumn>,
278    /// v0.31 / #41: when true (default false), the projector
279    /// diffs each PC's incoming payload against the prior rows
280    /// for the same (pc_id, job_id) BEFORE the DELETE-then-INSERT
281    /// replace, and writes added / removed / changed events into
282    /// `inventory_history`. Lets operators answer time-dimension
283    /// questions ("when did Chrome 120 first appear on PC X?",
284    /// "what's the Win 11 23H2 rollout curve") without storing
285    /// per-scan snapshots. Off by default so operators opt in
286    /// per-spec — history has a real storage cost on long-lived
287    /// deployments (mitigated by the 90-day default retention
288    /// sweeper, see `cleanup` module).
289    #[serde(default)]
290    pub track_history: bool,
291}
292
293/// One column in an [`ExplodeSpec`]'s derived table.
294#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
295pub struct ExplodeColumn {
296    /// JSON key under each array element. Becomes the column name
297    /// in the derived SQLite table — we don't rename.
298    pub field: String,
299    /// SQLite affinity: `"text"` (default), `"integer"`, `"real"`.
300    /// Storage maps directly via `sqlx::query.bind(...)`; type
301    /// mismatches at INSERT-time fail loudly rather than silently
302    /// dropping the row.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    #[serde(rename = "type")]
305    pub kind: Option<String>,
306    /// When true, the projector creates a `CREATE INDEX` on this
307    /// column at table-creation time. Boost for the common-filter
308    /// columns (`name`, `version`) — operators mark them
309    /// explicitly, the projector won't guess.
310    #[serde(default)]
311    pub index: bool,
312}
313
314#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
315pub struct DisplayField {
316    /// Top-level key in the stdout JSON.
317    pub field: String,
318    /// Human-readable column header.
319    pub label: String,
320    /// Optional render hint — `"number"`, `"bytes"`, `"timestamp"`,
321    /// or `"table"` (#39). Defaults to plain text rendering on the
322    /// SPA side. `"table"` expects the field's value to be a JSON
323    /// array of objects and renders a nested sub-table on the
324    /// per-PC detail page using `columns` as the schema; the fleet
325    /// summary view falls back to showing the row count for
326    /// `"table"` cells so the wide list stays compact.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    #[serde(rename = "type")]
329    pub kind: Option<String>,
330    /// v0.30 / #39: when `kind == "table"`, the SPA renders the
331    /// field's value (an array of objects like
332    /// `disks: [{ device_id, size_bytes, ... }]`) as a nested
333    /// sub-table using these columns. Each column is itself a
334    /// `DisplayField`, so the nested cells reuse the same render
335    /// hints (`bytes`, `number`, `timestamp`) — no parallel format
336    /// pipeline. Ignored for any other `kind`.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub columns: Option<Vec<DisplayField>>,
339}
340
341#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
342pub struct Rollout {
343    #[serde(default)]
344    pub strategy: RolloutStrategy,
345    pub waves: Vec<Wave>,
346}
347
348#[derive(
349    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
350)]
351#[serde(rename_all = "lowercase")]
352pub enum RolloutStrategy {
353    #[default]
354    Wave,
355}
356
357#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
358pub struct Wave {
359    pub group: String,
360    /// humantime delay measured from the deploy's publish time. wave[0]
361    /// typically has "0s"; subsequent waves use minutes / hours.
362    pub delay: String,
363}
364
365#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
366pub struct Target {
367    #[serde(default)]
368    pub groups: Vec<String>,
369    #[serde(default)]
370    pub pcs: Vec<String>,
371    #[serde(default)]
372    pub all: bool,
373}
374
375impl Target {
376    /// At least one of all / groups / pcs is set.
377    pub fn is_specified(&self) -> bool {
378        self.all || !self.groups.is_empty() || !self.pcs.is_empty()
379    }
380}
381
382#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
383#[serde(deny_unknown_fields)]
384pub struct Execute {
385    pub shell: ExecuteShell,
386    /// Inline script body. Mutually exclusive with [`script_file`]
387    /// and [`script_object`]; exactly one of the three must be set
388    /// (enforced by [`Execute::validate_script_source`] at the
389    /// write-side parse boundaries — `kanade job create` and
390    /// `POST /api/jobs`).
391    ///
392    /// Empty string is treated as **unset** so operators can swap
393    /// to a `script_file:` / `script_object:` alternative just by
394    /// commenting out the body, without having to also drop the
395    /// `script:` key entirely.
396    ///
397    /// [`script_file`]: Self::script_file
398    /// [`script_object`]: Self::script_object
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub script: Option<String>,
401    /// Repo-local file path resolved by the operator-side CLI at
402    /// `kanade job create` time. The CLI reads the file, slots its
403    /// contents into `script`, and clears this field before
404    /// POSTing — so the backend / agents never see `script_file`
405    /// in stored manifests. SPEC §2.4.1.
406    ///
407    /// Resolver lands in a follow-up PR
408    /// (yukimemi/kanade#210); today this field passes parse-time
409    /// validation but the operator-side CLI bails with "not yet
410    /// implemented" until the resolver ships, so manifests that
411    /// reach the backend with `script_file` set are treated as a
412    /// schema-bug.
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub script_file: Option<String>,
415    /// Object Store reference (`<name>/<version>`) into the
416    /// `scripts` bucket (`OBJECT_SCRIPTS`). Agents fetch the body
417    /// at Execute time via `/api/script-objects/{name}/{version}`
418    /// and cache it locally. SPEC §2.4.1.
419    ///
420    /// Resolver lands in the same follow-up PR as `script_file`;
421    /// today this field passes parse-time validation but the
422    /// backend / agent exec paths bail with "not yet implemented"
423    /// when they see it.
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub script_object: Option<String>,
426    /// humantime duration string (e.g. "30s", "10m"). Script-intrinsic
427    /// — represents how long this script reasonably takes to run.
428    pub timeout: String,
429    /// Token + session combination the agent uses to launch the
430    /// script (v0.21). Default = [`RunAs::System`] (Session 0,
431    /// LocalSystem privileges, no GUI) — matches pre-v0.21 behavior.
432    #[serde(default)]
433    pub run_as: RunAs,
434    /// Working directory for the spawned child (v0.21.1). When
435    /// unset, the child inherits the agent's cwd — on Windows that
436    /// means `%SystemRoot%\System32` for the prod service, which is
437    /// almost never what operators actually want. Use an absolute
438    /// path; relative paths are passed through to the OS verbatim.
439    /// `%PROGRAMDATA%` works for `run_as: system`; for `run_as: user`
440    /// you'd want `%USERPROFILE%` (but expansion happens in the
441    /// shell, so write `$env:USERPROFILE` for PowerShell, or set
442    /// it via teravars before `kanade job create`).
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub cwd: Option<String>,
445}
446
447impl Execute {
448    /// Treat an empty `script:` body as "intentionally unset". Operators
449    /// commenting out a block-scalar tend to leave the key behind, and
450    /// failing the validator on `script: ""` would surprise them.
451    fn has_inline_script(&self) -> bool {
452        matches!(&self.script, Some(s) if !s.is_empty())
453    }
454
455    /// Enforce that exactly one of `script` / `script_file` /
456    /// `script_object` is set. Called at the write-side parse
457    /// boundaries (CLI `kanade job create` + backend
458    /// `POST /api/jobs`) so ambiguous YAML is rejected before it
459    /// reaches the JOBS KV. Read paths (projector, agent
460    /// scheduler, list endpoints) skip this check — they only ever
461    /// see what the write path already validated.
462    pub fn validate_script_source(&self) -> Result<(), String> {
463        let inline = self.has_inline_script();
464        let file = self.script_file.is_some();
465        let obj = self.script_object.is_some();
466        let set = [inline, file, obj].into_iter().filter(|b| *b).count();
467        match set {
468            1 => Ok(()),
469            0 => Err("execute: one of `script`, `script_file`, `script_object` must be set".into()),
470            _ => Err(format!(
471                "execute: only one of `script` / `script_file` / `script_object` may be set \
472                 (got script={inline}, script_file={file}, script_object={obj})"
473            )),
474        }
475    }
476}
477
478impl Manifest {
479    /// Cross-field semantic checks that don't fit into pure serde
480    /// derive. Currently delegates to
481    /// [`Execute::validate_script_source`] — see that method's
482    /// docs for the rationale on which call sites should run this.
483    pub fn validate(&self) -> Result<(), String> {
484        self.execute.validate_script_source()?;
485        // Stdout-format compatibility. `inventory:` and `check:` both
486        // consume the SAME single JSON object — they COMPOSE: a check
487        // can extract `status`/`detail` for the Health tab while the
488        // projector explodes the rest into SPA sub-tables. `emit:` is
489        // different — its stdout is NDJSON and the agent omits it from
490        // the result entirely — so it can't be paired with either.
491        if self.emit.is_some() && (self.inventory.is_some() || self.check.is_some()) {
492            return Err(
493                "`emit:` is incompatible with `inventory:` / `check:` — emit's stdout is NDJSON \
494                 timeline events (and omitted from the result), while inventory/check read a \
495                 single JSON object from stdout"
496                    .to_string(),
497            );
498        }
499        // A check's `name` is the Health-tab row id (React key); the
500        // field names tell the agent where to read status/detail.
501        // An empty value is an invisible runtime bug, and the serde
502        // defaults don't guard an operator who writes `status_field:
503        // ""` explicitly — reject all three here.
504        if let Some(check) = &self.check {
505            for (label, value) in [
506                ("check.name", &check.name),
507                ("check.status_field", &check.status_field),
508                ("check.detail_field", &check.detail_field),
509            ] {
510                if value.trim().is_empty() {
511                    return Err(format!("{label} must not be empty"));
512                }
513            }
514            // A present-but-blank `troubleshoot` is a broken
515            // remediation job id (the "修復する" button would target
516            // an empty manifest id) — reject it too.
517            if let Some(troubleshoot) = &check.troubleshoot {
518                if troubleshoot.trim().is_empty() {
519                    return Err("check.troubleshoot must not be empty when set".to_string());
520                }
521            }
522        }
523        Ok(())
524    }
525}
526
527#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
528#[serde(rename_all = "lowercase")]
529pub enum ExecuteShell {
530    Powershell,
531    Cmd,
532}
533
534impl From<ExecuteShell> for Shell {
535    fn from(s: ExecuteShell) -> Self {
536        match s {
537            ExecuteShell::Powershell => Shell::Powershell,
538            ExecuteShell::Cmd => Shell::Cmd,
539        }
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    /// The example check-job + schedule YAMLs shipped under `configs/`
548    /// must stay valid as the schema evolves (#290 PR-C). `include_str!`
549    /// pins them at compile time so a breaking edit fails `cargo test`
550    /// rather than only `kanade job create` at deploy time.
551    #[test]
552    fn example_check_job_yamls_parse_and_validate() {
553        let jobs = [
554            (
555                "check-bitlocker",
556                include_str!("../../../configs/jobs/check-bitlocker.yaml"),
557            ),
558            (
559                "check-av-signature",
560                include_str!("../../../configs/jobs/check-av-signature.yaml"),
561            ),
562            (
563                "check-cert-expiry",
564                include_str!("../../../configs/jobs/check-cert-expiry.yaml"),
565            ),
566        ];
567        for (name, yaml) in jobs {
568            let m: Manifest =
569                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} parse: {e}"));
570            m.validate()
571                .unwrap_or_else(|e| panic!("{name} validate: {e}"));
572            let check = m
573                .check
574                .as_ref()
575                .unwrap_or_else(|| panic!("{name} must carry a check: hint"));
576            assert!(!check.name.trim().is_empty(), "{name} check.name empty");
577            // These three examples all read admin-only WMI namespaces,
578            // so they run_as system. NOTE: that's a property of these
579            // particular checks, NOT of the `check:` contract — a check
580            // probing user-session state could legitimately run_as user.
581            assert_eq!(
582                m.execute.run_as,
583                RunAs::System,
584                "{name} should run_as system"
585            );
586        }
587    }
588
589    #[test]
590    fn example_check_schedule_yamls_parse_and_validate() {
591        let schedules = [
592            (
593                "check-bitlocker",
594                include_str!("../../../configs/schedules/check-bitlocker.yaml"),
595            ),
596            (
597                "check-av-signature",
598                include_str!("../../../configs/schedules/check-av-signature.yaml"),
599            ),
600            (
601                "check-cert-expiry",
602                include_str!("../../../configs/schedules/check-cert-expiry.yaml"),
603            ),
604        ];
605        for (name, yaml) in schedules {
606            let s: Schedule =
607                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} schedule parse: {e}"));
608            s.validate()
609                .unwrap_or_else(|e| panic!("{name} schedule validate: {e}"));
610            assert_eq!(s.job_id, name, "{name} schedule must reference its job");
611        }
612    }
613
614    #[test]
615    fn target_is_specified_requires_at_least_one_field() {
616        let empty = Target::default();
617        assert!(!empty.is_specified());
618
619        let with_all = Target {
620            all: true,
621            ..Target::default()
622        };
623        assert!(with_all.is_specified());
624
625        let with_groups = Target {
626            groups: vec!["canary".into()],
627            ..Target::default()
628        };
629        assert!(with_groups.is_specified());
630
631        let with_pcs = Target {
632            pcs: vec!["pc-01".into()],
633            ..Target::default()
634        };
635        assert!(with_pcs.is_specified());
636    }
637
638    #[test]
639    fn manifest_deserialises_minimal_yaml() {
640        // Matches jobs/echo-test.yaml. v0.18: no target/rollout/jitter
641        // — those live on the schedule / exec request now.
642        let yaml = r#"
643id: echo-test
644version: 0.0.1
645execute:
646  shell: powershell
647  script: "echo 'kanade'"
648  timeout: 30s
649"#;
650        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
651        assert_eq!(m.id, "echo-test");
652        assert_eq!(m.version, "0.0.1");
653        assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
654        assert_eq!(
655            m.execute.script.as_deref().map(str::trim),
656            Some("echo 'kanade'")
657        );
658        assert!(m.execute.script_file.is_none());
659        assert!(m.execute.script_object.is_none());
660        assert_eq!(m.execute.timeout, "30s");
661        assert!(!m.require_approval);
662        m.validate()
663            .expect("inline-script manifest passes validation");
664    }
665
666    #[test]
667    fn manifest_parses_check_job_and_validates() {
668        // An operator-defined health check (#290): a `check:` hint +
669        // a PowerShell script that prints {status, detail}.
670        let yaml = r#"
671id: check-bitlocker
672version: 0.1.0
673execute:
674  shell: powershell
675  run_as: system
676  timeout: 15s
677  script: |
678    [pscustomobject]@{ status = 'ok'; detail = 'all volumes protected' } | ConvertTo-Json -Compress
679check:
680  name: bitlocker
681  troubleshoot: fix-bitlocker
682"#;
683        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
684        let check = m.check.as_ref().expect("check hint present");
685        assert_eq!(check.name, "bitlocker");
686        assert_eq!(check.troubleshoot.as_deref(), Some("fix-bitlocker"));
687        // Field names default to the conventional "status" / "detail".
688        assert_eq!(check.status_field, "status");
689        assert_eq!(check.detail_field, "detail");
690        assert!(m.inventory.is_none() && m.emit.is_none());
691        m.validate().expect("check-only manifest passes validation");
692    }
693
694    #[test]
695    fn manifest_check_defaults_and_custom_fields() {
696        // Minimal: only `name`; status/detail fields default.
697        let m: Manifest = serde_yaml::from_str(
698            r#"
699id: check-disk
700version: 0.1.0
701execute:
702  shell: powershell
703  script: "[pscustomobject]@{ status = 'ok' } | ConvertTo-Json -Compress"
704  timeout: 10s
705check:
706  name: disk_free
707"#,
708        )
709        .expect("parse");
710        let c = m.check.as_ref().unwrap();
711        assert_eq!(c.name, "disk_free");
712        assert_eq!(c.status_field, "status");
713        assert_eq!(c.detail_field, "detail");
714        assert!(c.troubleshoot.is_none());
715        m.validate().expect("validates");
716
717        // The operator can point status/detail at any field of their
718        // free-form inventory object.
719        let m2: Manifest = serde_yaml::from_str(
720            r#"
721id: check-custom
722version: 0.1.0
723execute:
724  shell: powershell
725  script: "echo x"
726  timeout: 10s
727check:
728  name: patch_level
729  status_field: compliance
730  detail_field: summary
731"#,
732        )
733        .expect("parse");
734        let c2 = m2.check.as_ref().unwrap();
735        assert_eq!(c2.status_field, "compliance");
736        assert_eq!(c2.detail_field, "summary");
737    }
738
739    #[test]
740    fn manifest_allows_check_composed_with_inventory() {
741        // `check:` + `inventory:` COMPOSE on the same stdout object:
742        // status/detail → Health tab, the rest → SPA projection +
743        // explode sub-tables. Must pass validation.
744        let yaml = r#"
745id: check-bitlocker-detailed
746version: 0.1.0
747execute:
748  shell: powershell
749  script: "echo x"
750  timeout: 10s
751check:
752  name: bitlocker
753inventory:
754  display:
755    - { field: status, label: Status }
756"#;
757        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
758        assert!(m.check.is_some() && m.inventory.is_some());
759        m.validate().expect("check + inventory compose");
760    }
761
762    #[test]
763    fn manifest_rejects_check_combined_with_emit() {
764        // `emit:` stdout is NDJSON (and omitted from the result), so
765        // it can't pair with `check:` (which needs a single JSON
766        // object on stdout).
767        let yaml = r#"
768id: bad-mix
769version: 0.1.0
770execute:
771  shell: powershell
772  script: "echo x"
773  timeout: 10s
774check:
775  name: bitlocker
776emit:
777  type: events
778"#;
779        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
780        let err = m.validate().expect_err("emit + check must fail");
781        assert!(err.contains("incompatible"), "err: {err}");
782    }
783
784    #[test]
785    fn manifest_rejects_emit_combined_with_inventory() {
786        // The other half of the emit-incompatibility condition.
787        let yaml = r#"
788id: bad-mix-2
789version: 0.1.0
790execute:
791  shell: powershell
792  script: "echo x"
793  timeout: 10s
794emit:
795  type: events
796inventory:
797  display:
798    - { field: status, label: Status }
799"#;
800        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
801        let err = m.validate().expect_err("emit + inventory must fail");
802        assert!(err.contains("incompatible"), "err: {err}");
803    }
804
805    #[test]
806    fn manifest_rejects_empty_check_field_names() {
807        // Empty name / status_field / detail_field are invisible
808        // runtime bugs (empty React key, agent reads the wrong field)
809        // — reject them even though serde supplies non-empty defaults.
810        let base = |inner: &str| {
811            format!(
812                "id: c\nversion: 0.1.0\nexecute:\n  shell: powershell\n  script: \"echo x\"\n  timeout: 10s\ncheck:\n{inner}"
813            )
814        };
815        for inner in [
816            "  name: \"\"\n",
817            "  name: ok\n  status_field: \"\"\n",
818            "  name: ok\n  detail_field: \"   \"\n",
819            // present-but-blank troubleshoot → broken remediation id.
820            "  name: ok\n  troubleshoot: \"  \"\n",
821        ] {
822            let m: Manifest = serde_yaml::from_str(&base(inner)).expect("parse");
823            let err = m.validate().expect_err("empty field must fail");
824            assert!(err.contains("must not be empty"), "err: {err}");
825        }
826    }
827
828    fn execute_with(
829        script: Option<&str>,
830        script_file: Option<&str>,
831        script_object: Option<&str>,
832    ) -> Execute {
833        Execute {
834            shell: ExecuteShell::Powershell,
835            script: script.map(str::to_owned),
836            script_file: script_file.map(str::to_owned),
837            script_object: script_object.map(str::to_owned),
838            timeout: "30s".into(),
839            run_as: RunAs::default(),
840            cwd: None,
841        }
842    }
843
844    #[test]
845    fn validate_accepts_inline_script() {
846        let e = execute_with(Some("echo hi"), None, None);
847        assert!(e.validate_script_source().is_ok());
848    }
849
850    #[test]
851    fn validate_accepts_script_file_alone() {
852        let e = execute_with(None, Some("scripts/cleanup.ps1"), None);
853        assert!(e.validate_script_source().is_ok());
854    }
855
856    #[test]
857    fn validate_accepts_script_object_alone() {
858        let e = execute_with(None, None, Some("cleanup/1.0.0"));
859        assert!(e.validate_script_source().is_ok());
860    }
861
862    #[test]
863    fn validate_treats_empty_inline_script_as_unset() {
864        // `script: ""` + `script_object` set is the natural shape
865        // when an operator comments out the YAML block-scalar body
866        // but leaves the key. Should pass.
867        let e = execute_with(Some(""), None, Some("cleanup/1.0.0"));
868        assert!(e.validate_script_source().is_ok());
869    }
870
871    #[test]
872    fn validate_rejects_zero_sources() {
873        let e = execute_with(None, None, None);
874        let err = e.validate_script_source().unwrap_err();
875        assert!(err.contains("must be set"), "got: {err}");
876    }
877
878    #[test]
879    fn validate_rejects_empty_inline_only() {
880        let e = execute_with(Some(""), None, None);
881        let err = e.validate_script_source().unwrap_err();
882        assert!(err.contains("must be set"), "got: {err}");
883    }
884
885    #[test]
886    fn validate_rejects_inline_plus_file() {
887        let e = execute_with(Some("echo hi"), Some("scripts/cleanup.ps1"), None);
888        let err = e.validate_script_source().unwrap_err();
889        assert!(err.contains("only one of"), "got: {err}");
890    }
891
892    #[test]
893    fn validate_rejects_inline_plus_object() {
894        let e = execute_with(Some("echo hi"), None, Some("cleanup/1.0.0"));
895        let err = e.validate_script_source().unwrap_err();
896        assert!(err.contains("only one of"), "got: {err}");
897    }
898
899    #[test]
900    fn validate_rejects_file_plus_object() {
901        let e = execute_with(None, Some("scripts/cleanup.ps1"), Some("cleanup/1.0.0"));
902        let err = e.validate_script_source().unwrap_err();
903        assert!(err.contains("only one of"), "got: {err}");
904    }
905
906    #[test]
907    fn validate_rejects_all_three() {
908        let e = execute_with(
909            Some("echo hi"),
910            Some("scripts/cleanup.ps1"),
911            Some("cleanup/1.0.0"),
912        );
913        let err = e.validate_script_source().unwrap_err();
914        assert!(err.contains("only one of"), "got: {err}");
915    }
916
917    #[test]
918    fn manifest_deserialises_script_object_yaml() {
919        // SPEC §2.4.1 example shape with the Object Store
920        // reference picked over inline.
921        let yaml = r#"
922id: cleanup-disk-temp
923version: 1.0.1
924execute:
925  shell: powershell
926  script_object: cleanup-disk-temp/1.0.1
927  timeout: 600s
928"#;
929        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
930        assert_eq!(
931            m.execute.script_object.as_deref(),
932            Some("cleanup-disk-temp/1.0.1")
933        );
934        assert!(m.execute.script.is_none());
935        m.validate()
936            .expect("script_object-only manifest passes validation");
937    }
938
939    #[test]
940    fn manifest_rejects_typo_in_script_field_name() {
941        // `deny_unknown_fields` on Execute catches `script_objectt`
942        // and similar fat-fingers at parse time instead of letting
943        // them silently fall through to "all three unset".
944        let yaml = r#"
945id: typo
946version: 1.0.0
947execute:
948  shell: powershell
949  script_objectt: oops
950  timeout: 30s
951"#;
952        let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
953        assert!(r.is_err(), "expected parse error, got {r:?}");
954    }
955
956    #[test]
957    fn schedule_carries_target_and_rollout() {
958        let yaml = r#"
959id: hourly-cleanup-canary
960when:
961  per_pc: { every: 1h }
962job_id: cleanup
963enabled: true
964target:
965  groups: [canary, wave1]
966jitter: 30s
967rollout:
968  strategy: wave
969  waves:
970    - { group: canary, delay: 0s }
971    - { group: wave1,  delay: 5s }
972"#;
973        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
974        assert_eq!(s.id, "hourly-cleanup-canary");
975        assert_eq!(s.job_id, "cleanup");
976        assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
977        assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
978        let rollout = s.plan.rollout.expect("rollout present");
979        assert_eq!(rollout.waves.len(), 2);
980        assert_eq!(rollout.waves[0].group, "canary");
981        assert_eq!(rollout.waves[1].delay, "5s");
982        assert_eq!(rollout.strategy, RolloutStrategy::Wave);
983    }
984
985    #[test]
986    fn schedule_minimal_target_all() {
987        let yaml = r#"
988id: kitting
989when:
990  per_pc: once
991enabled: true
992job_id: scheduled-echo
993target: { all: true }
994"#;
995        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
996        assert_eq!(s.id, "kitting");
997        assert_eq!(s.when, When::PerPc(PerPolicy::Once(OnceLiteral::Once)));
998        assert!(s.enabled);
999        assert_eq!(s.job_id, "scheduled-echo");
1000        assert!(s.plan.target.all);
1001        assert!(s.plan.rollout.is_none());
1002        assert!(s.plan.jitter.is_none());
1003        assert!(s.active.is_empty());
1004    }
1005
1006    #[test]
1007    fn schedule_enabled_defaults_to_true() {
1008        let yaml = r#"
1009id: x
1010when:
1011  per_pc: once
1012job_id: y
1013target: { all: true }
1014"#;
1015        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1016        assert!(s.enabled);
1017    }
1018
1019    // ---- `when` parsing (#418 Phase 1) ----
1020
1021    fn schedule_yaml_with(when_block: &str) -> String {
1022        format!(
1023            r#"
1024id: x
1025when:
1026{when_block}
1027job_id: y
1028target: {{ all: true }}
1029"#
1030        )
1031    }
1032
1033    #[test]
1034    fn when_per_pc_every_parses_unquoted_humantime() {
1035        // `6h` is digit-led but non-numeric → YAML string, same as
1036        // the old `cooldown: 6h` convention. No quotes needed.
1037        let s: Schedule =
1038            serde_yaml::from_str(&schedule_yaml_with("  per_pc: { every: 6h }")).expect("parse");
1039        assert_eq!(
1040            s.when,
1041            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() }))
1042        );
1043    }
1044
1045    #[test]
1046    fn when_per_target_every_parses() {
1047        let s: Schedule = serde_yaml::from_str(&schedule_yaml_with("  per_target: { every: 24h }"))
1048            .expect("parse");
1049        assert_eq!(
1050            s.when,
1051            When::PerTarget(PerPolicy::Every(EverySpec {
1052                every: "24h".into()
1053            }))
1054        );
1055    }
1056
1057    #[test]
1058    fn when_per_target_once_parses() {
1059        // Falls out of the shared PerPolicy shape and decide_fire
1060        // already implements it ("any one pc succeeds → skip the
1061        // target forever"), so it is allowed, not rejected.
1062        let s: Schedule =
1063            serde_yaml::from_str(&schedule_yaml_with("  per_target: once")).expect("parse");
1064        assert_eq!(s.when, When::PerTarget(PerPolicy::Once(OnceLiteral::Once)));
1065    }
1066
1067    #[test]
1068    fn when_calendar_time_parses() {
1069        let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(
1070            "  calendar:\n    at: \"09:00\"\n    days: [mon-fri]",
1071        ))
1072        .expect("parse");
1073        match &s.when {
1074            When::Calendar(c) => {
1075                assert_eq!(c.at, "09:00");
1076                assert_eq!(c.days, vec!["mon-fri"]);
1077            }
1078            other => panic!("expected calendar, got {other:?}"),
1079        }
1080    }
1081
1082    #[test]
1083    fn when_calendar_days_default_empty() {
1084        let s: Schedule =
1085            serde_yaml::from_str(&schedule_yaml_with("  calendar:\n    at: \"09:00\""))
1086                .expect("parse");
1087        match &s.when {
1088            When::Calendar(c) => assert!(c.days.is_empty(), "days defaults to empty (= daily)"),
1089            other => panic!("expected calendar, got {other:?}"),
1090        }
1091    }
1092
1093    #[test]
1094    fn when_calendar_datetime_parses_all_separators() {
1095        // one-shot: date+time in hyphen / ISO-T / slash forms
1096        for at in ["2026-06-10 09:00", "2026-06-10T09:00", "2026/06/10 09:00"] {
1097            let block = format!("  calendar:\n    at: \"{at}\"");
1098            let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(&block))
1099                .unwrap_or_else(|e| panic!("parse '{at}': {e}"));
1100            match &s.when {
1101                When::Calendar(c) => {
1102                    use chrono::Datelike;
1103                    let p = c.parse_at().expect("parse_at");
1104                    let d = p.date.expect("datetime at carries a date");
1105                    assert_eq!((d.year(), d.month(), d.day()), (2026, 6, 10), "for '{at}'");
1106                }
1107                other => panic!("expected calendar, got {other:?}"),
1108            }
1109        }
1110    }
1111
1112    #[test]
1113    fn when_rejects_bad_once_keyword() {
1114        // `onec` must be a parse error, not a silently-absorbed
1115        // string (OnceLiteral is a single-variant enum for exactly
1116        // this reason).
1117        let r: Result<Schedule, _> = serde_yaml::from_str(&schedule_yaml_with("  per_pc: onec"));
1118        assert!(r.is_err(), "expected parse error, got {r:?}");
1119    }
1120
1121    #[test]
1122    fn when_rejects_unknown_key_in_every() {
1123        // EverySpec is deny_unknown_fields so `evry:` typos fail
1124        // even under the untagged PerPolicy.
1125        let r: Result<Schedule, _> =
1126            serde_yaml::from_str(&schedule_yaml_with("  per_pc: { evry: 6h }"));
1127        assert!(r.is_err(), "expected parse error, got {r:?}");
1128    }
1129
1130    #[test]
1131    fn when_rejects_unknown_variant() {
1132        let r: Result<Schedule, _> =
1133            serde_yaml::from_str(&schedule_yaml_with("  per_galaxy: once"));
1134        assert!(r.is_err(), "expected parse error, got {r:?}");
1135    }
1136
1137    #[test]
1138    fn when_rejects_old_top_level_cron_field() {
1139        // Pre-#418 shape: top-level `cron:` + no `when:`. Must fail
1140        // loudly (missing `when`), which is what turns stale KV
1141        // blobs into warn-skips after the upgrade.
1142        let yaml = r#"
1143id: x
1144cron: "* * * * * *"
1145job_id: y
1146target: { all: true }
1147"#;
1148        let r: Result<Schedule, _> = serde_yaml::from_str(yaml);
1149        assert!(r.is_err(), "expected parse error, got {r:?}");
1150    }
1151
1152    #[test]
1153    fn when_rejects_retired_cron_escape_hatch() {
1154        // #418 Phase 2 retired `when: { cron: "..." }`. A raw cron
1155        // is now an unknown variant → parse error (operators use the
1156        // calendar form instead).
1157        let r: Result<Schedule, _> =
1158            serde_yaml::from_str(&schedule_yaml_with("  cron: \"0 0 9 * * mon-fri\""));
1159        assert!(
1160            r.is_err(),
1161            "expected parse error for retired cron, got {r:?}"
1162        );
1163    }
1164
1165    #[test]
1166    fn when_round_trips_json_and_yaml() {
1167        // Round-trip through the full Schedule: that is the wire
1168        // unit for both stores (JSON catalog KV + YAML mirror), and
1169        // it exercises the singleton_map field attribute that keeps
1170        // serde_yaml on the map shape instead of `!per_pc` tags.
1171        for when in [
1172            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1173            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1174            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
1175            When::PerTarget(PerPolicy::Every(EverySpec {
1176                every: "24h".into(),
1177            })),
1178            calendar("09:00", &["mon-fri"]),
1179            calendar("2026-06-10 09:00", &[]),
1180        ] {
1181            let s = schedule_with(when.clone(), RunsOn::Backend);
1182
1183            let json = serde_json::to_string(&s).expect("json serialise");
1184            let back: Schedule = serde_json::from_str(&json).expect("json deserialise");
1185            assert_eq!(back.when, when, "json round-trip for {when}");
1186
1187            let yaml = serde_yaml::to_string(&s).expect("yaml serialise");
1188            assert!(
1189                !yaml.contains('!'),
1190                "yaml must use the map shape, not tags: {yaml}"
1191            );
1192            let back: Schedule = serde_yaml::from_str(&yaml).expect("yaml deserialise");
1193            assert_eq!(back.when, when, "yaml round-trip for {when}");
1194        }
1195    }
1196
1197    #[test]
1198    fn when_once_serialises_as_bare_keyword() {
1199        // The wire shape operators see in the YAML mirror must stay
1200        // the ergonomic `per_pc: once`, not a one-variant map.
1201        let json = serde_json::to_value(When::PerPc(PerPolicy::Once(OnceLiteral::Once)))
1202            .expect("serialise");
1203        assert_eq!(json, serde_json::json!({ "per_pc": "once" }));
1204    }
1205
1206    #[test]
1207    fn when_displays_operator_summary() {
1208        for (when, expected) in [
1209            (
1210                When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1211                "per_pc once",
1212            ),
1213            (
1214                When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1215                "per_pc every 6h",
1216            ),
1217            (
1218                When::PerTarget(PerPolicy::Every(EverySpec {
1219                    every: "24h".into(),
1220                })),
1221                "per_target every 24h",
1222            ),
1223            (calendar("09:00", &["mon-fri"]), "at 09:00 [mon-fri]"),
1224            (calendar("2026-06-10 09:00", &[]), "at 2026-06-10 09:00"),
1225        ] {
1226            assert_eq!(when.to_string(), expected);
1227        }
1228    }
1229
1230    // ---- lowering (#418: when → engine vocabulary) ----
1231
1232    fn schedule_with(when: When, runs_on: RunsOn) -> Schedule {
1233        Schedule {
1234            id: "x".into(),
1235            when,
1236            job_id: "y".into(),
1237            plan: FanoutPlan::default(),
1238            active: Active::default(),
1239            constraints: Constraints::default(),
1240            tz: ScheduleTz::default(),
1241            starting_deadline: None,
1242            runs_on,
1243            enabled: true,
1244        }
1245    }
1246
1247    fn calendar(at: &str, days: &[&str]) -> When {
1248        When::Calendar(CalendarSpec {
1249            at: at.into(),
1250            days: days.iter().map(|d| (*d).to_string()).collect(),
1251        })
1252    }
1253
1254    #[test]
1255    fn lowering_matches_the_418_table() {
1256        let cases = [
1257            (
1258                When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1259                (POLL_CRON, ExecMode::OncePerPc, None),
1260            ),
1261            (
1262                When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1263                (POLL_CRON, ExecMode::OncePerPc, Some("6h")),
1264            ),
1265            (
1266                When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
1267                (POLL_CRON, ExecMode::OncePerTarget, None),
1268            ),
1269            (
1270                When::PerTarget(PerPolicy::Every(EverySpec {
1271                    every: "24h".into(),
1272                })),
1273                (POLL_CRON, ExecMode::OncePerTarget, Some("24h")),
1274            ),
1275            // calendar repeating → 6-field cron
1276            (
1277                calendar("09:00", &["mon-fri"]),
1278                ("0 0 9 * * mon-fri", ExecMode::EveryTick, None),
1279            ),
1280            // calendar daily (no days) → DOW *
1281            (
1282                calendar("18:30", &[]),
1283                ("0 30 18 * * *", ExecMode::EveryTick, None),
1284            ),
1285            // calendar one-shot → 7-field year cron
1286            (
1287                calendar("2026-06-10 09:00", &[]),
1288                ("0 0 9 10 6 * 2026", ExecMode::EveryTick, None),
1289            ),
1290        ];
1291        for (when, (cron, mode, cooldown)) in cases {
1292            let l = schedule_with(when.clone(), RunsOn::Backend).lowered();
1293            assert_eq!(l.cron, cron, "cron for {when}");
1294            assert_eq!(l.mode, mode, "mode for {when}");
1295            assert_eq!(l.cooldown.as_deref(), cooldown, "cooldown for {when}");
1296        }
1297    }
1298
1299    #[test]
1300    fn lowered_carries_schedule_tz() {
1301        for (tz, want) in [
1302            (ScheduleTz::Local, ScheduleTz::Local),
1303            (ScheduleTz::Utc, ScheduleTz::Utc),
1304        ] {
1305            let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
1306            s.tz = tz;
1307            assert_eq!(s.lowered().tz, want, "calendar carries tz");
1308            // reconcile shapes carry tz too (for the active-window check)
1309            let mut s = schedule_with(
1310                When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1311                RunsOn::Backend,
1312            );
1313            s.tz = tz;
1314            assert_eq!(s.lowered().tz, want, "reconcile carries tz");
1315        }
1316    }
1317
1318    #[test]
1319    fn poll_cron_is_accepted_by_the_engine_parser() {
1320        // POLL_CRON is system-generated — if the engine's parser
1321        // ever rejected it every reconcile schedule would die at
1322        // register time. Validate it with the same croner config
1323        // (Seconds::Required, dom_and_dow, year optional).
1324        croner::parser::CronParser::builder()
1325            .seconds(croner::parser::Seconds::Required)
1326            .dom_and_dow(true)
1327            .build()
1328            .parse(POLL_CRON)
1329            .expect("POLL_CRON must parse");
1330    }
1331
1332    // ---- Schedule::validate() (#418 decision F) ----
1333
1334    #[test]
1335    fn validate_accepts_reconcile_shapes() {
1336        for when in [
1337            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1338            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1339            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
1340            When::PerTarget(PerPolicy::Every(EverySpec {
1341                every: "24h".into(),
1342            })),
1343        ] {
1344            schedule_with(when.clone(), RunsOn::Backend)
1345                .validate()
1346                .unwrap_or_else(|e| panic!("{when} should validate: {e}"));
1347        }
1348    }
1349
1350    #[test]
1351    fn validate_accepts_per_pc_on_agent() {
1352        schedule_with(
1353            When::PerPc(PerPolicy::Every(EverySpec { every: "1h".into() })),
1354            RunsOn::Agent,
1355        )
1356        .validate()
1357        .expect("per_pc + agent is the offline-inventory shape");
1358    }
1359
1360    #[test]
1361    fn validate_rejects_per_target_on_agent() {
1362        let err = schedule_with(
1363            When::PerTarget(PerPolicy::Every(EverySpec {
1364                every: "24h".into(),
1365            })),
1366            RunsOn::Agent,
1367        )
1368        .validate()
1369        .unwrap_err();
1370        assert!(err.contains("per_target"), "got: {err}");
1371        assert!(err.contains("runs_on: agent"), "got: {err}");
1372
1373        // per_target: once is also backend-only.
1374        let err = schedule_with(
1375            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
1376            RunsOn::Agent,
1377        )
1378        .validate()
1379        .unwrap_err();
1380        assert!(err.contains("per_target"), "got (once): {err}");
1381        assert!(err.contains("runs_on: agent"), "got (once): {err}");
1382    }
1383
1384    #[test]
1385    fn validate_rejects_bad_every_duration() {
1386        let err = schedule_with(
1387            When::PerPc(PerPolicy::Every(EverySpec { every: "6x".into() })),
1388            RunsOn::Backend,
1389        )
1390        .validate()
1391        .unwrap_err();
1392        assert!(err.contains("when.every"), "got: {err}");
1393    }
1394
1395    #[test]
1396    fn validate_rejects_bad_jitter_and_starting_deadline() {
1397        let mut s = schedule_with(
1398            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1399            RunsOn::Backend,
1400        );
1401        s.plan.jitter = Some("5x".into());
1402        let err = s.validate().unwrap_err();
1403        assert!(err.contains("jitter"), "got: {err}");
1404
1405        let mut s = schedule_with(
1406            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1407            RunsOn::Backend,
1408        );
1409        s.starting_deadline = Some("soon".into());
1410        let err = s.validate().unwrap_err();
1411        assert!(err.contains("starting_deadline"), "got: {err}");
1412    }
1413
1414    #[test]
1415    fn validate_accepts_calendar_shapes() {
1416        for when in [
1417            calendar("09:00", &["mon-fri"]),   // weekday morning
1418            calendar("00:00", &["sun"]),       // weekly
1419            calendar("18:30", &[]),            // daily
1420            calendar("2026-06-10 09:00", &[]), // one-shot
1421            calendar("2026/12/25 00:00", &[]), // one-shot, slash form
1422        ] {
1423            schedule_with(when.clone(), RunsOn::Backend)
1424                .validate()
1425                .unwrap_or_else(|e| panic!("{when} should validate: {e}"));
1426        }
1427    }
1428
1429    #[test]
1430    fn validate_rejects_bad_at() {
1431        for bad in ["25:00", "09:60", "9", "noon", "2026-13-01 09:00"] {
1432            let err = schedule_with(calendar(bad, &[]), RunsOn::Backend)
1433                .validate()
1434                .unwrap_err();
1435            assert!(err.contains("when.at"), "for '{bad}', got: {err}");
1436        }
1437    }
1438
1439    #[test]
1440    fn validate_rejects_datetime_at_with_days() {
1441        // A dated `at` is a one-shot — pairing it with days is a
1442        // contradiction (the date already pins the day).
1443        let err = schedule_with(calendar("2026-06-10 09:00", &["mon"]), RunsOn::Backend)
1444            .validate()
1445            .unwrap_err();
1446        assert!(
1447            err.contains("one-shot") && err.contains("days"),
1448            "got: {err}"
1449        );
1450    }
1451
1452    #[test]
1453    fn validate_rejects_bad_day_name() {
1454        // A garbage DOW token is caught by the days pre-flight and
1455        // reported against `when.days`, not the confusing
1456        // "when.at lowered to invalid cron" (claude #432 review).
1457        let err = schedule_with(calendar("09:00", &["funday"]), RunsOn::Backend)
1458            .validate()
1459            .unwrap_err();
1460        assert!(err.contains("when.days"), "got: {err}");
1461        assert!(err.contains("funday"), "names the bad token: {err}");
1462        // a degenerate range like `mon-` reports the whole token, not
1463        // a cryptic empty part (claude #432 follow-up)
1464        let err = schedule_with(calendar("09:00", &["mon-"]), RunsOn::Backend)
1465            .validate()
1466            .unwrap_err();
1467        assert!(err.contains("'mon-'"), "names the whole token: {err}");
1468        // valid names / ranges / numeric / * all pass
1469        for ok in [
1470            calendar("09:00", &["mon-fri"]),
1471            calendar("09:00", &["mon", "wed", "sun"]),
1472            calendar("09:00", &["1-5"]),
1473        ] {
1474            schedule_with(ok.clone(), RunsOn::Backend)
1475                .validate()
1476                .unwrap_or_else(|e| panic!("{ok} should validate: {e}"));
1477        }
1478    }
1479
1480    #[test]
1481    fn calendar_oneshot_instant_detects_past() {
1482        use chrono::TimeZone;
1483        // a dated `at` resolves to an absolute instant…
1484        let c = CalendarSpec {
1485            at: "2024-01-01 09:00".into(),
1486            days: vec![],
1487        };
1488        let t = c
1489            .oneshot_instant(ScheduleTz::Utc)
1490            .expect("one-shot instant");
1491        assert_eq!(
1492            t,
1493            chrono::Utc.with_ymd_and_hms(2024, 1, 1, 9, 0, 0).unwrap()
1494        );
1495        assert!(t < chrono::Utc::now(), "2024 is in the past");
1496        // …while a repeating (time-only) calendar has no instant
1497        let rep = CalendarSpec {
1498            at: "09:00".into(),
1499            days: vec!["mon-fri".into()],
1500        };
1501        assert!(rep.oneshot_instant(ScheduleTz::Utc).is_none());
1502    }
1503
1504    fn schedule_with_active(from: Option<&str>, until: Option<&str>) -> Schedule {
1505        let mut s = schedule_with(
1506            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1507            RunsOn::Backend,
1508        );
1509        s.active = Active {
1510            from: from.map(str::to_owned),
1511            until: until.map(str::to_owned),
1512        };
1513        s
1514    }
1515
1516    #[test]
1517    fn validate_accepts_active_window() {
1518        schedule_with_active(Some("2026-07-01"), Some("2026-08-01T12:00:00+09:00"))
1519            .validate()
1520            .expect("date + rfc3339 bounds should validate");
1521    }
1522
1523    #[test]
1524    fn validate_rejects_unparseable_active_bound() {
1525        let err = schedule_with_active(Some("July 1st"), None)
1526            .validate()
1527            .unwrap_err();
1528        assert!(err.contains("active"), "got: {err}");
1529    }
1530
1531    #[test]
1532    fn validate_rejects_from_not_before_until() {
1533        let err = schedule_with_active(Some("2026-08-01"), Some("2026-07-01"))
1534            .validate()
1535            .unwrap_err();
1536        assert!(err.contains("strictly before"), "got: {err}");
1537
1538        let err = schedule_with_active(Some("2026-07-01"), Some("2026-07-01"))
1539            .validate()
1540            .unwrap_err();
1541        assert!(err.contains("strictly before"), "got: {err}");
1542    }
1543
1544    // ---- Active window semantics ----
1545
1546    #[test]
1547    fn active_window_is_half_open() {
1548        use chrono::TimeZone;
1549        let active = Active {
1550            from: Some("2026-07-01".into()),
1551            until: Some("2026-08-01".into()),
1552        };
1553        // UTC tz so the date bounds are UTC midnight.
1554        let at = |y, m, d, h| chrono::Utc.with_ymd_and_hms(y, m, d, h, 0, 0).unwrap();
1555        let c = |t| active.contains(t, ScheduleTz::Utc);
1556        assert!(!c(at(2026, 6, 30, 23)), "before from");
1557        assert!(c(at(2026, 7, 1, 0)), "at from (inclusive)");
1558        assert!(c(at(2026, 7, 15, 12)), "inside");
1559        assert!(!c(at(2026, 8, 1, 0)), "at until (exclusive)");
1560        assert!(!c(at(2026, 8, 2, 0)), "after until");
1561    }
1562
1563    #[test]
1564    fn active_empty_window_is_always_active() {
1565        assert!(Active::default().contains(chrono::Utc::now(), ScheduleTz::Local));
1566    }
1567
1568    #[test]
1569    fn active_rfc3339_bound_honours_offset_regardless_of_tz() {
1570        use chrono::TimeZone;
1571        let active = Active {
1572            from: Some("2026-07-01T09:00:00+09:00".into()),
1573            until: None,
1574        };
1575        // RFC3339 carries its own offset → tz arg is ignored.
1576        // 09:00 JST = 00:00 UTC.
1577        for tz in [ScheduleTz::Utc, ScheduleTz::Local] {
1578            assert!(
1579                !active.contains(
1580                    chrono::Utc
1581                        .with_ymd_and_hms(2026, 6, 30, 23, 59, 0)
1582                        .unwrap(),
1583                    tz
1584                )
1585            );
1586            assert!(active.contains(
1587                chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
1588                tz
1589            ));
1590        }
1591    }
1592
1593    #[test]
1594    fn active_date_bound_respects_tz() {
1595        // A bare `YYYY-MM-DD` bound is midnight *in the schedule's
1596        // tz* (#418 Phase 2). The UTC interpretation is exact and
1597        // host-independent; assert that precisely.
1598        use chrono::TimeZone;
1599        let utc = Active::parse_bound("2026-07-01", ScheduleTz::Utc).expect("utc");
1600        assert_eq!(
1601            utc,
1602            chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap()
1603        );
1604
1605        // The local interpretation must equal what chrono::Local
1606        // computes for the same wall-clock midnight — proves the tz
1607        // path is wired to the host zone (the magnitude vs UTC is
1608        // host-dependent, so we compare against Local directly rather
1609        // than hard-coding the JST offset, keeping CI green on UTC
1610        // runners).
1611        let local = Active::parse_bound("2026-07-01", ScheduleTz::Local).expect("local");
1612        let want = chrono::Local
1613            .with_ymd_and_hms(2026, 7, 1, 0, 0, 0)
1614            .single()
1615            .expect("local midnight is unambiguous")
1616            .with_timezone(&chrono::Utc);
1617        assert_eq!(local, want, "date bound resolved in host-local tz");
1618    }
1619
1620    #[test]
1621    fn active_empty_is_skipped_when_serialising() {
1622        let s = schedule_with(
1623            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1624            RunsOn::Backend,
1625        );
1626        let json = serde_json::to_value(&s).expect("serialise");
1627        assert!(
1628            json.get("active").is_none(),
1629            "empty active must not appear on the wire: {json}"
1630        );
1631    }
1632
1633    // ---- constraints.window (#418 Phase 3) ----
1634
1635    fn with_window(win: &str) -> Schedule {
1636        let mut s = schedule_with(
1637            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1638            RunsOn::Backend,
1639        );
1640        s.constraints.window = Some(win.into());
1641        s
1642    }
1643
1644    #[test]
1645    fn constraints_window_parses_and_round_trips() {
1646        let yaml = r#"
1647id: x
1648when:
1649  per_pc: { every: 6h }
1650job_id: y
1651target: { all: true }
1652constraints:
1653  window: "22:00-05:00"
1654"#;
1655        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1656        assert_eq!(s.constraints.window.as_deref(), Some("22:00-05:00"));
1657        let back: Schedule =
1658            serde_json::from_str(&serde_json::to_string(&s).expect("ser")).expect("de");
1659        assert_eq!(back.constraints.window.as_deref(), Some("22:00-05:00"));
1660    }
1661
1662    #[test]
1663    fn constraints_empty_is_skipped_when_serialising() {
1664        let s = schedule_with(
1665            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1666            RunsOn::Backend,
1667        );
1668        let json = serde_json::to_value(&s).expect("serialise");
1669        assert!(
1670            json.get("constraints").is_none(),
1671            "empty constraints must not appear on the wire: {json}"
1672        );
1673    }
1674
1675    #[test]
1676    fn window_no_constraint_always_allows() {
1677        let c = Constraints::default();
1678        assert!(c.allows(chrono::Utc::now(), ScheduleTz::Local));
1679    }
1680
1681    #[test]
1682    fn window_same_day_is_half_open() {
1683        use chrono::TimeZone;
1684        let s = with_window("09:00-17:00");
1685        let at = |h, m| chrono::Utc.with_ymd_and_hms(2026, 6, 9, h, m, 0).unwrap();
1686        let a = |t| s.constraints.allows(t, ScheduleTz::Utc);
1687        assert!(!a(at(8, 59)), "before start");
1688        assert!(a(at(9, 0)), "at start (inclusive)");
1689        assert!(a(at(16, 59)), "inside");
1690        assert!(!a(at(17, 0)), "at end (exclusive)");
1691        assert!(!a(at(23, 0)), "after end");
1692    }
1693
1694    #[test]
1695    fn window_crossing_midnight() {
1696        use chrono::TimeZone;
1697        let s = with_window("22:00-05:00");
1698        let at = |h, m| chrono::Utc.with_ymd_and_hms(2026, 6, 9, h, m, 0).unwrap();
1699        let a = |t| s.constraints.allows(t, ScheduleTz::Utc);
1700        assert!(a(at(22, 0)), "at start tonight");
1701        assert!(a(at(23, 30)), "late tonight");
1702        assert!(a(at(3, 0)), "early tomorrow");
1703        assert!(!a(at(5, 0)), "at end (exclusive)");
1704        assert!(!a(at(12, 0)), "midday outside");
1705        assert!(!a(at(21, 59)), "just before start");
1706    }
1707
1708    #[test]
1709    fn window_respects_tz() {
1710        // The same instant is inside the window under one tz and may
1711        // be outside under another. Compare UTC vs Local via the
1712        // host's own offset (kept CI-green on UTC runners like the
1713        // active tz test does).
1714        use chrono::TimeZone;
1715        let s = with_window("09:00-17:00");
1716        let noon_utc = chrono::Utc.with_ymd_and_hms(2026, 6, 9, 12, 0, 0).unwrap();
1717        // Under UTC, 12:00 is inside 09:00-17:00.
1718        assert!(s.constraints.allows(noon_utc, ScheduleTz::Utc));
1719        // Under Local, the verdict tracks the host wall-clock time;
1720        // assert it matches a direct wall_time membership check.
1721        let local_t = noon_utc.with_timezone(&chrono::Local).time();
1722        let in_local = local_t >= chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap()
1723            && local_t < chrono::NaiveTime::from_hms_opt(17, 0, 0).unwrap();
1724        assert_eq!(s.constraints.allows(noon_utc, ScheduleTz::Local), in_local);
1725    }
1726
1727    #[test]
1728    fn validate_accepts_good_window() {
1729        for w in ["09:00-17:00", "22:00-05:00", "00:00-23:59"] {
1730            with_window(w)
1731                .validate()
1732                .unwrap_or_else(|e| panic!("'{w}' should validate: {e}"));
1733        }
1734    }
1735
1736    #[test]
1737    fn validate_rejects_bad_window() {
1738        for bad in ["9-5", "22:00", "22:00-22:00", "25:00-05:00", "09:00_17:00"] {
1739            let err = with_window(bad).validate().unwrap_err();
1740            assert!(
1741                err.contains("constraints.window"),
1742                "for '{bad}', got: {err}"
1743            );
1744        }
1745    }
1746
1747    #[test]
1748    fn window_fail_closed_on_corrupt_blob() {
1749        // A malformed window (only reachable via a hand-edited KV
1750        // blob — validate() rejects it at create) must BLOCK, not
1751        // silently allow fires during a change-freeze (gemini #452).
1752        let s = with_window("22:00_05:00");
1753        assert!(
1754            !s.constraints.allows(chrono::Utc::now(), ScheduleTz::Utc),
1755            "corrupt window fails closed"
1756        );
1757        // …and the scheduler can surface why it's stuck.
1758        assert!(
1759            s.bad_window().is_some(),
1760            "bad_window reports the parse error"
1761        );
1762        assert!(with_window("22:00-05:00").bad_window().is_none());
1763    }
1764
1765    #[test]
1766    fn calendar_outside_window_is_flagged() {
1767        // at 09:00 can never fall in 22:00-05:00 → never fires.
1768        let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
1769        s.constraints.window = Some("22:00-05:00".into());
1770        assert!(s.calendar_outside_window(), "09:00 is not in 22:00-05:00");
1771
1772        // at 23:00 IS inside the overnight window → fine.
1773        let mut s = schedule_with(calendar("23:00", &[]), RunsOn::Backend);
1774        s.constraints.window = Some("22:00-05:00".into());
1775        assert!(!s.calendar_outside_window(), "23:00 is in 22:00-05:00");
1776
1777        // reconcile shapes are never flagged (they poll every minute).
1778        let mut s = schedule_with(
1779            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1780            RunsOn::Backend,
1781        );
1782        s.constraints.window = Some("22:00-05:00".into());
1783        assert!(!s.calendar_outside_window(), "reconcile is unaffected");
1784
1785        // no window → never flagged.
1786        let s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
1787        assert!(!s.calendar_outside_window());
1788    }
1789
1790    #[test]
1791    fn shipped_schedule_configs_parse_and_validate() {
1792        // Every YAML under configs/schedules/ must parse with the
1793        // current Schedule serde AND pass validate() — keeps the
1794        // shipped examples from drifting out of sync with the model
1795        // (#418 removed back-compat, so drift = broken at create).
1796        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../configs/schedules");
1797        let mut seen = 0;
1798        for entry in std::fs::read_dir(&dir).expect("read configs/schedules") {
1799            let path = entry.expect("dir entry").path();
1800            if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
1801                continue;
1802            }
1803            let body = std::fs::read_to_string(&path).expect("read yaml");
1804            let s: Schedule = serde_yaml::from_str(&body)
1805                .unwrap_or_else(|e| panic!("{} failed to parse: {e}", path.display()));
1806            s.validate()
1807                .unwrap_or_else(|e| panic!("{} failed validate(): {e}", path.display()));
1808            seen += 1;
1809        }
1810        assert!(seen > 0, "no schedule YAMLs found in {}", dir.display());
1811    }
1812
1813    // ---- pre-existing enum wire formats (unchanged by #418) ----
1814
1815    #[test]
1816    fn exec_mode_serialises_snake_case() {
1817        for (mode, expected) in [
1818            (ExecMode::EveryTick, "every_tick"),
1819            (ExecMode::OncePerPc, "once_per_pc"),
1820            (ExecMode::OncePerTarget, "once_per_target"),
1821        ] {
1822            let s = serde_json::to_value(mode).expect("serialise");
1823            assert_eq!(s, serde_json::Value::String(expected.into()));
1824            let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
1825                .expect("deserialise");
1826            assert_eq!(back, mode, "round-trip for {expected}");
1827        }
1828    }
1829
1830    #[test]
1831    fn schedule_runs_on_defaults_to_backend() {
1832        let yaml = r#"
1833id: x
1834when:
1835  per_pc: once
1836job_id: y
1837target: { all: true }
1838"#;
1839        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1840        assert_eq!(s.runs_on, RunsOn::Backend);
1841    }
1842
1843    #[test]
1844    fn schedule_runs_on_agent_parses() {
1845        let yaml = r#"
1846id: offline-inv
1847when:
1848  per_pc: { every: 1h }
1849job_id: inventory-hw
1850target: { all: true }
1851runs_on: agent
1852"#;
1853        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1854        assert_eq!(s.runs_on, RunsOn::Agent);
1855        assert_eq!(s.lowered().mode, ExecMode::OncePerPc);
1856    }
1857
1858    #[test]
1859    fn runs_on_serialises_snake_case() {
1860        for (mode, expected) in [(RunsOn::Backend, "backend"), (RunsOn::Agent, "agent")] {
1861            let s = serde_json::to_value(mode).expect("serialise");
1862            assert_eq!(s, serde_json::Value::String(expected.into()));
1863            let back: RunsOn = serde_json::from_value(serde_json::Value::String(expected.into()))
1864                .expect("deserialise");
1865            assert_eq!(back, mode);
1866        }
1867    }
1868
1869    #[test]
1870    fn execute_shell_into_wire_shell() {
1871        assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
1872        assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
1873    }
1874
1875    #[test]
1876    fn manifest_staleness_defaults_to_cached() {
1877        let yaml = r#"
1878id: x
1879version: 1.0.0
1880execute:
1881  shell: powershell
1882  script: "echo"
1883  timeout: 1s
1884"#;
1885        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1886        assert_eq!(m.staleness, Staleness::Cached);
1887    }
1888
1889    #[test]
1890    fn manifest_strict_staleness_parses() {
1891        let yaml = r#"
1892id: urgent-patch
1893version: 2.5.1
1894execute:
1895  shell: powershell
1896  script: Install-Hotfix
1897  timeout: 5m
1898staleness:
1899  mode: strict
1900  max_cache_age: 0s
1901"#;
1902        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1903        match m.staleness {
1904            Staleness::Strict { max_cache_age } => assert_eq!(max_cache_age, "0s"),
1905            other => panic!("expected strict, got {other:?}"),
1906        }
1907    }
1908
1909    #[test]
1910    fn manifest_unchecked_staleness_parses() {
1911        let yaml = r#"
1912id: legacy
1913version: 0.1.0
1914execute:
1915  shell: cmd
1916  script: "echo"
1917  timeout: 1s
1918staleness:
1919  mode: unchecked
1920"#;
1921        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1922        assert_eq!(m.staleness, Staleness::Unchecked);
1923    }
1924
1925    #[test]
1926    fn missing_required_field_errors() {
1927        // `id` missing.
1928        let yaml = r#"
1929version: 1.0.0
1930target: { all: true }
1931execute:
1932  shell: powershell
1933  script: "echo"
1934  timeout: 1s
1935"#;
1936        let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
1937        assert!(r.is_err(), "expected error, got {:?}", r);
1938    }
1939
1940    #[test]
1941    fn display_field_table_kind_round_trips_with_nested_columns() {
1942        // #39: `type: table` + `columns:` on a DisplayField gets
1943        // round-tripped through serde so the SPA receives the
1944        // nested schema verbatim. Nested columns themselves are
1945        // DisplayFields so they can carry `type: bytes` /
1946        // `type: number` for cell formatting.
1947        let yaml = r#"
1948id: inv-hw
1949version: 1.0.0
1950execute:
1951  shell: powershell
1952  script: "echo"
1953  timeout: 60s
1954inventory:
1955  display:
1956    - field: hostname
1957      label: Hostname
1958    - field: disks
1959      label: Disks
1960      type: table
1961      columns:
1962        - field: device_id
1963          label: Drive
1964        - field: size_bytes
1965          label: Size
1966          type: bytes
1967        - field: free_bytes
1968          label: Free
1969          type: bytes
1970        - field: file_system
1971          label: FS
1972"#;
1973        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1974        let inv = m.inventory.as_ref().expect("inventory hint");
1975        let disks = inv
1976            .display
1977            .iter()
1978            .find(|d| d.field == "disks")
1979            .expect("disks display row");
1980        assert_eq!(disks.kind.as_deref(), Some("table"));
1981        let cols = disks.columns.as_ref().expect("table needs columns");
1982        assert_eq!(cols.len(), 4);
1983        assert_eq!(cols[1].field, "size_bytes");
1984        assert_eq!(cols[1].kind.as_deref(), Some("bytes"));
1985    }
1986
1987    #[test]
1988    fn display_field_scalar_kind_keeps_columns_none() {
1989        // Defensive: when type is a scalar (`bytes` / `number` /
1990        // `timestamp`) the `columns` field stays None — the SPA
1991        // uses its presence as the "render nested table" signal,
1992        // so it must not leak in via serde defaults.
1993        let yaml = r#"
1994id: x
1995version: 1.0.0
1996execute:
1997  shell: powershell
1998  script: "echo"
1999  timeout: 5s
2000inventory:
2001  display:
2002    - { field: ram_bytes, label: RAM, type: bytes }
2003"#;
2004        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2005        let inv = m.inventory.as_ref().unwrap();
2006        assert!(inv.display[0].columns.is_none());
2007    }
2008}
2009
2010/// Periodic schedule (spec §2.4.3). v0.18.0 carries the fanout plan
2011/// (target + optional rollout + optional jitter) inline; the
2012/// referenced job (`job_id` → [`BUCKET_JOBS`]) supplies only the
2013/// script body. Two schedules of the same job can target different
2014/// groups on different cadences without copying the manifest.
2015///
2016/// #418 Phase 1: the cadence is the single [`When`] field. The old
2017/// `cron` × `mode` × `cooldown` × `auto_disable_when_done` quartet
2018/// is gone (no back-compat — pre-Phase-1 KV blobs fail to parse and
2019/// are warn-skipped; re-`schedule create` to upgrade them). The
2020/// engine underneath is unchanged: [`Schedule::lowered`] maps `when`
2021/// onto the same (cron, ExecMode, cooldown) trio the scheduler and
2022/// `decide_fire` always ran on.
2023#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
2024pub struct Schedule {
2025    pub id: String,
2026    /// When to fire — a reconcile cadence (`per_pc` / `per_target`)
2027    /// or a calendar time trigger (`at` / `days`). See [`When`].
2028    ///
2029    /// `singleton_map`: serde_yaml 0.9 renders externally-tagged
2030    /// enums as `!per_pc` YAML tags by default; this keeps the
2031    /// operator-facing map shape (`when: { per_pc: once }`). JSON
2032    /// output is identical either way, and the schemars schema
2033    /// (external tagging = oneOf of single-key objects) already
2034    /// matches the singleton-map wire shape.
2035    #[serde(with = "serde_yaml::with::singleton_map")]
2036    #[schemars(with = "When")]
2037    pub when: When,
2038    /// Key into [`crate::kv::BUCKET_JOBS`]. Must equal a registered
2039    /// Manifest's `id`.
2040    pub job_id: String,
2041    /// Who + how-to-phase + when-to-stagger. The Manifest doesn't
2042    /// carry these any more — same job + different fanout = different
2043    /// schedule.
2044    #[serde(flatten)]
2045    pub plan: FanoutPlan,
2046    /// Optional validity window. Outside `[from, until)` the
2047    /// schedule is dormant — still registered, still visible, but
2048    /// every tick is skipped (deleted ≠ dormant: a campaign that
2049    /// ended stays inspectable and can be re-armed by editing the
2050    /// window). Checked at tick time on both the backend scheduler
2051    /// and the agent's local scheduler.
2052    #[serde(default, skip_serializing_if = "Active::is_empty")]
2053    pub active: Active,
2054    /// #418 Phase 3: operational constraints gating *when within an
2055    /// active period* a fire may happen. Currently just `window`
2056    /// (a maintenance time-of-day window); future `require`
2057    /// (env gates) and `max_concurrent` land in the same namespace.
2058    /// Evaluated in the schedule's `tz` like the other wall-clock
2059    /// fields. Checked at tick time on both schedulers.
2060    #[serde(default, skip_serializing_if = "Constraints::is_empty")]
2061    pub constraints: Constraints,
2062    /// #418 Phase 2: the timezone this schedule's wall-clock fields
2063    /// are evaluated in — both the calendar `at` firing time AND the
2064    /// `active.{from,until}` window bounds. `local` (default) = the
2065    /// running host's TZ (the agent's for `runs_on: agent`, the
2066    /// backend server's otherwise); `utc` for TZ-independent
2067    /// schedules. Reconcile shapes (`per_pc`/`per_target`) ignore it
2068    /// for firing (poll cron runs every minute regardless) but still
2069    /// honor it for the `active` window.
2070    #[serde(default)]
2071    pub tz: ScheduleTz,
2072    /// v0.22: optional humantime window after a cron tick during
2073    /// which the Command is still considered "live". The scheduler
2074    /// computes `tick_at + starting_deadline` and stamps it onto
2075    /// each Command as `deadline_at`; agents skip Commands they
2076    /// receive after that absolute time. `None` (default) = no
2077    /// deadline, meaning a Command queued in the broker / stream
2078    /// during agent downtime runs whenever the agent reconnects —
2079    /// good for kitting / inventory / cleanup. Set this for
2080    /// time-of-day notifications, lunch reminders, etc., where
2081    /// "fire 3 hours late" would be wrong.
2082    #[serde(default, skip_serializing_if = "Option::is_none")]
2083    pub starting_deadline: Option<String>,
2084    /// v0.23: where does the cron tick happen? `Backend` (default,
2085    /// historical) = backend's scheduler fires Commands via NATS;
2086    /// agents passively receive. `Agent` = each targeted agent runs
2087    /// its own internal cron and fires locally, so the schedule
2088    /// keeps ticking even when the broker is unreachable (laptop on
2089    /// the train, broker maintenance window, full WAN outage). The
2090    /// two locations are mutually exclusive — when `Agent`, the
2091    /// backend scheduler stays out and just keeps the definition in
2092    /// KV for agents to read.
2093    #[serde(default)]
2094    pub runs_on: RunsOn,
2095    #[serde(default = "default_true")]
2096    pub enabled: bool,
2097}
2098
2099/// v0.23 — where the cron tick fires from.
2100#[derive(
2101    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
2102)]
2103#[serde(rename_all = "snake_case")]
2104pub enum RunsOn {
2105    /// Backend's central scheduler ticks and publishes Commands to
2106    /// NATS. Historical default, what every pre-v0.23 schedule
2107    /// uses. Agent offline ⇒ Command queued in STREAM_EXEC; agent
2108    /// reconnects ⇒ catch-up via [`command_replay`](crate)
2109    /// (see kanade-agent's command_replay module).
2110    #[default]
2111    Backend,
2112    /// Each targeted agent runs the cron tick locally. Survives
2113    /// broker / WAN outages. Best for laptops / mobile devices that
2114    /// roam off the corporate network. Agent must be online for the
2115    /// initial schedule + job-catalog pull, but once cached the
2116    /// agent fires the script standalone.
2117    Agent,
2118}
2119
2120/// Per-pc/per-target dedup semantics for a [`Schedule`] (v0.19).
2121#[derive(
2122    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
2123)]
2124#[serde(rename_all = "snake_case")]
2125pub enum ExecMode {
2126    /// Fire on every cron tick at the whole target. Historical
2127    /// (pre-v0.19) behavior; no dedup.
2128    #[default]
2129    EveryTick,
2130    /// Fire at each pc until that pc succeeds; then skip it until
2131    /// the optional cooldown elapses (or forever if no cooldown).
2132    /// Use for kitting / first-boot / per-pc compliance checks.
2133    OncePerPc,
2134    /// Fire at the whole target until **any** pc succeeds; then
2135    /// skip the whole target until the optional cooldown elapses
2136    /// (or forever if no cooldown). Use for "one delegate is
2137    /// enough" tasks like license check-in.
2138    OncePerTarget,
2139}
2140
2141/// #418 Phase 1 — the single "when does this fire" axis.
2142///
2143/// Replaces the old `cron` + `mode` + `cooldown` trio whose
2144/// interactions were implicit (cron doubled as both a real
2145/// time-of-day trigger and a reconcile poll period; contradictory
2146/// combinations silently no-opped). Two shapes:
2147///
2148/// * **reconcile** (`per_pc` / `per_target`) — desired-state: "each
2149///   pc (or one delegate) should have run this within `every`".
2150///   The poll period is system-generated ([`POLL_CRON`], every
2151///   minute) and no longer the operator's concern.
2152/// * **calendar** (`{ at, days }`) — a wall-clock time trigger
2153///   (#418 Phase 2, replacing the old raw-cron escape hatch). Fires
2154///   the whole target at the given time, no dedup. `at: "09:00"` +
2155///   `days` repeats; `at: "2026-06-10 09:00"` (a date+time) fires
2156///   exactly once. Evaluated in the schedule's top-level `tz`.
2157#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2158#[serde(rename_all = "snake_case")]
2159pub enum When {
2160    /// Fire at each targeted pc: `once` (kitting — succeed once,
2161    /// skip forever, forever catching brand-new / re-imaged pcs)
2162    /// or `{ every: <humantime> }` (patrol — re-arm per pc after
2163    /// the interval).
2164    PerPc(PerPolicy),
2165    /// Fire until **any** one pc of the target succeeds, then skip
2166    /// the whole target (`once`) or re-arm after `every`. Needs
2167    /// fleet-wide completion data, so it is backend-only —
2168    /// `runs_on: agent` + `per_target` is rejected by
2169    /// [`Schedule::validate`].
2170    PerTarget(PerPolicy),
2171    /// Calendar time trigger: `{ at: "09:00", days: [mon-fri] }`
2172    /// (repeating) or `{ at: "2026-06-10 09:00" }` (one-shot). Fires
2173    /// the whole target at that wall-clock time in the schedule's
2174    /// `tz` — no dedup, no cooldown.
2175    Calendar(CalendarSpec),
2176}
2177
2178/// Calendar time trigger (#418 Phase 2). `at` is either a time of
2179/// day (`"HH:MM"`, repeating — combine with `days`) or a full
2180/// date+time (`"YYYY-MM-DD HH:MM"`, a one-shot that fires once and
2181/// never again). Evaluated in the schedule's top-level `tz`.
2182#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2183#[serde(deny_unknown_fields)]
2184pub struct CalendarSpec {
2185    /// `"HH:MM"` (24h) for a repeating trigger, or
2186    /// `"YYYY-MM-DD HH:MM"` (hyphen / slash / `T` separators all
2187    /// accepted) for a one-shot. Parsed lazily —
2188    /// [`Schedule::validate`] rejects garbage at create time.
2189    pub at: String,
2190    /// Day-of-week filter for a time-of-day `at`: `["mon-fri"]`,
2191    /// `["mon","wed","fri"]`, … (passed verbatim to the cron DOW
2192    /// field, so ranges and names both work). Empty = every day.
2193    /// Must be empty when `at` carries a date (the date already
2194    /// pins the day).
2195    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2196    pub days: Vec<String>,
2197}
2198
2199/// Parsed `CalendarSpec.at`: the wall-clock minute/hour, plus the
2200/// date for a one-shot (`None` = repeating time-of-day).
2201struct ParsedAt {
2202    minute: u32,
2203    hour: u32,
2204    date: Option<chrono::NaiveDate>,
2205}
2206
2207impl CalendarSpec {
2208    /// Parse `at`: a date+time (`YYYY-MM-DD HH:MM`, hyphen / slash /
2209    /// `T` separators) is a one-shot; a bare `HH:MM` is repeating.
2210    fn parse_at(&self) -> Result<ParsedAt, String> {
2211        use chrono::Timelike;
2212        let s = self.at.trim();
2213        for fmt in ["%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y/%m/%d %H:%M"] {
2214            if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
2215                return Ok(ParsedAt {
2216                    minute: dt.minute(),
2217                    hour: dt.hour(),
2218                    date: Some(dt.date()),
2219                });
2220            }
2221        }
2222        if let Ok(t) = chrono::NaiveTime::parse_from_str(s, "%H:%M") {
2223            return Ok(ParsedAt {
2224                minute: t.minute(),
2225                hour: t.hour(),
2226                date: None,
2227            });
2228        }
2229        Err(format!(
2230            "when.at: unparseable '{}' (want HH:MM or YYYY-MM-DD HH:MM)",
2231            self.at
2232        ))
2233    }
2234
2235    /// Pre-flight check on the `days` tokens so a bad day name gives
2236    /// a `when.days:`-scoped error instead of croner's confusing
2237    /// "when.at lowered to invalid cron" (claude #432 review). Each
2238    /// token is a day name (`mon`..`sun`), a numeric DOW (`0`..`7`),
2239    /// `*`, or a `-` range of those.
2240    fn validate_days(&self) -> Result<(), String> {
2241        const NAMES: [&str; 7] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
2242        for tok in &self.days {
2243            // Report the whole token on a malformed range like `mon-`
2244            // (which would otherwise split to a cryptic empty part —
2245            // claude #432 follow-up).
2246            let invalid = |reason: &str| {
2247                Err(format!(
2248                    "when.days: invalid day token '{tok}' ({reason}; \
2249                     want mon..sun, 0-7, a range like mon-fri, or *)"
2250                ))
2251            };
2252            for part in tok.split('-') {
2253                let p = part.trim().to_ascii_lowercase();
2254                if p.is_empty() {
2255                    return invalid("empty range bound");
2256                }
2257                let ok = p == "*"
2258                    || NAMES.contains(&p.as_str())
2259                    || p.parse::<u8>().map(|n| n <= 7).unwrap_or(false);
2260                if !ok {
2261                    return invalid(&format!("'{part}' is not a day"));
2262                }
2263            }
2264        }
2265        Ok(())
2266    }
2267
2268    /// For a one-shot (`at` carries a date), the absolute instant it
2269    /// fires in `tz`. `None` for a repeating calendar. Used to warn
2270    /// about a one-shot whose date is already in the past (it would
2271    /// never fire).
2272    pub fn oneshot_instant(&self, tz: ScheduleTz) -> Option<chrono::DateTime<chrono::Utc>> {
2273        let p = self.parse_at().ok()?;
2274        let date = p.date?;
2275        let naive = date.and_hms_opt(p.hour, p.minute, 0)?;
2276        tz.naive_to_utc(naive)
2277    }
2278
2279    /// The wall-clock time-of-day this calendar fires at (`None` if
2280    /// `at` is unparseable — validate() guards that). Used to detect
2281    /// a calendar whose fire time can never fall inside its
2282    /// `constraints.window` (claude #452 review).
2283    pub fn fire_time(&self) -> Option<chrono::NaiveTime> {
2284        let p = self.parse_at().ok()?;
2285        chrono::NaiveTime::from_hms_opt(p.hour, p.minute, 0)
2286    }
2287
2288    /// Lower to the cron string the scheduler engine runs. Repeating
2289    /// → 6-field `0 {min} {hour} * * {dow}`; one-shot → 7-field
2290    /// `0 {min} {hour} {day} {month} * {year}` (a past year never
2291    /// fires — that's what makes it one-shot).
2292    fn to_cron(&self) -> Result<String, String> {
2293        use chrono::Datelike;
2294        let ParsedAt { minute, hour, date } = self.parse_at()?;
2295        match date {
2296            Some(d) => {
2297                if !self.days.is_empty() {
2298                    return Err(
2299                        "when.at with a date is a one-shot and cannot be combined with days".into(),
2300                    );
2301                }
2302                Ok(format!(
2303                    "0 {minute} {hour} {} {} * {}",
2304                    d.day(),
2305                    d.month(),
2306                    d.year()
2307                ))
2308            }
2309            None => {
2310                let dow = if self.days.is_empty() {
2311                    "*".to_string()
2312                } else {
2313                    self.validate_days()?;
2314                    self.days.join(",")
2315                };
2316                Ok(format!("0 {minute} {hour} * * {dow}"))
2317            }
2318        }
2319    }
2320}
2321
2322/// The timezone a schedule's wall-clock fields (`when.at`,
2323/// `active.{from,until}`) are evaluated in (#418 Phase 2).
2324#[derive(
2325    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
2326)]
2327#[serde(rename_all = "snake_case")]
2328pub enum ScheduleTz {
2329    /// The running host's local timezone — the agent's for
2330    /// `runs_on: agent`, the backend server's otherwise. Default.
2331    #[default]
2332    Local,
2333    /// UTC — for timezone-independent schedules.
2334    Utc,
2335}
2336
2337impl ScheduleTz {
2338    /// Interpret a naive (zoneless) datetime as being in this tz and
2339    /// convert to UTC. On a DST *fold* (the local time occurs twice
2340    /// when clocks go back) we pick `.earliest()` rather than
2341    /// rejecting it; `None` is reserved for a true DST *gap* (a local
2342    /// time that never exists). `Utc` is fixed-offset so neither ever
2343    /// happens; `Local` is whatever timezone the running host is set
2344    /// to and *can* hit a gap/fold on any DST-observing host — not
2345    /// just the JST we run today (gemini + claude #432 review).
2346    fn naive_to_utc(self, naive: chrono::NaiveDateTime) -> Option<chrono::DateTime<chrono::Utc>> {
2347        use chrono::TimeZone;
2348        match self {
2349            ScheduleTz::Utc => Some(chrono::DateTime::from_naive_utc_and_offset(
2350                naive,
2351                chrono::Utc,
2352            )),
2353            ScheduleTz::Local => chrono::Local
2354                .from_local_datetime(&naive)
2355                .earliest()
2356                .map(|dt| dt.with_timezone(&chrono::Utc)),
2357        }
2358    }
2359
2360    /// The wall-clock time-of-day `now` reads as in this tz — used by
2361    /// [`Constraints::allows`] to test a maintenance window
2362    /// (#418 Phase 3). `Utc` is the naive UTC time; `Local` is the
2363    /// running host's local time.
2364    fn wall_time(self, now: chrono::DateTime<chrono::Utc>) -> chrono::NaiveTime {
2365        match self {
2366            ScheduleTz::Utc => now.time(),
2367            ScheduleTz::Local => now.with_timezone(&chrono::Local).time(),
2368        }
2369    }
2370}
2371
2372/// `once` vs `{ every: <humantime> }` — shared by `per_pc` /
2373/// `per_target`. Untagged so the YAML stays the bare keyword or a
2374/// one-key map, nothing more ceremonial.
2375#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2376#[serde(untagged)]
2377pub enum PerPolicy {
2378    /// The bare string `once`: succeed once, then skip permanently
2379    /// (cooldown = infinity).
2380    Once(OnceLiteral),
2381    /// Re-arm after the humantime interval, e.g. `{ every: 6h }`.
2382    Every(EverySpec),
2383}
2384
2385/// Single-variant enum so serde accepts exactly the string `once`
2386/// (a free-form `String` would swallow typos like `onec`).
2387#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
2388#[serde(rename_all = "snake_case")]
2389pub enum OnceLiteral {
2390    Once,
2391}
2392
2393/// `{ every: <humantime> }`. Standalone struct (not an inline
2394/// struct variant) so `deny_unknown_fields` still bites under the
2395/// untagged [`PerPolicy`] — `{ evry: 6h }` is a parse error, not a
2396/// silently-ignored key.
2397#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
2398#[serde(deny_unknown_fields)]
2399pub struct EverySpec {
2400    /// Humantime interval (`10m`, `6h`, `1d`...). Parsed lazily —
2401    /// [`Schedule::validate`] rejects garbage at create time.
2402    pub every: String,
2403}
2404
2405impl PerPolicy {
2406    /// The cooldown this policy lowers to: `once` = `None`
2407    /// (permanent skip), `every` = the interval.
2408    fn cooldown(&self) -> Option<String> {
2409        match self {
2410            PerPolicy::Once(_) => None,
2411            PerPolicy::Every(EverySpec { every }) => Some(every.clone()),
2412        }
2413    }
2414}
2415
2416impl std::fmt::Display for When {
2417    /// Operator-facing one-liner (`per_pc once` / `per_pc every 6h`
2418    /// / `at 09:00 [mon-fri]` / `at 2026-06-10 09:00`) for log
2419    /// lines, audit payloads and the API's `ScheduleSummary`.
2420    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2421        let policy = |p: &PerPolicy| match p {
2422            PerPolicy::Once(_) => "once".to_string(),
2423            PerPolicy::Every(EverySpec { every }) => format!("every {every}"),
2424        };
2425        match self {
2426            When::PerPc(p) => write!(f, "per_pc {}", policy(p)),
2427            When::PerTarget(p) => write!(f, "per_target {}", policy(p)),
2428            When::Calendar(c) if c.days.is_empty() => write!(f, "at {}", c.at),
2429            When::Calendar(c) => write!(f, "at {} [{}]", c.at, c.days.join(",")),
2430        }
2431    }
2432}
2433
2434/// Optional validity window for a [`Schedule`] (#418 decision G).
2435/// Half-open `[from, until)`; either bound may be omitted. Bounds
2436/// are `YYYY-MM-DD` (= that day's 00:00 in the schedule's `tz`) or
2437/// full RFC3339 (offset is honored as-is, `tz` ignored). Kept as
2438/// strings so the JSON Schema the SPA editor consumes stays two
2439/// plain string fields, mirroring `jitter` / `starting_deadline`.
2440///
2441/// #418 Phase 2: bounds are evaluated in the schedule's top-level
2442/// `tz` (was UTC-only in Phase 1) so `tz: local` makes both the
2443/// calendar `at` AND the `active` window local — one consistent
2444/// timezone per schedule.
2445#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
2446#[serde(deny_unknown_fields)]
2447pub struct Active {
2448    /// Dormant before this instant.
2449    #[serde(default, skip_serializing_if = "Option::is_none")]
2450    pub from: Option<String>,
2451    /// Dormant from this instant on (exclusive).
2452    #[serde(default, skip_serializing_if = "Option::is_none")]
2453    pub until: Option<String>,
2454}
2455
2456impl Active {
2457    /// `skip_serializing_if` helper — an empty window means "always
2458    /// active" and is omitted from the wire format entirely.
2459    pub fn is_empty(&self) -> bool {
2460        self.from.is_none() && self.until.is_none()
2461    }
2462
2463    /// Parse one bound: RFC3339 first (offset honored, `tz`
2464    /// ignored), then bare `YYYY-MM-DD` (00:00 in `tz`).
2465    pub fn parse_bound(s: &str, tz: ScheduleTz) -> Result<chrono::DateTime<chrono::Utc>, String> {
2466        if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
2467            return Ok(dt.with_timezone(&chrono::Utc));
2468        }
2469        if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
2470            let midnight = d.and_hms_opt(0, 0, 0).expect("00:00:00 is always valid");
2471            return tz.naive_to_utc(midnight).ok_or_else(|| {
2472                format!("active: bound '{s}' falls in a DST gap for the schedule's tz")
2473            });
2474        }
2475        Err(format!(
2476            "active: unparseable bound '{s}' (want YYYY-MM-DD or RFC3339)"
2477        ))
2478    }
2479
2480    /// Is `now` inside the window? Unparseable bounds are treated
2481    /// as absent here (fail-open) — [`Schedule::validate`] is the
2482    /// place that rejects them loudly; this runs on every tick and
2483    /// must never panic on a stale KV blob.
2484    pub fn contains(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
2485        let bound = |s: &Option<String>| s.as_deref().and_then(|s| Self::parse_bound(s, tz).ok());
2486        if bound(&self.from).is_some_and(|from| now < from) {
2487            return false;
2488        }
2489        if bound(&self.until).is_some_and(|until| now >= until) {
2490            return false;
2491        }
2492        true
2493    }
2494}
2495
2496/// Operational constraints on a [`Schedule`] (#418 Phase 3). Where
2497/// [`Active`] decides *over what date range* a schedule is live,
2498/// `Constraints` decides *when, within an active period,* a fire is
2499/// allowed. Only `window` (a maintenance time-of-day window) so far;
2500/// `require` (env gates) and `max_concurrent` will join this struct
2501/// in later phases.
2502#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
2503#[serde(deny_unknown_fields)]
2504pub struct Constraints {
2505    /// `"HH:MM-HH:MM"` wall-clock window (evaluated in the schedule's
2506    /// `tz`). Fires outside it are skipped — mainly for reconcile
2507    /// cadences ("patrol every 6h, but only fire overnight") and
2508    /// daytime change-freezes. `start > end` crosses midnight
2509    /// (`"22:00-05:00"` = 22:00 through 05:00 next morning). Parsed
2510    /// lazily; [`Schedule::validate`] rejects garbage at create time.
2511    #[serde(default, skip_serializing_if = "Option::is_none")]
2512    pub window: Option<String>,
2513}
2514
2515impl Constraints {
2516    /// `skip_serializing_if` helper — empty constraints are omitted
2517    /// from the wire format entirely.
2518    pub fn is_empty(&self) -> bool {
2519        self.window.is_none()
2520    }
2521
2522    /// Parse `"HH:MM-HH:MM"` into `(start, end)`. Equal bounds are an
2523    /// error (a zero-width or all-day window is ambiguous — write no
2524    /// window for "always").
2525    pub fn parse_window(s: &str) -> Result<(chrono::NaiveTime, chrono::NaiveTime), String> {
2526        let (a, b) = s
2527            .split_once('-')
2528            .ok_or_else(|| format!("constraints.window: '{s}' must be 'HH:MM-HH:MM'"))?;
2529        let parse = |part: &str| {
2530            chrono::NaiveTime::parse_from_str(part.trim(), "%H:%M")
2531                .map_err(|e| format!("constraints.window: invalid time '{}': {e}", part.trim()))
2532        };
2533        let (start, end) = (parse(a)?, parse(b)?);
2534        if start == end {
2535            return Err(format!(
2536                "constraints.window: start and end are equal ('{s}'); omit window for 'always'"
2537            ));
2538        }
2539        Ok((start, end))
2540    }
2541
2542    /// Is a fire allowed at `now` (evaluated in `tz`)? No window =
2543    /// always allowed. Half-open `[start, end)`; `start > end`
2544    /// crosses midnight.
2545    ///
2546    /// **Fail-closed** on an unparseable window (returns `false`,
2547    /// gemini #452 review): a window is a *restrictive* constraint
2548    /// (change-freeze / overnight-only), so a corrupt one must NOT
2549    /// silently allow fires during the restricted hours. Bad windows
2550    /// are rejected at create time by [`Schedule::validate`]; this
2551    /// only bites a hand-edited KV blob, where blocking is the safe
2552    /// direction. The scheduler warns at register time
2553    /// ([`Schedule::bad_window`]) so a stuck schedule is diagnosable.
2554    /// The tick path never panics regardless.
2555    pub fn allows(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
2556        match self.window.as_deref() {
2557            // No window → always allowed.
2558            None => true,
2559            // Window set: membership, or fail-closed if unparseable
2560            // (`window_contains` returns None for a corrupt window).
2561            Some(_) => self.window_contains(tz.wall_time(now)).unwrap_or(false),
2562        }
2563    }
2564
2565    /// Membership of a wall-clock time-of-day in the window. `None`
2566    /// when there is no window or it's unparseable (callers decide
2567    /// the failure direction). `start > end` crosses midnight.
2568    fn window_contains(&self, t: chrono::NaiveTime) -> Option<bool> {
2569        let (start, end) = Self::parse_window(self.window.as_deref()?).ok()?;
2570        Some(if start <= end {
2571            start <= t && t < end
2572        } else {
2573            t >= start || t < end
2574        })
2575    }
2576}
2577
2578/// The system-generated poll cadence every reconcile-shaped `when`
2579/// lowers to. Operators never write this: the real inter-run
2580/// spacing is the `every` cooldown; this only bounds "how soon do
2581/// we notice somebody is due" (#418 decision B took the poll
2582/// period away from the operator).
2583pub const POLL_CRON: &str = "0 * * * * *";
2584
2585/// What a [`When`] lowers to — the exact (cron, mode, cooldown)
2586/// trio the pre-#418 engine ran on. Keeping the engine vocabulary
2587/// unchanged is what lets Phase 1 swap the operator surface without
2588/// touching the tick / dedup machinery.
2589pub struct Lowered {
2590    /// Cron handed to `tokio-cron-scheduler` — [`POLL_CRON`] for
2591    /// reconcile shapes, a 6/7-field cron for calendar shapes.
2592    pub cron: String,
2593    /// Dedup semantics for `decide_fire`.
2594    pub mode: ExecMode,
2595    /// Humantime re-arm interval (`None` = succeed once, skip
2596    /// forever).
2597    pub cooldown: Option<String>,
2598    /// Timezone to evaluate `cron` in (#418 Phase 2). The scheduler
2599    /// passes this to `Job::new_async_tz`. Reconcile shapes carry
2600    /// the schedule's tz too even though POLL_CRON is tz-agnostic,
2601    /// so the same value drives the `active`-window check.
2602    pub tz: ScheduleTz,
2603}
2604
2605impl Schedule {
2606    /// The error message if this schedule's `constraints.window` is
2607    /// set but unparseable, else `None`. The scheduler logs this at
2608    /// register time so a fail-closed (never-firing) schedule from a
2609    /// hand-edited KV blob is diagnosable (gemini #452 review).
2610    pub fn bad_window(&self) -> Option<String> {
2611        let w = self.constraints.window.as_deref()?;
2612        Constraints::parse_window(w).err()
2613    }
2614
2615    /// True when this is a `calendar` schedule whose fire time can
2616    /// never fall inside its `constraints.window` — the cron fires,
2617    /// the window check rejects it, and (firing only at that
2618    /// time-of-day) it effectively never runs. An easy misconfig to
2619    /// set up by accident; the scheduler warns at register time
2620    /// (claude #452 review). Reconcile shapes poll every minute, so
2621    /// they always catch the window opening and aren't affected.
2622    pub fn calendar_outside_window(&self) -> bool {
2623        let When::Calendar(c) = &self.when else {
2624            return false;
2625        };
2626        let Some(t) = c.fire_time() else {
2627            return false;
2628        };
2629        matches!(self.constraints.window_contains(t), Some(false))
2630    }
2631
2632    /// Lower the operator-facing `when` onto the engine vocabulary.
2633    /// Single seam shared by the backend scheduler and the agent's
2634    /// local scheduler so the two can never drift.
2635    pub fn lowered(&self) -> Lowered {
2636        let tz = self.tz;
2637        match &self.when {
2638            When::PerPc(p) => Lowered {
2639                cron: POLL_CRON.into(),
2640                mode: ExecMode::OncePerPc,
2641                cooldown: p.cooldown(),
2642                tz,
2643            },
2644            When::PerTarget(p) => Lowered {
2645                cron: POLL_CRON.into(),
2646                mode: ExecMode::OncePerTarget,
2647                cooldown: p.cooldown(),
2648                tz,
2649            },
2650            // `to_cron` only fails on a malformed `at` (rejected by
2651            // validate() at create time). For a hand-edited KV blob
2652            // that slipped past, emit a deliberately-invalid cron so
2653            // register()'s Job::new_async_tz fails → warn+skip,
2654            // rather than firing at the wrong time.
2655            When::Calendar(c) => Lowered {
2656                cron: c
2657                    .to_cron()
2658                    .unwrap_or_else(|_| "# invalid calendar at".into()),
2659                mode: ExecMode::EveryTick,
2660                cooldown: None,
2661                tz,
2662            },
2663        }
2664    }
2665
2666    /// Cross-field semantic checks that don't fit pure serde derive
2667    /// — the [`Manifest::validate`] counterpart (#418 decision F;
2668    /// pre-Phase-1 a broken schedule was accepted at create time
2669    /// and silently warn-skipped at tick time). Run at every create
2670    /// site: `kanade schedule create` (client-side) and
2671    /// `POST /api/schedules`. The job_id-exists check lives in the
2672    /// API handler instead — it needs the JOBS KV.
2673    pub fn validate(&self) -> Result<(), String> {
2674        if matches!(self.runs_on, RunsOn::Agent) && matches!(self.when, When::PerTarget(_)) {
2675            return Err(
2676                "when.per_target needs fleet-wide completion data and is backend-only; \
2677                 it cannot be combined with runs_on: agent (each agent self-schedules, \
2678                 so per-target dedup would be deduping across a target of 1)"
2679                    .into(),
2680            );
2681        }
2682        if let Some(cd) = self.lowered().cooldown.as_deref() {
2683            humantime::parse_duration(cd)
2684                .map_err(|e| format!("when.every: invalid duration '{cd}': {e}"))?;
2685        }
2686        if let When::Calendar(c) = &self.when {
2687            // Lower the calendar form to its cron (catches a bad `at`
2688            // and the date+days conflict), then validate that cron
2689            // with the same parser configuration tokio-cron-scheduler
2690            // 0.15 uses internally (croner, seconds required,
2691            // DOM-and-DOW both honored, year optional) — create-time
2692            // validation can never accept what register() rejects.
2693            let cron = c.to_cron()?;
2694            croner::parser::CronParser::builder()
2695                .seconds(croner::parser::Seconds::Required)
2696                .dom_and_dow(true)
2697                .build()
2698                .parse(&cron)
2699                .map_err(|e| format!("when.at lowered to invalid cron '{cron}': {e}"))?;
2700        }
2701        // The other humantime strings on the schedule (claude #419
2702        // review): runtime degrades gracefully on both (bad jitter →
2703        // silent no-op, bad starting_deadline → warn + skipped tick),
2704        // but "rejected at create time" should cover every field the
2705        // operator can typo, not just `when`.
2706        if let Some(j) = &self.plan.jitter {
2707            humantime::parse_duration(j)
2708                .map_err(|e| format!("jitter: invalid duration '{j}': {e}"))?;
2709        }
2710        if let Some(sd) = &self.starting_deadline {
2711            humantime::parse_duration(sd)
2712                .map_err(|e| format!("starting_deadline: invalid duration '{sd}': {e}"))?;
2713        }
2714        let from = self
2715            .active
2716            .from
2717            .as_deref()
2718            .map(|s| Active::parse_bound(s, self.tz))
2719            .transpose()?;
2720        let until = self
2721            .active
2722            .until
2723            .as_deref()
2724            .map(|s| Active::parse_bound(s, self.tz))
2725            .transpose()?;
2726        if let (Some(f), Some(u)) = (from, until) {
2727            if f >= u {
2728                return Err(format!(
2729                    "active.from ({}) must be strictly before active.until ({})",
2730                    self.active.from.as_deref().unwrap_or_default(),
2731                    self.active.until.as_deref().unwrap_or_default(),
2732                ));
2733            }
2734        }
2735        // #418 Phase 3: a bad maintenance window is rejected at create
2736        // time (parse_window also catches equal bounds).
2737        if let Some(w) = self.constraints.window.as_deref() {
2738            Constraints::parse_window(w)?;
2739        }
2740        Ok(())
2741    }
2742}
2743
2744fn default_true() -> bool {
2745    true
2746}