Skip to main content

kanade_shared/
manifest.rs

1use serde::{Deserialize, Serialize};
2
3use crate::wire::{FinalizeCommand, 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/// #492: these types are READ fleet-wide (agents decode them from
14/// BUCKET_JOBS / BUCKET_SCHEDULES and inside live Commands), so they
15/// must tolerate unknown fields — `deny_unknown_fields` here made a
16/// gradually-upgrading fleet's OLD agents reject the whole object
17/// the moment a newer backend added any field. Operator typo
18/// protection (the old reason for the attribute) lives at the WRITE
19/// boundaries instead: `kanade job/schedule create` and the backend
20/// POST extractor parse via [`crate::strict`], which rejects unknown
21/// keys with their full paths. The wire rule: new fields always get
22/// `#[serde(default)]` (+ `skip_serializing_if` while old readers
23/// may still be strict).
24#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
25pub struct Manifest {
26    pub id: String,
27    pub version: String,
28    #[serde(default)]
29    pub description: Option<String>,
30    pub execute: Execute,
31    #[serde(default)]
32    pub require_approval: bool,
33    /// Opt-in marker that this job produces a JSON inventory fact
34    /// payload on stdout. When present, the backend's results
35    /// projector parses `ExecResult.stdout` as JSON and upserts an
36    /// `inventory_facts` row keyed by `(pc_id, manifest.id)`. The
37    /// `display` sub-config drives the SPA's Inventory page render.
38    #[serde(default)]
39    pub inventory: Option<InventoryHint>,
40    /// Issue #246: opt-in marker that this job emits per-line
41    /// observability events on stdout (one JSON `ObsEvent` per
42    /// newline). When present, the agent — after the script exits
43    /// successfully — parses each non-empty stdout line as an
44    /// `ObsEvent`, publishes it on `obs.<pc_id>` via the
45    /// `obs_outbox`, and (intentionally) **omits the stdout from
46    /// the `ExecResult`** so the timeline data doesn't double up
47    /// in `execution_results.stdout` (which would multiply rows
48    /// by ~50/day/PC of noise).
49    ///
50    /// Distinct from `inventory:` (single JSON object → projector
51    /// upsert) — events are append-only timeline points consumed
52    /// by the dedicated `obs_events` table.
53    #[serde(default)]
54    pub emit: Option<EmitConfig>,
55    /// #290: opt-in marker that this job is an operator-defined
56    /// **health check** whose result feeds the Client App's Health
57    /// tab over KLP (`StateSnapshot.checks`). The script prints a
58    /// free-form JSON object on stdout (like any inventory job); the
59    /// agent reads the [`CheckHint::status_field`] value dynamically
60    /// into a [`crate::ipc::state::Check`] named `check.name`.
61    /// Cadence / windows / conditions come from
62    /// the job's Schedule (exactly like inventory) — there is
63    /// deliberately no interval here. **Composes with `inventory:` and
64    /// `collect:`** (#821): each reads its own `#KANADE-<KIND>`-fenced
65    /// stdout block, so one job can drive a check, project inventory
66    /// facts, and collect files in a single run. Only `emit:` (NDJSON
67    /// stdout) is incompatible. A check-only job may skip the fence
68    /// (whole stdout is the JSON); a multi-hint job fences each block.
69    #[serde(default)]
70    pub check: Option<CheckHint>,
71    /// #219: opt-in marker that this job COLLECTS files into a bundle.
72    /// The script does the collection work and prints a single JSON
73    /// object on stdout carrying a `files` array of paths (the field
74    /// name is [`CollectHint::files_field`], default `"files"`); the
75    /// agent — after the script exits successfully — zips those files,
76    /// uploads the archive to the `OBJECT_COLLECTIONS` Object Store
77    /// bucket (key `<pc_id>/<job_id>/<timestamp>.zip`), and records the
78    /// key in [`crate::wire::ExecResult::collect_object`]. The operator
79    /// downloads bundles from the SPA Collect page.
80    ///
81    /// Like `inventory:` / `check:` this reads a JSON object from stdout.
82    /// #821: it reads its own `#KANADE-COLLECT-BEGIN/END`-fenced block,
83    /// so it **composes with `inventory:` / `check:`** (and a user
84    /// message) on one stdout — only `emit:` (NDJSON) is incompatible
85    /// (enforced in [`Manifest::validate`]). A collect-only job may skip
86    /// the fence. It also composes with `client:` — a `collect:` +
87    /// `client:` job lets an end user trigger a collection from the
88    /// Client App (the same-host agent runs it).
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub collect: Option<CollectHint>,
91    /// #720: opt-in declarative aggregation over `obs_events` that drives
92    /// the SPA **Analytics** page. Unlike the other hints this one never
93    /// touches stdout and is never delivered to the agent — it's a pure
94    /// *read spec* the backend reads from `BUCKET_JOBS` at query time and
95    /// turns into `json_extract` aggregation SQL. Each entry is one widget
96    /// (a `dashboard:` tab groups them); `scope:` selects per-PC vs
97    /// fleet-wide rollup. Because it consumes nothing at run time it
98    /// composes with every other hint (typically paired with `emit:`,
99    /// which produces the events it reads). See [`AggregateWidget`].
100    ///
101    /// New field ⇒ #492 wire rule (`default` + `skip_serializing_if`).
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub aggregate: Option<Vec<AggregateWidget>>,
104    /// v0.26: Layer 2 staleness policy (SPEC.md §2.6.2). Controls
105    /// what the agent does at fire time when it can't verify the
106    /// `script_current` / `script_status` KV values are fresh —
107    /// especially relevant for `runs_on: agent` schedules where
108    /// the agent may fire from cache while offline. Defaults to
109    /// `Staleness::Cached` (silently use cached values), which
110    /// matches every pre-v0.26 Manifest.
111    #[serde(default)]
112    pub staleness: Staleness,
113    /// #291: opt-in marker that this job is offered to **end users**
114    /// in the Client App's job tabs over KLP (`jobs.list` →
115    /// `jobs.execute`). Parallel to [`inventory`] / [`check`] /
116    /// [`emit`]: the block's mere presence is the opt-in, and it
117    /// groups the end-user presentation fields (name / category /
118    /// icon) that only make sense for a user-facing job. `None`
119    /// (the default) ⇒ an operator-only job — inventory, checks,
120    /// scheduled maintenance — that never surfaces in the catalog.
121    ///
122    /// The agent re-reads this at every `jobs.list` / `jobs.execute`
123    /// (SPEC §2.1), so removing the block takes a job out of a
124    /// running client on its next action.
125    ///
126    /// [`inventory`]: Manifest::inventory
127    /// [`check`]: Manifest::check
128    /// [`emit`]: Manifest::emit
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub client: Option<ClientHint>,
131    /// Free-form operator taxonomy for the Jobs catalog. Purely a
132    /// SPA-side organisational aid — agents / scheduler / projector
133    /// never read it — so it carries no runtime semantics and any
134    /// string is allowed (`security`, `weekly`, `windows`, …). Jobs
135    /// cross-cut (a `check-bitlocker` is at once a health-check, a
136    /// security control, and Windows-specific), which is why this is
137    /// a multi-valued list rather than the single closed-enum
138    /// [`ClientHint::category`] (whose values are the end-user Client
139    /// App's tabs, a different concern). The operator Jobs page groups
140    /// rows by id-prefix for free; tags add the orthogonal filter axis
141    /// prefixes can't express.
142    ///
143    /// Empty by default (the overwhelming majority of jobs), and a
144    /// new field, so it follows the #492 wire rule: `serde(default)`
145    /// plus `skip_serializing_if` keep gradually-upgrading old readers
146    /// from tripping over its absence / presence.
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub tags: Vec<String>,
149    /// GitOps provenance (#678) — see [`RepoOrigin`]. Stamped by
150    /// `kanade job create` when the source YAML lives inside a Git work
151    /// tree, so the SPA can render the job read-only and point edits
152    /// back at the repo instead of letting a ClickOps edit silently
153    /// diverge from Git (SPEC design principle #3: 設定駆動 YAML + Git).
154    /// `None` for SPA-born jobs and for manifests applied from outside
155    /// any Git repo. Purely informational: agents / scheduler /
156    /// projector never read it, and it survives `script_file:` inlining
157    /// (it's orthogonal to the exactly-one-of script-source rule). New
158    /// field ⇒ #492 wire rule (`default` + `skip_serializing_if`).
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub origin: Option<RepoOrigin>,
161    /// Job-generic post-step hook. When set, the agent runs this script
162    /// AFTER the main `execute:` script exits cleanly (and, for a
163    /// `collect:` job, after the bundle finishes uploading), so the
164    /// operator can delete / move / notify based on what the step
165    /// produced. Best-effort: a finalize failure is logged but never
166    /// fails the run — the upload (if any) already succeeded.
167    ///
168    /// For `collect:` jobs the agent injects the environment variable
169    /// `KANADE_COLLECT_RESULT` — a JSON object
170    /// `{ "ok": true, "bundles": [ { "key", "uploaded", "files": [...] } ] }`
171    /// — so the hook acts on exactly the files that were bundled and
172    /// uploaded (e.g. deletes only the `uploaded` ones). Composes with
173    /// every hint. New field ⇒ #492 wire rule (`default` +
174    /// `skip_serializing_if`).
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub finalize: Option<FinalizeSpec>,
177}
178
179/// GitOps provenance for a repo-managed YAML artifact — a [`Manifest`]
180/// (#678) or a [`Schedule`] (#695). Populated by `kanade job create` /
181/// `kanade schedule create` from the Git context of the source YAML;
182/// the SPA reads it to render Git-managed entries read-only and link
183/// the operator back at the repo. Never consulted by the runtime.
184#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
185pub struct RepoOrigin {
186    /// Repo-relative path of the source YAML — the primary edit target
187    /// the SPA surfaces (e.g. `configs/jobs/foo.yaml`). Forward slashes
188    /// regardless of the authoring OS.
189    pub path: String,
190    /// `origin` remote URL, when the repo has one. Lets the SPA turn
191    /// `path` into a clickable link; `None` for remote-less repos.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub repo: Option<String>,
194    /// Repo-relative path of the `script_file:` a job manifest inlined,
195    /// when it used one — a secondary pointer shown beneath `path`.
196    /// Always `None` for schedules (they carry no script).
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub script_file: Option<String>,
199}
200
201/// "Who + how + when-to-stagger" — the fanout-plan side of an exec.
202/// Used both as the POST `/api/exec/{job_id}` body and as the embedded
203/// `target` / `rollout` / `jitter` slot on [`Schedule`]. Centralising
204/// here keeps the validation + serialisation logic in one place.
205#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
206pub struct FanoutPlan {
207    #[serde(default)]
208    pub target: Target,
209    /// Optional wave rollout — when present, the backend publishes
210    /// each wave's group subject on its own delay schedule instead
211    /// of fanning out the `target` block in one go. `target` then
212    /// only labels the deploy for the audit log.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub rollout: Option<Rollout>,
215    /// Optional humantime jitter; agent uses it to randomise
216    /// execution start. Lives here (not on the script) so different
217    /// schedules / ad-hoc fires of the same job can pick different
218    /// stagger windows.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub jitter: Option<String>,
221    /// Absolute time the scheduler stamps on each emitted Command
222    /// when this exec was driven by a [`Schedule`] with
223    /// `starting_deadline`. Agents receiving a Command after this
224    /// instant publish a synthetic skipped-result instead of
225    /// running the script. `None` (default) = no deadline / catch
226    /// up whenever delivered. Operators don't usually set this
227    /// directly — the scheduler computes it from `tick_at +
228    /// starting_deadline`.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub deadline_at: Option<chrono::DateTime<chrono::Utc>>,
231}
232
233/// Sentinel lines that fence a hint's structured JSON payload inside an
234/// otherwise human-readable job stdout. Each stdout-reading hint
235/// (`inventory:` / `check:` / `collect:`) has its OWN `#KANADE-<KIND>-
236/// BEGIN`/`-END` pair, so one job can carry several of them at once
237/// (and/or a user-facing message) on its single stdout stream — every
238/// consumer extracts only its own block via [`fenced_payload`].
239///
240/// Originated for inventory (#793): a `client:` job couldn't put both a
241/// friendly message and a JSON object on one stdout (the Client App
242/// renders stdout verbatim, the projector needs JSON). #821 generalised
243/// it so inventory / check / collect can coexist. `emit:` is the
244/// exception — its stdout is line-delimited NDJSON consumed whole, so it
245/// never fences and never coexists with the others.
246///
247/// A job carrying a SINGLE hint may still skip the fence —
248/// [`fenced_payload`] falls back to the whole stdout — but a job
249/// COMBINING hints must fence each block (else every consumer would try
250/// to parse the same whole stdout).
251pub const INVENTORY_BLOCK_BEGIN: &str = "#KANADE-INVENTORY-BEGIN";
252/// Closing marker — see [`INVENTORY_BLOCK_BEGIN`].
253pub const INVENTORY_BLOCK_END: &str = "#KANADE-INVENTORY-END";
254/// Check-payload opening marker — see [`INVENTORY_BLOCK_BEGIN`].
255pub const CHECK_BLOCK_BEGIN: &str = "#KANADE-CHECK-BEGIN";
256/// Check-payload closing marker.
257pub const CHECK_BLOCK_END: &str = "#KANADE-CHECK-END";
258/// Collect-payload opening marker — see [`INVENTORY_BLOCK_BEGIN`].
259pub const COLLECT_BLOCK_BEGIN: &str = "#KANADE-COLLECT-BEGIN";
260/// Collect-payload closing marker.
261pub const COLLECT_BLOCK_END: &str = "#KANADE-COLLECT-END";
262
263/// Extract a hint's fenced block when the `begin` marker is present, else
264/// `None`. An unterminated fence (closing marker missing, e.g. truncated
265/// output) takes everything after the opener. Trimmed so surrounding
266/// message text / whitespace never reaches the JSON parser.
267pub fn fenced_payload_if_present<'a>(stdout: &'a str, begin: &str, end: &str) -> Option<&'a str> {
268    let b = find_line_marker(stdout, begin)?;
269    let after = &stdout[b + begin.len()..];
270    let inner = match find_line_marker(after, end) {
271        Some(e) => &after[..e],
272        None => after,
273    };
274    Some(inner.trim())
275}
276
277/// True if stdout carries ANY `#KANADE-<KIND>-BEGIN` fence at a line
278/// start — i.e. the script opted into fenced output. Used to decide
279/// whether a missing fence means "single-hint, use the whole stdout" or
280/// "multi-hint author error / truncation, this hint just has no block".
281pub fn has_any_hint_fence(stdout: &str) -> bool {
282    [
283        INVENTORY_BLOCK_BEGIN,
284        CHECK_BLOCK_BEGIN,
285        COLLECT_BLOCK_BEGIN,
286    ]
287    .iter()
288    .any(|m| find_line_marker(stdout, m).is_some())
289}
290
291/// Extract one hint's JSON payload from a job's stdout. When the hint's
292/// own `#KANADE-<KIND>` fence is present, return that block. When it's
293/// absent, fall back to the WHOLE stdout only for an unfenced (single-
294/// hint) job; if any OTHER hint's fence is present (#821 multi-hint
295/// output) return `""` instead — the script opted into fences but this
296/// block is missing (author error or truncation), so this consumer must
297/// NOT grab a sibling hint's block. An empty payload fails the consumer's
298/// JSON parse and degrades to "no data for this hint", never cross-parse.
299pub fn fenced_payload<'a>(stdout: &'a str, begin: &str, end: &str) -> &'a str {
300    if let Some(p) = fenced_payload_if_present(stdout, begin, end) {
301        return p;
302    }
303    if has_any_hint_fence(stdout) {
304        ""
305    } else {
306        stdout.trim()
307    }
308}
309
310/// Inventory's fenced payload — [`fenced_payload`] with the inventory
311/// markers. Kept as a named helper for the projector call site.
312pub fn inventory_payload(stdout: &str) -> &str {
313    fenced_payload(stdout, INVENTORY_BLOCK_BEGIN, INVENTORY_BLOCK_END)
314}
315
316/// Find `needle` only where it begins a line (start of `hay` or right
317/// after a `\n`). Anchoring to line start means a script echoing the
318/// literal sentinel mid-message (e.g. printing a command name) can't
319/// false-trigger the fence (Claude #793).
320fn find_line_marker(hay: &str, needle: &str) -> Option<usize> {
321    if hay.starts_with(needle) {
322        return Some(0);
323    }
324    hay.find(&format!("\n{needle}")).map(|p| p + 1)
325}
326
327/// Manifest sub-section: how the SPA should render the inventory
328/// facts this job produces. Each field name (`field`) is a top-level
329/// key in the stdout JSON, e.g. `hostname`, `ram_gb`.
330///
331/// Two render modes:
332///   * `display` — vertical "field / value" per PC, used by the
333///     `/inventory?pc=<id>` detail view. ALL columns the operator
334///     wants visible on the detail page.
335///   * `summary` — horizontal table across the fleet (row = PC,
336///     column = field) on `/inventory`. Optional; when omitted the
337///     SPA falls back to `display`, but operators usually want a
338///     trimmer "hostname / OS / CPU / RAM" set for the fleet view.
339#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
340pub struct InventoryHint {
341    /// Detail-view columns, in order.
342    pub display: Vec<DisplayField>,
343    /// Optional fleet-list columns (row = PC). Defaults to `display`
344    /// when omitted, but operators usually pick a 3-5 column subset.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub summary: Option<Vec<DisplayField>>,
347    /// v0.31 / #40: payload arrays that should be exploded into
348    /// per-element rows of a derived SQLite table. Lets operators
349    /// answer cross-PC questions ("which PCs still have Chrome <
350    /// 120?", "C: >90% full") with normal SQL filters + indexes
351    /// instead of grepping JSON. The projector creates the derived
352    /// table on register and replaces this PC's rows on each result
353    /// (DELETE WHERE pc_id=? AND job_id=? + bulk INSERT). See
354    /// [`ExplodeSpec`] for the per-spec schema.
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub explode: Option<Vec<ExplodeSpec>>,
357    /// v0.35 / #93: top-level scalar fields whose changes the
358    /// projector logs to `inventory_history` (one event per
359    /// changed field per scan). Pairs with `explode[].track_history`
360    /// — that covers array elements; this covers single-valued
361    /// fields like `ram_bytes` / `os_version` / `cpu_model` /
362    /// `os_build` that operators want to track for "did the RAM
363    /// get upgraded?" / "when did Win 11 land on this PC?" /
364    /// "BIOS / firmware bumped?" questions. Field name = `field_path`
365    /// in the history row, `identity_json` is NULL, `before_json`
366    /// / `after_json` each carry `{"value": <prior or new value>}`.
367    /// First-ever observation of a scalar (no prior facts row)
368    /// emits `added`; subsequent value changes emit `changed`. No
369    /// `removed` events — a scalar disappearing from the payload
370    /// is rare and the operator can still see the last value via
371    /// the `before_json` of the most recent change.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub history_scalars: Option<Vec<String>>,
374}
375
376/// Manifest sub-section (#290): marks a job as an operator-defined
377/// **health check**. Parallel to [`InventoryHint`] / `EmitConfig`.
378/// The stdout contract is a free-form JSON object (same as any
379/// inventory job) from which the agent reads `status_field` /
380/// `detail_field` to build the KLP [`crate::ipc::state::Check`] shown
381/// on the Client App's Health tab.
382///
383/// There is deliberately **no timing field** — when / how often /
384/// in which window a check runs is driven by the job's Schedule,
385/// exactly like inventory jobs, so operators get the full `when:` /
386/// rollout / `runs_on` expressiveness for free.
387///
388/// A check's stdout is a **free-form inventory object** (arbitrary
389/// key/value pairs + arrays) — same as any inventory job — that also
390/// carries a status field. `check:` adds only the health semantics on
391/// top: which field is the ok/warn/fail/unknown status, an optional
392/// one-line summary field, and a remediation job. Everything else
393/// (rich per-PC detail, `explode` sub-tables like a software list) is
394/// driven by a co-present [`InventoryHint`] and rendered with the
395/// SAME display logic the SPA Inventory page uses — on the Client App
396/// too. This keeps checks maximally expressive without a bespoke
397/// payload type.
398#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
399pub struct CheckHint {
400    /// Stable check id → [`Check.name`](crate::ipc::state::Check),
401    /// the SPA/Client React key + analytics label. Unique within the
402    /// fleet's check set. Machine-friendly slug (`disk_space`,
403    /// `defender_rtp`); for the human-facing row title see [`label`].
404    ///
405    /// [`label`]: CheckHint::label
406    pub name: String,
407    /// Optional human-facing display title →
408    /// [`Check.label`](crate::ipc::state::Check). The Client App's
409    /// Health tab and the operator SPA's Compliance page render this
410    /// instead of the [`name`](CheckHint::name) slug when set
411    /// (`"ウイルス対策のリアルタイム保護"` reads better than
412    /// `defender_rtp`). Falls back to the slug when absent, so it's
413    /// purely additive. Author it in the check's language — there's no
414    /// per-locale variant; checks are operator-defined per fleet.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub label: Option<String>,
417    /// Top-level stdout field whose string value
418    /// (`ok`/`warn`/`fail`/`unknown`) becomes the Health-tab light
419    /// ([`CheckStatus`](crate::ipc::state::CheckStatus)). Defaults to
420    /// `"status"`; a missing / unparseable value → `unknown`.
421    #[serde(default = "default_status_field")]
422    pub status_field: String,
423    /// Top-level stdout field used as the Health-tab row's one-line
424    /// summary. Defaults to `"detail"`; absent in the payload → no
425    /// detail line (the rich breakdown lives in the inventory view).
426    #[serde(default = "default_detail_field")]
427    pub detail_field: String,
428    /// Optional remediation job id →
429    /// [`Check.troubleshoot`](crate::ipc::state::Check). The Client
430    /// App shows a "修復する" button when present; that job must be
431    /// `user_invokable`.
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub troubleshoot: Option<String>,
434    /// #290 PR-E: when `true` (default), the backend also projects this
435    /// check's `status` / `detail` into the `check_status` table so the
436    /// operator SPA gets a fleet-wide compliance view for free — no
437    /// `inventory:` block needed. Set `fleet: false` for a client-only
438    /// check the operator doesn't want surfaced across the fleet.
439    #[serde(default = "default_fleet")]
440    pub fleet: bool,
441    /// Optional auto-notification on a compliance transition. When set, the
442    /// backend publishes an end-user notification the moment this check
443    /// transitions *into* one of [`CheckAlert::on`] (e.g. ok → fail) — to
444    /// the failing PC's user and/or operator groups. Fired once per
445    /// transition (not on every poll). Requires `fleet: true` (the alert
446    /// rides the same projection that fills `check_status`).
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub alert: Option<CheckAlert>,
449}
450
451/// Auto-notification rule for a [`CheckHint`] (compliance alerting). When a
452/// check's status transitions into one of [`on`](Self::on), the backend
453/// publishes a notification to the failing PC's user
454/// ([`notify_user`](Self::notify_user)) and/or operator groups
455/// ([`notify_groups`](Self::notify_groups)). Deliberately config-driven:
456/// who gets told, how loud, and the wording all live in the manifest, not
457/// hardcoded in the backend.
458#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
459pub struct CheckAlert {
460    /// Statuses that fire the alert on *transition into* them (a check that
461    /// stays failing doesn't re-alert every poll). Defaults to `[fail]`.
462    /// `ok` is not representable — [`CheckAlertStatus`] has no `Ok` variant,
463    /// so a YAML `on: [ok]` fails to deserialize (before `validate()` is
464    /// even reached); "recovered" notifications are out of scope.
465    #[serde(default = "default_alert_on")]
466    pub on: Vec<CheckAlertStatus>,
467    /// Notify the user(s) on the failing PC (`notifications.pc.<pc_id>`).
468    #[serde(default)]
469    pub notify_user: bool,
470    /// Notify these operator groups (`notifications.group.<name>`).
471    #[serde(default, skip_serializing_if = "Vec::is_empty")]
472    pub notify_groups: Vec<String>,
473    /// Notification priority (colour/label only — toasting is the separate
474    /// `toast` flag). Defaults to `warn`.
475    #[serde(default = "default_alert_priority")]
476    pub priority: crate::ipc::notifications::NotificationPriority,
477    /// Require the recipient to click 確認 to dismiss.
478    #[serde(default)]
479    pub require_ack: bool,
480    /// Surface an OS toast (launches a closed Client App, Action Center
481    /// while locked). Recommended `true` for `notify_user` so a
482    /// non-emergency "your PC is non-compliant" nudge still reaches a user
483    /// whose app is closed.
484    #[serde(default)]
485    pub toast: bool,
486    /// Also send the alert by email, to every address mapped to the
487    /// `notify_groups` (via the `group_contacts` KV, edited on the SPA
488    /// Groups page). Opt-in: defaults to `false`, so an existing alert
489    /// never starts emailing on its own. Requires `notify_groups` to be
490    /// non-empty (there is no per-PC user email) and the backend's
491    /// `[mail]` config to be present; otherwise the email is a logged
492    /// no-op while the in-app/toast notification still fires.
493    #[serde(default)]
494    pub email: bool,
495    /// Notification title (required). May use the same `{…}` placeholders
496    /// as [`body`](Self::body).
497    pub title: String,
498    /// Notification body template. Placeholders: `{pc_id}`, `{name}` (check
499    /// slug), `{label}` (check label, falls back to slug), `{status}`,
500    /// `{detail}` (the check's one-line summary), `{last_logon}` (the PC's
501    /// last sign-in account). Absent → empty body.
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub body: Option<String>,
504}
505
506/// A check status that can trigger a [`CheckAlert`]. Mirrors the
507/// projected `check_status.status` values minus `ok` (alerting on `ok` is
508/// rejected at validation).
509#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
510#[serde(rename_all = "snake_case")]
511pub enum CheckAlertStatus {
512    Warn,
513    Fail,
514    Unknown,
515}
516
517impl CheckAlertStatus {
518    /// The wire string, matching the projected `check_status.status`.
519    pub fn as_str(self) -> &'static str {
520        match self {
521            Self::Warn => "warn",
522            Self::Fail => "fail",
523            Self::Unknown => "unknown",
524        }
525    }
526}
527
528fn default_alert_on() -> Vec<CheckAlertStatus> {
529    vec![CheckAlertStatus::Fail]
530}
531
532fn default_alert_priority() -> crate::ipc::notifications::NotificationPriority {
533    crate::ipc::notifications::NotificationPriority::Warn
534}
535
536fn default_status_field() -> String {
537    "status".to_string()
538}
539
540fn default_detail_field() -> String {
541    "detail".to_string()
542}
543
544fn default_fleet() -> bool {
545    true
546}
547
548fn default_files_field() -> String {
549    "files".to_string()
550}
551
552/// Fallback cap on a collect bundle's total input size when the
553/// manifest's `collect.max_size` is unset. 50 MB (decimal).
554pub const DEFAULT_COLLECT_MAX_SIZE: u64 = 50 * 1_000_000;
555
556/// Manifest sub-section (#219): marks a job as a **file collector** and
557/// carries how the collected bundle presents in the SPA. Parallel to
558/// [`InventoryHint`] / [`CheckHint`] — the block's presence is the
559/// opt-in. The script prints a single JSON object on stdout whose
560/// [`files_field`](CollectHint::files_field) key holds an array of file
561/// paths to bundle (env vars are expanded); the agent zips them and
562/// uploads to `OBJECT_COLLECTIONS`. See [`Manifest::collect`].
563#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
564pub struct CollectHint {
565    /// Operator/end-user-facing title for the collection, shown as the
566    /// bundle's heading on the SPA Collect page (and the Client App row
567    /// when paired with `client:`). Required; validated non-empty.
568    pub name: String,
569    /// Optional one-line description of what the bundle contains.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub description: Option<String>,
572    /// Human-readable cap on the bundle's total input size
573    /// (`"50MB"`, `"500KB"`, `"1GiB"`). The agent refuses to build a
574    /// bundle whose listed files exceed this. `None` ⇒
575    /// [`DEFAULT_COLLECT_MAX_SIZE`]. Parsed by [`parse_size_bytes`];
576    /// [`Manifest::validate`] rejects an unparseable value at create
577    /// time.
578    ///
579    /// Note: this bounds the **uncompressed** bytes the agent reads off
580    /// disk, not the resulting zip. Text logs compress well, so the
581    /// download is usually much smaller; many tiny files add a little
582    /// per-entry zip overhead. Read it as "how much the agent reads +
583    /// packs", not "the exact download size".
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub max_size: Option<String>,
586    /// Top-level stdout JSON key holding the array of file paths to
587    /// bundle. Defaults to `"files"`.
588    #[serde(default = "default_files_field")]
589    pub files_field: String,
590}
591
592impl CollectHint {
593    /// The effective size cap in bytes — the parsed `max_size` or
594    /// [`DEFAULT_COLLECT_MAX_SIZE`] when unset. Assumes `max_size` (if
595    /// present) already passed [`Manifest::validate`]; falls back to the
596    /// default on a parse error rather than panicking on the fire path.
597    pub fn max_size_bytes(&self) -> u64 {
598        match &self.max_size {
599            Some(s) => parse_size_bytes(s).unwrap_or(DEFAULT_COLLECT_MAX_SIZE),
600            None => DEFAULT_COLLECT_MAX_SIZE,
601        }
602    }
603}
604
605/// Parse a human-readable byte size (`"50MB"`, `"500 KB"`, `"1GiB"`,
606/// `"1024"`). Decimal units (KB/MB/GB) are 1000-based; binary units
607/// (KiB/MiB/GiB) are 1024-based; a bare number (or `B`) is bytes.
608/// Case-insensitive. Shared by `collect.max_size` validation and the
609/// agent's bundle-size enforcement.
610pub fn parse_size_bytes(s: &str) -> Result<u64, String> {
611    let t = s.trim();
612    if t.is_empty() {
613        return Err("size must not be empty".to_string());
614    }
615    let split = t.find(|c: char| !c.is_ascii_digit()).unwrap_or(t.len());
616    let (num_str, unit_raw) = t.split_at(split);
617    if num_str.is_empty() {
618        return Err(format!("size '{s}': missing leading number"));
619    }
620    let num: u64 = num_str
621        .parse()
622        .map_err(|_| format!("size '{s}': bad number '{num_str}'"))?;
623    let mult: u64 = match unit_raw.trim().to_ascii_lowercase().as_str() {
624        "" | "b" => 1,
625        "kb" => 1_000,
626        "mb" => 1_000_000,
627        "gb" => 1_000_000_000,
628        "kib" => 1024,
629        "mib" => 1024 * 1024,
630        "gib" => 1024 * 1024 * 1024,
631        other => {
632            return Err(format!(
633                "size '{s}': unknown unit '{other}' (use B/KB/MB/GB/KiB/MiB/GiB)"
634            ));
635        }
636    };
637    num.checked_mul(mult)
638        .ok_or_else(|| format!("size '{s}': overflow"))
639}
640
641/// Manifest sub-section (#291): marks a job as **user-invokable**
642/// from the Client App and carries how it presents to the end user.
643/// Parallel to [`InventoryHint`] / [`CheckHint`] / `EmitConfig` —
644/// the block's presence is the opt-in (no separate boolean), and its
645/// required fields (`name`, `category`) are enforced by serde at
646/// parse time, so a half-filled catalog entry fails
647/// `kanade job create` instead of rendering a nameless / tab-less row.
648///
649/// The agent maps this 1:1 into the KLP
650/// [`UserInvokableJob`](crate::ipc::jobs::UserInvokableJob) wire shape
651/// that `jobs.list` returns; the Client App renders one row per job in
652/// the tab named by `category`.
653#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
654pub struct ClientHint {
655    /// End-user-facing title for the job row. The operator-internal
656    /// `Manifest::id` slug is rarely what an end user should read, so
657    /// this is required (and validated non-empty by
658    /// [`Manifest::validate`]). Maps to `UserInvokableJob::display_name`.
659    pub name: String,
660    /// Optional one-line subtitle under `name` in the Client App.
661    /// Distinct from the operator-facing top-level
662    /// [`Manifest::description`] — this one is written for the end
663    /// user. Maps to `UserInvokableJob::display_description`.
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub description: Option<String>,
666    /// Which Client App tab the job lives in — a **free-form category
667    /// key** (#792). The Client App renders one tab per distinct key.
668    /// Well-known keys (`software_update`, `troubleshoot`, `catalog`)
669    /// carry built-in tab labels/icons; any other key defines a new tab
670    /// (style it with `category_label` / `category_icon`). Required and
671    /// validated non-empty — without it the agent can't place the job.
672    /// Note: the `software_update` key also drives the agent's
673    /// maintenance / auto-reboot grouping.
674    pub category: String,
675    /// Optional display name for the category's TAB. Set it on (at least
676    /// one of) a custom category's jobs to name the tab; `None` ⇒ a
677    /// built-in default for a well-known key, else the key itself.
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub category_label: Option<String>,
680    /// Optional icon for the category's TAB (lucide name or `data:` URL).
681    /// `None` ⇒ Client App default for the key.
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub category_icon: Option<String>,
684    /// Optional sort order for the TAB; lower sorts first. `None` ⇒
685    /// default (well-known keys keep their familiar order; custom keys
686    /// sort after, then by label).
687    #[serde(default, skip_serializing_if = "Option::is_none")]
688    pub category_order: Option<i64>,
689    /// Optional icon hint for the job ROW — a lucide-react icon name
690    /// or a `data:` URL. `None` ⇒ the Client App falls back to the
691    /// category's icon. Surfaced verbatim in `jobs.list[].icon`.
692    #[serde(default, skip_serializing_if = "Option::is_none")]
693    pub icon: Option<String>,
694    /// Optional visibility scope for the end-user Client App (#816).
695    ///
696    /// `None` ⇒ visible to every PC (current behavior). When set, only
697    /// agents whose `pc_id` / group membership match the [`Target`] list
698    /// the job in `jobs.list` and may run it via KLP `jobs.execute`.
699    ///
700    /// This gates the END-USER surface ONLY. Operators are unaffected:
701    /// `POST /api/exec/{job_id}` (SPA / `kanade exec`) is a separate path
702    /// that never consults `client:`, so an operator can still run the
703    /// job on any PC regardless of `visible_to`. Reuses the schedule
704    /// `Target` shape (`all` / `groups` / `pcs`); a present-but-empty
705    /// target is rejected by [`Manifest::validate`].
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub visible_to: Option<Target>,
708}
709
710/// #720 — one widget on the SPA **Analytics** page: a declarative
711/// aggregation over the `obs_events` table. The backend reads these off
712/// `Manifest::aggregate` (from `BUCKET_JOBS`) at query time and builds
713/// the `json_extract` GROUP BY / time-bucket SQL from these generic
714/// primitives, so an operator can chart any emitted event without a Rust
715/// change. The reference shapes are the attendance dashboards
716/// (presence / app_sample / web_visit), but the same DSL covers logon /
717/// reboot / agent-health trends, etc.
718#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
719pub struct AggregateWidget {
720    /// Tab this widget lives under on the Analytics page. Widgets from
721    /// every job are collected and grouped by this label, so the same
722    /// string across jobs builds one multi-source dashboard. Required.
723    pub dashboard: String,
724    /// Widget heading. Required, validated non-empty.
725    pub title: String,
726    /// Optional one-line subtitle shown muted under the `title` on the
727    /// Analytics page — room for a unit, a caveat, or what the number
728    /// means ("samples × 2 min", "Security 4624 only"). Rejected if
729    /// present-but-blank.
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub description: Option<String>,
732    /// Optional sort weight (#743). Once the order-aware sort lands (PR2)
733    /// widgets render in `(order, dashboard, title)` order, so a lower
734    /// `order` pulls a widget — and its tab — earlier; equal/absent `order`
735    /// falls back to the alphabetical `(dashboard, title)` ordering. Treated
736    /// as `0` when unset, so a fleet with no `order` anywhere stays purely
737    /// alphabetical (today's behaviour); negatives are allowed to pin
738    /// something first. (This field only carries the value; the backend
739    /// applies it.)
740    #[serde(default, skip_serializing_if = "Option::is_none")]
741    pub order: Option<i32>,
742    /// `pc` rolls up a single selected PC; `fleet` rolls up all PCs
743    /// (and unlocks `group_by: pc_id` to rank PCs against each other).
744    /// Defaults to `pc`.
745    #[serde(default)]
746    pub scope: AggregateScope,
747    /// `obs_events.kind` this widget reads (e.g. `app_sample`,
748    /// `presence`, `unexpected_shutdown`). Required for every aggregation
749    /// render (`bar`/`gauge`/`timeline`/`stat`); rejected for
750    /// `op_timeline`, which reconstructs a fixed multi-kind operational
751    /// swimlane (power/session/sleep) baked into the SPA and so reads no
752    /// single `kind`.
753    #[serde(default, skip_serializing_if = "Option::is_none")]
754    pub kind: Option<String>,
755    /// Optional `obs_events.source` filter, when one `kind` is emitted by
756    /// more than one collector.
757    #[serde(default, skip_serializing_if = "Option::is_none")]
758    pub source: Option<String>,
759    /// How to roll the matching events up. See [`AggregateAgg`]. Required
760    /// for every aggregation render; rejected for `op_timeline` (which
761    /// performs no rollup — it returns the raw operational events and the
762    /// SPA folds them into lane spans).
763    #[serde(default, skip_serializing_if = "Option::is_none")]
764    pub agg: Option<AggregateAgg>,
765    /// Dotted JSON path (no `$.` prefix) to group by for `agg: count` /
766    /// `sum` — e.g. `foreground.app`. The literal `pc_id` is special:
767    /// it groups by the `pc_id` column (fleet ranking), not a payload
768    /// field. Omit for a single total. Required when `agg: sum` needs a
769    /// breakdown; for `agg: count` omitting it yields the grand total.
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub group_by: Option<String>,
772    /// Dotted JSON path to a boolean for `agg: ratio` (e.g. `active`):
773    /// the widget reports `true_count / total`. Required when `agg: ratio`.
774    #[serde(default, skip_serializing_if = "Option::is_none")]
775    pub bool_path: Option<String>,
776    /// Dotted JSON path to a number for `agg: sum`. Required when `agg: sum`.
777    #[serde(default, skip_serializing_if = "Option::is_none")]
778    pub value_path: Option<String>,
779    /// Optional value transform applied before grouping. Currently only
780    /// `host` (parse a URL down to its host) — used by the top-sites
781    /// widget, where SQLite can't parse a URL so the backend does it in
782    /// Rust. See [`AggregateTransform`].
783    #[serde(default, skip_serializing_if = "Option::is_none")]
784    pub transform: Option<AggregateTransform>,
785    /// Optional sampling cadence in minutes. When set, a `count` is also
786    /// reported as estimated time (`count × sample_minutes`) — e.g. a
787    /// 2-minute app sampler turns 11 samples into ~22 minutes. Must be ≥ 1.
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    #[schemars(range(min = 1))]
790    pub sample_minutes: Option<u32>,
791    /// Grouped values to drop from the rollup (e.g. `["LockApp"]` so the
792    /// lock screen doesn't top the app ranking). Empty by default.
793    #[serde(default, skip_serializing_if = "Vec::is_empty")]
794    pub exclude: Vec<String>,
795    /// Optional time bucketing — `hour` buckets events by local
796    /// hour-of-day for a `timeline` render. See [`AggregateTimeBucket`].
797    #[serde(default, skip_serializing_if = "Option::is_none")]
798    pub time_bucket: Option<AggregateTimeBucket>,
799    /// Top-N cap for grouped renders (`bar`). Defaults to 10 when unset.
800    #[serde(default, skip_serializing_if = "Option::is_none")]
801    #[schemars(range(min = 1))]
802    pub limit: Option<u32>,
803    /// Which widget the SPA draws. See [`AggregateRender`].
804    pub render: AggregateRender,
805}
806
807/// Per-PC vs fleet-wide rollup for an [`AggregateWidget`].
808#[derive(
809    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
810)]
811#[serde(rename_all = "lowercase")]
812#[non_exhaustive]
813pub enum AggregateScope {
814    /// Roll up the single PC the operator selected. The default.
815    #[default]
816    Pc,
817    /// Roll up across every PC. Unlocks `group_by: pc_id`.
818    Fleet,
819    /// #492 forward-compat catch-all — a Manifest is read fleet-wide, so
820    /// an older reader must tolerate a future variant rather than failing
821    /// to decode the whole job. The backend skips an `Unknown` widget.
822    #[serde(other)]
823    Unknown,
824}
825
826/// The rollup function for an [`AggregateWidget`].
827#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
828#[serde(rename_all = "lowercase")]
829#[non_exhaustive]
830pub enum AggregateAgg {
831    /// Row count, optionally grouped (`group_by`) and time-estimated
832    /// (`sample_minutes`).
833    Count,
834    /// `true_count / total` over `bool_path`.
835    Ratio,
836    /// Sum of `value_path`, optionally grouped.
837    Sum,
838    /// #492 forward-compat catch-all (see [`AggregateScope::Unknown`]).
839    #[serde(other)]
840    Unknown,
841}
842
843/// Optional pre-grouping value transform for an [`AggregateWidget`].
844#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
845#[serde(rename_all = "lowercase")]
846#[non_exhaustive]
847pub enum AggregateTransform {
848    /// Parse the grouped value as a URL and keep only its host.
849    Host,
850    /// #492 forward-compat catch-all (see [`AggregateScope::Unknown`]).
851    #[serde(other)]
852    Unknown,
853}
854
855/// Time bucketing for an [`AggregateWidget`] (drives a `timeline`).
856#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
857#[serde(rename_all = "lowercase")]
858#[non_exhaustive]
859pub enum AggregateTimeBucket {
860    /// Bucket by local hour-of-day (0–23), summed over the window.
861    Hour,
862    /// #492 forward-compat catch-all (see [`AggregateScope::Unknown`]).
863    #[serde(other)]
864    Unknown,
865}
866
867/// Which visual the SPA renders an [`AggregateWidget`] as.
868#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
869#[serde(rename_all = "lowercase")]
870#[non_exhaustive]
871pub enum AggregateRender {
872    /// Ranked horizontal bars (a grouped `count` / `sum`).
873    Bar,
874    /// A single ratio dial (`agg: ratio`).
875    Gauge,
876    /// 24-hour activity strip (`time_bucket: hour`).
877    Timeline,
878    /// A single headline number (an ungrouped total).
879    Stat,
880    /// Per-PC operational swimlane (power / session / sleep) reconstructed
881    /// from a fixed multi-kind event set. Unlike the aggregation renders it
882    /// reads no single `kind`/`agg`: the backend returns the raw events in
883    /// the window and the SPA folds them into lane spans (shared with the
884    /// Events page strip). Per-PC only (`scope: pc`).
885    #[serde(rename = "op_timeline")]
886    OpTimeline,
887    /// #492 forward-compat catch-all (see [`AggregateScope::Unknown`]).
888    #[serde(other)]
889    Unknown,
890}
891
892/// True if `p` is a well-formed dotted JSON path of `[A-Za-z0-9_]`
893/// segments joined by single dots — the shape safe to bind into
894/// `json_extract(payload, '$.' || ?)`. The charset blocks injection; the
895/// segment check additionally rejects `"."`, `".foo"`, `"foo."`,
896/// `"foo..bar"`, which would pass the charset but produce a malformed
897/// `$.` path that errors at query time. Accepts `pc_id`, `foreground.app`,
898/// `active`, etc.
899fn is_valid_json_path(p: &str) -> bool {
900    !p.is_empty()
901        && p.split('.').all(|seg| {
902            !seg.is_empty() && seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
903        })
904}
905
906/// Per-widget validation for a list of [`AggregateWidget`]s — shared by
907/// the `aggregate:` job hint ([`Manifest::validate`]) and the standalone
908/// [`View`] resource (#743) so the two can't diverge. `field` names the
909/// containing key for error messages (`"aggregate"` or `"widgets"`).
910///
911/// Enforces: non-empty list; non-empty dashboard/title (and `kind`/`agg`
912/// for every aggregation render); a blank-when-set `source`; rejection of
913/// any #492 `Unknown` enum (an operator typo at create time); safe dotted
914/// JSON paths; the value path each `agg` needs (and rejection of mis-paired
915/// ones); `pc_id` grouping only in `fleet` scope; `transform`/`limit`/
916/// `exclude` only with a `group_by`; positive `limit`/`sample_minutes`;
917/// `gauge`⇔`ratio`; and `timeline`⇔`time_bucket`. A `render: op_timeline`
918/// widget is validated separately (per-PC, no aggregation knobs) — see
919/// [`validate_op_timeline_widget`].
920pub fn validate_aggregate_widgets(widgets: &[AggregateWidget], field: &str) -> Result<(), String> {
921    if widgets.is_empty() {
922        return Err(format!(
923            "`{field}:` must list at least one widget when present"
924        ));
925    }
926    for (i, w) in widgets.iter().enumerate() {
927        let at = format!("{field}[{i}]");
928        for (label, value) in [("dashboard", &w.dashboard), ("title", &w.title)] {
929            if value.trim().is_empty() {
930                return Err(format!("{at}.{label} must not be empty"));
931            }
932        }
933        // A present-but-blank `description` renders an empty muted line —
934        // reject it so the subtitle only shows when it says something.
935        if let Some(description) = &w.description {
936            if description.trim().is_empty() {
937                return Err(format!("{at}.description must not be empty when set"));
938            }
939        }
940        // Reject values that fell through to the #492 `Unknown` catch-all:
941        // at create time on the current version that's an operator typo. (A
942        // genuinely-future variant only reaches an older reader via a stored
943        // resource, which is never re-validated, so forward-compat holds.)
944        if w.scope == AggregateScope::Unknown {
945            return Err(format!("{at}.scope is not a known value (pc | fleet)"));
946        }
947        if w.render == AggregateRender::Unknown {
948            return Err(format!(
949                "{at}.render is not a known value (bar | gauge | timeline | stat | op_timeline)"
950            ));
951        }
952        // `op_timeline` reconstructs a fixed per-PC operational swimlane
953        // (power/session/sleep) from a baked-in multi-kind set — it uses none
954        // of the aggregation knobs, so validate it on its own terms (per-PC,
955        // no `kind`/`agg`/grouping) and skip the rollup rules below.
956        if w.render == AggregateRender::OpTimeline {
957            validate_op_timeline_widget(w, &at)?;
958            continue;
959        }
960        // Every other render is an aggregation over a single `kind`.
961        if w.kind.as_deref().map(str::trim).unwrap_or("").is_empty() {
962            return Err(format!("{at}.kind must not be empty"));
963        }
964        let agg = match w.agg {
965            Some(AggregateAgg::Unknown) => {
966                return Err(format!(
967                    "{at}.agg is not a known value (count | ratio | sum)"
968                ));
969            }
970            Some(agg) => agg,
971            None => return Err(format!("{at}.agg is required")),
972        };
973        // A present-but-blank `source` is a no-op filter — reject like the
974        // other blank-when-set guards.
975        if let Some(source) = &w.source {
976            if source.trim().is_empty() {
977                return Err(format!("{at}.source must not be empty when set"));
978            }
979        }
980        if w.transform == Some(AggregateTransform::Unknown) {
981            return Err(format!("{at}.transform is not a known value (host)"));
982        }
983        if w.time_bucket == Some(AggregateTimeBucket::Unknown) {
984            return Err(format!("{at}.time_bucket is not a known value (hour)"));
985        }
986        for (label, path) in [
987            ("group_by", &w.group_by),
988            ("bool_path", &w.bool_path),
989            ("value_path", &w.value_path),
990        ] {
991            if let Some(p) = path {
992                if !is_valid_json_path(p) {
993                    return Err(format!(
994                        "{at}.{label} '{p}' must be a dotted JSON path of [A-Za-z0-9_] segments"
995                    ));
996                }
997            }
998        }
999        // Each agg uses exactly one value path; reject a mis-paired path so
1000        // a typo fails at create rather than being ignored.
1001        match agg {
1002            // count: grouped → ranking, ungrouped → grand total.
1003            AggregateAgg::Count => {
1004                for (label, path) in [("bool_path", &w.bool_path), ("value_path", &w.value_path)] {
1005                    if path.is_some() {
1006                        return Err(format!("{at}.agg=count does not use `{label}`"));
1007                    }
1008                }
1009            }
1010            AggregateAgg::Ratio => {
1011                if w.bool_path.is_none() {
1012                    return Err(format!("{at}.agg=ratio requires `bool_path`"));
1013                }
1014                if w.value_path.is_some() {
1015                    return Err(format!("{at}.agg=ratio does not use `value_path`"));
1016                }
1017            }
1018            AggregateAgg::Sum => {
1019                if w.value_path.is_none() {
1020                    return Err(format!("{at}.agg=sum requires `value_path`"));
1021                }
1022                if w.bool_path.is_some() {
1023                    return Err(format!("{at}.agg=sum does not use `bool_path`"));
1024                }
1025            }
1026            // Rejected above; arm exists only for exhaustiveness.
1027            AggregateAgg::Unknown => {}
1028        }
1029        // Ranking PCs against each other only means something across the
1030        // fleet — within one PC it's a single bar.
1031        if w.group_by.as_deref() == Some("pc_id") && w.scope != AggregateScope::Fleet {
1032            return Err(format!(
1033                "{at}.group_by: pc_id is only valid with scope: fleet"
1034            ));
1035        }
1036        // `transform` rewrites the grouped PAYLOAD value (URL→host); it's
1037        // meaningless on a `pc_id` grouping (the pc_id column, not a payload
1038        // field), so reject the combo at create time.
1039        if w.transform.is_some() && w.group_by.as_deref() == Some("pc_id") {
1040            return Err(format!("{at}.transform is not valid with group_by: pc_id"));
1041        }
1042        // limit / transform / exclude all operate on grouped values, so
1043        // without a `group_by` they're silent no-ops — reject.
1044        if w.group_by.is_none() {
1045            if w.limit.is_some() {
1046                return Err(format!("{at}.limit requires `group_by`"));
1047            }
1048            if w.transform.is_some() {
1049                return Err(format!("{at}.transform requires `group_by`"));
1050            }
1051            if !w.exclude.is_empty() {
1052                return Err(format!("{at}.exclude requires `group_by`"));
1053            }
1054        }
1055        if w.limit == Some(0) {
1056            return Err(format!("{at}.limit must be > 0"));
1057        }
1058        if w.sample_minutes == Some(0) {
1059            return Err(format!("{at}.sample_minutes must be > 0"));
1060        }
1061        for ex in &w.exclude {
1062            if ex.trim().is_empty() {
1063                return Err(format!("{at}.exclude must not contain empty entries"));
1064            }
1065        }
1066        // A gauge draws a single ratio dial — only meaningful for agg: ratio.
1067        if w.render == AggregateRender::Gauge && agg != AggregateAgg::Ratio {
1068            return Err(format!("{at}.render=gauge is only valid with agg: ratio"));
1069        }
1070        // A timeline needs a bucket; a bucket on any other render is a no-op
1071        // that signals operator confusion — reject both.
1072        match (w.render, &w.time_bucket) {
1073            (AggregateRender::Timeline, None) => {
1074                return Err(format!("{at}.render=timeline requires `time_bucket`"));
1075            }
1076            (r, Some(_)) if r != AggregateRender::Timeline => {
1077                return Err(format!(
1078                    "{at}.time_bucket is only valid with render: timeline"
1079                ));
1080            }
1081            _ => {}
1082        }
1083    }
1084    Ok(())
1085}
1086
1087/// Validate a `render: op_timeline` widget. It draws a fixed per-PC
1088/// operational swimlane (power / session / sleep) reconstructed by the SPA
1089/// from a baked-in multi-kind event set, so it uses none of the aggregation
1090/// knobs: require `scope: pc` and reject every field that only makes sense
1091/// for a rollup (`kind`/`source`/`agg`/`group_by`/`bool_path`/`value_path`/
1092/// `transform`/`sample_minutes`/`exclude`/`time_bucket`/`limit`). Rejecting
1093/// the unused fields (rather than ignoring them) keeps an operator typo from
1094/// silently doing nothing, matching the rest of this validator.
1095fn validate_op_timeline_widget(w: &AggregateWidget, at: &str) -> Result<(), String> {
1096    // Per-PC only: a fleet-wide swimlane of every PC's spans is unbounded
1097    // and unreadable, and the backend only computes it in per-PC scope.
1098    if w.scope != AggregateScope::Pc {
1099        return Err(format!("{at}.render=op_timeline requires scope: pc"));
1100    }
1101    // Each unused field, with the name the operator wrote, so the error
1102    // points at exactly what to delete.
1103    if w.kind.is_some() {
1104        return Err(format!("{at}.render=op_timeline does not use `kind`"));
1105    }
1106    if w.source.is_some() {
1107        return Err(format!("{at}.render=op_timeline does not use `source`"));
1108    }
1109    if w.agg.is_some() {
1110        return Err(format!("{at}.render=op_timeline does not use `agg`"));
1111    }
1112    for (label, set) in [
1113        ("group_by", w.group_by.is_some()),
1114        ("bool_path", w.bool_path.is_some()),
1115        ("value_path", w.value_path.is_some()),
1116        ("transform", w.transform.is_some()),
1117        ("sample_minutes", w.sample_minutes.is_some()),
1118        ("time_bucket", w.time_bucket.is_some()),
1119        ("limit", w.limit.is_some()),
1120        ("exclude", !w.exclude.is_empty()),
1121    ] {
1122        if set {
1123            return Err(format!("{at}.render=op_timeline does not use `{label}`"));
1124        }
1125    }
1126    Ok(())
1127}
1128
1129/// A standalone declarative read/aggregation for the Analytics page (#743).
1130///
1131/// A **view** aggregates stored fleet data (`obs_events`, …) without an
1132/// `execute` or a schedule — unlike a [`Manifest`] it only declares
1133/// [`AggregateWidget`]s. (The first line is concise on purpose: `schemars`
1134/// uses it as the generated schema's `title`.) The backend reads views from
1135/// `BUCKET_VIEWS` at
1136/// query time and merges their widgets with the co-located `aggregate:`
1137/// hints on jobs, so a cross-cutting dashboard (one that charts events
1138/// emitted by several other jobs / the agent) has a home that doesn't need
1139/// a noop job carrier. Stored JSON in `BUCKET_VIEWS`, keyed by `id`.
1140#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1141pub struct View {
1142    /// Stable identifier (the KV key). Required, validated non-empty.
1143    pub id: String,
1144    /// Optional human description shown on the Views admin page.
1145    #[serde(default, skip_serializing_if = "Option::is_none")]
1146    pub description: Option<String>,
1147    /// The widgets this view contributes to the Analytics page.
1148    pub widgets: Vec<AggregateWidget>,
1149    /// Free-form operator taxonomy (same role as [`Manifest::tags`]).
1150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1151    pub tags: Vec<String>,
1152    /// GitOps provenance (#678), stamped by `kanade view create` from the
1153    /// source YAML's Git context — same as [`Manifest::origin`].
1154    #[serde(default, skip_serializing_if = "Option::is_none")]
1155    pub origin: Option<RepoOrigin>,
1156}
1157
1158/// True if `id` is a safe resource identifier — non-empty and only
1159/// `[A-Za-z0-9._-]`. A view `id` becomes a NATS KV key *and* a URL path
1160/// segment (`/api/views/{id}`), so this blocks `/`, `..`, whitespace and
1161/// other characters that would break the KV key or let a CLI arg wander
1162/// the URL space. (#743 / #744 follow-up — a deliberately small charset
1163/// rather than the looser set NATS technically allows.)
1164pub fn is_valid_resource_id(id: &str) -> bool {
1165    !id.is_empty()
1166        && id
1167            .chars()
1168            .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
1169}
1170
1171impl View {
1172    pub fn validate(&self) -> Result<(), String> {
1173        if !is_valid_resource_id(self.id.trim()) {
1174            return Err(
1175                "view.id must be non-empty and only [A-Za-z0-9._-] (it's a KV key + URL segment)"
1176                    .to_string(),
1177            );
1178        }
1179        validate_aggregate_widgets(&self.widgets, "widgets")?;
1180        for tag in &self.tags {
1181            if tag.trim().is_empty() {
1182                return Err("tags must not contain empty entries".to_string());
1183            }
1184        }
1185        Ok(())
1186    }
1187}
1188
1189/// Issue #246 — `emit:` manifest block for jobs whose stdout is
1190/// NDJSON observability events (one `ObsEvent` per line). Parallel
1191/// to `inventory:` but for the append-only timeline pipeline; see
1192/// `Manifest::emit` for the full contract.
1193#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1194pub struct EmitConfig {
1195    /// What kind of payload the agent should expect on stdout. Only
1196    /// `events` is defined today (parses each non-empty line as
1197    /// `ObsEvent` and publishes on `obs.<pc_id>`); future variants
1198    /// (e.g. metrics streams, structured trace events) plug in here.
1199    #[serde(rename = "type")]
1200    pub kind: EmitKind,
1201    /// Operator hint for where the script keeps its own state — the
1202    /// watermark file the PowerShell / sh body reads + writes
1203    /// between runs so it only emits NEW events since the last
1204    /// poll. The agent doesn't read this; it's documentation that
1205    /// the SPA (and `kanade job edit`) can surface to operators
1206    /// reviewing the manifest. Optional; the script is allowed to
1207    /// keep state anywhere (registry, env, etc.) — the field's
1208    /// presence makes the convention discoverable.
1209    #[serde(default, skip_serializing_if = "Option::is_none")]
1210    pub watermark_path: Option<String>,
1211}
1212
1213/// `emit.type` enum. Lowercase serde so manifests read
1214/// `type: events` rather than `Events`.
1215#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
1216#[serde(rename_all = "lowercase")]
1217pub enum EmitKind {
1218    /// Per-line `ObsEvent` JSON. Agent parses + publishes on
1219    /// `obs.<pc_id>`, drops the stdout from the resulting
1220    /// `ExecResult`.
1221    Events,
1222}
1223
1224/// v0.31 / #40: declarative "flatten this JSON array into a real
1225/// SQLite table" spec on an inventory manifest. The projector
1226/// creates the table on first registration (CREATE TABLE IF NOT
1227/// EXISTS + indexes) and writes a row per element of
1228/// `payload[field]` on every result, scoped by (pc_id, job_id) so
1229/// each PC's rows replace cleanly without a per-PC schema.
1230#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1231pub struct ExplodeSpec {
1232    /// JSON array key under the payload to explode. E.g. `"apps"`
1233    /// for `payload: { apps: [{...}, {...}] }`.
1234    pub field: String,
1235    /// Derived SQLite table name. Operators choose this — pick
1236    /// something namespaced + stable (`inventory_sw_apps`, not
1237    /// `apps`) so multiple inventory manifests don't collide on a
1238    /// generic name.
1239    pub table: String,
1240    /// Element-level fields that uniquely identify a row inside one
1241    /// PC's payload. The full PK is `(pc_id, job_id) + these
1242    /// columns`. Required — operators must think about uniqueness
1243    /// (e.g. `["name", "source"]` for installed apps because the
1244    /// same name appears in multiple uninstall hives).
1245    ///
1246    /// v0.31 / #41: same tuple drives history identity. When
1247    /// `track_history` is on, the projector serialises these
1248    /// fields' values into `inventory_history.identity_json` for
1249    /// every change event, so queries like "every PC that ever
1250    /// installed Chrome (any source)" filter on identity_json
1251    /// content without a per-manifest schema.
1252    pub primary_key: Vec<String>,
1253    /// Per-element fields that become columns in the derived table.
1254    pub columns: Vec<ExplodeColumn>,
1255    /// v0.31 / #41: when true (default false), the projector
1256    /// diffs each PC's incoming payload against the prior rows
1257    /// for the same (pc_id, job_id) BEFORE the DELETE-then-INSERT
1258    /// replace, and writes added / removed / changed events into
1259    /// `inventory_history`. Lets operators answer time-dimension
1260    /// questions ("when did Chrome 120 first appear on PC X?",
1261    /// "what's the Win 11 23H2 rollout curve") without storing
1262    /// per-scan snapshots. Off by default so operators opt in
1263    /// per-spec — history has a real storage cost on long-lived
1264    /// deployments (mitigated by the 90-day default retention
1265    /// sweeper, see `cleanup` module).
1266    #[serde(default)]
1267    pub track_history: bool,
1268}
1269
1270/// One column in an [`ExplodeSpec`]'s derived table.
1271#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1272pub struct ExplodeColumn {
1273    /// JSON key under each array element. Becomes the column name
1274    /// in the derived SQLite table — we don't rename.
1275    pub field: String,
1276    /// SQLite affinity: `"text"` (default), `"integer"`, `"real"`.
1277    /// Storage maps directly via `sqlx::query.bind(...)`; type
1278    /// mismatches at INSERT-time fail loudly rather than silently
1279    /// dropping the row.
1280    #[serde(default, skip_serializing_if = "Option::is_none")]
1281    #[serde(rename = "type")]
1282    pub kind: Option<String>,
1283    /// When true, the projector creates a `CREATE INDEX` on this
1284    /// column at table-creation time. Boost for the common-filter
1285    /// columns (`name`, `version`) — operators mark them
1286    /// explicitly, the projector won't guess.
1287    #[serde(default)]
1288    pub index: bool,
1289}
1290
1291#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1292pub struct DisplayField {
1293    /// Top-level key in the stdout JSON.
1294    pub field: String,
1295    /// Human-readable column header.
1296    pub label: String,
1297    /// Optional render hint — `"number"`, `"bytes"`, `"timestamp"`,
1298    /// or `"table"` (#39). Defaults to plain text rendering on the
1299    /// SPA side. `"table"` expects the field's value to be a JSON
1300    /// array of objects and renders a nested sub-table on the
1301    /// per-PC detail page using `columns` as the schema; the fleet
1302    /// summary view falls back to showing the row count for
1303    /// `"table"` cells so the wide list stays compact.
1304    #[serde(default, skip_serializing_if = "Option::is_none")]
1305    #[serde(rename = "type")]
1306    pub kind: Option<String>,
1307    /// v0.30 / #39: when `kind == "table"`, the SPA renders the
1308    /// field's value (an array of objects like
1309    /// `disks: [{ device_id, size_bytes, ... }]`) as a nested
1310    /// sub-table using these columns. Each column is itself a
1311    /// `DisplayField`, so the nested cells reuse the same render
1312    /// hints (`bytes`, `number`, `timestamp`) — no parallel format
1313    /// pipeline. Ignored for any other `kind`.
1314    #[serde(default, skip_serializing_if = "Option::is_none")]
1315    pub columns: Option<Vec<DisplayField>>,
1316}
1317
1318#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1319pub struct Rollout {
1320    #[serde(default)]
1321    pub strategy: RolloutStrategy,
1322    pub waves: Vec<Wave>,
1323}
1324
1325#[derive(
1326    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
1327)]
1328#[serde(rename_all = "lowercase")]
1329pub enum RolloutStrategy {
1330    #[default]
1331    Wave,
1332}
1333
1334#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1335pub struct Wave {
1336    pub group: String,
1337    /// humantime delay measured from the deploy's publish time. wave[0]
1338    /// typically has "0s"; subsequent waves use minutes / hours.
1339    pub delay: String,
1340}
1341
1342#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
1343pub struct Target {
1344    #[serde(default)]
1345    pub groups: Vec<String>,
1346    #[serde(default)]
1347    pub pcs: Vec<String>,
1348    #[serde(default)]
1349    pub all: bool,
1350}
1351
1352impl Target {
1353    /// At least one of all / groups / pcs is set.
1354    pub fn is_specified(&self) -> bool {
1355        self.all || !self.groups.is_empty() || !self.pcs.is_empty()
1356    }
1357
1358    /// Whether a PC (its `pc_id` + group membership) falls in this target:
1359    /// `all`, or the pc is listed, or it belongs to a listed group. Used
1360    /// by the agent to scope `client.visible_to` (#816). An unspecified
1361    /// target matches nobody (callers should treat "no target" as
1362    /// "visible to all" before calling this).
1363    pub fn matches(&self, pc_id: &str, groups: &[String]) -> bool {
1364        self.all
1365            || self.pcs.iter().any(|p| p == pc_id)
1366            || self.groups.iter().any(|g| groups.contains(g))
1367    }
1368}
1369
1370#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1371pub struct Execute {
1372    pub shell: ExecuteShell,
1373    /// Inline script body. Mutually exclusive with [`script_file`]
1374    /// and [`script_object`]; exactly one of the three must be set
1375    /// (enforced by [`Execute::validate_script_source`] at the
1376    /// write-side parse boundaries — `kanade job create` and
1377    /// `POST /api/jobs`).
1378    ///
1379    /// Empty string is treated as **unset** so operators can swap
1380    /// to a `script_file:` / `script_object:` alternative just by
1381    /// commenting out the body, without having to also drop the
1382    /// `script:` key entirely.
1383    ///
1384    /// [`script_file`]: Self::script_file
1385    /// [`script_object`]: Self::script_object
1386    #[serde(default, skip_serializing_if = "Option::is_none")]
1387    pub script: Option<String>,
1388    /// Repo-local file path resolved by the operator-side CLI at
1389    /// `kanade job create` time. The CLI reads the file, slots its
1390    /// contents into `script`, and clears this field before
1391    /// POSTing — so the backend / agents never see `script_file`
1392    /// in stored manifests. SPEC §2.4.1.
1393    ///
1394    /// Resolver lands in a follow-up PR
1395    /// (yukimemi/kanade#210); today this field passes parse-time
1396    /// validation but the operator-side CLI bails with "not yet
1397    /// implemented" until the resolver ships, so manifests that
1398    /// reach the backend with `script_file` set are treated as a
1399    /// schema-bug.
1400    #[serde(default, skip_serializing_if = "Option::is_none")]
1401    pub script_file: Option<String>,
1402    /// Object Store reference (`<name>/<version>`) into the
1403    /// `scripts` bucket (`OBJECT_SCRIPTS`). Agents fetch the body
1404    /// at Execute time via `/api/script-objects/{name}/{version}`
1405    /// and cache it locally. SPEC §2.4.1.
1406    ///
1407    /// Fully wired (#210/#211): the backend resolves the digest at
1408    /// exec submission (`api::exec::resolve_script_source`), the agent
1409    /// fetches + sha-verifies + caches the body (`script_cache`), and
1410    /// `kanade script` CRUDs the store. Unlike `script_file:` (inlined
1411    /// CLI-side, git-managed), this keeps the body in versioned,
1412    /// digest-pinned object storage — the ops-managed counterpart.
1413    #[serde(default, skip_serializing_if = "Option::is_none")]
1414    pub script_object: Option<String>,
1415    /// humantime duration string (e.g. "30s", "10m"). Script-intrinsic
1416    /// — represents how long this script reasonably takes to run.
1417    pub timeout: String,
1418    /// Token + session combination the agent uses to launch the
1419    /// script (v0.21). Default = [`RunAs::System`] (Session 0,
1420    /// LocalSystem privileges, no GUI) — matches pre-v0.21 behavior.
1421    #[serde(default)]
1422    pub run_as: RunAs,
1423    /// Working directory for the spawned child (v0.21.1). When
1424    /// unset, the child inherits the agent's cwd — on Windows that
1425    /// means `%SystemRoot%\System32` for the prod service, which is
1426    /// almost never what operators actually want. Use an absolute
1427    /// path; relative paths are passed through to the OS verbatim.
1428    /// `%PROGRAMDATA%` works for `run_as: system`; for `run_as: user`
1429    /// you'd want `%USERPROFILE%` (but expansion happens in the
1430    /// shell, so write `$env:USERPROFILE` for PowerShell, or set
1431    /// it via teravars before `kanade job create`).
1432    #[serde(default, skip_serializing_if = "Option::is_none")]
1433    pub cwd: Option<String>,
1434}
1435
1436impl Execute {
1437    /// Treat an empty `script:` body as "intentionally unset". Operators
1438    /// commenting out a block-scalar tend to leave the key behind, and
1439    /// failing the validator on `script: ""` would surprise them.
1440    fn has_inline_script(&self) -> bool {
1441        matches!(&self.script, Some(s) if !s.is_empty())
1442    }
1443
1444    /// Enforce that exactly one of `script` / `script_file` /
1445    /// `script_object` is set. Called at the write-side parse
1446    /// boundaries (CLI `kanade job create` + backend
1447    /// `POST /api/jobs`) so ambiguous YAML is rejected before it
1448    /// reaches the JOBS KV. Read paths (projector, agent
1449    /// scheduler, list endpoints) skip this check — they only ever
1450    /// see what the write path already validated.
1451    pub fn validate_script_source(&self) -> Result<(), String> {
1452        let inline = self.has_inline_script();
1453        let file = self.script_file.is_some();
1454        let obj = self.script_object.is_some();
1455        let set = [inline, file, obj].into_iter().filter(|b| *b).count();
1456        match set {
1457            1 => Ok(()),
1458            0 => Err("execute: one of `script`, `script_file`, `script_object` must be set".into()),
1459            _ => Err(format!(
1460                "execute: only one of `script` / `script_file` / `script_object` may be set \
1461                 (got script={inline}, script_file={file}, script_object={obj})"
1462            )),
1463        }
1464    }
1465}
1466
1467/// Job-generic post-step hook (see [`Manifest::finalize`]). Runs after
1468/// the main `execute:` script (and the collect upload) on a clean exit,
1469/// with the step's structured result injected via an environment
1470/// variable. P1 supports an inline `script:` only — `script_file:` /
1471/// `script_object:` are follow-ups.
1472#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1473pub struct FinalizeSpec {
1474    pub shell: ExecuteShell,
1475    /// Inline script body (required; inline-only in P1).
1476    pub script: String,
1477    /// humantime duration string (e.g. `"60s"`, `"5m"`). Defaults to
1478    /// `60s` when unset.
1479    #[serde(default = "default_finalize_timeout")]
1480    pub timeout: String,
1481    /// Token + session combination, like [`Execute::run_as`]. Defaults
1482    /// to [`RunAs::System`].
1483    #[serde(default)]
1484    pub run_as: RunAs,
1485    /// Working directory for the hook child, like [`Execute::cwd`].
1486    #[serde(default, skip_serializing_if = "Option::is_none")]
1487    pub cwd: Option<String>,
1488}
1489
1490/// Default `finalize.timeout` when the operator omits it.
1491fn default_finalize_timeout() -> String {
1492    "60s".to_string()
1493}
1494
1495impl FinalizeSpec {
1496    /// Lower to the wire form forwarded onto a [`Command`]. The timeout
1497    /// parse falls back to 60s — [`Manifest::validate`] already rejects
1498    /// an unparseable value at create time, so the fire path uses a safe
1499    /// default rather than failing (mirrors
1500    /// [`CollectHint::max_size_bytes`]). A sub-second timeout floors at
1501    /// 1s for the same reason `build_command` does.
1502    pub fn lower(&self) -> FinalizeCommand {
1503        let timeout_secs = humantime::parse_duration(&self.timeout)
1504            .map(|d| d.as_secs().max(1))
1505            .unwrap_or(60);
1506        FinalizeCommand {
1507            shell: self.shell.into(),
1508            script: self.script.clone(),
1509            timeout_secs,
1510            run_as: self.run_as,
1511            cwd: self.cwd.clone(),
1512        }
1513    }
1514}
1515
1516impl Manifest {
1517    /// Cross-field semantic checks that don't fit into pure serde
1518    /// derive. Currently delegates to
1519    /// [`Execute::validate_script_source`] — see that method's
1520    /// docs for the rationale on which call sites should run this.
1521    pub fn validate(&self) -> Result<(), String> {
1522        self.execute.validate_script_source()?;
1523        // A present-but-empty finalize script is an invisible no-op
1524        // (the hook would run an empty body); reject it at the write
1525        // boundary. Inline-only in P1, so `script` is the sole source.
1526        if let Some(finalize) = &self.finalize {
1527            if finalize.script.trim().is_empty() {
1528                return Err("finalize.script must not be empty".to_string());
1529            }
1530            // Reject an unparseable timeout at the write boundary so the
1531            // operator sees the error at `job create` rather than getting
1532            // a silent fire-time fallback (`FinalizeSpec::lower` floors to
1533            // 60s, which would otherwise mask a typo).
1534            if humantime::parse_duration(&finalize.timeout).is_err() {
1535                return Err(format!(
1536                    "finalize.timeout '{}' is not a valid duration",
1537                    finalize.timeout
1538                ));
1539            }
1540            // Disallow cmd for finalize: the agent injects the result JSON
1541            // into the hook's environment, and cmd.exe quoting doesn't
1542            // nest — JSON's `"` plus shell metacharacters in a collected
1543            // path/key could break out into command injection at the
1544            // agent's (often LocalSystem) privilege. PowerShell's
1545            // single-quote escaping is safe, and finalize hooks are
1546            // PowerShell by convention anyway.
1547            if finalize.shell == ExecuteShell::Cmd {
1548                return Err(
1549                    "finalize.shell: cmd is not supported for finalize hooks (shell-injection \
1550                     risk when the result JSON is injected into the environment); use powershell"
1551                        .to_string(),
1552                );
1553            }
1554        }
1555        // Stdout-format compatibility (#821). `inventory:` / `check:` /
1556        // `collect:` now COMPOSE: each reads its own `#KANADE-<KIND>-
1557        // BEGIN/END`-fenced JSON block from stdout, so a single job can
1558        // project inventory facts, drive a Health-tab check, AND collect
1559        // files in one run. (A single-hint job may still skip the fence;
1560        // a multi-hint job must fence each block.)
1561        //
1562        // `emit:` remains the exception — its stdout is line-delimited
1563        // NDJSON consumed whole and then omitted from the result — so it
1564        // can't share stdout with any fenced hint.
1565        if self.emit.is_some()
1566            && (self.inventory.is_some() || self.check.is_some() || self.collect.is_some())
1567        {
1568            return Err(
1569                "`emit:` is incompatible with `inventory:` / `check:` / `collect:` — emit's \
1570                 stdout is NDJSON timeline events (consumed whole and omitted from the result), \
1571                 while the others read fenced JSON blocks from stdout"
1572                    .to_string(),
1573            );
1574        }
1575        // A check's `name` is the Health-tab row id (React key); the
1576        // field names tell the agent where to read status/detail.
1577        // An empty value is an invisible runtime bug, and the serde
1578        // defaults don't guard an operator who writes `status_field:
1579        // ""` explicitly — reject all three here.
1580        if let Some(check) = &self.check {
1581            for (label, value) in [
1582                ("check.name", &check.name),
1583                ("check.status_field", &check.status_field),
1584                ("check.detail_field", &check.detail_field),
1585            ] {
1586                if value.trim().is_empty() {
1587                    return Err(format!("{label} must not be empty"));
1588                }
1589            }
1590            // A present-but-blank `troubleshoot` is a broken
1591            // remediation job id (the "修復する" button would target
1592            // an empty manifest id) — reject it too.
1593            if let Some(troubleshoot) = &check.troubleshoot {
1594                if troubleshoot.trim().is_empty() {
1595                    return Err("check.troubleshoot must not be empty when set".to_string());
1596                }
1597            }
1598            // A present-but-blank `label` would render an empty row
1599            // title on the Health tab / Compliance page — reject it so
1600            // the slug fallback only ever kicks in when label is absent.
1601            if let Some(label) = &check.label {
1602                if label.trim().is_empty() {
1603                    return Err("check.label must not be empty when set".to_string());
1604                }
1605            }
1606            if let Some(alert) = &check.alert {
1607                // An alert that names no recipient is a silent no-op.
1608                if !alert.notify_user && alert.notify_groups.is_empty() {
1609                    return Err("check.alert must set notify_user and/or notify_groups".to_string());
1610                }
1611                if alert.title.trim().is_empty() {
1612                    return Err("check.alert.title must not be empty".to_string());
1613                }
1614                // `on: []` would never fire; an empty group name resolves to
1615                // a malformed `notifications.group.` subject.
1616                if alert.on.is_empty() {
1617                    return Err("check.alert.on must list at least one status".to_string());
1618                }
1619                if alert.notify_groups.iter().any(|g| g.trim().is_empty()) {
1620                    return Err("check.alert.notify_groups must not contain blanks".to_string());
1621                }
1622                // Email is addressed via group_contacts (group → email), so
1623                // there must be a group to map. notify_user has no email.
1624                if alert.email && alert.notify_groups.is_empty() {
1625                    return Err(
1626                        "check.alert.email requires notify_groups (email is addressed per group, not per user)"
1627                            .to_string(),
1628                    );
1629                }
1630                // The alert rides the `check_status` projection, which only
1631                // runs for `fleet: true`.
1632                if !check.fleet {
1633                    return Err(
1634                        "check.alert requires fleet: true (the alert rides the compliance projection)"
1635                            .to_string(),
1636                    );
1637                }
1638            }
1639        }
1640        // #291: a `client:` job is rendered in the Client App's
1641        // catalog (`jobs.list` → `jobs.execute`). serde already makes
1642        // `name` + `category` required at parse time; the only gap is
1643        // a present-but-blank `name`, which would render an empty row
1644        // title — reject it like the other display-id fields.
1645        if let Some(client) = &self.client {
1646            if client.name.trim().is_empty() {
1647                return Err("client.name must not be empty".to_string());
1648            }
1649            // #792: category is a free-form key now, so a blank one would
1650            // group the job under an empty tab — reject it like `name`.
1651            if client.category.trim().is_empty() {
1652                return Err("client.category must not be empty".to_string());
1653            }
1654            // Optional display fields, when present, must be
1655            // meaningful: a blank `description` renders an empty
1656            // subtitle and a blank `icon` is a dangling lucide name.
1657            // Same present-but-blank guard the `check:` block applies
1658            // to its optional `troubleshoot` id.
1659            for (label, value) in [
1660                ("client.description", &client.description),
1661                ("client.icon", &client.icon),
1662                ("client.category_label", &client.category_label),
1663                ("client.category_icon", &client.category_icon),
1664            ] {
1665                if let Some(v) = value {
1666                    if v.trim().is_empty() {
1667                        return Err(format!("{label} must not be empty when set"));
1668                    }
1669                }
1670            }
1671            // #816: a present-but-empty `visible_to` (no all/groups/pcs)
1672            // would hide the job from everyone in the Client App — almost
1673            // certainly a mistake. Require at least one selector; omit the
1674            // whole block to mean "visible to all".
1675            if let Some(t) = &client.visible_to {
1676                if !t.is_specified() {
1677                    return Err(
1678                        "client.visible_to must set at least one of all / groups / pcs (omit it for all PCs)"
1679                            .to_string(),
1680                    );
1681                }
1682            }
1683        }
1684        // #219: a `collect:` job's `name` heads the bundle on the SPA
1685        // Collect page (and the Client App row when paired with
1686        // `client:`), `files_field` tells the agent where to read the
1687        // path list, and `max_size` must be a parseable size so a typo
1688        // is caught at create time rather than silently capping the
1689        // bundle at the default on the fire path.
1690        if let Some(collect) = &self.collect {
1691            if collect.name.trim().is_empty() {
1692                return Err("collect.name must not be empty".to_string());
1693            }
1694            if collect.files_field.trim().is_empty() {
1695                return Err("collect.files_field must not be empty".to_string());
1696            }
1697            if let Some(description) = &collect.description {
1698                if description.trim().is_empty() {
1699                    return Err("collect.description must not be empty when set".to_string());
1700                }
1701            }
1702            if let Some(max_size) = &collect.max_size {
1703                parse_size_bytes(max_size).map_err(|e| format!("collect.max_size: {e}"))?;
1704            }
1705        }
1706        // #720/#743: `aggregate:` is a pure read-spec (it never touches
1707        // stdout and is never sent to an agent), so it composes with every
1708        // other hint. The per-widget rules are shared with the standalone
1709        // `view` resource — see [`validate_aggregate_widgets`].
1710        if let Some(widgets) = &self.aggregate {
1711            validate_aggregate_widgets(widgets, "aggregate")?;
1712        }
1713        // A blank / whitespace-only tag is an invisible operator typo
1714        // that would render an empty filter chip on the Jobs page —
1715        // reject it like the other present-but-blank display fields.
1716        for tag in &self.tags {
1717            if tag.trim().is_empty() {
1718                return Err("tags must not contain empty entries".to_string());
1719            }
1720        }
1721        Ok(())
1722    }
1723}
1724
1725#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
1726#[serde(rename_all = "lowercase")]
1727pub enum ExecuteShell {
1728    Powershell,
1729    Cmd,
1730}
1731
1732impl From<ExecuteShell> for Shell {
1733    fn from(s: ExecuteShell) -> Self {
1734        match s {
1735            ExecuteShell::Powershell => Shell::Powershell,
1736            ExecuteShell::Cmd => Shell::Cmd,
1737        }
1738    }
1739}
1740
1741#[cfg(test)]
1742mod tests {
1743    use super::*;
1744
1745    #[test]
1746    fn inventory_payload_extracts_fenced_block() {
1747        // Readable message + fenced JSON → only the JSON, trimmed.
1748        let stdout = "Wi-Fi 設定を適用しました。\n\
1749            #KANADE-INVENTORY-BEGIN\n\
1750            {\"applied\": true}\n\
1751            #KANADE-INVENTORY-END\n";
1752        assert_eq!(inventory_payload(stdout), "{\"applied\": true}");
1753    }
1754
1755    #[test]
1756    fn inventory_payload_falls_back_to_whole_stdout() {
1757        // No fence (a plain inventory job) → whole stdout, trimmed.
1758        assert_eq!(
1759            inventory_payload("  {\"ram_gb\": 16}\n"),
1760            "{\"ram_gb\": 16}"
1761        );
1762    }
1763
1764    #[test]
1765    fn inventory_payload_handles_unterminated_fence() {
1766        // Closing marker missing (e.g. truncated) → everything after the
1767        // opener, trimmed.
1768        let stdout = "msg\n#KANADE-INVENTORY-BEGIN\n{\"a\": 1}";
1769        assert_eq!(inventory_payload(stdout), "{\"a\": 1}");
1770    }
1771
1772    #[test]
1773    fn inventory_payload_ignores_mid_line_sentinel() {
1774        // The marker echoed mid-line (not at a line start) must NOT be
1775        // treated as a fence — fall back to the whole stdout.
1776        let stdout = "see #KANADE-INVENTORY-BEGIN in the docs\nnot json";
1777        assert_eq!(inventory_payload(stdout), stdout.trim());
1778    }
1779
1780    #[test]
1781    fn fenced_payload_extracts_each_hint_block_independently() {
1782        // #821: one stdout carrying a user message + all three fenced
1783        // blocks — every consumer pulls only its own.
1784        let stdout = "\
1785done!
1786#KANADE-INVENTORY-BEGIN
1787{\"os\":\"win\"}
1788#KANADE-INVENTORY-END
1789#KANADE-CHECK-BEGIN
1790{\"status\":\"ok\"}
1791#KANADE-CHECK-END
1792#KANADE-COLLECT-BEGIN
1793{\"files\":[\"a\"]}
1794#KANADE-COLLECT-END
1795";
1796        assert_eq!(
1797            fenced_payload(stdout, INVENTORY_BLOCK_BEGIN, INVENTORY_BLOCK_END),
1798            "{\"os\":\"win\"}"
1799        );
1800        assert_eq!(
1801            fenced_payload(stdout, CHECK_BLOCK_BEGIN, CHECK_BLOCK_END),
1802            "{\"status\":\"ok\"}"
1803        );
1804        assert_eq!(
1805            fenced_payload(stdout, COLLECT_BLOCK_BEGIN, COLLECT_BLOCK_END),
1806            "{\"files\":[\"a\"]}"
1807        );
1808    }
1809
1810    #[test]
1811    fn fenced_payload_falls_back_to_whole_stdout_without_fence() {
1812        // A single-hint job needs no fence — the whole (trimmed) stdout is
1813        // the payload.
1814        let stdout = "  {\"files\":[\"a\"]}  ";
1815        assert_eq!(
1816            fenced_payload(stdout, COLLECT_BLOCK_BEGIN, COLLECT_BLOCK_END),
1817            "{\"files\":[\"a\"]}"
1818        );
1819    }
1820
1821    #[test]
1822    fn fenced_payload_returns_empty_when_other_fences_present_but_mine_missing() {
1823        // Multi-hint output (inventory + check fenced) but the COLLECT
1824        // fence is missing — collect must NOT fall back to the whole
1825        // stdout (which holds the inventory/check blocks) and cross-parse
1826        // a sibling block; it gets "" → its JSON parse fails → no data.
1827        let stdout = "\
1828#KANADE-INVENTORY-BEGIN
1829{\"os\":\"win\"}
1830#KANADE-INVENTORY-END
1831#KANADE-CHECK-BEGIN
1832{\"status\":\"ok\"}
1833#KANADE-CHECK-END
1834";
1835        assert_eq!(
1836            fenced_payload(stdout, COLLECT_BLOCK_BEGIN, COLLECT_BLOCK_END),
1837            ""
1838        );
1839        // ...while the hints that DID fence still extract correctly.
1840        assert_eq!(
1841            fenced_payload(stdout, INVENTORY_BLOCK_BEGIN, INVENTORY_BLOCK_END),
1842            "{\"os\":\"win\"}"
1843        );
1844    }
1845
1846    /// The example check-job + schedule YAMLs shipped under `configs/`
1847    /// must stay valid as the schema evolves (#290 PR-C). `include_str!`
1848    /// pins them at compile time so a breaking edit fails `cargo test`
1849    /// rather than only `kanade job create` at deploy time.
1850    #[test]
1851    fn example_check_job_yamls_parse_and_validate() {
1852        let jobs = [
1853            (
1854                "check-bitlocker",
1855                include_str!("../../../configs/jobs/check-bitlocker.yaml"),
1856            ),
1857            (
1858                "check-av-signature",
1859                include_str!("../../../configs/jobs/check-av-signature.yaml"),
1860            ),
1861            (
1862                "check-cert-expiry",
1863                include_str!("../../../configs/jobs/check-cert-expiry.yaml"),
1864            ),
1865            (
1866                "check-disk-space",
1867                include_str!("../../../configs/jobs/check-disk-space.yaml"),
1868            ),
1869            (
1870                "check-pending-reboot",
1871                include_str!("../../../configs/jobs/check-pending-reboot.yaml"),
1872            ),
1873            (
1874                "check-defender-rtp",
1875                include_str!("../../../configs/jobs/check-defender-rtp.yaml"),
1876            ),
1877            (
1878                "check-firewall",
1879                include_str!("../../../configs/jobs/check-firewall.yaml"),
1880            ),
1881        ];
1882        for (name, yaml) in jobs {
1883            let m: Manifest =
1884                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} parse: {e}"));
1885            m.validate()
1886                .unwrap_or_else(|e| panic!("{name} validate: {e}"));
1887            let check = m
1888                .check
1889                .as_ref()
1890                .unwrap_or_else(|| panic!("{name} must carry a check: hint"));
1891            assert!(!check.name.trim().is_empty(), "{name} check.name empty");
1892            // These examples all read admin-only WMI / registry / netsh
1893            // state, so they run_as system. NOTE: that's a property of
1894            // these particular checks, NOT of the `check:` contract — a
1895            // check probing user-session state could run_as user.
1896            assert_eq!(
1897                m.execute.run_as,
1898                RunAs::System,
1899                "{name} should run_as system"
1900            );
1901        }
1902    }
1903
1904    /// The example user-invokable job YAMLs (#291) shipped under
1905    /// `configs/jobs/` must stay valid as the `client:` schema
1906    /// evolves. `include_str!` pins them at compile time so a breaking
1907    /// edit fails `cargo test`, not `kanade job create` at deploy.
1908    #[test]
1909    fn example_client_job_yamls_parse_and_validate() {
1910        let jobs = [
1911            (
1912                "fix-teams-cache",
1913                "troubleshoot",
1914                include_str!("../../../configs/jobs/fix-teams-cache.yaml"),
1915            ),
1916            (
1917                "chrome-update",
1918                "software_update",
1919                include_str!("../../../configs/jobs/chrome-update.yaml"),
1920            ),
1921            (
1922                "install-slack",
1923                "catalog",
1924                include_str!("../../../configs/jobs/install-slack.yaml"),
1925            ),
1926            (
1927                "fix-defender-rtp",
1928                "troubleshoot",
1929                include_str!("../../../configs/jobs/fix-defender-rtp.yaml"),
1930            ),
1931            // #792 custom category ("settings") + #809 message/inventory.
1932            (
1933                "example-power-plan",
1934                "settings",
1935                include_str!("../../../configs/jobs/example-power-plan.yaml"),
1936            ),
1937            // #792: diagnostics moved to its own "support" tab.
1938            (
1939                "collect-diagnostics",
1940                "support",
1941                include_str!("../../../configs/jobs/collect-diagnostics.yaml"),
1942            ),
1943        ];
1944        for (id, category, yaml) in jobs {
1945            let m: Manifest =
1946                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{id} parse: {e}"));
1947            m.validate()
1948                .unwrap_or_else(|e| panic!("{id} validate: {e}"));
1949            assert_eq!(m.id, id, "{id} id mismatch");
1950            let client = m
1951                .client
1952                .as_ref()
1953                .unwrap_or_else(|| panic!("{id} must carry a client: block"));
1954            assert!(!client.name.trim().is_empty(), "{id} client.name empty");
1955            assert_eq!(client.category, category, "{id} category");
1956        }
1957    }
1958
1959    /// #219: the shipped `collect:` example must stay valid as the
1960    /// schema evolves. `include_str!` pins it at compile time so a
1961    /// breaking edit (or a YAML typo in the PowerShell block) fails
1962    /// `cargo test` rather than `kanade job create` at deploy. It carries
1963    /// both `collect:` and `client:` (end-user-triggerable), which must
1964    /// compose.
1965    #[test]
1966    fn example_collect_job_yaml_parses_and_validates() {
1967        let yaml = include_str!("../../../configs/jobs/collect-diagnostics.yaml");
1968        let m: Manifest = serde_yaml::from_str(yaml).expect("collect-diagnostics parse");
1969        m.validate().expect("collect-diagnostics validate");
1970        assert_eq!(m.id, "collect-diagnostics");
1971        let collect = m.collect.as_ref().expect("collect: block present");
1972        assert!(!collect.name.trim().is_empty());
1973        assert_eq!(collect.files_field, "files");
1974        assert_eq!(collect.max_size_bytes(), 50_000_000);
1975        // collect + client compose — the Client App can trigger it.
1976        assert!(
1977            m.client.is_some(),
1978            "collect-diagnostics also carries client:"
1979        );
1980    }
1981
1982    /// The `emit: { type: events }` collector jobs under
1983    /// `configs/jobs/` feed the obs_events timeline. `include_str!`
1984    /// pins them at compile time so a breaking edit (e.g. an `emit:`
1985    /// paired with `check:`/`inventory:`, a bad watermark field, or a
1986    /// YAML typo in the PowerShell block) fails `cargo test` rather
1987    /// than `kanade job create` at deploy. Every one must carry an
1988    /// `emit.type=events` block and NO check/inventory (validate()
1989    /// rejects the pairing).
1990    #[test]
1991    fn example_event_collector_job_yamls_parse_and_validate() {
1992        let jobs = [
1993            // collect-winlog-events was retired in #841 PR2 — the scheduled
1994            // human-session / power timeline is now read natively by the
1995            // agent (kanade-agent `winlog` module via EvtQuery), no
1996            // PowerShell job. collect-winlog-logons-all stays as the
1997            // on-demand forensic all-token-logons companion.
1998            (
1999                "collect-winlog-logons-all",
2000                include_str!("../../../configs/jobs/collect-winlog-logons-all.yaml"),
2001            ),
2002            (
2003                "collect-wlan-events",
2004                include_str!("../../../configs/jobs/collect-wlan-events.yaml"),
2005            ),
2006        ];
2007        for (id, yaml) in jobs {
2008            // Strict parse so an unknown-key typo in these fixtures fails
2009            // here (not silently at deploy) — the runtime Manifest is
2010            // unknown-key-tolerant, so the lenient serde_yaml::from_str
2011            // wouldn't catch fixture drift (CodeRabbit #689).
2012            let m: Manifest =
2013                crate::strict::from_yaml_str(yaml).unwrap_or_else(|e| panic!("{id} parse: {e}"));
2014            m.validate()
2015                .unwrap_or_else(|e| panic!("{id} validate: {e}"));
2016            assert_eq!(m.id, id, "{id} id mismatch");
2017            let emit = m
2018                .emit
2019                .as_ref()
2020                .unwrap_or_else(|| panic!("{id} must carry an emit: block"));
2021            assert_eq!(emit.kind, EmitKind::Events, "{id} emit.type");
2022            assert!(
2023                m.check.is_none() && m.inventory.is_none(),
2024                "{id}: emit jobs must not pair with check/inventory"
2025            );
2026        }
2027    }
2028
2029    /// The `inventory:` snapshot jobs under `configs/jobs/` project
2030    /// facts into `inventory_facts` + exploded tables. `include_str!`
2031    /// pins them at compile time so a breaking edit (bad explode
2032    /// schema, a YAML typo in the PowerShell block, an `inventory:`
2033    /// accidentally paired with `emit:`) fails `cargo test` rather
2034    /// than the projector at deploy. Each must carry an `inventory:`
2035    /// block and NO emit (validate() rejects the pairing).
2036    #[test]
2037    fn example_inventory_job_yamls_parse_and_validate() {
2038        let jobs = [
2039            (
2040                "inventory-hw",
2041                include_str!("../../../configs/jobs/inventory-hw.yaml"),
2042            ),
2043            (
2044                "inventory-sw",
2045                include_str!("../../../configs/jobs/inventory-sw.yaml"),
2046            ),
2047            (
2048                "inventory-driver",
2049                include_str!("../../../configs/jobs/inventory-driver.yaml"),
2050            ),
2051        ];
2052        for (id, yaml) in jobs {
2053            let m: Manifest =
2054                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{id} parse: {e}"));
2055            m.validate()
2056                .unwrap_or_else(|e| panic!("{id} validate: {e}"));
2057            assert_eq!(m.id, id, "{id} id mismatch");
2058            assert!(m.inventory.is_some(), "{id} must carry an inventory: block");
2059            assert!(m.emit.is_none(), "{id}: inventory jobs must not set emit:");
2060        }
2061    }
2062
2063    #[test]
2064    fn example_check_schedule_yamls_parse_and_validate() {
2065        let schedules = [
2066            (
2067                "check-bitlocker",
2068                include_str!("../../../configs/schedules/check-bitlocker.yaml"),
2069            ),
2070            (
2071                "check-av-signature",
2072                include_str!("../../../configs/schedules/check-av-signature.yaml"),
2073            ),
2074            (
2075                "check-cert-expiry",
2076                include_str!("../../../configs/schedules/check-cert-expiry.yaml"),
2077            ),
2078            (
2079                "check-disk-space",
2080                include_str!("../../../configs/schedules/check-disk-space.yaml"),
2081            ),
2082            (
2083                "check-pending-reboot",
2084                include_str!("../../../configs/schedules/check-pending-reboot.yaml"),
2085            ),
2086            (
2087                "check-defender-rtp",
2088                include_str!("../../../configs/schedules/check-defender-rtp.yaml"),
2089            ),
2090            (
2091                "check-firewall",
2092                include_str!("../../../configs/schedules/check-firewall.yaml"),
2093            ),
2094        ];
2095        for (name, yaml) in schedules {
2096            let s: Schedule =
2097                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} schedule parse: {e}"));
2098            s.validate()
2099                .unwrap_or_else(|e| panic!("{name} schedule validate: {e}"));
2100            assert_eq!(s.job_id, name, "{name} schedule must reference its job");
2101        }
2102    }
2103
2104    /// Inventory schedule wrappers (`per_pc` cadence) must stay valid
2105    /// alongside the schedule schema. `include_str!` pins them so a
2106    /// breaking edit fails `cargo test`, not `kanade schedule create`.
2107    #[test]
2108    fn example_inventory_schedule_yamls_parse_and_validate() {
2109        let schedules = [
2110            (
2111                "inventory-hw",
2112                include_str!("../../../configs/schedules/inventory-hw.yaml"),
2113            ),
2114            (
2115                "inventory-sw",
2116                include_str!("../../../configs/schedules/inventory-sw.yaml"),
2117            ),
2118            (
2119                "inventory-driver",
2120                include_str!("../../../configs/schedules/inventory-driver.yaml"),
2121            ),
2122        ];
2123        for (name, yaml) in schedules {
2124            let s: Schedule =
2125                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} schedule parse: {e}"));
2126            s.validate()
2127                .unwrap_or_else(|e| panic!("{name} schedule validate: {e}"));
2128            assert_eq!(s.job_id, name, "{name} schedule must reference its job");
2129        }
2130    }
2131
2132    #[test]
2133    fn target_is_specified_requires_at_least_one_field() {
2134        let empty = Target::default();
2135        assert!(!empty.is_specified());
2136
2137        let with_all = Target {
2138            all: true,
2139            ..Target::default()
2140        };
2141        assert!(with_all.is_specified());
2142
2143        let with_groups = Target {
2144            groups: vec!["canary".into()],
2145            ..Target::default()
2146        };
2147        assert!(with_groups.is_specified());
2148
2149        let with_pcs = Target {
2150            pcs: vec!["pc-01".into()],
2151            ..Target::default()
2152        };
2153        assert!(with_pcs.is_specified());
2154    }
2155
2156    #[test]
2157    fn manifest_deserialises_minimal_yaml() {
2158        // Matches jobs/echo-test.yaml. v0.18: no target/rollout/jitter
2159        // — those live on the schedule / exec request now.
2160        let yaml = r#"
2161id: echo-test
2162version: 0.0.1
2163execute:
2164  shell: powershell
2165  script: "echo 'kanade'"
2166  timeout: 30s
2167"#;
2168        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2169        assert_eq!(m.id, "echo-test");
2170        assert_eq!(m.version, "0.0.1");
2171        assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
2172        assert_eq!(
2173            m.execute.script.as_deref().map(str::trim),
2174            Some("echo 'kanade'")
2175        );
2176        assert!(m.execute.script_file.is_none());
2177        assert!(m.execute.script_object.is_none());
2178        assert_eq!(m.execute.timeout, "30s");
2179        assert!(!m.require_approval);
2180        m.validate()
2181            .expect("inline-script manifest passes validation");
2182    }
2183
2184    #[test]
2185    fn manifest_parses_check_job_and_validates() {
2186        // An operator-defined health check (#290): a `check:` hint +
2187        // a PowerShell script that prints {status, detail}.
2188        let yaml = r#"
2189id: check-bitlocker
2190version: 0.1.0
2191execute:
2192  shell: powershell
2193  run_as: system
2194  timeout: 15s
2195  script: |
2196    [pscustomobject]@{ status = 'ok'; detail = 'all volumes protected' } | ConvertTo-Json -Compress
2197check:
2198  name: bitlocker
2199  troubleshoot: fix-bitlocker
2200"#;
2201        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2202        let check = m.check.as_ref().expect("check hint present");
2203        assert_eq!(check.name, "bitlocker");
2204        assert_eq!(check.troubleshoot.as_deref(), Some("fix-bitlocker"));
2205        // Field names default to the conventional "status" / "detail".
2206        assert_eq!(check.status_field, "status");
2207        assert_eq!(check.detail_field, "detail");
2208        assert!(m.inventory.is_none() && m.emit.is_none());
2209        m.validate().expect("check-only manifest passes validation");
2210    }
2211
2212    #[test]
2213    fn manifest_check_defaults_and_custom_fields() {
2214        // Minimal: only `name`; status/detail fields default.
2215        let m: Manifest = serde_yaml::from_str(
2216            r#"
2217id: check-disk
2218version: 0.1.0
2219execute:
2220  shell: powershell
2221  script: "[pscustomobject]@{ status = 'ok' } | ConvertTo-Json -Compress"
2222  timeout: 10s
2223check:
2224  name: disk_free
2225"#,
2226        )
2227        .expect("parse");
2228        let c = m.check.as_ref().unwrap();
2229        assert_eq!(c.name, "disk_free");
2230        assert_eq!(c.status_field, "status");
2231        assert_eq!(c.detail_field, "detail");
2232        assert!(c.troubleshoot.is_none());
2233        m.validate().expect("validates");
2234
2235        // The operator can point status/detail at any field of their
2236        // free-form inventory object.
2237        let m2: Manifest = serde_yaml::from_str(
2238            r#"
2239id: check-custom
2240version: 0.1.0
2241execute:
2242  shell: powershell
2243  script: "echo x"
2244  timeout: 10s
2245check:
2246  name: patch_level
2247  status_field: compliance
2248  detail_field: summary
2249"#,
2250        )
2251        .expect("parse");
2252        let c2 = m2.check.as_ref().unwrap();
2253        assert_eq!(c2.status_field, "compliance");
2254        assert_eq!(c2.detail_field, "summary");
2255    }
2256
2257    #[test]
2258    fn manifest_allows_check_composed_with_inventory() {
2259        // `check:` + `inventory:` COMPOSE on the same stdout object:
2260        // status/detail → Health tab, the rest → SPA projection +
2261        // explode sub-tables. Must pass validation.
2262        let yaml = r#"
2263id: check-bitlocker-detailed
2264version: 0.1.0
2265execute:
2266  shell: powershell
2267  script: "echo x"
2268  timeout: 10s
2269check:
2270  name: bitlocker
2271inventory:
2272  display:
2273    - { field: status, label: Status }
2274"#;
2275        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2276        assert!(m.check.is_some() && m.inventory.is_some());
2277        m.validate().expect("check + inventory compose");
2278    }
2279
2280    #[test]
2281    fn manifest_parses_collect_job_and_validates() {
2282        // #219: a `collect:` hint + a script that lists files on stdout.
2283        let yaml = r#"
2284id: collect-diagnostics
2285version: 0.1.0
2286execute:
2287  shell: powershell
2288  run_as: system
2289  timeout: 120s
2290  script: |
2291    @{ files = @("$env:KANADE_COLLECT_DIR/system.csv") } | ConvertTo-Json
2292collect:
2293  name: "Full diagnostics"
2294  description: "Event logs + process"
2295  max_size: 50MB
2296"#;
2297        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2298        let c = m.collect.as_ref().expect("collect hint present");
2299        assert_eq!(c.name, "Full diagnostics");
2300        assert_eq!(c.files_field, "files"); // default
2301        assert_eq!(c.max_size_bytes(), 50_000_000);
2302        m.validate().expect("collect-only manifest validates");
2303    }
2304
2305    #[test]
2306    fn manifest_finalize_powershell_validates_and_lowers() {
2307        let yaml = r#"
2308id: collect-fin
2309version: 0.1.0
2310execute:
2311  shell: powershell
2312  timeout: 120s
2313  script: |
2314    @{ files = @() } | ConvertTo-Json
2315collect:
2316  name: "diag"
2317  max_size: 50MB
2318finalize:
2319  shell: powershell
2320  timeout: 30s
2321  run_as: system
2322  script: |
2323    Write-Output "cleanup"
2324"#;
2325        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2326        m.validate().expect("powershell finalize validates");
2327        let lowered = m.finalize.as_ref().expect("finalize present").lower();
2328        assert_eq!(lowered.timeout_secs, 30);
2329        assert!(matches!(lowered.shell, Shell::Powershell));
2330    }
2331
2332    #[test]
2333    fn manifest_finalize_rejects_cmd_shell() {
2334        // cmd finalize is an injection risk (the agent injects JSON into
2335        // the hook's env; cmd.exe quoting doesn't nest) — validate must
2336        // reject it.
2337        let yaml = r#"
2338id: collect-fin-cmd
2339version: 0.1.0
2340execute:
2341  shell: powershell
2342  timeout: 120s
2343  script: |
2344    @{ files = @() } | ConvertTo-Json
2345finalize:
2346  shell: cmd
2347  script: |
2348    echo hi
2349"#;
2350        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2351        let err = m.validate().expect_err("cmd finalize rejected");
2352        assert!(err.contains("finalize.shell"), "got: {err}");
2353    }
2354
2355    #[test]
2356    fn manifest_finalize_rejects_empty_script() {
2357        let yaml = r#"
2358id: collect-fin-empty
2359version: 0.1.0
2360execute:
2361  shell: powershell
2362  timeout: 120s
2363  script: |
2364    @{ files = @() } | ConvertTo-Json
2365finalize:
2366  shell: powershell
2367  script: "   "
2368"#;
2369        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2370        let err = m.validate().expect_err("empty finalize script rejected");
2371        assert!(err.contains("finalize.script"), "got: {err}");
2372    }
2373
2374    #[test]
2375    fn manifest_collect_max_size_defaults_when_unset() {
2376        let m: Manifest = serde_yaml::from_str(
2377            r#"
2378id: collect-min
2379version: 0.1.0
2380execute:
2381  shell: powershell
2382  script: "echo x"
2383  timeout: 10s
2384collect:
2385  name: minimal
2386"#,
2387        )
2388        .expect("parse");
2389        let c = m.collect.as_ref().unwrap();
2390        assert!(c.max_size.is_none());
2391        assert_eq!(c.max_size_bytes(), DEFAULT_COLLECT_MAX_SIZE);
2392        m.validate().expect("validates");
2393    }
2394
2395    #[test]
2396    fn manifest_allows_collect_with_client() {
2397        // collect composes with client (client doesn't touch stdout):
2398        // an end user can trigger a collection from the Client App.
2399        let yaml = r#"
2400id: collect-diag-client
2401version: 0.1.0
2402execute:
2403  shell: powershell
2404  script: "echo x"
2405  timeout: 10s
2406collect:
2407  name: diagnostics
2408client:
2409  name: "Send diagnostics"
2410  category: troubleshoot
2411"#;
2412        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2413        assert!(m.collect.is_some() && m.client.is_some());
2414        m.validate().expect("collect + client compose");
2415    }
2416
2417    #[test]
2418    fn manifest_allows_inventory_check_collect_coexistence() {
2419        // #821: the three fenced hints now COMPOSE — each reads its own
2420        // `#KANADE-<KIND>` stdout block, so one job can do all three.
2421        let yaml = r#"
2422id: multi-hint
2423version: 0.1.0
2424execute:
2425  shell: powershell
2426  script: "echo x"
2427  timeout: 10s
2428inventory:
2429  display:
2430    - { field: status, label: Status }
2431check:
2432  name: health
2433collect:
2434  name: diag
2435"#;
2436        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2437        m.validate()
2438            .expect("inventory + check + collect coexist after #821");
2439    }
2440
2441    #[test]
2442    fn manifest_rejects_emit_combined_with_fenced_hints() {
2443        // `emit:` consumes stdout as NDJSON (and blanks it), so it still
2444        // can't share with any fenced hint — inventory, check, OR collect.
2445        for extra in [
2446            "inventory:\n  display:\n    - { field: s, label: S }\n",
2447            "check:\n  name: health\n",
2448            "collect:\n  name: diag\n",
2449        ] {
2450            let yaml = format!(
2451                "id: bad-emit-mix\nversion: 0.1.0\nexecute:\n  shell: powershell\n  \
2452                 script: \"echo x\"\n  timeout: 10s\nemit:\n  type: events\n{extra}"
2453            );
2454            let m: Manifest = serde_yaml::from_str(&yaml).expect("parse");
2455            let err = m
2456                .validate()
2457                .expect_err("emit + fenced hint must be rejected");
2458            assert!(err.contains("emit"), "error mentions emit: {err}");
2459        }
2460    }
2461
2462    #[test]
2463    fn manifest_rejects_collect_empty_name_and_bad_size() {
2464        let empty_name: Manifest = serde_yaml::from_str(
2465            r#"
2466id: c
2467version: 0.1.0
2468execute: { shell: powershell, script: "echo x", timeout: 10s }
2469collect: { name: "  " }
2470"#,
2471        )
2472        .expect("parse");
2473        assert!(
2474            empty_name.validate().is_err(),
2475            "blank collect.name rejected"
2476        );
2477
2478        let bad_size: Manifest = serde_yaml::from_str(
2479            r#"
2480id: c
2481version: 0.1.0
2482execute: { shell: powershell, script: "echo x", timeout: 10s }
2483collect: { name: diag, max_size: "50 quux" }
2484"#,
2485        )
2486        .expect("parse");
2487        let err = bad_size.validate().expect_err("bad max_size rejected");
2488        assert!(err.contains("max_size"), "error mentions max_size: {err}");
2489    }
2490
2491    #[test]
2492    fn parse_size_bytes_units() {
2493        assert_eq!(parse_size_bytes("1024").unwrap(), 1024);
2494        assert_eq!(parse_size_bytes("1B").unwrap(), 1);
2495        assert_eq!(parse_size_bytes("50MB").unwrap(), 50_000_000);
2496        assert_eq!(parse_size_bytes("500 KB").unwrap(), 500_000);
2497        assert_eq!(parse_size_bytes("1GiB").unwrap(), 1024 * 1024 * 1024);
2498        assert_eq!(parse_size_bytes("2mib").unwrap(), 2 * 1024 * 1024);
2499        assert!(parse_size_bytes("").is_err());
2500        assert!(parse_size_bytes("MB").is_err());
2501        assert!(parse_size_bytes("12 zonks").is_err());
2502    }
2503
2504    #[test]
2505    fn manifest_rejects_check_combined_with_emit() {
2506        // `emit:` stdout is NDJSON (and omitted from the result), so
2507        // it can't pair with `check:` (which needs a single JSON
2508        // object on stdout).
2509        let yaml = r#"
2510id: bad-mix
2511version: 0.1.0
2512execute:
2513  shell: powershell
2514  script: "echo x"
2515  timeout: 10s
2516check:
2517  name: bitlocker
2518emit:
2519  type: events
2520"#;
2521        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2522        let err = m.validate().expect_err("emit + check must fail");
2523        assert!(err.contains("incompatible"), "err: {err}");
2524    }
2525
2526    #[test]
2527    fn manifest_rejects_emit_combined_with_inventory() {
2528        // The other half of the emit-incompatibility condition.
2529        let yaml = r#"
2530id: bad-mix-2
2531version: 0.1.0
2532execute:
2533  shell: powershell
2534  script: "echo x"
2535  timeout: 10s
2536emit:
2537  type: events
2538inventory:
2539  display:
2540    - { field: status, label: Status }
2541"#;
2542        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2543        let err = m.validate().expect_err("emit + inventory must fail");
2544        assert!(err.contains("incompatible"), "err: {err}");
2545    }
2546
2547    #[test]
2548    fn manifest_rejects_empty_check_field_names() {
2549        // Empty name / status_field / detail_field are invisible
2550        // runtime bugs (empty React key, agent reads the wrong field)
2551        // — reject them even though serde supplies non-empty defaults.
2552        let base = |inner: &str| {
2553            format!(
2554                "id: c\nversion: 0.1.0\nexecute:\n  shell: powershell\n  script: \"echo x\"\n  timeout: 10s\ncheck:\n{inner}"
2555            )
2556        };
2557        for inner in [
2558            "  name: \"\"\n",
2559            "  name: ok\n  status_field: \"\"\n",
2560            "  name: ok\n  detail_field: \"   \"\n",
2561            // present-but-blank troubleshoot → broken remediation id.
2562            "  name: ok\n  troubleshoot: \"  \"\n",
2563        ] {
2564            let m: Manifest = serde_yaml::from_str(&base(inner)).expect("parse");
2565            let err = m.validate().expect_err("empty field must fail");
2566            assert!(err.contains("must not be empty"), "err: {err}");
2567        }
2568    }
2569
2570    #[test]
2571    fn check_alert_decodes_with_defaults_and_validates() {
2572        let yaml = r#"
2573id: c
2574version: 0.1.0
2575execute:
2576  shell: powershell
2577  script: "echo x"
2578  timeout: 10s
2579check:
2580  name: bitlocker
2581  alert:
2582    notify_user: true
2583    title: "BitLocker 未準拠"
2584"#;
2585        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2586        m.validate().expect("valid alert");
2587        let alert = m.check.unwrap().alert.unwrap();
2588        // Defaults: on = [fail], priority = warn, body = None.
2589        assert_eq!(alert.on, vec![CheckAlertStatus::Fail]);
2590        assert_eq!(
2591            alert.priority,
2592            crate::ipc::notifications::NotificationPriority::Warn
2593        );
2594        assert!(alert.body.is_none());
2595        assert!(alert.notify_user);
2596    }
2597
2598    #[test]
2599    fn check_alert_validation_rejects_bad_configs() {
2600        let base = |alert: &str| {
2601            format!(
2602                "id: c\nversion: 0.1.0\nexecute:\n  shell: powershell\n  script: \"echo x\"\n  timeout: 10s\ncheck:\n  name: bitlocker\n  alert:\n{alert}"
2603            )
2604        };
2605        let cases = [
2606            // No recipient.
2607            ("    title: t\n", "notify_user and/or notify_groups"),
2608            // Empty title.
2609            (
2610                "    notify_user: true\n    title: \"  \"\n",
2611                "title must not be empty",
2612            ),
2613            // Empty `on`.
2614            (
2615                "    notify_user: true\n    title: t\n    on: []\n",
2616                "on must list at least one status",
2617            ),
2618            // Blank group name.
2619            (
2620                "    notify_groups: [\"  \"]\n    title: t\n",
2621                "notify_groups must not contain blanks",
2622            ),
2623            // alert requires fleet: true.
2624            (
2625                "    notify_user: true\n    title: t\n  fleet: false\n",
2626                "requires fleet: true",
2627            ),
2628            // email opt-in without a group to address.
2629            (
2630                "    notify_user: true\n    email: true\n    title: t\n",
2631                "email requires notify_groups",
2632            ),
2633        ];
2634        for (alert, want) in cases {
2635            let m: Manifest = serde_yaml::from_str(&base(alert)).expect("parse");
2636            let err = m.validate().expect_err("bad alert must fail");
2637            assert!(err.contains(want), "for {alert:?}: got {err}");
2638        }
2639    }
2640
2641    #[test]
2642    fn manifest_client_absent_by_default() {
2643        // A plain operator job (the overwhelming majority) carries no
2644        // `client:` block, so it never surfaces in the end-user
2645        // catalog.
2646        let yaml = r#"
2647id: echo-test
2648version: 0.0.1
2649execute:
2650  shell: powershell
2651  script: "echo 'kanade'"
2652  timeout: 30s
2653"#;
2654        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2655        assert!(m.client.is_none());
2656        m.validate().expect("operator-only job validates");
2657    }
2658
2659    #[test]
2660    fn manifest_client_parses_and_validates() {
2661        // The Client App "困ったとき" remediation job shape: a
2662        // user-invokable troubleshoot job with the end-user fields the
2663        // KLP `jobs.list` wire needs, grouped under `client:`.
2664        let yaml = r#"
2665id: fix-teams-cache
2666version: 1.0.0
2667execute:
2668  shell: powershell
2669  script: "echo clearing"
2670  timeout: 60s
2671client:
2672  name: "Teams のキャッシュをクリア"
2673  description: "Teams が重いときに試してください"
2674  category: troubleshoot
2675  icon: brush-cleaning
2676"#;
2677        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2678        let c = m.client.as_ref().expect("client block present");
2679        assert_eq!(c.name, "Teams のキャッシュをクリア");
2680        assert_eq!(
2681            c.description.as_deref(),
2682            Some("Teams が重いときに試してください")
2683        );
2684        assert_eq!(c.category, "troubleshoot");
2685        assert_eq!(c.icon.as_deref(), Some("brush-cleaning"));
2686        m.validate().expect("user-invokable job validates");
2687    }
2688
2689    #[test]
2690    fn manifest_client_minimal_only_name_and_category() {
2691        // description + icon are optional; name + category are the
2692        // serde-required minimum.
2693        let yaml = r#"
2694id: install-slack
2695version: 1.0.0
2696execute:
2697  shell: powershell
2698  script: "echo install"
2699  timeout: 600s
2700client:
2701  name: Slack
2702  category: catalog
2703"#;
2704        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2705        let c = m.client.as_ref().expect("client present");
2706        assert_eq!(c.category, "catalog");
2707        assert!(c.description.is_none() && c.icon.is_none());
2708        m.validate().expect("minimal client validates");
2709    }
2710
2711    #[test]
2712    fn manifest_client_rejects_blank_name() {
2713        // serde guarantees `name`/`category` are present; the one gap
2714        // is a present-but-blank name → empty catalog row title.
2715        let yaml = r#"
2716id: j
2717version: 1.0.0
2718execute:
2719  shell: powershell
2720  script: "echo x"
2721  timeout: 30s
2722client:
2723  name: "   "
2724  category: catalog
2725"#;
2726        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2727        let err = m.validate().expect_err("blank name must fail");
2728        assert!(err.contains("client.name"), "err: {err}");
2729    }
2730
2731    #[test]
2732    fn manifest_client_rejects_blank_optional_fields() {
2733        // description / icon are optional, but a present-but-blank
2734        // value is a bug (empty subtitle / dangling icon name) — reject
2735        // it, mirroring the check: block's troubleshoot guard.
2736        for (field, line) in [
2737            ("client.description", "  description: \"  \"\n"),
2738            ("client.icon", "  icon: \"\"\n"),
2739            // #792: the new category tab-metadata fields get the same
2740            // present-but-blank guard.
2741            ("client.category_label", "  category_label: \"  \"\n"),
2742            ("client.category_icon", "  category_icon: \"\"\n"),
2743        ] {
2744            let yaml = format!(
2745                "id: j\nversion: 1.0.0\nexecute:\n  shell: powershell\n  script: \"echo x\"\n  timeout: 30s\nclient:\n  name: A\n  category: catalog\n{line}"
2746            );
2747            let m: Manifest = serde_yaml::from_str(&yaml).expect("parse");
2748            let err = m.validate().expect_err("blank optional field must fail");
2749            assert!(err.contains(field), "expected {field} in err: {err}");
2750        }
2751    }
2752
2753    #[test]
2754    fn manifest_client_rejects_blank_category() {
2755        // #792: category is a free-form key now; serde keeps it required,
2756        // but a present-but-blank value would group the job under an empty
2757        // tab — validate() must reject it.
2758        let yaml = r#"
2759id: j
2760version: 1.0.0
2761execute:
2762  shell: powershell
2763  script: "echo x"
2764  timeout: 30s
2765client:
2766  name: "A job"
2767  category: "   "
2768"#;
2769        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2770        let err = m.validate().expect_err("blank category must fail");
2771        assert!(err.contains("client.category"), "err: {err}");
2772    }
2773
2774    #[test]
2775    fn target_matches_pc_group_and_all() {
2776        // #816: pc match, group match, all, and the no-match case.
2777        let by_pc = Target {
2778            pcs: vec!["PC1".into()],
2779            ..Default::default()
2780        };
2781        assert!(by_pc.matches("PC1", &[]));
2782        assert!(!by_pc.matches("PC2", &["g1".into()]));
2783
2784        let by_group = Target {
2785            groups: vec!["g1".into()],
2786            ..Default::default()
2787        };
2788        assert!(by_group.matches("PC2", &["g1".into()]));
2789        assert!(!by_group.matches("PC2", &["g2".into()]));
2790
2791        let all = Target {
2792            all: true,
2793            ..Default::default()
2794        };
2795        assert!(all.matches("anyPC", &[]));
2796    }
2797
2798    #[test]
2799    fn manifest_client_rejects_empty_visible_to() {
2800        // #816: a present-but-empty visible_to (no all/groups/pcs) would
2801        // hide the job from everyone — validate() must reject it.
2802        let yaml = r#"
2803id: j
2804version: 1.0.0
2805execute:
2806  shell: powershell
2807  script: "echo x"
2808  timeout: 30s
2809client:
2810  name: "A job"
2811  category: troubleshoot
2812  visible_to: {}
2813"#;
2814        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2815        let err = m.validate().expect_err("empty visible_to must fail");
2816        assert!(err.contains("client.visible_to"), "err: {err}");
2817    }
2818
2819    #[test]
2820    fn manifest_client_accepts_visible_to_groups() {
2821        let yaml = r#"
2822id: j
2823version: 1.0.0
2824execute:
2825  shell: powershell
2826  script: "echo x"
2827  timeout: 30s
2828client:
2829  name: "A job"
2830  category: settings
2831  visible_to:
2832    groups: [wifi-affected]
2833"#;
2834        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2835        m.validate().expect("visible_to with a group validates");
2836        let vt = m.client.unwrap().visible_to.unwrap();
2837        assert_eq!(vt.groups, vec!["wifi-affected".to_string()]);
2838    }
2839
2840    #[test]
2841    fn manifest_client_requires_category_at_parse() {
2842        // A `client:` block missing `category` is a hard parse error
2843        // (serde required field) — no manual validate() needed.
2844        let yaml = r#"
2845id: j
2846version: 1.0.0
2847execute:
2848  shell: powershell
2849  script: "echo x"
2850  timeout: 30s
2851client:
2852  name: "A job"
2853"#;
2854        let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
2855        assert!(
2856            r.is_err(),
2857            "missing category must be a parse error, got {r:?}"
2858        );
2859    }
2860
2861    #[test]
2862    fn manifest_client_rejects_unknown_field() {
2863        // #492: the strict create boundary catches a fat-fingered
2864        // `displayname:` (with its path) instead of silently
2865        // dropping it; the tolerant read path accepts it.
2866        let yaml = r#"
2867id: j
2868version: 1.0.0
2869execute:
2870  shell: powershell
2871  script: "echo x"
2872  timeout: 30s
2873client:
2874  name: "A job"
2875  category: catalog
2876  displayname: oops
2877"#;
2878        let r = crate::strict::from_yaml_str::<Manifest>(yaml);
2879        let err = r.expect_err("unknown client field must be rejected at the write boundary");
2880        // serde_ignored renders the Option layer as `?`:
2881        // `client.?.displayname`. Assert on the leaf key.
2882        assert!(err.contains("displayname"), "{err}");
2883        // The READ path tolerates the same payload (gradual-upgrade
2884        // contract: an old agent must accept a newer writer's field).
2885        let m: Manifest = serde_yaml::from_str(yaml).expect("tolerant read");
2886        assert_eq!(m.client.as_ref().map(|c| c.name.as_str()), Some("A job"));
2887    }
2888
2889    #[test]
2890    fn manifest_tags_default_empty() {
2891        // The overwhelming majority of jobs carry no tags; the field
2892        // must default to an empty Vec (not fail to parse) and skip
2893        // serialisation so old readers never see the key.
2894        let yaml = r#"
2895id: echo-test
2896version: 0.0.1
2897execute:
2898  shell: powershell
2899  script: "echo 'kanade'"
2900  timeout: 30s
2901"#;
2902        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2903        assert!(m.tags.is_empty());
2904        m.validate().expect("tag-less job validates");
2905        // skip_serializing_if = empty ⇒ the key is absent from JSON.
2906        let json = serde_json::to_string(&m).expect("serialize");
2907        assert!(
2908            !json.contains("tags"),
2909            "empty tags must not serialise: {json}"
2910        );
2911    }
2912
2913    #[test]
2914    fn manifest_parses_and_validates_tags() {
2915        let yaml = r#"
2916id: check-bitlocker
2917version: 0.1.0
2918execute:
2919  shell: powershell
2920  script: "echo x"
2921  timeout: 30s
2922tags: [security, windows, health-check]
2923"#;
2924        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2925        assert_eq!(m.tags, vec!["security", "windows", "health-check"]);
2926        m.validate().expect("tagged job validates");
2927        // Round-trips through JSON (the wire format the SPA reads).
2928        let json = serde_json::to_string(&m).expect("serialize");
2929        assert!(json.contains("\"tags\""), "non-empty tags must serialise");
2930    }
2931
2932    #[test]
2933    fn manifest_rejects_blank_tag() {
2934        // A whitespace-only tag renders an empty filter chip — reject
2935        // it at the write boundary like the other blank display fields.
2936        let yaml = r#"
2937id: j
2938version: 0.1.0
2939execute:
2940  shell: powershell
2941  script: "echo x"
2942  timeout: 30s
2943tags: [ok, "   "]
2944"#;
2945        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2946        let err = m.validate().expect_err("blank tag must fail");
2947        assert!(err.contains("tags must not contain empty"), "err: {err}");
2948    }
2949
2950    // #720 — wrap an `aggregate:` YAML block (already indented as a
2951    // top-level key body) into an otherwise-minimal valid manifest.
2952    fn manifest_with_aggregate(aggregate_block: &str) -> Manifest {
2953        let yaml = format!(
2954            "id: t\nversion: 0.0.1\nexecute:\n  shell: powershell\n  script: echo hi\n  timeout: 30s\n{aggregate_block}"
2955        );
2956        serde_yaml::from_str(&yaml).expect("parse aggregate manifest")
2957    }
2958
2959    #[test]
2960    fn aggregate_accepts_full_valid_spec() {
2961        // count+group_by+exclude+sample_minutes, ratio+bool_path,
2962        // timeline+time_bucket, fleet ranking via group_by: pc_id, and a
2963        // bare total stat — alongside emit (composes with every hint).
2964        let m = manifest_with_aggregate(
2965            "emit:\n  type: events\naggregate:\n\
2966             - { dashboard: Utilization, title: Top apps, kind: app_sample, agg: count, group_by: foreground.app, sample_minutes: 2, exclude: [LockApp], render: bar }\n\
2967             - { dashboard: Utilization, title: Active ratio, kind: presence, agg: ratio, bool_path: active, sample_minutes: 5, render: gauge }\n\
2968             - { dashboard: Utilization, title: By hour, kind: presence, agg: ratio, bool_path: active, time_bucket: hour, render: timeline }\n\
2969             - { dashboard: Reliability, title: Crashes by PC, scope: fleet, kind: unexpected_shutdown, agg: count, group_by: pc_id, render: bar }\n\
2970             - { dashboard: Reliability, title: Total crashes, scope: fleet, kind: unexpected_shutdown, agg: count, render: stat }\n",
2971        );
2972        m.validate().expect("valid aggregate spec");
2973    }
2974
2975    #[test]
2976    fn aggregate_rejects_empty_list() {
2977        let m = manifest_with_aggregate("aggregate: []\n");
2978        let err = m.validate().expect_err("empty list must fail");
2979        assert!(err.contains("at least one widget"), "err: {err}");
2980    }
2981
2982    #[test]
2983    fn aggregate_rejects_ratio_without_bool_path() {
2984        let m = manifest_with_aggregate(
2985            "aggregate:\n- { dashboard: D, title: T, kind: presence, agg: ratio, render: gauge }\n",
2986        );
2987        let err = m.validate().expect_err("ratio needs bool_path");
2988        assert!(err.contains("agg=ratio requires `bool_path`"), "err: {err}");
2989    }
2990
2991    #[test]
2992    fn aggregate_rejects_sum_without_value_path() {
2993        let m = manifest_with_aggregate(
2994            "aggregate:\n- { dashboard: D, title: T, kind: io, agg: sum, render: bar }\n",
2995        );
2996        let err = m.validate().expect_err("sum needs value_path");
2997        assert!(err.contains("agg=sum requires `value_path`"), "err: {err}");
2998    }
2999
3000    #[test]
3001    fn aggregate_rejects_pc_id_group_without_fleet() {
3002        let m = manifest_with_aggregate(
3003            "aggregate:\n- { dashboard: D, title: T, kind: presence, agg: count, group_by: pc_id, render: bar }\n",
3004        );
3005        let err = m.validate().expect_err("pc_id grouping needs fleet");
3006        assert!(
3007            err.contains("pc_id is only valid with scope: fleet"),
3008            "err: {err}"
3009        );
3010    }
3011
3012    #[test]
3013    fn aggregate_rejects_transform_with_pc_id_group() {
3014        let m = manifest_with_aggregate(
3015            "aggregate:\n- { dashboard: D, title: T, scope: fleet, kind: web_visit, agg: count, group_by: pc_id, transform: host, render: bar }\n",
3016        );
3017        let err = m
3018            .validate()
3019            .expect_err("transform on pc_id grouping must fail");
3020        assert!(
3021            err.contains("transform is not valid with group_by: pc_id"),
3022            "err: {err}"
3023        );
3024    }
3025
3026    #[test]
3027    fn aggregate_rejects_timeline_without_bucket() {
3028        let m = manifest_with_aggregate(
3029            "aggregate:\n- { dashboard: D, title: T, kind: presence, agg: ratio, bool_path: active, render: timeline }\n",
3030        );
3031        let err = m.validate().expect_err("timeline needs a bucket");
3032        assert!(
3033            err.contains("render=timeline requires `time_bucket`"),
3034            "err: {err}"
3035        );
3036    }
3037
3038    #[test]
3039    fn aggregate_rejects_bucket_on_non_timeline() {
3040        let m = manifest_with_aggregate(
3041            "aggregate:\n- { dashboard: D, title: T, kind: presence, agg: ratio, bool_path: active, time_bucket: hour, render: gauge }\n",
3042        );
3043        let err = m.validate().expect_err("bucket only on timeline");
3044        assert!(
3045            err.contains("time_bucket is only valid with render: timeline"),
3046            "err: {err}"
3047        );
3048    }
3049
3050    #[test]
3051    fn aggregate_rejects_unsafe_json_path() {
3052        // A path with characters outside [A-Za-z0-9_.] could break out of
3053        // the `'$.' || ?` bind — reject at create time.
3054        let m = manifest_with_aggregate(
3055            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, group_by: \"foo'; DROP\", render: bar }\n",
3056        );
3057        let err = m.validate().expect_err("unsafe path must fail");
3058        assert!(err.contains("dotted JSON path"), "err: {err}");
3059    }
3060
3061    #[test]
3062    fn aggregate_rejects_blank_title() {
3063        let m = manifest_with_aggregate(
3064            "aggregate:\n- { dashboard: D, title: \"  \", kind: k, agg: count, render: stat }\n",
3065        );
3066        let err = m.validate().expect_err("blank title must fail");
3067        assert!(err.contains("title must not be empty"), "err: {err}");
3068    }
3069
3070    #[test]
3071    fn aggregate_rejects_blank_kind() {
3072        let m = manifest_with_aggregate(
3073            "aggregate:\n- { dashboard: D, title: T, kind: \" \", agg: count, render: stat }\n",
3074        );
3075        let err = m.validate().expect_err("blank kind must fail");
3076        assert!(err.contains("kind must not be empty"), "err: {err}");
3077    }
3078
3079    #[test]
3080    fn aggregate_rejects_blank_source_when_set() {
3081        let m = manifest_with_aggregate(
3082            "aggregate:\n- { dashboard: D, title: T, kind: k, source: \"\", agg: count, render: stat }\n",
3083        );
3084        let err = m.validate().expect_err("blank source must fail");
3085        assert!(
3086            err.contains("source must not be empty when set"),
3087            "err: {err}"
3088        );
3089    }
3090
3091    #[test]
3092    fn aggregate_accepts_description_and_rejects_blank() {
3093        let ok = manifest_with_aggregate(
3094            "aggregate:\n- { dashboard: D, title: T, description: \"samples x 2 min\", kind: k, agg: count, render: stat }\n",
3095        );
3096        ok.validate()
3097            .expect("description is a valid optional field");
3098        assert_eq!(
3099            ok.aggregate.as_ref().unwrap()[0].description.as_deref(),
3100            Some("samples x 2 min")
3101        );
3102        let bad = manifest_with_aggregate(
3103            "aggregate:\n- { dashboard: D, title: T, description: \"  \", kind: k, agg: count, render: stat }\n",
3104        );
3105        let err = bad.validate().expect_err("blank description must fail");
3106        assert!(
3107            err.contains("description must not be empty when set"),
3108            "err: {err}"
3109        );
3110    }
3111
3112    #[test]
3113    fn aggregate_rejects_count_with_value_path() {
3114        let m = manifest_with_aggregate(
3115            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, value_path: bytes, render: stat }\n",
3116        );
3117        let err = m.validate().expect_err("count must not use value_path");
3118        assert!(
3119            err.contains("agg=count does not use `value_path`"),
3120            "err: {err}"
3121        );
3122    }
3123
3124    #[test]
3125    fn aggregate_rejects_ratio_with_value_path() {
3126        let m = manifest_with_aggregate(
3127            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: ratio, bool_path: active, value_path: bytes, render: gauge }\n",
3128        );
3129        let err = m.validate().expect_err("ratio must not use value_path");
3130        assert!(
3131            err.contains("agg=ratio does not use `value_path`"),
3132            "err: {err}"
3133        );
3134    }
3135
3136    #[test]
3137    fn aggregate_rejects_gauge_without_ratio() {
3138        let m = manifest_with_aggregate(
3139            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, group_by: app, render: gauge }\n",
3140        );
3141        let err = m.validate().expect_err("gauge needs ratio");
3142        assert!(
3143            err.contains("render=gauge is only valid with agg: ratio"),
3144            "err: {err}"
3145        );
3146    }
3147
3148    #[test]
3149    fn aggregate_rejects_limit_without_group_by() {
3150        let m = manifest_with_aggregate(
3151            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, limit: 5, render: stat }\n",
3152        );
3153        let err = m.validate().expect_err("limit needs group_by");
3154        assert!(err.contains("limit requires `group_by`"), "err: {err}");
3155    }
3156
3157    #[test]
3158    fn aggregate_rejects_exclude_without_group_by() {
3159        let m = manifest_with_aggregate(
3160            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, exclude: [x], render: stat }\n",
3161        );
3162        let err = m.validate().expect_err("exclude needs group_by");
3163        assert!(err.contains("exclude requires `group_by`"), "err: {err}");
3164    }
3165
3166    #[test]
3167    fn aggregate_rejects_zero_limit_and_zero_sample_minutes() {
3168        let m = manifest_with_aggregate(
3169            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, group_by: app, limit: 0, render: bar }\n",
3170        );
3171        assert!(m.validate().unwrap_err().contains("limit must be > 0"));
3172        let m = manifest_with_aggregate(
3173            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, group_by: app, sample_minutes: 0, render: bar }\n",
3174        );
3175        assert!(
3176            m.validate()
3177                .unwrap_err()
3178                .contains("sample_minutes must be > 0")
3179        );
3180    }
3181
3182    #[test]
3183    fn aggregate_rejects_empty_exclude_entry() {
3184        let m = manifest_with_aggregate(
3185            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, group_by: app, exclude: [\"  \"], render: bar }\n",
3186        );
3187        let err = m.validate().expect_err("blank exclude entry must fail");
3188        assert!(
3189            err.contains("exclude must not contain empty entries"),
3190            "err: {err}"
3191        );
3192    }
3193
3194    #[test]
3195    fn aggregate_rejects_malformed_dotted_paths() {
3196        for bad in [".foo", "foo.", "foo..bar", "."] {
3197            let m = manifest_with_aggregate(&format!(
3198                "aggregate:\n- {{ dashboard: D, title: T, kind: k, agg: count, group_by: \"{bad}\", render: bar }}\n"
3199            ));
3200            let err = m.validate().expect_err("malformed path must fail");
3201            assert!(err.contains("dotted JSON path"), "path {bad}: {err}");
3202        }
3203    }
3204
3205    #[test]
3206    fn aggregate_rejects_unknown_enum_value() {
3207        // An unrecognised render string deserialises to the #492 Unknown
3208        // catch-all (so old readers don't choke); validate() rejects it as
3209        // a typo at create time.
3210        let m = manifest_with_aggregate(
3211            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, render: heatmap }\n",
3212        );
3213        let err = m.validate().expect_err("unknown render must fail");
3214        assert!(err.contains("render is not a known value"), "err: {err}");
3215    }
3216
3217    #[test]
3218    fn aggregate_accepts_order_field() {
3219        let m = manifest_with_aggregate(
3220            "aggregate:\n- { dashboard: D, title: T, order: -5, kind: k, agg: count, render: stat }\n",
3221        );
3222        m.validate().expect("order is a valid optional field");
3223        let w = &m.aggregate.as_ref().unwrap()[0];
3224        assert_eq!(w.order, Some(-5));
3225    }
3226
3227    #[test]
3228    fn aggregate_accepts_minimal_op_timeline() {
3229        // op_timeline needs no kind/agg — it reconstructs a fixed multi-kind
3230        // swimlane. A bare per-PC spec is valid, and `kind`/`agg` stay None.
3231        let m = manifest_with_aggregate(
3232            "aggregate:\n- { dashboard: Uptime, title: Operational state, scope: pc, render: op_timeline }\n",
3233        );
3234        m.validate().expect("minimal op_timeline is valid");
3235        let w = &m.aggregate.as_ref().unwrap()[0];
3236        assert_eq!(w.render, AggregateRender::OpTimeline);
3237        assert!(w.kind.is_none());
3238        assert!(w.agg.is_none());
3239    }
3240
3241    #[test]
3242    fn aggregate_rejects_op_timeline_with_fleet_scope() {
3243        let m = manifest_with_aggregate(
3244            "aggregate:\n- { dashboard: Uptime, title: T, scope: fleet, render: op_timeline }\n",
3245        );
3246        let err = m.validate().expect_err("op_timeline must be per-PC");
3247        assert!(
3248            err.contains("render=op_timeline requires scope: pc"),
3249            "err: {err}"
3250        );
3251    }
3252
3253    #[test]
3254    fn aggregate_rejects_op_timeline_with_aggregation_fields() {
3255        // Each aggregation knob the operator might paste in is rejected
3256        // (rather than silently ignored), pointing at the field to delete.
3257        for (block, field) in [
3258            ("kind: boot", "kind"),
3259            ("agg: count", "agg"),
3260            ("source: winlog:Security", "source"),
3261            ("group_by: pc_id", "group_by"),
3262            ("bool_path: active", "bool_path"),
3263            ("time_bucket: hour", "time_bucket"),
3264            ("limit: 5", "limit"),
3265        ] {
3266            let m = manifest_with_aggregate(&format!(
3267                "aggregate:\n- {{ dashboard: Uptime, title: T, scope: pc, {block}, render: op_timeline }}\n"
3268            ));
3269            let err = m
3270                .validate()
3271                .expect_err(&format!("op_timeline must reject {field}"));
3272            assert!(
3273                err.contains(&format!("render=op_timeline does not use `{field}`")),
3274                "field {field}: {err}"
3275            );
3276        }
3277    }
3278
3279    // ── #743 View resource ───────────────────────────────────────────
3280    fn view_from(yaml_body: &str) -> View {
3281        serde_yaml::from_str(&format!("id: v1\n{yaml_body}")).expect("parse view")
3282    }
3283
3284    #[test]
3285    fn view_accepts_valid_widgets() {
3286        let v = view_from(
3287            "widgets:\n\
3288             - { dashboard: Reliability, title: Crashes by PC, scope: fleet, kind: unexpected_shutdown, agg: count, group_by: pc_id, render: bar }\n\
3289             - { dashboard: Reliability, title: Total, scope: fleet, kind: unexpected_shutdown, agg: count, render: stat }\n",
3290        );
3291        v.validate().expect("valid view");
3292    }
3293
3294    #[test]
3295    fn view_rejects_empty_widgets() {
3296        let v = view_from("widgets: []\n");
3297        let err = v.validate().expect_err("empty widgets must fail");
3298        assert!(err.contains("at least one widget"), "err: {err}");
3299    }
3300
3301    #[test]
3302    fn view_rejects_blank_id() {
3303        let v: View = serde_yaml::from_str(
3304            "id: \"  \"\nwidgets:\n- { dashboard: D, title: T, kind: k, agg: count, render: stat }\n",
3305        )
3306        .expect("parse");
3307        let err = v.validate().expect_err("blank id must fail");
3308        assert!(err.contains("view.id must"), "err: {err}");
3309    }
3310
3311    #[test]
3312    fn view_rejects_unsafe_id() {
3313        // A `/` or `..` in the id would break the KV key and the
3314        // `/api/views/{id}` URL segment — reject at create time.
3315        for bad in ["../etc", "a/b", "has space", "x;y"] {
3316            let v: View = serde_yaml::from_str(&format!(
3317                "id: \"{bad}\"\nwidgets:\n- {{ dashboard: D, title: T, kind: k, agg: count, render: stat }}\n",
3318            ))
3319            .expect("parse");
3320            let err = v.validate().expect_err("unsafe id must fail");
3321            assert!(err.contains("[A-Za-z0-9._-]"), "id {bad}: {err}");
3322        }
3323        assert!(is_valid_resource_id("dashboards-fleet.v1_2"));
3324    }
3325
3326    #[test]
3327    fn view_reuses_shared_widget_validation() {
3328        // The same per-widget rule the job hint enforces (ratio needs
3329        // bool_path), reported under the `widgets[..]` field.
3330        let v = view_from(
3331            "widgets:\n- { dashboard: D, title: T, kind: presence, agg: ratio, render: gauge }\n",
3332        );
3333        let err = v.validate().expect_err("ratio without bool_path must fail");
3334        assert!(
3335            err.contains("widgets[0].agg=ratio requires `bool_path`"),
3336            "err: {err}"
3337        );
3338    }
3339
3340    fn execute_with(
3341        script: Option<&str>,
3342        script_file: Option<&str>,
3343        script_object: Option<&str>,
3344    ) -> Execute {
3345        Execute {
3346            shell: ExecuteShell::Powershell,
3347            script: script.map(str::to_owned),
3348            script_file: script_file.map(str::to_owned),
3349            script_object: script_object.map(str::to_owned),
3350            timeout: "30s".into(),
3351            run_as: RunAs::default(),
3352            cwd: None,
3353        }
3354    }
3355
3356    #[test]
3357    fn validate_accepts_inline_script() {
3358        let e = execute_with(Some("echo hi"), None, None);
3359        assert!(e.validate_script_source().is_ok());
3360    }
3361
3362    #[test]
3363    fn validate_accepts_script_file_alone() {
3364        let e = execute_with(None, Some("scripts/cleanup.ps1"), None);
3365        assert!(e.validate_script_source().is_ok());
3366    }
3367
3368    #[test]
3369    fn validate_accepts_script_object_alone() {
3370        let e = execute_with(None, None, Some("cleanup/1.0.0"));
3371        assert!(e.validate_script_source().is_ok());
3372    }
3373
3374    #[test]
3375    fn validate_treats_empty_inline_script_as_unset() {
3376        // `script: ""` + `script_object` set is the natural shape
3377        // when an operator comments out the YAML block-scalar body
3378        // but leaves the key. Should pass.
3379        let e = execute_with(Some(""), None, Some("cleanup/1.0.0"));
3380        assert!(e.validate_script_source().is_ok());
3381    }
3382
3383    #[test]
3384    fn validate_rejects_zero_sources() {
3385        let e = execute_with(None, None, None);
3386        let err = e.validate_script_source().unwrap_err();
3387        assert!(err.contains("must be set"), "got: {err}");
3388    }
3389
3390    #[test]
3391    fn validate_rejects_empty_inline_only() {
3392        let e = execute_with(Some(""), None, None);
3393        let err = e.validate_script_source().unwrap_err();
3394        assert!(err.contains("must be set"), "got: {err}");
3395    }
3396
3397    #[test]
3398    fn validate_rejects_inline_plus_file() {
3399        let e = execute_with(Some("echo hi"), Some("scripts/cleanup.ps1"), None);
3400        let err = e.validate_script_source().unwrap_err();
3401        assert!(err.contains("only one of"), "got: {err}");
3402    }
3403
3404    #[test]
3405    fn validate_rejects_inline_plus_object() {
3406        let e = execute_with(Some("echo hi"), None, Some("cleanup/1.0.0"));
3407        let err = e.validate_script_source().unwrap_err();
3408        assert!(err.contains("only one of"), "got: {err}");
3409    }
3410
3411    #[test]
3412    fn validate_rejects_file_plus_object() {
3413        let e = execute_with(None, Some("scripts/cleanup.ps1"), Some("cleanup/1.0.0"));
3414        let err = e.validate_script_source().unwrap_err();
3415        assert!(err.contains("only one of"), "got: {err}");
3416    }
3417
3418    #[test]
3419    fn validate_rejects_all_three() {
3420        let e = execute_with(
3421            Some("echo hi"),
3422            Some("scripts/cleanup.ps1"),
3423            Some("cleanup/1.0.0"),
3424        );
3425        let err = e.validate_script_source().unwrap_err();
3426        assert!(err.contains("only one of"), "got: {err}");
3427    }
3428
3429    #[test]
3430    fn manifest_deserialises_script_object_yaml() {
3431        // SPEC §2.4.1 example shape with the Object Store
3432        // reference picked over inline.
3433        let yaml = r#"
3434id: cleanup-disk-temp
3435version: 1.0.1
3436execute:
3437  shell: powershell
3438  script_object: cleanup-disk-temp/1.0.1
3439  timeout: 600s
3440"#;
3441        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
3442        assert_eq!(
3443            m.execute.script_object.as_deref(),
3444            Some("cleanup-disk-temp/1.0.1")
3445        );
3446        assert!(m.execute.script.is_none());
3447        m.validate()
3448            .expect("script_object-only manifest passes validation");
3449    }
3450
3451    #[test]
3452    fn manifest_rejects_typo_in_script_field_name() {
3453        // #492: the strict create boundary catches `script_objectt`
3454        // and similar fat-fingers (with the full path) instead of
3455        // letting them silently fall through to "all three unset".
3456        let yaml = r#"
3457id: typo
3458version: 1.0.0
3459execute:
3460  shell: powershell
3461  script_objectt: oops
3462  timeout: 30s
3463"#;
3464        let err = crate::strict::from_yaml_str::<Manifest>(yaml)
3465            .expect_err("typo'd execute field must be rejected at the write boundary");
3466        assert!(err.contains("execute.script_objectt"), "{err}");
3467    }
3468
3469    #[test]
3470    fn schedule_carries_target_and_rollout() {
3471        let yaml = r#"
3472id: hourly-cleanup-canary
3473when:
3474  per_pc: { every: 1h }
3475job_id: cleanup
3476enabled: true
3477target:
3478  groups: [canary, wave1]
3479jitter: 30s
3480rollout:
3481  strategy: wave
3482  waves:
3483    - { group: canary, delay: 0s }
3484    - { group: wave1,  delay: 5s }
3485"#;
3486        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3487        assert_eq!(s.id, "hourly-cleanup-canary");
3488        assert_eq!(s.job_id, "cleanup");
3489        assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
3490        assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
3491        let rollout = s.plan.rollout.expect("rollout present");
3492        assert_eq!(rollout.waves.len(), 2);
3493        assert_eq!(rollout.waves[0].group, "canary");
3494        assert_eq!(rollout.waves[1].delay, "5s");
3495        assert_eq!(rollout.strategy, RolloutStrategy::Wave);
3496    }
3497
3498    #[test]
3499    fn schedule_minimal_target_all() {
3500        let yaml = r#"
3501id: kitting
3502when:
3503  per_pc: once
3504enabled: true
3505job_id: scheduled-echo
3506target: { all: true }
3507"#;
3508        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3509        assert_eq!(s.id, "kitting");
3510        assert_eq!(s.when, When::PerPc(PerPolicy::Once(OnceLiteral::Once)));
3511        assert!(s.enabled);
3512        assert_eq!(s.job_id, "scheduled-echo");
3513        assert!(s.plan.target.all);
3514        assert!(s.plan.rollout.is_none());
3515        assert!(s.plan.jitter.is_none());
3516        assert!(s.active.is_empty());
3517    }
3518
3519    #[test]
3520    fn schedule_enabled_defaults_to_true() {
3521        let yaml = r#"
3522id: x
3523when:
3524  per_pc: once
3525job_id: y
3526target: { all: true }
3527"#;
3528        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3529        assert!(s.enabled);
3530    }
3531
3532    #[test]
3533    fn schedule_tags_default_empty_and_skip_serialise() {
3534        let yaml = r#"
3535id: x
3536when:
3537  per_pc: once
3538job_id: y
3539target: { all: true }
3540"#;
3541        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3542        assert!(s.tags.is_empty());
3543        s.validate().expect("tag-less schedule validates");
3544        let json = serde_json::to_string(&s).expect("serialize");
3545        assert!(
3546            !json.contains("tags"),
3547            "empty tags must not serialise: {json}"
3548        );
3549    }
3550
3551    #[test]
3552    fn schedule_parses_and_validates_tags() {
3553        let yaml = r#"
3554id: weekly-cleanup
3555when:
3556  per_pc: { every: 1h }
3557job_id: cleanup
3558target: { all: true }
3559tags: [weekly, maintenance]
3560"#;
3561        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3562        assert_eq!(s.tags, vec!["weekly", "maintenance"]);
3563        s.validate().expect("tagged schedule validates");
3564    }
3565
3566    #[test]
3567    fn schedule_rejects_blank_tag() {
3568        let yaml = r#"
3569id: x
3570when:
3571  per_pc: once
3572job_id: y
3573target: { all: true }
3574tags: [ok, "  "]
3575"#;
3576        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3577        let err = s.validate().expect_err("blank tag must fail");
3578        assert!(err.contains("tags must not contain empty"), "err: {err}");
3579    }
3580
3581    // ---- `when` parsing (#418 Phase 1) ----
3582
3583    fn schedule_yaml_with(when_block: &str) -> String {
3584        format!(
3585            r#"
3586id: x
3587when:
3588{when_block}
3589job_id: y
3590target: {{ all: true }}
3591"#
3592        )
3593    }
3594
3595    #[test]
3596    fn when_per_pc_every_parses_unquoted_humantime() {
3597        // `6h` is digit-led but non-numeric → YAML string, same as
3598        // the old `cooldown: 6h` convention. No quotes needed.
3599        let s: Schedule =
3600            serde_yaml::from_str(&schedule_yaml_with("  per_pc: { every: 6h }")).expect("parse");
3601        assert_eq!(
3602            s.when,
3603            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() }))
3604        );
3605    }
3606
3607    #[test]
3608    fn when_per_target_every_parses() {
3609        let s: Schedule = serde_yaml::from_str(&schedule_yaml_with("  per_target: { every: 24h }"))
3610            .expect("parse");
3611        assert_eq!(
3612            s.when,
3613            When::PerTarget(PerPolicy::Every(EverySpec {
3614                every: "24h".into()
3615            }))
3616        );
3617    }
3618
3619    #[test]
3620    fn when_per_target_once_parses() {
3621        // Falls out of the shared PerPolicy shape and decide_fire
3622        // already implements it ("any one pc succeeds → skip the
3623        // target forever"), so it is allowed, not rejected.
3624        let s: Schedule =
3625            serde_yaml::from_str(&schedule_yaml_with("  per_target: once")).expect("parse");
3626        assert_eq!(s.when, When::PerTarget(PerPolicy::Once(OnceLiteral::Once)));
3627    }
3628
3629    #[test]
3630    fn when_calendar_time_parses() {
3631        let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(
3632            "  calendar:\n    at: \"09:00\"\n    days: [mon-fri]",
3633        ))
3634        .expect("parse");
3635        match &s.when {
3636            When::Calendar(c) => {
3637                assert_eq!(c.at, "09:00");
3638                assert_eq!(c.days, vec!["mon-fri"]);
3639            }
3640            other => panic!("expected calendar, got {other:?}"),
3641        }
3642    }
3643
3644    #[test]
3645    fn when_calendar_days_default_empty() {
3646        let s: Schedule =
3647            serde_yaml::from_str(&schedule_yaml_with("  calendar:\n    at: \"09:00\""))
3648                .expect("parse");
3649        match &s.when {
3650            When::Calendar(c) => assert!(c.days.is_empty(), "days defaults to empty (= daily)"),
3651            other => panic!("expected calendar, got {other:?}"),
3652        }
3653    }
3654
3655    #[test]
3656    fn when_calendar_datetime_parses_all_separators() {
3657        // one-shot: date+time in hyphen / ISO-T / slash forms
3658        for at in ["2026-06-10 09:00", "2026-06-10T09:00", "2026/06/10 09:00"] {
3659            let block = format!("  calendar:\n    at: \"{at}\"");
3660            let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(&block))
3661                .unwrap_or_else(|e| panic!("parse '{at}': {e}"));
3662            match &s.when {
3663                When::Calendar(c) => {
3664                    use chrono::Datelike;
3665                    let p = c.parse_at().expect("parse_at");
3666                    let d = p.date.expect("datetime at carries a date");
3667                    assert_eq!((d.year(), d.month(), d.day()), (2026, 6, 10), "for '{at}'");
3668                }
3669                other => panic!("expected calendar, got {other:?}"),
3670            }
3671        }
3672    }
3673
3674    #[test]
3675    fn when_rejects_bad_once_keyword() {
3676        // `onec` must be a parse error, not a silently-absorbed
3677        // string (OnceLiteral is a single-variant enum for exactly
3678        // this reason).
3679        let r: Result<Schedule, _> = serde_yaml::from_str(&schedule_yaml_with("  per_pc: onec"));
3680        assert!(r.is_err(), "expected parse error, got {r:?}");
3681    }
3682
3683    #[test]
3684    fn when_rejects_unknown_key_in_every() {
3685        // `{ evry: 6h }` still fails on the tolerant read path: the
3686        // required `every` key is missing, so no PerPolicy variant
3687        // matches (#492 removed deny_unknown_fields, but required
3688        // keys keep the untagged disambiguation honest).
3689        let r: Result<Schedule, _> =
3690            serde_yaml::from_str(&schedule_yaml_with("  per_pc: { evry: 6h }"));
3691        assert!(r.is_err(), "expected parse error, got {r:?}");
3692    }
3693
3694    #[test]
3695    fn when_rejects_unknown_variant() {
3696        let r: Result<Schedule, _> =
3697            serde_yaml::from_str(&schedule_yaml_with("  per_galaxy: once"));
3698        assert!(r.is_err(), "expected parse error, got {r:?}");
3699    }
3700
3701    #[test]
3702    fn when_rejects_old_top_level_cron_field() {
3703        // Pre-#418 shape: top-level `cron:` + no `when:`. Must fail
3704        // loudly (missing `when`), which is what turns stale KV
3705        // blobs into warn-skips after the upgrade.
3706        let yaml = r#"
3707id: x
3708cron: "* * * * * *"
3709job_id: y
3710target: { all: true }
3711"#;
3712        let r: Result<Schedule, _> = serde_yaml::from_str(yaml);
3713        assert!(r.is_err(), "expected parse error, got {r:?}");
3714    }
3715
3716    #[test]
3717    fn when_rejects_retired_cron_escape_hatch() {
3718        // #418 Phase 2 retired `when: { cron: "..." }`. A raw cron
3719        // is now an unknown variant → parse error (operators use the
3720        // calendar form instead).
3721        let r: Result<Schedule, _> =
3722            serde_yaml::from_str(&schedule_yaml_with("  cron: \"0 0 9 * * mon-fri\""));
3723        assert!(
3724            r.is_err(),
3725            "expected parse error for retired cron, got {r:?}"
3726        );
3727    }
3728
3729    #[test]
3730    fn when_round_trips_json_and_yaml() {
3731        // Round-trip through the full Schedule: that is the wire
3732        // unit for both stores (JSON catalog KV + YAML mirror), and
3733        // it exercises the singleton_map field attribute that keeps
3734        // serde_yaml on the map shape instead of `!per_pc` tags.
3735        for when in [
3736            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
3737            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
3738            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
3739            When::PerTarget(PerPolicy::Every(EverySpec {
3740                every: "24h".into(),
3741            })),
3742            calendar("09:00", &["mon-fri"]),
3743            calendar("2026-06-10 09:00", &[]),
3744            When::On(vec![OnTrigger::Startup]),
3745            When::On(vec![OnTrigger::Startup, OnTrigger::Logon]),
3746            When::On(vec![OnTrigger::Lock, OnTrigger::Unlock]),
3747            When::On(vec![OnTrigger::NetworkChange]),
3748        ] {
3749            // Event triggers are agent-only; the rest validate on backend.
3750            let runs_on = if matches!(when, When::On(_)) {
3751                RunsOn::Agent
3752            } else {
3753                RunsOn::Backend
3754            };
3755            let s = schedule_with(when.clone(), runs_on);
3756
3757            let json = serde_json::to_string(&s).expect("json serialise");
3758            let back: Schedule = serde_json::from_str(&json).expect("json deserialise");
3759            assert_eq!(back.when, when, "json round-trip for {when}");
3760
3761            let yaml = serde_yaml::to_string(&s).expect("yaml serialise");
3762            assert!(
3763                !yaml.contains('!'),
3764                "yaml must use the map shape, not tags: {yaml}"
3765            );
3766            let back: Schedule = serde_yaml::from_str(&yaml).expect("yaml deserialise");
3767            assert_eq!(back.when, when, "yaml round-trip for {when}");
3768        }
3769    }
3770
3771    #[test]
3772    fn when_once_serialises_as_bare_keyword() {
3773        // The wire shape operators see in the YAML mirror must stay
3774        // the ergonomic `per_pc: once`, not a one-variant map.
3775        let json = serde_json::to_value(When::PerPc(PerPolicy::Once(OnceLiteral::Once)))
3776            .expect("serialise");
3777        assert_eq!(json, serde_json::json!({ "per_pc": "once" }));
3778    }
3779
3780    #[test]
3781    fn when_displays_operator_summary() {
3782        for (when, expected) in [
3783            (
3784                When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
3785                "per_pc once",
3786            ),
3787            (
3788                When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
3789                "per_pc every 6h",
3790            ),
3791            (
3792                When::PerTarget(PerPolicy::Every(EverySpec {
3793                    every: "24h".into(),
3794                })),
3795                "per_target every 24h",
3796            ),
3797            (calendar("09:00", &["mon-fri"]), "at 09:00 [mon-fri]"),
3798            (calendar("2026-06-10 09:00", &[]), "at 2026-06-10 09:00"),
3799            (When::On(vec![OnTrigger::Startup]), "on [startup]"),
3800            (
3801                When::On(vec![OnTrigger::Startup, OnTrigger::Logon]),
3802                "on [startup,logon]",
3803            ),
3804            (
3805                When::On(vec![OnTrigger::Lock, OnTrigger::Unlock]),
3806                "on [lock,unlock]",
3807            ),
3808            (
3809                When::On(vec![OnTrigger::NetworkChange]),
3810                "on [network_change]",
3811            ),
3812        ] {
3813            assert_eq!(when.to_string(), expected);
3814        }
3815    }
3816
3817    // ---- lowering (#418: when → engine vocabulary) ----
3818
3819    fn schedule_with(when: When, runs_on: RunsOn) -> Schedule {
3820        Schedule {
3821            id: "x".into(),
3822            when,
3823            job_id: "y".into(),
3824            plan: FanoutPlan::default(),
3825            active: Active::default(),
3826            constraints: Constraints::default(),
3827            on_failure: OnFailure::default(),
3828            tz: ScheduleTz::default(),
3829            starting_deadline: None,
3830            runs_on,
3831            enabled: true,
3832            tags: Vec::new(),
3833            origin: None,
3834        }
3835    }
3836
3837    fn calendar(at: &str, days: &[&str]) -> When {
3838        When::Calendar(CalendarSpec {
3839            at: at.into(),
3840            days: days.iter().map(|d| (*d).to_string()).collect(),
3841        })
3842    }
3843
3844    #[test]
3845    fn next_calendar_fire_returns_next_utc_occurrence() {
3846        use chrono::TimeZone;
3847        // Daily 09:00, evaluated in UTC. From 08:00 the same day, the
3848        // next strict occurrence is 09:00 that day.
3849        let mut s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
3850        s.tz = ScheduleTz::Utc;
3851        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 9, 8, 0, 0).unwrap();
3852        let next = s.next_calendar_fire(now).expect("calendar has a next fire");
3853        assert_eq!(
3854            next,
3855            chrono::Utc.with_ymd_and_hms(2026, 6, 9, 9, 0, 0).unwrap()
3856        );
3857    }
3858
3859    #[test]
3860    fn next_calendar_fire_is_strictly_after_now() {
3861        use chrono::TimeZone;
3862        // Standing exactly on a fire instant must preview the *next*
3863        // one (inclusive = false), not the one firing right now.
3864        let mut s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
3865        s.tz = ScheduleTz::Utc;
3866        let on_fire = chrono::Utc.with_ymd_and_hms(2026, 6, 9, 9, 0, 0).unwrap();
3867        let next = s
3868            .next_calendar_fire(on_fire)
3869            .expect("calendar has a next fire");
3870        assert_eq!(
3871            next,
3872            chrono::Utc.with_ymd_and_hms(2026, 6, 10, 9, 0, 0).unwrap()
3873        );
3874    }
3875
3876    #[test]
3877    fn next_calendar_fire_none_for_reconcile_shapes() {
3878        // `per_pc` / `per_target` lower to the every-minute poll cron —
3879        // no discrete upcoming event to preview, so `None`.
3880        let now = chrono::Utc::now();
3881        for when in [
3882            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
3883            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
3884            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
3885            When::PerTarget(PerPolicy::Every(EverySpec {
3886                every: "24h".into(),
3887            })),
3888        ] {
3889            let s = schedule_with(when, RunsOn::Backend);
3890            assert!(
3891                s.next_calendar_fire(now).is_none(),
3892                "reconcile shapes have no calendar fire",
3893            );
3894        }
3895    }
3896
3897    // ---- preview_fires (#418 dry-run / preview) ----
3898
3899    fn cal_utc(at: &str, days: &[&str]) -> Schedule {
3900        let mut s = schedule_with(calendar(at, days), RunsOn::Backend);
3901        s.tz = ScheduleTz::Utc; // host-independent assertions
3902        s
3903    }
3904
3905    #[test]
3906    fn preview_lists_next_calendar_occurrences() {
3907        use chrono::TimeZone;
3908        // Weekday 09:00, from Wed 2026-06-10 00:00 UTC: the next five
3909        // fires skip the weekend (Sat 13 / Sun 14).
3910        let s = cal_utc("09:00", &["mon-fri"]);
3911        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 0, 0, 0).unwrap();
3912        let got = s.preview_fires(now, 5);
3913        let want: Vec<_> = [
3914            (2026, 6, 10), // Wed
3915            (2026, 6, 11), // Thu
3916            (2026, 6, 12), // Fri
3917            (2026, 6, 15), // Mon (skips Sat 13 / Sun 14)
3918            (2026, 6, 16), // Tue
3919        ]
3920        .iter()
3921        .map(|(y, m, d)| chrono::Utc.with_ymd_and_hms(*y, *m, *d, 9, 0, 0).unwrap())
3922        .collect();
3923        assert_eq!(got, want);
3924    }
3925
3926    #[test]
3927    fn preview_handles_nth_and_last_weekday() {
3928        use chrono::TimeZone;
3929        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap();
3930        // 2nd Tuesday (Patch Tuesday): Jun 9, Jul 14 2026.
3931        let nth = cal_utc("09:00", &["tue#2"]).preview_fires(now, 2);
3932        assert_eq!(
3933            nth,
3934            vec![
3935                chrono::Utc.with_ymd_and_hms(2026, 6, 9, 9, 0, 0).unwrap(),
3936                chrono::Utc.with_ymd_and_hms(2026, 7, 14, 9, 0, 0).unwrap(),
3937            ]
3938        );
3939        // Last Friday of the month: Jun 26, Jul 31 2026.
3940        let last = cal_utc("22:00", &["friL"]).preview_fires(now, 2);
3941        assert_eq!(
3942            last,
3943            vec![
3944                chrono::Utc.with_ymd_and_hms(2026, 6, 26, 22, 0, 0).unwrap(),
3945                chrono::Utc.with_ymd_and_hms(2026, 7, 31, 22, 0, 0).unwrap(),
3946            ]
3947        );
3948    }
3949
3950    #[test]
3951    fn preview_is_empty_for_reconcile_and_zero_count() {
3952        let now = chrono::Utc::now();
3953        // reconcile shapes have no discrete fire times
3954        let recon = schedule_with(
3955            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
3956            RunsOn::Backend,
3957        );
3958        assert!(recon.preview_fires(now, 5).is_empty());
3959        // count == 0 yields nothing even for a calendar
3960        assert!(cal_utc("09:00", &[]).preview_fires(now, 0).is_empty());
3961    }
3962
3963    #[test]
3964    fn preview_skips_outside_active_window() {
3965        use chrono::TimeZone;
3966        // Daily 09:00, active only [2026-06-15, 2026-06-17). Occurrences
3967        // before `from` are skipped; `until` is exclusive, so 06-17's
3968        // fire is out — leaving exactly the 15th and 16th.
3969        let mut s = cal_utc("09:00", &[]);
3970        s.active = Active {
3971            from: Some("2026-06-15".into()),
3972            until: Some("2026-06-17".into()),
3973        };
3974        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 0, 0, 0).unwrap();
3975        let got = s.preview_fires(now, 5);
3976        assert_eq!(
3977            got,
3978            vec![
3979                chrono::Utc.with_ymd_and_hms(2026, 6, 15, 9, 0, 0).unwrap(),
3980                chrono::Utc.with_ymd_and_hms(2026, 6, 16, 9, 0, 0).unwrap(),
3981            ]
3982        );
3983    }
3984
3985    #[test]
3986    fn preview_empty_when_calendar_time_outside_window() {
3987        use chrono::TimeZone;
3988        // Fires at 09:00 but the maintenance window is overnight — it can
3989        // never run, so the preview is empty (matches
3990        // `calendar_outside_window`), and the scan still terminates.
3991        let mut s = cal_utc("09:00", &[]);
3992        s.constraints = Constraints {
3993            window: Some("22:00-05:00".into()),
3994            ..Constraints::default()
3995        };
3996        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 0, 0, 0).unwrap();
3997        assert!(s.preview_fires(now, 5).is_empty());
3998        // Every candidate tick is rejected, so this also exercises the
3999        // SCAN_CAP bound: a large `count` must still terminate (and
4000        // return empty) rather than spin (claude #578 review).
4001        assert!(s.preview_fires(now, 50).is_empty());
4002    }
4003
4004    #[test]
4005    fn preview_past_one_shot_is_empty() {
4006        use chrono::TimeZone;
4007        // A dated one-shot whose instant has passed never fires again.
4008        let s = cal_utc("2026-06-10 09:00", &[]);
4009        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 11, 0, 0, 0).unwrap();
4010        assert!(s.preview_fires(now, 5).is_empty());
4011        // …but from before it, the single future fire shows up.
4012        let before = chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap();
4013        assert_eq!(
4014            s.preview_fires(before, 5),
4015            vec![chrono::Utc.with_ymd_and_hms(2026, 6, 10, 9, 0, 0).unwrap()]
4016        );
4017    }
4018
4019    #[test]
4020    fn lowering_matches_the_418_table() {
4021        let cases = [
4022            (
4023                When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4024                (POLL_CRON, ExecMode::OncePerPc, None),
4025            ),
4026            (
4027                When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
4028                (POLL_CRON, ExecMode::OncePerPc, Some("6h")),
4029            ),
4030            (
4031                When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
4032                (POLL_CRON, ExecMode::OncePerTarget, None),
4033            ),
4034            (
4035                When::PerTarget(PerPolicy::Every(EverySpec {
4036                    every: "24h".into(),
4037                })),
4038                (POLL_CRON, ExecMode::OncePerTarget, Some("24h")),
4039            ),
4040            // calendar repeating → 6-field cron
4041            (
4042                calendar("09:00", &["mon-fri"]),
4043                ("0 0 9 * * mon-fri", ExecMode::EveryTick, None),
4044            ),
4045            // calendar daily (no days) → DOW *
4046            (
4047                calendar("18:30", &[]),
4048                ("0 30 18 * * *", ExecMode::EveryTick, None),
4049            ),
4050            // calendar one-shot → 7-field year cron
4051            (
4052                calendar("2026-06-10 09:00", &[]),
4053                ("0 0 9 10 6 * 2026", ExecMode::EveryTick, None),
4054            ),
4055        ];
4056        for (when, (cron, mode, cooldown)) in cases {
4057            let l = schedule_with(when.clone(), RunsOn::Backend).lowered();
4058            assert_eq!(l.cron, cron, "cron for {when}");
4059            assert_eq!(l.mode, mode, "mode for {when}");
4060            assert_eq!(l.cooldown.as_deref(), cooldown, "cooldown for {when}");
4061        }
4062    }
4063
4064    #[test]
4065    fn lowered_carries_schedule_tz() {
4066        for (tz, want) in [
4067            (ScheduleTz::Local, ScheduleTz::Local),
4068            (ScheduleTz::Utc, ScheduleTz::Utc),
4069        ] {
4070            let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
4071            s.tz = tz;
4072            assert_eq!(s.lowered().tz, want, "calendar carries tz");
4073            // reconcile shapes carry tz too (for the active-window check)
4074            let mut s = schedule_with(
4075                When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4076                RunsOn::Backend,
4077            );
4078            s.tz = tz;
4079            assert_eq!(s.lowered().tz, want, "reconcile carries tz");
4080        }
4081    }
4082
4083    #[test]
4084    fn poll_cron_is_accepted_by_the_engine_parser() {
4085        // POLL_CRON is system-generated — if the engine's parser
4086        // ever rejected it every reconcile schedule would die at
4087        // register time. Validate it with the same croner config
4088        // (Seconds::Required, dom_and_dow, year optional).
4089        croner::parser::CronParser::builder()
4090            .seconds(croner::parser::Seconds::Required)
4091            .dom_and_dow(true)
4092            .build()
4093            .parse(POLL_CRON)
4094            .expect("POLL_CRON must parse");
4095    }
4096
4097    // ---- Schedule::validate() (#418 decision F) ----
4098
4099    #[test]
4100    fn validate_accepts_reconcile_shapes() {
4101        for when in [
4102            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4103            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
4104            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
4105            When::PerTarget(PerPolicy::Every(EverySpec {
4106                every: "24h".into(),
4107            })),
4108        ] {
4109            schedule_with(when.clone(), RunsOn::Backend)
4110                .validate()
4111                .unwrap_or_else(|e| panic!("{when} should validate: {e}"));
4112        }
4113    }
4114
4115    #[test]
4116    fn validate_accepts_per_pc_on_agent() {
4117        schedule_with(
4118            When::PerPc(PerPolicy::Every(EverySpec { every: "1h".into() })),
4119            RunsOn::Agent,
4120        )
4121        .validate()
4122        .expect("per_pc + agent is the offline-inventory shape");
4123    }
4124
4125    // ---- #418 event triggers (when: { on }) ----
4126
4127    #[test]
4128    fn validate_accepts_event_on_agent() {
4129        for triggers in [
4130            vec![OnTrigger::Startup],
4131            vec![OnTrigger::Logon],
4132            vec![OnTrigger::Lock],
4133            vec![OnTrigger::Unlock],
4134            vec![OnTrigger::NetworkChange],
4135            vec![
4136                OnTrigger::Startup,
4137                OnTrigger::Logon,
4138                OnTrigger::Lock,
4139                OnTrigger::Unlock,
4140                OnTrigger::NetworkChange,
4141            ],
4142        ] {
4143            schedule_with(When::On(triggers), RunsOn::Agent)
4144                .validate()
4145                .expect("when.on is valid on runs_on: agent");
4146        }
4147    }
4148
4149    #[test]
4150    fn validate_rejects_event_on_backend() {
4151        let err = schedule_with(When::On(vec![OnTrigger::Startup]), RunsOn::Backend)
4152            .validate()
4153            .unwrap_err();
4154        assert!(err.contains("when.on"), "got: {err}");
4155        assert!(err.contains("runs_on: agent"), "got: {err}");
4156    }
4157
4158    #[test]
4159    fn validate_rejects_empty_event_list() {
4160        let err = schedule_with(When::On(vec![]), RunsOn::Agent)
4161            .validate()
4162            .unwrap_err();
4163        assert!(err.contains("when.on"), "got: {err}");
4164        assert!(err.contains("at least one"), "got: {err}");
4165    }
4166
4167    #[test]
4168    fn event_schedule_lowers_to_event_mode_and_is_event() {
4169        let s = schedule_with(When::On(vec![OnTrigger::Startup]), RunsOn::Agent);
4170        assert!(s.is_event());
4171        assert_eq!(s.lowered().mode, ExecMode::Event);
4172        assert_eq!(s.event_triggers(), &[OnTrigger::Startup]);
4173        // non-event schedules report no triggers.
4174        let cal = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
4175        assert!(!cal.is_event());
4176        assert!(cal.event_triggers().is_empty());
4177    }
4178
4179    // ---- #418 constraints.require (env gates) ----
4180
4181    fn require_schedule(req: Require, runs_on: RunsOn) -> Schedule {
4182        let mut s = schedule_with(
4183            When::PerPc(PerPolicy::Every(EverySpec { every: "1m".into() })),
4184            runs_on,
4185        );
4186        s.constraints.require = Some(req);
4187        s
4188    }
4189
4190    #[test]
4191    fn require_met_combinations() {
4192        use std::time::Duration;
4193        let idle = |m: u64| Some(Duration::from_secs(m * 60));
4194        // Builder for the sensed state: (ac, idle, cpu, network).
4195        let env = |ac, idle, cpu, net| EnvState {
4196            ac_online: ac,
4197            idle,
4198            cpu_pct: cpu,
4199            network_up: net,
4200        };
4201        // Empty require — always met regardless of sensed state.
4202        assert!(require_met(
4203            &Require::default(),
4204            &env(false, None, None, false)
4205        ));
4206        // ac_power: only on AC.
4207        let ac = Require {
4208            ac_power: true,
4209            ..Default::default()
4210        };
4211        assert!(!require_met(&ac, &env(false, None, None, true)));
4212        assert!(require_met(&ac, &env(true, None, None, false)));
4213        // idle: needs >= the configured min; None idle never satisfies.
4214        let idle10 = Require {
4215            idle: Some("10m".into()),
4216            ..Default::default()
4217        };
4218        assert!(!require_met(&idle10, &env(true, None, None, true)));
4219        assert!(!require_met(&idle10, &env(true, idle(5), None, true)));
4220        assert!(require_met(&idle10, &env(true, idle(15), None, true)));
4221        assert!(require_met(&idle10, &env(true, idle(10), None, true))); // boundary inclusive
4222        // cpu_below: needs CPU strictly < threshold; None cpu never satisfies.
4223        let cpu20 = Require {
4224            cpu_below: Some(20.0),
4225            ..Default::default()
4226        };
4227        assert!(!require_met(&cpu20, &env(true, None, None, true))); // no sample → fail-closed
4228        assert!(!require_met(&cpu20, &env(true, None, Some(20.0), true))); // == threshold
4229        assert!(!require_met(&cpu20, &env(true, None, Some(55.0), true))); // busy
4230        assert!(require_met(&cpu20, &env(true, None, Some(5.0), true))); // quiet
4231        // network: only when online.
4232        let net = Require {
4233            network: true,
4234            ..Default::default()
4235        };
4236        assert!(!require_met(&net, &env(true, None, None, false))); // offline
4237        assert!(require_met(&net, &env(true, None, None, true))); // online
4238        // all four: AND.
4239        let all = Require {
4240            ac_power: true,
4241            idle: Some("10m".into()),
4242            cpu_below: Some(20.0),
4243            network: true,
4244        };
4245        assert!(!require_met(&all, &env(false, idle(20), Some(5.0), true))); // on battery
4246        assert!(!require_met(&all, &env(true, idle(1), Some(5.0), true))); // not idle enough
4247        assert!(!require_met(&all, &env(true, idle(20), Some(50.0), true))); // busy
4248        assert!(!require_met(&all, &env(true, idle(20), Some(5.0), false))); // offline
4249        assert!(require_met(&all, &env(true, idle(20), Some(5.0), true)));
4250        // An unparseable idle is treated as no-requirement by require_met
4251        // (validate rejects it at create time, so this only guards a
4252        // hand-edited blob): ac still gates.
4253        let bad = Require {
4254            ac_power: true,
4255            idle: Some("garbage".into()),
4256            ..Default::default()
4257        };
4258        assert!(require_met(&bad, &env(true, None, None, true)));
4259        assert!(!require_met(&bad, &env(false, None, None, true)));
4260    }
4261
4262    #[test]
4263    fn validate_accepts_and_rejects_cpu_below() {
4264        // In-range accepted.
4265        require_schedule(
4266            Require {
4267                cpu_below: Some(20.0),
4268                ..Default::default()
4269            },
4270            RunsOn::Agent,
4271        )
4272        .validate()
4273        .expect("cpu_below 20 is valid");
4274        // Upper boundary: 100.0 is accepted (fires unless CPU is exactly
4275        // 100%). Pins the inclusive upper bound against a future c < 100.0.
4276        require_schedule(
4277            Require {
4278                cpu_below: Some(100.0),
4279                ..Default::default()
4280            },
4281            RunsOn::Agent,
4282        )
4283        .validate()
4284        .expect("cpu_below 100 is valid");
4285        // Out of range rejected (0 and >100).
4286        for bad in [0.0, -5.0, 100.1] {
4287            let err = require_schedule(
4288                Require {
4289                    cpu_below: Some(bad),
4290                    ..Default::default()
4291                },
4292                RunsOn::Agent,
4293            )
4294            .validate()
4295            .unwrap_err();
4296            assert!(
4297                err.contains("constraints.require.cpu_below"),
4298                "cpu_below {bad}: {err}"
4299            );
4300        }
4301    }
4302
4303    #[test]
4304    fn validate_accepts_require_on_agent() {
4305        require_schedule(
4306            Require {
4307                ac_power: true,
4308                idle: Some("10m".into()),
4309                cpu_below: Some(20.0),
4310                network: true,
4311            },
4312            RunsOn::Agent,
4313        )
4314        .validate()
4315        .expect("constraints.require is valid on runs_on: agent");
4316    }
4317
4318    #[test]
4319    fn validate_rejects_require_on_backend() {
4320        let err = require_schedule(
4321            Require {
4322                ac_power: true,
4323                ..Default::default()
4324            },
4325            RunsOn::Backend,
4326        )
4327        .validate()
4328        .unwrap_err();
4329        assert!(err.contains("constraints.require"), "got: {err}");
4330        assert!(err.contains("runs_on: agent"), "got: {err}");
4331
4332        // An idle-only require (ac_power: false) is also non-empty
4333        // (is_empty folds the fields) and must reject on backend too —
4334        // guards against a regression in Require::is_empty.
4335        let err = require_schedule(
4336            Require {
4337                idle: Some("10m".into()),
4338                ..Default::default()
4339            },
4340            RunsOn::Backend,
4341        )
4342        .validate()
4343        .unwrap_err();
4344        assert!(
4345            err.contains("constraints.require"),
4346            "idle-only on backend: {err}"
4347        );
4348    }
4349
4350    #[test]
4351    fn validate_rejects_bad_require_idle() {
4352        let err = require_schedule(
4353            Require {
4354                idle: Some("not-a-duration".into()),
4355                ..Default::default()
4356            },
4357            RunsOn::Agent,
4358        )
4359        .validate()
4360        .unwrap_err();
4361        assert!(err.contains("constraints.require.idle"), "got: {err}");
4362    }
4363
4364    #[test]
4365    fn require_round_trips_and_skips_empty() {
4366        // ac_power: false is skipped; an all-default require nested in
4367        // constraints is omitted (is_empty folds it in).
4368        let yaml = "id: s\nwhen: { per_pc: { every: 1m } }\njob_id: j\nruns_on: agent\n\
4369                    constraints: { require: { ac_power: true, idle: 10m, cpu_below: 20, \
4370                    network: true } }\n";
4371        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
4372        let req = s.constraints.require.as_ref().expect("require present");
4373        assert!(req.ac_power);
4374        assert_eq!(req.idle.as_deref(), Some("10m"));
4375        assert_eq!(req.cpu_below, Some(20.0));
4376        assert!(req.network);
4377        // Re-serialize: idle + cpu_below + network present, ac_power true.
4378        let back = serde_json::to_string(&s.constraints).unwrap();
4379        assert!(back.contains("\"idle\":\"10m\""), "got: {back}");
4380        assert!(back.contains("\"cpu_below\":20"), "got: {back}");
4381        assert!(back.contains("\"network\":true"), "got: {back}");
4382        // An empty require is omitted entirely by is_empty.
4383        let mut empty = s.clone();
4384        empty.constraints.require = Some(Require::default());
4385        assert!(empty.constraints.is_empty());
4386    }
4387
4388    #[test]
4389    fn validate_rejects_per_target_on_agent() {
4390        let err = schedule_with(
4391            When::PerTarget(PerPolicy::Every(EverySpec {
4392                every: "24h".into(),
4393            })),
4394            RunsOn::Agent,
4395        )
4396        .validate()
4397        .unwrap_err();
4398        assert!(err.contains("per_target"), "got: {err}");
4399        assert!(err.contains("runs_on: agent"), "got: {err}");
4400
4401        // per_target: once is also backend-only.
4402        let err = schedule_with(
4403            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
4404            RunsOn::Agent,
4405        )
4406        .validate()
4407        .unwrap_err();
4408        assert!(err.contains("per_target"), "got (once): {err}");
4409        assert!(err.contains("runs_on: agent"), "got (once): {err}");
4410    }
4411
4412    #[test]
4413    fn validate_rejects_bad_every_duration() {
4414        let err = schedule_with(
4415            When::PerPc(PerPolicy::Every(EverySpec { every: "6x".into() })),
4416            RunsOn::Backend,
4417        )
4418        .validate()
4419        .unwrap_err();
4420        assert!(err.contains("when.every"), "got: {err}");
4421    }
4422
4423    #[test]
4424    fn validate_rejects_bad_jitter_and_starting_deadline() {
4425        let mut s = schedule_with(
4426            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4427            RunsOn::Backend,
4428        );
4429        s.plan.jitter = Some("5x".into());
4430        let err = s.validate().unwrap_err();
4431        assert!(err.contains("jitter"), "got: {err}");
4432
4433        let mut s = schedule_with(
4434            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4435            RunsOn::Backend,
4436        );
4437        s.starting_deadline = Some("soon".into());
4438        let err = s.validate().unwrap_err();
4439        assert!(err.contains("starting_deadline"), "got: {err}");
4440    }
4441
4442    #[test]
4443    fn validate_accepts_calendar_shapes() {
4444        for when in [
4445            calendar("09:00", &["mon-fri"]),   // weekday morning
4446            calendar("00:00", &["sun"]),       // weekly
4447            calendar("18:30", &[]),            // daily
4448            calendar("2026-06-10 09:00", &[]), // one-shot
4449            calendar("2026/12/25 00:00", &[]), // one-shot, slash form
4450        ] {
4451            schedule_with(when.clone(), RunsOn::Backend)
4452                .validate()
4453                .unwrap_or_else(|e| panic!("{when} should validate: {e}"));
4454        }
4455    }
4456
4457    #[test]
4458    fn validate_rejects_bad_at() {
4459        for bad in ["25:00", "09:60", "9", "noon", "2026-13-01 09:00"] {
4460            let err = schedule_with(calendar(bad, &[]), RunsOn::Backend)
4461                .validate()
4462                .unwrap_err();
4463            assert!(err.contains("when.at"), "for '{bad}', got: {err}");
4464        }
4465    }
4466
4467    #[test]
4468    fn validate_rejects_datetime_at_with_days() {
4469        // A dated `at` is a one-shot — pairing it with days is a
4470        // contradiction (the date already pins the day).
4471        let err = schedule_with(calendar("2026-06-10 09:00", &["mon"]), RunsOn::Backend)
4472            .validate()
4473            .unwrap_err();
4474        assert!(
4475            err.contains("one-shot") && err.contains("days"),
4476            "got: {err}"
4477        );
4478    }
4479
4480    #[test]
4481    fn validate_rejects_bad_day_name() {
4482        // A garbage DOW token is caught by the days pre-flight and
4483        // reported against `when.days`, not the confusing
4484        // "when.at lowered to invalid cron" (claude #432 review).
4485        let err = schedule_with(calendar("09:00", &["funday"]), RunsOn::Backend)
4486            .validate()
4487            .unwrap_err();
4488        assert!(err.contains("when.days"), "got: {err}");
4489        assert!(err.contains("funday"), "names the bad token: {err}");
4490        // a degenerate range like `mon-` reports the whole token, not
4491        // a cryptic empty part (claude #432 follow-up)
4492        let err = schedule_with(calendar("09:00", &["mon-"]), RunsOn::Backend)
4493            .validate()
4494            .unwrap_err();
4495        assert!(err.contains("'mon-'"), "names the whole token: {err}");
4496        // valid names / ranges / numeric / * all pass
4497        for ok in [
4498            calendar("09:00", &["mon-fri"]),
4499            calendar("09:00", &["mon", "wed", "sun"]),
4500            calendar("09:00", &["1-5"]),
4501        ] {
4502            schedule_with(ok.clone(), RunsOn::Backend)
4503                .validate()
4504                .unwrap_or_else(|e| panic!("{ok} should validate: {e}"));
4505        }
4506    }
4507
4508    #[test]
4509    fn validate_accepts_nth_weekday() {
4510        // #418: nth-weekday (Patch Tuesday). validate() also lowers to
4511        // a cron and parses it with croner, so passing here proves the
4512        // whole chain — token → DOW field → engine-acceptable cron.
4513        for ok in [
4514            calendar("09:00", &["tue#2"]),          // 2nd Tuesday
4515            calendar("09:00", &["fri#1"]),          // 1st Friday
4516            calendar("03:00", &["sun#5"]),          // 5th Sunday
4517            calendar("09:00", &["tue#2", "thu#2"]), // a list of nths
4518            calendar("09:00", &["2#2"]),            // numeric DOW + ordinal
4519            // Case-insensitive both sides: validate lowercases, croner
4520            // upper-cases the whole pattern before aliasing (claude #547).
4521            calendar("09:00", &["TUE#2"]),
4522        ] {
4523            schedule_with(ok.clone(), RunsOn::Backend)
4524                .validate()
4525                .unwrap_or_else(|e| panic!("{ok} should validate: {e}"));
4526        }
4527    }
4528
4529    #[test]
4530    fn validate_rejects_bad_nth_weekday() {
4531        // ordinal out of 1..5, a range with #, and a bad day before #.
4532        for bad in ["tue#0", "tue#6", "tue#x", "mon-fri#2", "funday#2"] {
4533            let err = schedule_with(calendar("09:00", &[bad]), RunsOn::Backend)
4534                .validate()
4535                .unwrap_err();
4536            assert!(err.contains("when.days"), "for '{bad}', got: {err}");
4537        }
4538    }
4539
4540    #[test]
4541    fn validate_accepts_last_weekday() {
4542        // #418: last-weekday (`friL` = last Friday). Like the nth case,
4543        // validate() lowers to a cron and round-trips it through croner,
4544        // so passing proves token → DOW field → engine-acceptable cron
4545        // with the verified last-<dow>-of-month semantics.
4546        for ok in [
4547            calendar("09:00", &["friL"]),         // last Friday
4548            calendar("03:00", &["sunL"]),         // last Sunday
4549            calendar("22:00", &["5L"]),           // numeric DOW + last
4550            calendar("00:00", &["0L"]),           // numeric Sunday (0…
4551            calendar("00:00", &["7L"]),           // …and its 7 alias)
4552            calendar("09:00", &["monL", "friL"]), // a list of last-weekdays
4553            // Case-insensitive both the weekday and the `L` suffix:
4554            // validate lowercases the day, croner upper-cases the whole
4555            // pattern before aliasing (claude #547).
4556            calendar("09:00", &["FRIL"]),
4557            calendar("09:00", &["fril"]),
4558        ] {
4559            schedule_with(ok.clone(), RunsOn::Backend)
4560                .validate()
4561                .unwrap_or_else(|e| panic!("{ok} should validate: {e}"));
4562        }
4563    }
4564
4565    #[test]
4566    fn validate_rejects_bad_last_weekday() {
4567        // bare `L` (no weekday — a footgun croner reads as Saturday), a
4568        // range with L, a bad day before L, and an internal space that
4569        // would otherwise leak a malformed cron downstream (gemini #560).
4570        for bad in ["L", "l", "mon-friL", "fundayL", "8L", "*L", "fri L"] {
4571            let err = schedule_with(calendar("09:00", &[bad]), RunsOn::Backend)
4572                .validate()
4573                .unwrap_err();
4574            assert!(err.contains("when.days"), "for '{bad}', got: {err}");
4575        }
4576    }
4577
4578    #[test]
4579    fn calendar_oneshot_instant_detects_past() {
4580        use chrono::TimeZone;
4581        // a dated `at` resolves to an absolute instant…
4582        let c = CalendarSpec {
4583            at: "2024-01-01 09:00".into(),
4584            days: vec![],
4585        };
4586        let t = c
4587            .oneshot_instant(ScheduleTz::Utc)
4588            .expect("one-shot instant");
4589        assert_eq!(
4590            t,
4591            chrono::Utc.with_ymd_and_hms(2024, 1, 1, 9, 0, 0).unwrap()
4592        );
4593        assert!(t < chrono::Utc::now(), "2024 is in the past");
4594        // …while a repeating (time-only) calendar has no instant
4595        let rep = CalendarSpec {
4596            at: "09:00".into(),
4597            days: vec!["mon-fri".into()],
4598        };
4599        assert!(rep.oneshot_instant(ScheduleTz::Utc).is_none());
4600    }
4601
4602    fn schedule_with_active(from: Option<&str>, until: Option<&str>) -> Schedule {
4603        let mut s = schedule_with(
4604            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4605            RunsOn::Backend,
4606        );
4607        s.active = Active {
4608            from: from.map(str::to_owned),
4609            until: until.map(str::to_owned),
4610        };
4611        s
4612    }
4613
4614    #[test]
4615    fn validate_accepts_active_window() {
4616        schedule_with_active(Some("2026-07-01"), Some("2026-08-01T12:00:00+09:00"))
4617            .validate()
4618            .expect("date + rfc3339 bounds should validate");
4619    }
4620
4621    #[test]
4622    fn validate_rejects_unparseable_active_bound() {
4623        let err = schedule_with_active(Some("July 1st"), None)
4624            .validate()
4625            .unwrap_err();
4626        assert!(err.contains("active"), "got: {err}");
4627    }
4628
4629    #[test]
4630    fn validate_rejects_from_not_before_until() {
4631        let err = schedule_with_active(Some("2026-08-01"), Some("2026-07-01"))
4632            .validate()
4633            .unwrap_err();
4634        assert!(err.contains("strictly before"), "got: {err}");
4635
4636        let err = schedule_with_active(Some("2026-07-01"), Some("2026-07-01"))
4637            .validate()
4638            .unwrap_err();
4639        assert!(err.contains("strictly before"), "got: {err}");
4640    }
4641
4642    // ---- Active window semantics ----
4643
4644    #[test]
4645    fn active_window_is_half_open() {
4646        use chrono::TimeZone;
4647        let active = Active {
4648            from: Some("2026-07-01".into()),
4649            until: Some("2026-08-01".into()),
4650        };
4651        // UTC tz so the date bounds are UTC midnight.
4652        let at = |y, m, d, h| chrono::Utc.with_ymd_and_hms(y, m, d, h, 0, 0).unwrap();
4653        let c = |t| active.contains(t, ScheduleTz::Utc);
4654        assert!(!c(at(2026, 6, 30, 23)), "before from");
4655        assert!(c(at(2026, 7, 1, 0)), "at from (inclusive)");
4656        assert!(c(at(2026, 7, 15, 12)), "inside");
4657        assert!(!c(at(2026, 8, 1, 0)), "at until (exclusive)");
4658        assert!(!c(at(2026, 8, 2, 0)), "after until");
4659    }
4660
4661    #[test]
4662    fn active_empty_window_is_always_active() {
4663        assert!(Active::default().contains(chrono::Utc::now(), ScheduleTz::Local));
4664    }
4665
4666    #[test]
4667    fn active_rfc3339_bound_honours_offset_regardless_of_tz() {
4668        use chrono::TimeZone;
4669        let active = Active {
4670            from: Some("2026-07-01T09:00:00+09:00".into()),
4671            until: None,
4672        };
4673        // RFC3339 carries its own offset → tz arg is ignored.
4674        // 09:00 JST = 00:00 UTC.
4675        for tz in [ScheduleTz::Utc, ScheduleTz::Local] {
4676            assert!(
4677                !active.contains(
4678                    chrono::Utc
4679                        .with_ymd_and_hms(2026, 6, 30, 23, 59, 0)
4680                        .unwrap(),
4681                    tz
4682                )
4683            );
4684            assert!(active.contains(
4685                chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
4686                tz
4687            ));
4688        }
4689    }
4690
4691    #[test]
4692    fn active_date_bound_respects_tz() {
4693        // A bare `YYYY-MM-DD` bound is midnight *in the schedule's
4694        // tz* (#418 Phase 2). The UTC interpretation is exact and
4695        // host-independent; assert that precisely.
4696        use chrono::TimeZone;
4697        let utc = Active::parse_bound("2026-07-01", ScheduleTz::Utc).expect("utc");
4698        assert_eq!(
4699            utc,
4700            chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap()
4701        );
4702
4703        // The local interpretation must equal what chrono::Local
4704        // computes for the same wall-clock midnight — proves the tz
4705        // path is wired to the host zone (the magnitude vs UTC is
4706        // host-dependent, so we compare against Local directly rather
4707        // than hard-coding the JST offset, keeping CI green on UTC
4708        // runners).
4709        let local = Active::parse_bound("2026-07-01", ScheduleTz::Local).expect("local");
4710        let want = chrono::Local
4711            .with_ymd_and_hms(2026, 7, 1, 0, 0, 0)
4712            .single()
4713            .expect("local midnight is unambiguous")
4714            .with_timezone(&chrono::Utc);
4715        assert_eq!(local, want, "date bound resolved in host-local tz");
4716    }
4717
4718    #[test]
4719    fn active_empty_is_skipped_when_serialising() {
4720        let s = schedule_with(
4721            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4722            RunsOn::Backend,
4723        );
4724        let json = serde_json::to_value(&s).expect("serialise");
4725        assert!(
4726            json.get("active").is_none(),
4727            "empty active must not appear on the wire: {json}"
4728        );
4729    }
4730
4731    // ---- constraints.window (#418 Phase 3) ----
4732
4733    fn with_window(win: &str) -> Schedule {
4734        let mut s = schedule_with(
4735            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
4736            RunsOn::Backend,
4737        );
4738        s.constraints.window = Some(win.into());
4739        s
4740    }
4741
4742    #[test]
4743    fn constraints_window_parses_and_round_trips() {
4744        let yaml = r#"
4745id: x
4746when:
4747  per_pc: { every: 6h }
4748job_id: y
4749target: { all: true }
4750constraints:
4751  window: "22:00-05:00"
4752"#;
4753        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
4754        assert_eq!(s.constraints.window.as_deref(), Some("22:00-05:00"));
4755        let back: Schedule =
4756            serde_json::from_str(&serde_json::to_string(&s).expect("ser")).expect("de");
4757        assert_eq!(back.constraints.window.as_deref(), Some("22:00-05:00"));
4758    }
4759
4760    #[test]
4761    fn constraints_empty_is_skipped_when_serialising() {
4762        let s = schedule_with(
4763            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4764            RunsOn::Backend,
4765        );
4766        let json = serde_json::to_value(&s).expect("serialise");
4767        assert!(
4768            json.get("constraints").is_none(),
4769            "empty constraints must not appear on the wire: {json}"
4770        );
4771    }
4772
4773    #[test]
4774    fn window_no_constraint_always_allows() {
4775        let c = Constraints::default();
4776        assert!(c.allows(chrono::Utc::now(), ScheduleTz::Local));
4777    }
4778
4779    #[test]
4780    fn window_same_day_is_half_open() {
4781        use chrono::TimeZone;
4782        let s = with_window("09:00-17:00");
4783        let at = |h, m| chrono::Utc.with_ymd_and_hms(2026, 6, 9, h, m, 0).unwrap();
4784        let a = |t| s.constraints.allows(t, ScheduleTz::Utc);
4785        assert!(!a(at(8, 59)), "before start");
4786        assert!(a(at(9, 0)), "at start (inclusive)");
4787        assert!(a(at(16, 59)), "inside");
4788        assert!(!a(at(17, 0)), "at end (exclusive)");
4789        assert!(!a(at(23, 0)), "after end");
4790    }
4791
4792    #[test]
4793    fn window_crossing_midnight() {
4794        use chrono::TimeZone;
4795        let s = with_window("22:00-05:00");
4796        let at = |h, m| chrono::Utc.with_ymd_and_hms(2026, 6, 9, h, m, 0).unwrap();
4797        let a = |t| s.constraints.allows(t, ScheduleTz::Utc);
4798        assert!(a(at(22, 0)), "at start tonight");
4799        assert!(a(at(23, 30)), "late tonight");
4800        assert!(a(at(3, 0)), "early tomorrow");
4801        assert!(!a(at(5, 0)), "at end (exclusive)");
4802        assert!(!a(at(12, 0)), "midday outside");
4803        assert!(!a(at(21, 59)), "just before start");
4804    }
4805
4806    #[test]
4807    fn window_respects_tz() {
4808        // The same instant is inside the window under one tz and may
4809        // be outside under another. Compare UTC vs Local via the
4810        // host's own offset (kept CI-green on UTC runners like the
4811        // active tz test does).
4812        use chrono::TimeZone;
4813        let s = with_window("09:00-17:00");
4814        let noon_utc = chrono::Utc.with_ymd_and_hms(2026, 6, 9, 12, 0, 0).unwrap();
4815        // Under UTC, 12:00 is inside 09:00-17:00.
4816        assert!(s.constraints.allows(noon_utc, ScheduleTz::Utc));
4817        // Under Local, the verdict tracks the host wall-clock time;
4818        // assert it matches a direct wall_time membership check.
4819        let local_t = noon_utc.with_timezone(&chrono::Local).time();
4820        let in_local = local_t >= chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap()
4821            && local_t < chrono::NaiveTime::from_hms_opt(17, 0, 0).unwrap();
4822        assert_eq!(s.constraints.allows(noon_utc, ScheduleTz::Local), in_local);
4823    }
4824
4825    #[test]
4826    fn validate_accepts_good_window() {
4827        for w in ["09:00-17:00", "22:00-05:00", "00:00-23:59"] {
4828            with_window(w)
4829                .validate()
4830                .unwrap_or_else(|e| panic!("'{w}' should validate: {e}"));
4831        }
4832    }
4833
4834    #[test]
4835    fn validate_rejects_bad_window() {
4836        for bad in ["9-5", "22:00", "22:00-22:00", "25:00-05:00", "09:00_17:00"] {
4837            let err = with_window(bad).validate().unwrap_err();
4838            assert!(
4839                err.contains("constraints.window"),
4840                "for '{bad}', got: {err}"
4841            );
4842        }
4843    }
4844
4845    // ---- constraints.skip_dates (#418 holiday exclusion) ----
4846
4847    fn with_skip_dates(dates: &[&str]) -> Schedule {
4848        let mut s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
4849        s.tz = ScheduleTz::Utc; // host-independent date assertions
4850        s.constraints.skip_dates = dates.iter().map(|d| (*d).to_string()).collect();
4851        s
4852    }
4853
4854    #[test]
4855    fn allows_blocks_listed_skip_date() {
4856        use chrono::TimeZone;
4857        let s = with_skip_dates(&["2026-06-10", "2026-12-25"]);
4858        // Any time on a listed date is blocked (whole day).
4859        let on = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 9, 0, 0).unwrap();
4860        assert!(!s.constraints.allows(on, ScheduleTz::Utc));
4861        let on_midnight = chrono::Utc.with_ymd_and_hms(2026, 12, 25, 0, 0, 0).unwrap();
4862        assert!(!s.constraints.allows(on_midnight, ScheduleTz::Utc));
4863        // A date not in the list fires normally.
4864        let off = chrono::Utc.with_ymd_and_hms(2026, 6, 11, 9, 0, 0).unwrap();
4865        assert!(s.constraints.allows(off, ScheduleTz::Utc));
4866    }
4867
4868    #[test]
4869    fn allows_corrupt_skip_date_fails_closed() {
4870        use chrono::TimeZone;
4871        // A garbled entry (only reachable via hand-edited KV) blocks
4872        // rather than silently re-enabling fires — same posture as a
4873        // corrupt window.
4874        let s = with_skip_dates(&["not-a-date"]);
4875        let any = chrono::Utc.with_ymd_and_hms(2026, 6, 11, 9, 0, 0).unwrap();
4876        assert!(!s.constraints.allows(any, ScheduleTz::Utc));
4877    }
4878
4879    #[test]
4880    fn validate_accepts_good_skip_dates() {
4881        with_skip_dates(&["2026-01-01", "2026-12-25", "2027-05-03"])
4882            .validate()
4883            .expect("well-formed skip dates should validate");
4884    }
4885
4886    #[test]
4887    fn validate_rejects_bad_skip_date() {
4888        for bad in ["2026-13-01", "01-01-2026", "nope", "2026/01/01"] {
4889            let err = with_skip_dates(&[bad]).validate().unwrap_err();
4890            assert!(
4891                err.contains("constraints.skip_dates"),
4892                "for '{bad}', got: {err}"
4893            );
4894        }
4895    }
4896
4897    #[test]
4898    fn preview_skips_holidays() {
4899        use chrono::TimeZone;
4900        // Daily 09:00 with two of the next five days marked as holidays
4901        // — preview drops exactly those, since it gates on `allows`.
4902        let mut s = cal_utc("09:00", &[]);
4903        s.constraints.skip_dates = vec!["2026-06-11".into(), "2026-06-13".into()];
4904        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 0, 0, 0).unwrap();
4905        let got = s.preview_fires(now, 4);
4906        let want: Vec<_> = [
4907            (2026, 6, 10),
4908            (2026, 6, 12), // skips 06-11
4909            (2026, 6, 14), // skips 06-13
4910            (2026, 6, 15),
4911        ]
4912        .iter()
4913        .map(|(y, m, d)| chrono::Utc.with_ymd_and_hms(*y, *m, *d, 9, 0, 0).unwrap())
4914        .collect();
4915        assert_eq!(got, want);
4916    }
4917
4918    // ---- constraints.max_concurrent (#418) ----
4919
4920    fn with_max_concurrent(max: u32, runs_on: RunsOn) -> Schedule {
4921        let mut s = schedule_with(
4922            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
4923            runs_on,
4924        );
4925        s.constraints.max_concurrent = Some(max);
4926        s
4927    }
4928
4929    #[test]
4930    fn validate_accepts_backend_max_concurrent() {
4931        with_max_concurrent(5, RunsOn::Backend)
4932            .validate()
4933            .expect("backend max_concurrent should validate");
4934    }
4935
4936    #[test]
4937    fn validate_rejects_max_concurrent_on_agent() {
4938        // Decision E: a central running-instance cap needs a central
4939        // counter, which agents don't have.
4940        let err = with_max_concurrent(5, RunsOn::Agent)
4941            .validate()
4942            .unwrap_err();
4943        assert!(err.contains("constraints.max_concurrent"), "got: {err}");
4944        assert!(err.contains("runs_on: agent"), "got: {err}");
4945    }
4946
4947    #[test]
4948    fn validate_rejects_zero_max_concurrent() {
4949        let err = with_max_concurrent(0, RunsOn::Backend)
4950            .validate()
4951            .unwrap_err();
4952        assert!(err.contains("max_concurrent must be >= 1"), "got: {err}");
4953    }
4954
4955    #[test]
4956    fn max_concurrent_round_trips_and_skips_when_absent() {
4957        let s = with_max_concurrent(3, RunsOn::Backend);
4958        let json = serde_json::to_value(&s.constraints).expect("ser");
4959        assert_eq!(json.get("max_concurrent").and_then(|v| v.as_u64()), Some(3));
4960        // A schedule with no constraints omits the whole block.
4961        let bare = schedule_with(
4962            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4963            RunsOn::Backend,
4964        );
4965        assert!(bare.constraints.is_empty());
4966    }
4967
4968    #[test]
4969    fn window_fail_closed_on_corrupt_blob() {
4970        // A malformed window (only reachable via a hand-edited KV
4971        // blob — validate() rejects it at create) must BLOCK, not
4972        // silently allow fires during a change-freeze (gemini #452).
4973        let s = with_window("22:00_05:00");
4974        assert!(
4975            !s.constraints.allows(chrono::Utc::now(), ScheduleTz::Utc),
4976            "corrupt window fails closed"
4977        );
4978        // …and the scheduler can surface why it's stuck.
4979        assert!(
4980            s.bad_window().is_some(),
4981            "bad_window reports the parse error"
4982        );
4983        assert!(with_window("22:00-05:00").bad_window().is_none());
4984    }
4985
4986    #[test]
4987    fn calendar_outside_window_is_flagged() {
4988        // at 09:00 can never fall in 22:00-05:00 → never fires.
4989        let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
4990        s.constraints.window = Some("22:00-05:00".into());
4991        assert!(s.calendar_outside_window(), "09:00 is not in 22:00-05:00");
4992
4993        // at 23:00 IS inside the overnight window → fine.
4994        let mut s = schedule_with(calendar("23:00", &[]), RunsOn::Backend);
4995        s.constraints.window = Some("22:00-05:00".into());
4996        assert!(!s.calendar_outside_window(), "23:00 is in 22:00-05:00");
4997
4998        // reconcile shapes are never flagged (they poll every minute).
4999        let mut s = schedule_with(
5000            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
5001            RunsOn::Backend,
5002        );
5003        s.constraints.window = Some("22:00-05:00".into());
5004        assert!(!s.calendar_outside_window(), "reconcile is unaffected");
5005
5006        // no window → never flagged.
5007        let s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
5008        assert!(!s.calendar_outside_window());
5009    }
5010
5011    // ---- on_failure.retry (#418 Phase 4) ----
5012
5013    fn with_retry(max: u32, backoff: &str) -> Schedule {
5014        let mut s = schedule_with(
5015            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
5016            RunsOn::Backend,
5017        );
5018        s.on_failure.retry = Some(Retry {
5019            max,
5020            backoff: backoff.into(),
5021        });
5022        s
5023    }
5024
5025    #[test]
5026    fn on_failure_parses_and_round_trips() {
5027        let yaml = r#"
5028id: x
5029when:
5030  per_pc: { every: 6h }
5031job_id: y
5032target: { all: true }
5033on_failure:
5034  retry: { max: 3, backoff: 10m }
5035"#;
5036        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
5037        let r = s.on_failure.retry.as_ref().expect("retry present");
5038        assert_eq!(r.max, 3);
5039        assert_eq!(r.backoff, "10m");
5040        let back: Schedule =
5041            serde_json::from_str(&serde_json::to_string(&s).expect("ser")).expect("de");
5042        assert_eq!(back.on_failure, s.on_failure);
5043    }
5044
5045    #[test]
5046    fn on_failure_empty_is_skipped_when_serialising() {
5047        let s = schedule_with(
5048            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
5049            RunsOn::Backend,
5050        );
5051        let json = serde_json::to_value(&s).expect("serialise");
5052        assert!(
5053            json.get("on_failure").is_none(),
5054            "empty on_failure must not appear on the wire: {json}"
5055        );
5056    }
5057
5058    #[test]
5059    fn validate_accepts_good_retry() {
5060        for (max, backoff) in [(1, "30s"), (3, "10m"), (10, "1h")] {
5061            with_retry(max, backoff)
5062                .validate()
5063                .unwrap_or_else(|e| panic!("retry {{max:{max}, backoff:{backoff}}}: {e}"));
5064        }
5065    }
5066
5067    #[test]
5068    fn validate_rejects_bad_backoff() {
5069        let err = with_retry(3, "soon").validate().unwrap_err();
5070        assert!(err.contains("on_failure.retry.backoff"), "got: {err}");
5071    }
5072
5073    #[test]
5074    fn validate_rejects_sub_second_backoff() {
5075        // "500ms" parses as humantime but lowers to 0s on the wire —
5076        // reject it so the operator doesn't get a silent no-wait
5077        // (coderabbit #466).
5078        for bad in ["500ms", "0s", "999ms"] {
5079            let err = with_retry(3, bad).validate().unwrap_err();
5080            assert!(
5081                err.contains("on_failure.retry.backoff must be >= 1s"),
5082                "for '{bad}', got: {err}"
5083            );
5084        }
5085    }
5086
5087    #[test]
5088    fn validate_rejects_out_of_range_max() {
5089        for bad in [0u32, 11, 1000] {
5090            let err = with_retry(bad, "10m").validate().unwrap_err();
5091            assert!(
5092                err.contains("on_failure.retry.max"),
5093                "for max={bad}, got: {err}"
5094            );
5095        }
5096    }
5097
5098    #[test]
5099    fn lowered_retry_reduces_backoff_to_seconds() {
5100        let s = with_retry(3, "10m");
5101        let spec = s.on_failure.lowered_retry().expect("a retry policy");
5102        assert_eq!(spec.max, 3);
5103        assert_eq!(spec.backoff_secs, 600);
5104    }
5105
5106    #[test]
5107    fn lowered_retry_is_none_without_policy() {
5108        let s = schedule_with(
5109            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
5110            RunsOn::Backend,
5111        );
5112        assert!(s.on_failure.lowered_retry().is_none());
5113    }
5114
5115    // ---- global change-freeze (#418 Phase 5) ----
5116
5117    #[test]
5118    fn freeze_empty_window_is_always_active() {
5119        // The big-red-button shape: no bounds = frozen until cleared.
5120        let f = Freeze::default();
5121        assert!(f.is_active(chrono::Utc::now()));
5122    }
5123
5124    #[test]
5125    fn freeze_window_is_half_open() {
5126        use chrono::TimeZone;
5127        let f = Freeze {
5128            from: Some("2026-12-20T00:00:00+00:00".into()),
5129            until: Some("2027-01-05T00:00:00+00:00".into()),
5130            reason: Some("year-end".into()),
5131            tz: ScheduleTz::Utc,
5132        };
5133        let at = |y, mo, d| chrono::Utc.with_ymd_and_hms(y, mo, d, 0, 0, 0).unwrap();
5134        assert!(!f.is_active(at(2026, 12, 19)), "before from = not frozen");
5135        assert!(f.is_active(at(2026, 12, 20)), "from is inclusive");
5136        assert!(f.is_active(at(2026, 12, 31)), "inside window");
5137        assert!(!f.is_active(at(2027, 1, 5)), "until is exclusive");
5138        assert!(!f.is_active(at(2027, 1, 6)), "after until = not frozen");
5139    }
5140
5141    #[test]
5142    fn freeze_fails_closed_on_corrupt_bound() {
5143        // A freeze is a safety switch: an unparseable bound (only
5144        // reachable via a hand-edited KV blob) must read as FROZEN, not
5145        // "fire normally" (coderabbit #472) — the opposite of `active`,
5146        // which fail-opens.
5147        let f = Freeze {
5148            from: Some("not-a-date".into()),
5149            until: None,
5150            reason: None,
5151            tz: ScheduleTz::Utc,
5152        };
5153        assert!(f.is_active(chrono::Utc::now()), "corrupt bound → frozen");
5154    }
5155
5156    #[test]
5157    fn freeze_validate_accepts_good_bounds() {
5158        Freeze {
5159            from: Some("2026-12-20".into()),
5160            until: Some("2027-01-05T12:00:00+09:00".into()),
5161            reason: None,
5162            tz: ScheduleTz::Local,
5163        }
5164        .validate()
5165        .expect("date + rfc3339 bounds should validate");
5166        // Empty (indefinite) freeze is valid.
5167        Freeze::default().validate().expect("empty freeze is valid");
5168    }
5169
5170    #[test]
5171    fn freeze_validate_rejects_bad_bound_and_inverted_window() {
5172        let err = Freeze {
5173            from: Some("never".into()),
5174            ..Default::default()
5175        }
5176        .validate()
5177        .unwrap_err();
5178        assert!(err.contains("freeze:"), "got: {err}");
5179
5180        let inverted = Freeze {
5181            from: Some("2027-01-05".into()),
5182            until: Some("2026-12-20".into()),
5183            ..Default::default()
5184        }
5185        .validate()
5186        .unwrap_err();
5187        assert!(inverted.contains("freeze.from"), "got: {inverted}");
5188    }
5189
5190    #[test]
5191    fn freeze_round_trips_and_skips_empty_fields() {
5192        let f = Freeze {
5193            from: None,
5194            until: Some("2027-01-05".into()),
5195            reason: Some("INC-1234".into()),
5196            tz: ScheduleTz::Utc,
5197        };
5198        let json = serde_json::to_value(&f).expect("serialise");
5199        assert!(json.get("from").is_none(), "empty from omitted: {json}");
5200        let back: Freeze = serde_json::from_value(json).expect("round-trip");
5201        assert_eq!(back, f);
5202    }
5203
5204    #[test]
5205    fn shipped_schedule_configs_parse_and_validate() {
5206        // Every YAML under configs/schedules/ must parse with the
5207        // current Schedule serde AND pass validate() — keeps the
5208        // shipped examples from drifting out of sync with the model
5209        // (#418 removed back-compat, so drift = broken at create).
5210        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../configs/schedules");
5211        let mut seen = 0;
5212        for entry in std::fs::read_dir(&dir).expect("read configs/schedules") {
5213            let path = entry.expect("dir entry").path();
5214            if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
5215                continue;
5216            }
5217            let body = std::fs::read_to_string(&path).expect("read yaml");
5218            let s: Schedule = serde_yaml::from_str(&body)
5219                .unwrap_or_else(|e| panic!("{} failed to parse: {e}", path.display()));
5220            s.validate()
5221                .unwrap_or_else(|e| panic!("{} failed validate(): {e}", path.display()));
5222            seen += 1;
5223        }
5224        assert!(seen > 0, "no schedule YAMLs found in {}", dir.display());
5225    }
5226
5227    // ---- pre-existing enum wire formats (unchanged by #418) ----
5228
5229    #[test]
5230    fn exec_mode_serialises_snake_case() {
5231        for (mode, expected) in [
5232            (ExecMode::EveryTick, "every_tick"),
5233            (ExecMode::OncePerPc, "once_per_pc"),
5234            (ExecMode::OncePerTarget, "once_per_target"),
5235        ] {
5236            let s = serde_json::to_value(mode).expect("serialise");
5237            assert_eq!(s, serde_json::Value::String(expected.into()));
5238            let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
5239                .expect("deserialise");
5240            assert_eq!(back, mode, "round-trip for {expected}");
5241        }
5242    }
5243
5244    #[test]
5245    fn schedule_runs_on_defaults_to_backend() {
5246        let yaml = r#"
5247id: x
5248when:
5249  per_pc: once
5250job_id: y
5251target: { all: true }
5252"#;
5253        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
5254        assert_eq!(s.runs_on, RunsOn::Backend);
5255    }
5256
5257    #[test]
5258    fn schedule_runs_on_agent_parses() {
5259        let yaml = r#"
5260id: offline-inv
5261when:
5262  per_pc: { every: 1h }
5263job_id: inventory-hw
5264target: { all: true }
5265runs_on: agent
5266"#;
5267        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
5268        assert_eq!(s.runs_on, RunsOn::Agent);
5269        assert_eq!(s.lowered().mode, ExecMode::OncePerPc);
5270    }
5271
5272    #[test]
5273    fn runs_on_serialises_snake_case() {
5274        for (mode, expected) in [(RunsOn::Backend, "backend"), (RunsOn::Agent, "agent")] {
5275            let s = serde_json::to_value(mode).expect("serialise");
5276            assert_eq!(s, serde_json::Value::String(expected.into()));
5277            let back: RunsOn = serde_json::from_value(serde_json::Value::String(expected.into()))
5278                .expect("deserialise");
5279            assert_eq!(back, mode);
5280        }
5281    }
5282
5283    #[test]
5284    fn execute_shell_into_wire_shell() {
5285        assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
5286        assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
5287    }
5288
5289    #[test]
5290    fn manifest_staleness_defaults_to_cached() {
5291        let yaml = r#"
5292id: x
5293version: 1.0.0
5294execute:
5295  shell: powershell
5296  script: "echo"
5297  timeout: 1s
5298"#;
5299        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
5300        assert_eq!(m.staleness, Staleness::Cached);
5301    }
5302
5303    #[test]
5304    fn manifest_strict_staleness_parses() {
5305        let yaml = r#"
5306id: urgent-patch
5307version: 2.5.1
5308execute:
5309  shell: powershell
5310  script: Install-Hotfix
5311  timeout: 5m
5312staleness:
5313  mode: strict
5314  max_cache_age: 0s
5315"#;
5316        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
5317        match m.staleness {
5318            Staleness::Strict { max_cache_age } => assert_eq!(max_cache_age, "0s"),
5319            other => panic!("expected strict, got {other:?}"),
5320        }
5321    }
5322
5323    #[test]
5324    fn manifest_unchecked_staleness_parses() {
5325        let yaml = r#"
5326id: legacy
5327version: 0.1.0
5328execute:
5329  shell: cmd
5330  script: "echo"
5331  timeout: 1s
5332staleness:
5333  mode: unchecked
5334"#;
5335        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
5336        assert_eq!(m.staleness, Staleness::Unchecked);
5337    }
5338
5339    #[test]
5340    fn missing_required_field_errors() {
5341        // `id` missing.
5342        let yaml = r#"
5343version: 1.0.0
5344target: { all: true }
5345execute:
5346  shell: powershell
5347  script: "echo"
5348  timeout: 1s
5349"#;
5350        let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
5351        assert!(r.is_err(), "expected error, got {:?}", r);
5352    }
5353
5354    #[test]
5355    fn display_field_table_kind_round_trips_with_nested_columns() {
5356        // #39: `type: table` + `columns:` on a DisplayField gets
5357        // round-tripped through serde so the SPA receives the
5358        // nested schema verbatim. Nested columns themselves are
5359        // DisplayFields so they can carry `type: bytes` /
5360        // `type: number` for cell formatting.
5361        let yaml = r#"
5362id: inv-hw
5363version: 1.0.0
5364execute:
5365  shell: powershell
5366  script: "echo"
5367  timeout: 60s
5368inventory:
5369  display:
5370    - field: hostname
5371      label: Hostname
5372    - field: disks
5373      label: Disks
5374      type: table
5375      columns:
5376        - field: device_id
5377          label: Drive
5378        - field: size_bytes
5379          label: Size
5380          type: bytes
5381        - field: free_bytes
5382          label: Free
5383          type: bytes
5384        - field: file_system
5385          label: FS
5386"#;
5387        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
5388        let inv = m.inventory.as_ref().expect("inventory hint");
5389        let disks = inv
5390            .display
5391            .iter()
5392            .find(|d| d.field == "disks")
5393            .expect("disks display row");
5394        assert_eq!(disks.kind.as_deref(), Some("table"));
5395        let cols = disks.columns.as_ref().expect("table needs columns");
5396        assert_eq!(cols.len(), 4);
5397        assert_eq!(cols[1].field, "size_bytes");
5398        assert_eq!(cols[1].kind.as_deref(), Some("bytes"));
5399    }
5400
5401    #[test]
5402    fn display_field_scalar_kind_keeps_columns_none() {
5403        // Defensive: when type is a scalar (`bytes` / `number` /
5404        // `timestamp`) the `columns` field stays None — the SPA
5405        // uses its presence as the "render nested table" signal,
5406        // so it must not leak in via serde defaults.
5407        let yaml = r#"
5408id: x
5409version: 1.0.0
5410execute:
5411  shell: powershell
5412  script: "echo"
5413  timeout: 5s
5414inventory:
5415  display:
5416    - { field: ram_bytes, label: RAM, type: bytes }
5417"#;
5418        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
5419        let inv = m.inventory.as_ref().unwrap();
5420        assert!(inv.display[0].columns.is_none());
5421    }
5422
5423    // ---- checked-in JSON Schema freshness (docs/schemas/) ----
5424
5425    /// The JSON Schemas under `docs/schemas/` must match what
5426    /// `schema_for!` produces today — a Cargo.lock-style freshness guard
5427    /// so a `Schedule` / `Manifest` field change can't silently drift
5428    /// the operator-facing schema. The SPA editor, the backend
5429    /// `/api/schemas/*` endpoints, and these files all read the same
5430    /// derived shape; this test fails CI if the checked-in copy lags.
5431    /// Regenerate with:
5432    ///   `UPDATE_SCHEMAS=1 cargo test -p kanade-shared schema_files_are_current`
5433    #[test]
5434    fn schema_files_are_current() {
5435        assert_schema_file("schedule.schema.json", &schemars::schema_for!(Schedule));
5436        assert_schema_file("job.schema.json", &schemars::schema_for!(Manifest));
5437        assert_schema_file("view.schema.json", &schemars::schema_for!(View));
5438    }
5439
5440    fn assert_schema_file(name: &str, schema: &schemars::Schema) {
5441        let generated = serde_json::to_string_pretty(schema).expect("serialize schema") + "\n";
5442        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
5443            .join("../../docs/schemas")
5444            .join(name);
5445        if std::env::var_os("UPDATE_SCHEMAS").is_some() {
5446            std::fs::create_dir_all(path.parent().unwrap()).expect("mkdir docs/schemas");
5447            std::fs::write(&path, &generated).unwrap_or_else(|e| panic!("write {path:?}: {e}"));
5448            return;
5449        }
5450        // Normalize CRLF→LF before comparing: `.gitattributes` already
5451        // pins these files to `eol=lf`, but a stray CRLF working-tree
5452        // copy (autocrlf, a tool rewrite) shouldn't turn a *content*-
5453        // freshness check into a confusing line-ending failure — that's
5454        // .gitattributes' job, not this test's (gemini #588).
5455        let on_disk = std::fs::read_to_string(&path)
5456            .unwrap_or_else(|e| {
5457                panic!(
5458                    "read {path:?}: {e}\n\
5459                     generate it with: UPDATE_SCHEMAS=1 cargo test -p kanade-shared schema_files_are_current"
5460                )
5461            })
5462            .replace("\r\n", "\n");
5463        assert_eq!(
5464            on_disk, generated,
5465            "{name} is stale — a Schedule/Manifest schema change isn't reflected in docs/schemas/. \
5466             Refresh with: UPDATE_SCHEMAS=1 cargo test -p kanade-shared schema_files_are_current"
5467        );
5468    }
5469}
5470
5471/// Periodic schedule (spec §2.4.3). v0.18.0 carries the fanout plan
5472/// (target + optional rollout + optional jitter) inline; the
5473/// referenced job (`job_id` → [`BUCKET_JOBS`]) supplies only the
5474/// script body. Two schedules of the same job can target different
5475/// groups on different cadences without copying the manifest.
5476///
5477/// #418 Phase 1: the cadence is the single [`When`] field. The old
5478/// `cron` × `mode` × `cooldown` × `auto_disable_when_done` quartet
5479/// is gone (no back-compat — pre-Phase-1 KV blobs fail to parse and
5480/// are warn-skipped; re-`schedule create` to upgrade them). The
5481/// engine underneath is unchanged: [`Schedule::lowered`] maps `when`
5482/// onto the same (cron, ExecMode, cooldown) trio the scheduler and
5483/// `decide_fire` always ran on.
5484#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
5485pub struct Schedule {
5486    pub id: String,
5487    /// When to fire — a reconcile cadence (`per_pc` / `per_target`)
5488    /// or a calendar time trigger (`at` / `days`). See [`When`].
5489    ///
5490    /// `singleton_map`: serde_yaml 0.9 renders externally-tagged
5491    /// enums as `!per_pc` YAML tags by default; this keeps the
5492    /// operator-facing map shape (`when: { per_pc: once }`). JSON
5493    /// output is identical either way, and the schemars schema
5494    /// (external tagging = oneOf of single-key objects) already
5495    /// matches the singleton-map wire shape.
5496    #[serde(with = "serde_yaml::with::singleton_map")]
5497    #[schemars(with = "When")]
5498    pub when: When,
5499    /// Key into [`crate::kv::BUCKET_JOBS`]. Must equal a registered
5500    /// Manifest's `id`.
5501    pub job_id: String,
5502    /// Who + how-to-phase + when-to-stagger. The Manifest doesn't
5503    /// carry these any more — same job + different fanout = different
5504    /// schedule.
5505    #[serde(flatten)]
5506    pub plan: FanoutPlan,
5507    /// Optional validity window. Outside `[from, until)` the
5508    /// schedule is dormant — still registered, still visible, but
5509    /// every tick is skipped (deleted ≠ dormant: a campaign that
5510    /// ended stays inspectable and can be re-armed by editing the
5511    /// window). Checked at tick time on both the backend scheduler
5512    /// and the agent's local scheduler.
5513    #[serde(default, skip_serializing_if = "Active::is_empty")]
5514    pub active: Active,
5515    /// #418 operational constraints gating *when within an active
5516    /// period* a fire may happen: a maintenance `window`, a fleet
5517    /// `max_concurrent` cap, and `skip_dates` (holiday exclusion). The
5518    /// wall-clock ones are evaluated in the schedule's `tz`; future
5519    /// `require` (env gates) lands in the same namespace. Checked at
5520    /// tick time on both schedulers (and surfaced by `preview`).
5521    #[serde(default, skip_serializing_if = "Constraints::is_empty")]
5522    pub constraints: Constraints,
5523    /// #418 Phase 4: what to do after a fire's script comes back
5524    /// failed. Currently just `retry` (fixed-backoff in-process
5525    /// re-run); future `notify` / `disable` join the same namespace.
5526    /// Applied fire-side in `handle_command` (the retry policy is
5527    /// lowered onto every Command this schedule produces), so it
5528    /// covers both `runs_on` locations.
5529    #[serde(default, skip_serializing_if = "OnFailure::is_empty")]
5530    pub on_failure: OnFailure,
5531    /// #418 Phase 2: the timezone this schedule's wall-clock fields
5532    /// are evaluated in — both the calendar `at` firing time AND the
5533    /// `active.{from,until}` window bounds. `local` (default) = the
5534    /// running host's TZ (the agent's for `runs_on: agent`, the
5535    /// backend server's otherwise); `utc` for TZ-independent
5536    /// schedules. Reconcile shapes (`per_pc`/`per_target`) ignore it
5537    /// for firing (poll cron runs every minute regardless) but still
5538    /// honor it for the `active` window.
5539    #[serde(default)]
5540    pub tz: ScheduleTz,
5541    /// v0.22: optional humantime window after a cron tick during
5542    /// which the Command is still considered "live". The scheduler
5543    /// computes `tick_at + starting_deadline` and stamps it onto
5544    /// each Command as `deadline_at`; agents skip Commands they
5545    /// receive after that absolute time. `None` (default) = no
5546    /// deadline, meaning a Command queued in the broker / stream
5547    /// during agent downtime runs whenever the agent reconnects —
5548    /// good for kitting / inventory / cleanup. Set this for
5549    /// time-of-day notifications, lunch reminders, etc., where
5550    /// "fire 3 hours late" would be wrong.
5551    #[serde(default, skip_serializing_if = "Option::is_none")]
5552    pub starting_deadline: Option<String>,
5553    /// v0.23: where does the cron tick happen? `Backend` (default,
5554    /// historical) = backend's scheduler fires Commands via NATS;
5555    /// agents passively receive. `Agent` = each targeted agent runs
5556    /// its own internal cron and fires locally, so the schedule
5557    /// keeps ticking even when the broker is unreachable (laptop on
5558    /// the train, broker maintenance window, full WAN outage). The
5559    /// two locations are mutually exclusive — when `Agent`, the
5560    /// backend scheduler stays out and just keeps the definition in
5561    /// KV for agents to read.
5562    #[serde(default)]
5563    pub runs_on: RunsOn,
5564    #[serde(default = "default_true")]
5565    pub enabled: bool,
5566    /// Free-form operator taxonomy for the Schedules page — the
5567    /// schedule-side mirror of `Manifest.tags` (added in #640; a plain
5568    /// code ref rather than an intra-doc link, since that field isn't
5569    /// on this branch until #640 merges). Purely a SPA-side
5570    /// organisational aid (search / filter chips alongside the
5571    /// id-prefix grouping); the scheduler never reads it, so any
5572    /// string is allowed and it carries no firing semantics. A
5573    /// schedule's own tags are independent of its job's: the same job
5574    /// may back a `weekly` maintenance schedule and a `canary` rollout
5575    /// schedule. Empty by default and `skip_serializing_if`-elided per
5576    /// the #492 gradual-upgrade wire rule.
5577    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5578    pub tags: Vec<String>,
5579    /// GitOps provenance (#695) — see [`RepoOrigin`]. Stamped by
5580    /// `kanade schedule create` when the source YAML lives inside a Git
5581    /// work tree, so the SPA renders the schedule read-only and points
5582    /// edits back at the repo (SPEC design principle #3: 設定駆動 YAML +
5583    /// Git), parity with a job's [`Manifest::origin`]. `None` for
5584    /// SPA-born schedules and ones applied from outside any repo. Purely
5585    /// informational — the scheduler never reads it. New field ⇒ #492
5586    /// wire rule (`default` + `skip_serializing_if`).
5587    #[serde(default, skip_serializing_if = "Option::is_none")]
5588    pub origin: Option<RepoOrigin>,
5589}
5590
5591/// v0.23 — where the cron tick fires from.
5592#[derive(
5593    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
5594)]
5595#[serde(rename_all = "snake_case")]
5596pub enum RunsOn {
5597    /// Backend's central scheduler ticks and publishes Commands to
5598    /// NATS. Historical default, what every pre-v0.23 schedule
5599    /// uses. Agent offline ⇒ Command queued in STREAM_EXEC; agent
5600    /// reconnects ⇒ catch-up via [`command_replay`](crate)
5601    /// (see kanade-agent's command_replay module).
5602    #[default]
5603    Backend,
5604    /// Each targeted agent runs the cron tick locally. Survives
5605    /// broker / WAN outages. Best for laptops / mobile devices that
5606    /// roam off the corporate network. Agent must be online for the
5607    /// initial schedule + job-catalog pull, but once cached the
5608    /// agent fires the script standalone.
5609    Agent,
5610}
5611
5612/// Per-pc/per-target dedup semantics for a [`Schedule`] (v0.19).
5613#[derive(
5614    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
5615)]
5616#[serde(rename_all = "snake_case")]
5617pub enum ExecMode {
5618    /// Fire on every cron tick at the whole target. Historical
5619    /// (pre-v0.19) behavior; no dedup.
5620    #[default]
5621    EveryTick,
5622    /// Fire at each pc until that pc succeeds; then skip it until
5623    /// the optional cooldown elapses (or forever if no cooldown).
5624    /// Use for kitting / first-boot / per-pc compliance checks.
5625    OncePerPc,
5626    /// Fire at the whole target until **any** pc succeeds; then
5627    /// skip the whole target until the optional cooldown elapses
5628    /// (or forever if no cooldown). Use for "one delegate is
5629    /// enough" tasks like license check-in.
5630    OncePerTarget,
5631    /// #418 OS-native event trigger (`when: { on: [...] }`). There is
5632    /// no cron — the agent fires it from an OS event source (boot /
5633    /// session-change), not a tick — so the scheduler skips
5634    /// `tokio-cron` registration for it. Each event occurrence fires
5635    /// once, gated by the standard freeze / active / window /
5636    /// skip_dates checks.
5637    Event,
5638}
5639
5640/// #418 Phase 1 — the single "when does this fire" axis.
5641///
5642/// Replaces the old `cron` + `mode` + `cooldown` trio whose
5643/// interactions were implicit (cron doubled as both a real
5644/// time-of-day trigger and a reconcile poll period; contradictory
5645/// combinations silently no-opped). Two shapes:
5646///
5647/// * **reconcile** (`per_pc` / `per_target`) — desired-state: "each
5648///   pc (or one delegate) should have run this within `every`".
5649///   The poll period is system-generated ([`POLL_CRON`], every
5650///   minute) and no longer the operator's concern.
5651/// * **calendar** (`{ at, days }`) — a wall-clock time trigger
5652///   (#418 Phase 2, replacing the old raw-cron escape hatch). Fires
5653///   the whole target at the given time, no dedup. `at: "09:00"` +
5654///   `days` repeats; `at: "2026-06-10 09:00"` (a date+time) fires
5655///   exactly once. Evaluated in the schedule's top-level `tz`.
5656#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
5657#[serde(rename_all = "snake_case")]
5658pub enum When {
5659    /// Fire at each targeted pc: `once` (kitting — succeed once,
5660    /// skip forever, forever catching brand-new / re-imaged pcs)
5661    /// or `{ every: <humantime> }` (patrol — re-arm per pc after
5662    /// the interval).
5663    PerPc(PerPolicy),
5664    /// Fire until **any** one pc of the target succeeds, then skip
5665    /// the whole target (`once`) or re-arm after `every`. Needs
5666    /// fleet-wide completion data, so it is backend-only —
5667    /// `runs_on: agent` + `per_target` is rejected by
5668    /// [`Schedule::validate`].
5669    PerTarget(PerPolicy),
5670    /// Calendar time trigger: `{ at: "09:00", days: [mon-fri] }`
5671    /// (repeating) or `{ at: "2026-06-10 09:00" }` (one-shot). Fires
5672    /// the whole target at that wall-clock time in the schedule's
5673    /// `tz` — no dedup, no cooldown.
5674    Calendar(CalendarSpec),
5675    /// #418 OS-native event trigger: `when: { on: [startup, logon] }`.
5676    /// Fires when the agent observes the listed OS event(s) rather than
5677    /// on a clock — there is no cron. `runs_on: agent` only (the agent
5678    /// owns the event source); [`Schedule::validate`] rejects it on
5679    /// `backend` and rejects an empty list. Each event occurrence fires
5680    /// once, gated by the same freeze / active / `constraints.window` /
5681    /// `skip_dates` checks as the cron path. `startup` fires once per OS
5682    /// boot (deduped via the host boot time); a `starting_deadline`, if
5683    /// set, limits it to "agent came up within that long after boot".
5684    On(Vec<OnTrigger>),
5685}
5686
5687/// An OS event the agent can fire a schedule on (#418 `when: { on }`).
5688#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
5689#[serde(rename_all = "snake_case")]
5690pub enum OnTrigger {
5691    /// Once per OS boot (the agent's first run for that boot). Catches
5692    /// freshly-imaged / reinstalled hosts at their next startup.
5693    Startup,
5694    /// On an interactive-session user logon — console, RDP, or
5695    /// auto-logon (Windows `WTS_SESSION_LOGON`). Does not fire for
5696    /// service / network / batch logons (no interactive session).
5697    Logon,
5698    /// When the workstation is locked (Win+L / idle lock; Windows
5699    /// `WTS_SESSION_LOCK`). Use for step-away compliance / cleanup.
5700    Lock,
5701    /// When the workstation is unlocked — the user returns to a locked
5702    /// session (Windows `WTS_SESSION_UNLOCK`). Use to re-check
5703    /// compliance / refresh state when work resumes.
5704    Unlock,
5705    /// When the host's network changes — IP address table change on
5706    /// connect / disconnect / DHCP renew / VPN / Wi-Fi roam (Windows
5707    /// `NotifyAddrChange`). Debounced agent-side (a burst of changes
5708    /// from one transition fires once after the network settles), so
5709    /// use it for "re-check connectivity / re-register on network move"
5710    /// rather than expecting one fire per raw adapter event.
5711    ///
5712    /// IPv4 only: `NotifyAddrChange` watches the IPv4 address table, so a
5713    /// transition that touches only IPv6 addresses won't fire. In practice
5714    /// dual-stack networks change both tables together, but a pure-IPv6
5715    /// move (e.g. an IPv6-only Wi-Fi roam) is not detected.
5716    NetworkChange,
5717}
5718
5719/// Calendar time trigger (#418 Phase 2). `at` is either a time of
5720/// day (`"HH:MM"`, repeating — combine with `days`) or a full
5721/// date+time (`"YYYY-MM-DD HH:MM"`, a one-shot that fires once and
5722/// never again). Evaluated in the schedule's top-level `tz`.
5723#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
5724pub struct CalendarSpec {
5725    /// `"HH:MM"` (24h) for a repeating trigger, or
5726    /// `"YYYY-MM-DD HH:MM"` (hyphen / slash / `T` separators all
5727    /// accepted) for a one-shot. Parsed lazily —
5728    /// [`Schedule::validate`] rejects garbage at create time.
5729    pub at: String,
5730    /// Day-of-week filter for a time-of-day `at`: `["mon-fri"]`,
5731    /// `["mon","wed","fri"]`, … (passed verbatim to the cron DOW
5732    /// field, so ranges and names both work). An **nth-weekday**
5733    /// `["tue#2"]` fires only on the 2nd Tuesday of each month
5734    /// ("Patch Tuesday"); the ordinal is `1..5`. A **last-weekday**
5735    /// `["friL"]` fires only on the last Friday of each month (handy
5736    /// for monthly maintenance). Empty = every day. Must be empty
5737    /// when `at` carries a date (the date already pins the day).
5738    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5739    pub days: Vec<String>,
5740}
5741
5742/// Parsed `CalendarSpec.at`: the wall-clock minute/hour, plus the
5743/// date for a one-shot (`None` = repeating time-of-day).
5744struct ParsedAt {
5745    minute: u32,
5746    hour: u32,
5747    date: Option<chrono::NaiveDate>,
5748}
5749
5750impl CalendarSpec {
5751    /// Parse `at`: a date+time (`YYYY-MM-DD HH:MM`, hyphen / slash /
5752    /// `T` separators) is a one-shot; a bare `HH:MM` is repeating.
5753    fn parse_at(&self) -> Result<ParsedAt, String> {
5754        use chrono::Timelike;
5755        let s = self.at.trim();
5756        for fmt in ["%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y/%m/%d %H:%M"] {
5757            if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
5758                return Ok(ParsedAt {
5759                    minute: dt.minute(),
5760                    hour: dt.hour(),
5761                    date: Some(dt.date()),
5762                });
5763            }
5764        }
5765        if let Ok(t) = chrono::NaiveTime::parse_from_str(s, "%H:%M") {
5766            return Ok(ParsedAt {
5767                minute: t.minute(),
5768                hour: t.hour(),
5769                date: None,
5770            });
5771        }
5772        Err(format!(
5773            "when.at: unparseable '{}' (want HH:MM or YYYY-MM-DD HH:MM)",
5774            self.at
5775        ))
5776    }
5777
5778    /// Pre-flight check on the `days` tokens so a bad day name gives
5779    /// a `when.days:`-scoped error instead of croner's confusing
5780    /// "when.at lowered to invalid cron" (claude #432 review). Each
5781    /// token is a day name (`mon`..`sun`), a numeric DOW (`0`..`7`),
5782    /// `*`, a `-` range of those, an **nth-weekday** like `tue#2`
5783    /// (2nd Tuesday of the month — "Patch Tuesday"), or a
5784    /// **last-weekday** like `friL` (last Friday of the month).
5785    fn validate_days(&self) -> Result<(), String> {
5786        const NAMES: [&str; 7] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
5787        let is_day = |p: &str| NAMES.contains(&p) || p.parse::<u8>().is_ok_and(|n| n <= 7);
5788        for tok in &self.days {
5789            // Report the whole token on a malformed range like `mon-`
5790            // (which would otherwise split to a cryptic empty part —
5791            // claude #432 follow-up).
5792            let invalid = |reason: &str| {
5793                Err(format!(
5794                    "when.days: invalid day token '{tok}' ({reason}; \
5795                     want mon..sun, 0-7, a range like mon-fri, an nth-weekday \
5796                     like tue#2, a last-weekday like friL, or *)"
5797                ))
5798            };
5799            // #418: nth-weekday suffix (`tue#2` = 2nd Tuesday). Croner
5800            // accepts `<dow>#<n>` (n = 1..5) in the DOW field, and
5801            // `to_cron` passes the token through verbatim, so the
5802            // engine fires only on that occurrence. It's a single
5803            // weekday + ordinal — not combinable with a range.
5804            if let Some((day_part, nth_part)) = tok.split_once('#') {
5805                // Normalize once and use `d` consistently (gemini #547);
5806                // the outer `invalid` already echoes the raw `tok`.
5807                let d = day_part.trim().to_ascii_lowercase();
5808                if d.contains('-') || !is_day(&d) {
5809                    return invalid("the part before # must be a single weekday");
5810                }
5811                match nth_part.trim().parse::<u8>() {
5812                    Ok(n) if (1..=5).contains(&n) => {}
5813                    _ => return invalid("the # ordinal must be 1..5 (e.g. tue#2 = 2nd Tuesday)"),
5814                }
5815                continue;
5816            }
5817            // #418: last-weekday suffix (`friL` = last Friday of the
5818            // month — the monthly-maintenance sibling of Patch Tuesday).
5819            // Croner accepts `<dow>L` in the DOW field with verified
5820            // last-<dow>-of-month semantics, and `to_cron` passes it
5821            // through verbatim. A single weekday + `L` — bare `L` and
5822            // ranges are rejected (croner would read bare `L` as
5823            // Saturday, which is a confusing footgun).
5824            if let Some(day_part) = tok.strip_suffix(['L', 'l']) {
5825                // No `.trim()`: a cron DOW token can't carry internal
5826                // whitespace, so `"fri L"` must be *rejected* here (its
5827                // strip leaves `"fri "`, and `is_day` catches the space)
5828                // rather than trimmed into a clean `"fri"` that then
5829                // produces a malformed `fri L` cron downstream and a
5830                // confusing croner error (gemini #560).
5831                let d = day_part.to_ascii_lowercase();
5832                if d.is_empty() {
5833                    return invalid("`L` (last-weekday) needs a weekday before it, e.g. friL");
5834                }
5835                if d.contains('-') || !is_day(&d) {
5836                    return invalid(
5837                        "the part before L must be a single weekday (e.g. friL = last Friday)",
5838                    );
5839                }
5840                continue;
5841            }
5842            for part in tok.split('-') {
5843                let p = part.trim().to_ascii_lowercase();
5844                if p.is_empty() {
5845                    return invalid("empty range bound");
5846                }
5847                if p != "*" && !is_day(&p) {
5848                    return invalid(&format!("'{part}' is not a day"));
5849                }
5850            }
5851        }
5852        Ok(())
5853    }
5854
5855    /// For a one-shot (`at` carries a date), the absolute instant it
5856    /// fires in `tz`. `None` for a repeating calendar. Used to warn
5857    /// about a one-shot whose date is already in the past (it would
5858    /// never fire).
5859    pub fn oneshot_instant(&self, tz: ScheduleTz) -> Option<chrono::DateTime<chrono::Utc>> {
5860        let p = self.parse_at().ok()?;
5861        let date = p.date?;
5862        let naive = date.and_hms_opt(p.hour, p.minute, 0)?;
5863        tz.naive_to_utc(naive)
5864    }
5865
5866    /// The wall-clock time-of-day this calendar fires at (`None` if
5867    /// `at` is unparseable — validate() guards that). Used to detect
5868    /// a calendar whose fire time can never fall inside its
5869    /// `constraints.window` (claude #452 review).
5870    pub fn fire_time(&self) -> Option<chrono::NaiveTime> {
5871        let p = self.parse_at().ok()?;
5872        chrono::NaiveTime::from_hms_opt(p.hour, p.minute, 0)
5873    }
5874
5875    /// Lower to the cron string the scheduler engine runs. Repeating
5876    /// → 6-field `0 {min} {hour} * * {dow}`; one-shot → 7-field
5877    /// `0 {min} {hour} {day} {month} * {year}` (a past year never
5878    /// fires — that's what makes it one-shot).
5879    fn to_cron(&self) -> Result<String, String> {
5880        use chrono::Datelike;
5881        let ParsedAt { minute, hour, date } = self.parse_at()?;
5882        match date {
5883            Some(d) => {
5884                if !self.days.is_empty() {
5885                    return Err(
5886                        "when.at with a date is a one-shot and cannot be combined with days".into(),
5887                    );
5888                }
5889                Ok(format!(
5890                    "0 {minute} {hour} {} {} * {}",
5891                    d.day(),
5892                    d.month(),
5893                    d.year()
5894                ))
5895            }
5896            None => {
5897                let dow = if self.days.is_empty() {
5898                    "*".to_string()
5899                } else {
5900                    self.validate_days()?;
5901                    self.days.join(",")
5902                };
5903                Ok(format!("0 {minute} {hour} * * {dow}"))
5904            }
5905        }
5906    }
5907}
5908
5909/// The timezone a schedule's wall-clock fields (`when.at`,
5910/// `active.{from,until}`) are evaluated in (#418 Phase 2).
5911#[derive(
5912    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
5913)]
5914#[serde(rename_all = "snake_case")]
5915pub enum ScheduleTz {
5916    /// The running host's local timezone — the agent's for
5917    /// `runs_on: agent`, the backend server's otherwise. Default.
5918    #[default]
5919    Local,
5920    /// UTC — for timezone-independent schedules.
5921    Utc,
5922}
5923
5924impl ScheduleTz {
5925    /// Interpret a naive (zoneless) datetime as being in this tz and
5926    /// convert to UTC. On a DST *fold* (the local time occurs twice
5927    /// when clocks go back) we pick `.earliest()` rather than
5928    /// rejecting it; `None` is reserved for a true DST *gap* (a local
5929    /// time that never exists). `Utc` is fixed-offset so neither ever
5930    /// happens; `Local` is whatever timezone the running host is set
5931    /// to and *can* hit a gap/fold on any DST-observing host — not
5932    /// just the JST we run today (gemini + claude #432 review).
5933    fn naive_to_utc(self, naive: chrono::NaiveDateTime) -> Option<chrono::DateTime<chrono::Utc>> {
5934        use chrono::TimeZone;
5935        match self {
5936            ScheduleTz::Utc => Some(chrono::DateTime::from_naive_utc_and_offset(
5937                naive,
5938                chrono::Utc,
5939            )),
5940            ScheduleTz::Local => chrono::Local
5941                .from_local_datetime(&naive)
5942                .earliest()
5943                .map(|dt| dt.with_timezone(&chrono::Utc)),
5944        }
5945    }
5946
5947    /// The wall-clock time-of-day `now` reads as in this tz — used by
5948    /// [`Constraints::allows`] to test a maintenance window
5949    /// (#418 Phase 3). `Utc` is the naive UTC time; `Local` is the
5950    /// running host's local time.
5951    fn wall_time(self, now: chrono::DateTime<chrono::Utc>) -> chrono::NaiveTime {
5952        match self {
5953            ScheduleTz::Utc => now.time(),
5954            ScheduleTz::Local => now.with_timezone(&chrono::Local).time(),
5955        }
5956    }
5957
5958    /// The wall-clock *date* `now` reads as in this tz — used by
5959    /// [`Constraints::allows`] to test `skip_dates` (#418 holiday
5960    /// exclusion). Same tz semantics as [`Self::wall_time`].
5961    fn wall_date(self, now: chrono::DateTime<chrono::Utc>) -> chrono::NaiveDate {
5962        match self {
5963            ScheduleTz::Utc => now.date_naive(),
5964            ScheduleTz::Local => now.with_timezone(&chrono::Local).date_naive(),
5965        }
5966    }
5967
5968    /// Stable lowercase wire/display label (`local` / `utc`) — matches
5969    /// the serde `snake_case` representation. Used for the preview
5970    /// response's `tz` field so the JSON shape isn't coupled to the
5971    /// `Debug` repr (claude #578 review).
5972    pub fn as_str(self) -> &'static str {
5973        match self {
5974            ScheduleTz::Local => "local",
5975            ScheduleTz::Utc => "utc",
5976        }
5977    }
5978}
5979
5980impl std::fmt::Display for ScheduleTz {
5981    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5982        f.write_str(self.as_str())
5983    }
5984}
5985
5986/// `once` vs `{ every: <humantime> }` — shared by `per_pc` /
5987/// `per_target`. Untagged so the YAML stays the bare keyword or a
5988/// one-key map, nothing more ceremonial.
5989#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
5990#[serde(untagged)]
5991pub enum PerPolicy {
5992    /// The bare string `once`: succeed once, then skip permanently
5993    /// (cooldown = infinity).
5994    Once(OnceLiteral),
5995    /// Re-arm after the humantime interval, e.g. `{ every: 6h }`.
5996    Every(EverySpec),
5997}
5998
5999/// Single-variant enum so serde accepts exactly the string `once`
6000/// (a free-form `String` would swallow typos like `onec`).
6001#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
6002#[serde(rename_all = "snake_case")]
6003pub enum OnceLiteral {
6004    Once,
6005}
6006
6007/// `{ every: <humantime> }`. Standalone struct (not an inline
6008/// struct variant). `{ evry: 6h }` still fails to parse (the
6009/// required `every` key is missing), and the create boundaries
6010/// reject the unknown `evry` via [`crate::strict`] with its path —
6011/// while agents reading a future writer's extra fields tolerate
6012/// them (#492).
6013#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
6014pub struct EverySpec {
6015    /// Humantime interval (`10m`, `6h`, `1d`...). Parsed lazily —
6016    /// [`Schedule::validate`] rejects garbage at create time.
6017    pub every: String,
6018}
6019
6020impl PerPolicy {
6021    /// The cooldown this policy lowers to: `once` = `None`
6022    /// (permanent skip), `every` = the interval.
6023    fn cooldown(&self) -> Option<String> {
6024        match self {
6025            PerPolicy::Once(_) => None,
6026            PerPolicy::Every(EverySpec { every }) => Some(every.clone()),
6027        }
6028    }
6029}
6030
6031impl std::fmt::Display for When {
6032    /// Operator-facing one-liner (`per_pc once` / `per_pc every 6h`
6033    /// / `at 09:00 [mon-fri]` / `at 2026-06-10 09:00`) for log
6034    /// lines, audit payloads and the API's `ScheduleSummary`.
6035    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6036        let policy = |p: &PerPolicy| match p {
6037            PerPolicy::Once(_) => "once".to_string(),
6038            PerPolicy::Every(EverySpec { every }) => format!("every {every}"),
6039        };
6040        match self {
6041            When::PerPc(p) => write!(f, "per_pc {}", policy(p)),
6042            When::PerTarget(p) => write!(f, "per_target {}", policy(p)),
6043            When::Calendar(c) if c.days.is_empty() => write!(f, "at {}", c.at),
6044            When::Calendar(c) => write!(f, "at {} [{}]", c.at, c.days.join(",")),
6045            When::On(triggers) => {
6046                let names: Vec<&str> = triggers.iter().map(|t| t.as_str()).collect();
6047                write!(f, "on [{}]", names.join(","))
6048            }
6049        }
6050    }
6051}
6052
6053impl OnTrigger {
6054    /// Lowercase wire/display label (matches the serde `snake_case`).
6055    pub fn as_str(self) -> &'static str {
6056        match self {
6057            OnTrigger::Startup => "startup",
6058            OnTrigger::Logon => "logon",
6059            OnTrigger::Lock => "lock",
6060            OnTrigger::Unlock => "unlock",
6061            OnTrigger::NetworkChange => "network_change",
6062        }
6063    }
6064}
6065
6066/// Optional validity window for a [`Schedule`] (#418 decision G).
6067/// Half-open `[from, until)`; either bound may be omitted. Bounds
6068/// are `YYYY-MM-DD` (= that day's 00:00 in the schedule's `tz`) or
6069/// full RFC3339 (offset is honored as-is, `tz` ignored). Kept as
6070/// strings so the JSON Schema the SPA editor consumes stays two
6071/// plain string fields, mirroring `jitter` / `starting_deadline`.
6072///
6073/// #418 Phase 2: bounds are evaluated in the schedule's top-level
6074/// `tz` (was UTC-only in Phase 1) so `tz: local` makes both the
6075/// calendar `at` AND the `active` window local — one consistent
6076/// timezone per schedule.
6077#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
6078pub struct Active {
6079    /// Dormant before this instant.
6080    #[serde(default, skip_serializing_if = "Option::is_none")]
6081    pub from: Option<String>,
6082    /// Dormant from this instant on (exclusive).
6083    #[serde(default, skip_serializing_if = "Option::is_none")]
6084    pub until: Option<String>,
6085}
6086
6087impl Active {
6088    /// `skip_serializing_if` helper — an empty window means "always
6089    /// active" and is omitted from the wire format entirely.
6090    pub fn is_empty(&self) -> bool {
6091        self.from.is_none() && self.until.is_none()
6092    }
6093
6094    /// Parse one bound: RFC3339 first (offset honored, `tz`
6095    /// ignored), then bare `YYYY-MM-DD` (00:00 in `tz`).
6096    pub fn parse_bound(s: &str, tz: ScheduleTz) -> Result<chrono::DateTime<chrono::Utc>, String> {
6097        if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
6098            return Ok(dt.with_timezone(&chrono::Utc));
6099        }
6100        if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
6101            let midnight = d.and_hms_opt(0, 0, 0).expect("00:00:00 is always valid");
6102            return tz.naive_to_utc(midnight).ok_or_else(|| {
6103                format!("active: bound '{s}' falls in a DST gap for the schedule's tz")
6104            });
6105        }
6106        Err(format!(
6107            "active: unparseable bound '{s}' (want YYYY-MM-DD or RFC3339)"
6108        ))
6109    }
6110
6111    /// Is `now` inside the window? Unparseable bounds are treated
6112    /// as absent here (fail-open) — [`Schedule::validate`] is the
6113    /// place that rejects them loudly; this runs on every tick and
6114    /// must never panic on a stale KV blob.
6115    pub fn contains(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
6116        let bound = |s: &Option<String>| s.as_deref().and_then(|s| Self::parse_bound(s, tz).ok());
6117        if bound(&self.from).is_some_and(|from| now < from) {
6118            return false;
6119        }
6120        if bound(&self.until).is_some_and(|until| now >= until) {
6121            return false;
6122        }
6123        true
6124    }
6125}
6126
6127/// Host-environment gate (#418 `constraints.require`). Fire only when
6128/// the target host is in the required state. Sensed **in-process by the
6129/// agent** (Win32), so it is `runs_on: agent` only — the backend cannot
6130/// read a target host's power/idle state ([`Schedule::validate`]
6131/// rejects it on `runs_on: backend`, symmetric with `when: { on }`).
6132///
6133/// Evaluated at fire time as a skip-this-tick gate (NOT in
6134/// [`Constraints::allows`], which stays pure for `preview`): a reconcile
6135/// cadence re-checks every minute (so it effectively defers until the
6136/// state is met — the intended pairing); a `calendar` fire that lands
6137/// while the state is unmet is simply missed, same as `window`. It is
6138/// therefore a *runtime* gate and does not appear in `preview`.
6139// No `Eq`: `cpu_below: Option<f64>` is only `PartialEq` (f64 is not Eq).
6140#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq)]
6141pub struct Require {
6142    /// Fire only while on **AC power** (skip on battery). Reads
6143    /// `GetSystemPowerStatus`; an unknown/unreadable status is treated
6144    /// as not-on-AC (fail-closed — a restrictive gate must not fire
6145    /// when it can't confirm the condition). `false` (default) = no
6146    /// power requirement.
6147    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
6148    pub ac_power: bool,
6149    /// Fire only when the active console session has had **no keyboard /
6150    /// mouse input for at least this long** (humantime, e.g. `"10m"`) —
6151    /// "don't run while the user is actively working". Input-based
6152    /// (simpler than Task Scheduler's CPU/disk-aware idle). A
6153    /// headless / disconnected console (no interactive user) trivially
6154    /// satisfies it. `None` (default) = no idle requirement. Parsed
6155    /// lazily; [`Schedule::validate`] rejects garbage at create time.
6156    #[serde(default, skip_serializing_if = "Option::is_none")]
6157    pub idle: Option<String>,
6158    /// Fire only when the **whole-machine CPU usage is below this
6159    /// percent** (0–100; e.g. `20.0` = "system CPU < 20%") — "don't run
6160    /// while the box is busy". Reuses the agent's `host_perf` system CPU%
6161    /// sample (`sysinfo` mean over cores), so the reading is up to one
6162    /// `host_perf` cadence old (default 60s) — fine as a "generally
6163    /// busy?" proxy, and more accurate than a fresh one-shot read (CPU%
6164    /// needs two samples). An unavailable sample (host_perf not warmed
6165    /// up yet, or stale) is treated as "not below" (fail-closed — a
6166    /// restrictive gate must not fire when it can't confirm). `None`
6167    /// (default) = no CPU requirement. [`Schedule::validate`] rejects an
6168    /// out-of-range value at create time.
6169    #[serde(default, skip_serializing_if = "Option::is_none")]
6170    pub cpu_below: Option<f64>,
6171    /// Fire only when the host has **internet connectivity** (Windows
6172    /// `GetNetworkConnectivityHint` reports InternetAccess) — "don't run
6173    /// until online" for jobs that download / phone home. A captive
6174    /// portal (ConstrainedInternetAccess), LAN-only (LocalAccess), or
6175    /// unknown/unreadable state is treated as offline (fail-closed) — a
6176    /// portal would just fail a download, so we hold the run. For VPN /
6177    /// SASE / app-specific conditions, use a custom script gate (separate
6178    /// slice). `false` (default) = no network requirement.
6179    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
6180    pub network: bool,
6181}
6182
6183impl Require {
6184    /// `skip_serializing_if` helper for an embedded empty `require`.
6185    pub fn is_empty(&self) -> bool {
6186        !self.ac_power && self.idle.is_none() && self.cpu_below.is_none() && !self.network
6187    }
6188
6189    /// Parsed minimum-idle duration (`None` = no idle requirement, or an
6190    /// unparseable value — `validate` rejects the latter at create time).
6191    pub fn min_idle(&self) -> Option<std::time::Duration> {
6192        self.idle
6193            .as_deref()
6194            .and_then(|s| humantime::parse_duration(s.trim()).ok())
6195    }
6196
6197    /// First unparseable field for create-time rejection (mirrors
6198    /// [`Constraints::bad_skip_date`]).
6199    pub fn bad_idle(&self) -> Option<String> {
6200        self.idle.as_deref().and_then(|s| {
6201            humantime::parse_duration(s.trim())
6202                .err()
6203                .map(|e| format!("constraints.require.idle: invalid duration '{s}': {e}"))
6204        })
6205    }
6206}
6207
6208/// Host-environment state sensed by the agent, fed to [`require_met`].
6209/// A named struct (not positional args) so the growing set of sensed
6210/// signals — several of them `bool` — can't be transposed at a call
6211/// site. The Win32 sensing lives in `kanade-agent::env_gate`.
6212#[derive(Debug, Clone, Copy, Default)]
6213pub struct EnvState {
6214    /// Is the host on AC power (`false` if on battery or unreadable).
6215    pub ac_online: bool,
6216    /// How long the console has been idle (`None` = couldn't determine).
6217    pub idle: Option<std::time::Duration>,
6218    /// Whole-machine CPU usage 0–100 (`None` = no sample yet).
6219    pub cpu_pct: Option<f64>,
6220    /// Does the host have internet connectivity (`false` if offline /
6221    /// LAN-only / unreadable).
6222    pub network_up: bool,
6223}
6224
6225/// Pure env-gate decision (#418 `constraints.require`). The Win32
6226/// sensing lives in the agent (`kanade-agent::env_gate`); this is the
6227/// testable core, fed the already-sensed [`EnvState`]. Deliberately a
6228/// free fn (not folded into [`Constraints::allows`]) so `allows` stays
6229/// pure and `preview` never evaluates a runtime gate. Each set
6230/// requirement is a restrictive AND: any unmet (or unknown) gate skips.
6231pub fn require_met(req: &Require, env: &EnvState) -> bool {
6232    if req.ac_power && !env.ac_online {
6233        return false;
6234    }
6235    if let Some(min) = req.min_idle() {
6236        match env.idle {
6237            Some(d) if d >= min => {}
6238            _ => return false,
6239        }
6240    }
6241    if let Some(max) = req.cpu_below {
6242        match env.cpu_pct {
6243            Some(p) if p < max => {}
6244            _ => return false,
6245        }
6246    }
6247    if req.network && !env.network_up {
6248        return false;
6249    }
6250    true
6251}
6252
6253/// [`Active`] decides *over what date range* a schedule is live,
6254/// `Constraints` decides *when, within an active period,* a fire is
6255/// allowed: `window` (a maintenance time-of-day window),
6256/// `max_concurrent` (a fleet-wide running-instance cap), `skip_dates`
6257/// (holiday exclusion) and `require` (host-environment gates, agent-only
6258/// — see [`Require`]).
6259// No `Eq`: contains `require: Option<Require>` which holds an f64.
6260#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq)]
6261pub struct Constraints {
6262    /// `"HH:MM-HH:MM"` wall-clock window (evaluated in the schedule's
6263    /// `tz`). Fires outside it are skipped — mainly for reconcile
6264    /// cadences ("patrol every 6h, but only fire overnight") and
6265    /// daytime change-freezes. `start > end` crosses midnight
6266    /// (`"22:00-05:00"` = 22:00 through 05:00 next morning). Parsed
6267    /// lazily; [`Schedule::validate`] rejects garbage at create time.
6268    #[serde(default, skip_serializing_if = "Option::is_none")]
6269    pub window: Option<String>,
6270    /// Fleet-wide cap on how many instances of this schedule's job may
6271    /// run **at the same time** (#418 "同時実行ハード上限"). The
6272    /// backend scheduler counts the job's still-in-flight runs
6273    /// (`execution_results.finished_at IS NULL`) each tick and only
6274    /// dispatches to as many remaining pcs as there are free slots —
6275    /// a rolling window that refills as runs complete. Useful for
6276    /// disk/CPU/network-heavy jobs you don't want hammering the whole
6277    /// fleet at once.
6278    ///
6279    /// **Backend-only** (it needs a central counter): combining it
6280    /// with `runs_on: agent` is rejected by [`Schedule::validate`]
6281    /// (#418 decision E — "中央上限には中央が要る"). Most meaningful
6282    /// for `per_pc` reconcile cadences, where the poll re-ticks and
6283    /// refills slots. `None` (default) = no cap.
6284    #[serde(default, skip_serializing_if = "Option::is_none")]
6285    pub max_concurrent: Option<u32>,
6286    /// Calendar dates the schedule must **not** fire on — holidays,
6287    /// blackout days, one-off freeze dates (#418 "祝日除外"). Each is
6288    /// `YYYY-MM-DD`, evaluated as a wall-clock date in the schedule's
6289    /// `tz`. Applies to every `when` shape (a reconcile cadence skips
6290    /// the whole day; a calendar fire landing on the date is
6291    /// suppressed) and is honored by both the live scheduler and
6292    /// `preview`, since both gate on [`Constraints::allows`]. Empty
6293    /// (default) = no skips. Operator-supplied: there is no built-in
6294    /// holiday calendar — list the dates you care about. Parsed lazily;
6295    /// [`Schedule::validate`] rejects a malformed date at create time.
6296    #[serde(default, skip_serializing_if = "Vec::is_empty")]
6297    pub skip_dates: Vec<String>,
6298    /// Host-environment gate (#418): fire only when the target host is
6299    /// in the required state (on AC power, idle). Agent-sensed at fire
6300    /// time, `runs_on: agent` only. See [`Require`]. `None` (default) =
6301    /// no environment requirement.
6302    #[serde(default, skip_serializing_if = "Option::is_none")]
6303    pub require: Option<Require>,
6304}
6305
6306impl Constraints {
6307    /// `skip_serializing_if` helper — empty constraints are omitted
6308    /// from the wire format entirely.
6309    pub fn is_empty(&self) -> bool {
6310        self.window.is_none()
6311            && self.max_concurrent.is_none()
6312            && self.skip_dates.is_empty()
6313            && self.require.as_ref().is_none_or(Require::is_empty)
6314    }
6315
6316    /// The first unparseable `skip_dates` entry, if any — the
6317    /// scheduler logs it at register time so a fail-closed
6318    /// (never-firing) schedule from a hand-edited KV blob is
6319    /// diagnosable, mirroring [`Schedule::bad_window`].
6320    pub fn bad_skip_date(&self) -> Option<String> {
6321        self.skip_dates.iter().find_map(|s| {
6322            chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d")
6323                .err()
6324                .map(|e| format!("constraints.skip_dates: invalid date '{s}': {e}"))
6325        })
6326    }
6327
6328    /// Parse `"HH:MM-HH:MM"` into `(start, end)`. Equal bounds are an
6329    /// error (a zero-width or all-day window is ambiguous — write no
6330    /// window for "always").
6331    pub fn parse_window(s: &str) -> Result<(chrono::NaiveTime, chrono::NaiveTime), String> {
6332        let (a, b) = s
6333            .split_once('-')
6334            .ok_or_else(|| format!("constraints.window: '{s}' must be 'HH:MM-HH:MM'"))?;
6335        let parse = |part: &str| {
6336            chrono::NaiveTime::parse_from_str(part.trim(), "%H:%M")
6337                .map_err(|e| format!("constraints.window: invalid time '{}': {e}", part.trim()))
6338        };
6339        let (start, end) = (parse(a)?, parse(b)?);
6340        if start == end {
6341            return Err(format!(
6342                "constraints.window: start and end are equal ('{s}'); omit window for 'always'"
6343            ));
6344        }
6345        Ok((start, end))
6346    }
6347
6348    /// Is a fire allowed at `now` (evaluated in `tz`)? No window =
6349    /// always allowed. Half-open `[start, end)`; `start > end`
6350    /// crosses midnight.
6351    ///
6352    /// **Fail-closed** on an unparseable window (returns `false`,
6353    /// gemini #452 review): a window is a *restrictive* constraint
6354    /// (change-freeze / overnight-only), so a corrupt one must NOT
6355    /// silently allow fires during the restricted hours. Bad windows
6356    /// are rejected at create time by [`Schedule::validate`]; this
6357    /// only bites a hand-edited KV blob, where blocking is the safe
6358    /// direction. The scheduler warns at register time
6359    /// ([`Schedule::bad_window`]) so a stuck schedule is diagnosable.
6360    /// The tick path never panics regardless.
6361    pub fn allows(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
6362        // #418 holiday / blackout dates: never fire on a listed wall
6363        // date (in `tz`). Checked before the window since a skipped day
6364        // overrides any within-window allowance. Fail-closed on a
6365        // corrupt entry (same posture as `window`): a skip date is a
6366        // *restrictive* constraint, so a garbled one must not silently
6367        // re-enable fires — it blocks until fixed (`validate` rejects it
6368        // at create time; `bad_skip_date` lets the scheduler warn).
6369        if !self.skip_dates.is_empty() {
6370            let today = tz.wall_date(now);
6371            let blocked = self.skip_dates.iter().any(|s| {
6372                match chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d") {
6373                    Ok(d) => d == today,
6374                    Err(_) => true, // corrupt entry → fail-closed (block)
6375                }
6376            });
6377            if blocked {
6378                return false;
6379            }
6380        }
6381        match self.window.as_deref() {
6382            // No window → always allowed.
6383            None => true,
6384            // Window set: membership, or fail-closed if unparseable
6385            // (`window_contains` returns None for a corrupt window).
6386            Some(_) => self.window_contains(tz.wall_time(now)).unwrap_or(false),
6387        }
6388    }
6389
6390    /// Membership of a wall-clock time-of-day in the window. `None`
6391    /// when there is no window or it's unparseable (callers decide
6392    /// the failure direction). `start > end` crosses midnight.
6393    fn window_contains(&self, t: chrono::NaiveTime) -> Option<bool> {
6394        let (start, end) = Self::parse_window(self.window.as_deref()?).ok()?;
6395        Some(if start <= end {
6396            start <= t && t < end
6397        } else {
6398            t >= start || t < end
6399        })
6400    }
6401}
6402
6403/// What to do when a fire's script fails (#418 Phase 4 — the "高"
6404/// retry/backoff gap). Where [`Constraints`] gates *whether* a fire
6405/// happens, `OnFailure` decides what happens *after* one ran and
6406/// came back bad. Only `retry` so far; future `notify` / `disable`
6407/// would join the same namespace.
6408#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
6409pub struct OnFailure {
6410    /// Re-run the script in-process when it exits non-zero (or times
6411    /// out), up to a cap, with a fixed backoff between attempts.
6412    /// `None` (default) = no retry: a failed run is published as-is
6413    /// and (for reconcile cadences) simply re-fires on the next poll
6414    /// tick. See [`Retry`].
6415    #[serde(default, skip_serializing_if = "Option::is_none")]
6416    pub retry: Option<Retry>,
6417}
6418
6419impl OnFailure {
6420    /// `skip_serializing_if` helper — an empty policy is omitted from
6421    /// the wire format entirely.
6422    pub fn is_empty(&self) -> bool {
6423        self.retry.is_none()
6424    }
6425
6426    /// Lower the operator-facing `retry` (humantime backoff) onto the
6427    /// engine vocabulary the agent's executor runs on (backoff in
6428    /// whole seconds). Single seam shared by the backend command
6429    /// builder and the agent's local scheduler so the two stamp the
6430    /// same [`crate::wire::RetrySpec`] onto every Command. Returns
6431    /// `None` when there is no retry policy or the backoff is
6432    /// unparseable (validate() rejects the latter at create time;
6433    /// this stays fail-safe = "no retry" for a hand-edited KV blob
6434    /// rather than panicking on the fire path).
6435    pub fn lowered_retry(&self) -> Option<crate::wire::RetrySpec> {
6436        let r = self.retry.as_ref()?;
6437        let backoff_secs = humantime::parse_duration(&r.backoff).ok()?.as_secs();
6438        Some(crate::wire::RetrySpec {
6439            max: r.max,
6440            backoff_secs,
6441        })
6442    }
6443}
6444
6445/// Fixed-backoff retry policy (#418 Phase 4). `max` is the number of
6446/// *additional* attempts after the first run (so `max: 3` = up to 4
6447/// total executions); `backoff` is the humantime delay slept between
6448/// attempts. The retry happens fire-side (inside `kanade fire` /
6449/// `handle_command`) on every OS for the PoC — the Windows-native
6450/// "restart on failure" Task Scheduler path is deferred to the
6451/// native-delegation phase (#418 decision H).
6452#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
6453pub struct Retry {
6454    /// Max additional attempts after the first failure. Bounded
6455    /// `1..=10` by [`Schedule::validate`] — a typo'd `max: 1000`
6456    /// with a short backoff would otherwise pin a flapping script in
6457    /// a tight loop for the whole window.
6458    pub max: u32,
6459    /// Humantime delay slept between attempts (`"10m"`, `"30s"`).
6460    pub backoff: String,
6461}
6462
6463/// Fleet-wide change-freeze (#418 Phase 5 — the "メンテナンス窓 /
6464/// 変更凍結" gap's global half). Where [`Constraints::window`] is a
6465/// *per-schedule* time-of-day gate, a `Freeze` is a *single, fleet-
6466/// global* "stop all automated change" switch the operator flips
6467/// during an incident or a year-end change-freeze. It lives in its
6468/// own KV singleton ([`crate::kv::KEY_FREEZE`]); when present and
6469/// active, both the backend scheduler and every agent's local
6470/// scheduler skip *every* fire.
6471///
6472/// Shapes:
6473/// * `{}` (no bounds) — frozen indefinitely until the operator
6474///   clears it (incident "big red button").
6475/// * `{ from, until }` — frozen only within `[from, until)`,
6476///   evaluated in `tz` (planned change-freeze; auto-thaws).
6477///
6478/// The KV key being *absent* means "not frozen" — so clearing the
6479/// freeze is a KV delete, and `is_active` only ever runs on a freeze
6480/// the operator actually set.
6481#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
6482pub struct Freeze {
6483    /// Frozen from this instant (RFC3339 or bare `YYYY-MM-DD` in
6484    /// `tz`). `None` ⇒ frozen from the beginning of time.
6485    #[serde(default, skip_serializing_if = "Option::is_none")]
6486    pub from: Option<String>,
6487    /// Thawed from this instant on, exclusive. `None` ⇒ frozen with
6488    /// no scheduled end (manual clear required).
6489    #[serde(default, skip_serializing_if = "Option::is_none")]
6490    pub until: Option<String>,
6491    /// Operator-supplied note surfaced on the freeze-skip log and the
6492    /// SPA banner ("year-end change freeze", "INC-1234"). Advisory.
6493    #[serde(default, skip_serializing_if = "Option::is_none")]
6494    pub reason: Option<String>,
6495    /// Timezone the bare-date bounds are evaluated in (RFC3339 bounds
6496    /// carry their own offset). Defaults to host-local like a
6497    /// schedule's `tz`.
6498    #[serde(default)]
6499    pub tz: ScheduleTz,
6500}
6501
6502impl Freeze {
6503    /// Is the fleet frozen at `now`? An empty window (`from`/`until`
6504    /// both absent) is frozen unconditionally; otherwise membership of
6505    /// `[from, until)` in `tz`. Half-open like [`Active::contains`],
6506    /// but **fails CLOSED** on an unparseable bound — a freeze is a
6507    /// safety switch, so a corrupt window (only reachable via a
6508    /// hand-edited KV blob; `validate` rejects it at set time) must
6509    /// mean "frozen", not "fire normally" (coderabbit #472). This is
6510    /// the one deliberate divergence from `active`'s fail-OPEN
6511    /// behaviour, where an unparseable bound dormant-skips a schedule.
6512    pub fn is_active(&self, now: chrono::DateTime<chrono::Utc>) -> bool {
6513        // Parse a bound; an unparseable one short-circuits the whole
6514        // check to `true` (frozen) via the closure's `None` sentinel
6515        // handled below.
6516        let bound = |s: &Option<String>| -> Result<Option<chrono::DateTime<chrono::Utc>>, ()> {
6517            match s.as_deref() {
6518                None => Ok(None),
6519                Some(raw) => Active::parse_bound(raw, self.tz).map(Some).map_err(|_| ()),
6520            }
6521        };
6522        let (from, until) = match (bound(&self.from), bound(&self.until)) {
6523            (Ok(f), Ok(u)) => (f, u),
6524            // Any corrupt bound → fail closed (frozen).
6525            _ => return true,
6526        };
6527        if from.is_some_and(|f| now < f) {
6528            return false;
6529        }
6530        if until.is_some_and(|u| now >= u) {
6531            return false;
6532        }
6533        true
6534    }
6535
6536    /// Reject unparseable bounds / `from >= until` at set time (the
6537    /// API + CLI counterpart to [`Schedule::validate`]).
6538    pub fn validate(&self) -> Result<(), String> {
6539        let from = self
6540            .from
6541            .as_deref()
6542            .map(|s| Active::parse_bound(s, self.tz))
6543            .transpose()
6544            .map_err(|e| e.replace("active:", "freeze:"))?;
6545        let until = self
6546            .until
6547            .as_deref()
6548            .map(|s| Active::parse_bound(s, self.tz))
6549            .transpose()
6550            .map_err(|e| e.replace("active:", "freeze:"))?;
6551        if let (Some(f), Some(u)) = (from, until) {
6552            if f >= u {
6553                return Err(format!(
6554                    "freeze.from ({}) must be strictly before freeze.until ({})",
6555                    self.from.as_deref().unwrap_or_default(),
6556                    self.until.as_deref().unwrap_or_default(),
6557                ));
6558            }
6559        }
6560        Ok(())
6561    }
6562}
6563
6564/// The system-generated poll cadence every reconcile-shaped `when`
6565/// lowers to. Operators never write this: the real inter-run
6566/// spacing is the `every` cooldown; this only bounds "how soon do
6567/// we notice somebody is due" (#418 decision B took the poll
6568/// period away from the operator).
6569pub const POLL_CRON: &str = "0 * * * * *";
6570
6571/// What a [`When`] lowers to — the exact (cron, mode, cooldown)
6572/// trio the pre-#418 engine ran on. Keeping the engine vocabulary
6573/// unchanged is what lets Phase 1 swap the operator surface without
6574/// touching the tick / dedup machinery.
6575pub struct Lowered {
6576    /// Cron handed to `tokio-cron-scheduler` — [`POLL_CRON`] for
6577    /// reconcile shapes, a 6/7-field cron for calendar shapes.
6578    pub cron: String,
6579    /// Dedup semantics for `decide_fire`.
6580    pub mode: ExecMode,
6581    /// Humantime re-arm interval (`None` = succeed once, skip
6582    /// forever).
6583    pub cooldown: Option<String>,
6584    /// Timezone to evaluate `cron` in (#418 Phase 2). The scheduler
6585    /// passes this to `Job::new_async_tz`. Reconcile shapes carry
6586    /// the schedule's tz too even though POLL_CRON is tz-agnostic,
6587    /// so the same value drives the `active`-window check.
6588    pub tz: ScheduleTz,
6589}
6590
6591impl Schedule {
6592    /// The error message if this schedule's `constraints.window` is
6593    /// set but unparseable, else `None`. The scheduler logs this at
6594    /// register time so a fail-closed (never-firing) schedule from a
6595    /// hand-edited KV blob is diagnosable (gemini #452 review).
6596    pub fn bad_window(&self) -> Option<String> {
6597        let w = self.constraints.window.as_deref()?;
6598        Constraints::parse_window(w).err()
6599    }
6600
6601    /// True when this is a `calendar` schedule whose fire time can
6602    /// never fall inside its `constraints.window` — the cron fires,
6603    /// the window check rejects it, and (firing only at that
6604    /// time-of-day) it effectively never runs. An easy misconfig to
6605    /// set up by accident; the scheduler warns at register time
6606    /// (claude #452 review). Reconcile shapes poll every minute, so
6607    /// they always catch the window opening and aren't affected.
6608    pub fn calendar_outside_window(&self) -> bool {
6609        let When::Calendar(c) = &self.when else {
6610            return false;
6611        };
6612        let Some(t) = c.fire_time() else {
6613            return false;
6614        };
6615        matches!(self.constraints.window_contains(t), Some(false))
6616    }
6617
6618    /// Up to `count` future instants this schedule will fire, as
6619    /// absolute UTC, strictly after `now` — the dry-run / preview
6620    /// surface (#418 "ドライラン / プレビュー"). Only **calendar**
6621    /// schedules have discrete fire times; reconcile shapes
6622    /// (`per_pc`/`per_target`) poll every minute gated by cooldown, so
6623    /// they return an empty vec and the caller describes the cadence
6624    /// instead. Occurrences outside the `active.{from,until}` window or
6625    /// the `constraints.window` are **skipped**, so the list reflects
6626    /// when the schedule will ACTUALLY run, not the raw cron ticks.
6627    /// Evaluated in the schedule's `tz`, exactly like the scheduler's
6628    /// `Job::new_async_tz`, and with the same croner config the
6629    /// scheduler / [`Schedule::validate`] use, so a preview can never
6630    /// disagree with a real fire. A schedule that can never fire (a
6631    /// calendar time wholly outside its window, a past one-shot,
6632    /// `enabled: false` is *not* considered here — callers gate on
6633    /// `enabled` separately) yields an empty vec.
6634    pub fn preview_fires(
6635        &self,
6636        now: chrono::DateTime<chrono::Utc>,
6637        count: usize,
6638    ) -> Vec<chrono::DateTime<chrono::Utc>> {
6639        use croner::parser::{CronParser, Seconds};
6640        if !matches!(self.when, When::Calendar(_)) {
6641            return Vec::new();
6642        }
6643        // Same lowering + croner config as `next_calendar_fire` and the
6644        // live scheduler, so a preview can never disagree with a real
6645        // fire. `preview_fires` adds the N-occurrence walk and the
6646        // active / window filtering on top of that single seam.
6647        let lowered = self.lowered();
6648        let Ok(cron) = CronParser::builder()
6649            .seconds(Seconds::Required)
6650            .dom_and_dow(true)
6651            .build()
6652            .parse(&lowered.cron)
6653        else {
6654            return Vec::new();
6655        };
6656        let accept = |utc: chrono::DateTime<chrono::Utc>| {
6657            self.active.contains(utc, self.tz) && self.constraints.allows(utc, self.tz)
6658        };
6659        match self.tz {
6660            ScheduleTz::Utc => Self::next_occurrences(&cron, now, count, accept),
6661            ScheduleTz::Local => {
6662                Self::next_occurrences(&cron, now.with_timezone(&chrono::Local), count, accept)
6663            }
6664        }
6665    }
6666
6667    /// Walk croner forward from `after` collecting up to `count`
6668    /// accepted occurrences (converted to UTC). Generic over the tz the
6669    /// cron is evaluated in so `preview_fires` can run it in either
6670    /// `Utc` or `Local` without duplicating the loop.
6671    fn next_occurrences<Tz>(
6672        cron: &croner::Cron,
6673        after: chrono::DateTime<Tz>,
6674        count: usize,
6675        accept: impl Fn(chrono::DateTime<chrono::Utc>) -> bool,
6676    ) -> Vec<chrono::DateTime<chrono::Utc>>
6677    where
6678        Tz: chrono::TimeZone,
6679    {
6680        // Bound the scan so an `active`/window dead-end (every future
6681        // tick rejected) can't spin forever: ~4096 raw ticks covers
6682        // >10y of a daily calendar while staying instant for croner.
6683        const SCAN_CAP: usize = 4096;
6684        let mut out = Vec::with_capacity(count.min(SCAN_CAP));
6685        let mut cursor = after;
6686        let mut scanned = 0usize;
6687        while out.len() < count && scanned < SCAN_CAP {
6688            scanned += 1;
6689            let Ok(next) = cron.find_next_occurrence(&cursor, false) else {
6690                break;
6691            };
6692            let utc = next.with_timezone(&chrono::Utc);
6693            if accept(utc) {
6694                out.push(utc);
6695            }
6696            // `find_next_occurrence(.., inclusive = false)` already
6697            // advances strictly past `cursor`, so handing it `next`
6698            // verbatim gets the following occurrence — no manual +1s
6699            // nudge (and `DateTime<Tz>` is `Copy`, so no clone).
6700            cursor = next;
6701        }
6702        out
6703    }
6704
6705    /// Lower the operator-facing `when` onto the engine vocabulary.
6706    /// Single seam shared by the backend scheduler and the agent's
6707    /// local scheduler so the two can never drift.
6708    pub fn lowered(&self) -> Lowered {
6709        let tz = self.tz;
6710        match &self.when {
6711            When::PerPc(p) => Lowered {
6712                cron: POLL_CRON.into(),
6713                mode: ExecMode::OncePerPc,
6714                cooldown: p.cooldown(),
6715                tz,
6716            },
6717            When::PerTarget(p) => Lowered {
6718                cron: POLL_CRON.into(),
6719                mode: ExecMode::OncePerTarget,
6720                cooldown: p.cooldown(),
6721                tz,
6722            },
6723            // `to_cron` only fails on a malformed `at` (rejected by
6724            // validate() at create time). For a hand-edited KV blob
6725            // that slipped past, emit a deliberately-invalid cron so
6726            // register()'s Job::new_async_tz fails → warn+skip,
6727            // rather than firing at the wrong time.
6728            When::Calendar(c) => Lowered {
6729                cron: c
6730                    .to_cron()
6731                    .unwrap_or_else(|_| "# invalid calendar at".into()),
6732                mode: ExecMode::EveryTick,
6733                cooldown: None,
6734                tz,
6735            },
6736            // Event triggers have no cron — the agent fires them from an
6737            // OS event source. The `# event-trigger` cron is never
6738            // registered (the scheduler branches on `is_event()` first),
6739            // but keep it deliberately-invalid as a belt-and-suspenders
6740            // so a stray registration would fail rather than misfire.
6741            When::On(_) => Lowered {
6742                cron: "# event-trigger (no cron)".into(),
6743                mode: ExecMode::Event,
6744                cooldown: None,
6745                tz,
6746            },
6747        }
6748    }
6749
6750    /// True when this schedule fires from an OS event (`when: { on }`)
6751    /// rather than a clock — the agent skips `tokio-cron` registration
6752    /// for these and drives them from boot / session-change instead.
6753    pub fn is_event(&self) -> bool {
6754        matches!(self.when, When::On(_))
6755    }
6756
6757    /// The OS event triggers this schedule listens for, or `&[]` when it
6758    /// is not an event schedule.
6759    pub fn event_triggers(&self) -> &[OnTrigger] {
6760        match &self.when {
6761            When::On(t) => t,
6762            _ => &[],
6763        }
6764    }
6765
6766    /// The next absolute (UTC) time this schedule fires, or `None` when
6767    /// it has no discrete upcoming fire to preview.
6768    ///
6769    /// Used by the KLP `maintenance.list` preview ("what's about to
6770    /// happen on my PC", SPEC §2.1). Returns `None` for:
6771    ///
6772    /// - reconcile shapes (`per_pc` / `per_target`) — they lower to the
6773    ///   every-minute [`POLL_CRON`] and re-converge state continuously,
6774    ///   so "next fire" is always ~60s away and means nothing to a user
6775    ///   previewing upcoming maintenance;
6776    /// - a calendar schedule whose lowered cron won't parse (a
6777    ///   hand-edited KV blob that slipped past [`Schedule::validate`]);
6778    /// - a cron with no future occurrence.
6779    ///
6780    /// The wall-clock fire is evaluated in the schedule's own `tz`
6781    /// (matching the live tick's `Job::new_async_tz`) then normalised
6782    /// to UTC for the wire. `inclusive = false`: strictly the *next*
6783    /// fire after `now`, never one matching the current instant.
6784    pub fn next_calendar_fire(
6785        &self,
6786        now: chrono::DateTime<chrono::Utc>,
6787    ) -> Option<chrono::DateTime<chrono::Utc>> {
6788        if !matches!(self.when, When::Calendar(_)) {
6789            return None;
6790        }
6791        let lowered = self.lowered();
6792        // Same parser configuration tokio-cron-scheduler 0.15 uses
6793        // internally, so this can never compute a fire the live
6794        // scheduler wouldn't (seconds required, DOM-and-DOW honored).
6795        let cron = croner::parser::CronParser::builder()
6796            .seconds(croner::parser::Seconds::Required)
6797            .dom_and_dow(true)
6798            .build()
6799            .parse(&lowered.cron)
6800            .ok()?;
6801        match lowered.tz {
6802            ScheduleTz::Utc => cron.find_next_occurrence(&now, false).ok(),
6803            ScheduleTz::Local => {
6804                let now_local = now.with_timezone(&chrono::Local);
6805                cron.find_next_occurrence(&now_local, false)
6806                    .ok()
6807                    .map(|t| t.with_timezone(&chrono::Utc))
6808            }
6809        }
6810    }
6811
6812    /// Cross-field semantic checks that don't fit pure serde derive
6813    /// — the [`Manifest::validate`] counterpart (#418 decision F;
6814    /// pre-Phase-1 a broken schedule was accepted at create time
6815    /// and silently warn-skipped at tick time). Run at every create
6816    /// site: `kanade schedule create` (client-side) and
6817    /// `POST /api/schedules`. The job_id-exists check lives in the
6818    /// API handler instead — it needs the JOBS KV.
6819    pub fn validate(&self) -> Result<(), String> {
6820        if matches!(self.runs_on, RunsOn::Agent) && matches!(self.when, When::PerTarget(_)) {
6821            return Err(
6822                "when.per_target needs fleet-wide completion data and is backend-only; \
6823                 it cannot be combined with runs_on: agent (each agent self-schedules, \
6824                 so per-target dedup would be deduping across a target of 1)"
6825                    .into(),
6826            );
6827        }
6828        // #418 event triggers: the agent owns the OS event source
6829        // (boot / session-change), so `when: { on }` is agent-only and
6830        // needs at least one trigger.
6831        if let When::On(triggers) = &self.when {
6832            if !matches!(self.runs_on, RunsOn::Agent) {
6833                return Err(
6834                    "when.on (OS event trigger) is fired by the agent's own event \
6835                     source, so it requires runs_on: agent"
6836                        .into(),
6837                );
6838            }
6839            if triggers.is_empty() {
6840                return Err(
6841                    "when.on must list at least one trigger (e.g. [startup, logon])".into(),
6842                );
6843            }
6844        }
6845        if let Some(cd) = self.lowered().cooldown.as_deref() {
6846            humantime::parse_duration(cd)
6847                .map_err(|e| format!("when.every: invalid duration '{cd}': {e}"))?;
6848        }
6849        if let When::Calendar(c) = &self.when {
6850            // Lower the calendar form to its cron (catches a bad `at`
6851            // and the date+days conflict), then validate that cron
6852            // with the same parser configuration tokio-cron-scheduler
6853            // 0.15 uses internally (croner, seconds required,
6854            // DOM-and-DOW both honored, year optional) — create-time
6855            // validation can never accept what register() rejects.
6856            let cron = c.to_cron()?;
6857            croner::parser::CronParser::builder()
6858                .seconds(croner::parser::Seconds::Required)
6859                .dom_and_dow(true)
6860                .build()
6861                .parse(&cron)
6862                .map_err(|e| format!("when.at lowered to invalid cron '{cron}': {e}"))?;
6863        }
6864        // The other humantime strings on the schedule (claude #419
6865        // review): runtime degrades gracefully on both (bad jitter →
6866        // silent no-op, bad starting_deadline → warn + skipped tick),
6867        // but "rejected at create time" should cover every field the
6868        // operator can typo, not just `when`.
6869        if let Some(j) = &self.plan.jitter {
6870            humantime::parse_duration(j)
6871                .map_err(|e| format!("jitter: invalid duration '{j}': {e}"))?;
6872        }
6873        if let Some(sd) = &self.starting_deadline {
6874            humantime::parse_duration(sd)
6875                .map_err(|e| format!("starting_deadline: invalid duration '{sd}': {e}"))?;
6876        }
6877        let from = self
6878            .active
6879            .from
6880            .as_deref()
6881            .map(|s| Active::parse_bound(s, self.tz))
6882            .transpose()?;
6883        let until = self
6884            .active
6885            .until
6886            .as_deref()
6887            .map(|s| Active::parse_bound(s, self.tz))
6888            .transpose()?;
6889        if let (Some(f), Some(u)) = (from, until) {
6890            if f >= u {
6891                return Err(format!(
6892                    "active.from ({}) must be strictly before active.until ({})",
6893                    self.active.from.as_deref().unwrap_or_default(),
6894                    self.active.until.as_deref().unwrap_or_default(),
6895                ));
6896            }
6897        }
6898        // #418 Phase 3: a bad maintenance window is rejected at create
6899        // time (parse_window also catches equal bounds).
6900        if let Some(w) = self.constraints.window.as_deref() {
6901            Constraints::parse_window(w)?;
6902        }
6903        // #418 holiday exclusion: reject a malformed skip date at create
6904        // time so the fail-closed `allows` path only ever bites a
6905        // hand-edited KV blob, not a fresh `kanade schedule create`.
6906        if let Some(err) = self.constraints.bad_skip_date() {
6907            return Err(err);
6908        }
6909        // #418: constraints.max_concurrent is a central running-instance
6910        // cap, so it needs the backend's counter — reject it on
6911        // runs_on: agent (decision E), and reject a meaningless 0.
6912        if let Some(mc) = self.constraints.max_concurrent {
6913            // Check the structural incompatibility (agent has no central
6914            // counter) before the value range, so a `max_concurrent: 0`
6915            // + `runs_on: agent` combo reports the more fundamental
6916            // problem first (claude #542).
6917            if matches!(self.runs_on, RunsOn::Agent) {
6918                return Err(
6919                    "constraints.max_concurrent needs a central counter and is backend-only; \
6920                     it cannot be combined with runs_on: agent (each agent self-schedules, \
6921                     so there is no fleet-wide count to cap against)"
6922                        .into(),
6923                );
6924            }
6925            if mc == 0 {
6926                return Err(
6927                    "constraints.max_concurrent must be >= 1 (0 would never fire; \
6928                     omit it for no cap)"
6929                        .into(),
6930                );
6931            }
6932        }
6933        // #418: constraints.require (host-state env gates: ac_power /
6934        // idle / cpu_below / network) is sensed in-process by the agent,
6935        // so it needs runs_on: agent — the backend can't read a target
6936        // host's power / idle / cpu / connectivity state. Symmetric with
6937        // `when: { on }` (also agent-only); inverse of max_concurrent
6938        // (backend-only).
6939        if let Some(req) = &self.constraints.require {
6940            if !req.is_empty() && matches!(self.runs_on, RunsOn::Backend) {
6941                return Err(
6942                    "constraints.require (host-state env gates: ac_power / idle / cpu_below / \
6943                     network) is sensed in-process by the agent and needs runs_on: agent; the \
6944                     backend cannot read a target host's power / idle / cpu / connectivity state"
6945                        .into(),
6946                );
6947            }
6948            // Reject a malformed idle duration at create time so the
6949            // fail-closed runtime path only ever bites a hand-edited
6950            // KV blob (mirror skip_dates / on_failure.retry).
6951            if let Some(err) = req.bad_idle() {
6952                return Err(err);
6953            }
6954            // cpu_below is a percent — reject out-of-range so a typo
6955            // can't make a schedule that never (>=100 is always-busy?
6956            // no — <0 never matches) or trivially fires.
6957            if let Some(c) = req.cpu_below
6958                && !(c > 0.0 && c <= 100.0)
6959            {
6960                return Err(format!(
6961                    "constraints.require.cpu_below must be in (0, 100] percent (got {c}); \
6962                     omit it for no CPU requirement"
6963                ));
6964            }
6965        }
6966        // #418 Phase 4: a bad on_failure.retry is rejected at create
6967        // time — backoff must be valid humantime, and max is bounded
6968        // so a typo can't pin a flapping script in a tight loop.
6969        if let Some(r) = &self.on_failure.retry {
6970            let backoff = humantime::parse_duration(&r.backoff).map_err(|e| {
6971                format!(
6972                    "on_failure.retry.backoff: invalid duration '{}': {e}",
6973                    r.backoff
6974                )
6975            })?;
6976            // The wire form lowers backoff to whole seconds, so a
6977            // sub-second value would silently become a 0s no-wait
6978            // (coderabbit #466). Reject it rather than honour a backoff
6979            // the operator can't actually get.
6980            if backoff.as_secs() < 1 {
6981                return Err(format!(
6982                    "on_failure.retry.backoff must be >= 1s (got '{}'); sub-second backoffs \
6983                     round to 0 on the wire",
6984                    r.backoff
6985                ));
6986            }
6987            if !(1..=10).contains(&r.max) {
6988                return Err(format!(
6989                    "on_failure.retry.max must be 1..=10 (got {}); it counts additional \
6990                     attempts after the first run",
6991                    r.max
6992                ));
6993            }
6994        }
6995        // A blank / whitespace-only tag renders an empty filter chip on
6996        // the Schedules page — reject it at create time, mirroring the
6997        // Manifest::validate tag guard.
6998        for tag in &self.tags {
6999            if tag.trim().is_empty() {
7000                return Err("tags must not contain empty entries".to_string());
7001            }
7002        }
7003        Ok(())
7004    }
7005}
7006
7007fn default_true() -> bool {
7008    true
7009}