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}