1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use harn_vm::flow::{IntentClusterer, ObservedAtom, SqliteFlowStore, TextOp, VcsBackend};
7use serde::ser::SerializeStruct;
8use serde::Serialize;
9use serde_json::json;
10use time::format_description::well_known::Rfc3339;
11use time::{Date, Duration, OffsetDateTime, Time};
12
13use crate::cli::{
14 FlowArchivistCommand, FlowArgs, FlowCommand, FlowReplayAuditArgs, FlowShipCommand,
15};
16
17const SHIP_CAPTAIN_EVAL_PACKS: [&str; 4] = [
18 "slice_quality",
19 "false_ship_rate",
20 "coverage_fidelity",
21 "latency_pr_to_merge",
22];
23
24pub(crate) fn run_flow(args: &FlowArgs) -> Result<i32, String> {
25 match &args.command {
26 FlowCommand::ReplayAudit(replay) => run_replay_audit(replay),
27 FlowCommand::Ship(ship) => match &ship.command {
28 FlowShipCommand::Watch(watch) => run_ship_watch(watch),
29 },
30 FlowCommand::Archivist(archivist) => match &archivist.command {
31 FlowArchivistCommand::Scan(scan) => run_archivist_scan(scan),
32 },
33 }
34}
35
36pub(crate) fn run_replay_audit(args: &FlowReplayAuditArgs) -> Result<i32, String> {
37 let since = args.since.as_deref().map(parse_since).transpose()?;
38 if !args.store.is_file() {
39 return Err(format!(
40 "Flow store {} does not exist",
41 args.store.display()
42 ));
43 }
44 let store = SqliteFlowStore::open(&args.store, "replay-audit").map_err(|error| {
45 format!(
46 "failed to open Flow store {}: {error}",
47 args.store.display()
48 )
49 })?;
50
51 let chains = current_predicate_chains(&args.predicate_root, &args.touched_dirs);
52 let diagnostics = discovery_diagnostics(&chains);
53 if has_discovery_error(&diagnostics) {
54 return Err(render_discovery_diagnostics(&diagnostics));
55 }
56 if !args.json {
57 print_discovery_warnings(&diagnostics);
58 }
59
60 let current_predicates = harn_vm::flow::resolve_predicates_for_touched_directories(&chains);
61 let stored = store
62 .shipped_derived_slices_since(since)
63 .map_err(|error| format!("failed to list shipped slices: {error}"))?;
64 let created_at_by_slice = stored
65 .iter()
66 .map(|stored| (stored.slice.id, stored.created_at.clone()))
67 .collect::<std::collections::BTreeMap<_, _>>();
68 let report = harn_vm::flow::replay_audit_report(
69 stored.into_iter().map(|stored| stored.slice),
70 ¤t_predicates,
71 );
72
73 if args.json {
74 let json = serde_json::to_string_pretty(&report)
75 .map_err(|error| format!("failed to encode replay-audit report: {error}"))?;
76 println!("{json}");
77 } else {
78 print_human_report(
79 args.since.as_deref().unwrap_or("beginning"),
80 &report,
81 &created_at_by_slice,
82 );
83 }
84
85 Ok(i32::from(args.fail_on_drift && report.has_drift()))
86}
87
88#[derive(Debug, Clone)]
93pub struct FlowShipWatchInputs<'a> {
94 pub store: &'a Path,
95 pub predicate_root: &'a Path,
96 pub touched_dirs: &'a [PathBuf],
97 pub persona: &'a str,
98 pub mock_pr_out: Option<&'a Path>,
101}
102
103pub fn ship_watch_payload(inputs: &FlowShipWatchInputs<'_>) -> Result<serde_json::Value, String> {
110 let store = open_store(inputs.store)?;
111 let atom_refs = store
112 .list_atoms()
113 .map_err(|error| format!("failed to list Flow atoms: {error}"))?;
114 if atom_refs.is_empty() {
115 return Ok(json!({
119 "status": "idle",
120 "reason": "no_atoms",
121 "persona": inputs.persona,
122 "phase": "phase_0",
123 "mode": "shadow",
124 "autonomy": "propose_with_approval",
125 "receipts_required": true,
126 }));
127 }
128
129 let atoms = atom_refs
130 .iter()
131 .map(|atom_ref| {
132 store
133 .get_atom(atom_ref.atom_id)
134 .map_err(|error| format!("failed to load atom {}: {error}", atom_ref.atom_id))
135 })
136 .collect::<Result<Vec<_>, _>>()?;
137 let intents = IntentClusterer::default().cluster(
138 atoms
139 .iter()
140 .enumerate()
141 .map(|(index, atom)| ObservedAtom::from_atom(atom, (index + 1) as u64)),
142 );
143 let intent_payload = intents
144 .iter()
145 .map(|intent| {
146 json!({
147 "id": intent.id,
148 "goal_description": intent.goal_description,
149 "atoms": intent.atoms,
150 "confidence": intent.confidence,
151 "origin_transcript_span": intent.origin_transcript_span,
152 })
153 })
154 .collect::<Vec<_>>();
155
156 let chains = current_predicate_chains(inputs.predicate_root, inputs.touched_dirs);
157 let diagnostics = discovery_diagnostics(&chains);
158 if has_discovery_error(&diagnostics) {
159 return Err(render_discovery_diagnostics(&diagnostics));
160 }
161 let bootstrap_payload = bootstrap_policy_payload(inputs.predicate_root);
162 let predicates = harn_vm::flow::resolve_predicates_for_touched_directories(&chains);
163 let predicate_payload = predicates
164 .iter()
165 .map(|predicate| {
166 json!({
167 "qualified_name": predicate.qualified_name,
168 "logical_name": predicate.logical_name,
169 "hash": predicate.predicate.source_hash,
170 "kind": predicate.predicate.kind,
171 "relative_dir": predicate.source.relative_dir,
172 "retroactive": predicate.predicate.retroactive,
173 })
174 })
175 .collect::<Vec<_>>();
176 let ceiling = harn_vm::flow::PredicateCeiling::default();
177 let ceiling_outcome = harn_vm::flow::enforce_predicate_ceiling(&predicates, &ceiling);
178 let ceiling_payload = serialize_ceiling_outcome(&ceiling_outcome, &ceiling);
179 let validation_status = match ceiling_outcome.violation().map(|v| v.level) {
180 None => "ok",
181 Some(harn_vm::flow::PredicateCeilingLevel::RequireApproval) => "require_approval",
182 Some(harn_vm::flow::PredicateCeilingLevel::Block) => "blocked",
183 };
184
185 let atom_ids: Vec<_> = atom_refs.iter().map(|atom| atom.atom_id).collect();
186 let slice = store
187 .derive_slice(&atom_ids)
188 .map_err(|error| format!("failed to derive candidate slice: {error}"))?;
189 let ship_receipt = store
190 .ship_slice(&slice)
191 .map_err(|error| format!("failed to persist Ship Captain receipt: {error}"))?;
192 let created_at = OffsetDateTime::now_utc()
193 .format(&Rfc3339)
194 .map_err(|error| format!("failed to format receipt timestamp: {error}"))?;
195 let mock_pr = json!({
196 "number": 0,
197 "state": "open",
198 "url": format!("mock://github/pull/{}", slice.id),
199 "title": format!("Flow slice {}", slice.id),
200 "body": format!(
201 "Shadow-mode Ship Captain candidate slice.\n\nAtoms: {}\nIntents: {}\nPredicates discovered: {}\nValidation: {}\n\nNo remote PR was opened.",
202 slice.atoms.len(),
203 intents.len(),
204 predicates.len(),
205 validation_status,
206 ),
207 "requires_approval": true,
208 "validation_status": validation_status,
209 });
210 let payload = json!({
211 "status": "mock_pr_opened",
212 "persona": inputs.persona,
213 "phase": "phase_0",
214 "mode": "shadow",
215 "autonomy": "propose_with_approval",
216 "receipts_required": true,
217 "created_at": created_at,
218 "slice": {
219 "id": slice.id,
220 "atoms": slice.atoms,
221 "atom_count": slice.atoms.len(),
222 },
223 "intents": intent_payload,
224 "predicate_validation": {
225 "predicate_root": inputs.predicate_root,
226 "touched_dirs": if inputs.touched_dirs.is_empty() {
227 vec![PathBuf::from(".")]
228 } else {
229 inputs.touched_dirs.to_vec()
230 },
231 "status": validation_status,
232 "predicates": predicate_payload,
233 "ceiling": ceiling_payload,
234 "bootstrap_policy": bootstrap_payload,
235 "diagnostics": diagnostics.iter().map(|(path, diagnostic)| json!({
236 "path": path,
237 "severity": discovery_severity_label(diagnostic.severity),
238 "message": diagnostic.message,
239 })).collect::<Vec<_>>(),
240 },
241 "ship_receipt": {
242 "slice_id": ship_receipt.slice_id,
243 "commit": ship_receipt.commit,
244 "ref_name": ship_receipt.ref_name,
245 },
246 "mock_pr": mock_pr,
247 "eval_packs": SHIP_CAPTAIN_EVAL_PACKS,
248 });
249
250 if let Some(path) = inputs.mock_pr_out {
251 write_json(path, &payload)
252 .map_err(|error| format!("failed to write mock PR receipt: {error}"))?;
253 }
254 Ok(payload)
255}
256
257fn run_ship_watch(args: &crate::cli::FlowShipWatchArgs) -> Result<i32, String> {
258 let inputs = FlowShipWatchInputs {
259 store: &args.store,
260 predicate_root: &args.predicate_root,
261 touched_dirs: &args.touched_dirs,
262 persona: &args.persona,
263 mock_pr_out: args.mock_pr_out.as_deref(),
264 };
265
266 if !args.json {
267 let chains = current_predicate_chains(&args.predicate_root, &args.touched_dirs);
268 let diagnostics = discovery_diagnostics(&chains);
269 if !has_discovery_error(&diagnostics) {
270 print_discovery_warnings(&diagnostics);
271 }
272 }
273
274 let payload = ship_watch_payload(&inputs)?;
275 let summary = match payload.get("status").and_then(|status| status.as_str()) {
276 Some("idle") => "Ship Captain idle: no atoms in the Flow store.".to_string(),
277 _ => match payload
278 .get("slice")
279 .and_then(|slice| slice.get("id"))
280 .and_then(|id| id.as_str())
281 {
282 Some(slice_id) => format!("mock PR opened for candidate slice {slice_id}"),
283 None => "Ship Captain receipt emitted.".to_string(),
284 },
285 };
286 print_payload(args.json, &summary, &payload);
287 Ok(0)
288}
289
290fn serialize_ceiling_outcome(
291 outcome: &harn_vm::flow::PredicateCeilingOutcome,
292 ceiling: &harn_vm::flow::PredicateCeiling,
293) -> serde_json::Value {
294 use harn_vm::flow::{PredicateCeilingLevel, PredicateCeilingOutcome};
295 let mut payload = json!({
296 "count": outcome.count(),
297 "require_approval_threshold": ceiling.require_approval_threshold,
298 "block_threshold": ceiling.block_threshold,
299 });
300 match outcome {
301 PredicateCeilingOutcome::Within { .. } => {
302 payload["status"] = json!("within");
303 }
304 PredicateCeilingOutcome::Exceeded(violation) => {
305 payload["status"] = json!(match violation.level {
306 PredicateCeilingLevel::RequireApproval => "require_approval",
307 PredicateCeilingLevel::Block => "blocked",
308 });
309 payload["threshold"] = json!(violation.threshold);
310 payload["message"] = json!(violation.message());
311 payload["top_contributors"] = json!(violation
312 .top_contributors
313 .iter()
314 .map(|item| json!({
315 "relative_dir": item.relative_dir,
316 "count": item.count,
317 }))
318 .collect::<Vec<_>>());
319 }
320 }
321 payload
322}
323
324fn bootstrap_policy_payload(predicate_root: &Path) -> serde_json::Value {
325 use harn_vm::flow::Approver;
326 let Some(discovered) = harn_vm::flow::discover_bootstrap_policy(predicate_root) else {
327 return json!({
328 "status": "absent",
329 "path": predicate_root.join(harn_vm::flow::META_INVARIANTS_FILE),
330 });
331 };
332 let maintainers = discovered
333 .policy
334 .maintainers
335 .iter()
336 .map(|approver| match approver {
337 Approver::Role { name } => json!({"kind": "role", "id": name}),
338 Approver::Principal { id } => json!({"kind": "principal", "id": id}),
339 })
340 .collect::<Vec<_>>();
341 let diagnostics = discovered
342 .diagnostics
343 .iter()
344 .map(|diagnostic| {
345 json!({
346 "severity": discovery_severity_label(diagnostic.severity),
347 "message": diagnostic.message,
348 })
349 })
350 .collect::<Vec<_>>();
351 json!({
352 "status": "present",
353 "path": discovered.path,
354 "hash": discovered.policy.hash,
355 "maintainers": maintainers,
356 "diagnostics": diagnostics,
357 })
358}
359
360fn discovery_severity_label(severity: harn_vm::flow::DiscoveryDiagnosticSeverity) -> &'static str {
361 match severity {
362 harn_vm::flow::DiscoveryDiagnosticSeverity::Warning => "warning",
363 harn_vm::flow::DiscoveryDiagnosticSeverity::Error => "error",
364 }
365}
366
367fn run_archivist_scan(args: &crate::cli::FlowArchivistScanArgs) -> Result<i32, String> {
368 let repo = args
369 .repo
370 .canonicalize()
371 .unwrap_or_else(|_| args.repo.clone());
372 let source_date = OffsetDateTime::now_utc().date().to_string();
373 let inventory = inventory_repo(&repo);
374 let stack_hints = inventory.stack_hints.clone();
375 let manifest = load_archivist_manifest(&repo, args.manifest.as_deref());
376 let invariant_files = find_invariant_dirs(&repo);
377 let mut seen = BTreeSet::new();
378 let mut predicates = Vec::new();
379 let mut discovery_diagnostics = Vec::new();
380 for dir in &invariant_files {
381 for file in harn_vm::flow::discover_invariants(&repo, dir) {
382 let relative_dir = file.relative_dir.clone();
383 for diagnostic in &file.diagnostics {
384 discovery_diagnostics.push(json!({
385 "relative_dir": relative_dir,
386 "path": file.path,
387 "severity": format!("{:?}", diagnostic.severity).to_lowercase(),
388 "message": diagnostic.message,
389 }));
390 }
391 for predicate in file.predicates {
392 if !seen.insert(predicate.source_hash.clone()) {
393 continue;
394 }
395 predicates.push(json!({
396 "name": predicate.name,
397 "hash": predicate.source_hash,
398 "kind": predicate.kind,
399 "fallback": predicate.fallback,
400 "relative_dir": relative_dir.clone(),
401 "retroactive": predicate.retroactive,
402 "archivist": predicate.archivist.map(|archivist| json!({
403 "evidence": archivist.evidence,
404 "confidence": archivist.confidence,
405 "source_date": archivist.source_date,
406 "coverage_examples": archivist.coverage_examples,
407 })),
408 }));
409 }
410 }
411 }
412 let convention = mine_convention_signals(&repo);
413 let motion = mine_motion_signals(&repo);
414 let bootstrap_payload = bootstrap_policy_payload(&repo);
415 let proposals = archivist_proposals(
416 &repo,
417 &inventory,
418 &convention,
419 &motion,
420 predicates.is_empty(),
421 &source_date,
422 );
423 let shadow_evaluation = shadow_evaluate(&repo, &args.store, args.shadow_days, &proposals)?;
424 let payload = json!({
425 "status": "proposal_set",
426 "persona": {
427 "name": "archivist",
428 "mode": "propose_only",
429 "autonomy": "propose_only",
430 "promotion": "human_review_required",
431 },
432 "repo": repo,
433 "manifest": manifest,
434 "inventory": inventory,
435 "stack_hints": stack_hints,
436 "convention_signals": convention,
437 "motion_signals": motion,
438 "seed_library": {
439 "repository": "https://github.com/burin-labs/harn-canon",
440 "strategy": "detected-stack seeds are copied into proposals, then repo-local evidence prunes them before review",
441 },
442 "existing_predicates": predicates,
443 "discovery_diagnostics": discovery_diagnostics,
444 "bootstrap_policy": bootstrap_payload,
445 "proposals": proposals,
446 "shadow_evaluation": shadow_evaluation,
447 });
448
449 if let Some(path) = &args.out {
450 write_json(path, &payload)
451 .map_err(|error| format!("failed to write Archivist proposal set: {error}"))?;
452 }
453 print_payload(args.json, "Archivist proposal set emitted.", &payload);
454 Ok(0)
455}
456
457#[derive(Clone, Debug, Default, Serialize)]
458struct RepoInventory {
459 stack_hints: Vec<&'static str>,
460 lockfiles: Vec<String>,
461 config_files: Vec<String>,
462 source_roots: Vec<String>,
463}
464
465#[derive(Clone, Debug, Serialize)]
466struct Signal {
467 kind: &'static str,
468 path: String,
469 detail: String,
470}
471
472#[derive(Clone, Debug, Serialize)]
473struct MotionSignal {
474 kind: &'static str,
475 count: usize,
476 examples: Vec<String>,
477}
478
479#[derive(Clone, Debug)]
480struct ArchivistProposal {
481 id: &'static str,
482 title: &'static str,
483 path: String,
484 rationale: String,
485 predicate_name: &'static str,
486 match_terms: Vec<&'static str>,
487 evidence: Vec<String>,
488 confidence: f64,
489 coverage_examples: Vec<String>,
490 source: String,
491}
492
493impl Serialize for ArchivistProposal {
494 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
495 let mut state = serializer.serialize_struct("ArchivistProposal", 11)?;
496 state.serialize_field("id", self.id)?;
497 state.serialize_field("title", self.title)?;
498 state.serialize_field("path", &self.path)?;
499 state.serialize_field("rationale", &self.rationale)?;
500 state.serialize_field("predicate_name", self.predicate_name)?;
501 state.serialize_field("autonomy", "propose_only")?;
502 state.serialize_field("promotion", "human_review_required")?;
503 state.serialize_field("evidence", &self.evidence)?;
504 state.serialize_field("confidence", &self.confidence)?;
505 state.serialize_field("coverage_examples", &self.coverage_examples)?;
506 state.serialize_field("predicate_source", &self.source)?;
507 state.end()
508 }
509}
510
511fn inventory_repo(repo: &Path) -> RepoInventory {
512 let mut inventory = RepoInventory::default();
513 let known = [
514 ("Cargo.toml", "rust", "config"),
515 ("Cargo.lock", "rust", "lockfile"),
516 ("rust-toolchain.toml", "rust", "config"),
517 ("rustfmt.toml", "rust", "config"),
518 ("clippy.toml", "rust", "config"),
519 ("package.json", "javascript", "config"),
520 ("package-lock.json", "javascript", "lockfile"),
521 ("pnpm-lock.yaml", "javascript", "lockfile"),
522 ("yarn.lock", "javascript", "lockfile"),
523 ("tsconfig.json", "typescript", "config"),
524 ("pyproject.toml", "python", "config"),
525 ("poetry.lock", "python", "lockfile"),
526 ("uv.lock", "python", "lockfile"),
527 ("go.mod", "go", "config"),
528 ("go.sum", "go", "lockfile"),
529 ("Package.swift", "swift", "config"),
530 ];
531 for (path, stack, kind) in known {
532 if repo.join(path).exists() {
533 push_unique(&mut inventory.stack_hints, stack);
534 match kind {
535 "lockfile" => inventory.lockfiles.push(path.to_string()),
536 _ => inventory.config_files.push(path.to_string()),
537 }
538 }
539 }
540 if repo.join(".github/workflows").is_dir() {
541 inventory.config_files.push(".github/workflows".to_string());
542 }
543 for root in ["crates", "src", "docs/src", "conformance/tests", "examples"] {
544 if repo.join(root).exists() {
545 inventory.source_roots.push(root.to_string());
546 }
547 }
548 inventory
549}
550
551fn push_unique(values: &mut Vec<&'static str>, value: &'static str) {
552 if !values.contains(&value) {
553 values.push(value);
554 }
555}
556
557fn load_archivist_manifest(repo: &Path, explicit: Option<&Path>) -> serde_json::Value {
558 let explicit_manifest = explicit.is_some();
559 let candidates = explicit
560 .map(|path| vec![path.to_path_buf()])
561 .unwrap_or_else(|| {
562 [
563 repo.join("harn.toml"),
564 repo.join("examples/personas/flow.harn.toml"),
565 repo.join("examples/personas/harn.toml"),
566 ]
567 .into_iter()
568 .filter(|path| path.is_file())
569 .collect()
570 });
571 let mut loaded_without_archivist = None;
572 let mut first_invalid = None;
573 for candidate in candidates {
574 match crate::package::load_personas_from_manifest_path(&candidate) {
575 Ok(catalog) => {
576 let archivist = catalog
577 .personas
578 .iter()
579 .find(|persona| persona.name.as_deref() == Some("archivist"));
580 if let Some(persona) = archivist {
581 return json!({
582 "status": "loaded",
583 "path": catalog.manifest_path,
584 "persona": persona,
585 });
586 }
587 loaded_without_archivist.get_or_insert_with(|| json!({
588 "status": "loaded_without_archivist",
589 "path": catalog.manifest_path,
590 "personas": catalog.personas.iter().filter_map(|p| p.name.clone()).collect::<Vec<_>>(),
591 }));
592 }
593 Err(errors) => {
594 let invalid = json!({
595 "status": "invalid",
596 "path": candidate,
597 "errors": errors.iter().map(ToString::to_string).collect::<Vec<_>>(),
598 });
599 if explicit_manifest {
600 return invalid;
601 }
602 first_invalid.get_or_insert(invalid);
603 }
604 }
605 }
606 if let Some(loaded) = loaded_without_archivist {
607 return loaded;
608 }
609 if let Some(invalid) = first_invalid {
610 return invalid;
611 }
612 json!({
613 "status": "not_found",
614 "searched": ["harn.toml", "examples/personas/flow.harn.toml", "examples/personas/harn.toml"],
615 })
616}
617
618fn mine_convention_signals(repo: &Path) -> Vec<Signal> {
619 let mut signals = Vec::new();
620 for path in walk_repo_files(repo, 4_000) {
621 let relative = relative_path(repo, &path);
622 let file_name = path
623 .file_name()
624 .and_then(|name| name.to_str())
625 .unwrap_or("");
626 if matches!(
627 file_name,
628 "rustfmt.toml" | "clippy.toml" | "deny.toml" | ".markdownlint.json" | ".prettierrc"
629 ) {
630 signals.push(Signal {
631 kind: "lint_config",
632 path: relative.clone(),
633 detail: "repo-local style or lint policy".to_string(),
634 });
635 }
636 if relative.ends_with(".harn")
637 || relative.ends_with(".rs")
638 || relative.ends_with(".md")
639 || relative.ends_with(".toml")
640 {
641 if let Ok(source) = fs::read_to_string(&path) {
642 for (index, line) in source.lines().enumerate() {
643 let trimmed = line.trim_start();
644 let is_comment = trimmed.starts_with("//")
645 || trimmed.starts_with('#')
646 || trimmed.starts_with("<!--");
647 if is_comment {
648 if let Some(pos) = trimmed.to_ascii_lowercase().find("invariant:") {
649 signals.push(Signal {
650 kind: "inline_invariant",
651 path: format!("{relative}:{}", index + 1),
652 detail: trimmed[pos..].trim().chars().take(180).collect(),
653 });
654 }
655 }
656 if signals.len() >= 80 {
657 return signals;
658 }
659 }
660 }
661 }
662 }
663 signals
664}
665
666fn mine_motion_signals(repo: &Path) -> Vec<MotionSignal> {
667 let output = Command::new("git")
668 .arg("-C")
669 .arg(repo)
670 .args([
671 "log",
672 "--since=90 days ago",
673 "--pretty=%s",
674 "--max-count=200",
675 ])
676 .output();
677 let Ok(output) = output else {
678 return Vec::new();
679 };
680 if !output.status.success() {
681 return Vec::new();
682 }
683 let stdout = String::from_utf8_lossy(&output.stdout);
684 let buckets: [(&str, &[&str]); 4] = [
685 ("tests", &["test", "coverage", "conformance"]),
686 ("lint_format", &["lint", "format", "fmt", "clippy"]),
687 (
688 "flow_predicates",
689 &["flow", "predicate", "invariant", "archivist"],
690 ),
691 ("release_docs", &["release", "docs", "changelog"]),
692 ];
693 let mut counts: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
694 for subject in stdout.lines() {
695 let lower = subject.to_ascii_lowercase();
696 for (kind, terms) in buckets {
697 if terms.iter().any(|term| lower.contains(term)) {
698 counts
699 .entry(kind)
700 .or_default()
701 .push(subject.chars().take(140).collect());
702 }
703 }
704 }
705 counts
706 .into_iter()
707 .map(|(kind, examples)| MotionSignal {
708 kind,
709 count: examples.len(),
710 examples: examples.into_iter().take(5).collect(),
711 })
712 .collect()
713}
714
715fn archivist_proposals(
716 repo: &Path,
717 inventory: &RepoInventory,
718 convention: &[Signal],
719 motion: &[MotionSignal],
720 no_existing_predicates: bool,
721 source_date: &str,
722) -> Vec<ArchivistProposal> {
723 let mut proposals = Vec::new();
724 if no_existing_predicates {
725 proposals.push(bootstrap_proposal(source_date));
726 }
727 if inventory.stack_hints.contains(&"rust") {
728 proposals.push(rust_unsafe_proposal(repo, source_date));
729 proposals.push(rust_panics_proposal(repo, source_date));
730 }
731 if inventory
732 .config_files
733 .iter()
734 .any(|path| path == ".github/workflows")
735 {
736 proposals.push(github_actions_permissions_proposal(source_date));
737 }
738 if motion
739 .iter()
740 .any(|signal| signal.kind == "tests" && signal.count >= 3)
741 {
742 proposals.push(test_motion_proposal(source_date));
743 }
744 let inline_signals = convention
745 .iter()
746 .filter(|signal| signal.kind == "inline_invariant")
747 .take(5)
748 .collect::<Vec<_>>();
749 if !inline_signals.is_empty() {
750 proposals.push(inline_invariant_proposal(&inline_signals, source_date));
751 }
752 proposals
753}
754
755fn bootstrap_proposal(source_date: &str) -> ArchivistProposal {
756 let evidence = vec![
757 "https://slsa.dev/spec/v1.0/provenance".to_string(),
758 "https://in-toto.io/attestation-spec/".to_string(),
759 ];
760 let coverage_examples = vec![
761 "invariants.harn".to_string(),
762 "meta-invariants.harn".to_string(),
763 ];
764 proposal(
765 "bootstrap-meta-invariants",
766 "Seed repo-wide predicate authorship metadata",
767 "invariants.harn",
768 "The repository has no discovered Flow predicates; seed review-only bootstrap metadata before expanding policy.",
769 "predicate_metadata_is_reviewable",
770 vec!["@archivist", "@semantic", "@deterministic"],
771 evidence,
772 0.72,
773 coverage_examples,
774 source_date,
775 "flow_invariant_warn(\"bootstrap predicate metadata should be reviewed by a human maintainer\")",
776 )
777}
778
779fn rust_unsafe_proposal(repo: &Path, source_date: &str) -> ArchivistProposal {
780 let examples = files_containing(repo, "unsafe", &["rs"], 5);
781 proposal(
782 "rust-unsafe-safety-comment",
783 "Require review evidence near new Rust unsafe blocks",
784 "invariants.harn",
785 "Rust is detected and unsafe blocks are a recurring high-value review boundary; propose a deterministic guard that warns on unsafe additions without nearby safety rationale.",
786 "rust_unsafe_requires_safety_comment",
787 vec!["unsafe", "SAFETY:"],
788 vec![
789 "https://doc.rust-lang.org/clippy/lint_configuration.html#undocumented_unsafe_blocks".to_string(),
790 "https://rust-lang.github.io/api-guidelines/documentation.html".to_string(),
791 ],
792 0.82,
793 examples,
794 source_date,
795 "flow_invariant_warn(\"new unsafe code should include nearby SAFETY rationale or explicit reviewer approval\")",
796 )
797}
798
799fn rust_panics_proposal(repo: &Path, source_date: &str) -> ArchivistProposal {
800 let mut examples = files_containing(repo, "panic!", &["rs"], 5);
801 examples.extend(files_containing(
802 repo,
803 ".unwrap()",
804 &["rs"],
805 5 - examples.len().min(5),
806 ));
807 proposal(
808 "rust-library-panic-surface",
809 "Flag new library panic surfaces without tests or documentation",
810 "invariants.harn",
811 "The Rust API Guidelines call out documented panic conditions; Flow can cheaply warn when atoms add panic-prone surfaces in library crates.",
812 "rust_library_panics_are_documented",
813 vec!["panic!", "unwrap()", "expect("],
814 vec![
815 "https://rust-lang.github.io/api-guidelines/documentation.html#c-failure".to_string(),
816 "https://rust-lang.github.io/rust-clippy/beta/".to_string(),
817 ],
818 0.76,
819 examples,
820 source_date,
821 "flow_invariant_warn(\"new panic-prone Rust paths should include tests or documented panic conditions\")",
822 )
823}
824
825fn github_actions_permissions_proposal(source_date: &str) -> ArchivistProposal {
826 proposal(
827 "github-actions-minimal-permissions",
828 "Warn on workflow edits without explicit permissions",
829 ".github/invariants.harn",
830 "GitHub workflow files are present; explicit job/workflow permissions make CI authority reviewable and reduce supply-chain blast radius.",
831 "github_actions_permissions_are_explicit",
832 vec!["permissions:", "uses:"],
833 vec![
834 "https://docs.github.com/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions".to_string(),
835 "https://docs.github.com/code-security/supply-chain-security/understanding-your-software-supply-chain/about-supply-chain-security".to_string(),
836 ],
837 0.79,
838 vec![".github/workflows".to_string()],
839 source_date,
840 "flow_invariant_warn(\"workflow edits should keep explicit least-privilege permissions\")",
841 )
842}
843
844fn test_motion_proposal(source_date: &str) -> ArchivistProposal {
845 proposal(
846 "motion-tests-near-flow-changes",
847 "Keep test coverage close to recurring Flow changes",
848 "invariants.harn",
849 "Recent history repeatedly touches tests/conformance around Flow work; propose a warning when Flow atoms lack nearby test coverage evidence.",
850 "flow_changes_keep_tests_nearby",
851 vec!["flow", "predicate", "conformance", "test"],
852 vec![
853 "git log --since='90 days ago' --pretty=%s".to_string(),
854 "conformance/tests/".to_string(),
855 ],
856 0.68,
857 vec!["crates/harn-vm/src/flow".to_string(), "conformance/tests".to_string()],
858 source_date,
859 "flow_invariant_warn(\"Flow predicate/runtime changes should carry focused tests or conformance coverage\")",
860 )
861}
862
863fn inline_invariant_proposal(signals: &[&Signal], source_date: &str) -> ArchivistProposal {
864 let id = "inline-invariant-crystallization";
865 let examples = signals
866 .iter()
867 .map(|signal| signal.path.clone())
868 .collect::<Vec<_>>();
869 proposal(
870 id,
871 "Crystallize inline invariant comment into Flow predicate",
872 "invariants.harn",
873 "Found inline invariant comments; propose turning recurring comments into reviewable predicate metadata.",
874 "inline_invariant_comment_is_crystallized",
875 vec!["invariant:"],
876 examples.clone(),
877 0.64,
878 examples,
879 source_date,
880 "flow_invariant_warn(\"inline invariant comments should graduate into reviewable Flow predicates when they recur\")",
881 )
882}
883
884#[allow(clippy::too_many_arguments)]
885fn proposal(
886 id: &'static str,
887 title: &'static str,
888 path: &str,
889 rationale: &str,
890 predicate_name: &'static str,
891 match_terms: Vec<&'static str>,
892 evidence: Vec<String>,
893 confidence: f64,
894 coverage_examples: Vec<String>,
895 source_date: &str,
896 result_expr: &str,
897) -> ArchivistProposal {
898 let evidence_harn = evidence
899 .iter()
900 .map(|item| format!("{item:?}"))
901 .collect::<Vec<_>>()
902 .join(", ");
903 let coverage_harn = coverage_examples
904 .iter()
905 .map(|item| format!("{item:?}"))
906 .collect::<Vec<_>>()
907 .join(", ");
908 let source = format!(
909 "@invariant\n@deterministic\n@archivist(evidence: [{evidence_harn}], confidence: {confidence:.2}, source_date: {source_date:?}, coverage_examples: [{coverage_harn}])\nfn {predicate_name}(slice) {{\n return {result_expr}\n}}\n"
910 );
911 ArchivistProposal {
912 id,
913 title,
914 path: path.to_string(),
915 rationale: rationale.to_string(),
916 predicate_name,
917 match_terms,
918 evidence,
919 confidence,
920 coverage_examples,
921 source,
922 }
923}
924
925fn shadow_evaluate(
926 repo: &Path,
927 store_path: &Path,
928 shadow_days: u32,
929 proposals: &[ArchivistProposal],
930) -> Result<serde_json::Value, String> {
931 let store_path = if store_path.is_absolute() {
932 store_path.to_path_buf()
933 } else {
934 repo.join(store_path)
935 };
936 if !store_path.is_file() {
937 return Ok(json!({
938 "status": "no_flow_store",
939 "store": store_path,
940 "window_days": shadow_days,
941 "recent_atoms": 0,
942 "proposal_results": empty_shadow_results(proposals),
943 "false_positive_candidates": [],
944 }));
945 }
946 let store = SqliteFlowStore::open(&store_path, "archivist-shadow").map_err(|error| {
947 format!(
948 "failed to open Flow store {}: {error}",
949 store_path.display()
950 )
951 })?;
952 let since = OffsetDateTime::now_utc() - Duration::days(i64::from(shadow_days));
953 let refs = store
954 .list_atoms()
955 .map_err(|error| format!("failed to list Flow atoms: {error}"))?;
956 let mut recent_atoms = Vec::new();
957 for atom_ref in refs {
958 let atom = store
959 .get_atom(atom_ref.atom_id)
960 .map_err(|error| format!("failed to load Flow atom {}: {error}", atom_ref.atom_id))?;
961 if atom.provenance.timestamp >= since {
962 recent_atoms.push(atom);
963 }
964 }
965
966 let mut false_positive_candidates = Vec::new();
967 let mut results = Vec::new();
968 for proposal in proposals {
969 let mut matched_atoms = 0usize;
970 for atom in &recent_atoms {
971 let inserted = inserted_text(atom);
972 if proposal.match_terms.iter().any(|term| {
973 inserted
974 .to_ascii_lowercase()
975 .contains(&term.to_ascii_lowercase())
976 }) {
977 matched_atoms += 1;
978 if likely_false_positive(proposal, &inserted) {
979 false_positive_candidates.push(json!({
980 "proposal_id": proposal.id,
981 "atom": atom.id,
982 "transcript_ref": atom.provenance.transcript_ref,
983 "diff_span": first_insert_span(atom),
984 "reason": "heuristic match may already contain satisfying context",
985 }));
986 }
987 }
988 }
989 results.push(json!({
990 "proposal_id": proposal.id,
991 "recent_atoms": recent_atoms.len(),
992 "matching_atoms": matched_atoms,
993 "estimated_coverage": if recent_atoms.is_empty() { 0.0 } else { matched_atoms as f64 / recent_atoms.len() as f64 },
994 }));
995 }
996 Ok(json!({
997 "status": "evaluated",
998 "store": store_path,
999 "window_days": shadow_days,
1000 "recent_atoms": recent_atoms.len(),
1001 "proposal_results": results,
1002 "false_positive_candidates": false_positive_candidates,
1003 }))
1004}
1005
1006fn empty_shadow_results(proposals: &[ArchivistProposal]) -> Vec<serde_json::Value> {
1007 proposals
1008 .iter()
1009 .map(|proposal| {
1010 json!({
1011 "proposal_id": proposal.id,
1012 "recent_atoms": 0,
1013 "matching_atoms": 0,
1014 "estimated_coverage": 0.0,
1015 })
1016 })
1017 .collect()
1018}
1019
1020fn inserted_text(atom: &harn_vm::flow::Atom) -> String {
1021 atom.ops
1022 .iter()
1023 .filter_map(|op| match op {
1024 TextOp::Insert { content, .. } => Some(content.as_str()),
1025 TextOp::Delete { .. } => None,
1026 })
1027 .collect::<Vec<_>>()
1028 .join("\n")
1029}
1030
1031fn first_insert_span(atom: &harn_vm::flow::Atom) -> serde_json::Value {
1032 atom.ops
1033 .iter()
1034 .find_map(|op| match op {
1035 TextOp::Insert { offset, content } => Some(json!({
1036 "start": offset,
1037 "end": offset.saturating_add(content.len() as u64),
1038 })),
1039 TextOp::Delete { .. } => None,
1040 })
1041 .unwrap_or_else(|| json!({"start": 0, "end": 0}))
1042}
1043
1044fn likely_false_positive(proposal: &ArchivistProposal, inserted: &str) -> bool {
1045 match proposal.id {
1046 "rust-unsafe-safety-comment" => {
1047 inserted.contains("unsafe") && inserted.to_ascii_lowercase().contains("safety")
1048 }
1049 "github-actions-minimal-permissions" => {
1050 inserted.contains("permissions:") && inserted.contains("uses:")
1051 }
1052 _ => false,
1053 }
1054}
1055
1056fn files_containing(repo: &Path, needle: &str, extensions: &[&str], limit: usize) -> Vec<String> {
1057 if limit == 0 {
1058 return Vec::new();
1059 }
1060 let needle = needle.to_ascii_lowercase();
1061 let mut matches = Vec::new();
1062 for path in walk_repo_files(repo, 4_000) {
1063 let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
1064 continue;
1065 };
1066 if !extensions.contains(&ext) {
1067 continue;
1068 }
1069 let Ok(source) = fs::read_to_string(&path) else {
1070 continue;
1071 };
1072 if source.to_ascii_lowercase().contains(&needle) {
1073 matches.push(relative_path(repo, &path));
1074 if matches.len() >= limit {
1075 break;
1076 }
1077 }
1078 }
1079 matches
1080}
1081
1082fn walk_repo_files(repo: &Path, limit: usize) -> Vec<PathBuf> {
1083 let mut files = Vec::new();
1084 collect_repo_files(repo, repo, limit, &mut files);
1085 files
1086}
1087
1088fn collect_repo_files(root: &Path, dir: &Path, limit: usize, out: &mut Vec<PathBuf>) {
1089 if out.len() >= limit {
1090 return;
1091 }
1092 let Ok(entries) = fs::read_dir(dir) else {
1093 return;
1094 };
1095 let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
1096 entries.sort_by_key(|entry| entry.path());
1097 for entry in entries {
1098 if out.len() >= limit {
1099 return;
1100 }
1101 let path = entry.path();
1102 let name = path
1103 .file_name()
1104 .and_then(|name| name.to_str())
1105 .unwrap_or_default();
1106 if path.is_dir() {
1107 if should_skip_scan_dir(name) {
1108 continue;
1109 }
1110 collect_repo_files(root, &path, limit, out);
1111 } else if path.is_file() {
1112 let relative = relative_path(root, &path);
1113 if !relative.ends_with(".lock")
1114 || matches!(name, "Cargo.lock" | "package-lock.json" | "yarn.lock")
1115 {
1116 out.push(path);
1117 }
1118 }
1119 }
1120}
1121
1122fn should_skip_scan_dir(name: &str) -> bool {
1123 matches!(
1124 name,
1125 ".git"
1126 | "target"
1127 | "node_modules"
1128 | "docs/dist"
1129 | ".harn"
1130 | ".harn-runs"
1131 | ".claude"
1132 | ".burin"
1133 )
1134}
1135
1136fn relative_path(root: &Path, path: &Path) -> String {
1137 path.strip_prefix(root)
1138 .unwrap_or(path)
1139 .components()
1140 .filter_map(|component| match component {
1141 std::path::Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
1142 _ => None,
1143 })
1144 .collect::<Vec<_>>()
1145 .join("/")
1146}
1147
1148fn current_predicate_chains(
1149 root: &Path,
1150 touched_dirs: &[PathBuf],
1151) -> Vec<Vec<harn_vm::flow::DiscoveredInvariantFile>> {
1152 let dirs: Vec<PathBuf> = if touched_dirs.is_empty() {
1153 vec![PathBuf::from(".")]
1154 } else {
1155 touched_dirs.to_vec()
1156 };
1157 dirs.into_iter()
1158 .map(|dir| harn_vm::flow::discover_invariants(root, &dir))
1159 .collect()
1160}
1161
1162fn open_store(path: &Path) -> Result<SqliteFlowStore, String> {
1163 if let Some(parent) = path
1164 .parent()
1165 .filter(|parent| !parent.as_os_str().is_empty())
1166 {
1167 fs::create_dir_all(parent).map_err(|error| error.to_string())?;
1168 }
1169 SqliteFlowStore::open(path, "flow-cli").map_err(|error| error.to_string())
1170}
1171
1172fn find_invariant_dirs(root: &Path) -> Vec<PathBuf> {
1173 let mut dirs = Vec::new();
1174 collect_invariant_dirs(root, root, &mut dirs);
1175 dirs
1176}
1177
1178fn collect_invariant_dirs(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) {
1179 let Ok(entries) = fs::read_dir(dir) else {
1180 return;
1181 };
1182 let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
1183 entries.sort_by_key(|entry| entry.path());
1184 for entry in entries {
1185 let path = entry.path();
1186 if path.is_dir() {
1187 let name = path
1188 .file_name()
1189 .and_then(|name| name.to_str())
1190 .unwrap_or_default();
1191 if matches!(name, ".git" | "target" | "node_modules") {
1192 continue;
1193 }
1194 collect_invariant_dirs(root, &path, out);
1195 } else if path.file_name().and_then(|name| name.to_str()) == Some("invariants.harn") {
1196 out.push(path.parent().unwrap_or(root).to_path_buf());
1197 }
1198 }
1199}
1200
1201fn print_human_report(
1202 since: &str,
1203 report: &harn_vm::flow::ReplayAuditReport,
1204 created_at_by_slice: &std::collections::BTreeMap<harn_vm::flow::SliceId, String>,
1205) {
1206 println!(
1207 "Audited {} shipped derived slice(s) since {since}; {} slice(s) have advisory drift.",
1208 report.audited_slices, report.drifted_slices
1209 );
1210 if report.slices.is_empty() {
1211 return;
1212 }
1213 for slice in &report.slices {
1214 let created_at = created_at_by_slice
1215 .get(&slice.slice_id)
1216 .map(String::as_str)
1217 .unwrap_or("unknown");
1218 println!("slice {} created_at={created_at}", slice.slice_id);
1219 if !slice.advisory_drift.is_empty() {
1220 println!(" current @retroactive predicates not pinned:");
1221 for predicate in &slice.advisory_drift {
1222 println!(" - {} {}", predicate.name, predicate.hash.as_str());
1223 }
1224 }
1225 if !slice.historical_only_predicates.is_empty() {
1226 println!(" historical predicate hashes no longer in current set:");
1227 for hash in &slice.historical_only_predicates {
1228 println!(" - {}", hash.as_str());
1229 }
1230 }
1231 }
1232}
1233
1234fn discovery_diagnostics(
1235 chains: &[Vec<harn_vm::flow::DiscoveredInvariantFile>],
1236) -> Vec<(String, &harn_vm::flow::DiscoveryDiagnostic)> {
1237 chains
1238 .iter()
1239 .flat_map(|chain| chain.iter())
1240 .flat_map(|file| {
1241 file.diagnostics
1242 .iter()
1243 .map(move |diagnostic| (file.path.display().to_string(), diagnostic))
1244 })
1245 .collect()
1246}
1247
1248fn has_discovery_error(diagnostics: &[(String, &harn_vm::flow::DiscoveryDiagnostic)]) -> bool {
1249 diagnostics.iter().any(|(_, diagnostic)| {
1250 diagnostic.severity == harn_vm::flow::DiscoveryDiagnosticSeverity::Error
1251 })
1252}
1253
1254fn print_discovery_warnings(diagnostics: &[(String, &harn_vm::flow::DiscoveryDiagnostic)]) {
1255 for (path, diagnostic) in diagnostics.iter().filter(|(_, diagnostic)| {
1256 diagnostic.severity == harn_vm::flow::DiscoveryDiagnosticSeverity::Warning
1257 }) {
1258 eprintln!("warning: {path}: {}", diagnostic.message);
1259 }
1260}
1261
1262fn render_discovery_diagnostics(
1263 diagnostics: &[(String, &harn_vm::flow::DiscoveryDiagnostic)],
1264) -> String {
1265 diagnostics
1266 .iter()
1267 .map(|(path, diagnostic)| format!("{path}: {}", diagnostic.message))
1268 .collect::<Vec<_>>()
1269 .join("\n")
1270}
1271
1272fn write_json(path: &Path, value: &serde_json::Value) -> Result<(), std::io::Error> {
1273 if let Some(parent) = path
1274 .parent()
1275 .filter(|parent| !parent.as_os_str().is_empty())
1276 {
1277 fs::create_dir_all(parent)?;
1278 }
1279 fs::write(path, serde_json::to_vec_pretty(value).unwrap())
1280}
1281
1282fn print_payload(json_output: bool, text: &str, payload: &serde_json::Value) {
1283 if json_output {
1284 println!("{}", serde_json::to_string_pretty(payload).unwrap());
1285 } else {
1286 println!("{text}");
1287 }
1288}
1289
1290fn parse_since(raw: &str) -> Result<OffsetDateTime, String> {
1291 if let Ok(parsed) = OffsetDateTime::parse(raw, &Rfc3339) {
1292 return Ok(parsed);
1293 }
1294 if let Ok(unix) = raw.parse::<i64>() {
1295 let parsed = if raw.len() > 10 {
1296 OffsetDateTime::from_unix_timestamp_nanos(unix as i128 * 1_000_000)
1297 } else {
1298 OffsetDateTime::from_unix_timestamp(unix)
1299 };
1300 return parsed.map_err(|error| format!("invalid --since timestamp '{raw}': {error}"));
1301 }
1302 let date_format = time::format_description::parse("[year]-[month]-[day]")
1303 .map_err(|error| format!("failed to build date parser: {error}"))?;
1304 let date = Date::parse(raw, &date_format).map_err(|_| {
1305 format!("invalid --since date '{raw}'; use RFC3339, unix time, or YYYY-MM-DD")
1306 })?;
1307 Ok(date.with_time(Time::MIDNIGHT).assume_utc())
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312 use super::*;
1313 use ed25519_dalek::SigningKey;
1314 use harn_vm::flow::{Atom, Provenance};
1315
1316 #[test]
1317 fn parse_since_accepts_rfc3339_unix_and_date() {
1318 assert_eq!(
1319 parse_since("2026-04-26T12:00:00Z")
1320 .unwrap()
1321 .unix_timestamp(),
1322 1_777_204_800
1323 );
1324 assert_eq!(
1325 parse_since("1777205600").unwrap().unix_timestamp(),
1326 1_777_205_600
1327 );
1328 assert_eq!(
1329 parse_since("2026-04-26").unwrap().unix_timestamp(),
1330 1_777_161_600
1331 );
1332 }
1333
1334 #[test]
1335 fn archivist_rust_proposal_is_parseable_harn_with_provenance() {
1336 let temp = tempfile::tempdir().unwrap();
1337 fs::write(
1338 temp.path().join("Cargo.toml"),
1339 "[package]\nname = \"demo\"\n",
1340 )
1341 .unwrap();
1342 fs::create_dir_all(temp.path().join("src")).unwrap();
1343 fs::write(temp.path().join("src/lib.rs"), "pub unsafe fn raw() {}\n").unwrap();
1344
1345 let inventory = inventory_repo(temp.path());
1346 let proposals = archivist_proposals(temp.path(), &inventory, &[], &[], true, "2026-04-26");
1347 let rust = proposals
1348 .iter()
1349 .find(|proposal| proposal.id == "rust-unsafe-safety-comment")
1350 .expect("rust unsafe proposal");
1351
1352 let parsed = harn_vm::flow::parse_invariants_source(&rust.source);
1353 assert!(
1354 parsed.diagnostics.is_empty(),
1355 "generated source should parse cleanly: {:?}",
1356 parsed.diagnostics
1357 );
1358 assert_eq!(
1359 parsed.predicates[0].name,
1360 "rust_unsafe_requires_safety_comment"
1361 );
1362 assert!(parsed.predicates[0].archivist.is_some());
1363 }
1364
1365 #[test]
1366 fn shadow_evaluate_reports_false_positive_atom_pointers() {
1367 let temp = tempfile::tempdir().unwrap();
1368 fs::write(
1369 temp.path().join("Cargo.toml"),
1370 "[package]\nname = \"demo\"\n",
1371 )
1372 .unwrap();
1373 let store_path = temp.path().join(".harn/flow.sqlite");
1374 fs::create_dir_all(store_path.parent().unwrap()).unwrap();
1375
1376 {
1377 let store = SqliteFlowStore::open(&store_path, "test").unwrap();
1378 let principal = SigningKey::from_bytes(&[7; 32]);
1379 let persona = SigningKey::from_bytes(&[8; 32]);
1380 let atom = Atom::sign(
1381 vec![TextOp::Insert {
1382 offset: 0,
1383 content: "unsafe { /* SAFETY: fixture */ }".to_string(),
1384 }],
1385 Vec::new(),
1386 Provenance::new("user:test", "archivist-test", "run-1", "trace-1", "tx-1"),
1387 None,
1388 &principal,
1389 &persona,
1390 )
1391 .unwrap();
1392 store.emit_atoms(&[atom]).unwrap();
1393 }
1394
1395 let proposal = rust_unsafe_proposal(temp.path(), "2026-04-26");
1396 let report = shadow_evaluate(temp.path(), &store_path, 30, &[proposal]).unwrap();
1397 assert_eq!(report["status"], "evaluated");
1398 assert_eq!(report["recent_atoms"], 1);
1399 assert_eq!(
1400 report["false_positive_candidates"][0]["transcript_ref"],
1401 "tx-1"
1402 );
1403 assert!(report["false_positive_candidates"][0]["atom"].is_string());
1404 }
1405}