1use 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
59pub const APPROVAL_SURFACE: &str = "approval";
64
65pub enum TouchInput<'a> {
67 Declared(&'a [String]),
71 Actual(&'a [String]),
74}
75
76fn 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
114fn 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
136fn 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
157fn 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
168fn selectable(manifest: &StandardsManifest, rule: &RuleMeta) -> bool {
172 matches!(
173 manifest.effective_status(rule),
174 RfcStatus::Approved | RfcStatus::Enforced
175 )
176}
177
178pub 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
197pub 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
226fn 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
254pub 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
274pub 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
320pub 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
377pub 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
387pub 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#[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 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 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 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
558fn 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
575pub(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
606pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
671pub struct DriftReport {
672 pub approved_digest: String,
674 pub current_digest: Option<String>,
678 pub changed_rules: Vec<String>,
680}
681
682pub 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 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, ¤t);
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
817fn 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
829fn 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
860pub 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#[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 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 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 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 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 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 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 #[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 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 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 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 let hit = resolve(
1344 &manifest,
1345 RuleStage::Implementation,
1346 Some(" Implementation "),
1347 &touch,
1348 );
1349 assert_eq!(hit.len(), 2);
1350 let docs = resolve(&manifest, RuleStage::Implementation, Some("docs"), &touch);
1352 assert_eq!(docs.len(), 1);
1353 assert_eq!(docs[0].id, "ZZ-ANY");
1354 let classless = resolve(&manifest, RuleStage::Implementation, None, &touch);
1356 assert_eq!(classless.len(), 1);
1357 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 for (globs, expect) in [
1480 (vec!["crates/**"], true), (vec!["crates/engine/**"], true), (vec!["crates/engine/src/types.rs"], true), (vec!["**"], true), (vec!["docs/**"], false), (vec!["!crates/**"], false), (vec![], false), ] {
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 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), ] {
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 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 #[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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 git(&["rm", "-rq", "vendor/pack"]);
1883 git(&["commit", "-qm", "remove the pack"]);
1884 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 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 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 #[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 assert!(
1977 approval_pin(&repo, &cfg_with_pack(None), &root, "main", None, None, &[])
1978 .expect("ok")
1979 .is_none()
1980 );
1981 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 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 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 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}