Skip to main content

kranz_engine/pack/
standards.rs

1//! The Flight Rules standards corpus (ticket
2//! `.kranz/tickets/flight-rules-pack-contract.md`, KRZ-341; design
3//! `docs/scoping/flight-rules-engineering-standards.md`, decisions D-A through
4//! D-C, D-F, and D-J): an ADDITIVE schema-4 extension of the pack contract.
5//! A pack declaring `[standards] root = "..."` carries a corpus of RFC and
6//! rule Markdown files that this module loads into ONE normalized,
7//! stable-sorted manifest plus a sha256 content digest, and whose lifecycle
8//! transitions `kranz standards lint --against <ref>` checks against the
9//! trusted base.
10//!
11//! Corpus shape (D-A, "Canonical pack shape"):
12//!
13//! ```text
14//! <root>/RFC-014-slug/rfc.md            — one directory per RFC
15//! <root>/RFC-014-slug/rules/RULE-ID.md  — flat rule files, one rule each
16//! ```
17//!
18//! WHY a hand-written frontmatter subset and not YAML: the engine's
19//! dependency tree has no YAML crate and AGENTS.md prefers existing
20//! utilities over new dependencies — the same call `super::toml` made for
21//! the manifest. The subset is line-oriented and fully accountable: `key:
22//! value` scalars, `key: [a, b]` inline lists, double-quoted strings with
23//! `\\`/`\"` escapes, and `#` comments. Everything else — anchors, aliases,
24//! tags, block scalars, single-quoted strings, nested/block values, tabs —
25//! is REFUSED naming the file and field, never guessed at. Implicit typing
26//! is refused field-by-field: integers are digit strings, booleans are
27//! exactly `true`/`false`, enums name their vocabulary.
28//!
29//! WHY the traversal is capability-relative and no-follow (D-J): house
30//! standards are prompt input and policy input. The pack dir is the
31//! operator-chosen anchor (the same trust basis as [`crate::pack::Pack::load`]'s
32//! textFile reads); every parent component is opened `open_dir_nofollow`,
33//! every leaf is stat-checked regular and read `FollowSymlinks::No` with a
34//! byte cap — a symlinked parent/leaf, a FIFO, a device, an oversized file,
35//! or a bloated corpus fails promptly naming the file, and is never
36//! followed or read unboundedly. The git-ref source applies the same
37//! posture to tracked blobs: `git ls-tree` modes `120000` (symlink) and
38//! `160000` (submodule) inside the corpus are load errors.
39//!
40//! WHY the digest covers exactly these bytes (D-C/D-F): identity comes from
41//! stable frontmatter IDs, never paths — renaming a file preserves the
42//! digest. The digest hashes the NORMALIZED metadata (every frontmatter
43//! field of every RFC/rule, sorted by id, lists sorted and deduplicated)
44//! PLUS the declarations of referenced pack gates, so changing a referenced
45//! checker changes the standards digest. Markdown prose bodies are NOT
46//! hashed: prose is rationale, not a second machine authority — a rationale
47//! typo must not churn the digest.
48//!
49//! WHY the trust boundary (D-A/D-J): an external/untracked pack may supply
50//! approved ADVISORY rules but cannot activate ENFORCED rules in this slice
51//! — kranz has no base history with which to prove an external pack's
52//! lifecycle transitions. [`crate::pack::standards::StandardsTrust::External`] plus an effectively
53//! enforced rule is a load error naming the remedy (vendor the pack into
54//! the repo as a tracked, repo-relative `packDir`). Repo-relative packs are
55//! read from tracked blobs in the pinned base tree where the engine already
56//! does that ([`crate::pack::standards::load_at_ref`], mirroring the merge-gate base-read idiom);
57//! mission-time approval pinning is the next slice (KRZ-342).
58//!
59//! Loading and linting parse bytes only — no checker command or source text
60//! is ever executed by this module.
61
62use super::{PackGateDecl, PACK_MANIFEST};
63use sha2::{Digest as _, Sha256};
64use std::collections::{BTreeMap, HashSet};
65use std::path::{Path, PathBuf};
66
67/// Per-file byte cap for one corpus file (`rfc.md` or a rule file). The
68/// frontmatter is small by construction; the allowance is for rationale
69/// prose. A larger file fails the load naming the file.
70pub const MAX_STANDARDS_FILE_BYTES: u64 = 256 * 1024;
71
72/// Cap on the number of corpus files (RFC + rule files together).
73pub const MAX_STANDARDS_FILES: usize = 512;
74
75/// Cap on the number of rules in one pack's corpus.
76pub const MAX_STANDARDS_RULES: usize = 256;
77
78/// Cap on the total NORMALIZED manifest bytes (the canonical text the
79/// digest hashes) — the bound later resolution/prompt budgets rely on.
80pub const MAX_STANDARDS_NORMALIZED_BYTES: usize = 1024 * 1024;
81
82/// The first line of the canonical manifest text. Bumping the format is a
83/// deliberate, reviewable digest change — old digests simply stop matching.
84const CANONICAL_HEADER: &str = "kranz-standards-manifest v1";
85
86/// Whether the pack's bytes carry provable repo history (D-A/D-J). The
87/// decision is made by the caller ([`trust_for_dir`] for the lint surfaces,
88/// `packDir` absoluteness for mission loads); the loader only enforces the
89/// consequence.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum StandardsTrust {
92    /// A tracked, repo-relative pack: enforced rules may activate (their
93    /// lifecycle is provable against base history).
94    RepoTracked,
95    /// An external or untracked pack: approved advisory rules load; an
96    /// effectively ENFORCED rule is a load error naming the trust remedy.
97    External,
98}
99
100/// RFC lifecycle (D-B). Every active contained rule inherits the RFC's
101/// status; a rule may only narrow it to `retired`.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum RfcStatus {
104    Draft,
105    Approved,
106    Enforced,
107    Retired,
108}
109
110impl RfcStatus {
111    fn parse(raw: &str) -> Option<Self> {
112        match raw {
113            "draft" => Some(Self::Draft),
114            "approved" => Some(Self::Approved),
115            "enforced" => Some(Self::Enforced),
116            "retired" => Some(Self::Retired),
117            _ => None,
118        }
119    }
120
121    pub fn as_str(&self) -> &'static str {
122        match self {
123            Self::Draft => "draft",
124            Self::Approved => "approved",
125            Self::Enforced => "enforced",
126            Self::Retired => "retired",
127        }
128    }
129}
130
131/// RFC-2119 level of a rule's normative statement. The design's vocabulary
132/// is SHOULD/MUST only (D-B's behavior table has no `may` row) — `may` is a
133/// load error naming the supported levels.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum RuleLevel {
136    Must,
137    Should,
138}
139
140impl RuleLevel {
141    fn parse(raw: &str) -> Option<Self> {
142        match raw {
143            "must" => Some(Self::Must),
144            "should" => Some(Self::Should),
145            _ => None,
146        }
147    }
148
149    pub fn as_str(&self) -> &'static str {
150        match self {
151            Self::Must => "must",
152            Self::Should => "should",
153        }
154    }
155}
156
157/// A rule's own declared status (D-B): `active` inherits the parent RFC's
158/// lifecycle; `retired` is the immutable one-way tombstone.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum RuleStatus {
161    Active,
162    Retired,
163}
164
165impl RuleStatus {
166    fn parse(raw: &str) -> Option<Self> {
167        match raw {
168            "active" => Some(Self::Active),
169            "retired" => Some(Self::Retired),
170            _ => None,
171        }
172    }
173
174    pub fn as_str(&self) -> &'static str {
175        match self {
176            Self::Active => "active",
177            Self::Retired => "retired",
178        }
179    }
180}
181
182/// The workflow stage vocabulary a rule scopes itself to (D-G's projection
183/// stages).
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum RuleStage {
186    Planning,
187    Implementation,
188    Validation,
189    Merge,
190}
191
192impl RuleStage {
193    /// Parse the canonical stage spelling (`planning`, `implementation`,
194    /// `validation`, `merge`). `pub(crate)` for the KRZ-342 resolver, which
195    /// re-resolves pinned rules whose stages ride the plan contract as
196    /// strings.
197    pub(crate) fn parse(raw: &str) -> Option<Self> {
198        match raw {
199            "planning" => Some(Self::Planning),
200            "implementation" => Some(Self::Implementation),
201            "validation" => Some(Self::Validation),
202            "merge" => Some(Self::Merge),
203            _ => None,
204        }
205    }
206
207    pub fn as_str(&self) -> &'static str {
208        match self {
209            Self::Planning => "planning",
210            Self::Implementation => "implementation",
211            Self::Validation => "validation",
212            Self::Merge => "merge",
213        }
214    }
215}
216
217/// A rule's typed checker binding (D-F): a registered pack gate, the
218/// engine-owned contextual reviewer, or an explicit human decision — never
219/// arbitrary executable prose.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum Checker {
222    /// `gate:<stable-gate-id>` — must name a `[[gate]]` declared by the same
223    /// pack; the referenced declaration joins the digest so a checker edit
224    /// is byte-visible (D-J's drift row).
225    Gate(String),
226    /// `agent-judgement` — the engine-owned contextual standards reviewer.
227    AgentJudgement,
228    /// `manual-attestation` — an explicit authorized human decision.
229    ManualAttestation,
230}
231
232impl Checker {
233    fn parse(raw: &str) -> Result<Self, String> {
234        match raw {
235            "agent-judgement" => Ok(Self::AgentJudgement),
236            "manual-attestation" => Ok(Self::ManualAttestation),
237            _ => match raw.strip_prefix("gate:") {
238                Some(id) if !id.is_empty() => Ok(Self::Gate(id.to_string())),
239                _ => Err(format!(
240                    "supported checker forms are `gate:<id>` (a declared pack gate), \
241                     `agent-judgement`, and `manual-attestation`, got `{raw}`"
242                )),
243            },
244        }
245    }
246
247    /// The canonical spelling used in the manifest and reports.
248    pub fn render(&self) -> String {
249        match self {
250            Self::Gate(id) => format!("gate:{id}"),
251            Self::AgentJudgement => "agent-judgement".to_string(),
252            Self::ManualAttestation => "manual-attestation".to_string(),
253        }
254    }
255}
256
257/// One RFC's normalized governance metadata (`rfc.md` frontmatter).
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct RfcMeta {
260    pub id: String,
261    pub title: String,
262    pub owner: String,
263    pub status: RfcStatus,
264    /// RFC3339 promotion instant, normalized to UTC seconds (`Z`). Before
265    /// this instant a freshly-enforced RFC remains approved in effect (D-B's
266    /// absorption window); this slice parses and carries it, later slices
267    /// evaluate it.
268    pub effective_at: Option<String>,
269    /// RFC ids this RFC supersedes (sorted, deduplicated).
270    pub supersedes: Vec<String>,
271}
272
273/// One rule's normalized metadata (a `rules/*.md` frontmatter).
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct RuleMeta {
276    pub id: String,
277    /// Positive, monotonic per id (D-C): a semantic change without an
278    /// increment is a transition-lint refusal.
279    pub revision: u64,
280    /// Parent RFC id — validated to exist (no orphan rules).
281    pub rfc: String,
282    pub level: RuleLevel,
283    pub status: RuleStatus,
284    /// The one-line normative statement — the canonical machine/human text.
285    pub statement: String,
286    /// Browsing/reporting labels (D-D: never an LLM-selected policy switch).
287    /// Sorted and deduplicated.
288    pub domains: Vec<String>,
289    /// The stages the rule applies to (sorted by name). Never empty.
290    pub stages: Vec<RuleStage>,
291    /// Repo-relative path prefixes (normalized); empty means unscoped.
292    pub when_paths: Vec<String>,
293    /// Mission task classes (free-form, matched by config/routing
294    /// normalization); empty means unscoped.
295    pub task_classes: Vec<String>,
296    /// The typed checker binding. Draft rules may omit it (D-F); promotion
297    /// to an approved/enforced EFFECTIVE status requires it (fail-closed).
298    pub checker: Option<Checker>,
299    /// Waiver posture (D-I): `false` when omitted — fail-closed.
300    pub waivable: bool,
301}
302
303/// The normalized, stable-sorted product of a standards corpus load: the
304/// RFCs and rules (each sorted by id), the declarations of pack gates the
305/// rules reference (sorted by name), the canonical text, and its sha256.
306/// This is the object later slices pin into the approved mission contract.
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct StandardsManifest {
309    /// The normalized pack-relative root from `[standards] root`.
310    pub root: String,
311    pub rfcs: Vec<RfcMeta>,
312    pub rules: Vec<RuleMeta>,
313    /// Declarations of the pack gates referenced by `gate:` checkers —
314    /// governing bytes, hashed into the digest (D-F).
315    pub gate_bindings: Vec<PackGateDecl>,
316    /// All pack gates from the same trusted source. Only referenced bindings
317    /// participate in the standards digest, but approval pins the full list
318    /// so ordinary advisory pack gates also avoid a mission-worktree re-read.
319    pub pack_gates: Vec<PackGateDecl>,
320    /// Lowercase hex sha256 over [`Self::canonical_text`].
321    pub digest: String,
322    canonical: String,
323}
324
325impl StandardsManifest {
326    /// The normalized governing bytes the digest hashes: every frontmatter
327    /// field of every RFC/rule (sorted by id, lists sorted/deduplicated),
328    /// plus the referenced pack gate declarations. Prose bodies and file
329    /// paths are deliberately absent (D-C).
330    pub fn canonical_text(&self) -> &str {
331        &self.canonical
332    }
333
334    /// The rule with this id, if present.
335    pub fn rule(&self, id: &str) -> Option<&RuleMeta> {
336        self.rules.iter().find(|r| r.id == id)
337    }
338
339    /// The RFC with this id, if present.
340    pub fn rfc(&self, id: &str) -> Option<&RfcMeta> {
341        self.rfcs.iter().find(|r| r.id == id)
342    }
343
344    /// A rule's EFFECTIVE lifecycle (D-B): its own tombstone wins; an active
345    /// rule inherits its parent RFC's status. The parent always exists in a
346    /// loaded manifest (orphans are load errors); the defensive fallback is
347    /// `Retired`, the status that can never block anything.
348    pub fn effective_status(&self, rule: &RuleMeta) -> RfcStatus {
349        if rule.status == RuleStatus::Retired {
350            return RfcStatus::Retired;
351        }
352        self.rfc(&rule.rfc)
353            .map(|rfc| rfc.status)
354            .unwrap_or(RfcStatus::Retired)
355    }
356}
357
358// ---------------------------------------------------------------------------
359// Entry points
360// ---------------------------------------------------------------------------
361
362/// Load the standards corpus of a filesystem pack: a capability-relative,
363/// no-follow walk of `<pack_dir>/<root>`. Called by
364/// [`super::Pack::from_document`] when the manifest declares `[standards]`,
365/// so every consuming surface sees the same fully-accounted pack.
366pub(crate) fn load_from_pack_dir(
367    pack_dir: &Path,
368    root: &str,
369    gates: &[PackGateDecl],
370    trust: StandardsTrust,
371) -> Result<StandardsManifest, String> {
372    use cap_fs_ext::DirExt as _;
373
374    let display_root = pack_dir.join(root);
375    let mut dir = cap_std::fs::Dir::open_ambient_dir(pack_dir, cap_std::ambient_authority())
376        .map_err(|e| format!("cannot open pack dir {}: {e}", pack_dir.display()))?;
377    // `root` reaches here normalized (plain Normal components), so splitting
378    // on '/' yields plain names — the same idiom as the textFile reader.
379    for name in root.split('/') {
380        dir = dir.open_dir_nofollow(name).map_err(|e| {
381            if e.kind() == std::io::ErrorKind::NotFound {
382                format!(
383                    "[standards] root `{root}` does not exist in the pack ({})",
384                    display_root.display()
385                )
386            } else {
387                format!(
388                    "[standards] root `{root}` resolves through a symlinked or non-directory \
389                     component ({}) — the standards corpus loads no-follow",
390                    display_root.display()
391                )
392            }
393        })?;
394    }
395    let source = FsSource {
396        root_dir: dir,
397        display_root,
398    };
399    load_from_source(&source, root, gates, trust)
400}
401
402/// Load the standards corpus as of a base git ref: `pack.toml` and every
403/// governing byte come from TRACKED BLOBS at `<ref>` (`git show` /
404/// `git ls-tree`), never the filesystem worktree (D-A) — a mission branch
405/// edit cannot reshape the policy judging it. `pack_rel_dir` is the pack's
406/// repo-relative slash path (`""` when the pack sits at the repo root).
407/// `Ok(None)` means the ref has no pack manifest or the pack declares no
408/// standards root — the base simply had no standards.
409pub fn load_at_ref(
410    repo: &crate::git_ops::GitRepo,
411    refname: &str,
412    pack_rel_dir: &str,
413) -> Result<Option<StandardsManifest>, String> {
414    let oid = repo
415        .rev_parse(refname)
416        .map_err(|e| format!("cannot resolve ref `{refname}`: {e}"))?;
417    let manifest_rel = join_rel(pack_rel_dir, PACK_MANIFEST);
418    let Some(bytes) = repo
419        .show_file(&oid, &manifest_rel)
420        .map_err(|e| format!("cannot read {manifest_rel} at `{refname}`: {e}"))?
421    else {
422        return Ok(None);
423    };
424    let text = String::from_utf8(bytes)
425        .map_err(|_| format!("{manifest_rel} at `{refname}` is not valid UTF-8"))?;
426    let doc =
427        super::toml::parse(&text).map_err(|e| format!("{manifest_rel} at `{refname}`: {e}"))?;
428    // The base manifest is validated through the SAME strict helpers as a
429    // worktree load — a base the engine cannot fully account for fails
430    // closed rather than silently comparing against a partial read.
431    let (_name, schema) =
432        super::manifest_header(&doc).map_err(|e| format!("{manifest_rel} at `{refname}`: {e}"))?;
433    let Some(root) = super::standards_root_of(&doc, schema)
434        .map_err(|e| format!("{manifest_rel} at `{refname}`: {e}"))?
435    else {
436        return Ok(None);
437    };
438    // Checker bindings are base-owned too: the gate declarations come from
439    // the same tracked pack.toml, never the worktree.
440    let mut gates = Vec::new();
441    for (idx, item) in doc.array("gate").iter().enumerate() {
442        gates.push(
443            super::load_gate(item, idx)
444                .map_err(|e| format!("{manifest_rel} at `{refname}`: {e}"))?,
445        );
446    }
447    super::reject_duplicate_names("gate", gates.iter().map(|g| g.name.as_str()))
448        .map_err(|e| format!("{manifest_rel} at `{refname}`: {e}"))?;
449    let prefix = join_rel(pack_rel_dir, &root);
450    let source = GitSource {
451        repo,
452        oid: &oid,
453        refname,
454        prefix,
455    };
456    // Base bytes are tracked by construction — RepoTracked is descriptive,
457    // not a decision.
458    load_from_source(&source, &root, &gates, StandardsTrust::RepoTracked).map(Some)
459}
460
461/// The trust level a pack directory earns from its relationship to the repo
462/// (D-A/D-J): inside the repo with a TRACKED `pack.toml` ⇒
463/// [`StandardsTrust::RepoTracked`]; anything else — outside the repo,
464/// inside but untracked, not a git repo, any lookup failure — fails closed
465/// to [`StandardsTrust::External`] (advisory-only).
466pub fn trust_for_dir(repo_root: &Path, pack_dir: &Path) -> StandardsTrust {
467    let Some(rel) = repo_relative_dir(repo_root, pack_dir) else {
468        return StandardsTrust::External;
469    };
470    let manifest_rel = join_rel(&rel, PACK_MANIFEST);
471    let Ok(repo) = crate::git_ops::GitRepo::open(repo_root) else {
472        return StandardsTrust::External;
473    };
474    match repo.is_tracked(&manifest_rel) {
475        Ok(true) => StandardsTrust::RepoTracked,
476        _ => StandardsTrust::External,
477    }
478}
479
480/// The repo-relative slash path of `dir` when it sits inside `repo_root`
481/// (`""` for the repo root itself), `None` when outside. Both sides are
482/// canonicalized first so symlinked prefixes (macOS `/tmp` → `/private/...`)
483/// cannot fake or defeat the containment check.
484pub fn repo_relative_dir(repo_root: &Path, dir: &Path) -> Option<String> {
485    let repo_c = std::fs::canonicalize(repo_root).ok()?;
486    let dir_c = std::fs::canonicalize(dir).ok()?;
487    let rel = dir_c.strip_prefix(&repo_c).ok()?;
488    let mut out = String::new();
489    for part in rel.components() {
490        let std::path::Component::Normal(name) = part else {
491            return None;
492        };
493        if !out.is_empty() {
494            out.push('/');
495        }
496        out.push_str(name.to_str()?);
497    }
498    Some(out)
499}
500
501/// Join a repo-relative directory (possibly empty) and a file name into a
502/// slash path git pathspecs understand.
503fn join_rel(dir: &str, leaf: &str) -> String {
504    if dir.is_empty() {
505        leaf.to_string()
506    } else {
507        format!("{dir}/{leaf}")
508    }
509}
510
511// ---------------------------------------------------------------------------
512// Lifecycle transition lint (D-B/D-C)
513// ---------------------------------------------------------------------------
514
515/// The lifecycle transition check behind `kranz standards lint --against
516/// <ref>`: compare the PROPOSED manifest against the trusted BASE (`None` —
517/// the base ref had no standards) and return every violation, each naming
518/// the rule/RFC and the refused transition. An empty vec is "transitions
519/// clean". The four refused classes are D-B/D-C's:
520///
521/// - absent/draft → enforced (RFC or rule): blocking policy must absorb an
522///   approved advisory period first;
523/// - a semantic rule change (statement, level, stages, when-paths,
524///   task-classes, checker, waivable — D-C's list) without a revision
525///   increment; revisions also never move backwards;
526/// - disappearance of a rule ID known at the base (retire, never delete);
527/// - tombstone reactivation (a rule retired at the base stays retired).
528pub fn check_transitions(
529    base: Option<&StandardsManifest>,
530    proposed: &StandardsManifest,
531) -> Vec<String> {
532    let mut errors = Vec::new();
533
534    for rfc in proposed
535        .rfcs
536        .iter()
537        .filter(|r| r.status == RfcStatus::Enforced)
538    {
539        match base.and_then(|b| b.rfc(&rfc.id)) {
540            None => errors.push(format!(
541                "RFC `{}` is enforced but absent at the base — an RFC may not move \
542                 absent/draft → enforced; land it approved first so the advisory period \
543                 produces real evidence (D-B)",
544                rfc.id
545            )),
546            Some(base_rfc) if base_rfc.status == RfcStatus::Draft => errors.push(format!(
547                "RFC `{}` is enforced but was draft at the base — absent/draft → enforced \
548                 is refused; promote through approved first (D-B)",
549                rfc.id
550            )),
551            Some(base_rfc) if base_rfc.status == RfcStatus::Retired => errors.push(format!(
552                "RFC `{}` is enforced but was retired at the base — a tombstone is one-way \
553                 (D-B/D-C)",
554                rfc.id
555            )),
556            Some(_) => {}
557        }
558    }
559
560    for rule in &proposed.rules {
561        if proposed.effective_status(rule) != RfcStatus::Enforced {
562            continue;
563        }
564        let base_effective = match base {
565            Some(base) => base
566                .rule(&rule.id)
567                .map(|base_rule| base.effective_status(base_rule)),
568            None => None,
569        };
570        match base_effective {
571            Some(RfcStatus::Approved | RfcStatus::Enforced) => {}
572            // A reactivated tombstone is already reported below — one error
573            // per violation class is enough.
574            Some(RfcStatus::Retired) => {}
575            Some(RfcStatus::Draft) => errors.push(format!(
576                "rule `{}` is enforced but was draft at the base — absent/draft → enforced \
577                 is refused; an approved advisory period comes first (D-B)",
578                rule.id
579            )),
580            None => errors.push(format!(
581                "rule `{}` is enforced but absent at the base — new rules enter as draft or \
582                 approved, never directly enforced (D-B)",
583                rule.id
584            )),
585        }
586    }
587
588    let Some(base) = base else {
589        return errors;
590    };
591    for base_rule in &base.rules {
592        let Some(proposed_rule) = proposed.rule(&base_rule.id) else {
593            errors.push(format!(
594                "rule `{}` (base revision {}) is gone — known rule IDs cannot disappear; \
595                 retire the rule as a one-way tombstone instead of deleting it (D-C)",
596                base_rule.id, base_rule.revision
597            ));
598            continue;
599        };
600        if base_rule.status == RuleStatus::Retired && proposed_rule.status == RuleStatus::Active {
601            errors.push(format!(
602                "rule `{}` was retired at the base and cannot be reactivated — retirement \
603                 is a one-way tombstone; a successor rule needs a new ID (D-B/D-C)",
604                base_rule.id
605            ));
606            continue;
607        }
608        if base_rule.status == RuleStatus::Active && proposed_rule.status == RuleStatus::Active {
609            if proposed_rule.revision < base_rule.revision {
610                errors.push(format!(
611                    "rule `{}` revision moved backwards ({} → {}) — revisions are monotonic \
612                     (D-C)",
613                    base_rule.id, base_rule.revision, proposed_rule.revision
614                ));
615            } else if proposed_rule.revision == base_rule.revision {
616                if let Some(field) = semantic_change(base_rule, proposed_rule) {
617                    errors.push(format!(
618                        "rule `{}` changed `{field}` without a revision increment (still \
619                         {}) — a semantic change to statement, level, scope, checker, or \
620                         waiver posture requires a bump (D-C)",
621                        base_rule.id, base_rule.revision
622                    ));
623                }
624            }
625        }
626    }
627    errors
628}
629
630/// The first differing semantic field between two revisions of one rule, or
631/// None. The field list is D-C's: statement, level, scope (stages,
632/// when-paths, task-classes), checker, waiver posture. Domains are browsing
633/// labels (D-D) and the parent-RFC link is lifecycle, not rule semantics —
634/// neither forces a bump here.
635fn semantic_change(base: &RuleMeta, proposed: &RuleMeta) -> Option<&'static str> {
636    if base.statement != proposed.statement {
637        Some("statement")
638    } else if base.level != proposed.level {
639        Some("level")
640    } else if base.stages != proposed.stages {
641        Some("stages")
642    } else if base.when_paths != proposed.when_paths {
643        Some("when-paths")
644    } else if base.task_classes != proposed.task_classes {
645        Some("task-classes")
646    } else if base.checker != proposed.checker {
647        Some("checker")
648    } else if base.waivable != proposed.waivable {
649        Some("waivable")
650    } else {
651        None
652    }
653}
654
655// ---------------------------------------------------------------------------
656// Reports
657// ---------------------------------------------------------------------------
658
659/// The standards block of `kranz pack lint`: the registration summary —
660/// root, digest, and lifecycle tallies — for a schema-4 pack.
661pub fn render_registration(manifest: &StandardsManifest) -> String {
662    let tally = |status: RfcStatus| manifest.rfcs.iter().filter(|r| r.status == status).count();
663    let active_rules = manifest
664        .rules
665        .iter()
666        .filter(|r| r.status == RuleStatus::Active)
667        .count();
668    format!(
669        "standards (schema {} root `{}`):\n  digest: sha256:{}\n  RFCs: {} (draft {}, approved \
670         {}, enforced {}, retired {}); rules: {} (active {}, retired {}); gate bindings: {}\n",
671        super::SCHEMA_STANDARDS,
672        manifest.root,
673        manifest.digest,
674        manifest.rfcs.len(),
675        tally(RfcStatus::Draft),
676        tally(RfcStatus::Approved),
677        tally(RfcStatus::Enforced),
678        tally(RfcStatus::Retired),
679        manifest.rules.len(),
680        active_rules,
681        manifest.rules.len() - active_rules,
682        manifest.gate_bindings.len(),
683    )
684}
685
686/// The headline of `kranz standards lint`: the full normalized manifest —
687/// every RFC and rule with its effective status, checker binding, and
688/// scopes — plus the digest and the trust posture the loader applied.
689pub fn render_manifest(manifest: &StandardsManifest, trust: StandardsTrust) -> String {
690    let mut out = format!(
691        "standards root `{}` — {} RFC(s), {} rule(s)\ndigest: sha256:{}\n",
692        manifest.root,
693        manifest.rfcs.len(),
694        manifest.rules.len(),
695        manifest.digest
696    );
697    out.push_str(match trust {
698        StandardsTrust::RepoTracked => "trust: repo-tracked — enforced rules may activate\n",
699        StandardsTrust::External => {
700            "trust: external/untracked — advisory only; enforced rules are refused at load \
701             (D-A/D-J)\n"
702        }
703    });
704    out.push_str("RFCs:\n");
705    if manifest.rfcs.is_empty() {
706        out.push_str("  (none)\n");
707    }
708    for rfc in &manifest.rfcs {
709        let effective = rfc
710            .effective_at
711            .as_deref()
712            .map(|ts| format!(", effective {ts}"))
713            .unwrap_or_default();
714        let supersedes = if rfc.supersedes.is_empty() {
715            String::new()
716        } else {
717            format!(", supersedes {}", rfc.supersedes.join(", "))
718        };
719        out.push_str(&format!(
720            "  - {} \"{}\" — {}, owner {}{}{}\n",
721            rfc.id,
722            rfc.title,
723            rfc.status.as_str(),
724            rfc.owner,
725            effective,
726            supersedes
727        ));
728    }
729    out.push_str("rules:\n");
730    if manifest.rules.is_empty() {
731        out.push_str("  (none)\n");
732    }
733    for rule in &manifest.rules {
734        let checker = rule
735            .checker
736            .as_ref()
737            .map(Checker::render)
738            .unwrap_or_else(|| "-".to_string());
739        out.push_str(&format!(
740            "  - {} r{} — {}, {}; checker {}; waivable: {}\n      statement: {}\n",
741            rule.id,
742            rule.revision,
743            rule.level.as_str(),
744            manifest.effective_status(rule).as_str(),
745            checker,
746            rule.waivable,
747            rule.statement
748        ));
749        let list = |items: &[String]| {
750            if items.is_empty() {
751                "-".to_string()
752            } else {
753                items.join(", ")
754            }
755        };
756        let stages = rule
757            .stages
758            .iter()
759            .map(RuleStage::as_str)
760            .collect::<Vec<_>>()
761            .join(", ");
762        out.push_str(&format!(
763            "      stages: {}; domains: {}; when-paths: {}; task-classes: {}\n",
764            stages,
765            list(&rule.domains),
766            list(&rule.when_paths),
767            list(&rule.task_classes)
768        ));
769    }
770    out
771}
772
773/// The `--against <ref>` section of `kranz standards lint`: what the base
774/// held and every refused transition (empty ⇒ clean).
775pub fn render_transition_report(
776    refname: &str,
777    base: Option<&StandardsManifest>,
778    errors: &[String],
779) -> String {
780    let base_desc = match base {
781        Some(base) => format!(
782            "base digest sha256:{}, {} RFC(s), {} rule(s)",
783            base.digest,
784            base.rfcs.len(),
785            base.rules.len()
786        ),
787        None => "no standards at the base ref".to_string(),
788    };
789    let mut out = format!("transition check against `{refname}` ({base_desc}):\n");
790    if errors.is_empty() {
791        out.push_str("  ok — no lifecycle violations\n");
792    } else {
793        for error in errors {
794            out.push_str(&format!("  REFUSED: {error}\n"));
795        }
796    }
797    out
798}
799
800// ---------------------------------------------------------------------------
801// Corpus sources: the two D-A byte origins, one validation path
802// ---------------------------------------------------------------------------
803
804/// One listed corpus file: root-relative slash path, byte size, and the
805/// display name errors quote (a filesystem path or `<ref>:<path>`).
806struct SourceFile {
807    rel: String,
808    size: u64,
809    display: String,
810}
811
812/// A governing-bytes source. Both shapes apply the same fail-closed posture:
813/// regular files only, hostile shapes named and refused at LISTING time,
814/// per-file caps checked before (and while) reading.
815trait CorpusSource {
816    /// Every regular file under the standards root, sorted by rel path.
817    fn list_files(&self) -> Result<Vec<SourceFile>, String>;
818    /// The bytes of one listed file (cap re-checked while reading).
819    fn read_bytes(&self, file: &SourceFile) -> Result<Vec<u8>, String>;
820}
821
822/// The worktree/external source: a capability-relative, no-follow walk
823/// under the pack dir anchor (D-J).
824struct FsSource {
825    /// The standards root directory, opened no-follow from the anchor.
826    root_dir: cap_std::fs::Dir,
827    display_root: PathBuf,
828}
829
830impl CorpusSource for FsSource {
831    fn list_files(&self) -> Result<Vec<SourceFile>, String> {
832        use cap_fs_ext::DirExt as _;
833
834        let mut out = Vec::new();
835        for (name, ftype) in sorted_entries(&self.root_dir, &self.display_root)? {
836            let display = self.display_root.join(&name);
837            check_entry_name(&name, &display)?;
838            if ftype.is_symlink() {
839                return Err(format!(
840                    "{} resolves through a symlink — the standards corpus never follows \
841                     symlinks (D-J)",
842                    display.display()
843                ));
844            }
845            if !ftype.is_dir() {
846                return Err(format!(
847                    "{} is not an RFC directory — the standards root holds one directory \
848                     per RFC, nothing else",
849                    display.display()
850                ));
851            }
852            let rfc_dir = self.root_dir.open_dir_nofollow(&name).map_err(|_| {
853                format!(
854                    "{} resolves through a symlinked or non-directory component — the \
855                     standards corpus never follows symlinks (D-J)",
856                    display.display()
857                )
858            })?;
859            list_rfc_dir(&rfc_dir, &name, &display, &mut out)?;
860        }
861        Ok(out)
862    }
863
864    fn read_bytes(&self, file: &SourceFile) -> Result<Vec<u8>, String> {
865        use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _};
866        use std::io::Read as _;
867
868        let mut dir = self
869            .root_dir
870            .try_clone()
871            .map_err(|e| format!("{} cannot be opened: {e}", file.display))?;
872        let mut names = file.rel.split('/').peekable();
873        while let Some(name) = names.next() {
874            if names.peek().is_some() {
875                dir = dir.open_dir_nofollow(name).map_err(|_| {
876                    format!(
877                        "{} resolves through a symlinked or non-directory component — the \
878                         standards corpus never follows symlinks (D-J)",
879                        file.display
880                    )
881                })?;
882                continue;
883            }
884            // Stat BEFORE opening: a FIFO would block an O_RDONLY open
885            // forever waiting for a writer — the refusal must be prompt.
886            let meta = dir
887                .symlink_metadata(name)
888                .map_err(|e| format!("{} cannot be stat'ed: {e}", file.display))?;
889            let ftype = meta.file_type();
890            if ftype.is_symlink() {
891                return Err(format!(
892                    "{} is a symlink — the standards corpus never follows symlinks (D-J)",
893                    file.display
894                ));
895            }
896            if !ftype.is_file() {
897                return Err(format!(
898                    "{} is not a regular file (FIFO/device/socket) — the standards corpus \
899                     accepts regular files only (D-J)",
900                    file.display
901                ));
902            }
903            let mut options = cap_std::fs::OpenOptions::new();
904            options.read(true).follow(FollowSymlinks::No);
905            let opened = dir
906                .open_with(name, &options)
907                .map_err(|e| format!("{} cannot be read: {e}", file.display))?;
908            let mut bytes = Vec::new();
909            opened
910                .take(MAX_STANDARDS_FILE_BYTES + 1)
911                .read_to_end(&mut bytes)
912                .map_err(|e| format!("{} cannot be read: {e}", file.display))?;
913            if bytes.len() as u64 > MAX_STANDARDS_FILE_BYTES {
914                return Err(format!(
915                    "{} is {} bytes, over the {}-byte per-file cap",
916                    file.display,
917                    bytes.len(),
918                    MAX_STANDARDS_FILE_BYTES
919                ));
920            }
921            return Ok(bytes);
922        }
923        // Unreachable: rel paths are non-empty by construction — fail closed
924        // rather than panic if that ever changes.
925        Err(format!("{} resolves to no file", file.display))
926    }
927}
928
929/// One RFC directory's listing: its `rfc.md` plus its rule files.
930fn list_rfc_dir(
931    dir: &cap_std::fs::Dir,
932    rel_prefix: &str,
933    display: &Path,
934    out: &mut Vec<SourceFile>,
935) -> Result<(), String> {
936    use cap_fs_ext::DirExt as _;
937
938    for (name, ftype) in sorted_entries(dir, display)? {
939        let entry_display = display.join(&name);
940        if ftype.is_symlink() {
941            return Err(format!(
942                "{} resolves through a symlink — the standards corpus never follows \
943                 symlinks (D-J)",
944                entry_display.display()
945            ));
946        }
947        if name == "rfc.md" {
948            if !ftype.is_file() {
949                return Err(format!(
950                    "{} must be a regular file",
951                    entry_display.display()
952                ));
953            }
954            out.push(SourceFile {
955                rel: format!("{rel_prefix}/rfc.md"),
956                size: dir.symlink_metadata(&name).map(|m| m.len()).unwrap_or(0),
957                display: entry_display.display().to_string(),
958            });
959            continue;
960        }
961        if name == "rules" {
962            if !ftype.is_dir() {
963                return Err(format!(
964                    "{} must be a directory holding rule files",
965                    entry_display.display()
966                ));
967            }
968            let rules_dir = dir.open_dir_nofollow(&name).map_err(|_| {
969                format!(
970                    "{} resolves through a symlinked or non-directory component — the \
971                     standards corpus never follows symlinks (D-J)",
972                    entry_display.display()
973                )
974            })?;
975            for (rule_name, rule_ftype) in sorted_entries(&rules_dir, &entry_display)? {
976                let rule_display = entry_display.join(&rule_name);
977                if rule_ftype.is_symlink() {
978                    return Err(format!(
979                        "{} resolves through a symlink — the standards corpus never \
980                         follows symlinks (D-J)",
981                        rule_display.display()
982                    ));
983                }
984                if rule_ftype.is_dir() {
985                    return Err(format!(
986                        "{} is a directory — rules/ holds rule Markdown files only, no \
987                         nested directories",
988                        rule_display.display()
989                    ));
990                }
991                if !rule_ftype.is_file() {
992                    return Err(format!(
993                        "{} is not a regular file (FIFO/device/socket) — the standards \
994                         corpus accepts regular files only (D-J)",
995                        rule_display.display()
996                    ));
997                }
998                if !rule_name.ends_with(".md") {
999                    return Err(format!(
1000                        "{} is not a `.md` rule file — rules/ holds rule Markdown files \
1001                         only",
1002                        rule_display.display()
1003                    ));
1004                }
1005                check_entry_name(&rule_name, &rule_display)?;
1006                out.push(SourceFile {
1007                    rel: format!("{rel_prefix}/rules/{rule_name}"),
1008                    size: rules_dir
1009                        .symlink_metadata(&rule_name)
1010                        .map(|m| m.len())
1011                        .unwrap_or(0),
1012                    display: rule_display.display().to_string(),
1013                });
1014            }
1015            continue;
1016        }
1017        return Err(format!(
1018            "{} is unexpected — an RFC directory holds `rfc.md` and `rules/`, nothing else",
1019            entry_display.display()
1020        ));
1021    }
1022    Ok(())
1023}
1024
1025/// The pinned-base source: tracked blobs at one resolved git OID (D-A).
1026struct GitSource<'a> {
1027    repo: &'a crate::git_ops::GitRepo,
1028    oid: &'a str,
1029    /// The user-facing ref name, for error text.
1030    refname: &'a str,
1031    /// Repo-relative slash path of the standards root.
1032    prefix: String,
1033}
1034
1035impl CorpusSource for GitSource<'_> {
1036    fn list_files(&self) -> Result<Vec<SourceFile>, String> {
1037        let entries = self
1038            .repo
1039            .ls_tree_recursive(self.oid, &self.prefix)
1040            .map_err(|e| format!("cannot list the standards root at `{}`: {e}", self.refname))?;
1041        if entries.is_empty() {
1042            return Err(format!(
1043                "[standards] root is declared but no tracked files exist under `{}` at \
1044                 `{}`",
1045                self.prefix, self.refname
1046            ));
1047        }
1048        let mut out = Vec::new();
1049        for entry in entries {
1050            let display = format!("{}:{}", self.refname, entry.path);
1051            if entry.path == self.prefix {
1052                return Err(format!(
1053                    "{display} is a file, not a directory tree — the standards root must \
1054                     be a directory"
1055                ));
1056            }
1057            let Some(rel) = entry.path.strip_prefix(&format!("{}/", self.prefix)) else {
1058                return Err(format!(
1059                    "git ls-tree reported {display} outside the standards root `{}`",
1060                    self.prefix
1061                ));
1062            };
1063            if rel.starts_with('"') {
1064                return Err(format!(
1065                    "{display} needed git quoting — corpus names stay inside ASCII \
1066                     alphanumerics, `.`, `_`, `-`"
1067                ));
1068            }
1069            // The no-follow posture applied to tracked bytes (D-J): a git
1070            // tree can carry symlinks (mode 120000) and submodules (mode
1071            // 160000); both are refused, never followed.
1072            if entry.mode == "120000" {
1073                return Err(format!(
1074                    "{display} is a tracked symlink — the standards corpus never follows \
1075                     symlinks (D-J)"
1076                ));
1077            }
1078            if entry.kind != "blob" {
1079                return Err(format!(
1080                    "{display} is a {} (mode {}) — the standards corpus accepts regular \
1081                     files only (D-J)",
1082                    entry.kind, entry.mode
1083                ));
1084            }
1085            let size = entry.size.unwrap_or(0);
1086            validate_corpus_rel(rel, &display)?;
1087            out.push(SourceFile {
1088                rel: rel.to_string(),
1089                size,
1090                display,
1091            });
1092        }
1093        Ok(out)
1094    }
1095
1096    fn read_bytes(&self, file: &SourceFile) -> Result<Vec<u8>, String> {
1097        let path = format!("{}/{}", self.prefix, file.rel);
1098        let bytes = self
1099            .repo
1100            .show_file(self.oid, &path)
1101            .map_err(|e| format!("{} cannot be read: {e}", file.display))?
1102            .ok_or_else(|| {
1103                format!(
1104                    "{} vanished between listing and read — refusing to continue",
1105                    file.display
1106                )
1107            })?;
1108        if bytes.len() as u64 > MAX_STANDARDS_FILE_BYTES {
1109            return Err(format!(
1110                "{} is {} bytes, over the {MAX_STANDARDS_FILE_BYTES}-byte per-file cap",
1111                file.display,
1112                bytes.len()
1113            ));
1114        }
1115        Ok(bytes)
1116    }
1117}
1118
1119/// The entries of one capability dir as (name, no-follow file type) pairs,
1120/// sorted by name for deterministic error and load order.
1121fn sorted_entries(
1122    dir: &cap_std::fs::Dir,
1123    display: &Path,
1124) -> Result<Vec<(String, cap_std::fs::FileType)>, String> {
1125    let mut out = Vec::new();
1126    let entries = dir
1127        .entries()
1128        .map_err(|e| format!("cannot list {}: {e}", display.display()))?;
1129    for entry in entries {
1130        let entry = entry.map_err(|e| format!("cannot list {}: {e}", display.display()))?;
1131        let name = entry.file_name().into_string().map_err(|_| {
1132            format!(
1133                "{} holds a non-UTF-8 file name — the standards corpus requires UTF-8 names",
1134                display.display()
1135            )
1136        })?;
1137        // symlink_metadata: the type of the ENTRY ITSELF, never its target.
1138        let meta = dir
1139            .symlink_metadata(&name)
1140            .map_err(|e| format!("{} cannot be stat'ed: {e}", display.join(&name).display()))?;
1141        out.push((name, meta.file_type()));
1142    }
1143    out.sort_by(|a, b| a.0.cmp(&b.0));
1144    Ok(out)
1145}
1146
1147/// The corpus name charset: ASCII alphanumerics plus `.`, `_`, `-`, never
1148/// dot-leading (hidden files have no place in a policy corpus — a stray
1149/// `.DS_Store` fails the load rather than being quietly skipped).
1150fn check_entry_name(name: &str, display: &Path) -> Result<(), String> {
1151    if name.starts_with('.')
1152        || !name
1153            .chars()
1154            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1155    {
1156        return Err(format!(
1157            "{}: corpus entry names stay inside ASCII alphanumerics, `.`, `_`, `-` and \
1158             never start with `.`",
1159            display.display()
1160        ));
1161    }
1162    Ok(())
1163}
1164
1165/// The git-source shape check for one root-relative path: `<dir>/rfc.md` or
1166/// `<dir>/rules/<RULE>.md` — anything else is named and refused, exactly
1167/// like the filesystem walk refuses unexpected shapes.
1168fn validate_corpus_rel(rel: &str, display: &str) -> Result<(), String> {
1169    let parts: Vec<&str> = rel.split('/').collect();
1170    for part in &parts {
1171        if part.starts_with('.')
1172            || part.is_empty()
1173            || !part
1174                .chars()
1175                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1176        {
1177            return Err(format!(
1178                "{display}: corpus entry names stay inside ASCII alphanumerics, `.`, `_`, \
1179                 `-` and never start with `.`"
1180            ));
1181        }
1182    }
1183    let well_formed = match parts.as_slice() {
1184        [_dir, file] => *file == "rfc.md",
1185        [_dir, rules, file] => *rules == "rules" && file.ends_with(".md"),
1186        _ => false,
1187    };
1188    if !well_formed {
1189        return Err(format!(
1190            "{display}: unexpected path shape — the corpus holds `<RFC-dir>/rfc.md` and \
1191             `<RFC-dir>/rules/<rule>.md` only"
1192        ));
1193    }
1194    Ok(())
1195}
1196
1197// ---------------------------------------------------------------------------
1198// The load pipeline: list → shape → parse → validate → normalize → digest
1199// ---------------------------------------------------------------------------
1200
1201/// Load one corpus from either source into the normalized manifest. Every
1202/// failure names the offending file/field; every cap fails promptly.
1203fn load_from_source<S: CorpusSource>(
1204    source: &S,
1205    root: &str,
1206    gates: &[PackGateDecl],
1207    trust: StandardsTrust,
1208) -> Result<StandardsManifest, String> {
1209    let mut listing = source.list_files()?;
1210    listing.sort_by(|a, b| a.rel.cmp(&b.rel));
1211    if listing.len() > MAX_STANDARDS_FILES {
1212        return Err(format!(
1213            "[standards] root `{root}` holds {} files, over the {}-file cap",
1214            listing.len(),
1215            MAX_STANDARDS_FILES
1216        ));
1217    }
1218    // The per-file cap fails at LISTING time (before any byte is read);
1219    // `read_bytes` re-checks while reading, closing the grow-after-stat race.
1220    for file in &listing {
1221        if file.size > MAX_STANDARDS_FILE_BYTES {
1222            return Err(format!(
1223                "{} is {} bytes, over the {}-byte per-file cap",
1224                file.display, file.size, MAX_STANDARDS_FILE_BYTES
1225            ));
1226        }
1227    }
1228    // Group by RFC directory (sorted iteration via BTreeMap): each must
1229    // carry exactly one rfc.md; rules/ files group under it.
1230    let mut groups: BTreeMap<String, (Option<&SourceFile>, Vec<&SourceFile>)> = BTreeMap::new();
1231    for file in &listing {
1232        let parts: Vec<&str> = file.rel.split('/').collect();
1233        let (dir, is_rfc) = match parts.as_slice() {
1234            [dir, name] if *name == "rfc.md" => ((*dir).to_string(), true),
1235            [dir, rules, name] if *rules == "rules" && name.ends_with(".md") => {
1236                ((*dir).to_string(), false)
1237            }
1238            _ => {
1239                return Err(format!(
1240                    "{}: unexpected path shape — the corpus holds `<RFC-dir>/rfc.md` and \
1241                     `<RFC-dir>/rules/<rule>.md` only",
1242                    file.display
1243                ))
1244            }
1245        };
1246        let group = groups.entry(dir).or_insert_with(|| (None, Vec::new()));
1247        if is_rfc {
1248            if group.0.is_some() {
1249                return Err(format!(
1250                    "{}: duplicate rfc.md in one RFC directory",
1251                    file.display
1252                ));
1253            }
1254            group.0 = Some(file);
1255        } else {
1256            group.1.push(file);
1257        }
1258    }
1259
1260    let mut rfcs = Vec::new();
1261    let mut rules = Vec::new();
1262    for (dir, (rfc_file, rule_files)) in &groups {
1263        let Some(rfc_file) = rfc_file else {
1264            return Err(format!(
1265                "standards RFC directory `{dir}` has rules but no rfc.md ({})",
1266                rule_files[0].display
1267            ));
1268        };
1269        let text = read_text(source, rfc_file)?;
1270        let fm = parse_frontmatter(&text, &rfc_file.display)?;
1271        rfcs.push(load_rfc(&fm, &rfc_file.display)?);
1272        for rule_file in rule_files {
1273            if rules.len() >= MAX_STANDARDS_RULES {
1274                return Err(format!(
1275                    "{}: the rule count exceeds the {}-rule cap",
1276                    rule_file.display, MAX_STANDARDS_RULES
1277                ));
1278            }
1279            let text = read_text(source, rule_file)?;
1280            let fm = parse_frontmatter(&text, &rule_file.display)?;
1281            let rule = load_rule(&fm, &rule_file.display)?;
1282            // A declared checker resolves against this pack's gates HERE,
1283            // where the file is known, so the refusal names the file (D-F).
1284            if let Some(Checker::Gate(id)) = &rule.checker {
1285                if !gates.iter().any(|g| &g.name == id) {
1286                    return Err(format!(
1287                        "{}: field `checker`: rule `{}` checker `gate:{id}` names no \
1288                         declared [[gate]] in this pack — the checker must resolve to a \
1289                         pack gate at load (D-F)",
1290                        rule_file.display, rule.id
1291                    ));
1292                }
1293            }
1294            rules.push(rule);
1295        }
1296    }
1297    assemble(root, rfcs, rules, gates, trust)
1298}
1299
1300/// Read one file as UTF-8 text (per-file cap re-checked inside the source).
1301fn read_text<S: CorpusSource>(source: &S, file: &SourceFile) -> Result<String, String> {
1302    let bytes = source.read_bytes(file)?;
1303    String::from_utf8(bytes).map_err(|_| {
1304        format!(
1305            "{} is not valid UTF-8 — corpus files are UTF-8 text",
1306            file.display
1307        )
1308    })
1309}
1310
1311/// Cross-file validation, normalization, and the digest (D-C/D-F).
1312fn assemble(
1313    root: &str,
1314    mut rfcs: Vec<RfcMeta>,
1315    mut rules: Vec<RuleMeta>,
1316    gates: &[PackGateDecl],
1317    trust: StandardsTrust,
1318) -> Result<StandardsManifest, String> {
1319    // One pack-wide ID namespace (D-C): an RFC and a rule sharing an ID is
1320    // as much a collision as two rules sharing one.
1321    let mut ids = HashSet::new();
1322    for rfc in &rfcs {
1323        if !ids.insert(rfc.id.as_str()) {
1324            return Err(format!(
1325                "duplicate standards id `{}` — RFC and rule IDs are pack-wide unique (D-C)",
1326                rfc.id
1327            ));
1328        }
1329    }
1330    for rule in &rules {
1331        if !ids.insert(rule.id.as_str()) {
1332            return Err(format!(
1333                "duplicate standards id `{}` — RFC and rule IDs are pack-wide unique (D-C)",
1334                rule.id
1335            ));
1336        }
1337    }
1338
1339    let status_of = |id: &str| rfcs.iter().find(|r| r.id == id).map(|r| r.status);
1340    let mut gate_bindings: BTreeMap<&str, &PackGateDecl> = BTreeMap::new();
1341    for rule in &rules {
1342        let Some(rfc_status) = status_of(&rule.rfc) else {
1343            return Err(format!(
1344                "rule `{}` names parent RFC `{}`, which does not exist in this pack — \
1345                 orphan rules fail the load (D-C)",
1346                rule.id, rule.rfc
1347            ));
1348        };
1349        let effective = if rule.status == RuleStatus::Retired {
1350            RfcStatus::Retired
1351        } else {
1352            rfc_status
1353        };
1354        // D-F: a checker that is DECLARED must resolve; a rule whose
1355        // effective status is approved/enforced must declare one (the
1356        // advisory period is mechanically evaluable). Drafts may omit it.
1357        if let Some(Checker::Gate(id)) = &rule.checker {
1358            let Some(gate) = gates.iter().find(|g| &g.name == id) else {
1359                return Err(format!(
1360                    "rule `{}` checker `gate:{id}` names no declared [[gate]] in this pack \
1361                     — the checker must resolve to a pack gate at load (D-F)",
1362                    rule.id
1363                ));
1364            };
1365            gate_bindings.insert(gate.name.as_str(), gate);
1366        }
1367        if matches!(effective, RfcStatus::Approved | RfcStatus::Enforced) && rule.checker.is_none()
1368        {
1369            return Err(format!(
1370                "rule `{}` is effectively {} (RFC `{}` is {}) but declares no checker — \
1371                 promotion to approved requires a valid typed binding; only draft rules \
1372                 may omit one (D-F)",
1373                rule.id,
1374                effective.as_str(),
1375                rule.rfc,
1376                rfc_status.as_str()
1377            ));
1378        }
1379        // D-A/D-J: blocking policy requires provable base history.
1380        if trust == StandardsTrust::External && effective == RfcStatus::Enforced {
1381            return Err(format!(
1382                "rule `{}` is effectively enforced but this pack is external/untracked — \
1383                 an external pack may supply approved advisory rules, never enforced ones, \
1384                 in this slice (D-A/D-J). Remedy: vendor the pack into the repo as a \
1385                 tracked, repo-relative packDir so its lifecycle is provable from base \
1386                 history",
1387                rule.id
1388            ));
1389        }
1390    }
1391
1392    rfcs.sort_by(|a, b| a.id.cmp(&b.id));
1393    rules.sort_by(|a, b| a.id.cmp(&b.id));
1394    let gate_bindings: Vec<PackGateDecl> =
1395        gate_bindings.values().map(|gate| (*gate).clone()).collect();
1396    let canonical = canonical_text(&rfcs, &rules, &gate_bindings);
1397    if canonical.len() > MAX_STANDARDS_NORMALIZED_BYTES {
1398        return Err(format!(
1399            "the normalized standards manifest is {} bytes, over the {}-byte cap",
1400            canonical.len(),
1401            MAX_STANDARDS_NORMALIZED_BYTES
1402        ));
1403    }
1404    let digest = Sha256::digest(canonical.as_bytes());
1405    let digest = digest
1406        .iter()
1407        .map(|b| format!("{b:02x}"))
1408        .collect::<String>();
1409    Ok(StandardsManifest {
1410        root: root.to_string(),
1411        rfcs,
1412        rules,
1413        gate_bindings,
1414        pack_gates: gates.to_vec(),
1415        digest,
1416        canonical,
1417    })
1418}
1419
1420/// The canonical normalized bytes (D-C): fixed field order, one value per
1421/// line, lists sorted and deduplicated with one element per line, `-` for
1422/// an absent optional scalar. Paths and prose never appear — identity is
1423/// frontmatter IDs only.
1424fn canonical_text(rfcs: &[RfcMeta], rules: &[RuleMeta], gates: &[PackGateDecl]) -> String {
1425    fn list_lines(out: &mut String, indent: &str, items: &[String]) {
1426        for item in items {
1427            out.push_str(&format!("{indent}- {item}\n"));
1428        }
1429    }
1430
1431    let mut out = format!("{CANONICAL_HEADER}\n");
1432    for rfc in rfcs {
1433        out.push_str(&format!("rfc {}\n", rfc.id));
1434        out.push_str(&format!("  title: {}\n", rfc.title));
1435        out.push_str(&format!("  owner: {}\n", rfc.owner));
1436        out.push_str(&format!("  status: {}\n", rfc.status.as_str()));
1437        out.push_str(&format!(
1438            "  effective-at: {}\n",
1439            rfc.effective_at.as_deref().unwrap_or("-")
1440        ));
1441        out.push_str("  supersedes:\n");
1442        list_lines(&mut out, "    ", &rfc.supersedes);
1443    }
1444    for rule in rules {
1445        out.push_str(&format!("rule {}\n", rule.id));
1446        out.push_str(&format!("  revision: {}\n", rule.revision));
1447        out.push_str(&format!("  rfc: {}\n", rule.rfc));
1448        out.push_str(&format!("  level: {}\n", rule.level.as_str()));
1449        out.push_str(&format!("  status: {}\n", rule.status.as_str()));
1450        out.push_str(&format!("  statement: {}\n", rule.statement));
1451        out.push_str("  domains:\n");
1452        list_lines(&mut out, "    ", &rule.domains);
1453        out.push_str("  stages:\n");
1454        let stages: Vec<String> = rule.stages.iter().map(|s| s.as_str().to_string()).collect();
1455        list_lines(&mut out, "    ", &stages);
1456        out.push_str("  when-paths:\n");
1457        list_lines(&mut out, "    ", &rule.when_paths);
1458        out.push_str("  task-classes:\n");
1459        list_lines(&mut out, "    ", &rule.task_classes);
1460        out.push_str(&format!(
1461            "  checker: {}\n",
1462            rule.checker
1463                .as_ref()
1464                .map(Checker::render)
1465                .unwrap_or_else(|| "-".to_string())
1466        ));
1467        out.push_str(&format!("  waivable: {}\n", rule.waivable));
1468    }
1469    for gate in gates {
1470        out.push_str(&format!("gate {}\n", gate.name));
1471        out.push_str(&format!("  command: {}\n", gate.command));
1472        out.push_str("  when-paths:\n");
1473        list_lines(&mut out, "    ", &gate.when_paths);
1474    }
1475    out
1476}
1477
1478// ---------------------------------------------------------------------------
1479// The strict frontmatter subset (no YAML — see the module docs)
1480// ---------------------------------------------------------------------------
1481
1482/// One parsed field value: a scalar or an inline list.
1483#[derive(Debug, Clone, PartialEq, Eq)]
1484enum FieldValue {
1485    Scalar(String),
1486    List(Vec<String>),
1487}
1488
1489/// A parsed frontmatter block: fields in declared order (duplicates were
1490/// refused at parse time).
1491struct Frontmatter {
1492    fields: Vec<(String, FieldValue)>,
1493}
1494
1495impl Frontmatter {
1496    fn get(&self, key: &str) -> Option<&FieldValue> {
1497        self.fields.iter().find(|(k, _)| k == key).map(|(_, v)| v)
1498    }
1499
1500    /// Refuse any field the document type does not declare, naming file and
1501    /// field (the unknown-field failure class).
1502    fn check_unknown(&self, display: &str, known: &[&str]) -> Result<(), String> {
1503        for (key, _) in &self.fields {
1504            if !known.contains(&key.as_str()) {
1505                return Err(format!(
1506                    "{display}: unknown frontmatter field `{key}` (declared fields: {})",
1507                    known.join(", ")
1508                ));
1509            }
1510        }
1511        Ok(())
1512    }
1513
1514    fn scalar(&self, key: &str, display: &str) -> Result<Option<&str>, String> {
1515        match self.get(key) {
1516            Some(FieldValue::Scalar(value)) => Ok(Some(value.as_str())),
1517            Some(FieldValue::List(_)) => Err(format!(
1518                "{display}: field `{key}` must be a scalar, got a list"
1519            )),
1520            None => Ok(None),
1521        }
1522    }
1523
1524    fn required_scalar(&self, key: &str, display: &str) -> Result<String, String> {
1525        self.scalar(key, display)?
1526            .map(str::to_string)
1527            .ok_or_else(|| format!("{display}: missing required field `{key}`"))
1528    }
1529
1530    fn list(&self, key: &str, display: &str) -> Result<Option<&[String]>, String> {
1531        match self.get(key) {
1532            Some(FieldValue::List(items)) => Ok(Some(items.as_slice())),
1533            Some(FieldValue::Scalar(_)) => Err(format!(
1534                "{display}: field `{key}` must be a list (`{key}: [a, b]`), got a scalar"
1535            )),
1536            None => Ok(None),
1537        }
1538    }
1539
1540    fn required_list(&self, key: &str, display: &str) -> Result<Vec<String>, String> {
1541        self.list(key, display)?
1542            .map(<[String]>::to_vec)
1543            .ok_or_else(|| format!("{display}: missing required field `{key}`"))
1544    }
1545}
1546
1547/// Split a corpus file into its frontmatter fields, refusing everything the
1548/// strict subset does not declare. The Markdown body after the closing
1549/// fence is never parsed and never hashed (rationale, not authority).
1550fn parse_frontmatter(text: &str, display: &str) -> Result<Frontmatter, String> {
1551    // A UTF-8 BOM is normalized away rather than refused: Windows-authored
1552    // Markdown carries one and it changes no semantics.
1553    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
1554    let mut lines = text.lines().enumerate();
1555    let Some((_, first)) = lines.next() else {
1556        return Err(format!(
1557            "{display}: empty file — expected a `---` frontmatter fence"
1558        ));
1559    };
1560    if first != "---" {
1561        return Err(format!(
1562            "{display}: line 1 must be the `---` frontmatter fence, got `{first}`"
1563        ));
1564    }
1565    let mut fields: Vec<(String, FieldValue)> = Vec::new();
1566    for (idx, line) in lines {
1567        let line_no = idx + 1;
1568        if line == "---" {
1569            return Ok(Frontmatter { fields });
1570        }
1571        if line.trim().is_empty() {
1572            continue;
1573        }
1574        if line.starts_with(char::is_whitespace) {
1575            return Err(format!(
1576                "{display}: line {line_no}: unexpected indentation — the frontmatter \
1577                 subset has no nested or block values"
1578            ));
1579        }
1580        if line.contains('\t') {
1581            return Err(format!(
1582                "{display}: line {line_no}: tab characters are not in the frontmatter \
1583                 subset"
1584            ));
1585        }
1586        let Some(colon) = line.find(':') else {
1587            return Err(format!(
1588                "{display}: line {line_no}: expected `key: value`, got `{line}`"
1589            ));
1590        };
1591        let key = &line[..colon];
1592        if !is_kebab_key(key) {
1593            return Err(format!(
1594                "{display}: line {line_no}: unsupported field name `{key}` (lowercase \
1595                 kebab-case keys only)"
1596            ));
1597        }
1598        if fields.iter().any(|(k, _)| k == key) {
1599            return Err(format!(
1600                "{display}: line {line_no}: duplicate field `{key}`"
1601            ));
1602        }
1603        let raw = line[colon + 1..].trim();
1604        if raw.is_empty() {
1605            return Err(format!(
1606                "{display}: line {line_no}: field `{key}` has an empty value — omit \
1607                 optional fields instead (implicit null is not in the subset)"
1608            ));
1609        }
1610        let value = parse_value(raw, display, line_no, key)?;
1611        fields.push((key.to_string(), value));
1612    }
1613    Err(format!(
1614        "{display}: missing the closing `---` frontmatter fence"
1615    ))
1616}
1617
1618/// Kebab-case field names (`effective-at`, `when-paths`): lowercase ASCII
1619/// letters and digits, dash-separated, starting with a letter.
1620fn is_kebab_key(key: &str) -> bool {
1621    let mut parts = key.split('-');
1622    let valid_part = |part: &str| {
1623        !part.is_empty()
1624            && part
1625                .chars()
1626                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
1627    };
1628    match parts.next() {
1629        Some(first) if first.chars().next().is_some_and(|c| c.is_ascii_lowercase()) => {
1630            valid_part(first) && parts.all(valid_part)
1631        }
1632        _ => false,
1633    }
1634}
1635
1636/// Parse one field's raw value text into a scalar or list, refusing the
1637/// YAML constructs the subset does not support (each error names the
1638/// construct and the field).
1639fn parse_value(raw: &str, display: &str, line_no: usize, key: &str) -> Result<FieldValue, String> {
1640    let refusal = |what: &str| {
1641        format!(
1642            "{display}: line {line_no}: field `{key}`: {what} is not in the frontmatter \
1643             subset"
1644        )
1645    };
1646    match raw.chars().next() {
1647        Some('[') => parse_inline_list(raw, display, line_no, key).map(FieldValue::List),
1648        Some('"') => {
1649            let (value, rest) = parse_quoted(&raw[1..], display, line_no, key)?;
1650            check_trailing(rest, display, line_no, key)?;
1651            Ok(FieldValue::Scalar(value))
1652        }
1653        Some('\'') => Err(refusal("single-quoted strings (use double quotes)")),
1654        Some('&') => Err(refusal("anchors")),
1655        Some('*') => Err(refusal("aliases")),
1656        Some('!') => Err(refusal("tags")),
1657        Some('|' | '>') => Err(refusal(
1658            "block scalars (values are single-line; the statement is one line)",
1659        )),
1660        Some('{') => Err(refusal("flow mappings")),
1661        _ => {
1662            // A bare scalar runs to end-of-line; a ` #` starts a comment
1663            // (YAML-consistent) so a hash inside a value needs quotes.
1664            let cut = raw.find(" #").unwrap_or(raw.len());
1665            let value = raw[..cut].trim();
1666            if value.is_empty() {
1667                return Err(format!(
1668                    "{display}: line {line_no}: field `{key}` has an empty value — omit \
1669                     optional fields instead"
1670                ));
1671            }
1672            Ok(FieldValue::Scalar(value.to_string()))
1673        }
1674    }
1675}
1676
1677/// Whatever follows a quoted value or list: whitespace plus an optional `#`
1678/// comment, nothing else.
1679fn check_trailing(rest: &str, display: &str, line_no: usize, key: &str) -> Result<(), String> {
1680    let rest = rest.trim();
1681    if rest.is_empty() || rest.starts_with('#') {
1682        Ok(())
1683    } else {
1684        Err(format!(
1685            "{display}: line {line_no}: field `{key}` has trailing text after the value"
1686        ))
1687    }
1688}
1689
1690/// A double-quoted string with exactly two escapes (`\"` and `\\`) —
1691/// everything else is refused as an unsupported escape.
1692fn parse_quoted<'a>(
1693    text: &'a str,
1694    display: &str,
1695    line_no: usize,
1696    key: &str,
1697) -> Result<(String, &'a str), String> {
1698    let mut out = String::new();
1699    let mut chars = text.char_indices();
1700    while let Some((idx, c)) = chars.next() {
1701        match c {
1702            '"' => return Ok((out, &text[idx + 1..])),
1703            '\\' => match chars.next() {
1704                Some((_, '"')) => out.push('"'),
1705                Some((_, '\\')) => out.push('\\'),
1706                Some((_, other)) => {
1707                    return Err(format!(
1708                        "{display}: line {line_no}: field `{key}`: unsupported escape \
1709                         `\\{other}` (only `\\\"` and `\\\\` are in the subset)"
1710                    ))
1711                }
1712                None => break,
1713            },
1714            c => out.push(c),
1715        }
1716    }
1717    Err(format!(
1718        "{display}: line {line_no}: field `{key}`: unterminated quoted string"
1719    ))
1720}
1721
1722/// An inline list `[a, b, "c, d"]`: comma-separated bare or double-quoted
1723/// elements, optional trailing comma, no comments inside the brackets.
1724fn parse_inline_list(
1725    raw: &str,
1726    display: &str,
1727    line_no: usize,
1728    key: &str,
1729) -> Result<Vec<String>, String> {
1730    let mut items = Vec::new();
1731    let mut rest = &raw[1..];
1732    loop {
1733        rest = rest.trim_start();
1734        if let Some(after) = rest.strip_prefix(']') {
1735            check_trailing(after, display, line_no, key)?;
1736            return Ok(items);
1737        }
1738        if rest.is_empty() {
1739            return Err(format!(
1740                "{display}: line {line_no}: field `{key}`: unterminated `[` in list value"
1741            ));
1742        }
1743        if let Some(after) = rest.strip_prefix('"') {
1744            let (value, after) = parse_quoted(after, display, line_no, key)?;
1745            items.push(value);
1746            rest = after;
1747        } else {
1748            let end = rest.find([',', ']']).ok_or_else(|| {
1749                format!(
1750                    "{display}: line {line_no}: field `{key}`: unterminated `[` in \
1751                         list value"
1752                )
1753            })?;
1754            let element = rest[..end].trim();
1755            if element.is_empty() {
1756                return Err(format!(
1757                    "{display}: line {line_no}: field `{key}`: empty list element"
1758                ));
1759            }
1760            if element.contains(['#', '"', '[', '\'']) {
1761                return Err(format!(
1762                    "{display}: line {line_no}: field `{key}`: bare list element \
1763                     `{element}` contains a character the subset does not allow (quote \
1764                     the element)"
1765                ));
1766            }
1767            items.push(element.to_string());
1768            rest = &rest[end..];
1769        }
1770        rest = rest.trim_start();
1771        match rest.strip_prefix(',') {
1772            Some(after) => rest = after,
1773            None => {
1774                // Must now be `]` (handled at the loop top).
1775                if !rest.starts_with(']') {
1776                    return Err(format!(
1777                        "{display}: line {line_no}: field `{key}`: expected `,` or `]` in \
1778                         list value"
1779                    ));
1780                }
1781            }
1782        }
1783    }
1784}
1785
1786// ---------------------------------------------------------------------------
1787// Per-document semantic validation
1788// ---------------------------------------------------------------------------
1789
1790/// An RFC/rule ID: non-empty, ASCII alphanumerics plus `.`, `_`, `-`. IDs
1791/// are join keys for findings and trends (D-C), so their charset is tight.
1792fn is_valid_id(raw: &str) -> bool {
1793    !raw.is_empty()
1794        && raw
1795            .chars()
1796            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1797}
1798
1799fn required_id(fm: &Frontmatter, key: &str, display: &str) -> Result<String, String> {
1800    let id = fm.required_scalar(key, display)?;
1801    if !is_valid_id(&id) {
1802        return Err(format!(
1803            "{display}: field `{key}` is `{id}` — IDs use ASCII alphanumerics, `.`, `_`, \
1804             `-` only"
1805        ));
1806    }
1807    Ok(id)
1808}
1809
1810/// A `revision`: digit characters only (no sign, no YAML implicit-typing
1811/// surprises), value ≥ 1.
1812fn required_revision(fm: &Frontmatter, display: &str) -> Result<u64, String> {
1813    let raw = fm.required_scalar("revision", display)?;
1814    if raw.is_empty() || !raw.chars().all(|c| c.is_ascii_digit()) {
1815        return Err(format!(
1816            "{display}: field `revision` must be a positive integer, got `{raw}`"
1817        ));
1818    }
1819    match raw.parse::<u64>() {
1820        Ok(n) if n >= 1 => Ok(n),
1821        _ => Err(format!(
1822            "{display}: field `revision` must be a positive integer, got `{raw}`"
1823        )),
1824    }
1825}
1826
1827/// `waivable`: exactly `true`/`false` — implicit boolean typing (`yes`,
1828/// `True`) is refused, and the DEFAULT IS FALSE (D-I fails closed).
1829fn optional_bool(fm: &Frontmatter, key: &str, display: &str) -> Result<bool, String> {
1830    match fm.scalar(key, display)? {
1831        Some("true") => Ok(true),
1832        Some("false") | None => Ok(false),
1833        Some(other) => Err(format!(
1834            "{display}: field `{key}` must be exactly `true` or `false`, got `{other}`"
1835        )),
1836    }
1837}
1838
1839/// Sorted, deduplicated list normalization — authored order and duplicates
1840/// never reach the canonical bytes.
1841fn normalized_list(mut items: Vec<String>) -> Vec<String> {
1842    items.sort();
1843    items.dedup();
1844    items
1845}
1846
1847/// Validate and normalize one `rfc.md` frontmatter.
1848fn load_rfc(fm: &Frontmatter, display: &str) -> Result<RfcMeta, String> {
1849    fm.check_unknown(
1850        display,
1851        &[
1852            "id",
1853            "title",
1854            "owner",
1855            "status",
1856            "effective-at",
1857            "supersedes",
1858        ],
1859    )?;
1860    let id = required_id(fm, "id", display)?;
1861    let title = fm.required_scalar("title", display)?;
1862    let owner = fm.required_scalar("owner", display)?;
1863    let status_raw = fm.required_scalar("status", display)?;
1864    let status = RfcStatus::parse(&status_raw).ok_or_else(|| {
1865        format!(
1866            "{display}: field `status` is `{status_raw}` — RFC statuses are draft, \
1867             approved, enforced, retired (D-B)"
1868        )
1869    })?;
1870    let effective_at = match fm.scalar("effective-at", display)? {
1871        Some(raw) => {
1872            let parsed = chrono::DateTime::parse_from_rfc3339(raw).map_err(|_| {
1873                format!(
1874                    "{display}: field `effective-at` must be an RFC3339 timestamp, got \
1875                     `{raw}`"
1876                )
1877            })?;
1878            // Normalized to UTC seconds so byte-identical instants digest
1879            // identically however they were authored (`+00:00` vs `Z`).
1880            Some(
1881                parsed
1882                    .with_timezone(&chrono::Utc)
1883                    .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1884            )
1885        }
1886        None => None,
1887    };
1888    let mut supersedes = Vec::new();
1889    if let Some(items) = fm.list("supersedes", display)? {
1890        for item in items {
1891            if !is_valid_id(item) {
1892                return Err(format!(
1893                    "{display}: field `supersedes` element `{item}` is not a valid RFC id"
1894                ));
1895            }
1896            supersedes.push(item.clone());
1897        }
1898    }
1899    Ok(RfcMeta {
1900        id,
1901        title,
1902        owner,
1903        status,
1904        effective_at,
1905        supersedes: normalized_list(supersedes),
1906    })
1907}
1908
1909/// Validate and normalize one rule file's frontmatter.
1910fn load_rule(fm: &Frontmatter, display: &str) -> Result<RuleMeta, String> {
1911    fm.check_unknown(
1912        display,
1913        &[
1914            "id",
1915            "revision",
1916            "rfc",
1917            "level",
1918            "status",
1919            "statement",
1920            "domains",
1921            "stages",
1922            "when-paths",
1923            "task-classes",
1924            "checker",
1925            "waivable",
1926        ],
1927    )?;
1928    let id = required_id(fm, "id", display)?;
1929    let revision = required_revision(fm, display)?;
1930    let rfc = required_id(fm, "rfc", display)?;
1931    let level_raw = fm.required_scalar("level", display)?;
1932    let level = RuleLevel::parse(&level_raw).ok_or_else(|| {
1933        format!(
1934            "{display}: field `level` is `{level_raw}` — RFC-2119 levels are must and \
1935             should (D-B has no `may` row)"
1936        )
1937    })?;
1938    let status_raw = fm.required_scalar("status", display)?;
1939    let status = RuleStatus::parse(&status_raw).ok_or_else(|| {
1940        format!(
1941            "{display}: field `status` is `{status_raw}` — rule statuses are active and \
1942             retired (D-B)"
1943        )
1944    })?;
1945    let statement = fm.required_scalar("statement", display)?;
1946    let domains = normalized_list(fm.required_list("domains", display)?);
1947    let mut stages = Vec::new();
1948    for raw in fm.required_list("stages", display)? {
1949        let Some(stage) = RuleStage::parse(&raw) else {
1950            return Err(format!(
1951                "{display}: field `stages` element `{raw}` — stages are planning, \
1952                 implementation, validation, merge"
1953            ));
1954        };
1955        stages.push(stage);
1956    }
1957    if stages.is_empty() {
1958        return Err(format!(
1959            "{display}: field `stages` must list at least one stage — a rule applying \
1960             nowhere is not a rule"
1961        ));
1962    }
1963    // Sorted by NAME so authored order never reaches the canonical bytes.
1964    stages.sort_by_key(|s| s.as_str());
1965    stages.dedup();
1966    let mut when_paths = Vec::new();
1967    if let Some(items) = fm.list("when-paths", display)? {
1968        for item in items {
1969            let path = Path::new(item);
1970            if item.trim().is_empty()
1971                || path.is_absolute()
1972                || path.components().any(|part| {
1973                    !matches!(
1974                        part,
1975                        std::path::Component::CurDir | std::path::Component::Normal(_)
1976                    )
1977                })
1978            {
1979                return Err(format!(
1980                    "{display}: field `when-paths` entries must be repo-relative paths \
1981                     without parent components: {item:?}"
1982                ));
1983            }
1984            let normalized = crate::merge_gate::normalize_relative_path(item, false);
1985            if normalized.is_empty() || normalized == "." {
1986                return Err(format!(
1987                    "{display}: field `when-paths` entries must name a repo path — omit \
1988                     the field to leave the rule unscoped"
1989                ));
1990            }
1991            when_paths.push(normalized);
1992        }
1993    }
1994    let mut task_classes = Vec::new();
1995    if let Some(items) = fm.list("task-classes", display)? {
1996        task_classes.extend(items.iter().cloned());
1997    }
1998    let checker = match fm.scalar("checker", display)? {
1999        Some(raw) => {
2000            Some(Checker::parse(raw).map_err(|e| format!("{display}: field `checker`: {e}"))?)
2001        }
2002        None => None,
2003    };
2004    let waivable = optional_bool(fm, "waivable", display)?;
2005    Ok(RuleMeta {
2006        id,
2007        revision,
2008        rfc,
2009        level,
2010        status,
2011        statement,
2012        domains,
2013        stages,
2014        when_paths: normalized_list(when_paths),
2015        task_classes: normalized_list(task_classes),
2016        checker,
2017        waivable,
2018    })
2019}
2020
2021// ---------------------------------------------------------------------------
2022// Tests (KRZ-341 acceptance) — the `flight_rules_contract_` prefix is unique
2023// in the workspace (verified by grep before landing), so the contract filter
2024// `cargo test --workspace flight_rules_contract_` can never pass vacuously
2025// on a pre-existing test.
2026// ---------------------------------------------------------------------------
2027
2028#[cfg(test)]
2029mod tests {
2030    use super::*;
2031    use crate::pack::{SCHEMA_BASE, SCHEMA_CONTRACT, SCHEMA_STANDARDS};
2032
2033    /// A pack directory in a tempdir with the given pack.toml + corpus files.
2034    fn pack_with(manifest: &str, files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
2035        let tmp = tempfile::tempdir().expect("tempdir");
2036        let dir = tmp.path().join("pack");
2037        std::fs::create_dir_all(&dir).unwrap();
2038        std::fs::write(dir.join(PACK_MANIFEST), manifest).unwrap();
2039        for (rel, body) in files {
2040            let path = dir.join(rel);
2041            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2042            std::fs::write(path, body).unwrap();
2043        }
2044        (tmp, dir)
2045    }
2046
2047    /// The schema-4 manifest of the synthetic fixture pack: one declared
2048    /// gate (rule one's checker target), one standards root.
2049    const PACK_TOML: &str = r#"
2050[pack]
2051name = "zz-standards-pack"
2052schema = 4
2053
2054[standards]
2055root = "standards"
2056
2057[[gate]]
2058name = "zz-gate-one"
2059command = "cd ."
2060"#;
2061
2062    /// One approved RFC (effective-at authored non-UTC to pin the UTC
2063    /// normalization) — prose below the fence is never hashed.
2064    const RFC_MD: &str = "\
2065---
2066id: RFC-001
2067title: zz synthetic safety standard
2068status: approved
2069owner: zz-platform
2070effective-at: 2026-09-01T02:00:00+02:00
2071---
2072Prose rationale — never hashed.
2073";
2074
2075    const RULE_ONE: &str = "\
2076---
2077id: ZZ-RULE-001
2078revision: 1
2079rfc: RFC-001
2080level: must
2081status: active
2082statement: zz synthetic must statement one.
2083domains: [zz-domain]
2084stages: [implementation, validation]
2085checker: gate:zz-gate-one
2086waivable: false
2087---
2088Rule one prose.
2089";
2090
2091    /// Domains authored UNSORTED to pin the normalized (sorted, deduplicated)
2092    /// ordering; rule two also exercises every optional field.
2093    const RULE_TWO: &str = "\
2094---
2095id: ZZ-RULE-002
2096revision: 2
2097rfc: RFC-001
2098level: should
2099status: active
2100statement: zz synthetic should statement two.
2101domains: [zz-other, zz-domain]
2102stages: [planning, implementation, validation, merge]
2103when-paths: [crates/]
2104task-classes: [implementation]
2105checker: agent-judgement
2106---
2107Rule two prose.
2108";
2109
2110    const CORPUS: &[(&str, &str)] = &[
2111        ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2112        ("standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md", RULE_ONE),
2113        ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
2114    ];
2115
2116    fn synthetic_pack() -> (tempfile::TempDir, PathBuf) {
2117        pack_with(PACK_TOML, CORPUS)
2118    }
2119
2120    /// Load the fixture pack as a repo-tracked pack and return its standards
2121    /// manifest (the pack-level `standards` field is the eager-load proof).
2122    fn load_trusted(dir: &Path) -> StandardsManifest {
2123        crate::pack::Pack::load_with_trust(dir, StandardsTrust::RepoTracked)
2124            .expect("load")
2125            .expect("a pack")
2126            .standards
2127            .expect("a standards manifest")
2128    }
2129
2130    /// The canonical bytes of the synthetic fixture, pinned verbatim: any
2131    /// change to the normalization format breaks this test DELIBERATELY (the
2132    /// digest is an audit surface — silent format drift is unacceptable).
2133    const EXPECTED_CANONICAL: &str = "\
2134kranz-standards-manifest v1
2135rfc RFC-001
2136  title: zz synthetic safety standard
2137  owner: zz-platform
2138  status: approved
2139  effective-at: 2026-09-01T00:00:00Z
2140  supersedes:
2141rule ZZ-RULE-001
2142  revision: 1
2143  rfc: RFC-001
2144  level: must
2145  status: active
2146  statement: zz synthetic must statement one.
2147  domains:
2148    - zz-domain
2149  stages:
2150    - implementation
2151    - validation
2152  when-paths:
2153  task-classes:
2154  checker: gate:zz-gate-one
2155  waivable: false
2156rule ZZ-RULE-002
2157  revision: 2
2158  rfc: RFC-001
2159  level: should
2160  status: active
2161  statement: zz synthetic should statement two.
2162  domains:
2163    - zz-domain
2164    - zz-other
2165  stages:
2166    - implementation
2167    - merge
2168    - planning
2169    - validation
2170  when-paths:
2171    - crates
2172  task-classes:
2173    - implementation
2174  checker: agent-judgement
2175  waivable: false
2176gate zz-gate-one
2177  command: cd .
2178  when-paths:
2179";
2180
2181    /// sha256(EXPECTED_CANONICAL), pinned: the digest is an audit surface, so
2182    /// a normalization-format change must be deliberate and review-visible.
2183    const EXPECTED_DIGEST: &str =
2184        "8dfb505b203fea5f66286353defce0ef46118331fc45946b00b27c6bacd01df7";
2185
2186    #[test]
2187    fn flight_rules_contract_synthetic_pack_loads_byte_stable_manifest_and_digest() {
2188        let (_tmp, dir) = synthetic_pack();
2189        let manifest = load_trusted(&dir);
2190        assert_eq!(manifest.root, "standards");
2191        assert_eq!(manifest.rfcs.len(), 1);
2192        assert_eq!(manifest.rules.len(), 2);
2193        assert_eq!(manifest.gate_bindings.len(), 1);
2194        assert_eq!(manifest.canonical_text(), EXPECTED_CANONICAL);
2195        // sha256(EXPECTED_CANONICAL) — pinned so any normalization drift is
2196        // a deliberate, review-visible change.
2197        assert_eq!(manifest.digest, EXPECTED_DIGEST);
2198        // Spot-check the parsed metadata end to end.
2199        let rfc = &manifest.rfcs[0];
2200        assert_eq!(rfc.id, "RFC-001");
2201        assert_eq!(rfc.status, RfcStatus::Approved);
2202        assert_eq!(
2203            rfc.effective_at.as_deref(),
2204            Some("2026-09-01T00:00:00Z"),
2205            "effective-at normalizes to UTC seconds"
2206        );
2207        let rule = manifest.rule("ZZ-RULE-002").expect("rule two");
2208        assert_eq!(rule.revision, 2);
2209        assert_eq!(rule.level, RuleLevel::Should);
2210        assert_eq!(rule.domains, vec!["zz-domain", "zz-other"], "sorted");
2211        assert_eq!(rule.when_paths, vec!["crates"], "normalized, slash-free");
2212        assert_eq!(rule.checker, Some(Checker::AgentJudgement));
2213        assert!(!rule.waivable);
2214        assert_eq!(
2215            manifest.effective_status(rule),
2216            RfcStatus::Approved,
2217            "an active rule inherits its RFC's lifecycle (D-B)"
2218        );
2219    }
2220
2221    #[test]
2222    fn flight_rules_contract_renaming_files_and_dirs_preserves_identity_and_digest() {
2223        let (_tmp, dir) = synthetic_pack();
2224        let before = load_trusted(&dir);
2225        // Identity is frontmatter IDs, never paths (D-C): rename the rule
2226        // file AND the RFC directory — the digest must not move.
2227        let renamed = &[
2228            ("standards/RFC-001-renamed/rfc.md", RFC_MD),
2229            (
2230                "standards/RFC-001-renamed/rules/ZZ-RENAMED-001.md",
2231                RULE_ONE,
2232            ),
2233            ("standards/RFC-001-renamed/rules/ZZ-RULE-002.md", RULE_TWO),
2234        ];
2235        let (_tmp2, dir2) = pack_with(PACK_TOML, renamed);
2236        let after = load_trusted(&dir2);
2237        assert_eq!(before.digest, after.digest);
2238        assert_eq!(before, after, "paths are not identity (D-C)");
2239    }
2240
2241    #[test]
2242    fn flight_rules_contract_prose_edits_do_not_churn_the_digest() {
2243        let (_tmp, dir) = synthetic_pack();
2244        let before = load_trusted(&dir);
2245        let edited_rfc = RFC_MD.replace("never hashed", "EDITED rationale");
2246        let edited_rule = RULE_ONE.replace("Rule one prose.", "COMPLETELY NEW PROSE.");
2247        let corpus = &[
2248            ("standards/RFC-001-zz-safety/rfc.md", edited_rfc.as_str()),
2249            (
2250                "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2251                edited_rule.as_str(),
2252            ),
2253            ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
2254        ];
2255        let (_tmp2, dir2) = pack_with(PACK_TOML, corpus);
2256        let after = load_trusted(&dir2);
2257        assert_eq!(
2258            before.digest, after.digest,
2259            "prose is rationale, not a second machine authority (D-C)"
2260        );
2261    }
2262
2263    #[test]
2264    fn flight_rules_contract_changing_a_referenced_gate_declaration_changes_the_digest() {
2265        let (_tmp, dir) = synthetic_pack();
2266        let before = load_trusted(&dir);
2267        // The referenced checker's declaration is governing bytes (D-F):
2268        // editing its command must change the standards digest even though
2269        // no rule file moved.
2270        let manifest_toml = PACK_TOML.replace("command = \"cd .\"", "command = \"cd ..\"");
2271        let (_tmp2, dir2) = pack_with(&manifest_toml, CORPUS);
2272        let after = load_trusted(&dir2);
2273        assert_ne!(before.digest, after.digest);
2274        // An UNREFERENCED gate stays out of the digest: only the bindings
2275        // rules actually name are governing bytes.
2276        let with_extra_gate =
2277            format!("{PACK_TOML}\n[[gate]]\nname = \"zz-gate-two\"\ncommand = \"cd /\"\n");
2278        let (_tmp3, dir3) = pack_with(&with_extra_gate, CORPUS);
2279        let third = load_trusted(&dir3);
2280        assert_eq!(before.digest, third.digest);
2281    }
2282
2283    #[test]
2284    fn flight_rules_contract_standards_section_requires_schema_four() {
2285        for schema in [SCHEMA_BASE, SCHEMA_CONTRACT] {
2286            let manifest = format!(
2287                "[pack]\nname = \"x\"\nschema = {schema}\n\n[standards]\nroot = \"standards\"\n"
2288            );
2289            let (_tmp, dir) = pack_with(&manifest, &[]);
2290            let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2291            assert!(err.contains("[standards]"), "names the field: {err}");
2292            assert!(err.contains("schema"), "says why: {err}");
2293        }
2294    }
2295
2296    #[test]
2297    fn flight_rules_contract_standards_section_unknown_key_fails_closed() {
2298        let manifest =
2299            "[pack]\nname = \"x\"\nschema = 4\n\n[standards]\nroot = \"standards\"\nbogus = 1\n";
2300        let (_tmp, dir) = pack_with(manifest, &[]);
2301        let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2302        assert!(err.contains("unknown field `bogus`"), "{err}");
2303        assert!(err.contains("[standards]"), "{err}");
2304
2305        // A declared root that does not exist is a misconfiguration, not an
2306        // empty corpus.
2307        let manifest = "[pack]\nname = \"x\"\nschema = 4\n\n[standards]\nroot = \"missing\"\n";
2308        let (_tmp2, dir2) = pack_with(manifest, &[]);
2309        let err = crate::pack::Pack::load(&dir2).expect_err("must fail");
2310        assert!(err.contains("root `missing` does not exist"), "{err}");
2311
2312        // An escaping root is refused like every other pack path.
2313        let manifest = "[pack]\nname = \"x\"\nschema = 4\n\n[standards]\nroot = \"../outside\"\n";
2314        let (_tmp3, dir3) = pack_with(manifest, &[]);
2315        let err = crate::pack::Pack::load(&dir3).expect_err("must fail");
2316        assert!(err.contains("pack-relative path"), "{err}");
2317    }
2318
2319    #[test]
2320    fn flight_rules_contract_missing_and_unknown_frontmatter_fields_fail() {
2321        // Missing required field (statement), naming file and field.
2322        let rule = RULE_ONE.replace("statement: zz synthetic must statement one.\n", "");
2323        let (_tmp, dir) = pack_with(
2324            PACK_TOML,
2325            &[
2326                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2327                (
2328                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2329                    rule.as_str(),
2330                ),
2331            ],
2332        );
2333        let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2334        assert!(err.contains("missing required field `statement`"), "{err}");
2335        assert!(err.contains("ZZ-RULE-001.md"), "names the file: {err}");
2336
2337        // Unknown field, naming file and field.
2338        let rule = RULE_ONE.replace("waivable: false", "waivable: false\nbogus: nope");
2339        let (_tmp2, dir2) = pack_with(
2340            PACK_TOML,
2341            &[
2342                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2343                (
2344                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2345                    rule.as_str(),
2346                ),
2347            ],
2348        );
2349        let err = crate::pack::Pack::load(&dir2).expect_err("must fail");
2350        assert!(err.contains("unknown frontmatter field `bogus`"), "{err}");
2351        assert!(err.contains("ZZ-RULE-001.md"), "names the file: {err}");
2352
2353        // An RFC missing its owner.
2354        let rfc = RFC_MD.replace("owner: zz-platform\n", "");
2355        let (_tmp3, dir3) = pack_with(
2356            PACK_TOML,
2357            &[("standards/RFC-001-zz-safety/rfc.md", rfc.as_str())],
2358        );
2359        let err = crate::pack::Pack::load(&dir3).expect_err("must fail");
2360        assert!(err.contains("missing required field `owner`"), "{err}");
2361        assert!(err.contains("rfc.md"), "names the file: {err}");
2362    }
2363
2364    #[test]
2365    fn flight_rules_contract_invalid_level_status_and_stage_fail() {
2366        for (from, to, needle) in [
2367            ("level: must", "level: may", "field `level` is `may`"),
2368            (
2369                "status: active",
2370                "status: limbo",
2371                "field `status` is `limbo`",
2372            ),
2373            (
2374                "stages: [implementation, validation]",
2375                "stages: [implementation, guessing]",
2376                "field `stages` element `guessing`",
2377            ),
2378            (
2379                "waivable: false",
2380                "waivable: yes",
2381                "field `waivable` must be exactly `true` or `false`",
2382            ),
2383        ] {
2384            let rule = RULE_ONE.replace(from, to);
2385            let (_tmp, dir) = pack_with(
2386                PACK_TOML,
2387                &[
2388                    ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2389                    (
2390                        "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2391                        rule.as_str(),
2392                    ),
2393                ],
2394            );
2395            let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2396            assert!(err.contains(needle), "{from} → {to}: {err}");
2397            assert!(err.contains("ZZ-RULE-001.md"), "names the file: {err}");
2398        }
2399        // An invalid RFC status fails the same way.
2400        let rfc = RFC_MD.replace("status: approved", "status: wishful");
2401        let (_tmp, dir) = pack_with(
2402            PACK_TOML,
2403            &[("standards/RFC-001-zz-safety/rfc.md", rfc.as_str())],
2404        );
2405        let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2406        assert!(err.contains("field `status` is `wishful`"), "{err}");
2407    }
2408
2409    #[test]
2410    fn flight_rules_contract_duplicate_ids_fail() {
2411        // Two rules sharing an ID.
2412        let dupe = RULE_TWO.replace("ZZ-RULE-002", "ZZ-RULE-001");
2413        let (_tmp, dir) = pack_with(
2414            PACK_TOML,
2415            &[
2416                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2417                ("standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md", RULE_ONE),
2418                (
2419                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md",
2420                    dupe.as_str(),
2421                ),
2422            ],
2423        );
2424        let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2425        assert!(
2426            err.contains("duplicate standards id `ZZ-RULE-001`"),
2427            "{err}"
2428        );
2429
2430        // A rule ID colliding with an RFC ID: ONE pack-wide namespace (D-C).
2431        let dupe = RULE_ONE.replace("id: ZZ-RULE-001", "id: RFC-001");
2432        let (_tmp2, dir2) = pack_with(
2433            PACK_TOML,
2434            &[
2435                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2436                (
2437                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2438                    dupe.as_str(),
2439                ),
2440            ],
2441        );
2442        let err = crate::pack::Pack::load(&dir2).expect_err("must fail");
2443        assert!(err.contains("duplicate standards id `RFC-001`"), "{err}");
2444    }
2445
2446    #[test]
2447    fn flight_rules_contract_orphan_rule_fails() {
2448        let orphan = RULE_ONE.replace("rfc: RFC-001", "rfc: RFC-999");
2449        let (_tmp, dir) = pack_with(
2450            PACK_TOML,
2451            &[
2452                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2453                (
2454                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2455                    orphan.as_str(),
2456                ),
2457            ],
2458        );
2459        let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2460        assert!(err.contains("rule `ZZ-RULE-001`"), "{err}");
2461        assert!(err.contains("RFC-999"), "names the missing parent: {err}");
2462        assert!(err.contains("orphan"), "{err}");
2463    }
2464
2465    #[test]
2466    fn flight_rules_contract_bad_revision_and_checker_fail() {
2467        for (from, to, needle) in [
2468            (
2469                "revision: 1",
2470                "revision: 0",
2471                "field `revision` must be a positive integer",
2472            ),
2473            (
2474                "revision: 1",
2475                "revision: -2",
2476                "field `revision` must be a positive integer",
2477            ),
2478            (
2479                "revision: 1",
2480                "revision: two",
2481                "field `revision` must be a positive integer",
2482            ),
2483            (
2484                "checker: gate:zz-gate-one",
2485                "checker: gate:zz-undeclared",
2486                "names no declared [[gate]]",
2487            ),
2488            (
2489                "checker: gate:zz-gate-one",
2490                "checker: run-the-script",
2491                "supported checker forms",
2492            ),
2493            (
2494                "checker: gate:zz-gate-one",
2495                "checker: gate:",
2496                "supported checker forms",
2497            ),
2498        ] {
2499            let rule = RULE_ONE.replace(from, to);
2500            let (_tmp, dir) = pack_with(
2501                PACK_TOML,
2502                &[
2503                    ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2504                    (
2505                        "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2506                        rule.as_str(),
2507                    ),
2508                ],
2509            );
2510            let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2511            assert!(err.contains(needle), "{from} → {to}: {err}");
2512            assert!(err.contains("ZZ-RULE-001.md"), "names the file: {err}");
2513        }
2514    }
2515
2516    #[test]
2517    fn flight_rules_contract_approved_rule_requires_a_checker_draft_may_omit() {
2518        // RFC approved + rule without a checker: the advisory period must be
2519        // mechanically evaluable (D-F) — refused, naming the rule.
2520        let no_checker = RULE_ONE.replace("checker: gate:zz-gate-one\n", "");
2521        let (_tmp, dir) = pack_with(
2522            PACK_TOML,
2523            &[
2524                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2525                (
2526                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2527                    no_checker.as_str(),
2528                ),
2529            ],
2530        );
2531        let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2532        assert!(err.contains("rule `ZZ-RULE-001`"), "{err}");
2533        assert!(err.contains("declares no checker"), "{err}");
2534
2535        // The SAME rule under a DRAFT RFC loads: drafts may omit the binding.
2536        let draft_rfc = RFC_MD.replace("status: approved", "status: draft");
2537        let (_tmp2, dir2) = pack_with(
2538            PACK_TOML,
2539            &[
2540                ("standards/RFC-001-zz-safety/rfc.md", draft_rfc.as_str()),
2541                (
2542                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2543                    no_checker.as_str(),
2544                ),
2545            ],
2546        );
2547        let manifest = load_trusted(&dir2);
2548        assert_eq!(manifest.rules.len(), 1);
2549        assert_eq!(manifest.rules[0].checker, None);
2550        assert_eq!(
2551            manifest.effective_status(&manifest.rules[0]),
2552            RfcStatus::Draft
2553        );
2554    }
2555
2556    #[test]
2557    fn flight_rules_contract_retired_rule_may_omit_its_checker() {
2558        // A tombstone keeps its identity without a binding (D-B): retired
2559        // under an enforced RFC is still retired, never enforced.
2560        let enforced_rfc = RFC_MD.replace("status: approved", "status: enforced");
2561        let retired = RULE_ONE
2562            .replace("status: active", "status: retired")
2563            .replace("checker: gate:zz-gate-one\n", "");
2564        let (_tmp, dir) = pack_with(
2565            PACK_TOML,
2566            &[
2567                ("standards/RFC-001-zz-safety/rfc.md", enforced_rfc.as_str()),
2568                (
2569                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2570                    retired.as_str(),
2571                ),
2572            ],
2573        );
2574        let manifest = load_trusted(&dir);
2575        assert_eq!(
2576            manifest.effective_status(&manifest.rules[0]),
2577            RfcStatus::Retired,
2578            "the rule tombstone narrows an enforced RFC (D-B)"
2579        );
2580    }
2581
2582    #[test]
2583    fn flight_rules_contract_caps_fail_promptly() {
2584        // Per-file bytes: one oversized rule file trips the cap naming it.
2585        let big = format!(
2586            "{RULE_ONE}{}",
2587            "x".repeat(MAX_STANDARDS_FILE_BYTES as usize + 1)
2588        );
2589        let (_tmp, dir) = pack_with(
2590            PACK_TOML,
2591            &[
2592                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2593                (
2594                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2595                    big.as_str(),
2596                ),
2597            ],
2598        );
2599        let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2600        assert!(err.contains("per-file cap"), "{err}");
2601        assert!(err.contains("ZZ-RULE-001.md"), "names the file: {err}");
2602
2603        // File count: more corpus files than the cap (RFC-only dirs, so the
2604        // rule cap cannot fire first).
2605        let mut files: Vec<(String, String)> = Vec::new();
2606        for i in 0..=(MAX_STANDARDS_FILES) {
2607            files.push((
2608                format!("standards/RFC-D{i:04}/rfc.md"),
2609                RFC_MD.replace("RFC-001", &format!("RFC-D{i:04}")),
2610            ));
2611        }
2612        let refs: Vec<(&str, &str)> = files
2613            .iter()
2614            .map(|(p, b)| (p.as_str(), b.as_str()))
2615            .collect();
2616        let (_tmp2, dir2) = pack_with(PACK_TOML, &refs);
2617        let err = crate::pack::Pack::load(&dir2).expect_err("must fail");
2618        assert!(err.contains("file cap"), "{err}");
2619
2620        // Rule count: one RFC with more rule files than the rule cap.
2621        let mut files: Vec<(String, String)> = vec![(
2622            "standards/RFC-001-zz-safety/rfc.md".to_string(),
2623            RFC_MD.to_string(),
2624        )];
2625        for i in 0..=(MAX_STANDARDS_RULES) {
2626            let rule = RULE_ONE.replace("ZZ-RULE-001", &format!("ZZ-RULE-C{i:04}"));
2627            files.push((
2628                format!("standards/RFC-001-zz-safety/rules/ZZ-RULE-C{i:04}.md"),
2629                rule,
2630            ));
2631        }
2632        let refs: Vec<(&str, &str)> = files
2633            .iter()
2634            .map(|(p, b)| (p.as_str(), b.as_str()))
2635            .collect();
2636        let (_tmp3, dir3) = pack_with(PACK_TOML, &refs);
2637        let err = crate::pack::Pack::load(&dir3).expect_err("must fail");
2638        assert!(err.contains("rule cap"), "{err}");
2639
2640        // Total normalized bytes: many rules with long single-line
2641        // statements — under the per-file and count caps, over the total.
2642        let mut files: Vec<(String, String)> = vec![(
2643            "standards/RFC-001-zz-safety/rfc.md".to_string(),
2644            RFC_MD.to_string(),
2645        )];
2646        for i in 0..250usize {
2647            let rule = RULE_ONE
2648                .replace("ZZ-RULE-001", &format!("ZZ-RULE-N{i:04}"))
2649                .replace("zz synthetic must statement one.", &"s".repeat(4600));
2650            files.push((
2651                format!("standards/RFC-001-zz-safety/rules/ZZ-RULE-N{i:04}.md"),
2652                rule,
2653            ));
2654        }
2655        let refs: Vec<(&str, &str)> = files
2656            .iter()
2657            .map(|(p, b)| (p.as_str(), b.as_str()))
2658            .collect();
2659        let (_tmp4, dir4) = pack_with(PACK_TOML, &refs);
2660        let err = crate::pack::Pack::load(&dir4).expect_err("must fail");
2661        assert!(err.contains("normalized standards manifest"), "{err}");
2662    }
2663
2664    #[test]
2665    fn flight_rules_contract_invalid_utf8_fails() {
2666        let (_tmp, dir) = synthetic_pack();
2667        let path = dir.join("standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md");
2668        let mut bytes = RULE_ONE.as_bytes().to_vec();
2669        bytes.push(0xFF);
2670        bytes.push(0xFE);
2671        std::fs::write(&path, bytes).unwrap();
2672        let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2673        assert!(err.contains("not valid UTF-8"), "{err}");
2674        assert!(err.contains("ZZ-RULE-001.md"), "names the file: {err}");
2675    }
2676
2677    #[test]
2678    fn flight_rules_contract_frontmatter_subset_refuses_yaml_surprises() {
2679        for (line, needle) in [
2680            ("statement: &anchor text", "anchors"),
2681            ("statement: *alias", "aliases"),
2682            ("statement: !!str text", "tags"),
2683            ("statement: |", "block scalars"),
2684            ("statement: 'single'", "single-quoted strings"),
2685            ("statement: {flow: map}", "flow mappings"),
2686            ("statement:", "empty value"),
2687            ("domains: [zz-domain", "unterminated `[`"),
2688            ("domains: [zz-domain,, zz-other]", "empty list element"),
2689        ] {
2690            let rule = RULE_ONE.replace("statement: zz synthetic must statement one.", line);
2691            let rule = if line.starts_with("domains:") {
2692                RULE_ONE.replace("domains: [zz-domain]", line)
2693            } else {
2694                rule
2695            };
2696            let (_tmp, dir) = pack_with(
2697                PACK_TOML,
2698                &[
2699                    ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2700                    (
2701                        "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2702                        rule.as_str(),
2703                    ),
2704                ],
2705            );
2706            let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2707            assert!(err.contains(needle), "{line}: {err}");
2708            assert!(err.contains("ZZ-RULE-001.md"), "names the file: {err}");
2709        }
2710
2711        // A ` #` comment after a value is accepted (and never hashed): the
2712        // full fixture with only an added comment digests identically.
2713        let commented = RULE_ONE.replace(
2714            "statement: zz synthetic must statement one.",
2715            "statement: zz synthetic must statement one. # reviewed",
2716        );
2717        let (_tmp, dir) = pack_with(
2718            PACK_TOML,
2719            &[
2720                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2721                (
2722                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2723                    commented.as_str(),
2724                ),
2725                ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
2726            ],
2727        );
2728        let manifest = load_trusted(&dir);
2729        assert_eq!(manifest.digest, EXPECTED_DIGEST, "comments never govern");
2730
2731        // Indented (nested-looking) and block-list lines are not the subset.
2732        for bad_line in ["  status: active", "- status: active"] {
2733            let rule = RULE_ONE.replace("status: active", bad_line);
2734            let (_tmp, dir) = pack_with(
2735                PACK_TOML,
2736                &[
2737                    ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2738                    (
2739                        "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2740                        rule.as_str(),
2741                    ),
2742                ],
2743            );
2744            let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2745            assert!(
2746                err.contains("unexpected indentation") || err.contains("unsupported field name"),
2747                "{bad_line}: {err}"
2748            );
2749        }
2750
2751        // Duplicate fields and a missing closing fence fail closed.
2752        let dupe = RULE_ONE.replace("level: must", "level: must\nlevel: should");
2753        let (_tmp, dir) = pack_with(
2754            PACK_TOML,
2755            &[
2756                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2757                (
2758                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2759                    dupe.as_str(),
2760                ),
2761            ],
2762        );
2763        let err = crate::pack::Pack::load(&dir).expect_err("must fail");
2764        assert!(err.contains("duplicate field `level`"), "{err}");
2765
2766        let unfenced = RULE_ONE.replace("---\nRule one prose.", "");
2767        let (_tmp2, dir2) = pack_with(
2768            PACK_TOML,
2769            &[
2770                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2771                (
2772                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2773                    unfenced.as_str(),
2774                ),
2775            ],
2776        );
2777        let err = crate::pack::Pack::load(&dir2).expect_err("must fail");
2778        assert!(err.contains("closing `---`"), "{err}");
2779    }
2780
2781    #[test]
2782    fn flight_rules_contract_external_pack_cannot_activate_enforced_rules() {
2783        // Approved advisory rules load from an external pack…
2784        let (_tmp, dir) = synthetic_pack();
2785        let pack = crate::pack::Pack::load_with_trust(&dir, StandardsTrust::External)
2786            .expect("advisory loads")
2787            .expect("a pack");
2788        assert_eq!(pack.standards.as_ref().unwrap().rules.len(), 2);
2789
2790        // …but an effectively ENFORCED rule is a load error naming the
2791        // trust remedy (D-A/D-J).
2792        let enforced_rfc = RFC_MD.replace("status: approved", "status: enforced");
2793        let (_tmp2, dir2) = pack_with(
2794            PACK_TOML,
2795            &[
2796                ("standards/RFC-001-zz-safety/rfc.md", enforced_rfc.as_str()),
2797                ("standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md", RULE_ONE),
2798            ],
2799        );
2800        let err = crate::pack::Pack::load_with_trust(&dir2, StandardsTrust::External)
2801            .expect_err("must fail");
2802        assert!(err.contains("external/untracked"), "{err}");
2803        assert!(err.contains("rule `ZZ-RULE-001`"), "names the rule: {err}");
2804        assert!(
2805            err.contains("vendor the pack into the repo"),
2806            "names the remedy: {err}"
2807        );
2808        // The same bytes load as RepoTracked — the boundary is trust, not content.
2809        let pack = crate::pack::Pack::load_with_trust(&dir2, StandardsTrust::RepoTracked)
2810            .expect("tracked loads")
2811            .expect("a pack");
2812        assert_eq!(
2813            pack.standards.as_ref().unwrap().rfcs[0].status,
2814            RfcStatus::Enforced
2815        );
2816    }
2817
2818    #[test]
2819    fn flight_rules_contract_schema_two_and_three_packs_carry_no_standards() {
2820        // Byte-identical pre-KRZ-341 behavior: no standards field, and the
2821        // run-start describe()/lint lines keep their pre-standards shape.
2822        for schema in [SCHEMA_BASE, SCHEMA_CONTRACT] {
2823            let manifest = format!("[pack]\nname = \"zz-plain\"\nschema = {schema}\n");
2824            let (_tmp, dir) = pack_with(&manifest, &[]);
2825            let pack = crate::pack::Pack::load(&dir)
2826                .expect("load")
2827                .expect("a pack");
2828            assert_eq!(pack.schema, schema);
2829            assert!(pack.standards.is_none());
2830            assert!(!pack.describe().contains("standards"), "byte-identical");
2831            assert!(!crate::pack::render_lint(&pack).contains("digest"));
2832        }
2833        // Schema 4 without [standards] is valid and registers no corpus.
2834        let (_tmp, dir) = pack_with("[pack]\nname = \"zz-bare-four\"\nschema = 4\n", &[]);
2835        let pack = crate::pack::Pack::load(&dir)
2836            .expect("load")
2837            .expect("a pack");
2838        assert_eq!(pack.schema, SCHEMA_STANDARDS);
2839        assert!(pack.standards.is_none());
2840    }
2841
2842    #[test]
2843    fn flight_rules_contract_pack_lint_reports_the_standards_registration() {
2844        let (_tmp, dir) = synthetic_pack();
2845        let pack = crate::pack::Pack::load_with_trust(&dir, StandardsTrust::RepoTracked)
2846            .expect("load")
2847            .expect("a pack");
2848        let report = crate::pack::render_lint(&pack);
2849        assert!(
2850            report.contains("standards (schema 4 root `standards`)"),
2851            "{report}"
2852        );
2853        assert!(report.contains("digest: sha256:"), "{report}");
2854        assert!(
2855            report.contains("RFCs: 1 (draft 0, approved 1, enforced 0, retired 0)"),
2856            "{report}"
2857        );
2858        assert!(
2859            report.contains("rules: 2 (active 2, retired 0)"),
2860            "{report}"
2861        );
2862
2863        // The standards-lint render names every rule with its effective
2864        // status and checker binding.
2865        let manifest = pack.standards.as_ref().unwrap();
2866        let rendered = render_manifest(manifest, StandardsTrust::RepoTracked);
2867        assert!(rendered.contains("trust: repo-tracked"), "{rendered}");
2868        assert!(
2869            rendered.contains("ZZ-RULE-001 r1 — must, approved; checker gate:zz-gate-one"),
2870            "{rendered}"
2871        );
2872        assert!(rendered.contains("digest: sha256:"), "{rendered}");
2873    }
2874
2875    // ---- lifecycle transition lint --------------------------------------
2876
2877    /// Two on-disk packs (base + proposed) through the same loader, then the
2878    /// pure comparison — the engine half of `standards lint --against`.
2879    fn transitions(
2880        base_files: Option<&[(&str, &str)]>,
2881        proposed_files: &[(&str, &str)],
2882    ) -> Vec<String> {
2883        let base = base_files.map(|files| {
2884            let (tmp, dir) = pack_with(PACK_TOML, files);
2885            let manifest = load_trusted(&dir);
2886            drop(tmp);
2887            manifest
2888        });
2889        let (_tmp, dir) = pack_with(PACK_TOML, proposed_files);
2890        let proposed = load_trusted(&dir);
2891        check_transitions(base.as_ref(), &proposed)
2892    }
2893
2894    #[test]
2895    fn flight_rules_contract_transition_lint_refuses_absent_or_draft_to_enforced() {
2896        let enforced_rfc = RFC_MD.replace("status: approved", "status: enforced");
2897        let proposed = &[
2898            ("standards/RFC-001-zz-safety/rfc.md", enforced_rfc.as_str()),
2899            ("standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md", RULE_ONE),
2900        ];
2901        // Absent at the base (no standards at all).
2902        let errors = transitions(None, proposed);
2903        assert_eq!(errors.len(), 2, "RFC and rule both refuse: {errors:?}");
2904        assert!(
2905            errors
2906                .iter()
2907                .any(|e| e.contains("RFC `RFC-001`") && e.contains("absent/draft")),
2908            "{errors:?}"
2909        );
2910        assert!(
2911            errors
2912                .iter()
2913                .any(|e| e.contains("rule `ZZ-RULE-001`") && e.contains("absent")),
2914            "{errors:?}"
2915        );
2916
2917        // Draft at the base.
2918        let draft_rfc = RFC_MD.replace("status: approved", "status: draft");
2919        let draft_rule = RULE_ONE.replace("checker: gate:zz-gate-one\n", "");
2920        let base = &[
2921            ("standards/RFC-001-zz-safety/rfc.md", draft_rfc.as_str()),
2922            (
2923                "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2924                draft_rule.as_str(),
2925            ),
2926        ];
2927        let errors = transitions(Some(base), proposed);
2928        assert!(
2929            errors
2930                .iter()
2931                .any(|e| e.contains("RFC `RFC-001`") && e.contains("draft")),
2932            "{errors:?}"
2933        );
2934        assert!(
2935            errors
2936                .iter()
2937                .any(|e| e.contains("rule `ZZ-RULE-001`") && e.contains("draft")),
2938            "{errors:?}"
2939        );
2940    }
2941
2942    #[test]
2943    fn flight_rules_contract_transition_lint_refuses_semantic_change_without_revision_bump() {
2944        let changed = RULE_ONE.replace(
2945            "statement: zz synthetic must statement one.",
2946            "statement: zz REWRITTEN statement.",
2947        );
2948        let errors = transitions(
2949            Some(CORPUS),
2950            &[
2951                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2952                (
2953                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2954                    changed.as_str(),
2955                ),
2956                ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
2957            ],
2958        );
2959        assert_eq!(errors.len(), 1, "{errors:?}");
2960        assert!(errors[0].contains("rule `ZZ-RULE-001`"), "{errors:?}");
2961        assert!(
2962            errors[0].contains("statement"),
2963            "names the field: {errors:?}"
2964        );
2965        assert!(errors[0].contains("revision increment"), "{errors:?}");
2966
2967        // The same change WITH a bump is the reviewed path — accepted.
2968        let bumped = changed.replace("revision: 1", "revision: 2");
2969        let errors = transitions(
2970            Some(CORPUS),
2971            &[
2972                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2973                (
2974                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2975                    bumped.as_str(),
2976                ),
2977                ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
2978            ],
2979        );
2980        assert!(errors.is_empty(), "{errors:?}");
2981
2982        // A backwards revision is refused even with unchanged semantics.
2983        let base_bumped = RULE_ONE.replace("revision: 1", "revision: 5");
2984        let errors = transitions(
2985            Some(&[
2986                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
2987                (
2988                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
2989                    base_bumped.as_str(),
2990                ),
2991                ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
2992            ]),
2993            CORPUS,
2994        );
2995        assert!(
2996            errors.iter().any(|e| e.contains("moved backwards")),
2997            "{errors:?}"
2998        );
2999    }
3000
3001    #[test]
3002    fn flight_rules_contract_transition_lint_refuses_disappearing_ids_and_tombstone_reactivation() {
3003        // A rule present at the base is gone in the proposal.
3004        let errors = transitions(
3005            Some(CORPUS),
3006            &[
3007                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
3008                ("standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md", RULE_ONE),
3009            ],
3010        );
3011        assert_eq!(errors.len(), 1, "{errors:?}");
3012        assert!(errors[0].contains("rule `ZZ-RULE-002`"), "{errors:?}");
3013        assert!(errors[0].contains("cannot disappear"), "{errors:?}");
3014
3015        // A tombstone coming back active.
3016        let retired_base = RULE_ONE.replace("status: active", "status: retired");
3017        let errors = transitions(
3018            Some(&[
3019                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
3020                (
3021                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
3022                    retired_base.as_str(),
3023                ),
3024                ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
3025            ]),
3026            CORPUS,
3027        );
3028        assert!(
3029            errors
3030                .iter()
3031                .any(|e| e.contains("rule `ZZ-RULE-001`") && e.contains("tombstone")),
3032            "{errors:?}"
3033        );
3034    }
3035
3036    #[test]
3037    fn flight_rules_contract_transition_lint_accepts_reviewed_transitions() {
3038        // The designed path (D-B): approved at the base, enforced in the
3039        // proposal — the advisory period happened, so promotion is clean.
3040        let enforced_rfc = RFC_MD.replace("status: approved", "status: enforced");
3041        let errors = transitions(
3042            Some(CORPUS),
3043            &[
3044                ("standards/RFC-001-zz-safety/rfc.md", enforced_rfc.as_str()),
3045                ("standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md", RULE_ONE),
3046                ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
3047            ],
3048        );
3049        assert!(errors.is_empty(), "{errors:?}");
3050
3051        // Retirement needs no revision bump; the tombstone stays.
3052        let retired = RULE_ONE.replace("status: active", "status: retired");
3053        let errors = transitions(
3054            Some(CORPUS),
3055            &[
3056                ("standards/RFC-001-zz-safety/rfc.md", RFC_MD),
3057                (
3058                    "standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md",
3059                    retired.as_str(),
3060                ),
3061                ("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md", RULE_TWO),
3062            ],
3063        );
3064        assert!(errors.is_empty(), "{errors:?}");
3065
3066        // Unchanged corpus: no errors.
3067        let errors = transitions(Some(CORPUS), CORPUS);
3068        assert!(errors.is_empty(), "{errors:?}");
3069    }
3070
3071    // ---- git-ref sourcing (the `--against` byte origin) ------------------
3072
3073    /// A temp git repo holding the given files committed at HEAD; returns
3074    /// the TempDir, the repo root, and the opened GitRepo.
3075    fn git_repo_with_files(
3076        files: &[(String, String)],
3077    ) -> (tempfile::TempDir, PathBuf, crate::git_ops::GitRepo) {
3078        let tmp = tempfile::tempdir().unwrap();
3079        let root = tmp.path().join("repo");
3080        std::fs::create_dir_all(&root).unwrap();
3081        let git = |args: &[&str]| {
3082            let out = std::process::Command::new("git")
3083                .args(args)
3084                .current_dir(&root)
3085                .output()
3086                .expect("spawn git");
3087            assert!(out.status.success(), "git {args:?} failed: {out:?}");
3088        };
3089        git(&["init", "-q"]);
3090        git(&["config", "user.email", "t@t"]);
3091        git(&["config", "user.name", "t"]);
3092        for (rel, body) in files {
3093            let path = root.join(rel);
3094            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3095            std::fs::write(path, body).unwrap();
3096        }
3097        git(&["add", "."]);
3098        git(&["commit", "-qm", "pack"]);
3099        let repo = crate::git_ops::GitRepo::open(&root).expect("git repo");
3100        (tmp, root, repo)
3101    }
3102
3103    /// The fixture pack nested under `vendor/pack` inside the repo.
3104    fn vendored_files() -> Vec<(String, String)> {
3105        let mut files = vec![("vendor/pack/pack.toml".to_string(), PACK_TOML.to_string())];
3106        for (rel, body) in CORPUS {
3107            files.push((format!("vendor/pack/{rel}"), (*body).to_string()));
3108        }
3109        files
3110    }
3111
3112    #[test]
3113    fn flight_rules_contract_load_at_ref_reads_tracked_blobs_not_the_worktree() {
3114        let (_tmp, root, repo) = git_repo_with_files(&vendored_files());
3115
3116        // The base manifest comes from HEAD's tracked blobs; identical bytes
3117        // through the worktree source agree on the digest.
3118        let base = load_at_ref(&repo, "HEAD", "vendor/pack")
3119            .expect("base load")
3120            .expect("standards at HEAD");
3121        let worktree = crate::pack::Pack::load_with_trust(
3122            &root.join("vendor/pack"),
3123            StandardsTrust::RepoTracked,
3124        )
3125        .expect("worktree load")
3126        .expect("a pack");
3127        let worktree = worktree.standards.as_ref().unwrap();
3128        assert_eq!(base.digest, worktree.digest, "identical bytes agree");
3129
3130        // …so an UNCOMMITTED worktree edit is invisible to the base read —
3131        // a mission branch cannot reshape the policy judging it (D-A).
3132        let edited = RULE_ONE.replace(
3133            "statement: zz synthetic must statement one.",
3134            "statement: zz WORKTREE-ONLY EDIT.",
3135        );
3136        std::fs::write(
3137            root.join("vendor/pack/standards/RFC-001-zz-safety/rules/ZZ-RULE-001.md"),
3138            &edited,
3139        )
3140        .unwrap();
3141        let base_after = load_at_ref(&repo, "HEAD", "vendor/pack")
3142            .expect("base load")
3143            .expect("standards at HEAD");
3144        assert_eq!(base.digest, base_after.digest, "the base is pinned blobs");
3145        let dirty = crate::pack::Pack::load_with_trust(
3146            &root.join("vendor/pack"),
3147            StandardsTrust::RepoTracked,
3148        )
3149        .expect("worktree load")
3150        .expect("a pack");
3151        let dirty = dirty.standards.as_ref().unwrap();
3152        assert_ne!(
3153            base_after.digest, dirty.digest,
3154            "the worktree moved; the base did not"
3155        );
3156
3157        // The transition check composes: base from git, proposed from the
3158        // worktree, one refusal naming the rule and the missing bump.
3159        let errors = check_transitions(Some(&base_after), dirty);
3160        assert_eq!(errors.len(), 1, "{errors:?}");
3161        assert!(errors[0].contains("rule `ZZ-RULE-001`"), "{errors:?}");
3162        assert!(errors[0].contains("revision increment"), "{errors:?}");
3163
3164        // A ref whose tree has no pack yields no base.
3165        let git = |args: &[&str]| {
3166            let out = std::process::Command::new("git")
3167                .args(args)
3168                .current_dir(&root)
3169                .output()
3170                .expect("spawn git");
3171            assert!(out.status.success(), "git {args:?} failed: {out:?}");
3172        };
3173        git(&["rm", "-rqf", "vendor"]);
3174        git(&["commit", "-qm", "drop pack"]);
3175        assert_eq!(
3176            load_at_ref(&repo, "HEAD", "vendor/pack").expect("load"),
3177            None,
3178            "no pack at this ref"
3179        );
3180    }
3181
3182    #[cfg(unix)]
3183    #[test]
3184    fn flight_rules_contract_load_at_ref_refuses_a_tracked_symlink() {
3185        use std::os::unix::fs::symlink;
3186        let mut files = vendored_files();
3187        files.retain(|(p, _)| !p.ends_with("ZZ-RULE-002.md"));
3188        let (_tmp, root, repo) = git_repo_with_files(&files);
3189        // Commit a symlink where a rule file belongs (mode 120000 in the
3190        // tree) — the no-follow posture applies to tracked bytes too (D-J).
3191        symlink(
3192            "ZZ-RULE-001.md",
3193            root.join("vendor/pack/standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md"),
3194        )
3195        .unwrap();
3196        let git = |args: &[&str]| {
3197            let out = std::process::Command::new("git")
3198                .args(args)
3199                .current_dir(&root)
3200                .output()
3201                .expect("spawn git");
3202            assert!(out.status.success(), "git {args:?} failed: {out:?}");
3203        };
3204        git(&["add", "."]);
3205        git(&["commit", "-qm", "add symlink"]);
3206        let err = load_at_ref(&repo, "HEAD", "vendor/pack").expect_err("must fail");
3207        assert!(err.contains("symlink"), "{err}");
3208        assert!(err.contains("ZZ-RULE-002.md"), "names the path: {err}");
3209    }
3210
3211    #[test]
3212    fn flight_rules_contract_trust_for_dir_distinguishes_repo_tracked_from_external() {
3213        let (_tmp, root, _repo) = git_repo_with_files(&vendored_files());
3214        // A tracked, in-repo pack earns RepoTracked…
3215        assert_eq!(
3216            trust_for_dir(&root, &root.join("vendor/pack")),
3217            StandardsTrust::RepoTracked
3218        );
3219        // …an in-repo but UNTRACKED pack does not (no base history)…
3220        std::fs::create_dir_all(root.join("scratch/pack")).unwrap();
3221        std::fs::write(root.join("scratch/pack/pack.toml"), PACK_TOML).unwrap();
3222        assert_eq!(
3223            trust_for_dir(&root, &root.join("scratch/pack")),
3224            StandardsTrust::External
3225        );
3226        // …and neither does a pack outside the repo entirely.
3227        let outside = tempfile::tempdir().unwrap();
3228        let pack = outside.path().join("pack");
3229        std::fs::create_dir_all(&pack).unwrap();
3230        std::fs::write(pack.join(PACK_MANIFEST), PACK_TOML).unwrap();
3231        assert_eq!(
3232            trust_for_dir(&root, &pack),
3233            StandardsTrust::External,
3234            "outside the repo is external"
3235        );
3236    }
3237
3238    // ---- hostile filesystem shapes (unix: symlinks, FIFOs) ---------------
3239
3240    #[cfg(unix)]
3241    #[test]
3242    fn flight_rules_contract_symlinked_parents_and_leaves_fail_no_follow() {
3243        use std::os::unix::fs::symlink;
3244        // A symlinked RULE FILE (leaf).
3245        let (_tmp, dir) = synthetic_pack();
3246        let target = dir.join("outside.md");
3247        std::fs::write(&target, RULE_ONE).unwrap();
3248        let link = dir.join("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md");
3249        std::fs::remove_file(&link).unwrap();
3250        symlink(&target, &link).unwrap();
3251        let err = crate::pack::Pack::load(&dir).expect_err("must fail");
3252        assert!(err.contains("symlink"), "{err}");
3253        assert!(err.contains("never follows symlinks"), "{err}");
3254
3255        // A symlinked RFC DIRECTORY (parent).
3256        let (_tmp2, dir2) = synthetic_pack();
3257        let real = dir2.join("real-rfc");
3258        std::fs::rename(dir2.join("standards/RFC-001-zz-safety"), &real).unwrap();
3259        symlink(&real, dir2.join("standards/RFC-001-zz-safety")).unwrap();
3260        let err = crate::pack::Pack::load(&dir2).expect_err("must fail");
3261        assert!(err.contains("symlink"), "{err}");
3262
3263        // A FIFO in place of a rule file fails PROMPTLY (never blocks on open).
3264        let (_tmp3, dir3) = synthetic_pack();
3265        let fifo = dir3.join("standards/RFC-001-zz-safety/rules/ZZ-RULE-002.md");
3266        std::fs::remove_file(&fifo).unwrap();
3267        let status = std::process::Command::new("mkfifo")
3268            .arg(&fifo)
3269            .status()
3270            .expect("spawn mkfifo");
3271        assert!(status.success());
3272        let err = crate::pack::Pack::load(&dir3).expect_err("must fail");
3273        assert!(err.contains("not a regular file"), "{err}");
3274        assert!(err.contains("ZZ-RULE-002.md"), "names the file: {err}");
3275
3276        // A stray file directly under the root, and a subdirectory under
3277        // rules/, are both refused naming the shape.
3278        let (_tmp4, dir4) = synthetic_pack();
3279        std::fs::write(dir4.join("standards/notes.txt"), "stray").unwrap();
3280        let err = crate::pack::Pack::load(&dir4).expect_err("must fail");
3281        assert!(err.contains("not an RFC directory"), "{err}");
3282        let (_tmp5, dir5) = synthetic_pack();
3283        std::fs::create_dir_all(dir5.join("standards/RFC-001-zz-safety/rules/nested")).unwrap();
3284        let err = crate::pack::Pack::load(&dir5).expect_err("must fail");
3285        assert!(err.contains("no nested directories"), "{err}");
3286    }
3287}