Skip to main content

harn_vm/flow/predicates/
bootstrap.rs

1//! Repo-root `meta-invariants.harn` bootstrap policy.
2//!
3//! Solves the "predicate code that gates predicate code" problem flagged in
4//! decision 2 of `docs/src/flow-predicates.md`. Per-directory `invariants.harn`
5//! files declare slice gates; this module declares the small, hand-authored
6//! policy that gates *those* gates' authorship.
7//!
8//! Two validation entrypoints front the policy:
9//!
10//! - [`validate_predicate_edit`] checks a proposed edit to an
11//!   `invariants.harn` file. Archivist may propose; humans and other
12//!   non-Archivist actors may also propose. Promotion still requires the
13//!   normal slice approval chain — this function only enforces the bootstrap
14//!   rules (parseability, kind annotation, archivist provenance, semantic
15//!   fallback presence).
16//! - [`validate_bootstrap_edit`] checks a proposed edit to
17//!   `meta-invariants.harn` itself. Archivist authorship is rejected outright;
18//!   any other author yields `RequireApproval` routing to a human maintainer
19//!   listed in the previous policy. The previous policy hash is pinned in the
20//!   result so the slice approval chain has an explicit audit reference.
21//!
22//! See parent epic #571, the predicate decision record (#584), and the
23//! implementation ticket (#734).
24
25use std::path::{Path, PathBuf};
26
27use harn_lexer::Lexer;
28use harn_parser::{peel_attributes, Attribute, AttributeArg, Node, Parser};
29use serde::{Deserialize, Serialize};
30use sha2::{Digest, Sha256};
31
32use super::discovery::{
33    parse_invariants_source, ArchivistMetadata, DiagnosticSeverity, DiscoveryDiagnostic,
34};
35use super::result::{Approver, InvariantBlockError, Verdict};
36use crate::flow::slice::PredicateHash;
37
38/// Filename used for the repo-root bootstrap policy file.
39pub const META_INVARIANTS_FILE: &str = "meta-invariants.harn";
40
41/// Default maintainer routed when the discovered policy doesn't list any.
42///
43/// Mirrors the Ship Captain ceiling default approver so a freshly-seeded
44/// repository still ends up at the same human review desk.
45pub const DEFAULT_MAINTAINER_ROLE: &str = "flow-platform";
46
47/// Stable error codes attached to [`BootstrapViolation`]s.
48pub mod codes {
49    pub const PARSE_ERROR: &str = "bootstrap_parse_error";
50    pub const KIND_COLLISION: &str = "bootstrap_kind_collision";
51    pub const MISSING_ARCHIVIST: &str = "bootstrap_missing_archivist";
52    pub const ARCHIVIST_PROVENANCE_INCOMPLETE: &str = "bootstrap_archivist_provenance_incomplete";
53    pub const MISSING_SEMANTIC_FALLBACK: &str = "bootstrap_missing_semantic_fallback";
54    pub const UNRESOLVED_SEMANTIC_FALLBACK: &str = "bootstrap_unresolved_semantic_fallback";
55    pub const ARCHIVIST_AUTHORED_BOOTSTRAP: &str = "bootstrap_archivist_cannot_author_bootstrap";
56}
57
58/// Identity of the actor proposing a predicate-authorship change.
59///
60/// Stored on the proposal envelope and consulted by both validators: the
61/// bootstrap policy is the only place Archivist authorship is a hard error,
62/// and only the meta-invariants validator checks the discriminant — normal
63/// `invariants.harn` edits accept any author because the slice approval chain
64/// is responsible for promotion.
65#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(tag = "kind", rename_all = "snake_case")]
67pub enum EditAuthor {
68    /// The Archivist persona — propose-only, never auto-promotes bootstrap.
69    Archivist,
70    /// A named human maintainer (e.g. `user:alice`).
71    Human { id: String },
72    /// Any other automated actor (Fixer, Ship Captain, replay tools).
73    System { id: String },
74}
75
76impl EditAuthor {
77    pub fn human(id: impl Into<String>) -> Self {
78        Self::Human { id: id.into() }
79    }
80
81    pub fn system(id: impl Into<String>) -> Self {
82        Self::System { id: id.into() }
83    }
84
85    fn label(&self) -> String {
86        match self {
87            EditAuthor::Archivist => "archivist".to_string(),
88            EditAuthor::Human { id } => format!("human:{id}"),
89            EditAuthor::System { id } => format!("system:{id}"),
90        }
91    }
92}
93
94/// One bootstrap-policy violation produced by the validators.
95///
96/// The `code` field is owned `String` so the type round-trips through serde.
97/// The static codes in [`codes`] are the canonical authoring source.
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99pub struct BootstrapViolation {
100    pub code: String,
101    pub message: String,
102    /// Predicate name when the violation is tied to a specific declaration.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub predicate: Option<String>,
105}
106
107impl BootstrapViolation {
108    fn new(code: &'static str, message: impl Into<String>) -> Self {
109        Self {
110            code: code.to_string(),
111            message: message.into(),
112            predicate: None,
113        }
114    }
115
116    fn with_predicate(mut self, predicate: impl Into<String>) -> Self {
117        self.predicate = Some(predicate.into());
118        self
119    }
120}
121
122/// Parsed `meta-invariants.harn` contents.
123///
124/// The file is ordinary Harn syntax so the existing lexer and parser handle
125/// it. The policy carries the file's content hash (pinned for replay audit)
126/// and the maintainer routing list extracted from a top-level
127/// `@bootstrap_maintainers(...)` attribute, if present. Parser diagnostics
128/// are returned alongside via [`BootstrapPolicy::parse_with_diagnostics`] so
129/// the policy struct itself is serde-clean for audit payloads.
130#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
131pub struct BootstrapPolicy {
132    /// Stable content hash of the entire file source.
133    pub hash: PredicateHash,
134    /// Maintainer approvers in source order. At least one entry is always
135    /// present — empty input lists fall back to [`DEFAULT_MAINTAINER_ROLE`].
136    pub maintainers: Vec<Approver>,
137}
138
139impl BootstrapPolicy {
140    /// Parse a `meta-invariants.harn` source string. Discards any parser
141    /// diagnostics; use [`BootstrapPolicy::parse_with_diagnostics`] when you
142    /// need to surface them.
143    ///
144    /// The hash is computed across the full source bytes so any change — even
145    /// reordering whitespace — produces a new hash. This matches the existing
146    /// `predicate_source_hash` shape (`sha256:<hex>`).
147    pub fn parse(source: &str) -> Self {
148        Self::parse_with_diagnostics(source).0
149    }
150
151    /// Parse a `meta-invariants.harn` source string and return the structural
152    /// diagnostics raised by the lexer/parser. Missing optional configuration
153    /// (e.g. no `@bootstrap_maintainers` attribute) is a silent fall back to
154    /// defaults — only real parse errors appear here.
155    pub fn parse_with_diagnostics(source: &str) -> (Self, Vec<DiscoveryDiagnostic>) {
156        let hash = bootstrap_hash(source);
157        let parsed = parse_invariants_source(source);
158        let mut diagnostics = parsed.diagnostics;
159
160        let mut maintainers = Vec::new();
161        match collect_top_level_attributes(source) {
162            Ok(attrs) => {
163                for attr in attrs {
164                    if attr.name == "bootstrap_maintainers" {
165                        maintainers.extend(parse_maintainers(&attr.args));
166                    }
167                }
168            }
169            Err(diagnostic) => diagnostics.push(diagnostic),
170        }
171        if maintainers.is_empty() {
172            maintainers.push(Approver::role(DEFAULT_MAINTAINER_ROLE));
173        }
174
175        (Self { hash, maintainers }, diagnostics)
176    }
177}
178
179/// Bootstrap policy as discovered on disk.
180#[derive(Clone, Debug)]
181pub struct DiscoveredBootstrapPolicy {
182    /// Absolute path to `meta-invariants.harn`.
183    pub path: PathBuf,
184    /// Raw source — kept around so callers can render diagnostics or echo it
185    /// back in audit payloads.
186    pub source: String,
187    /// Parsed policy contents.
188    pub policy: BootstrapPolicy,
189    /// Diagnostics raised while parsing.
190    pub diagnostics: Vec<DiscoveryDiagnostic>,
191}
192
193/// Look for `<root>/meta-invariants.harn` and parse it if present.
194///
195/// Returns `None` when the file is missing or unreadable. A parse failure is
196/// surfaced as `Some(...)` with the structural diagnostics returned on
197/// [`DiscoveredBootstrapPolicy::diagnostics`] — callers that need to
198/// fail-fast should inspect that field.
199pub fn discover_bootstrap_policy(root: &Path) -> Option<DiscoveredBootstrapPolicy> {
200    let path = root.join(META_INVARIANTS_FILE);
201    if !path.is_file() {
202        return None;
203    }
204    let source = std::fs::read_to_string(&path).ok()?;
205    let (policy, diagnostics) = BootstrapPolicy::parse_with_diagnostics(&source);
206    Some(DiscoveredBootstrapPolicy {
207        path,
208        source,
209        policy,
210        diagnostics,
211    })
212}
213
214/// Result of running a bootstrap validator.
215#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
216pub struct BootstrapValidation {
217    /// Effective verdict for the proposed edit. `Block` when the proposal
218    /// violates a structural rule or — for bootstrap edits — the author lacks
219    /// authorship rights. `RequireApproval` when a human cosigner must
220    /// promote the edit. `Allow` only when the edit is structurally clean and
221    /// no approval routing is required.
222    pub verdict: Verdict,
223    /// Hash of the previous committed bootstrap policy. `None` for an initial
224    /// seed where there is no prior policy to compare against.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub previous_policy_hash: Option<PredicateHash>,
227    /// Hash of the proposed `meta-invariants.harn` source. Set only by
228    /// [`validate_bootstrap_edit`].
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub proposed_policy_hash: Option<PredicateHash>,
231    /// Author label echoed back for audit-log convenience.
232    pub author: String,
233    /// Structured violations. Empty on the happy path.
234    #[serde(default, skip_serializing_if = "Vec::is_empty")]
235    pub violations: Vec<BootstrapViolation>,
236}
237
238impl BootstrapValidation {
239    /// True when the validation produced a structural `Block`.
240    pub fn is_blocked(&self) -> bool {
241        matches!(self.verdict, Verdict::Block { .. })
242    }
243
244    /// True when the proposal needs an approver cosignature before promotion.
245    pub fn requires_approval(&self) -> bool {
246        matches!(self.verdict, Verdict::RequireApproval { .. })
247    }
248}
249
250/// Validate a proposed edit to a per-directory `invariants.harn` file.
251///
252/// Bootstrap policy promotes the soft warnings on `parse_invariants_source`
253/// into hard errors:
254///
255/// - Source must lex/parse cleanly.
256/// - Every `@invariant` predicate must declare exactly one of
257///   `@deterministic` or `@semantic`.
258/// - Every predicate must carry a complete `@archivist(evidence,
259///   confidence, source_date, coverage_examples)` provenance block.
260/// - `@semantic` predicates must declare a fallback whose target is a
261///   deterministic predicate visible in the proposed source.
262///
263/// `previous_policy` lets the caller pin the previously committed bootstrap
264/// hash into the validation result for audit. It does not change the rules
265/// applied — the bootstrap rules themselves live in this function so a
266/// repository can roll its policy hash forward without rewriting Rust.
267pub fn validate_predicate_edit(
268    proposed_source: &str,
269    author: &EditAuthor,
270    previous_policy: Option<&BootstrapPolicy>,
271) -> BootstrapValidation {
272    let violations = collect_predicate_edit_violations(proposed_source);
273    let verdict = if violations.is_empty() {
274        Verdict::Allow
275    } else {
276        Verdict::Block {
277            error: build_block_error(codes::PARSE_ERROR, &violations),
278        }
279    };
280    BootstrapValidation {
281        verdict,
282        previous_policy_hash: previous_policy.map(|policy| policy.hash.clone()),
283        proposed_policy_hash: None,
284        author: author.label(),
285        violations,
286    }
287}
288
289/// Validate a proposed edit to `meta-invariants.harn`.
290///
291/// - Archivist authorship is a hard `Block` with the stable code
292///   `bootstrap_archivist_cannot_author_bootstrap`.
293/// - Otherwise, the proposed source is parsed for structural problems. If
294///   parsing fails, the result is `Block`.
295/// - Clean proposals from a non-Archivist author yield `RequireApproval`,
296///   routed to one of the maintainers listed in the previous policy (or the
297///   default maintainer role on initial seed). The previous policy's hash and
298///   the proposed policy's hash are both pinned in the result so the slice
299///   approval chain has explicit audit pointers.
300pub fn validate_bootstrap_edit(
301    proposed_source: &str,
302    author: &EditAuthor,
303    previous_policy: Option<&BootstrapPolicy>,
304) -> BootstrapValidation {
305    let proposed_hash = bootstrap_hash(proposed_source);
306    let previous_hash = previous_policy.map(|policy| policy.hash.clone());
307    let mut violations = Vec::new();
308
309    if matches!(author, EditAuthor::Archivist) {
310        violations.push(BootstrapViolation::new(
311            codes::ARCHIVIST_AUTHORED_BOOTSTRAP,
312            "Archivist persona is propose-only and may not author or promote \
313             meta-invariants.harn edits — escalate to a human maintainer",
314        ));
315        let error = build_block_error(codes::ARCHIVIST_AUTHORED_BOOTSTRAP, &violations);
316        return BootstrapValidation {
317            verdict: Verdict::Block { error },
318            previous_policy_hash: previous_hash,
319            proposed_policy_hash: Some(proposed_hash),
320            author: author.label(),
321            violations,
322        };
323    }
324
325    let (parsed_policy, parse_diagnostics) =
326        BootstrapPolicy::parse_with_diagnostics(proposed_source);
327    for diagnostic in parse_diagnostics
328        .iter()
329        .filter(|d| d.severity == DiagnosticSeverity::Error)
330    {
331        violations.push(BootstrapViolation::new(
332            codes::PARSE_ERROR,
333            diagnostic.message.clone(),
334        ));
335    }
336
337    if !violations.is_empty() {
338        let error = build_block_error(codes::PARSE_ERROR, &violations);
339        return BootstrapValidation {
340            verdict: Verdict::Block { error },
341            previous_policy_hash: previous_hash,
342            proposed_policy_hash: Some(proposed_hash),
343            author: author.label(),
344            violations,
345        };
346    }
347
348    let approver = previous_policy
349        .and_then(|policy| policy.maintainers.first().cloned())
350        .or_else(|| parsed_policy.maintainers.first().cloned())
351        .unwrap_or_else(|| Approver::role(DEFAULT_MAINTAINER_ROLE));
352
353    BootstrapValidation {
354        verdict: Verdict::RequireApproval { approver },
355        previous_policy_hash: previous_hash,
356        proposed_policy_hash: Some(proposed_hash),
357        author: author.label(),
358        violations,
359    }
360}
361
362fn collect_predicate_edit_violations(proposed_source: &str) -> Vec<BootstrapViolation> {
363    let parsed = parse_invariants_source(proposed_source);
364    let mut violations = Vec::new();
365
366    for diagnostic in &parsed.diagnostics {
367        if diagnostic.severity != DiagnosticSeverity::Error {
368            continue;
369        }
370        let code = if diagnostic.message.contains("pick exactly one") {
371            codes::KIND_COLLISION
372        } else if diagnostic
373            .message
374            .contains("must declare a deterministic fallback")
375        {
376            codes::MISSING_SEMANTIC_FALLBACK
377        } else if diagnostic
378            .message
379            .contains("same invariants.harn file or an ancestor file")
380        {
381            codes::UNRESOLVED_SEMANTIC_FALLBACK
382        } else {
383            codes::PARSE_ERROR
384        };
385        violations.push(BootstrapViolation::new(code, diagnostic.message.clone()));
386    }
387
388    for predicate in &parsed.predicates {
389        // The parser already raises kind-collision and missing-semantic-fallback
390        // as error diagnostics, picked up in the loop above. Bootstrap adds the
391        // missing-/incomplete-archivist checks because the parser only warns.
392        match predicate.archivist.as_ref() {
393            None => {
394                violations.push(
395                    BootstrapViolation::new(
396                        codes::MISSING_ARCHIVIST,
397                        format!(
398                            "predicate `{}` must declare `@archivist(...)` provenance \
399                             before promotion",
400                            predicate.name
401                        ),
402                    )
403                    .with_predicate(&predicate.name),
404                );
405            }
406            Some(archivist) => {
407                for missing in archivist_missing_fields(archivist) {
408                    violations.push(
409                        BootstrapViolation::new(
410                            codes::ARCHIVIST_PROVENANCE_INCOMPLETE,
411                            format!(
412                                "predicate `{}` is missing required @archivist field `{missing}`",
413                                predicate.name
414                            ),
415                        )
416                        .with_predicate(&predicate.name),
417                    );
418                }
419            }
420        }
421    }
422
423    deduplicate_violations(violations)
424}
425
426fn deduplicate_violations(violations: Vec<BootstrapViolation>) -> Vec<BootstrapViolation> {
427    let mut seen = std::collections::BTreeSet::<(String, String, Option<String>)>::new();
428    let mut out = Vec::with_capacity(violations.len());
429    for violation in violations {
430        let key = (
431            violation.code.to_string(),
432            violation.message.clone(),
433            violation.predicate.clone(),
434        );
435        if seen.insert(key) {
436            out.push(violation);
437        }
438    }
439    out
440}
441
442fn archivist_missing_fields(archivist: &ArchivistMetadata) -> Vec<&'static str> {
443    let mut missing = Vec::new();
444    if archivist.evidence.is_empty() {
445        missing.push("evidence");
446    }
447    if archivist.confidence.is_none() {
448        missing.push("confidence");
449    }
450    if archivist.source_date.is_none() {
451        missing.push("source_date");
452    }
453    if archivist.coverage_examples.is_empty() {
454        missing.push("coverage_examples");
455    }
456    missing
457}
458
459fn build_block_error(code: &'static str, violations: &[BootstrapViolation]) -> InvariantBlockError {
460    let summary = violations
461        .iter()
462        .map(|v| {
463            if let Some(predicate) = &v.predicate {
464                format!("{} (in `{predicate}`): {}", v.code, v.message)
465            } else {
466                format!("{}: {}", v.code, v.message)
467            }
468        })
469        .collect::<Vec<_>>()
470        .join("; ");
471    let message = if summary.is_empty() {
472        "bootstrap policy rejected the proposed edit".to_string()
473    } else {
474        format!("bootstrap policy rejected the proposed edit: {summary}")
475    };
476    InvariantBlockError::new(code, message)
477}
478
479fn bootstrap_hash(source: &str) -> PredicateHash {
480    PredicateHash::new(format!(
481        "sha256:{}",
482        hex::encode(Sha256::digest(source.as_bytes()))
483    ))
484}
485
486fn parse_maintainers(args: &[AttributeArg]) -> Vec<Approver> {
487    args.iter()
488        .flat_map(|arg| match &arg.value.node {
489            Node::ListLiteral(items) => items
490                .iter()
491                .filter_map(|item| match &item.node {
492                    Node::StringLiteral(s) | Node::RawStringLiteral(s) => {
493                        Some(parse_maintainer_str(s))
494                    }
495                    _ => None,
496                })
497                .collect::<Vec<_>>(),
498            Node::StringLiteral(s) | Node::RawStringLiteral(s) => {
499                vec![parse_maintainer_str(s)]
500            }
501            _ => Vec::new(),
502        })
503        .collect()
504}
505
506fn parse_maintainer_str(value: &str) -> Approver {
507    if let Some(role) = value.strip_prefix("role:") {
508        Approver::role(role.trim())
509    } else if let Some(principal) = value.strip_prefix("user:") {
510        Approver::principal(format!("user:{}", principal.trim()))
511    } else {
512        Approver::principal(value.trim())
513    }
514}
515
516/// Walk the top-level attribute lists in a Harn source string. Used by the
517/// bootstrap parser to find a `@bootstrap_maintainers(...)` configuration
518/// without needing it to live on a real predicate function.
519fn collect_top_level_attributes(source: &str) -> Result<Vec<Attribute>, DiscoveryDiagnostic> {
520    let tokens = Lexer::new(source)
521        .tokenize()
522        .map_err(|error| DiscoveryDiagnostic {
523            severity: DiagnosticSeverity::Error,
524            message: format!("lex error: {error:?}"),
525            span: None,
526        })?;
527    let program = Parser::new(tokens)
528        .parse()
529        .map_err(|error| DiscoveryDiagnostic {
530            severity: DiagnosticSeverity::Error,
531            message: format!("parse error: {error:?}"),
532            span: None,
533        })?;
534    let mut out = Vec::new();
535    for node in &program {
536        let (attrs, _inner) = peel_attributes(node);
537        out.extend(attrs.iter().cloned());
538    }
539    Ok(out)
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use std::fs;
546    use tempfile::TempDir;
547
548    const VALID_PREDICATE: &str = r#"
549@invariant
550@deterministic
551@archivist(
552  evidence: ["https://example.com/spec"],
553  confidence: 0.95,
554  source_date: "2026-04-26",
555  coverage_examples: ["crates/api/src/auth.rs"]
556)
557fn no_raw_tokens(slice) {
558  return flow_invariant_allow()
559}
560"#;
561
562    const VALID_BOOTSTRAP: &str = r#"
563@bootstrap_maintainers(approvers: ["role:flow-platform", "user:alice"])
564fn _meta_invariants_marker() {
565  return nil
566}
567"#;
568
569    #[test]
570    fn parses_bootstrap_policy_hash_and_maintainers() {
571        let policy = BootstrapPolicy::parse(VALID_BOOTSTRAP);
572        assert!(policy.hash.as_str().starts_with("sha256:"));
573        assert_eq!(policy.maintainers.len(), 2);
574        assert!(matches!(
575            policy.maintainers[0],
576            Approver::Role { ref name } if name == "flow-platform"
577        ));
578        assert!(matches!(
579            policy.maintainers[1],
580            Approver::Principal { ref id } if id == "user:alice"
581        ));
582    }
583
584    #[test]
585    fn bootstrap_hash_is_stable_across_identical_sources() {
586        let a = BootstrapPolicy::parse(VALID_BOOTSTRAP);
587        let b = BootstrapPolicy::parse(VALID_BOOTSTRAP);
588        assert_eq!(a.hash, b.hash);
589    }
590
591    #[test]
592    fn bootstrap_hash_changes_when_source_changes() {
593        let a = BootstrapPolicy::parse(VALID_BOOTSTRAP);
594        let b = BootstrapPolicy::parse(&VALID_BOOTSTRAP.replace("alice", "bob"));
595        assert_ne!(a.hash, b.hash);
596    }
597
598    #[test]
599    fn bootstrap_default_maintainer_when_attribute_missing() {
600        let policy = BootstrapPolicy::parse("// no maintainer attribute here\n");
601        assert_eq!(policy.maintainers.len(), 1);
602        assert!(matches!(
603            policy.maintainers[0],
604            Approver::Role { ref name } if name == DEFAULT_MAINTAINER_ROLE
605        ));
606    }
607
608    #[test]
609    fn validate_initial_seed_predicate_edit_passes_without_prior_policy() {
610        let result = validate_predicate_edit(VALID_PREDICATE, &EditAuthor::Archivist, None);
611        assert!(matches!(result.verdict, Verdict::Allow), "{result:?}");
612        assert_eq!(result.previous_policy_hash, None);
613        assert!(result.violations.is_empty());
614        assert_eq!(result.author, "archivist");
615    }
616
617    #[test]
618    fn validate_normal_predicate_edit_pins_previous_policy_hash() {
619        let policy = BootstrapPolicy::parse(VALID_BOOTSTRAP);
620        let result = validate_predicate_edit(
621            VALID_PREDICATE,
622            &EditAuthor::human("user:alice"),
623            Some(&policy),
624        );
625        assert!(matches!(result.verdict, Verdict::Allow));
626        assert_eq!(result.previous_policy_hash, Some(policy.hash.clone()));
627    }
628
629    #[test]
630    fn validate_predicate_edit_blocks_when_archivist_provenance_missing() {
631        let source = r#"
632@invariant
633@deterministic
634fn no_provenance(slice) { return true }
635"#;
636        let result = validate_predicate_edit(source, &EditAuthor::Archivist, None);
637        assert!(result.is_blocked());
638        let codes: Vec<&str> = result.violations.iter().map(|v| v.code.as_str()).collect();
639        assert!(codes.contains(&codes::MISSING_ARCHIVIST), "{codes:?}");
640    }
641
642    #[test]
643    fn validate_predicate_edit_blocks_when_archivist_provenance_partial() {
644        let source = r#"
645@invariant
646@deterministic
647@archivist(evidence: ["https://x"])
648fn partial_provenance(slice) { return true }
649"#;
650        let result = validate_predicate_edit(source, &EditAuthor::Archivist, None);
651        assert!(result.is_blocked());
652        let missing_fields: Vec<String> = result
653            .violations
654            .iter()
655            .filter(|v| v.code == codes::ARCHIVIST_PROVENANCE_INCOMPLETE)
656            .map(|v| v.message.clone())
657            .collect();
658        assert!(
659            missing_fields.iter().any(|m| m.contains("confidence")),
660            "{missing_fields:?}"
661        );
662        assert!(
663            missing_fields.iter().any(|m| m.contains("source_date")),
664            "{missing_fields:?}"
665        );
666        assert!(
667            missing_fields
668                .iter()
669                .any(|m| m.contains("coverage_examples")),
670            "{missing_fields:?}"
671        );
672    }
673
674    #[test]
675    fn validate_predicate_edit_blocks_when_kinds_collide() {
676        let source = r#"
677@invariant
678@deterministic
679@semantic
680@archivist(evidence: ["x"], confidence: 0.5, source_date: "2026-04-26", coverage_examples: ["a"])
681fn dual_mode(slice) { return true }
682"#;
683        let result = validate_predicate_edit(source, &EditAuthor::Archivist, None);
684        assert!(result.is_blocked());
685        let codes: Vec<&str> = result.violations.iter().map(|v| v.code.as_str()).collect();
686        assert!(codes.contains(&codes::KIND_COLLISION), "{codes:?}");
687    }
688
689    #[test]
690    fn validate_predicate_edit_blocks_when_semantic_fallback_missing() {
691        let source = r#"
692@invariant
693@semantic
694@archivist(evidence: ["x"], confidence: 0.5, source_date: "2026-04-26", coverage_examples: ["a"])
695fn semantic_no_fallback(slice) { return true }
696"#;
697        let result = validate_predicate_edit(source, &EditAuthor::Archivist, None);
698        assert!(result.is_blocked());
699        let codes: Vec<&str> = result.violations.iter().map(|v| v.code.as_str()).collect();
700        assert!(
701            codes.contains(&codes::MISSING_SEMANTIC_FALLBACK),
702            "{codes:?}"
703        );
704    }
705
706    #[test]
707    fn validate_bootstrap_edit_rejects_archivist_authorship() {
708        let previous = BootstrapPolicy::parse(VALID_BOOTSTRAP);
709        let proposed = VALID_BOOTSTRAP.replace("alice", "mallory");
710        let result = validate_bootstrap_edit(&proposed, &EditAuthor::Archivist, Some(&previous));
711        assert!(result.is_blocked());
712        let codes: Vec<&str> = result.violations.iter().map(|v| v.code.as_str()).collect();
713        assert_eq!(codes, vec![codes::ARCHIVIST_AUTHORED_BOOTSTRAP]);
714        assert_eq!(result.previous_policy_hash, Some(previous.hash));
715        assert!(result.proposed_policy_hash.is_some());
716    }
717
718    #[test]
719    fn validate_bootstrap_edit_routes_human_to_require_approval() {
720        let previous = BootstrapPolicy::parse(VALID_BOOTSTRAP);
721        let proposed = VALID_BOOTSTRAP.replace("alice", "carol");
722        let result =
723            validate_bootstrap_edit(&proposed, &EditAuthor::human("user:carol"), Some(&previous));
724        assert!(result.requires_approval(), "{result:?}");
725        let approver = match &result.verdict {
726            Verdict::RequireApproval { approver } => approver.clone(),
727            other => panic!("expected RequireApproval, got {other:?}"),
728        };
729        assert!(matches!(approver, Approver::Role { ref name } if name == "flow-platform"));
730        assert_eq!(result.previous_policy_hash, Some(previous.hash));
731    }
732
733    #[test]
734    fn validate_bootstrap_edit_initial_seed_uses_default_role() {
735        let result =
736            validate_bootstrap_edit("// initial seed\n", &EditAuthor::human("user:alice"), None);
737        assert!(result.requires_approval());
738        let approver = match &result.verdict {
739            Verdict::RequireApproval { approver } => approver.clone(),
740            other => panic!("expected RequireApproval, got {other:?}"),
741        };
742        assert!(matches!(
743            approver,
744            Approver::Role { ref name } if name == DEFAULT_MAINTAINER_ROLE
745        ));
746        assert_eq!(result.previous_policy_hash, None);
747    }
748
749    #[test]
750    fn validate_bootstrap_edit_blocks_unparseable_source() {
751        let proposed = r#"
752@invariant
753@deterministic
754@semantic
755@archivist(evidence: ["x"])
756fn bad(slice) { return true }
757"#;
758        let previous = BootstrapPolicy::parse(VALID_BOOTSTRAP);
759        let result =
760            validate_bootstrap_edit(proposed, &EditAuthor::human("user:alice"), Some(&previous));
761        assert!(result.is_blocked(), "{result:?}");
762    }
763
764    #[test]
765    fn discover_returns_none_when_file_missing() {
766        let tmp = TempDir::new().unwrap();
767        assert!(discover_bootstrap_policy(tmp.path()).is_none());
768    }
769
770    #[test]
771    fn discover_loads_meta_invariants_from_root() {
772        let tmp = TempDir::new().unwrap();
773        fs::write(tmp.path().join(META_INVARIANTS_FILE), VALID_BOOTSTRAP).unwrap();
774        let discovered = discover_bootstrap_policy(tmp.path()).expect("policy present");
775        assert!(discovered.path.ends_with(META_INVARIANTS_FILE));
776        assert_eq!(discovered.policy.maintainers.len(), 2);
777        assert_eq!(discovered.source, VALID_BOOTSTRAP);
778    }
779}