Skip to main content

octl_core/
plan.rs

1//! `plan.json` v3 — serde types + structural validator (design.md §4, §7, §13).
2//!
3//! `plan.json` is the **interface contract** the spec-node writes and the
4//! supervisor + orchestrator read. It is immutable per revision, versioned, and
5//! provenance-bearing. This module provides:
6//!
7//! - The serde [`Plan`] type (and its parts) mirroring `plan-schema.md` v3.
8//! - A structural [`validate_plan`] / [`parse_and_validate_plan`] pass that
9//!   rejects a bad plan with a domain-typed [`PlanValidationError`] (the CLI
10//!   maps these to its `schema_violation` envelope at the boundary, exactly as
11//!   it does for [`crate::report::ReportValidationError`]).
12//! - [`PLAN_V3_JSON_SCHEMA`], the checked-in JSON Schema artifact, so external
13//!   readers/writers validate against a single source of truth. A drift-guard
14//!   test keeps the Rust types and the JSON Schema in agreement.
15//!
16//! # v3: baseline provenance is structurally required
17//!
18//! v3 promotes the three baseline provenance fields — `commit_oid`,
19//! `toolchain`, and `enumerated_targets_hash` — from additive-optional
20//! (`#[serde(default)]` in v2) to **required**, so a plan that carries no
21//! provenance can never be certified. The requirement is enforced in two
22//! layers: a document that *omits* a field fails to deserialize
23//! ([`PlanValidationError::Malformed`], because the fields carry no serde
24//! default), and one that carries a *blank* field is rejected by
25//! [`validate_plan`] ([`PROVENANCE_REQUIRED_SCHEMA`],
26//! [`PlanValidationError::EmptyString`]) — the same two-layer treatment the
27//! other required baseline strings get. This is only the *structural* half: it
28//! proves the evidence is present and non-blank, not that it is well-formed or
29//! authentic. The runtime fail-closed gate (`verify_plan_baseline` /
30//! `gate_plan_baseline` in the CLI) is the other half — it checks the values
31//! *match* the live snapshot (and validates the OID shape + toolchain there).
32//!
33//! # Compatibility semantics (design.md §13, `plan-schema.md` "Principles")
34//!
35//! `schema_version` gates the file with *real* compatibility semantics — this
36//! is not "ignore everything unknown":
37//!
38//! - Readers **reject unsupported major versions** ([`SUPPORTED_PLAN_SCHEMAS`]).
39//! - Readers **reject undeclared fields** — any key not in the v3 shape is a
40//!   rejection. On the map-like objects (plan, `feature`, `baseline`,
41//!   `chunks[]`, `chunks[].checks[]`) this is [`PlanValidationError::UnknownField`],
42//!   gated by a **per-object-shape** allowlist (`tolerated_fields`): a field
43//!   ratified as additive on one shape is tolerated there and nowhere else. On
44//!   `acceptance[]` items (a tagged enum) it is a `deny_unknown_fields`
45//!   deserialization error ([`PlanValidationError::Malformed`]) — the same
46//!   stance the JSON Schema takes, with no additive seam in v3. The allowlists
47//!   are empty in v3, so every unknown key is currently rejected; a future minor
48//!   registers an additive optional field against its shape (and in the JSON
49//!   Schema) so older readers tolerate it, and only then. Schema growth
50//!   otherwise goes gap-event → reviewed proposal → versioned schema.
51//!
52//! This module is **read-only + validation types**. It does not touch the
53//! reducer, the lock layer, or any event-append path (state-integrity
54//! invariants), and it is not yet wired into a live path — T3 (deterministic
55//! floor) and T5 (supervisor) consume it.
56
57use std::collections::{HashMap, HashSet};
58
59use serde::{Deserialize, Serialize};
60use serde_json::{Map, Value};
61
62/// The current `plan.json` schema major version this crate writes.
63pub const PLAN_SCHEMA_VERSION: u32 = 3;
64
65/// All `plan.json` schema major versions this crate can read. A file whose
66/// `schema_version` is not listed here is rejected outright
67/// ([`PlanValidationError::UnsupportedSchemaVersion`]) — tolerant reading is
68/// limited to additive optional fields *within* a supported major, never to a
69/// whole unknown major.
70///
71/// v2 is intentionally dropped: a v2 plan carries optional-by-default provenance
72/// and so cannot satisfy the v3 structural requirement. Rather than admit it and
73/// then fail it on the provenance gate, a v2 document is rejected up front as an
74/// unsupported major (the runtime already fails closed on missing provenance).
75pub const SUPPORTED_PLAN_SCHEMAS: &[u32] = &[3];
76
77/// The first schema major at which baseline provenance (`commit_oid`,
78/// `toolchain`, `enumerated_targets_hash`) is **structurally required** at
79/// [`validate_plan`]: a plan whose `schema_version` is `>=` this value must
80/// carry all three as non-empty strings.
81///
82/// Today [`SUPPORTED_PLAN_SCHEMAS`] is `[3]` and this equals `3`, so every plan
83/// that reaches the gate already satisfies the threshold — the check is
84/// effectively unconditional (a `debug_assert!` in [`validate_plan`] pins that
85/// invariant). The constant is named rather than inlined only to document *when*
86/// the requirement began and to give a future major that keeps these exact three
87/// provenance fields a single place to reason about. It is **not** a
88/// back-compat seam: the [`Baseline`] fields carry no `#[serde(default)]`, so a
89/// document missing them cannot deserialize regardless of this threshold — a
90/// future major that dropped the requirement would need its own wire type, not
91/// merely a lower `schema_version`.
92pub const PROVENANCE_REQUIRED_SCHEMA: u32 = 3;
93
94/// Field names tolerated when they appear as unknown keys in an otherwise-valid
95/// plan — the governed-evolution seam (design.md §13). Empty in v3: no additive
96/// optional field has been ratified yet, so every unknown key is currently a
97/// rejection.
98///
99/// This is the flattened union across every object shape, exposed for
100/// documentation and the `expected` hint. The *operative* allowlist is
101/// **per-object-shape** (`tolerated_fields`): a field ratified as additive on
102/// `chunks[]` is tolerated there and nowhere else — a field's optionality
103/// depends on its location, not just its name, so a global name-only allowlist
104/// would leak a `chunks[].retries` tolerance onto `feature`, `baseline`, and
105/// the top level. A future minor registers a new field against its specific
106/// `ObjectShape` (and in the JSON Schema) so older readers tolerate it there;
107/// anything not listed for that shape is a possibly-required unknown and is
108/// rejected.
109pub const TOLERATED_OPTIONAL_FIELDS: &[&str] = &[];
110
111/// The object shapes an unknown-field check runs against — each carries its own
112/// additive-optional allowlist ([`tolerated_fields`]), so the governed-evolution
113/// seam is scoped to a location rather than a bare field name. (`Acceptance`
114/// items are absent: they reject unknowns at deserialize time via
115/// `deny_unknown_fields`, matching the schema, and have no seam in v3.)
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117enum ObjectShape {
118    /// The top-level plan object.
119    Plan,
120    /// `feature`.
121    Feature,
122    /// `baseline`.
123    Baseline,
124    /// A `chunks[]` element.
125    Chunk,
126    /// A `chunks[].checks[]` element.
127    Check,
128}
129
130impl ObjectShape {
131    /// Dotted path fragment naming this shape in an error (the chunk/check
132    /// arms fill in the index at the call site).
133    fn label(self) -> &'static str {
134        match self {
135            ObjectShape::Plan => "<plan>",
136            ObjectShape::Feature => "feature",
137            ObjectShape::Baseline => "baseline",
138            ObjectShape::Chunk => "chunks[]",
139            ObjectShape::Check => "checks[]",
140        }
141    }
142}
143
144/// The additive-optional fields tolerated on a given object shape. Empty for
145/// every shape in v3 — the seam exists so a ratified field can be admitted at
146/// exactly one location without widening any other (design.md §13).
147const fn tolerated_fields(shape: ObjectShape) -> &'static [&'static str] {
148    match shape {
149        ObjectShape::Plan
150        | ObjectShape::Feature
151        | ObjectShape::Baseline
152        | ObjectShape::Chunk
153        | ObjectShape::Check => &[],
154    }
155}
156
157/// The checked-in JSON Schema (Draft 2020-12) describing `plan.json` v3.
158///
159/// This is the machine-readable artifact external readers/writers validate
160/// against. The operative source of truth for the supervisor/spec-node is the
161/// [`Plan`] type + [`validate_plan`] in this module; a drift-guard test
162/// (`json_schema_matches_rust_types`) asserts the two never diverge on the
163/// version constant, the required top-level fields, the [`Tier`] enum, and the
164/// acceptance `kind` discriminants.
165pub const PLAN_V3_JSON_SCHEMA: &str = include_str!("../schemas/plan.v3.schema.json");
166
167/// Return the checked-in JSON Schema source for `plan.json` v3.
168#[must_use]
169pub fn plan_v3_json_schema() -> &'static str {
170    PLAN_V3_JSON_SCHEMA
171}
172
173/// The checked-in canonical `plan.json` v3 example (`plan-schema.md` sample),
174/// exposed so a spec-node prompt can show the model the exact target shape.
175pub const PLAN_V3_EXAMPLE: &str = include_str!("../schemas/plan.v3.example.json");
176
177/// Return the canonical `plan.json` v3 example document.
178#[must_use]
179pub fn plan_v3_json_schema_example() -> &'static str {
180    PLAN_V3_EXAMPLE
181}
182
183/// A `plan.json` v3 document (design.md §4, §7; `plan-schema.md`).
184///
185/// Deserialization is deliberately *tolerant* of unknown keys (they are
186/// captured into `extra` rather than failing the parse) so the structural
187/// validator can decide their fate per the compatibility semantics above —
188/// rejecting undeclared fields while leaving room for an allowlisted additive
189/// optional field. Always construct through [`parse_and_validate_plan`] (or run
190/// [`validate_plan`] after deserializing) before trusting a `Plan`.
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
192pub struct Plan {
193    /// Major schema version. Readers reject unsupported majors.
194    pub schema_version: u32,
195    /// Immutable revision of this plan; chunk attempts reference it.
196    pub plan_rev: u32,
197    /// The `intent.md` revision this plan targets (intent is referenced, not
198    /// embedded).
199    pub intent_rev: u32,
200    /// Feature identity: slug + source/integration branches.
201    pub feature: Feature,
202    /// Snapshot at `feat/<slug>` fork; the floor + verify diff against it.
203    pub baseline: Baseline,
204    /// Whole-feature intent gate; each item is a `check` or an `assertion`,
205    /// and at least one must be a `check`.
206    pub acceptance: Vec<Acceptance>,
207    /// The DAG of implementation chunks (`deps` form an acyclic graph).
208    pub chunks: Vec<Chunk>,
209    /// Unrecognized top-level keys, captured for the compatibility check rather
210    /// than silently dropped. Serialized back out verbatim so a tolerated
211    /// additive field round-trips.
212    #[serde(flatten)]
213    pub extra: Map<String, Value>,
214}
215
216/// Feature identity block (owner: orchestrator/spec).
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218pub struct Feature {
219    /// Feature slug (e.g. `user-csv-export`).
220    pub slug: String,
221    /// Branch the feature forks from (e.g. `main`).
222    pub source_branch: String,
223    /// Integration branch the chunks merge into (e.g. `feat/user-csv-export`).
224    pub integration_branch: String,
225    /// Unrecognized keys, captured for the compatibility check.
226    #[serde(flatten)]
227    pub extra: Map<String, Value>,
228}
229
230/// Baseline snapshot captured at the `feat/<slug>` fork (owner: supervisor).
231///
232/// # Provenance fields (`floor-capture-hardening-round-2` item 5 / F10)
233///
234/// `r#ref` is a **mutable** ref string (`feat/<slug>@fork`) — a force-push can
235/// re-point it. The floor therefore also records the pinned `commit_oid` the ref
236/// resolved to at capture time, the `toolchain` fingerprint the snapshot was
237/// captured with, and `enumerated_targets_hash` (F7). The evaluator compares all
238/// of these — not just the two content hashes — so a spec-node cannot smuggle a
239/// baseline captured at a different commit, under a different toolchain, or over
240/// a narrowed target set than the one the supervisor gates against.
241///
242/// As of schema v3 ([`PROVENANCE_REQUIRED_SCHEMA`]) all three are **required**:
243/// they carry no `#[serde(default)]`, so a document missing one fails to
244/// deserialize ([`PlanValidationError::Malformed`]), and [`validate_plan`]
245/// additionally rejects an empty / whitespace-only value
246/// ([`PlanValidationError::EmptyString`]) — the same two-layer treatment the
247/// other required baseline strings (`ref`, the two content hashes) get. This is
248/// the structural half of the fail-closed provenance guard; the evaluator
249/// (`verify_plan_baseline`) is the runtime half that additionally checks the
250/// values *match* the live snapshot. A security oracle treats "no evidence" as
251/// a rejection, not a match — so the plan may not even omit the evidence.
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
253pub struct Baseline {
254    /// Git ref the snapshot was taken at (e.g. `feat/<slug>@fork`) — mutable,
255    /// display/audit only; `commit_oid` is the authoritative binding.
256    pub r#ref: String,
257    /// The ref resolved to an immutable commit OID at capture time (provenance).
258    /// Required as of v3.
259    pub commit_oid: String,
260    /// `rustc -V` fingerprint the snapshot was captured with (provenance).
261    /// Required as of v3.
262    pub toolchain: String,
263    /// Hash of the passing-test list at baseline (floor: no baseline pass may
264    /// regress).
265    pub test_passlist_hash: String,
266    /// Hash of the clippy-warning list at baseline (floor: no new warnings).
267    pub clippy_warnings_hash: String,
268    /// Hash of the enumerated `(package, target_kind, target)` test-target set at
269    /// baseline (floor F7: the tip's set must be a superset — a shrink fails
270    /// closed). Required as of v3.
271    pub enumerated_targets_hash: String,
272    /// Unrecognized keys, captured for the compatibility check.
273    #[serde(flatten)]
274    pub extra: Map<String, Value>,
275}
276
277/// A whole-feature acceptance criterion — an executable `check` or an
278/// LLM-judged `assertion`. Internally tagged on `kind`, so an unknown `kind`
279/// fails deserialization (surfaced as [`PlanValidationError::Malformed`]).
280///
281/// `deny_unknown_fields` makes an undeclared key inside a variant (e.g. a
282/// `run` on an `assertion`, or a stray `budget` on a `check`) a hard
283/// deserialization error, matching the JSON Schema's `additionalProperties:
284/// false` on each acceptance variant. Acceptance items therefore have **no
285/// additive-optional seam** in v3 — the same stance the schema takes; a future
286/// minor that needs one would move to a captured-`extra` shape (as the
287/// [`Chunk`]/[`Check`] structs use) under governed evolution.
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
289#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
290pub enum Acceptance {
291    /// An executable end-to-end check (`desc` + shell/test `run`, with optional
292    /// `cwd` / `expect_exit` precision — same flexible shape as [`Check`]).
293    Check {
294        /// The general goal of the check — what it verifies.
295        desc: String,
296        /// A flexible shell command the supervisor executes.
297        run: String,
298        /// Optional working directory (repo-relative) to run `run` in.
299        #[serde(default, skip_serializing_if = "Option::is_none")]
300        cwd: Option<String>,
301        /// Optional expected exit code (absent = exit 0).
302        #[serde(default, skip_serializing_if = "Option::is_none")]
303        expect_exit: Option<i32>,
304    },
305    /// An LLM-judged criterion (no executable command).
306    Assertion {
307        /// Human-readable description of the asserted property.
308        desc: String,
309    },
310}
311
312impl Acceptance {
313    /// True for the executable [`Acceptance::Check`] arm.
314    #[must_use]
315    pub fn is_check(&self) -> bool {
316        matches!(self, Acceptance::Check { .. })
317    }
318}
319
320/// One implementation chunk (owner: spec).
321#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
322pub struct Chunk {
323    /// Unique id within the plan; referenced by other chunks' `deps`.
324    pub id: String,
325    /// Short human-readable title.
326    pub title: String,
327    /// Ids of chunks this one depends on (the DAG edges).
328    #[serde(default)]
329    pub deps: Vec<String>,
330    /// Starting model-tier hint; the orchestrator owns promotion.
331    pub tier: Tier,
332    /// Turnkey, self-contained implementation brief.
333    pub brief: String,
334    /// Repo-relative files this chunk may touch — a merge-time constraint, not
335    /// just a hint.
336    pub files_touched: Vec<String>,
337    /// Executable per-chunk checks (`desc` + `run`); at least one required.
338    pub checks: Vec<Check>,
339    /// LLM-judged criteria, additive above the deterministic floor.
340    #[serde(default)]
341    pub assertions: Vec<String>,
342    /// If true, the supervisor blocks a merge that added/modified no tests.
343    #[serde(default)]
344    pub requires_tests: bool,
345    /// Unrecognized keys, captured for the compatibility check.
346    #[serde(flatten)]
347    pub extra: Map<String, Value>,
348}
349
350/// An executable check: the general goal plus a flexible runnable form that
351/// proves it. The goal (`desc`) is always communicated and the command (`run`)
352/// is a free-form shell string; precision (`cwd`, `expect_exit`) is available
353/// but not forced (owner decision 2026-07-23, `plan-check-run-contract`).
354#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
355pub struct Check {
356    /// The general goal of the check — what it verifies. Always present,
357    /// human- and LLM-readable.
358    pub desc: String,
359    /// A flexible shell command the supervisor executes (via `sh -c`).
360    pub run: String,
361    /// Optional working directory (repo-relative) to run `run` in; when absent
362    /// the check runs at the worktree root.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub cwd: Option<String>,
365    /// Optional expected exit code — the check passes iff the command exits with
366    /// this code. Absent means the default: exit 0.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub expect_exit: Option<i32>,
369    /// Unrecognized keys, captured for the compatibility check.
370    #[serde(flatten)]
371    pub extra: Map<String, Value>,
372}
373
374/// Model-tier hint for a chunk. Serialized as its lowercase wire name.
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
376#[serde(rename_all = "snake_case")]
377pub enum Tier {
378    /// Cheapest tier — turnkey briefs, no architectural reasoning.
379    Code,
380    /// Mid tier.
381    Mid,
382    /// Highest tier — reserved for the hardest chunks / promotions.
383    High,
384}
385
386impl Tier {
387    /// The lowercase wire name serde (de)serializes this tier as.
388    #[must_use]
389    pub const fn wire_name(self) -> &'static str {
390        match self {
391            Tier::Code => "code",
392            Tier::Mid => "mid",
393            Tier::High => "high",
394        }
395    }
396
397    /// Every tier's wire name, in declaration order — the single source of
398    /// truth for "the set of accepted tiers" (mirrors [`crate::schema::Kind`]).
399    pub const WIRE_NAMES: &'static [&'static str] = &[
400        Tier::Code.wire_name(),
401        Tier::Mid.wire_name(),
402        Tier::High.wire_name(),
403    ];
404}
405
406/// A `plan.json` document failed schema validation.
407///
408/// Every variant names one violation. The CLI renders these as a
409/// `schema_violation` error; [`PlanValidationError::expected`] supplies the
410/// machine-readable `expected` hint for the variants that carry one, mirroring
411/// [`crate::report::ReportValidationError`].
412#[derive(Debug, thiserror::Error, PartialEq, Eq)]
413pub enum PlanValidationError {
414    /// The document root was not a JSON object.
415    #[error("plan must be a JSON object")]
416    NotObject,
417
418    /// The required `schema_version` field was absent.
419    #[error("plan missing required field `schema_version`")]
420    SchemaVersionMissing,
421
422    /// `schema_version` was present but not a non-negative integer.
423    #[error("field `schema_version` must be a non-negative integer")]
424    SchemaVersionNotInt,
425
426    /// `schema_version` declared a major this build does not support.
427    #[error("unsupported plan schema_version {found} (supported: {supported:?})")]
428    UnsupportedSchemaVersion {
429        /// The `schema_version` value read from the document.
430        found: u64,
431        /// The majors this build accepts (see [`SUPPORTED_PLAN_SCHEMAS`]).
432        supported: Vec<u32>,
433    },
434
435    /// The document is a supported version but does not match the v3 shape
436    /// (missing required field, wrong type, unknown acceptance `kind`, unknown
437    /// `tier`, …). Carries the underlying serde message.
438    #[error("plan is malformed: {message}")]
439    Malformed {
440        /// The serde deserialization message.
441        message: String,
442    },
443
444    /// An undeclared field appeared and is not in [`TOLERATED_OPTIONAL_FIELDS`].
445    #[error("unknown field `{field}` at {path} (not a tolerated additive optional field)")]
446    UnknownField {
447        /// Dotted path to the object carrying the unknown key.
448        path: String,
449        /// The offending field name.
450        field: String,
451    },
452
453    /// A required string field was empty (or whitespace-only).
454    #[error("field `{path}` must be a non-empty string")]
455    EmptyString {
456        /// Dotted path to the offending field.
457        path: String,
458    },
459
460    /// `acceptance[]` was empty.
461    #[error("`acceptance` must contain at least one item")]
462    AcceptanceEmpty,
463
464    /// `acceptance[]` contained no executable `check` (only assertions).
465    #[error("`acceptance` must contain at least one executable check (not all assertions)")]
466    AcceptanceNoCheck,
467
468    /// `chunks[]` was empty.
469    #[error("`chunks` must contain at least one chunk")]
470    ChunksEmpty,
471
472    /// A chunk id was empty or used characters outside `[A-Za-z0-9_.-]` (with a
473    /// leading alphanumeric). Chunk ids must be safe to reference and log.
474    #[error("chunk id {id:?} is invalid: expected {expected}")]
475    InvalidChunkId {
476        /// The offending id.
477        id: String,
478        /// Accepted-shape hint.
479        expected: &'static str,
480    },
481
482    /// Two chunks shared an id.
483    #[error("duplicate chunk id {id:?}")]
484    DuplicateChunkId {
485        /// The repeated id.
486        id: String,
487    },
488
489    /// A chunk's `deps` referenced an id that no chunk defines.
490    #[error("chunk {chunk:?} depends on unknown chunk {dep:?}")]
491    UnknownDep {
492        /// The depending chunk.
493        chunk: String,
494        /// The dangling dependency id.
495        dep: String,
496    },
497
498    /// A chunk listed the same dependency more than once.
499    #[error("chunk {chunk:?} lists duplicate dependency {dep:?}")]
500    DuplicateDep {
501        /// The depending chunk.
502        chunk: String,
503        /// The repeated dependency id.
504        dep: String,
505    },
506
507    /// The dependency graph contained a cycle.
508    #[error("chunk dependency graph has a cycle: {}", cycle.join(" -> "))]
509    DependencyCycle {
510        /// The chunk ids forming the cycle, in order, with the entry id
511        /// repeated at the end (e.g. `["c1", "c2", "c1"]`).
512        cycle: Vec<String>,
513    },
514
515    /// A chunk declared no executable `check`.
516    #[error("chunk {chunk:?} must have at least one check")]
517    ChunkNoCheck {
518        /// The offending chunk id.
519        chunk: String,
520    },
521
522    /// A chunk declared no `files_touched` entries.
523    #[error("chunk {chunk:?} must declare at least one file in `files_touched`")]
524    ChunkNoFiles {
525        /// The offending chunk id.
526        chunk: String,
527    },
528
529    /// A `files_touched` entry was not a safe repo-relative path.
530    #[error("path {path:?} in chunk {chunk:?} is not a safe repo-relative path (no absolute paths, `~`, `\\`, `:`, control chars, or `.`/`..`/empty components)")]
531    UnsafePath {
532        /// The offending chunk id.
533        chunk: String,
534        /// The offending path.
535        path: String,
536    },
537
538    /// A check's optional `cwd` was not a safe repo-relative directory. Held to
539    /// the same lexical guard as `files_touched` (`is_safe_repo_relative`) —
540    /// `cwd` controls *where a shell command executes*, so an absolute path
541    /// (`/etc`) or a `..`/`~` traversal would let a check escape the worktree the
542    /// floor gates. Absence already means "the worktree root", so a bare `.` is
543    /// rejected too — there is one spelling for root, not two.
544    #[error("cwd {path:?} at {location} is not a safe repo-relative directory (no absolute paths, `~`, `\\`, `:`, control chars, or `.`/`..`/empty components; omit `cwd` for the worktree root)")]
545    UnsafeCwd {
546        /// Dotted path to the offending `cwd` (e.g. `chunks[c1].checks[0].cwd`).
547        location: String,
548        /// The offending path.
549        path: String,
550    },
551
552    /// A check's optional `expect_exit` was outside the range a `sh -c` process
553    /// can actually report. A shell exit status is `0..=255`; a value outside it
554    /// (negative, or `> 255`) could never match `code()` and would make the check
555    /// permanently un-passable, so it is rejected at validation rather than
556    /// silently failing every run.
557    #[error("expect_exit {value} at {location} is out of range (a shell exit status is 0..=255)")]
558    ExpectExitOutOfRange {
559        /// Dotted path to the offending `expect_exit`.
560        location: String,
561        /// The offending value.
562        value: i64,
563    },
564}
565
566impl PlanValidationError {
567    /// The machine-readable `expected` hint for this error, if any — mirrors
568    /// [`crate::report::ReportValidationError::expected`] so the CLI can attach
569    /// the same structured payload.
570    #[must_use]
571    pub fn expected(&self) -> Option<Value> {
572        match self {
573            Self::SchemaVersionMissing | Self::SchemaVersionNotInt => {
574                Some(serde_json::json!({"field": "schema_version", "type": "integer"}))
575            }
576            Self::UnsupportedSchemaVersion { supported, .. } => {
577                Some(serde_json::json!({"field": "schema_version", "supported": supported}))
578            }
579            Self::UnknownField { .. } => {
580                Some(serde_json::json!({"tolerated_optional": TOLERATED_OPTIONAL_FIELDS}))
581            }
582            _ => None,
583        }
584    }
585}
586
587/// Parse a raw JSON value as a `plan.json` v3 document and validate it.
588///
589/// The two-phase entry point the supervisor/spec-node use. It gates the version
590/// *before* deserializing into the typed shape, so a future/unknown major yields
591/// a clean [`PlanValidationError::UnsupportedSchemaVersion`] instead of a
592/// confusing shape mismatch. On success the returned [`Plan`] has passed every
593/// structural rule in [`validate_plan`].
594///
595/// # Errors
596///
597/// Returns the first [`PlanValidationError`] found.
598pub fn parse_and_validate_plan(raw: &Value) -> Result<Plan, PlanValidationError> {
599    let obj = raw.as_object().ok_or(PlanValidationError::NotObject)?;
600
601    // Gate the version first, from the raw value, so an unsupported major is a
602    // version error rather than a shape error.
603    let version = obj
604        .get("schema_version")
605        .ok_or(PlanValidationError::SchemaVersionMissing)?;
606    let version = version
607        .as_u64()
608        .ok_or(PlanValidationError::SchemaVersionNotInt)?;
609    check_supported_version(version)?;
610
611    // Deserialize into the typed shape. Missing required fields, wrong types,
612    // an unknown acceptance `kind`, and an unknown `tier` all fail here.
613    let plan: Plan =
614        serde_json::from_value(raw.clone()).map_err(|e| PlanValidationError::Malformed {
615            message: e.to_string(),
616        })?;
617
618    validate_plan(&plan)?;
619    Ok(plan)
620}
621
622/// Reject a `schema_version` value whose major is not in
623/// [`SUPPORTED_PLAN_SCHEMAS`]. Shared by the raw-`Value` gate in
624/// [`parse_and_validate_plan`] and the typed re-check in [`validate_plan`], so
625/// neither entry point can admit an unsupported major.
626fn check_supported_version(version: u64) -> Result<(), PlanValidationError> {
627    if u32::try_from(version).is_ok_and(|v| SUPPORTED_PLAN_SCHEMAS.contains(&v)) {
628        Ok(())
629    } else {
630        Err(PlanValidationError::UnsupportedSchemaVersion {
631            found: version,
632            supported: SUPPORTED_PLAN_SCHEMAS.to_vec(),
633        })
634    }
635}
636
637/// Structural validation of an already-deserialized [`Plan`].
638///
639/// Enforces every rule the deserializer cannot (design.md §4, §13): no
640/// undeclared fields, non-empty required strings, unique chunk ids, resolvable
641/// and acyclic `deps`, at least one executable check per chunk and in
642/// `acceptance[]`, and safe repo-relative `files_touched` paths. Split out from
643/// [`parse_and_validate_plan`] so a caller holding a typed `Plan` (e.g. one it
644/// just built) can re-check it without re-serializing.
645///
646/// # Errors
647///
648/// Returns the first [`PlanValidationError`] found.
649pub fn validate_plan(plan: &Plan) -> Result<(), PlanValidationError> {
650    // Re-gate the version: `validate_plan` is a public entry point, and a `Plan`
651    // built directly or deserialized without the raw gate could carry an
652    // unsupported major. Without this, a caller re-checking a typed plan (as the
653    // doc invites) could admit an unsupported `schema_version` (e.g. `4`).
654    check_supported_version(u64::from(plan.schema_version))?;
655
656    // --- undeclared-field rejection (compatibility semantics) ---
657    reject_unknown_fields(&plan.extra, ObjectShape::Plan)?;
658    reject_unknown_fields(&plan.feature.extra, ObjectShape::Feature)?;
659    reject_unknown_fields(&plan.baseline.extra, ObjectShape::Baseline)?;
660
661    // --- required non-empty strings ---
662    non_empty(&plan.feature.slug, "feature.slug")?;
663    non_empty(&plan.feature.source_branch, "feature.source_branch")?;
664    non_empty(
665        &plan.feature.integration_branch,
666        "feature.integration_branch",
667    )?;
668    non_empty(&plan.baseline.r#ref, "baseline.ref")?;
669    non_empty(
670        &plan.baseline.test_passlist_hash,
671        "baseline.test_passlist_hash",
672    )?;
673    non_empty(
674        &plan.baseline.clippy_warnings_hash,
675        "baseline.clippy_warnings_hash",
676    )?;
677
678    // --- baseline provenance required (v3 / PROVENANCE_REQUIRED_SCHEMA) ---
679    // At v3 and above the three provenance fields are structurally required.
680    // A missing field already failed deserialization (they carry no serde
681    // default); this rejects an empty / whitespace-only value with a per-field
682    // error, closing the "present but blank" hole a security oracle must not
683    // treat as evidence. Presence + non-blankness only — the OID/toolchain
684    // *shape* and value *match* are the runtime gate's job (`verify_plan_baseline`).
685    //
686    // The version gate below is effectively unconditional today: the
687    // `check_supported_version` guard above admits only majors in
688    // `SUPPORTED_PLAN_SCHEMAS` (`[3]`), all `>= PROVENANCE_REQUIRED_SCHEMA`. The
689    // `debug_assert` pins that so a future maintainer who widens the supported
690    // set to re-admit an older major is forced to revisit this gate (that major
691    // would also need its own wire type — these fields have no serde default).
692    debug_assert!(
693        plan.schema_version >= PROVENANCE_REQUIRED_SCHEMA,
694        "supported majors must all require provenance; \
695         a lower major needs its own wire type, not a skipped gate"
696    );
697    if plan.schema_version >= PROVENANCE_REQUIRED_SCHEMA {
698        non_empty(&plan.baseline.commit_oid, "baseline.commit_oid")?;
699        non_empty(&plan.baseline.toolchain, "baseline.toolchain")?;
700        non_empty(
701            &plan.baseline.enumerated_targets_hash,
702            "baseline.enumerated_targets_hash",
703        )?;
704    }
705
706    // --- acceptance: non-empty, ≥1 executable check ---
707    if plan.acceptance.is_empty() {
708        return Err(PlanValidationError::AcceptanceEmpty);
709    }
710    for (i, item) in plan.acceptance.iter().enumerate() {
711        match item {
712            Acceptance::Check {
713                desc,
714                run,
715                cwd,
716                expect_exit,
717            } => {
718                non_empty(desc, &format!("acceptance[{i}].desc"))?;
719                non_empty(run, &format!("acceptance[{i}].run"))?;
720                validate_check_precision(
721                    cwd.as_deref(),
722                    *expect_exit,
723                    &format!("acceptance[{i}]"),
724                )?;
725            }
726            Acceptance::Assertion { desc } => {
727                non_empty(desc, &format!("acceptance[{i}].desc"))?;
728            }
729        }
730    }
731    if !plan.acceptance.iter().any(Acceptance::is_check) {
732        return Err(PlanValidationError::AcceptanceNoCheck);
733    }
734
735    // --- chunks: non-empty, unique ids, per-chunk rules ---
736    if plan.chunks.is_empty() {
737        return Err(PlanValidationError::ChunksEmpty);
738    }
739    let mut ids: HashSet<&str> = HashSet::with_capacity(plan.chunks.len());
740    for chunk in &plan.chunks {
741        validate_chunk_id(&chunk.id)?;
742        if !ids.insert(chunk.id.as_str()) {
743            return Err(PlanValidationError::DuplicateChunkId {
744                id: chunk.id.clone(),
745            });
746        }
747    }
748    for chunk in &plan.chunks {
749        validate_chunk(chunk, &ids)?;
750    }
751
752    // --- deps resolvable + DAG acyclic (over the whole graph) ---
753    detect_cycle(&plan.chunks)?;
754
755    Ok(())
756}
757
758/// Reject any key in `extra` not on `shape`'s [`tolerated_fields`] allowlist —
759/// the per-object-shape compatibility check. `path` overrides `shape.label()`
760/// when the caller can name the concrete location (e.g. `chunks[c1]`).
761fn reject_unknown_fields_at(
762    extra: &Map<String, Value>,
763    shape: ObjectShape,
764    path: &str,
765) -> Result<(), PlanValidationError> {
766    let allow = tolerated_fields(shape);
767    if let Some((field, _)) = extra.iter().find(|(k, _)| !allow.contains(&k.as_str())) {
768        return Err(PlanValidationError::UnknownField {
769            path: path.to_string(),
770            field: field.clone(),
771        });
772    }
773    Ok(())
774}
775
776/// [`reject_unknown_fields_at`] using the shape's own label as the error path —
777/// for the fixed-location shapes (`Plan`, `feature`, `baseline`).
778fn reject_unknown_fields(
779    extra: &Map<String, Value>,
780    shape: ObjectShape,
781) -> Result<(), PlanValidationError> {
782    reject_unknown_fields_at(extra, shape, shape.label())
783}
784
785/// Reject an empty / whitespace-only required string.
786fn non_empty(s: &str, path: &str) -> Result<(), PlanValidationError> {
787    if s.trim().is_empty() {
788        return Err(PlanValidationError::EmptyString {
789            path: path.to_string(),
790        });
791    }
792    Ok(())
793}
794
795/// The highest exit status a `sh -c` process can report; a shell truncates the
796/// wait status to `0..=255` (a signalled child surfaces as `128 + signal`), so
797/// an `expect_exit` outside this range can never match and is rejected.
798const MAX_SHELL_EXIT: i32 = 255;
799
800/// Validate a check's optional precision fields (`cwd`, `expect_exit`) — shared
801/// by the per-chunk `checks[]` and `acceptance[]` check paths so the two never
802/// diverge. `location` is the dotted path to the check (e.g.
803/// `chunks[c1].checks[0]` or `acceptance[0]`); the field name is appended here.
804///
805/// - `cwd`, when present, must be a non-empty *safe repo-relative* directory —
806///   the same lexical guard `files_touched` gets ([`is_safe_repo_relative`]),
807///   because `cwd` chooses where a shell command runs and an unchecked `/etc` or
808///   `../..` would escape the worktree the floor gates. Absence already means the
809///   worktree root, so a bare `.` is rejected (one spelling for root).
810/// - `expect_exit`, when present, must be a real shell exit status (`0..=255`).
811fn validate_check_precision(
812    cwd: Option<&str>,
813    expect_exit: Option<i32>,
814    location: &str,
815) -> Result<(), PlanValidationError> {
816    if let Some(cwd) = cwd {
817        non_empty(cwd, &format!("{location}.cwd"))?;
818        if !is_safe_repo_relative(cwd) {
819            return Err(PlanValidationError::UnsafeCwd {
820                location: format!("{location}.cwd"),
821                path: cwd.to_string(),
822            });
823        }
824    }
825    if let Some(code) = expect_exit {
826        if !(0..=MAX_SHELL_EXIT).contains(&code) {
827            return Err(PlanValidationError::ExpectExitOutOfRange {
828                location: format!("{location}.expect_exit"),
829                value: i64::from(code),
830            });
831        }
832    }
833    Ok(())
834}
835
836/// Chunk-id shape hint shared by every rejection.
837const CHUNK_ID_EXPECTED: &str = "a non-empty id of `[A-Za-z0-9_.-]` starting with an alphanumeric";
838
839/// Validate a chunk id: non-empty, leading alphanumeric, body limited to
840/// `[A-Za-z0-9_.-]`. Keeps ids safe to reference in errors, logs, and any
841/// future path derived from them (no `/`, `..`, or leading dot).
842fn validate_chunk_id(id: &str) -> Result<(), PlanValidationError> {
843    let ok = {
844        let mut chars = id.chars();
845        chars.next().is_some_and(|c| c.is_ascii_alphanumeric())
846            && id
847                .chars()
848                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
849    };
850    if ok {
851        Ok(())
852    } else {
853        Err(PlanValidationError::InvalidChunkId {
854            id: id.to_string(),
855            expected: CHUNK_ID_EXPECTED,
856        })
857    }
858}
859
860/// Per-chunk structural rules (unknown fields, non-empty strings, ≥1 check,
861/// declared + safe `files_touched`, resolvable + unique `deps`, non-empty
862/// assertions).
863fn validate_chunk(chunk: &Chunk, ids: &HashSet<&str>) -> Result<(), PlanValidationError> {
864    reject_unknown_fields_at(
865        &chunk.extra,
866        ObjectShape::Chunk,
867        &format!("chunks[{}]", chunk.id),
868    )?;
869    non_empty(&chunk.title, &format!("chunks[{}].title", chunk.id))?;
870    non_empty(&chunk.brief, &format!("chunks[{}].brief", chunk.id))?;
871
872    // deps must resolve to a real chunk (cycles are caught separately) and must
873    // not repeat — a duplicate edge is malformed and skews any indegree-based
874    // scheduler (a dependent counted twice can never unblock).
875    let mut seen_deps: HashSet<&str> = HashSet::with_capacity(chunk.deps.len());
876    for dep in &chunk.deps {
877        if !ids.contains(dep.as_str()) {
878            return Err(PlanValidationError::UnknownDep {
879                chunk: chunk.id.clone(),
880                dep: dep.clone(),
881            });
882        }
883        if !seen_deps.insert(dep.as_str()) {
884            return Err(PlanValidationError::DuplicateDep {
885                chunk: chunk.id.clone(),
886                dep: dep.clone(),
887            });
888        }
889    }
890
891    // ≥1 executable check.
892    if chunk.checks.is_empty() {
893        return Err(PlanValidationError::ChunkNoCheck {
894            chunk: chunk.id.clone(),
895        });
896    }
897    for (i, check) in chunk.checks.iter().enumerate() {
898        reject_unknown_fields_at(
899            &check.extra,
900            ObjectShape::Check,
901            &format!("chunks[{}].checks[{i}]", chunk.id),
902        )?;
903        non_empty(
904            &check.desc,
905            &format!("chunks[{}].checks[{i}].desc", chunk.id),
906        )?;
907        non_empty(&check.run, &format!("chunks[{}].checks[{i}].run", chunk.id))?;
908        validate_check_precision(
909            check.cwd.as_deref(),
910            check.expect_exit,
911            &format!("chunks[{}].checks[{i}]", chunk.id),
912        )?;
913    }
914
915    // assertions are LLM-judged criteria — an empty one is nonsensical (mirrors
916    // the non-empty check applied to `acceptance[]` items).
917    for (i, assertion) in chunk.assertions.iter().enumerate() {
918        non_empty(assertion, &format!("chunks[{}].assertions[{i}]", chunk.id))?;
919    }
920
921    // files_touched: declared + safe repo-relative.
922    if chunk.files_touched.is_empty() {
923        return Err(PlanValidationError::ChunkNoFiles {
924            chunk: chunk.id.clone(),
925        });
926    }
927    for path in &chunk.files_touched {
928        if !is_safe_repo_relative(path) {
929            return Err(PlanValidationError::UnsafePath {
930                chunk: chunk.id.clone(),
931                path: path.clone(),
932            });
933        }
934    }
935
936    Ok(())
937}
938
939/// True iff `p` is a safe repo-relative path. This is a **lexical** guard
940/// (mirroring the crate's id-level path-traversal stance in `schema.rs`) applied
941/// to multi-component paths — it is deliberately platform-independent, because a
942/// plan may be written on one OS and consumed on another. It is NOT a
943/// filesystem-resolution guarantee: a lexically-safe path can still resolve
944/// outside the repo through a symlinked directory, so the supervisor's actual
945/// merge-scope enforcement must not rely on this alone.
946///
947/// Rejects: empty; absolute (`/…`); `~` home-expansion; backslash (`\`, a
948/// Windows separator — kills `\\server\share` too); a `:` anywhere (kills
949/// Windows drive/`C:foo` and drive-absolute `C:/…`); any control character
950/// (NUL, `\n`, `\r`, `\t` — legal in some filenames but log-poisoning and
951/// adversarial); and any component that is empty (`a//b`), whitespace-only,
952/// `.` (`a/./b` — a non-canonical form that would defeat file-scope matching),
953/// or `..` (traversal).
954fn is_safe_repo_relative(p: &str) -> bool {
955    if p.is_empty()
956        || p.starts_with('/')
957        || p.starts_with('~')
958        || p.contains('\\')
959        || p.contains(':')
960        || p.chars().any(char::is_control)
961    {
962        return false;
963    }
964    p.split('/')
965        .all(|comp| !comp.trim().is_empty() && comp != "." && comp != "..")
966}
967
968/// Detect a cycle (or a self-loop) in the chunk dependency graph via a
969/// three-colour DFS. On a back-edge to a node still on the DFS stack, returns
970/// [`PlanValidationError::DependencyCycle`] with the cycle path (entry id
971/// repeated at the end). Assumes every `deps` entry already resolves to a real
972/// chunk (checked by [`validate_chunk`]).
973fn detect_cycle(chunks: &[Chunk]) -> Result<(), PlanValidationError> {
974    #[derive(Clone, Copy, PartialEq)]
975    enum Colour {
976        White,
977        Grey,
978        Black,
979    }
980
981    let adj: HashMap<&str, &[String]> = chunks
982        .iter()
983        .map(|c| (c.id.as_str(), c.deps.as_slice()))
984        .collect();
985    let mut colour: HashMap<&str, Colour> = chunks
986        .iter()
987        .map(|c| (c.id.as_str(), Colour::White))
988        .collect();
989
990    // Iterative DFS with an explicit stack of (node, next-dep-index) so a deep
991    // or wide graph cannot blow the call stack. `path` mirrors the grey stack
992    // for cycle reconstruction.
993    for start in chunks.iter().map(|c| c.id.as_str()) {
994        if colour[start] != Colour::White {
995            continue;
996        }
997        let mut stack: Vec<(&str, usize)> = vec![(start, 0)];
998        let mut path: Vec<&str> = vec![start];
999        colour.insert(start, Colour::Grey);
1000
1001        while let Some(&mut (node, ref mut idx)) = stack.last_mut() {
1002            let deps = adj[node];
1003            if *idx < deps.len() {
1004                let dep = deps[*idx].as_str();
1005                *idx += 1;
1006                match colour[dep] {
1007                    Colour::White => {
1008                        colour.insert(dep, Colour::Grey);
1009                        stack.push((dep, 0));
1010                        path.push(dep);
1011                    }
1012                    Colour::Grey => {
1013                        // Back-edge: `dep` is an ancestor on the current path.
1014                        let from = path.iter().position(|&n| n == dep).unwrap_or(0);
1015                        let mut cycle: Vec<String> =
1016                            path[from..].iter().map(|s| (*s).to_string()).collect();
1017                        cycle.push(dep.to_string());
1018                        return Err(PlanValidationError::DependencyCycle { cycle });
1019                    }
1020                    Colour::Black => {}
1021                }
1022            } else {
1023                colour.insert(node, Colour::Black);
1024                stack.pop();
1025                path.pop();
1026            }
1027        }
1028    }
1029    Ok(())
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034    use super::*;
1035    use serde_json::json;
1036
1037    /// The canonical valid plan — the `plan-schema.md` example, kept in sync
1038    /// with the checked-in `schemas/plan.v3.example.json` by
1039    /// `checked_in_example_is_valid`.
1040    fn valid_plan() -> Value {
1041        serde_json::from_str(include_str!("../schemas/plan.v3.example.json")).unwrap()
1042    }
1043
1044    // --- valid ---
1045
1046    #[test]
1047    fn example_plan_validates() {
1048        let plan = parse_and_validate_plan(&valid_plan()).expect("example must validate");
1049        assert_eq!(plan.schema_version, PLAN_SCHEMA_VERSION);
1050        assert_eq!(plan.chunks.len(), 2);
1051        assert_eq!(plan.chunks[1].deps, vec!["c1".to_string()]);
1052        assert!(plan.chunks[0].requires_tests);
1053    }
1054
1055    #[test]
1056    fn checked_in_example_is_valid() {
1057        // The example artifact and the doc example are one and the same; if the
1058        // artifact drifts out of the v3 shape this fails.
1059        let raw: Value = serde_json::from_str(PLAN_V3_EXAMPLE).unwrap();
1060        assert!(parse_and_validate_plan(&raw).is_ok());
1061    }
1062
1063    /// A minimal but complete v3 baseline block — all required strings present
1064    /// and non-empty, including the three provenance fields. Shared by the
1065    /// inline-fixture tests that don't start from [`valid_plan`].
1066    fn minimal_baseline() -> Value {
1067        json!({
1068            "ref": "feat/f@fork",
1069            "commit_oid": "0123456789abcdef0123456789abcdef01234567",
1070            "toolchain": "rustc 1.97.1 (abcdef012 2026-06-01)",
1071            "test_passlist_hash": "sha256:a",
1072            "clippy_warnings_hash": "sha256:b",
1073            "enumerated_targets_hash": "sha256:c"
1074        })
1075    }
1076
1077    #[test]
1078    fn minimal_valid_plan() {
1079        let v = json!({
1080            "schema_version": 3,
1081            "plan_rev": 1,
1082            "intent_rev": 1,
1083            "feature": {"slug": "f", "source_branch": "main", "integration_branch": "feat/f"},
1084            "baseline": minimal_baseline(),
1085            "acceptance": [{"kind": "check", "desc": "e2e", "run": "cargo test"}],
1086            "chunks": [{
1087                "id": "c1", "title": "t", "tier": "code", "brief": "b",
1088                "files_touched": ["src/a.rs"],
1089                "checks": [{"desc": "d", "run": "cargo test a"}]
1090            }],
1091        });
1092        assert!(parse_and_validate_plan(&v).is_ok());
1093    }
1094
1095    #[test]
1096    fn round_trips_through_serde() {
1097        let plan = parse_and_validate_plan(&valid_plan()).unwrap();
1098        let reser = serde_json::to_value(&plan).unwrap();
1099        let again = parse_and_validate_plan(&reser).unwrap();
1100        assert_eq!(plan, again);
1101    }
1102
1103    // --- version gating ---
1104
1105    #[test]
1106    fn unsupported_major_rejected() {
1107        let mut v = valid_plan();
1108        v["schema_version"] = json!(4);
1109        let err = parse_and_validate_plan(&v).unwrap_err();
1110        assert!(matches!(
1111            err,
1112            PlanValidationError::UnsupportedSchemaVersion { found: 4, .. }
1113        ));
1114        assert_eq!(
1115            err.expected(),
1116            Some(json!({"field": "schema_version", "supported": [3]}))
1117        );
1118    }
1119
1120    #[test]
1121    fn v2_major_now_unsupported() {
1122        // v2 is deliberately dropped from SUPPORTED_PLAN_SCHEMAS: a v2 plan
1123        // carries optional-by-default provenance and cannot satisfy the v3
1124        // requirement, so it is rejected up front as an unsupported major.
1125        let mut v = valid_plan();
1126        v["schema_version"] = json!(2);
1127        assert!(matches!(
1128            parse_and_validate_plan(&v).unwrap_err(),
1129            PlanValidationError::UnsupportedSchemaVersion { found: 2, .. }
1130        ));
1131    }
1132
1133    #[test]
1134    fn genuine_v2_document_rejected_at_version_gate_not_shape() {
1135        // A *real* v2 document — v2 shape, no provenance fields at all — is
1136        // rejected at the raw version gate (UnsupportedSchemaVersion), never
1137        // reaching deserialization. This pins the design choice: v2 is refused by
1138        // major, so its missing provenance never surfaces as a shape/Malformed
1139        // error. (Regression guard for the "reject at version, not at shape"
1140        // decision — a v2 plan would otherwise be a confusing missing-field error.)
1141        let v = json!({
1142            "schema_version": 2, "plan_rev": 1, "intent_rev": 1,
1143            "feature": {"slug": "f", "source_branch": "main", "integration_branch": "feat/f"},
1144            "baseline": {"ref": "feat/f@fork", "test_passlist_hash": "h", "clippy_warnings_hash": "h"},
1145            "acceptance": [{"kind": "check", "desc": "e2e", "run": "cargo test"}],
1146            "chunks": [{
1147                "id": "c1", "title": "t", "tier": "code", "brief": "b",
1148                "files_touched": ["src/a.rs"],
1149                "checks": [{"desc": "d", "run": "cargo test a"}]
1150            }],
1151        });
1152        assert!(matches!(
1153            parse_and_validate_plan(&v).unwrap_err(),
1154            PlanValidationError::UnsupportedSchemaVersion { found: 2, .. }
1155        ));
1156    }
1157
1158    #[test]
1159    fn missing_version_rejected() {
1160        let mut v = valid_plan();
1161        v.as_object_mut().unwrap().remove("schema_version");
1162        assert_eq!(
1163            parse_and_validate_plan(&v).unwrap_err(),
1164            PlanValidationError::SchemaVersionMissing
1165        );
1166    }
1167
1168    #[test]
1169    fn non_integer_version_rejected() {
1170        let mut v = valid_plan();
1171        v["schema_version"] = json!("2");
1172        assert_eq!(
1173            parse_and_validate_plan(&v).unwrap_err(),
1174            PlanValidationError::SchemaVersionNotInt
1175        );
1176    }
1177
1178    #[test]
1179    fn validate_plan_regates_version_on_typed_plan() {
1180        // A typed `Plan` that bypassed the raw gate (built directly, or mutated
1181        // after deserialization) must still be rejected by `validate_plan` —
1182        // otherwise the "re-check a typed plan" path admits an unsupported major.
1183        let mut plan = parse_and_validate_plan(&valid_plan()).unwrap();
1184        plan.schema_version = 4;
1185        assert!(matches!(
1186            validate_plan(&plan).unwrap_err(),
1187            PlanValidationError::UnsupportedSchemaVersion { found: 4, .. }
1188        ));
1189    }
1190
1191    // --- unknown fields ---
1192
1193    #[test]
1194    fn unknown_top_level_field_rejected() {
1195        let mut v = valid_plan();
1196        v["budget"] = json!(1000);
1197        let err = parse_and_validate_plan(&v).unwrap_err();
1198        assert!(matches!(
1199            err,
1200            PlanValidationError::UnknownField { ref field, .. } if field == "budget"
1201        ));
1202    }
1203
1204    #[test]
1205    fn unknown_chunk_field_rejected() {
1206        let mut v = valid_plan();
1207        v["chunks"][0]["retries"] = json!(3);
1208        assert!(matches!(
1209            parse_and_validate_plan(&v).unwrap_err(),
1210            PlanValidationError::UnknownField { field, .. } if field == "retries"
1211        ));
1212    }
1213
1214    // --- malformed (deserialize-time) ---
1215
1216    #[test]
1217    fn unknown_acceptance_kind_rejected() {
1218        let mut v = valid_plan();
1219        v["acceptance"][0]["kind"] = json!("gut-feeling");
1220        assert!(matches!(
1221            parse_and_validate_plan(&v).unwrap_err(),
1222            PlanValidationError::Malformed { .. }
1223        ));
1224    }
1225
1226    #[test]
1227    fn unknown_tier_rejected() {
1228        let mut v = valid_plan();
1229        v["chunks"][0]["tier"] = json!("ultra");
1230        assert!(matches!(
1231            parse_and_validate_plan(&v).unwrap_err(),
1232            PlanValidationError::Malformed { .. }
1233        ));
1234    }
1235
1236    #[test]
1237    fn missing_required_chunk_field_rejected() {
1238        let mut v = valid_plan();
1239        v["chunks"][0].as_object_mut().unwrap().remove("brief");
1240        assert!(matches!(
1241            parse_and_validate_plan(&v).unwrap_err(),
1242            PlanValidationError::Malformed { .. }
1243        ));
1244    }
1245
1246    #[test]
1247    fn non_object_root_rejected() {
1248        assert_eq!(
1249            parse_and_validate_plan(&json!([1, 2, 3])).unwrap_err(),
1250            PlanValidationError::NotObject
1251        );
1252    }
1253
1254    // --- acceptance rules ---
1255
1256    #[test]
1257    fn acceptance_all_assertions_rejected() {
1258        let mut v = valid_plan();
1259        v["acceptance"] = json!([{"kind": "assertion", "desc": "vibes"}]);
1260        assert_eq!(
1261            parse_and_validate_plan(&v).unwrap_err(),
1262            PlanValidationError::AcceptanceNoCheck
1263        );
1264    }
1265
1266    #[test]
1267    fn acceptance_empty_rejected() {
1268        let mut v = valid_plan();
1269        v["acceptance"] = json!([]);
1270        assert_eq!(
1271            parse_and_validate_plan(&v).unwrap_err(),
1272            PlanValidationError::AcceptanceEmpty
1273        );
1274    }
1275
1276    #[test]
1277    fn acceptance_check_unknown_field_rejected() {
1278        // The tagged `Acceptance` enum uses `deny_unknown_fields`, so a stray
1279        // key inside a variant fails at deserialize time (Malformed), matching
1280        // the JSON Schema's `additionalProperties: false`. This is the fix for
1281        // the silent-drop divergence all reviewers flagged.
1282        let mut v = valid_plan();
1283        v["acceptance"][0]["budget"] = json!(100);
1284        assert!(matches!(
1285            parse_and_validate_plan(&v).unwrap_err(),
1286            PlanValidationError::Malformed { .. }
1287        ));
1288    }
1289
1290    #[test]
1291    fn acceptance_assertion_with_run_rejected() {
1292        // `run` is not a field of the `assertion` variant — reject it rather
1293        // than silently drop an executable command onto a non-executable item.
1294        let mut v = valid_plan();
1295        v["acceptance"] = json!([
1296            {"kind": "check", "desc": "e2e", "run": "cargo test"},
1297            {"kind": "assertion", "desc": "x", "run": "rm -rf /"},
1298        ]);
1299        assert!(matches!(
1300            parse_and_validate_plan(&v).unwrap_err(),
1301            PlanValidationError::Malformed { .. }
1302        ));
1303    }
1304
1305    // --- chunk rules ---
1306
1307    #[test]
1308    fn chunk_missing_check_rejected() {
1309        let mut v = valid_plan();
1310        v["chunks"][0]["checks"] = json!([]);
1311        assert!(matches!(
1312            parse_and_validate_plan(&v).unwrap_err(),
1313            PlanValidationError::ChunkNoCheck { chunk } if chunk == "c1"
1314        ));
1315    }
1316
1317    #[test]
1318    fn chunk_empty_files_touched_rejected() {
1319        let mut v = valid_plan();
1320        v["chunks"][0]["files_touched"] = json!([]);
1321        assert!(matches!(
1322            parse_and_validate_plan(&v).unwrap_err(),
1323            PlanValidationError::ChunkNoFiles { chunk } if chunk == "c1"
1324        ));
1325    }
1326
1327    #[test]
1328    fn duplicate_chunk_id_rejected() {
1329        let mut v = valid_plan();
1330        v["chunks"][1]["id"] = json!("c1");
1331        // dep "c1" still resolves; the duplicate id is what fails.
1332        assert!(matches!(
1333            parse_and_validate_plan(&v).unwrap_err(),
1334            PlanValidationError::DuplicateChunkId { id } if id == "c1"
1335        ));
1336    }
1337
1338    #[test]
1339    fn dangling_dep_rejected() {
1340        let mut v = valid_plan();
1341        v["chunks"][1]["deps"] = json!(["nope"]);
1342        assert!(matches!(
1343            parse_and_validate_plan(&v).unwrap_err(),
1344            PlanValidationError::UnknownDep { dep, .. } if dep == "nope"
1345        ));
1346    }
1347
1348    #[test]
1349    fn invalid_chunk_id_rejected() {
1350        let mut v = valid_plan();
1351        v["chunks"][0]["id"] = json!("../evil");
1352        // deps still point at "c1"; make c2 independent so the id check fires.
1353        v["chunks"][1]["deps"] = json!([]);
1354        assert!(matches!(
1355            parse_and_validate_plan(&v).unwrap_err(),
1356            PlanValidationError::InvalidChunkId { .. }
1357        ));
1358    }
1359
1360    #[test]
1361    fn duplicate_dep_rejected() {
1362        let mut v = valid_plan();
1363        v["chunks"][1]["deps"] = json!(["c1", "c1"]);
1364        assert!(matches!(
1365            parse_and_validate_plan(&v).unwrap_err(),
1366            PlanValidationError::DuplicateDep { dep, .. } if dep == "c1"
1367        ));
1368    }
1369
1370    #[test]
1371    fn empty_chunk_assertion_rejected() {
1372        let mut v = valid_plan();
1373        v["chunks"][0]["assertions"] = json!(["ok", "   "]);
1374        assert!(matches!(
1375            parse_and_validate_plan(&v).unwrap_err(),
1376            PlanValidationError::EmptyString { path } if path == "chunks[c1].assertions[1]"
1377        ));
1378    }
1379
1380    // --- flexible check shape (desc + run + optional cwd/expect_exit) ---
1381
1382    #[test]
1383    fn check_with_cwd_and_expect_exit_validates_and_round_trips() {
1384        let mut v = valid_plan();
1385        v["chunks"][0]["checks"] = json!([
1386            {"desc": "runs in a subdir with a non-zero expected code",
1387             "run": "make check", "cwd": "crates/x", "expect_exit": 2},
1388        ]);
1389        v["acceptance"] = json!([
1390            {"kind": "check", "desc": "e2e", "run": "cargo test", "cwd": "tests", "expect_exit": 0},
1391        ]);
1392        let plan = parse_and_validate_plan(&v).expect("optional check fields must validate");
1393
1394        // The optional fields land on the typed shape, not in `extra`.
1395        let check = &plan.chunks[0].checks[0];
1396        assert_eq!(check.cwd.as_deref(), Some("crates/x"));
1397        assert_eq!(check.expect_exit, Some(2));
1398        assert!(check.extra.is_empty());
1399        assert!(matches!(
1400            &plan.acceptance[0],
1401            Acceptance::Check { cwd, expect_exit, .. }
1402                if cwd.as_deref() == Some("tests") && *expect_exit == Some(0)
1403        ));
1404
1405        // Round-trips through serde back to an equal, still-valid plan.
1406        let reser = serde_json::to_value(&plan).unwrap();
1407        assert_eq!(parse_and_validate_plan(&reser).unwrap(), plan);
1408    }
1409
1410    #[test]
1411    fn check_without_optional_fields_defaults() {
1412        // Back-compat: a check with only desc+run parses, leaving the optional
1413        // precision absent (expect_exit defaults to 0 at execution time). The
1414        // absent fields skip serialization entirely.
1415        let plan = parse_and_validate_plan(&valid_plan()).unwrap();
1416        let check = &plan.chunks[0].checks[0];
1417        assert_eq!(check.cwd, None);
1418        assert_eq!(check.expect_exit, None);
1419
1420        let reser = serde_json::to_value(&plan.chunks[0].checks[0]).unwrap();
1421        let obj = reser.as_object().unwrap();
1422        assert!(!obj.contains_key("cwd"));
1423        assert!(!obj.contains_key("expect_exit"));
1424    }
1425
1426    #[test]
1427    fn empty_check_cwd_rejected() {
1428        let mut v = valid_plan();
1429        v["chunks"][0]["checks"][0]["cwd"] = json!("  ");
1430        assert!(matches!(
1431            parse_and_validate_plan(&v).unwrap_err(),
1432            PlanValidationError::EmptyString { path } if path == "chunks[c1].checks[0].cwd"
1433        ));
1434    }
1435
1436    #[test]
1437    fn empty_acceptance_check_cwd_rejected() {
1438        let mut v = valid_plan();
1439        v["acceptance"][0]["cwd"] = json!("");
1440        assert!(matches!(
1441            parse_and_validate_plan(&v).unwrap_err(),
1442            PlanValidationError::EmptyString { path } if path == "acceptance[0].cwd"
1443        ));
1444    }
1445
1446    #[test]
1447    fn non_integer_expect_exit_rejected() {
1448        let mut v = valid_plan();
1449        v["chunks"][0]["checks"][0]["expect_exit"] = json!("nope");
1450        assert!(matches!(
1451            parse_and_validate_plan(&v).unwrap_err(),
1452            PlanValidationError::Malformed { .. }
1453        ));
1454    }
1455
1456    #[test]
1457    fn unsafe_check_cwd_rejected() {
1458        // `cwd` controls where a shell command runs, so it gets the same
1459        // repo-relative safety guard as `files_touched` — an absolute path or a
1460        // `..`/`~` traversal would let a check escape the worktree the floor
1461        // gates. A bare `.` is rejected too: absence already means the root.
1462        for bad in [
1463            "/etc",
1464            "../../outside",
1465            "a/../../etc",
1466            "~/secret",
1467            ".",
1468            "a\\b",
1469        ] {
1470            let mut v = valid_plan();
1471            v["chunks"][0]["checks"][0]["cwd"] = json!(bad);
1472            assert!(
1473                matches!(
1474                    parse_and_validate_plan(&v).unwrap_err(),
1475                    PlanValidationError::UnsafeCwd { location, .. }
1476                        if location == "chunks[c1].checks[0].cwd"
1477                ),
1478                "expected UnsafeCwd for chunk cwd {bad:?}"
1479            );
1480        }
1481    }
1482
1483    #[test]
1484    fn unsafe_acceptance_check_cwd_rejected() {
1485        for bad in ["/etc", "../escape", "~/x", "."] {
1486            let mut v = valid_plan();
1487            v["acceptance"][0]["cwd"] = json!(bad);
1488            assert!(
1489                matches!(
1490                    parse_and_validate_plan(&v).unwrap_err(),
1491                    PlanValidationError::UnsafeCwd { location, .. }
1492                        if location == "acceptance[0].cwd"
1493                ),
1494                "expected UnsafeCwd for acceptance cwd {bad:?}"
1495            );
1496        }
1497    }
1498
1499    #[test]
1500    fn out_of_range_expect_exit_rejected() {
1501        // A shell exit status is 0..=255; anything outside can never match
1502        // `code()` and would make the check permanently un-passable.
1503        for (loc, patch) in [
1504            (
1505                "chunks[c1].checks[0].expect_exit",
1506                (&["chunks", "0", "checks", "0"][..], -1),
1507            ),
1508            (
1509                "chunks[c1].checks[0].expect_exit",
1510                (&["chunks", "0", "checks", "0"][..], 256),
1511            ),
1512            ("acceptance[0].expect_exit", (&["acceptance", "0"][..], 300)),
1513        ] {
1514            let mut v = valid_plan();
1515            let (path, code) = patch;
1516            let mut node = &mut v;
1517            for key in path {
1518                node = match key.parse::<usize>() {
1519                    Ok(idx) => &mut node[idx],
1520                    Err(_) => &mut node[key],
1521                };
1522            }
1523            node["expect_exit"] = json!(code);
1524            assert!(
1525                matches!(
1526                    parse_and_validate_plan(&v).unwrap_err(),
1527                    PlanValidationError::ExpectExitOutOfRange { location, value }
1528                        if location == loc && value == i64::from(code)
1529                ),
1530                "expected ExpectExitOutOfRange for {loc} = {code}"
1531            );
1532        }
1533    }
1534
1535    #[test]
1536    fn boundary_expect_exit_accepted() {
1537        // 0 and 255 are the inclusive bounds — both valid.
1538        for code in [0, 255] {
1539            let mut v = valid_plan();
1540            v["chunks"][0]["checks"][0]["expect_exit"] = json!(code);
1541            assert!(
1542                parse_and_validate_plan(&v).is_ok(),
1543                "expect_exit {code} should be accepted"
1544            );
1545        }
1546    }
1547
1548    // --- DAG acyclicity ---
1549
1550    #[test]
1551    fn self_loop_rejected() {
1552        let mut v = valid_plan();
1553        v["chunks"][0]["deps"] = json!(["c1"]);
1554        assert!(matches!(
1555            parse_and_validate_plan(&v).unwrap_err(),
1556            PlanValidationError::DependencyCycle { .. }
1557        ));
1558    }
1559
1560    #[test]
1561    fn two_cycle_rejected() {
1562        let mut v = valid_plan();
1563        // c1 -> c2 and c2 -> c1.
1564        v["chunks"][0]["deps"] = json!(["c2"]);
1565        v["chunks"][1]["deps"] = json!(["c1"]);
1566        let err = parse_and_validate_plan(&v).unwrap_err();
1567        match err {
1568            PlanValidationError::DependencyCycle { cycle } => {
1569                assert_eq!(cycle.first(), cycle.last());
1570                assert!(cycle.contains(&"c1".to_string()));
1571                assert!(cycle.contains(&"c2".to_string()));
1572            }
1573            other => panic!("expected cycle, got {other:?}"),
1574        }
1575    }
1576
1577    #[test]
1578    fn longer_cycle_rejected() {
1579        // Three chunks a -> b -> c -> a.
1580        let v = json!({
1581            "schema_version": 3, "plan_rev": 1, "intent_rev": 1,
1582            "feature": {"slug": "f", "source_branch": "main", "integration_branch": "feat/f"},
1583            "baseline": minimal_baseline(),
1584            "acceptance": [{"kind": "check", "desc": "e2e", "run": "t"}],
1585            "chunks": [
1586                {"id": "a", "title": "t", "tier": "code", "brief": "b", "deps": ["c"], "files_touched": ["x"], "checks": [{"desc": "d", "run": "r"}]},
1587                {"id": "b", "title": "t", "tier": "code", "brief": "b", "deps": ["a"], "files_touched": ["y"], "checks": [{"desc": "d", "run": "r"}]},
1588                {"id": "c", "title": "t", "tier": "code", "brief": "b", "deps": ["b"], "files_touched": ["z"], "checks": [{"desc": "d", "run": "r"}]},
1589            ],
1590        });
1591        assert!(matches!(
1592            parse_and_validate_plan(&v).unwrap_err(),
1593            PlanValidationError::DependencyCycle { .. }
1594        ));
1595    }
1596
1597    #[test]
1598    fn diamond_dag_is_acyclic() {
1599        // a -> {b, c} -> d is a valid DAG (a shared dep + a join).
1600        let v = json!({
1601            "schema_version": 3, "plan_rev": 1, "intent_rev": 1,
1602            "feature": {"slug": "f", "source_branch": "main", "integration_branch": "feat/f"},
1603            "baseline": minimal_baseline(),
1604            "acceptance": [{"kind": "check", "desc": "e2e", "run": "t"}],
1605            "chunks": [
1606                {"id": "a", "title": "t", "tier": "code", "brief": "b", "files_touched": ["w"], "checks": [{"desc": "d", "run": "r"}]},
1607                {"id": "b", "title": "t", "tier": "code", "brief": "b", "deps": ["a"], "files_touched": ["x"], "checks": [{"desc": "d", "run": "r"}]},
1608                {"id": "c", "title": "t", "tier": "code", "brief": "b", "deps": ["a"], "files_touched": ["y"], "checks": [{"desc": "d", "run": "r"}]},
1609                {"id": "d", "title": "t", "tier": "code", "brief": "b", "deps": ["b", "c"], "files_touched": ["z"], "checks": [{"desc": "d", "run": "r"}]},
1610            ],
1611        });
1612        assert!(parse_and_validate_plan(&v).is_ok());
1613    }
1614
1615    // --- path traversal ---
1616
1617    #[test]
1618    fn path_traversal_in_files_touched_rejected() {
1619        for bad in [
1620            "../etc/passwd",
1621            "/abs/path",
1622            "~/secret",
1623            "a/../b",
1624            "a//b",
1625            "a\\b",
1626        ] {
1627            let mut v = valid_plan();
1628            v["chunks"][0]["files_touched"] = json!([bad]);
1629            assert!(
1630                matches!(
1631                    parse_and_validate_plan(&v).unwrap_err(),
1632                    PlanValidationError::UnsafePath { .. }
1633                ),
1634                "expected UnsafePath for {bad:?}"
1635            );
1636        }
1637    }
1638
1639    #[test]
1640    fn safe_paths_accepted() {
1641        for ok in [
1642            "src/a.rs",
1643            "crates/x/src/mod.rs",
1644            "a.rs",
1645            "deep/nested/dir/file.txt",
1646            ".github/workflows/ci.yml", // leading-dot dir is fine; only `.`/`..` components are rejected
1647        ] {
1648            assert!(is_safe_repo_relative(ok), "should accept {ok:?}");
1649        }
1650        for bad in [
1651            "",             // empty
1652            "/x",           // absolute
1653            "~/x",          // home expansion
1654            "..",           // traversal
1655            "a/../b",       // traversal component
1656            "a//b",         // empty component
1657            "a\\b",         // backslash separator
1658            "a/./b",        // non-canonical `.` component
1659            ".",            // bare `.`
1660            "C:/Windows",   // windows drive-absolute (colon)
1661            "C:foo",        // windows drive-relative (colon)
1662            "src/foo\nbar", // control char (log poisoning)
1663            "src/foo\tbar", // control char
1664            "   ",          // whitespace-only
1665            "a/   /b",      // whitespace-only component
1666        ] {
1667            assert!(!is_safe_repo_relative(bad), "should reject {bad:?}");
1668        }
1669    }
1670
1671    // --- empty required strings ---
1672
1673    #[test]
1674    fn empty_feature_slug_rejected() {
1675        let mut v = valid_plan();
1676        v["feature"]["slug"] = json!("   ");
1677        assert!(matches!(
1678            parse_and_validate_plan(&v).unwrap_err(),
1679            PlanValidationError::EmptyString { path } if path == "feature.slug"
1680        ));
1681    }
1682
1683    #[test]
1684    fn empty_baseline_hash_rejected() {
1685        let mut v = valid_plan();
1686        v["baseline"]["test_passlist_hash"] = json!("");
1687        assert!(matches!(
1688            parse_and_validate_plan(&v).unwrap_err(),
1689            PlanValidationError::EmptyString { path } if path == "baseline.test_passlist_hash"
1690        ));
1691    }
1692
1693    // --- v3 baseline provenance is structurally required ---
1694
1695    #[test]
1696    fn missing_provenance_field_is_malformed() {
1697        // Each provenance field carries no serde default in v3, so a document
1698        // that OMITS one fails to deserialize (Malformed) — provenance can't be
1699        // silently defaulted to empty as it was in v2.
1700        for field in ["commit_oid", "toolchain", "enumerated_targets_hash"] {
1701            let mut v = valid_plan();
1702            v["baseline"].as_object_mut().unwrap().remove(field);
1703            let err = parse_and_validate_plan(&v).unwrap_err();
1704            assert!(
1705                matches!(err, PlanValidationError::Malformed { .. }),
1706                "expected Malformed for missing baseline.{field}, got {err:?}"
1707            );
1708        }
1709    }
1710
1711    #[test]
1712    fn all_provenance_fields_missing_is_malformed() {
1713        // Removing all three at once (serde stops at the first missing field, so
1714        // the per-field loop only proves each in isolation).
1715        let mut v = valid_plan();
1716        for field in ["commit_oid", "toolchain", "enumerated_targets_hash"] {
1717            v["baseline"].as_object_mut().unwrap().remove(field);
1718        }
1719        assert!(matches!(
1720            parse_and_validate_plan(&v).unwrap_err(),
1721            PlanValidationError::Malformed { .. }
1722        ));
1723    }
1724
1725    #[test]
1726    fn empty_provenance_field_rejected() {
1727        // A present-but-blank provenance value is rejected by validate_plan with
1728        // a per-field EmptyString error (the PROVENANCE_REQUIRED_SCHEMA gate) —
1729        // "no evidence" is never treated as evidence.
1730        for field in ["commit_oid", "toolchain", "enumerated_targets_hash"] {
1731            let mut v = valid_plan();
1732            v["baseline"][field] = json!("   ");
1733            let err = parse_and_validate_plan(&v).unwrap_err();
1734            assert!(
1735                matches!(&err, PlanValidationError::EmptyString { path } if path == &format!("baseline.{field}")),
1736                "expected EmptyString for blank baseline.{field}, got {err:?}"
1737            );
1738        }
1739    }
1740
1741    #[test]
1742    fn provenance_gate_fires_via_validate_plan_on_typed_blank() {
1743        // A typed Plan that blanks a provenance field after deserialization is
1744        // still rejected by validate_plan (the gate is in validate_plan, not
1745        // only at the serde boundary).
1746        let mut plan = parse_and_validate_plan(&valid_plan()).unwrap();
1747        plan.baseline.commit_oid = String::new();
1748        assert!(matches!(
1749            validate_plan(&plan).unwrap_err(),
1750            PlanValidationError::EmptyString { path } if path == "baseline.commit_oid"
1751        ));
1752    }
1753
1754    #[test]
1755    fn provenance_fields_always_serialize() {
1756        // Required fields carry no skip_serializing_if, so a round-trip always
1757        // re-emits them (a spec that dropped one on serialize would fail the
1758        // reader that re-validates it).
1759        let plan = parse_and_validate_plan(&valid_plan()).unwrap();
1760        let reser = serde_json::to_value(&plan).unwrap();
1761        let baseline = reser["baseline"].as_object().unwrap();
1762        for field in ["commit_oid", "toolchain", "enumerated_targets_hash"] {
1763            assert!(
1764                baseline.contains_key(field),
1765                "baseline must serialize {field}"
1766            );
1767        }
1768    }
1769
1770    // --- tier wire names ---
1771
1772    #[test]
1773    fn tier_wire_names_round_trip() {
1774        for &name in Tier::WIRE_NAMES {
1775            let tier: Tier = serde_json::from_value(json!(name)).unwrap();
1776            assert_eq!(serde_json::to_value(tier).unwrap(), json!(name));
1777        }
1778    }
1779
1780    // --- JSON Schema drift guard ---
1781
1782    #[test]
1783    fn json_schema_matches_rust_types() {
1784        let schema: Value = serde_json::from_str(PLAN_V3_JSON_SCHEMA)
1785            .expect("checked-in JSON Schema must be valid JSON");
1786
1787        // Version constant agrees.
1788        assert_eq!(
1789            schema["properties"]["schema_version"]["const"],
1790            json!(PLAN_SCHEMA_VERSION)
1791        );
1792
1793        // Required top-level fields agree with the Rust struct's fields.
1794        let required: HashSet<String> = schema["required"]
1795            .as_array()
1796            .unwrap()
1797            .iter()
1798            .map(|v| v.as_str().unwrap().to_string())
1799            .collect();
1800        let expected: HashSet<String> = [
1801            "schema_version",
1802            "plan_rev",
1803            "intent_rev",
1804            "feature",
1805            "baseline",
1806            "acceptance",
1807            "chunks",
1808        ]
1809        .iter()
1810        .map(ToString::to_string)
1811        .collect();
1812        assert_eq!(required, expected);
1813
1814        // Tier enum agrees.
1815        let tiers: Vec<String> = schema["$defs"]["chunk"]["properties"]["tier"]["enum"]
1816            .as_array()
1817            .unwrap()
1818            .iter()
1819            .map(|v| v.as_str().unwrap().to_string())
1820            .collect();
1821        assert_eq!(tiers, Tier::WIRE_NAMES);
1822
1823        // Nested required-field sets agree with the Rust structs.
1824        let required_at = |ptr: &str| -> HashSet<String> {
1825            schema
1826                .pointer(ptr)
1827                .and_then(Value::as_array)
1828                .unwrap_or_else(|| panic!("missing required[] at {ptr}"))
1829                .iter()
1830                .map(|v| v.as_str().unwrap().to_string())
1831                .collect()
1832        };
1833        let set = |fields: &[&str]| -> HashSet<String> {
1834            fields.iter().map(ToString::to_string).collect()
1835        };
1836        assert_eq!(
1837            required_at("/properties/feature/required"),
1838            set(&["slug", "source_branch", "integration_branch"])
1839        );
1840        assert_eq!(
1841            required_at("/properties/baseline/required"),
1842            set(&[
1843                "ref",
1844                "commit_oid",
1845                "toolchain",
1846                "test_passlist_hash",
1847                "clippy_warnings_hash",
1848                "enumerated_targets_hash",
1849            ])
1850        );
1851
1852        // Every required baseline string carries `minLength: 1` — the schema-side
1853        // mirror of the Rust `non_empty` check (including the three v3 provenance
1854        // fields). If a future edit dropped `minLength` from one, the JSON Schema
1855        // would tolerate `""` while the Rust validator still rejects it; this
1856        // pins the two together. (Note the residual, deliberate gap: `minLength`
1857        // rejects only length-0, whereas Rust's `non_empty` trims, so a
1858        // whitespace-only value is rejected by the operative Rust validator but
1859        // tolerated by the JSON Schema. The Rust validator is the source of truth
1860        // per the module docs; the schema is the coarser machine-readable mirror.)
1861        for field in [
1862            "ref",
1863            "commit_oid",
1864            "toolchain",
1865            "test_passlist_hash",
1866            "clippy_warnings_hash",
1867            "enumerated_targets_hash",
1868        ] {
1869            assert_eq!(
1870                schema.pointer(&format!(
1871                    "/properties/baseline/properties/{field}/minLength"
1872                )),
1873                Some(&json!(1)),
1874                "expected baseline.{field} minLength:1 in the JSON Schema"
1875            );
1876        }
1877
1878        assert_eq!(
1879            required_at("/$defs/chunk/required"),
1880            set(&["id", "title", "tier", "brief", "files_touched", "checks"])
1881        );
1882        assert_eq!(required_at("/$defs/check/required"), set(&["desc", "run"]));
1883
1884        // Acceptance variants keep their exact required-sets — the `check` arm
1885        // requires `kind`+`desc`+`run` (never the optional precision), the
1886        // `assertion` arm `kind`+`desc`. A future edit that promoted `cwd`/
1887        // `expect_exit` to required would diverge from the Rust `Option<_>`.
1888        assert_eq!(
1889            required_at("/$defs/acceptance_item/oneOf/0/required"),
1890            set(&["kind", "desc", "run"])
1891        );
1892        assert_eq!(
1893            required_at("/$defs/acceptance_item/oneOf/1/required"),
1894            set(&["kind", "desc"])
1895        );
1896
1897        // The flexible-check optional fields (`plan-check-run-contract`) are
1898        // present as optional (not required) properties on both the per-chunk
1899        // check def and the acceptance `check` variant — mirroring the Rust
1900        // `Option<_>` fields on `Check` / `Acceptance::Check`. The schema-side
1901        // constraints must also match the Rust validator: `cwd` non-empty
1902        // (`minLength: 1`) and `expect_exit` bounded to the shell range
1903        // `0..=255`. If a future edit drops or loosens either, schema and types
1904        // stop agreeing and this fails.
1905        for ptr in [
1906            "/$defs/check/properties",
1907            "/$defs/acceptance_item/oneOf/0/properties",
1908        ] {
1909            let props = schema
1910                .pointer(ptr)
1911                .unwrap_or_else(|| panic!("missing {ptr}"));
1912            assert_eq!(
1913                props["cwd"]["type"],
1914                json!("string"),
1915                "expected optional string `cwd` at {ptr}"
1916            );
1917            assert_eq!(
1918                props["cwd"]["minLength"],
1919                json!(1),
1920                "expected `cwd` minLength:1 at {ptr}"
1921            );
1922            assert_eq!(
1923                props["expect_exit"]["type"],
1924                json!("integer"),
1925                "expected optional integer `expect_exit` at {ptr}"
1926            );
1927            assert_eq!(
1928                props["expect_exit"]["minimum"],
1929                json!(0),
1930                "expected `expect_exit` minimum:0 at {ptr}"
1931            );
1932            assert_eq!(
1933                props["expect_exit"]["maximum"],
1934                json!(i64::from(MAX_SHELL_EXIT)),
1935                "expected `expect_exit` maximum:255 at {ptr}"
1936            );
1937        }
1938
1939        // Every object shape closes itself with `additionalProperties: false` —
1940        // the schema-side mirror of the Rust reject-unknown-fields policy. If a
1941        // future edit drops one, the two stop agreeing and this fails.
1942        for ptr in [
1943            "",
1944            "/properties/feature",
1945            "/properties/baseline",
1946            "/$defs/chunk",
1947            "/$defs/check",
1948            "/$defs/acceptance_item/oneOf/0",
1949            "/$defs/acceptance_item/oneOf/1",
1950        ] {
1951            let node = if ptr.is_empty() {
1952                &schema
1953            } else {
1954                schema
1955                    .pointer(ptr)
1956                    .unwrap_or_else(|| panic!("missing {ptr}"))
1957            };
1958            assert_eq!(
1959                node["additionalProperties"],
1960                json!(false),
1961                "expected additionalProperties:false at {ptr:?}"
1962            );
1963        }
1964
1965        // Acceptance `kind` discriminants agree with the Rust enum wire names.
1966        let kinds: HashSet<String> = schema["$defs"]["acceptance_item"]["oneOf"]
1967            .as_array()
1968            .unwrap()
1969            .iter()
1970            .map(|variant| {
1971                variant["properties"]["kind"]["const"]
1972                    .as_str()
1973                    .unwrap()
1974                    .to_string()
1975            })
1976            .collect();
1977        assert_eq!(kinds, set(&["check", "assertion"]));
1978
1979        // The example the doc/tests use validates against the Rust validator,
1980        // tying schema + types + example together.
1981        let example: Value = serde_json::from_str(PLAN_V3_EXAMPLE).unwrap();
1982        assert!(parse_and_validate_plan(&example).is_ok());
1983    }
1984
1985    #[test]
1986    fn tolerated_optional_seam_is_empty_in_v3() {
1987        // The governed-evolution seam exists but admits nothing in v3: every
1988        // object shape's allowlist is empty, so any unknown key is rejected.
1989        for shape in [
1990            ObjectShape::Plan,
1991            ObjectShape::Feature,
1992            ObjectShape::Baseline,
1993            ObjectShape::Chunk,
1994            ObjectShape::Check,
1995        ] {
1996            assert!(tolerated_fields(shape).is_empty());
1997        }
1998        assert!(TOLERATED_OPTIONAL_FIELDS.is_empty());
1999    }
2000
2001    #[test]
2002    fn unknown_field_scoped_to_its_object() {
2003        // A per-shape allowlist means an unknown key is reported against the
2004        // object that carries it, not conflated across shapes.
2005        let mut v = valid_plan();
2006        v["feature"]["team"] = json!("payments");
2007        assert!(matches!(
2008            parse_and_validate_plan(&v).unwrap_err(),
2009            PlanValidationError::UnknownField { path, field }
2010                if path == "feature" && field == "team"
2011        ));
2012    }
2013}