1use 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
38pub const META_INVARIANTS_FILE: &str = "meta-invariants.harn";
40
41pub const DEFAULT_MAINTAINER_ROLE: &str = "flow-platform";
46
47pub 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(tag = "kind", rename_all = "snake_case")]
67pub enum EditAuthor {
68 Archivist,
70 Human { id: String },
72 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99pub struct BootstrapViolation {
100 pub code: String,
101 pub message: String,
102 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
131pub struct BootstrapPolicy {
132 pub hash: PredicateHash,
134 pub maintainers: Vec<Approver>,
137}
138
139impl BootstrapPolicy {
140 pub fn parse(source: &str) -> Self {
148 Self::parse_with_diagnostics(source).0
149 }
150
151 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#[derive(Clone, Debug)]
181pub struct DiscoveredBootstrapPolicy {
182 pub path: PathBuf,
184 pub source: String,
187 pub policy: BootstrapPolicy,
189 pub diagnostics: Vec<DiscoveryDiagnostic>,
191}
192
193pub 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
216pub struct BootstrapValidation {
217 pub verdict: Verdict,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub previous_policy_hash: Option<PredicateHash>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub proposed_policy_hash: Option<PredicateHash>,
231 pub author: String,
233 #[serde(default, skip_serializing_if = "Vec::is_empty")]
235 pub violations: Vec<BootstrapViolation>,
236}
237
238impl BootstrapValidation {
239 pub fn is_blocked(&self) -> bool {
241 matches!(self.verdict, Verdict::Block { .. })
242 }
243
244 pub fn requires_approval(&self) -> bool {
246 matches!(self.verdict, Verdict::RequireApproval { .. })
247 }
248}
249
250pub 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
289pub 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 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
516fn 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}