Skip to main content

kranz_engine/pack/
resolution.rs

1//! Flight Rules deterministic resolution, approval pinning, and drift
2//! refusal (ticket `.kranz/tickets/flight-rules-resolution-pin.md`, KRZ-342;
3//! design `docs/scoping/flight-rules-engineering-standards.md`, decisions
4//! D-D, D-E, and D-G over the KRZ-341 schema-4 corpus).
5//!
6//! WHY the engine, not a model, selects (D-D): applicability is a pure
7//! function of declared rule metadata and three selection inputs — the
8//! workflow stage, the mission task class, and the touch paths — so the same
9//! inputs always select the same stable-sorted rules. Domains are
10//! browsing/reporting labels and are NEVER a selection input. Lifecycle is
11//! part of the predicate (D-B): `retired` rules never apply, `draft` rules
12//! apply only on lint/authoring surfaces (this module serves mission
13//! surfaces, so drafts never resolve here), and `approved`/`enforced` rules
14//! apply.
15//!
16//! WHY two touch-path interpretations: approval resolves against the
17//! APPROVED TOUCH SET — gitignore-style globs, not real paths — so a
18//! declared glob and a rule's `when-paths` prefix are compared by OVERLAP
19//! (either's literal extent sits at/below the other), a conservative
20//! SUPERSET: any actual path admitted by the touch set that a rule scopes to
21//! was already selected at approval. Final validation and merge resolve
22//! against REAL changed paths with the exact prefix match
23//! ([`crate::merge_gate::when_paths_match`]). The superset property is what
24//! makes "newly applicable at validation" mean "the mission escaped its
25//! approved envelope" rather than a matching artifact — an empty touch set
26//! selects no path-scoped rule at approval, so an enforced path-scoped rule
27//! the actual diff activates is correctly an escape.
28//!
29//! WHY the pin is engine-authored (D-E): the plan contract's
30//! `standardsManifest` is a consent artifact. Approval reloads the TRUSTED
31//! source — tracked base blobs for a repo-relative pack
32//! ([`crate::pack::standards::load_at_ref`], the merge-gates ownership idiom: a
33//! mission-branch edit is structurally invisible to it) or a single
34//! capability read for an external pack — resolves, and writes the pin. A
35//! plan CARRYING a manifest that differs from the fresh resolution is stale
36//! or substituted and approval rejects it; a plan carrying none is pinned by
37//! the engine (the planner never authors policy). Every later stage consumes
38//! the pin, never a later filesystem or branch read: an external pack edit
39//! after approval cannot change a run, and a mission that edits its own
40//! repo-relative pack is judged by the OLD base version.
41//!
42//! WHY drift refusal at merge (D-E): the approved pin records what the
43//! operator consented to. Merge re-resolves the LIVE base policy against the
44//! exact scratch integration diff; if the applicable ENFORCED set differs
45//! from the approved one — a rule added, removed, re-revised, re-scoped, or
46//! re-bound — merge refuses with `standards.drifted` rather than
47//! grandfather-skipping current policy or silently applying new policy to an
48//! old consent artifact. The comparison re-resolves BOTH sides with the same
49//! inputs (merge stage, the pin's task class, the integration paths), so an
50//! unchanged base policy can never false-positive.
51
52use super::standards::{
53    load_at_ref, Checker, RfcStatus, RuleMeta, RuleStage, StandardsManifest, StandardsTrust,
54};
55use crate::git_ops::GitRepo;
56use crate::types::{MissionConfig, PinnedGate, PinnedRule, StandardsPin, StandardsPinSource};
57use std::path::Path;
58
59/// The resolution surface recorded on `standards.resolved` for the
60/// approval-time pinning resolution. Stage-specific projections (KRZ-345)
61/// emit their own surfaces; this slice resolves the mission-wide set once,
62/// at approval.
63pub const APPROVAL_SURFACE: &str = "approval";
64
65/// How the touch-paths selection input is interpreted (D-D).
66pub enum TouchInput<'a> {
67    /// The approved touch-set globs (approval): a rule's `when-paths` apply
68    /// when any non-negated glob COULD admit a path at or below a declared
69    /// prefix — literal-stem overlap, the conservative superset.
70    Declared(&'a [String]),
71    /// Real changed paths (final validation, merge): exact at/below-prefix
72    /// matching, the merge-gate idiom.
73    Actual(&'a [String]),
74}
75
76// ---------------------------------------------------------------------------
77// The D-D predicate
78// ---------------------------------------------------------------------------
79
80/// The D-D applicability predicate over one rule's scope fields. A rule
81/// applies when (1) `stage` appears in its `stages`, (2) its `task_classes`
82/// is empty or contains the mission task class (routing normalization:
83/// trimmed, case-insensitive — a classless mission matches no scoped rule),
84/// and (3) its `when_paths` is empty or matches under `touch`'s
85/// interpretation. Domains are never consulted.
86fn rule_applies(
87    rule: &RuleMeta,
88    stage: RuleStage,
89    task_class: Option<&str>,
90    touch: &TouchInput,
91) -> bool {
92    if !rule.stages.contains(&stage) {
93        return false;
94    }
95    if !rule.task_classes.is_empty() {
96        let Some(task_class) = task_class else {
97            return false;
98        };
99        let wanted = crate::routing::normalize_task_class(task_class);
100        if !rule
101            .task_classes
102            .iter()
103            .any(|class| crate::routing::normalize_task_class(class) == wanted)
104        {
105            return false;
106        }
107    }
108    match touch {
109        TouchInput::Actual(paths) => crate::merge_gate::when_paths_match(&rule.when_paths, paths),
110        TouchInput::Declared(globs) => declared_touch_overlaps(&rule.when_paths, globs),
111    }
112}
113
114/// The declared touch-set interpretation (approval): `when_paths` empty
115/// matches everything; otherwise at least one non-negated glob's literal
116/// extent must overlap a declared prefix. Overlap is symmetric at/below on
117/// `/`-boundaries: `crates/**` overlaps `crates/engine` (the mission may
118/// reach it), `crates/engine/types.rs` overlaps `crates`, and `docs/**`
119/// overlaps neither. Negated (`!`) globs are ignored — they can only shrink
120/// the real envelope, so ignoring them keeps the selection a superset.
121fn declared_touch_overlaps(when_paths: &[String], globs: &[String]) -> bool {
122    if when_paths.is_empty() {
123        return true;
124    }
125    globs
126        .iter()
127        .filter(|glob| !glob.starts_with('!'))
128        .map(|glob| glob_literal_stem(glob))
129        .any(|stem| {
130            when_paths
131                .iter()
132                .any(|prefix| paths_overlap(&stem, prefix.trim_end_matches('/')))
133        })
134}
135
136/// The directory-bounded literal stem of a gitignore-style glob: everything
137/// before the first glob metachar (`*`, `?`, `[`, `{`, or an escape `\`),
138/// cut back to the last `/`. `crates/eng*/*.rs` stems to `crates`; `*.rs`
139/// stems to `""` (the repo root — which overlaps every prefix, the safe
140/// over-selection direction).
141fn glob_literal_stem(glob: &str) -> String {
142    let bytes = glob.as_bytes();
143    let mut end = bytes.len();
144    for (idx, byte) in bytes.iter().enumerate() {
145        if matches!(byte, b'*' | b'?' | b'[' | b'{' | b'\\') {
146            end = idx;
147            break;
148        }
149    }
150    let literal = &glob[..end];
151    match literal.rfind('/') {
152        Some(idx) => literal[..idx].to_string(),
153        None => String::new(),
154    }
155}
156
157/// `/`-boundary overlap between a glob stem and a rule prefix: equal, or one
158/// sits below the other. An empty side is the repo root and overlaps
159/// everything below it.
160fn paths_overlap(a: &str, b: &str) -> bool {
161    a.is_empty()
162        || b.is_empty()
163        || a == b
164        || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
165        || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
166}
167
168/// Whether a rule may be selected on a mission surface (D-B): effective
169/// `approved` or `enforced`. Retired never applies; draft is confined to
170/// lint/authoring surfaces, which this module never serves.
171fn selectable(manifest: &StandardsManifest, rule: &RuleMeta) -> bool {
172    matches!(
173        manifest.effective_status(rule),
174        RfcStatus::Approved | RfcStatus::Enforced
175    )
176}
177
178/// Resolve one stage's applicable rules over a live manifest (D-D):
179/// lifecycle-filtered and stable-sorted by id, so identical inputs always
180/// produce an identical selection.
181pub fn resolve(
182    manifest: &StandardsManifest,
183    stage: RuleStage,
184    task_class: Option<&str>,
185    touch: &TouchInput,
186) -> Vec<RuleMeta> {
187    let mut selected: Vec<RuleMeta> = manifest
188        .rules
189        .iter()
190        .filter(|rule| selectable(manifest, rule) && rule_applies(rule, stage, task_class, touch))
191        .cloned()
192        .collect();
193    selected.sort_by(|a, b| a.id.cmp(&b.id));
194    selected
195}
196
197/// The mission-wide applicable set pinned at approval (D-E/D-G's "one
198/// resolved set"): the union over the four workflow stages — a rule is
199/// selected when it applies at ANY of them. Stage projections later filter
200/// this set back down per stage.
201pub fn resolve_mission_set(
202    manifest: &StandardsManifest,
203    task_class: Option<&str>,
204    touch: &TouchInput,
205) -> Vec<RuleMeta> {
206    let mut selected: Vec<RuleMeta> = manifest
207        .rules
208        .iter()
209        .filter(|rule| {
210            selectable(manifest, rule)
211                && [
212                    RuleStage::Planning,
213                    RuleStage::Implementation,
214                    RuleStage::Validation,
215                    RuleStage::Merge,
216                ]
217                .iter()
218                .any(|stage| rule_applies(rule, *stage, task_class, touch))
219        })
220        .cloned()
221        .collect();
222    selected.sort_by(|a, b| a.id.cmp(&b.id));
223    selected
224}
225
226// ---------------------------------------------------------------------------
227// The pin (D-E)
228// ---------------------------------------------------------------------------
229
230/// Snapshot one resolved rule into its pinned form — the canonical spellings
231/// the plan contract carries as strings.
232fn pin_rule(manifest: &StandardsManifest, rule: &RuleMeta) -> PinnedRule {
233    PinnedRule {
234        id: rule.id.clone(),
235        revision: rule.revision,
236        rfc: rule.rfc.clone(),
237        level: rule.level.as_str().to_string(),
238        effective_status: manifest.effective_status(rule).as_str().to_string(),
239        statement: rule.statement.clone(),
240        domains: rule.domains.clone(),
241        stages: rule
242            .stages
243            .iter()
244            .map(RuleStage::as_str)
245            .map(str::to_string)
246            .collect(),
247        when_paths: rule.when_paths.clone(),
248        task_classes: rule.task_classes.clone(),
249        checker: rule.checker.as_ref().map(Checker::render),
250        waivable: rule.waivable,
251    }
252}
253
254/// Build the approval pin from a freshly resolved trusted manifest.
255pub fn pin_from_manifest(
256    manifest: &StandardsManifest,
257    source: StandardsPinSource,
258    pack_name: &str,
259    pack_dir: &str,
260    task_class: Option<&str>,
261    touch_set: &[String],
262) -> StandardsPin {
263    pin_from_manifest_with_context(
264        manifest,
265        source,
266        pack_name,
267        pack_dir,
268        task_class,
269        touch_set,
270        &[],
271    )
272}
273
274/// [`pin_from_manifest`] with read-only selection context. Context paths
275/// affect applicability but are not part of the mission's writable touch set.
276pub fn pin_from_manifest_with_context(
277    manifest: &StandardsManifest,
278    source: StandardsPinSource,
279    pack_name: &str,
280    pack_dir: &str,
281    task_class: Option<&str>,
282    touch_set: &[String],
283    context_paths: &[String],
284) -> StandardsPin {
285    let mut context_paths = context_paths.to_vec();
286    context_paths.sort();
287    context_paths.dedup();
288    let mut selection_paths = touch_set.to_vec();
289    selection_paths.extend(context_paths.iter().cloned());
290    let resolved = resolve_mission_set(
291        manifest,
292        task_class,
293        &TouchInput::Declared(&selection_paths),
294    );
295    StandardsPin {
296        pack_name: pack_name.to_string(),
297        pack_dir: pack_dir.to_string(),
298        standards_root: manifest.root.clone(),
299        digest: manifest.digest.clone(),
300        source,
301        task_class: task_class.map(crate::routing::normalize_task_class),
302        touch_set: touch_set.to_vec(),
303        context_paths,
304        gates: manifest
305            .pack_gates
306            .iter()
307            .map(|gate| PinnedGate {
308                id: gate.name.clone(),
309                command: gate.command.clone(),
310                when_paths: gate.when_paths.clone(),
311            })
312            .collect(),
313        rules: resolved
314            .iter()
315            .map(|rule| pin_rule(manifest, rule))
316            .collect(),
317    }
318}
319
320/// Re-resolve a PIN at one stage over `touch` — final validation and merge
321/// read the approved snapshot through this, never a live source. A pinned
322/// stage string that no longer parses scopes the rule to NO stage: the pin
323/// is engine-written, so an unparseable entry means a hand-edited plan, and
324/// the merge drift check then fails closed against the live side.
325pub fn resolve_pin(pin: &StandardsPin, stage: RuleStage, touch: &TouchInput) -> Vec<PinnedRule> {
326    let task_class = pin.task_class.as_deref();
327    let declared = matches!(touch, TouchInput::Declared(_));
328    let mut effective_paths = match touch {
329        TouchInput::Declared(paths) | TouchInput::Actual(paths) => paths.to_vec(),
330    };
331    effective_paths.extend(pin.context_paths.iter().cloned());
332    effective_paths.sort();
333    effective_paths.dedup();
334    let effective_touch = if declared {
335        TouchInput::Declared(&effective_paths)
336    } else {
337        TouchInput::Actual(&effective_paths)
338    };
339    let mut selected: Vec<PinnedRule> = pin
340        .rules
341        .iter()
342        .filter(|rule| {
343            let stages: Vec<RuleStage> = rule
344                .stages
345                .iter()
346                .filter_map(|name| RuleStage::parse(name))
347                .collect();
348            if stages.len() != rule.stages.len() || !stages.contains(&stage) {
349                return false;
350            }
351            if !rule.task_classes.is_empty() {
352                let Some(task_class) = task_class else {
353                    return false;
354                };
355                let wanted = crate::routing::normalize_task_class(task_class);
356                if !rule
357                    .task_classes
358                    .iter()
359                    .any(|class| crate::routing::normalize_task_class(class) == wanted)
360                {
361                    return false;
362                }
363            }
364            match &effective_touch {
365                TouchInput::Actual(paths) => {
366                    crate::merge_gate::when_paths_match(&rule.when_paths, paths)
367                }
368                TouchInput::Declared(globs) => declared_touch_overlaps(&rule.when_paths, globs),
369            }
370        })
371        .cloned()
372        .collect();
373    selected.sort_by(|a, b| a.id.cmp(&b.id));
374    selected
375}
376
377/// Actual changed paths plus approval-pinned, read-only context. The latter
378/// affects rule and checker applicability but never the contract write sweep.
379pub fn evaluation_paths(pin: &StandardsPin, actual_paths: &[String]) -> Vec<String> {
380    let mut paths = actual_paths.to_vec();
381    paths.extend(pin.context_paths.iter().cloned());
382    paths.sort();
383    paths.dedup();
384    paths
385}
386
387// ---------------------------------------------------------------------------
388// Approval: trusted-source load + stale/substituted rejection (D-E)
389// ---------------------------------------------------------------------------
390
391/// Resolve the standards pin for a plan approval: load the TRUSTED source,
392/// resolve the mission-wide applicable set against the plan's touch set, and
393/// reconcile with the manifest the plan already carries. Returns the pin to
394/// attach to the plan (`None` — and a byte-identical approval — when no
395/// standards-configured pack governs).
396///
397/// Trusted sources (D-A/D-E):
398/// - no `packDir` configured: no standards — a carried manifest is a
399///   substitution and rejected;
400/// - a repo-relative `packDir`: tracked blobs at `base_ref`
401///   ([`load_at_ref`]) — a mission-branch or worktree edit is invisible to
402///   this read. A malformed base corpus fails BEFORE any approval side
403///   effect. A worktree pack that DECLARES standards while the base has none
404///   is an untracked-policy attempt and is refused naming the remedy;
405/// - an absolute `packDir` (external/untracked): one capability read,
406///   `External` trust — an effectively enforced rule fails the load here
407///   (D-A/D-J: advisory-only until tracked or signed/versioned).
408///
409/// A carried manifest equal to the fresh resolution approves; a differing
410/// one is stale or substituted and is rejected naming both digests.
411pub fn approval_pin(
412    repo: &GitRepo,
413    cfg: &MissionConfig,
414    repo_root: &Path,
415    base_ref: &str,
416    task_class: Option<&str>,
417    carried: Option<&StandardsPin>,
418    touch_set: &[String],
419) -> Result<Option<StandardsPin>, String> {
420    approval_pin_with_context(
421        repo,
422        cfg,
423        repo_root,
424        base_ref,
425        task_class,
426        carried,
427        touch_set,
428        &[],
429    )
430}
431
432/// [`approval_pin`] with explicit read-only applicability context. Used by
433/// review-artifact consumers so rules scoped to the reviewed source are
434/// selected without authorizing that source for mutation.
435#[allow(clippy::too_many_arguments)]
436pub fn approval_pin_with_context(
437    repo: &GitRepo,
438    cfg: &MissionConfig,
439    repo_root: &Path,
440    base_ref: &str,
441    task_class: Option<&str>,
442    carried: Option<&StandardsPin>,
443    touch_set: &[String],
444    context_paths: &[String],
445) -> Result<Option<StandardsPin>, String> {
446    let Some(configured) = cfg.pack_dir.as_deref() else {
447        return match carried {
448            None => Ok(None),
449            Some(_) => Err(
450                "plan carries a standardsManifest but no packDir is configured — a \
451                 substituted manifest is never approved"
452                    .to_string(),
453            ),
454        };
455    };
456
457    let raw = Path::new(configured);
458    let fresh: Option<StandardsPin> = if raw.is_absolute() {
459        // External pack: capability-read once, here; the pin is the only
460        // authority from approval on (D-E). The loader applies External
461        // trust, so an effectively enforced rule is a refusal naming the
462        // trust remedy.
463        let pack =
464            super::Pack::load_with_trust(raw, StandardsTrust::External)?.ok_or_else(|| {
465                format!(
466                    "packDir `{configured}` resolves to {}, which has no {} — it is not a pack",
467                    raw.display(),
468                    super::PACK_MANIFEST
469                )
470            })?;
471        match &pack.standards {
472            None => None,
473            Some(manifest) => Some(pin_from_manifest_with_context(
474                manifest,
475                StandardsPinSource::ExternalPinned,
476                &pack.name,
477                configured,
478                task_class,
479                touch_set,
480                context_paths,
481            )),
482        }
483    } else {
484        super::validate_pack_relative_path(configured, "mission config", "packDir")?;
485        let pack_rel = crate::merge_gate::normalize_relative_path(configured, false);
486        match load_at_ref(repo, base_ref, &pack_rel)? {
487            Some(manifest) => {
488                let name = pack_name_at_ref(repo, base_ref, &pack_rel)?
489                    .unwrap_or_else(|| pack_rel.clone());
490                Some(pin_from_manifest_with_context(
491                    &manifest,
492                    StandardsPinSource::RepoTracked,
493                    &name,
494                    &pack_rel,
495                    task_class,
496                    touch_set,
497                    context_paths,
498                ))
499            }
500            None => {
501                // The base ref carries no standards for this packDir. If the
502                // WORKTREE pack declares a corpus anyway, the operator is
503                // pointing at an untracked pack — blocking policy needs base
504                // history (D-A), so refuse naming the remedy rather than
505                // silently running standards-free. A valid schema-2/3 pack
506                // stays byte-identical; a missing or malformed worktree pack
507                // fails approval here because its advisory contract cannot be
508                // established safely.
509                let worktree_declares_standards = match super::Pack::load_with_trust(
510                    &repo_root.join(raw),
511                    StandardsTrust::External,
512                ) {
513                    Ok(pack) => pack.is_some_and(|pack| pack.standards.is_some()),
514                    Err(error) => {
515                        return Err(format!(
516                            "packDir `{configured}` is not tracked on base branch `{base_ref}` \
517                             and its worktree pack cannot be accepted as advisory-only: {error}"
518                        ));
519                    }
520                };
521                if worktree_declares_standards {
522                    return Err(format!(
523                        "packDir `{configured}` declares a standards corpus but is not tracked \
524                         on base branch `{base_ref}` — policy a mission is judged by needs base \
525                         history: commit the pack to the base branch (a vendor-style tracked, \
526                         repo-relative pack), or point packDir at an absolute path for an \
527                         advisory-only external pack"
528                    ));
529                }
530                None
531            }
532        }
533    };
534
535    // The projection budget (KRZ-345): fail approval naming the excess BEFORE
536    // any approval side effect — never truncate an enforced rule to fit.
537    check_projection_budget(&fresh)?;
538
539    match (carried, fresh) {
540        (None, fresh) => Ok(fresh),
541        (Some(_), None) => Err(format!(
542            "plan carries a standardsManifest but no standards govern at the trusted source \
543             for packDir `{configured}` — a substituted manifest is never approved"
544        )),
545        (Some(carried), Some(fresh)) if *carried == fresh => Ok(Some(fresh)),
546        (Some(carried), Some(fresh)) => Err(format!(
547            "plan carries a stale or substituted standardsManifest (digest sha256:{}, {} \
548             rule(s)) — the trusted source for packDir `{configured}` resolves to sha256:{} \
549             ({} rule(s)); re-draft the plan against the current policy",
550            carried.digest,
551            carried.rules.len(),
552            fresh.digest,
553            fresh.rules.len()
554        )),
555    }
556}
557
558/// The projection-budget approval gate (KRZ-345, design D-D/D-J): the
559/// applicable normative statements must fit the hard projection caps, or
560/// approval FAILS naming the excess — an enforced rule is never silently
561/// dropped to fit a prompt budget. Checked against the fresh trusted
562/// resolution so every approval path (initial, carried, revised) fails
563/// closed on the same contract.
564fn check_projection_budget(fresh: &Option<StandardsPin>) -> Result<(), String> {
565    if let Some(pin) = fresh {
566        super::projection::check_budget(
567            pin.rules
568                .iter()
569                .map(|rule| (rule.id.as_str(), rule.statement.as_str())),
570        )?;
571    }
572    Ok(())
573}
574
575/// The pack name as committed at `refname` (the pin's display identity).
576/// `load_at_ref` deliberately returns only the standards manifest; the name
577/// is audit metadata, re-read here from the same tracked `pack.toml` rather
578/// than by widening the KRZ-341 loader's signature. `pub(crate)` for the
579/// KRZ-345 planning projection, which names the same source identity in the
580/// planner-facing seed header.
581pub(crate) fn pack_name_at_ref(
582    repo: &GitRepo,
583    refname: &str,
584    pack_rel_dir: &str,
585) -> Result<Option<String>, String> {
586    let manifest_rel = if pack_rel_dir.is_empty() {
587        super::PACK_MANIFEST.to_string()
588    } else {
589        format!("{pack_rel_dir}/{}", super::PACK_MANIFEST)
590    };
591    let Some(bytes) = repo
592        .show_file(refname, &manifest_rel)
593        .map_err(|e| format!("cannot read {manifest_rel} at `{refname}`: {e}"))?
594    else {
595        return Ok(None);
596    };
597    let text = String::from_utf8(bytes)
598        .map_err(|_| format!("{manifest_rel} at `{refname}` is not valid UTF-8"))?;
599    let doc =
600        super::toml::parse(&text).map_err(|e| format!("{manifest_rel} at `{refname}`: {e}"))?;
601    let (name, _schema) =
602        super::manifest_header(&doc).map_err(|e| format!("{manifest_rel} at `{refname}`: {e}"))?;
603    Ok(Some(name))
604}
605
606// ---------------------------------------------------------------------------
607// Final validation: the approved-envelope check (D-E)
608// ---------------------------------------------------------------------------
609
610/// The final-validation envelope check: re-read the pinned source snapshot
611/// (the mission's pinned `base_sha` — immutable, so exactly the bytes
612/// approval read) and resolve it against the ACTUAL changed paths. Any
613/// effectively ENFORCED rule that is applicable now but was not pinned is a
614/// newly applicable enforced rule: the mission escaped its approved policy
615/// envelope and must be revised/reapproved, not silently judged against a
616/// moving set. Returns the offending rules as pin-shaped snapshots.
617///
618/// External pins need no re-read: the pinned bytes are the only authority,
619/// and an external corpus can never carry enforced rules (the loader
620/// refuses them), so nothing can newly block.
621pub fn newly_applicable_enforced(
622    repo: &GitRepo,
623    base_sha: &str,
624    pin: &StandardsPin,
625    actual_paths: &[String],
626) -> Result<Vec<PinnedRule>, String> {
627    if pin.source == StandardsPinSource::ExternalPinned {
628        return Ok(Vec::new());
629    }
630    let manifest = load_at_ref(repo, base_sha, &pin.pack_dir)?.ok_or_else(|| {
631        format!(
632            "the pinned base {base_sha} no longer yields the approved standards pack `{}` \
633                 (pinned digest sha256:{}) — the approval snapshot is inconsistent; re-approve \
634                 the mission",
635            pin.pack_dir, pin.digest
636        )
637    })?;
638    if manifest.digest != pin.digest {
639        // Same immutable ref must give same bytes; a mismatch means the ref
640        // was rewritten — fail closed rather than compare against policy the
641        // operator never saw.
642        return Err(format!(
643            "the standards pack `{}` at the pinned base {base_sha} digests to sha256:{} but \
644             approval pinned sha256:{} — the base history moved under the mission; re-approve \
645             against the current policy",
646            pin.pack_dir, manifest.digest, pin.digest
647        ));
648    }
649    let task_class = pin.task_class.as_deref();
650    let evaluation_paths = evaluation_paths(pin, actual_paths);
651    let now = resolve_mission_set(
652        &manifest,
653        task_class,
654        &TouchInput::Actual(&evaluation_paths),
655    );
656    Ok(now
657        .iter()
658        .filter(|rule| manifest.effective_status(rule) == RfcStatus::Enforced)
659        .filter(|rule| !pin.rules.iter().any(|pinned| pinned.id == rule.id))
660        .map(|rule| pin_rule(&manifest, rule))
661        .collect())
662}
663
664// ---------------------------------------------------------------------------
665// Merge: live-base policy drift (D-E)
666// ---------------------------------------------------------------------------
667
668/// The merge-time drift verdict: the applicable ENFORCED set resolved from
669/// the live base differs from the approved pin's.
670#[derive(Debug, Clone, PartialEq, Eq)]
671pub struct DriftReport {
672    /// The digest pinned at approval.
673    pub approved_digest: String,
674    /// The digest resolved from the live base — `None` when the live base
675    /// no longer yields a readable standards manifest (removed or malformed:
676    /// the ultimate drift, failed closed).
677    pub current_digest: Option<String>,
678    /// Id-level change lines (added / removed / changed), stable-sorted.
679    pub changed_rules: Vec<String>,
680}
681
682/// The merge-time policy-drift check (D-E): re-resolve the LIVE base policy
683/// against the exact scratch integration diff and compare the applicable
684/// ENFORCED set against the approved pin's, both sides resolved with the
685/// same inputs (merge stage, the pin's task class, the integration paths) so
686/// an unchanged base can never false-positive. `Ok(None)` — no drift — lets
687/// the merge proceed. External pins skip entirely: advisory-only, and the
688/// pinned bytes remain the authority (a later filesystem edit cannot change
689/// a run).
690pub fn merge_drift(
691    repo: &GitRepo,
692    live_base_ref: &str,
693    pin: &StandardsPin,
694    integration_paths: &[String],
695) -> Result<Option<DriftReport>, String> {
696    if pin.source == StandardsPinSource::ExternalPinned {
697        return Ok(None);
698    }
699    let evaluation_paths = evaluation_paths(pin, integration_paths);
700    let approved_rules = resolve_pin(
701        pin,
702        RuleStage::Merge,
703        &TouchInput::Actual(&evaluation_paths),
704    );
705    let approved = enforced_snapshot(&approved_rules);
706    let approved_bindings = pinned_enforced_gate_snapshot(&approved_rules, &pin.gates);
707    let (current, current_bindings, current_digest) =
708        match load_at_ref(repo, live_base_ref, &pin.pack_dir) {
709            Ok(Some(manifest)) => {
710                let resolved = resolve(
711                    &manifest,
712                    RuleStage::Merge,
713                    pin.task_class.as_deref(),
714                    &TouchInput::Actual(&evaluation_paths),
715                );
716                let pinned: Vec<PinnedRule> = resolved
717                    .iter()
718                    .map(|rule| pin_rule(&manifest, rule))
719                    .collect();
720                let bindings = manifest_enforced_gate_snapshot(&pinned, &manifest.pack_gates);
721                (
722                    enforced_snapshot(&pinned),
723                    bindings,
724                    Some(manifest.digest.clone()),
725                )
726            }
727            Ok(None) => (
728                std::collections::BTreeMap::new(),
729                std::collections::BTreeMap::new(),
730                None,
731            ),
732            Err(error) => {
733                // A live base whose policy cannot be read must fail closed:
734                // merging under an unknowable enforced set is not an option.
735                return Ok(Some(DriftReport {
736                    approved_digest: pin.digest.clone(),
737                    current_digest: None,
738                    changed_rules: vec![format!(
739                        "live base standards pack `{}` failed to load: {error}",
740                        pin.pack_dir
741                    )],
742                }));
743            }
744        };
745    let mut changed = drift_lines(&approved, &current);
746    for (rule_id, approved_gate) in &approved_bindings {
747        match current_bindings.get(rule_id) {
748            Some(current_gate) if current_gate == approved_gate => {}
749            Some(_) => changed.push(format!(
750                "{rule_id} checker gate declaration changed on the live base since approval"
751            )),
752            None => changed.push(format!(
753                "{rule_id} checker gate declaration is missing on the live base"
754            )),
755        }
756    }
757    for rule_id in current_bindings.keys() {
758        if !approved_bindings.contains_key(rule_id) {
759            changed.push(format!(
760                "{rule_id} checker gate declaration is newly applicable on the live base"
761            ));
762        }
763    }
764    changed.sort();
765    changed.dedup();
766    if changed.is_empty() {
767        return Ok(None);
768    }
769    Ok(Some(DriftReport {
770        approved_digest: pin.digest.clone(),
771        current_digest,
772        changed_rules: changed,
773    }))
774}
775
776fn pinned_enforced_gate_snapshot(
777    rules: &[PinnedRule],
778    gates: &[crate::types::PinnedGate],
779) -> std::collections::BTreeMap<String, crate::types::PinnedGate> {
780    rules
781        .iter()
782        .filter(|rule| rule.effective_status == RfcStatus::Enforced.as_str())
783        .filter_map(|rule| {
784            let id = rule.checker.as_deref()?.strip_prefix("gate:")?;
785            gates
786                .iter()
787                .find(|gate| gate.id == id)
788                .cloned()
789                .map(|gate| (rule.id.clone(), gate))
790        })
791        .collect()
792}
793
794fn manifest_enforced_gate_snapshot(
795    rules: &[PinnedRule],
796    gates: &[super::PackGateDecl],
797) -> std::collections::BTreeMap<String, crate::types::PinnedGate> {
798    rules
799        .iter()
800        .filter(|rule| rule.effective_status == RfcStatus::Enforced.as_str())
801        .filter_map(|rule| {
802            let id = rule.checker.as_deref()?.strip_prefix("gate:")?;
803            gates.iter().find(|gate| gate.name == id).map(|gate| {
804                (
805                    rule.id.clone(),
806                    crate::types::PinnedGate {
807                        id: gate.name.clone(),
808                        command: gate.command.clone(),
809                        when_paths: gate.when_paths.clone(),
810                    },
811                )
812            })
813        })
814        .collect()
815}
816
817/// The applicable ENFORCED snapshot, keyed by rule id: the drift comparison
818/// unit. The full pinned rule is the value, so a revision bump, a statement
819/// or scope edit, a checker rebind, or a waiver-posture change all count —
820/// the merge must not rely on the lifecycle lint having run.
821fn enforced_snapshot(rules: &[PinnedRule]) -> std::collections::BTreeMap<String, PinnedRule> {
822    rules
823        .iter()
824        .filter(|rule| rule.effective_status == RfcStatus::Enforced.as_str())
825        .map(|rule| (rule.id.clone(), rule.clone()))
826        .collect()
827}
828
829/// Id-level change lines between two enforced snapshots, stable-sorted.
830fn drift_lines(
831    approved: &std::collections::BTreeMap<String, PinnedRule>,
832    current: &std::collections::BTreeMap<String, PinnedRule>,
833) -> Vec<String> {
834    let mut lines = Vec::new();
835    for (id, rule) in current {
836        match approved.get(id) {
837            None => lines.push(format!(
838                "{id} r{} (newly applicable enforced rule on the live base)",
839                rule.revision
840            )),
841            Some(before) if *before != *rule => lines.push(format!(
842                "{id} r{} -> r{} (changed on the live base since approval)",
843                before.revision, rule.revision
844            )),
845            Some(_) => {}
846        }
847    }
848    for id in approved.keys() {
849        if !current.contains_key(id) {
850            lines.push(format!(
851                "{id} r{} (approved enforced rule absent from the live base policy)",
852                approved[id].revision
853            ));
854        }
855    }
856    lines.sort();
857    lines
858}
859
860// ---------------------------------------------------------------------------
861// Review rendering (D-G: plan review sees the exact rules being accepted)
862// ---------------------------------------------------------------------------
863
864/// The plan.md section for a pin: source identity + digest, the selection
865/// inputs, and every applicable rule's id, revision, effective status,
866/// statement, scopes, and checker binding — the review surface D-G requires.
867pub fn render_pin_section(pin: &StandardsPin) -> String {
868    use std::fmt::Write as _;
869    let mut out = String::new();
870    let _ = writeln!(out, "## Flight Rules standards (approved manifest pin)\n");
871    let _ = writeln!(
872        out,
873        "Pack `{}` (`{}`, source {}) — standards root `{}`, digest `sha256:{}`.",
874        pin.pack_name,
875        pin.pack_dir,
876        pin.source.as_str(),
877        pin.standards_root,
878        pin.digest
879    );
880    if !pin.context_paths.is_empty() {
881        let _ = writeln!(
882            out,
883            "Read-only applicability context (not write authority): {}.\n",
884            pin.context_paths.join(", ")
885        );
886    }
887    let task_class = pin.task_class.as_deref().unwrap_or("(none)");
888    let touch_set = if pin.touch_set.is_empty() {
889        "(empty)".to_string()
890    } else {
891        pin.touch_set.join(", ")
892    };
893    let _ = writeln!(
894        out,
895        "Resolved with task class `{task_class}` over touch set: {touch_set}. \
896         This snapshot — not a later branch or filesystem read — governs every mission stage.\n"
897    );
898    if pin.rules.is_empty() {
899        let _ = writeln!(out, "No rules apply to this mission's selection inputs.");
900        return out;
901    }
902    for rule in &pin.rules {
903        let checker = rule.checker.as_deref().unwrap_or("-");
904        let _ = writeln!(
905            out,
906            "- **{} r{}** — {}, {}; checker `{}`; waivable: {}",
907            rule.id, rule.revision, rule.level, rule.effective_status, checker, rule.waivable
908        );
909        let _ = writeln!(out, "  - statement: {}", rule.statement);
910        let list = |items: &[String]| {
911            if items.is_empty() {
912                "-".to_string()
913            } else {
914                items.join(", ")
915            }
916        };
917        let _ = writeln!(
918            out,
919            "  - stages: {}; when-paths: {}; task-classes: {}; domains: {}",
920            list(&rule.stages),
921            list(&rule.when_paths),
922            list(&rule.task_classes),
923            list(&rule.domains)
924        );
925    }
926    out
927}
928
929// ---------------------------------------------------------------------------
930// Tests (ticket flight-rules-resolution-pin; anti-vacuity prefix
931// `flight_rules_pin_` — grep-verified unique to this ticket's tests)
932// ---------------------------------------------------------------------------
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937    use crate::events::EventKind;
938    use crate::pack::standards::StandardsTrust;
939    use std::path::PathBuf;
940
941    // ---- fixtures ---------------------------------------------------------
942
943    /// The schema-4 fixture manifest: one declared gate (checker target),
944    /// one standards root.
945    const PACK_TOML: &str = "[pack]\nname = \"zz-pin-pack\"\nschema = 4\n\n\
946                             [standards]\nroot = \"standards\"\n\n\
947                             [[gate]]\nname = \"zz-gate\"\ncommand = \"cd .\"\n";
948
949    fn rfc_md(id: &str, status: &str) -> String {
950        format!("---\nid: {id}\ntitle: zz fixture\nstatus: {status}\nowner: zz\n---\nprose\n")
951    }
952
953    #[allow(clippy::too_many_arguments)]
954    fn rule_md(
955        id: &str,
956        rfc: &str,
957        revision: u64,
958        level: &str,
959        status: &str,
960        stages: &str,
961        when_paths: Option<&str>,
962        task_classes: Option<&str>,
963        checker: Option<&str>,
964    ) -> String {
965        // `domains` is a required field (browsing labels only — selection
966        // never reads it, which several tests prove by NOT varying it).
967        let mut out = format!(
968            "---\nid: {id}\nrevision: {revision}\nrfc: {rfc}\nlevel: {level}\nstatus: \
969             {status}\nstatement: zz statement for {id}.\ndomains: [zz]\nstages: [{stages}]\n"
970        );
971        if let Some(paths) = when_paths {
972            out.push_str(&format!("when-paths: [{paths}]\n"));
973        }
974        if let Some(classes) = task_classes {
975            out.push_str(&format!("task-classes: [{classes}]\n"));
976        }
977        if let Some(checker) = checker {
978            out.push_str(&format!("checker: {checker}\n"));
979        }
980        out.push_str("---\nprose\n");
981        out
982    }
983
984    /// A pack dir with the given RFCs and rules; returns the TempDir (kept
985    /// alive by the caller) and the pack dir.
986    fn pack_dir_with(
987        rfcs: &[(&str, &str)],
988        rules: &[(&str, String)],
989    ) -> (tempfile::TempDir, PathBuf) {
990        let tmp = tempfile::tempdir().expect("tempdir");
991        let dir = tmp.path().join("pack");
992        std::fs::create_dir_all(&dir).unwrap();
993        std::fs::write(dir.join(super::super::PACK_MANIFEST), PACK_TOML).unwrap();
994        for (id, status) in rfcs {
995            let path = dir.join(format!("standards/{id}-slug/rfc.md"));
996            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
997            std::fs::write(path, rfc_md(id, status)).unwrap();
998        }
999        for (id, body) in rules {
1000            let rfc = body
1001                .lines()
1002                .find_map(|line| line.strip_prefix("rfc: "))
1003                .expect("rule fixture names its rfc")
1004                .to_string();
1005            let path = dir.join(format!("standards/{rfc}-slug/rules/{id}.md"));
1006            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1007            std::fs::write(path, body).unwrap();
1008        }
1009        (tmp, dir)
1010    }
1011
1012    /// Load a fixture pack's standards manifest as repo-tracked (the trust
1013    /// level that lets enforced rules activate).
1014    fn manifest_of(dir: &Path) -> StandardsManifest {
1015        crate::pack::Pack::load_with_trust(dir, StandardsTrust::RepoTracked)
1016            .expect("load")
1017            .expect("a pack")
1018            .standards
1019            .expect("a standards manifest")
1020    }
1021
1022    /// A temp git repo whose HEAD commits the given files; returns the
1023    /// TempDir, the root, and the opened repo (the standards.rs fixture
1024    /// idiom).
1025    fn git_repo_with_files(
1026        files: &[(String, String)],
1027    ) -> Option<(tempfile::TempDir, PathBuf, GitRepo)> {
1028        let tmp = tempfile::tempdir().unwrap();
1029        let root = tmp.path().join("repo");
1030        std::fs::create_dir_all(&root).unwrap();
1031        let init = std::process::Command::new("git")
1032            .args(["init", "-q", "-b", "main"])
1033            .current_dir(&root)
1034            .output()
1035            .ok()?;
1036        if !init.status.success() {
1037            crate::test_capability::skip(
1038                crate::test_capability::capability::GIT,
1039                "git is not on PATH",
1040            );
1041            return None;
1042        }
1043        let git = |args: &[&str]| {
1044            let out = std::process::Command::new("git")
1045                .args(args)
1046                .current_dir(&root)
1047                .output()
1048                .expect("spawn git");
1049            assert!(out.status.success(), "git {args:?} failed: {out:?}");
1050        };
1051        git(&["config", "user.email", "t@t"]);
1052        git(&["config", "user.name", "t"]);
1053        for (rel, body) in files {
1054            let path = root.join(rel);
1055            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1056            std::fs::write(path, body).unwrap();
1057        }
1058        git(&["add", "."]);
1059        git(&["commit", "-qm", "pack"]);
1060        let repo = GitRepo::open(&root).expect("git repo");
1061        Some((tmp, root, repo))
1062    }
1063
1064    /// Commit the fixture pack (one approved RFC, one enforced RFC with a
1065    /// gated must rule) vendored under `vendor/pack`, plus a seed file.
1066    fn vendored_pack_files(enforced_rfc_status: &str) -> Vec<(String, String)> {
1067        vec![
1068            ("README.md".to_string(), "seed\n".to_string()),
1069            ("vendor/pack/pack.toml".to_string(), PACK_TOML.to_string()),
1070            (
1071                "vendor/pack/standards/RFC-001-slug/rfc.md".to_string(),
1072                rfc_md("RFC-001", "approved"),
1073            ),
1074            (
1075                "vendor/pack/standards/RFC-001-slug/rules/ZZ-ADV-001.md".to_string(),
1076                rule_md(
1077                    "ZZ-ADV-001",
1078                    "RFC-001",
1079                    1,
1080                    "should",
1081                    "active",
1082                    "planning, implementation, validation, merge",
1083                    None,
1084                    None,
1085                    Some("agent-judgement"),
1086                ),
1087            ),
1088            (
1089                "vendor/pack/standards/RFC-002-slug/rfc.md".to_string(),
1090                rfc_md("RFC-002", enforced_rfc_status),
1091            ),
1092            (
1093                "vendor/pack/standards/RFC-002-slug/rules/ZZ-MUST-001.md".to_string(),
1094                rule_md(
1095                    "ZZ-MUST-001",
1096                    "RFC-002",
1097                    1,
1098                    "must",
1099                    "active",
1100                    "implementation, validation, merge",
1101                    Some("crates/"),
1102                    None,
1103                    Some("gate:zz-gate"),
1104                ),
1105            ),
1106        ]
1107    }
1108
1109    fn cfg_with_pack(pack_dir: Option<String>) -> MissionConfig {
1110        MissionConfig {
1111            pack_dir,
1112            ..MissionConfig::default()
1113        }
1114    }
1115
1116    // ---- D-D: deterministic selection -------------------------------------
1117
1118    #[test]
1119    fn flight_rules_pin_resolution_is_deterministic_and_stable_sorted() {
1120        let (_tmp, dir) = pack_dir_with(
1121            &[("RFC-001", "approved")],
1122            &[
1123                (
1124                    "ZZ-B-002",
1125                    rule_md(
1126                        "ZZ-B-002",
1127                        "RFC-001",
1128                        1,
1129                        "should",
1130                        "active",
1131                        "validation",
1132                        None,
1133                        None,
1134                        Some("agent-judgement"),
1135                    ),
1136                ),
1137                (
1138                    "ZZ-A-001",
1139                    rule_md(
1140                        "ZZ-A-001",
1141                        "RFC-001",
1142                        3,
1143                        "must",
1144                        "active",
1145                        "validation",
1146                        None,
1147                        None,
1148                        Some("agent-judgement"),
1149                    ),
1150                ),
1151            ],
1152        );
1153        let manifest = manifest_of(&dir);
1154        let touch = TouchInput::Actual(&["crates/x.rs".to_string()]);
1155        let first = resolve(
1156            &manifest,
1157            RuleStage::Validation,
1158            Some("implementation"),
1159            &touch,
1160        );
1161        let second = resolve(
1162            &manifest,
1163            RuleStage::Validation,
1164            Some("implementation"),
1165            &touch,
1166        );
1167        assert_eq!(first, second, "same inputs must select identically");
1168        let ids: Vec<&str> = first.iter().map(|r| r.id.as_str()).collect();
1169        assert_eq!(ids, ["ZZ-A-001", "ZZ-B-002"], "stable-sorted by id");
1170
1171        // The mission-wide set agrees on membership and order regardless of
1172        // which single stage was asked first.
1173        let mission = resolve_mission_set(
1174            &manifest,
1175            Some("implementation"),
1176            &TouchInput::Declared(&["crates/**".to_string()]),
1177        );
1178        let mission_ids: Vec<&str> = mission.iter().map(|r| r.id.as_str()).collect();
1179        assert_eq!(mission_ids, ["ZZ-A-001", "ZZ-B-002"]);
1180    }
1181
1182    #[test]
1183    fn flight_rules_pin_domains_never_select() {
1184        // The fixture rules all carry `domains: [zz]` — a label any browsing
1185        // query would "match" — yet selection must consult only stage, task
1186        // class, and when-paths (D-D).
1187        let (_tmp, dir) = pack_dir_with(
1188            &[("RFC-001", "approved")],
1189            &[(
1190                "ZZ-SCOPED",
1191                rule_md(
1192                    "ZZ-SCOPED",
1193                    "RFC-001",
1194                    1,
1195                    "must",
1196                    "active",
1197                    "validation",
1198                    Some("crates/"),
1199                    None,
1200                    Some("agent-judgement"),
1201                ),
1202            )],
1203        );
1204        let manifest = manifest_of(&dir);
1205        // Domain-relevant by label, path-irrelevant by scope: not selected.
1206        let miss = resolve(
1207            &manifest,
1208            RuleStage::Validation,
1209            None,
1210            &TouchInput::Actual(&["docs/readme.md".to_string()]),
1211        );
1212        assert!(miss.is_empty(), "domains never invoke selection: {miss:?}");
1213        let hit = resolve(
1214            &manifest,
1215            RuleStage::Validation,
1216            None,
1217            &TouchInput::Actual(&["crates/lib.rs".to_string()]),
1218        );
1219        assert_eq!(hit.len(), 1);
1220    }
1221
1222    #[test]
1223    fn flight_rules_pin_lifecycle_retired_and_draft_never_apply() {
1224        let (_tmp, dir) = pack_dir_with(
1225            &[
1226                ("RFC-001", "draft"),
1227                ("RFC-002", "enforced"),
1228                ("RFC-003", "retired"),
1229            ],
1230            &[
1231                (
1232                    "ZZ-DRAFT",
1233                    rule_md(
1234                        "ZZ-DRAFT",
1235                        "RFC-001",
1236                        1,
1237                        "must",
1238                        "active",
1239                        "validation",
1240                        None,
1241                        None,
1242                        None,
1243                    ),
1244                ),
1245                (
1246                    "ZZ-ENFORCED",
1247                    rule_md(
1248                        "ZZ-ENFORCED",
1249                        "RFC-002",
1250                        1,
1251                        "must",
1252                        "active",
1253                        "validation",
1254                        None,
1255                        None,
1256                        Some("gate:zz-gate"),
1257                    ),
1258                ),
1259                (
1260                    "ZZ-TOMBSTONE",
1261                    rule_md(
1262                        "ZZ-TOMBSTONE",
1263                        "RFC-002",
1264                        1,
1265                        "must",
1266                        "retired",
1267                        "validation",
1268                        None,
1269                        None,
1270                        None,
1271                    ),
1272                ),
1273                (
1274                    "ZZ-RETIRED",
1275                    rule_md(
1276                        "ZZ-RETIRED",
1277                        "RFC-003",
1278                        1,
1279                        "must",
1280                        "active",
1281                        "validation",
1282                        None,
1283                        None,
1284                        None,
1285                    ),
1286                ),
1287            ],
1288        );
1289        let manifest = manifest_of(&dir);
1290        let selected = resolve(
1291            &manifest,
1292            RuleStage::Validation,
1293            None,
1294            &TouchInput::Actual(&["crates/x.rs".to_string()]),
1295        );
1296        let ids: Vec<&str> = selected.iter().map(|r| r.id.as_str()).collect();
1297        assert_eq!(
1298            ids,
1299            ["ZZ-ENFORCED"],
1300            "draft-RFC rules, tombstones, and retired-RFC rules never apply on mission surfaces"
1301        );
1302        assert_eq!(manifest.effective_status(&selected[0]), RfcStatus::Enforced);
1303    }
1304
1305    #[test]
1306    fn flight_rules_pin_task_class_and_stage_scoping() {
1307        let (_tmp, dir) = pack_dir_with(
1308            &[("RFC-001", "approved")],
1309            &[
1310                (
1311                    "ZZ-IMPL",
1312                    rule_md(
1313                        "ZZ-IMPL",
1314                        "RFC-001",
1315                        1,
1316                        "should",
1317                        "active",
1318                        "implementation",
1319                        None,
1320                        Some("implementation"),
1321                        Some("agent-judgement"),
1322                    ),
1323                ),
1324                (
1325                    "ZZ-ANY",
1326                    rule_md(
1327                        "ZZ-ANY",
1328                        "RFC-001",
1329                        1,
1330                        "should",
1331                        "active",
1332                        "implementation",
1333                        None,
1334                        None,
1335                        Some("agent-judgement"),
1336                    ),
1337                ),
1338            ],
1339        );
1340        let manifest = manifest_of(&dir);
1341        let touch = TouchInput::Actual(&["crates/x.rs".to_string()]);
1342        // Routing normalization: case/trim-insensitive match on the class.
1343        let hit = resolve(
1344            &manifest,
1345            RuleStage::Implementation,
1346            Some("  Implementation "),
1347            &touch,
1348        );
1349        assert_eq!(hit.len(), 2);
1350        // A different class: the class-scoped rule drops out.
1351        let docs = resolve(&manifest, RuleStage::Implementation, Some("docs"), &touch);
1352        assert_eq!(docs.len(), 1);
1353        assert_eq!(docs[0].id, "ZZ-ANY");
1354        // A classless mission matches no class-scoped rule.
1355        let classless = resolve(&manifest, RuleStage::Implementation, None, &touch);
1356        assert_eq!(classless.len(), 1);
1357        // Wrong stage: neither applies.
1358        assert!(resolve(&manifest, RuleStage::Merge, Some("implementation"), &touch).is_empty());
1359    }
1360
1361    #[test]
1362    fn flight_rules_review_class_selects_only_its_artifact_policy() {
1363        let (_tmp, dir) = pack_dir_with(
1364            &[("RFC-001", "approved")],
1365            &[
1366                (
1367                    "ZZ-SPEC",
1368                    rule_md(
1369                        "ZZ-SPEC",
1370                        "RFC-001",
1371                        1,
1372                        "should",
1373                        "active",
1374                        "validation",
1375                        Some("docs/spec.md"),
1376                        Some("spec-review"),
1377                        Some("agent-judgement"),
1378                    ),
1379                ),
1380                (
1381                    "ZZ-INCIDENT",
1382                    rule_md(
1383                        "ZZ-INCIDENT",
1384                        "RFC-001",
1385                        1,
1386                        "should",
1387                        "active",
1388                        "validation",
1389                        Some("incidents/"),
1390                        Some("incident-review"),
1391                        Some("agent-judgement"),
1392                    ),
1393                ),
1394                (
1395                    "ZZ-IMPLEMENT",
1396                    rule_md(
1397                        "ZZ-IMPLEMENT",
1398                        "RFC-001",
1399                        1,
1400                        "should",
1401                        "active",
1402                        "validation",
1403                        None,
1404                        Some("implementation"),
1405                        Some("agent-judgement"),
1406                    ),
1407                ),
1408            ],
1409        );
1410        let manifest = manifest_of(&dir);
1411        let spec_pin = pin_from_manifest_with_context(
1412            &manifest,
1413            StandardsPinSource::RepoTracked,
1414            "zz",
1415            "vendor/zz",
1416            Some("spec-review"),
1417            &["reviews/spec.md".to_string()],
1418            &["docs/spec.md".to_string()],
1419        );
1420        assert_eq!(
1421            spec_pin
1422                .rules
1423                .iter()
1424                .map(|rule| rule.id.as_str())
1425                .collect::<Vec<_>>(),
1426            ["ZZ-SPEC"]
1427        );
1428        assert_eq!(spec_pin.context_paths, ["docs/spec.md"]);
1429        assert!(render_pin_section(&spec_pin)
1430            .contains("Read-only applicability context (not write authority): docs/spec.md"));
1431        let projected = resolve_pin(
1432            &spec_pin,
1433            RuleStage::Validation,
1434            &TouchInput::Actual(&["reviews/spec.md".to_string()]),
1435        );
1436        assert_eq!(projected.len(), 1);
1437        assert_eq!(projected[0].id, "ZZ-SPEC");
1438
1439        let incident = resolve(
1440            &manifest,
1441            RuleStage::Validation,
1442            Some("incident-review"),
1443            &TouchInput::Actual(&["incidents/42.md".to_string()]),
1444        );
1445        assert_eq!(incident.len(), 1);
1446        assert_eq!(incident[0].id, "ZZ-INCIDENT");
1447        let implementation = resolve(
1448            &manifest,
1449            RuleStage::Validation,
1450            Some("implementation"),
1451            &TouchInput::Actual(&["docs/spec.md".to_string()]),
1452        );
1453        assert_eq!(implementation.len(), 1);
1454        assert_eq!(implementation[0].id, "ZZ-IMPLEMENT");
1455    }
1456
1457    #[test]
1458    fn flight_rules_pin_declared_overlap_and_actual_prefix_matching() {
1459        let (_tmp, dir) = pack_dir_with(
1460            &[("RFC-001", "approved")],
1461            &[(
1462                "ZZ-ENG",
1463                rule_md(
1464                    "ZZ-ENG",
1465                    "RFC-001",
1466                    1,
1467                    "must",
1468                    "active",
1469                    "validation",
1470                    Some("crates/engine"),
1471                    None,
1472                    Some("gate:zz-gate"),
1473                ),
1474            )],
1475        );
1476        let manifest = manifest_of(&dir);
1477
1478        // Declared (approval) semantics: conservative overlap.
1479        for (globs, expect) in [
1480            (vec!["crates/**"], true),                  // mission may reach the prefix
1481            (vec!["crates/engine/**"], true),           // stem sits below the prefix
1482            (vec!["crates/engine/src/types.rs"], true), // exact path below
1483            (vec!["**"], true),                         // root glob admits everything
1484            (vec!["docs/**"], false),                   // disjoint
1485            (vec!["!crates/**"], false),                // a negation-only set admits nothing
1486            (vec![], false),                            // empty touch set: no path-scoped rule
1487        ] {
1488            let selected = resolve(
1489                &manifest,
1490                RuleStage::Validation,
1491                None,
1492                &TouchInput::Declared(&globs.iter().map(|s| s.to_string()).collect::<Vec<_>>()),
1493            );
1494            assert_eq!(
1495                !selected.is_empty(),
1496                expect,
1497                "declared touch set {globs:?} overlap must be {expect}"
1498            );
1499        }
1500
1501        // Actual (validation/merge) semantics: exact at/below prefix, the
1502        // merge-gate idiom — a sibling crate does NOT match.
1503        for (paths, expect) in [
1504            (vec!["crates/engine"], true),
1505            (vec!["crates/engine/src/types.rs"], true),
1506            (vec!["crates/cli/main.rs"], false),
1507            (vec!["crates/engine-extra/x.rs"], false), // not a /-boundary match
1508        ] {
1509            let selected = resolve(
1510                &manifest,
1511                RuleStage::Validation,
1512                None,
1513                &TouchInput::Actual(&paths.iter().map(|s| s.to_string()).collect::<Vec<_>>()),
1514            );
1515            assert_eq!(
1516                !selected.is_empty(),
1517                expect,
1518                "actual paths {paths:?} prefix match must be {expect}"
1519            );
1520        }
1521
1522        // The superset property the envelope check relies on: a path admitted
1523        // by the declared glob AND below the prefix was selected at approval.
1524        let declared = resolve(
1525            &manifest,
1526            RuleStage::Validation,
1527            None,
1528            &TouchInput::Declared(&["crates/**".to_string()]),
1529        );
1530        let actual = resolve(
1531            &manifest,
1532            RuleStage::Validation,
1533            None,
1534            &TouchInput::Actual(&["crates/engine/src/types.rs".to_string()]),
1535        );
1536        assert!(declared.len() >= actual.len());
1537    }
1538
1539    // ---- D-E: approval pinning --------------------------------------------
1540
1541    #[test]
1542    fn flight_rules_pin_approval_pins_from_trusted_base_and_rejects_stale() {
1543        let Some((_tmp, root, repo)) = git_repo_with_files(&vendored_pack_files("enforced")) else {
1544            return;
1545        };
1546        let cfg = cfg_with_pack(Some("vendor/pack".to_string()));
1547        let touch_set = vec!["crates/**".to_string()];
1548
1549        // Engine-authored pin: the plan carried nothing.
1550        let pin = approval_pin(
1551            &repo,
1552            &cfg,
1553            &root,
1554            "main",
1555            Some("implementation"),
1556            None,
1557            &touch_set,
1558        )
1559        .expect("pin")
1560        .expect("standards govern");
1561        assert_eq!(pin.pack_name, "zz-pin-pack");
1562        assert_eq!(pin.pack_dir, "vendor/pack");
1563        assert_eq!(pin.source, StandardsPinSource::RepoTracked);
1564        assert_eq!(pin.task_class.as_deref(), Some("implementation"));
1565        let ids: Vec<&str> = pin.rules.iter().map(|r| r.id.as_str()).collect();
1566        assert_eq!(ids, ["ZZ-ADV-001", "ZZ-MUST-001"]);
1567        let must = &pin.rules[1];
1568        assert_eq!(must.effective_status, "enforced");
1569        assert_eq!(must.checker.as_deref(), Some("gate:zz-gate"));
1570        assert_eq!(must.when_paths, vec!["crates".to_string()]);
1571
1572        // A carried manifest identical to the fresh resolution approves.
1573        let again = approval_pin(
1574            &repo,
1575            &cfg,
1576            &root,
1577            "main",
1578            Some("implementation"),
1579            Some(&pin),
1580            &touch_set,
1581        )
1582        .expect("a carried manifest equal to the trusted resolution approves");
1583        assert_eq!(again.as_ref(), Some(&pin));
1584
1585        // A stale or substituted carried manifest is rejected, naming both
1586        // digests.
1587        let mut stale = pin.clone();
1588        stale.digest = "0".repeat(64);
1589        let err = approval_pin(
1590            &repo,
1591            &cfg,
1592            &root,
1593            "main",
1594            Some("implementation"),
1595            Some(&stale),
1596            &touch_set,
1597        )
1598        .expect_err("a stale manifest must be rejected");
1599        assert!(err.contains("stale or substituted"), "{err}");
1600        assert!(err.contains(&pin.digest), "{err}");
1601
1602        // A carried manifest with no pack configured is a substitution.
1603        let err = approval_pin(
1604            &repo,
1605            &cfg_with_pack(None),
1606            &root,
1607            "main",
1608            Some("implementation"),
1609            Some(&pin),
1610            &touch_set,
1611        )
1612        .expect_err("a substituted manifest must be rejected");
1613        assert!(err.contains("no packDir is configured"), "{err}");
1614    }
1615
1616    #[test]
1617    fn flight_rules_pin_approval_ignores_mission_branch_pack_edit() {
1618        let Some((_tmp, root, repo)) = git_repo_with_files(&vendored_pack_files("enforced")) else {
1619            return;
1620        };
1621        let cfg = cfg_with_pack(Some("vendor/pack".to_string()));
1622        let pin = approval_pin(
1623            &repo,
1624            &cfg,
1625            &root,
1626            "main",
1627            None,
1628            None,
1629            &["crates/**".to_string()],
1630        )
1631        .expect("pin")
1632        .expect("standards govern");
1633
1634        // A mission branch weakens the pack (retire the enforced RFC) — the
1635        // approval read at the base is structurally blind to it (D-A/D-E).
1636        let git = |args: &[&str]| {
1637            let out = std::process::Command::new("git")
1638                .args(args)
1639                .current_dir(&root)
1640                .output()
1641                .expect("spawn git");
1642            assert!(out.status.success(), "git {args:?} failed: {out:?}");
1643        };
1644        git(&["checkout", "-qb", "kranz/mission-x"]);
1645        std::fs::write(
1646            root.join("vendor/pack/standards/RFC-002-slug/rfc.md"),
1647            rfc_md("RFC-002", "retired"),
1648        )
1649        .unwrap();
1650        git(&["add", "."]);
1651        git(&["commit", "-qm", "weaken policy"]);
1652        git(&["checkout", "-q", "main"]);
1653
1654        let repin = approval_pin(
1655            &repo,
1656            &cfg,
1657            &root,
1658            "main",
1659            None,
1660            None,
1661            &["crates/**".to_string()],
1662        )
1663        .expect("pin")
1664        .expect("standards govern");
1665        assert_eq!(
1666            pin, repin,
1667            "the mission branch's pack edit can never reshape the approval read"
1668        );
1669        assert!(repin.rules.iter().any(|r| r.effective_status == "enforced"));
1670    }
1671
1672    #[test]
1673    fn flight_rules_pin_approval_refuses_untracked_repo_pack_corpus() {
1674        // The pack exists ONLY in the worktree (never committed to the base):
1675        // an untracked repo-relative corpus has no base history, so approval
1676        // refuses it naming the remedy (D-A/D-J) instead of silently running
1677        // standards-free.
1678        let Some((_tmp, root, repo)) = git_repo_with_files(&vendored_pack_files("approved")) else {
1679            return;
1680        };
1681        let git = |args: &[&str]| {
1682            let out = std::process::Command::new("git")
1683                .args(args)
1684                .current_dir(&root)
1685                .output()
1686                .expect("spawn git");
1687            assert!(out.status.success(), "git {args:?} failed: {out:?}");
1688        };
1689        git(&["rm", "-rq", "vendor/pack"]);
1690        git(&["commit", "-qm", "drop the pack from the base"]);
1691        // The worktree still holds a pack (restored, untracked relative to HEAD).
1692        // Restore an ENFORCED corpus. External-trust loading rejects it;
1693        // approval must propagate that refusal rather than swallowing it as
1694        // `None` and silently running standards-free.
1695        for (rel, body) in vendored_pack_files("enforced") {
1696            if rel == "README.md" {
1697                continue;
1698            }
1699            let path = root.join(&rel);
1700            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1701            std::fs::write(path, body).unwrap();
1702        }
1703        let cfg = cfg_with_pack(Some("vendor/pack".to_string()));
1704        let err = approval_pin(&repo, &cfg, &root, "main", None, None, &[])
1705            .expect_err("an untracked standards pack must refuse approval");
1706        assert!(err.contains("not tracked on base branch"), "{err}");
1707    }
1708
1709    #[test]
1710    fn flight_rules_pin_external_enforced_refused_at_approval() {
1711        let Some((_tmp, root, repo)) = git_repo_with_files(&vendored_pack_files("enforced")) else {
1712            return;
1713        };
1714        // An ABSOLUTE packDir is external/untracked by construction: its
1715        // enforced rule is refused at approval with the trust remedy (D-A).
1716        let external = root.join("vendor/pack");
1717        let cfg = cfg_with_pack(Some(external.to_string_lossy().into_owned()));
1718        let err = approval_pin(
1719            &repo,
1720            &cfg,
1721            &root,
1722            "main",
1723            None,
1724            None,
1725            &["crates/**".to_string()],
1726        )
1727        .expect_err("an enforced rule from an external pack must refuse approval");
1728        assert!(
1729            err.contains("repo-tracked") || err.contains("vendor"),
1730            "the refusal names the trust remedy: {err}"
1731        );
1732    }
1733
1734    #[test]
1735    fn flight_rules_pin_external_advisory_pin_survives_later_edits() {
1736        let Some((_tmp, root, repo)) = git_repo_with_files(&vendored_pack_files("approved")) else {
1737            return;
1738        };
1739        // All rules advisory (approved RFC): the external pack pins fine.
1740        let external = root.join("vendor/pack");
1741        let cfg = cfg_with_pack(Some(external.to_string_lossy().into_owned()));
1742        let pin = approval_pin(
1743            &repo,
1744            &cfg,
1745            &root,
1746            "main",
1747            None,
1748            None,
1749            &["crates/**".to_string()],
1750        )
1751        .expect("pin")
1752        .expect("advisory standards pin");
1753        assert_eq!(pin.source, StandardsPinSource::ExternalPinned);
1754        assert!(pin.rules.iter().all(|r| r.effective_status == "approved"));
1755
1756        // An external pack edit after approval cannot change the run: the
1757        // final-validation check needs no re-read, and merge never re-reads
1758        // an external pack at all.
1759        std::fs::write(
1760            root.join("vendor/pack/standards/RFC-001-slug/rules/ZZ-ADV-001.md"),
1761            rule_md(
1762                "ZZ-ADV-001",
1763                "RFC-001",
1764                9,
1765                "must",
1766                "active",
1767                "merge",
1768                None,
1769                None,
1770                None,
1771            ),
1772        )
1773        .unwrap();
1774        let newly = newly_applicable_enforced(&repo, "main", &pin, &["crates/x.rs".to_string()])
1775            .expect("external pins never re-read");
1776        assert!(newly.is_empty());
1777        let drift = merge_drift(&repo, "main", &pin, &["crates/x.rs".to_string()])
1778            .expect("external pins skip the drift check");
1779        assert!(drift.is_none(), "an external pack edit cannot change a run");
1780    }
1781
1782    // ---- D-E: final validation + merge drift ------------------------------
1783
1784    #[test]
1785    fn flight_rules_pin_final_validation_flags_newly_applicable_enforced() {
1786        let Some((_tmp, root, repo)) = git_repo_with_files(&vendored_pack_files("enforced")) else {
1787            return;
1788        };
1789        let cfg = cfg_with_pack(Some("vendor/pack".to_string()));
1790        // Approved with a touch set that never reaches `crates/` — the
1791        // path-scoped enforced rule is NOT in the approved set.
1792        let pin = approval_pin(
1793            &repo,
1794            &cfg,
1795            &root,
1796            "main",
1797            None,
1798            None,
1799            &["docs/**".to_string()],
1800        )
1801        .expect("pin")
1802        .expect("standards govern");
1803        assert!(pin.rules.iter().all(|r| r.id != "ZZ-MUST-001"));
1804
1805        let base_sha = repo.rev_parse("main").expect("base sha");
1806        // The actual diff escaped the envelope into crates/: the enforced
1807        // rule is newly applicable — park for revision/reapproval.
1808        let newly = newly_applicable_enforced(
1809            &repo,
1810            &base_sha,
1811            &pin,
1812            &["crates/engine/src/lib.rs".to_string()],
1813        )
1814        .expect("check");
1815        let ids: Vec<&str> = newly.iter().map(|r| r.id.as_str()).collect();
1816        assert_eq!(ids, ["ZZ-MUST-001"]);
1817        assert_eq!(newly[0].effective_status, "enforced");
1818
1819        // A diff INSIDE the approved envelope can never newly apply anything
1820        // (the declared overlap is a superset of anything it admits).
1821        let clean =
1822            newly_applicable_enforced(&repo, &base_sha, &pin, &["docs/guide.md".to_string()])
1823                .expect("check");
1824        assert!(clean.is_empty(), "{clean:?}");
1825    }
1826
1827    #[test]
1828    fn flight_rules_pin_merge_drift_refuses_enforced_set_change() {
1829        let Some((_tmp, root, repo)) = git_repo_with_files(&vendored_pack_files("approved")) else {
1830            return;
1831        };
1832        let cfg = cfg_with_pack(Some("vendor/pack".to_string()));
1833        let touch = vec!["crates/**".to_string()];
1834        let pin = approval_pin(&repo, &cfg, &root, "main", None, None, &touch)
1835            .expect("pin")
1836            .expect("standards govern");
1837        // The approved RFC-002 fixture is `approved` here, so the approved
1838        // enforced set is EMPTY — nothing to drift yet.
1839        let paths = vec!["crates/engine/src/lib.rs".to_string()];
1840        assert!(
1841            merge_drift(&repo, "main", &pin, &paths)
1842                .expect("check")
1843                .is_none(),
1844            "an unchanged base can never drift"
1845        );
1846
1847        // The live base promotes RFC-002 to enforced (through its approved
1848        // state — a legitimate transition) and bumps nothing else.
1849        std::fs::write(
1850            root.join("vendor/pack/standards/RFC-002-slug/rfc.md"),
1851            rfc_md("RFC-002", "enforced"),
1852        )
1853        .unwrap();
1854        let git = |args: &[&str]| {
1855            let out = std::process::Command::new("git")
1856                .args(args)
1857                .current_dir(&root)
1858                .output()
1859                .expect("spawn git");
1860            assert!(out.status.success(), "git {args:?} failed: {out:?}");
1861        };
1862        git(&["add", "."]);
1863        git(&["commit", "-qm", "promote RFC-002 to enforced"]);
1864
1865        let report = merge_drift(&repo, "main", &pin, &paths)
1866            .expect("check")
1867            .expect("the enforced set changed: drift must refuse");
1868        assert_eq!(report.approved_digest, pin.digest);
1869        assert!(report.current_digest.is_some());
1870        assert_ne!(report.current_digest.as_deref(), Some(pin.digest.as_str()));
1871        assert!(
1872            report
1873                .changed_rules
1874                .iter()
1875                .any(|line| line.contains("ZZ-MUST-001") && line.contains("newly applicable")),
1876            "{:?}",
1877            report.changed_rules
1878        );
1879
1880        // And a REMOVED pack on the live base is drift too (policy removal):
1881        // flip the base pack's enforced RFC back via a base WITHOUT standards.
1882        git(&["rm", "-rq", "vendor/pack"]);
1883        git(&["commit", "-qm", "remove the pack"]);
1884        // Approve a pin that HAS an enforced rule: rebuild the pack enforced
1885        // at an earlier commit and pin against it.
1886        let enforced_files = vendored_pack_files("enforced");
1887        for (rel, body) in &enforced_files {
1888            if rel == "README.md" {
1889                continue;
1890            }
1891            let path = root.join(rel);
1892            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1893            std::fs::write(path, body).unwrap();
1894        }
1895        git(&["add", "."]);
1896        git(&["commit", "-qm", "restore the pack enforced"]);
1897        let enforced_pin = approval_pin(&repo, &cfg, &root, "main", None, None, &touch)
1898            .expect("pin")
1899            .expect("standards govern");
1900        assert!(enforced_pin
1901            .rules
1902            .iter()
1903            .any(|r| r.effective_status == "enforced"));
1904        git(&["rm", "-rq", "vendor/pack"]);
1905        git(&["commit", "-qm", "remove the pack again"]);
1906        let report = merge_drift(&repo, "main", &enforced_pin, &paths)
1907            .expect("check")
1908            .expect("a vanished pack is drift");
1909        assert!(report.current_digest.is_none());
1910        assert!(
1911            report
1912                .changed_rules
1913                .iter()
1914                .any(|line| line.contains("ZZ-MUST-001") && line.contains("absent")),
1915            "{:?}",
1916            report.changed_rules
1917        );
1918    }
1919
1920    #[test]
1921    fn flight_rules_pin_merge_ignores_mission_branch_pack_edit() {
1922        let Some((_tmp, root, repo)) = git_repo_with_files(&vendored_pack_files("enforced")) else {
1923            return;
1924        };
1925        let cfg = cfg_with_pack(Some("vendor/pack".to_string()));
1926        let touch = vec!["crates/**".to_string()];
1927        let pin = approval_pin(&repo, &cfg, &root, "main", None, None, &touch)
1928            .expect("pin")
1929            .expect("standards govern");
1930
1931        // The mission branch edits its pack; the LIVE BASE does not move.
1932        let git = |args: &[&str]| {
1933            let out = std::process::Command::new("git")
1934                .args(args)
1935                .current_dir(&root)
1936                .output()
1937                .expect("spawn git");
1938            assert!(out.status.success(), "git {args:?} failed: {out:?}");
1939        };
1940        git(&["checkout", "-qb", "kranz/mission-x"]);
1941        std::fs::write(
1942            root.join("vendor/pack/standards/RFC-002-slug/rfc.md"),
1943            rfc_md("RFC-002", "retired"),
1944        )
1945        .unwrap();
1946        std::fs::write(root.join("crates/engine/src/lib.rs"), "pub fn x() {}\n").unwrap_or_else(
1947            |_| {
1948                std::fs::create_dir_all(root.join("crates/engine/src")).unwrap();
1949                std::fs::write(root.join("crates/engine/src/lib.rs"), "pub fn x() {}\n").unwrap();
1950            },
1951        );
1952        git(&["add", "."]);
1953        git(&["commit", "-qm", "mission work plus a pack edit"]);
1954        git(&["checkout", "-q", "main"]);
1955
1956        // Merge resolves the LIVE base policy: the mission's own edit is
1957        // invisible, so no drift — the mission is judged by the OLD base
1958        // version, and its edit can govern only future missions (D-E).
1959        let paths = vec!["crates/engine/src/lib.rs".to_string()];
1960        assert!(
1961            merge_drift(&repo, "main", &pin, &paths)
1962                .expect("check")
1963                .is_none(),
1964            "a mission-branch pack edit is not policy drift"
1965        );
1966    }
1967
1968    // ---- the no-pack / old-log regressions ---------------------------------
1969
1970    #[test]
1971    fn flight_rules_pin_no_pack_and_old_logs_are_byte_identical() {
1972        let Some((_tmp, root, repo)) = git_repo_with_files(&vendored_pack_files("approved")) else {
1973            return;
1974        };
1975        // No packDir configured: no pin, no error.
1976        assert!(
1977            approval_pin(&repo, &cfg_with_pack(None), &root, "main", None, None, &[])
1978                .expect("ok")
1979                .is_none()
1980        );
1981        // A schema-2/3 pack (no [standards]): no pin either.
1982        let schema3 = vec![(
1983            "vendor/pack/pack.toml".to_string(),
1984            "[pack]\nname = \"plain\"\nschema = 3\n".to_string(),
1985        )];
1986        let Some((_t2, root2, repo2)) = git_repo_with_files(&schema3) else {
1987            return;
1988        };
1989        assert!(
1990            approval_pin(
1991                &repo2,
1992                &cfg_with_pack(Some("vendor/pack".to_string())),
1993                &root2,
1994                "main",
1995                None,
1996                None,
1997                &[]
1998            )
1999            .expect("ok")
2000            .is_none(),
2001            "a schema-3 pack governs no standards"
2002        );
2003
2004        // A pin-less Plan serializes WITHOUT the key (old plans stay
2005        // byte-identical), and an old plan.json without the key folds back
2006        // with None.
2007        let plan = crate::types::Plan {
2008            goal: "g".into(),
2009            validation_contract: vec![],
2010            milestones: vec![],
2011            considered_alternatives: None,
2012            command_grants: vec![],
2013            touch_set: vec![],
2014            standards_manifest: None,
2015            reviewer_independence: None,
2016        };
2017        let json = serde_json::to_string(&plan).expect("serialize");
2018        assert!(!json.contains("standardsManifest"), "{json}");
2019        let old: crate::types::Plan =
2020            serde_json::from_str(r#"{"goal":"g","validationContract":[],"milestones":[]}"#)
2021                .expect("an old plan folds");
2022        assert_eq!(old.standards_manifest, None);
2023    }
2024
2025    #[test]
2026    fn flight_rules_pin_events_round_trip_and_fold() {
2027        // Both new kinds serialize under their dotted names with the
2028        // documented payload keys, and fold as audit-only no-ops.
2029        let resolved = EventKind::StandardsResolved {
2030            source: "repo-tracked".to_string(),
2031            pack_name: "zz-pin-pack".to_string(),
2032            standards_root: "standards".to_string(),
2033            digest: "ab".repeat(32),
2034            stage: APPROVAL_SURFACE.to_string(),
2035            task_class: Some("implementation".to_string()),
2036            touch_set: vec!["crates/**".to_string()],
2037            context_paths: vec!["docs/spec.md".to_string()],
2038            rules: vec![crate::types::StandardsRuleRef {
2039                id: "ZZ-MUST-001".to_string(),
2040                revision: 1,
2041                effective_status: "enforced".to_string(),
2042            }],
2043            approval_seq: 7,
2044        };
2045        assert_eq!(resolved.type_name(), "standards.resolved");
2046        let value = serde_json::to_value(&resolved).expect("serialize");
2047        assert_eq!(value["type"], "standards.resolved");
2048        assert_eq!(value["payload"]["approvalSeq"], 7);
2049        assert_eq!(value["payload"]["contextPaths"][0], "docs/spec.md");
2050        let back: EventKind = serde_json::from_value(value).expect("round trip");
2051        assert_eq!(back.type_name(), "standards.resolved");
2052
2053        let drifted = EventKind::StandardsDrifted {
2054            approved_digest: "ab".repeat(32),
2055            current_digest: None,
2056            surface: "merge".to_string(),
2057            changed_rules: vec![
2058                "ZZ-MUST-001 r1 (newly applicable enforced rule on the live base)".to_string(),
2059            ],
2060        };
2061        assert_eq!(drifted.type_name(), "standards.drifted");
2062        let value = serde_json::to_value(&drifted).expect("serialize");
2063        assert_eq!(value["type"], "standards.drifted");
2064        // currentDigest skipped when None — old consumers see no new key.
2065        assert!(value["payload"].get("currentDigest").is_none());
2066        let back: EventKind = serde_json::from_value(value).expect("round trip");
2067        let EventKind::StandardsDrifted { current_digest, .. } = back else {
2068            panic!("round trip preserves the variant");
2069        };
2070        assert_eq!(current_digest, None);
2071    }
2072}