1use clap::{Args as ClapArgs, Subcommand, ValueEnum};
26use serde_json::json;
27
28use memstead_base::binding::{
29 BINDING_VERSION, Binding, BuildMode, BuildOperation, CapabilityError, DEFAULT_ADJUDICATION_CAP,
30 DEFAULT_FULL_RESYNC_EVERY, Operations, PruneConfig, SyncOperation, VerifyOperation,
31 prune_guarantee_for_medium, validate_binding,
32};
33use memstead_base::binding_migrate::{
34 BindingMigrateError, check_all_consumed, fold_v1_binding, migrate_gen2_bindings,
35};
36use memstead_base::ingest::advance::{
37 AdvanceError, DispositionInput, ExcludeError, advance_baseline, record_exclusions,
38};
39use memstead_base::ingest::findings::{
40 FindingsError, FullResyncDecision, record_anchor_hash_backfill, record_verified_baseline,
41 verify_binding, verify_binding_full,
42};
43use memstead_base::ingest::report::{
44 DEFAULT_REPORT_BUDGET, compute_fidelity_report, render_fidelity_report,
45};
46use memstead_base::ingest::resolve::{ResolveError, ResolvedSource, resolve_binding_run};
47use memstead_base::ingest::{
48 OperationFilter, OperationKind, RenderBriefError, render_ingest_brief, render_sync_brief_for,
49 render_verify_brief_for, select_next_due_operation,
50};
51use memstead_base::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode, Source};
52use memstead_base::pipeline_store::{
53 ProjectionGeneration, delete_ingest, load_legacy_pipeline_configs, load_pipeline_configs,
54 load_projection_generations, read_binding, remove_mediums_and_facets_trees, write_binding,
55};
56use memstead_base::workspace_store::StoreError;
57use memstead_base::{migrate_legacy_pipeline, read_legacy_pipeline_configs};
58
59use crate::CliError;
60use crate::output::{ExitKind, print_json, print_markdown};
61use crate::setup::{CliContext, workspace_not_initialised_error};
62
63#[derive(ClapArgs, Debug)]
64pub struct Args {
65 #[command(subcommand)]
66 pub command: ProjectionCommand,
67}
68
69#[derive(Subcommand, Debug)]
70pub enum ProjectionCommand {
71 Brief(BriefArgs),
92 Init(InitArgs),
102 Migrate(MigrateArgs),
116 Enable(EnableArgs),
126 Advance(AdvanceArgs),
139 Exclude(ExcludeArgs),
151 Verify(VerifyArgs),
167}
168
169#[derive(Clone, Copy, Debug, ValueEnum)]
173pub enum MediumTypeArg {
174 Codebase,
176 Filesystem,
178 Git,
180 Graph,
182 Web,
184}
185
186impl MediumTypeArg {
187 fn to_medium_type(self) -> MediumType {
188 match self {
189 MediumTypeArg::Codebase => MediumType::Codebase,
190 MediumTypeArg::Filesystem => MediumType::Filesystem,
191 MediumTypeArg::Git => MediumType::Git,
192 MediumTypeArg::Graph => MediumType::Graph,
193 MediumTypeArg::Web => MediumType::Web,
194 }
195 }
196}
197
198#[derive(ClapArgs, Debug)]
199pub struct BriefArgs {
200 pub binding: Option<String>,
205 #[arg(long)]
210 pub all: bool,
211 #[arg(long, value_enum, default_value_t = BriefOperationArg::Build, requires = "all", conflicts_with_all = ["verify", "sync"])]
218 pub operation: BriefOperationArg,
219 #[arg(long, conflicts_with = "sync")]
224 pub verify: bool,
225 #[arg(long, conflicts_with = "verify")]
231 pub sync: bool,
232}
233
234#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
238pub enum BriefOperationArg {
239 Build,
241 Sync,
243 Verify,
245 Any,
247}
248
249impl BriefOperationArg {
250 fn to_filter(self) -> OperationFilter {
251 match self {
252 BriefOperationArg::Build => OperationFilter::Only(OperationKind::Build),
253 BriefOperationArg::Sync => OperationFilter::Only(OperationKind::Sync),
254 BriefOperationArg::Verify => OperationFilter::Only(OperationKind::Verify),
255 BriefOperationArg::Any => OperationFilter::Any,
256 }
257 }
258}
259
260#[derive(ClapArgs, Debug)]
261pub struct InitArgs {
262 #[arg(long)]
265 pub mem: String,
266 #[arg(long)]
269 pub source: String,
270 #[arg(long = "medium-type", value_enum)]
273 pub medium_type: MediumTypeArg,
274 #[arg(long)]
276 pub intent: Option<String>,
277 #[arg(long)]
281 pub name: Option<String>,
282}
283
284#[derive(ClapArgs, Debug)]
285pub struct MigrateArgs {
286 #[arg(long)]
289 pub dry_run: bool,
290}
291
292#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
297pub enum EnableOperationArg {
298 Build,
300 Sync,
302 Verify,
304}
305
306impl EnableOperationArg {
307 fn name(self) -> &'static str {
308 match self {
309 EnableOperationArg::Build => "build",
310 EnableOperationArg::Sync => "sync",
311 EnableOperationArg::Verify => "verify",
312 }
313 }
314}
315
316#[derive(ClapArgs, Debug)]
317pub struct EnableArgs {
318 #[arg(value_enum)]
320 pub operation: EnableOperationArg,
321 pub binding: String,
323}
324
325#[derive(ClapArgs, Debug)]
326pub struct AdvanceArgs {
327 pub binding: String,
329 #[arg(long)]
339 pub dispositions: String,
340}
341
342#[derive(ClapArgs, Debug)]
343pub struct ExcludeArgs {
344 pub binding: String,
346 #[arg(long)]
352 pub exclusions: String,
353}
354
355#[derive(ClapArgs, Debug)]
356pub struct VerifyArgs {
357 pub binding: String,
359 #[arg(long)]
364 pub budget: Option<usize>,
365 #[arg(long = "include")]
368 pub include: Vec<String>,
369 #[arg(long)]
378 pub full: bool,
379}
380
381pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
382 match args.command {
383 ProjectionCommand::Brief(a) => brief(ctx, a),
384 ProjectionCommand::Init(a) => init(ctx, a),
385 ProjectionCommand::Migrate(a) => migrate(ctx, a),
386 ProjectionCommand::Enable(a) => enable(ctx, a),
387 ProjectionCommand::Advance(a) => advance(ctx, a),
388 ProjectionCommand::Exclude(a) => exclude(ctx, a),
389 ProjectionCommand::Verify(a) => verify(ctx, a),
390 }
391}
392
393fn map_brief_err(binding_id: &str, err: RenderBriefError) -> CliError {
399 let message = err.to_string();
400 let mapped = match &err {
401 RenderBriefError::ConfigLoad(_) => {
402 CliError::new(ExitKind::Generic, "PROJECTION_LOAD_FAILED", message)
403 }
404 RenderBriefError::BuildOperationAbsent { .. } => CliError::new(
407 ExitKind::Validation,
408 "PROJECTION_BUILD_NOT_ENABLED",
409 message,
410 ),
411 RenderBriefError::FindingsRead { .. } => CliError::new(
413 ExitKind::Generic,
414 "PROJECTION_FINDINGS_READ_FAILED",
415 message,
416 ),
417 RenderBriefError::Resolve(inner) => match inner {
418 ResolveError::BindingNotFound { .. } => {
419 CliError::new(ExitKind::NotFound, "PROJECTION_NOT_FOUND", message)
420 }
421 ResolveError::MalformedProjectionRef { .. } => {
422 CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
423 }
424 },
425 };
426 mapped.with_details(json!({ "binding": binding_id }))
427}
428
429fn brief(ctx: &CliContext, args: BriefArgs) -> anyhow::Result<()> {
430 let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
431 workspace_not_initialised_error(
432 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
433 )
434 })?;
435
436 let cli_engine = ctx.cli_engine_at(&root)?;
437 let engine = cli_engine.base();
438
439 if let Some(binding_id) = args.binding.as_deref()
444 && let Ok(configs) = load_pipeline_configs(&root)
445 && configs
446 .quarantined
447 .iter()
448 .any(|q| format!("{}/{}", q.mem, q.name) == binding_id)
449 {
450 return Err(binding_miss_error(&configs, binding_id).into());
451 }
452
453 if args.verify || args.sync {
457 let binding_id = args.binding.ok_or_else(|| {
458 CliError::new(
459 ExitKind::Validation,
460 "PROJECTION_BRIEF_BINDING_REQUIRED",
461 format!(
462 "`projection brief --{}` needs a binding id `<mem>/<stem>` — it renders one \
463 binding's brief, not an `--all` rotation",
464 if args.verify { "verify" } else { "sync" }
465 ),
466 )
467 })?;
468 let (rendered, operation) = if args.verify {
469 (
470 render_verify_brief_for(engine, &root, &binding_id),
471 OperationKind::Verify,
472 )
473 } else {
474 (
475 render_sync_brief_for(engine, &root, &binding_id),
476 OperationKind::Sync,
477 )
478 };
479 let rendered = rendered.map_err(|e| map_brief_err(&binding_id, e))?;
480
481 if ctx.json {
482 print_json(&json!({ "brief": rendered, "operation": operation.as_wire() }))?;
483 } else {
484 print!("{rendered}");
485 }
486 return Ok(());
487 }
488
489 let selected = match args.binding {
495 Some(binding) if !args.all => Some((binding, OperationKind::Build)),
496 _ => {
497 let configs = load_pipeline_configs(&root).map_err(|e| {
498 CliError::new(
499 ExitKind::Generic,
500 "PROJECTION_LOAD_FAILED",
501 format!("could not load binding store: {e}"),
502 )
503 .with_details(json!({ "error": e.to_string() }))
504 })?;
505 if configs.bindings.is_empty() {
513 if ctx.json {
514 print_json(&json!({ "no_bindings": true }))?;
515 } else {
516 println!("> **[projection] No bindings configured in this workspace yet.**");
517 }
518 return Ok(());
519 }
520 select_next_due_operation(engine, &root, &configs, args.operation.to_filter())
521 }
522 };
523
524 let Some((binding_id, operation)) = selected else {
525 if ctx.json {
528 print_json(&json!({ "skipped": true }))?;
529 } else {
530 println!(
531 "> **[projection] Skipped — every eligible binding is backing off this pass.**"
532 );
533 }
534 return Ok(());
535 };
536
537 let rendered = match operation {
540 OperationKind::Build => render_ingest_brief(engine, &root, &binding_id),
541 OperationKind::Sync => render_sync_brief_for(engine, &root, &binding_id),
542 OperationKind::Verify => render_verify_brief_for(engine, &root, &binding_id),
543 }
544 .map_err(|e| map_brief_err(&binding_id, e))?;
545
546 if ctx.json {
547 print_json(&json!({ "brief": rendered, "operation": operation.as_wire() }))?;
548 } else {
549 print!("{rendered}");
552 }
553 Ok(())
554}
555
556fn is_single_component(value: &str) -> bool {
561 !value.is_empty()
562 && value != "."
563 && value != ".."
564 && !value.contains('/')
565 && !value.contains('\\')
566 && !value.contains(':')
567 && !value.contains('\0')
568}
569
570fn derive_stem(source: &str) -> String {
574 source
575 .trim_end_matches('/')
576 .rsplit('/')
577 .next()
578 .unwrap_or(source)
579 .to_string()
580}
581
582fn init_write_error(binding_id: &str, err: StoreError) -> CliError {
584 CliError::new(
585 ExitKind::Generic,
586 "PROJECTION_INIT_FAILED",
587 format!("could not scaffold binding `{binding_id}`: {err}"),
588 )
589 .with_details(json!({ "binding": binding_id, "error": err.to_string() }))
590}
591
592fn init(ctx: &CliContext, args: InitArgs) -> anyhow::Result<()> {
593 let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
594 workspace_not_initialised_error(
595 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
596 )
597 })?;
598
599 let mem = args.mem;
600 let stem = args
601 .name
602 .clone()
603 .unwrap_or_else(|| derive_stem(&args.source));
604
605 for (kind, value) in [("mem", mem.as_str()), ("name", stem.as_str())] {
608 if !is_single_component(value) {
609 return Err(CliError::new(
610 ExitKind::Validation,
611 "PROJECTION_INVALID_NAME",
612 format!(
613 "invalid {kind} '{}': must be a single path component (no separators, \
614 traversal segments, ':' or NUL) — pass an explicit --name",
615 value.escape_default()
616 ),
617 )
618 .with_details(json!({ "kind": kind, "value": value }))
619 .into());
620 }
621 }
622
623 let binding_id = format!("{mem}/{stem}");
624 let medium_type = args.medium_type.to_medium_type();
625
626 let binding_path = root
630 .join(".memstead")
631 .join("projections")
632 .join(&mem)
633 .join(format!("{stem}.json"));
634 if binding_path.exists() {
635 return Err(CliError::new(
636 ExitKind::Validation,
637 "PROJECTION_EXISTS",
638 format!(
639 "a binding `{binding_id}` already exists at \
640 .memstead/projections/{mem}/{stem}.json — `projection init` never overwrites; \
641 choose a different --name or edit the existing binding"
642 ),
643 )
644 .with_details(json!({ "binding": binding_id }))
645 .into());
646 }
647
648 let source = Source {
652 name: stem.clone(),
653 medium_type,
654 pointer: args.source.clone(),
655 change_detection: None,
656 scope: vec![PatternEntry {
657 path: "**/*".to_string(),
658 mode: PatternMode::Allow,
659 }],
660 engagement: None,
661 preparation: None,
662 };
663
664 let deny_paths: Vec<String> = if matches!(
675 medium_type,
676 memstead_base::MediumType::Codebase | memstead_base::MediumType::Filesystem
677 ) {
678 memstead_base::binding::DEFAULT_SCAFFOLD_DENY_PATHS
679 .iter()
680 .map(|s| s.to_string())
681 .collect()
682 } else {
683 Vec::new()
684 };
685
686 let mut binding = Binding {
687 version: BINDING_VERSION,
688 intent: args.intent.clone(),
689 sources: vec![source],
690 reference_mems: Vec::new(),
691 destination_mem: mem.clone(),
692 deny_paths,
693 coverage_semantics: None,
696 rules: None,
697 prune: None,
698 operations: Operations {
699 build: Some(BuildOperation {
700 mode: BuildMode::Discovery,
701 trigger: IngestTrigger::Loop,
702 batch_size: 20,
703 post_actions: None,
704 }),
705 sync: Some(SyncOperation {
706 trigger: IngestTrigger::Manual,
707 batch_size: 20,
708 }),
709 verify: Some(VerifyOperation {
710 trigger: IngestTrigger::Manual,
711 batch_size: 20,
712 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
713 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
714 }),
715 },
716 };
717
718 let mut warnings: Vec<String> = Vec::new();
719
720 if matches!(
726 medium_type,
727 memstead_base::MediumType::Codebase | memstead_base::MediumType::Filesystem
728 ) {
729 let base = memstead_base::ingest::cursor::medium_base(&args.source, &root);
730 let canon_base = std::fs::canonicalize(&base).unwrap_or(base);
734 let canon_root = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
735 if !canon_base.starts_with(&canon_root) {
736 warnings.push(format!(
737 "medium base '{}' resolves outside the workspace root '{}': artifact ids will be \
738 workspace-relative ('../…' chains), and anchors written against source-relative \
739 paths will fail to resolve (orphaned). Consider rooting the workspace at the \
740 source tree.",
741 canon_base.display(),
742 canon_root.display()
743 ));
744 }
745 }
746
747 if let Err(refusals) = validate_binding(&binding) {
748 for r in &refusals {
749 if let CapabilityError::OperationOutOfScope { operation, .. } = r {
750 match *operation {
751 "sync" => binding.operations.sync = None,
752 "verify" => binding.operations.verify = None,
753 _ => {}
754 }
755 }
756 warnings.push(r.to_string());
757 }
758 }
759
760 if binding.operations.sync.is_some() {
766 binding.prune = Some(PruneConfig {
767 guarantee: prune_guarantee_for_medium(medium_type),
768 });
769 }
770
771 let mut operations: Vec<&str> = vec!["build"];
772 if binding.operations.sync.is_some() {
773 operations.push("sync");
774 }
775 if binding.operations.verify.is_some() {
776 operations.push("verify");
777 }
778
779 write_binding(&root, &mem, &stem, &binding).map_err(|e| init_write_error(&binding_id, e))?;
784
785 let created = vec![format!(".memstead/projections/{mem}/{stem}.json")];
786
787 if ctx.json {
788 print_json(&json!({
790 "binding": binding_id,
791 "created": created,
792 "operations": operations,
793 "warnings": warnings,
794 }))?;
795 } else {
796 let mut out = format!("# Projection init\n\nScaffolded binding `{binding_id}`:\n");
797 for c in &created {
798 out.push_str(&format!("- `{c}`\n"));
799 }
800 out.push_str(&format!("\nOperations: {}\n", operations.join(", ")));
801 if !warnings.is_empty() {
802 out.push_str("\n## Warnings\n\n");
803 for w in &warnings {
804 out.push_str(&format!("- {w}\n"));
805 }
806 }
807 print_markdown(&out);
808 }
809 Ok(())
810}
811
812fn map_migrate_err(err: BindingMigrateError) -> CliError {
813 let message = err.to_string();
817 match &err {
818 BindingMigrateError::RefinementModeDeleted { .. } => CliError::new(
819 ExitKind::Validation,
820 "PROJECTION_MIGRATE_REFINEMENT",
821 message,
822 ),
823 BindingMigrateError::MalformedProjectionRef { .. } => CliError::new(
824 ExitKind::Validation,
825 "PROJECTION_MIGRATE_MALFORMED_REF",
826 message,
827 ),
828 BindingMigrateError::DanglingProjectionRef { .. }
829 | BindingMigrateError::DanglingFacetRef { .. }
830 | BindingMigrateError::DanglingMediumRef { .. } => CliError::new(
831 ExitKind::Validation,
832 "PROJECTION_MIGRATE_DANGLING_REF",
833 message,
834 ),
835 BindingMigrateError::OrphanRecords { .. } => CliError::new(
836 ExitKind::Validation,
837 "PROJECTION_MIGRATE_ORPHAN_RECORDS",
838 message,
839 ),
840 }
841}
842
843fn has_legacy_root_layout(root: &std::path::Path) -> bool {
849 ["scopes", "projections", "ingests"]
850 .iter()
851 .any(|d| root.join(d).is_dir())
852}
853
854fn migrate_load_err(err: StoreError) -> CliError {
856 CliError::new(
857 ExitKind::Generic,
858 "PROJECTION_MIGRATE_FAILED",
859 format!("could not load pipeline config: {err}"),
860 )
861 .with_details(json!({ "error": err.to_string() }))
862}
863
864fn pointer_resolves_to(root: &std::path::Path, medium_pointer: &str, abs_path: &str) -> bool {
869 let resolved = if medium_pointer.is_empty() {
870 root.to_path_buf()
871 } else {
872 root.join(medium_pointer)
873 };
874 match (
875 std::fs::canonicalize(&resolved),
876 std::fs::canonicalize(abs_path),
877 ) {
878 (Ok(a), Ok(b)) => a == b,
879 _ => resolved == std::path::Path::new(abs_path),
880 }
881}
882
883fn propose_workspace_toml(root: &std::path::Path) -> Option<String> {
888 let path = root.join(".memstead").join("workspace.toml");
889 let content = std::fs::read_to_string(path).ok()?;
890 let hits: Vec<(usize, &str)> = content
891 .lines()
892 .enumerate()
893 .filter(|(_, l)| {
894 let low = l.to_lowercase();
895 low.contains("reconcile-cursors") || low.contains("ingests/") || low.contains("ingest ")
896 })
897 .collect();
898 if hits.is_empty() {
899 return None;
900 }
901 let mut block = String::from(
902 "## Proposal: workspace.toml (NOT applied)\n\n`projection migrate` never edits \
903 `workspace.toml`. It found references to retired pipeline vocabulary — review and \
904 update these lines by hand, then commit:\n\n",
905 );
906 for (i, line) in hits {
907 block.push_str(&format!("- L{}: `{}`\n", i + 1, line.trim()));
908 }
909 Some(block)
910}
911
912fn binding_miss_error(configs: &memstead_base::BindingConfigs, binding_id: &str) -> CliError {
919 if let Some(q) = configs
920 .quarantined
921 .iter()
922 .find(|q| format!("{}/{}", q.mem, q.name) == binding_id)
923 {
924 return CliError::new(
925 ExitKind::Validation,
926 "PROJECTION_QUARANTINED",
927 format!(
928 "binding `{binding_id}` is quarantined — its stored file failed the load and \
929 it serves no operations until repaired: [{}] {}",
930 q.reason_code, q.reason_message
931 ),
932 )
933 .with_details(json!({
934 "binding": binding_id,
935 "reason_code": q.reason_code,
936 "reason_message": q.reason_message,
937 "path": q.path,
938 }));
939 }
940 CliError::new(
941 ExitKind::NotFound,
942 "PROJECTION_NOT_FOUND",
943 format!(
944 "no binding `{binding_id}` in this workspace — scaffold one with \
945 `projection init` or migrate a legacy workspace with `projection migrate`"
946 ),
947 )
948 .with_details(json!({ "binding": binding_id }))
949}
950
951fn consume_reconcile_cursors(
958 ctx: &CliContext,
959 root: &std::path::Path,
960) -> anyhow::Result<(Vec<String>, Option<String>)> {
961 let cursor_path = root.join(".memstead").join("reconcile-cursors.json");
962 if !cursor_path.exists() {
963 return Ok((Vec::new(), None));
964 }
965 let cursors: std::collections::BTreeMap<String, String> = std::fs::read(&cursor_path)
966 .ok()
967 .and_then(|b| serde_json::from_slice(&b).ok())
968 .unwrap_or_default();
969
970 let mut seeded: Vec<String> = Vec::new();
971 if !cursors.is_empty() {
972 let configs = load_pipeline_configs(root).map_err(migrate_load_err)?;
973 let mut cli_engine = match ctx.cli_engine_at(root) {
982 Ok(e) => e,
983 Err(boot_err) => {
984 return Ok((
985 Vec::new(),
986 Some(format!(
987 "RECONCILE_CURSORS_DEFERRED: the workspace does not boot yet \
988 ({boot_err:#}); reconcile-cursors.json was kept — repair the boot, \
989 then re-run `memstead projection migrate` to seed the sync baselines"
990 )),
991 ));
992 }
993 };
994 let engine = cli_engine.base_mut();
995 for (cursor_key, sha) in &cursors {
996 let Some((_cursor_mem, abs_path)) = cursor_key.split_once(':') else {
998 continue;
999 };
1000 for record in &configs.bindings {
1001 let binding_id = format!("{}/{}", record.mem, record.name);
1002 let Ok(resolved) = resolve_binding_run(&binding_id, &record.config) else {
1003 continue;
1004 };
1005 for source in &resolved.sources {
1006 if let ResolvedSource::Primary(p) = source
1007 && pointer_resolves_to(root, &p.pointer, abs_path)
1008 {
1009 let key = format!("{binding_id}/{}#synced", p.name);
1010 if engine
1011 .set_mem_sync_state(
1012 &resolved.destination_mem,
1013 &key,
1014 sha,
1015 Some("projection migrate: seeded from reconcile-cursors.json"),
1016 )
1017 .is_ok()
1018 {
1019 seeded.push(key);
1020 }
1021 }
1022 }
1023 }
1024 }
1025 }
1026 let _ = std::fs::remove_file(&cursor_path);
1028 Ok((seeded, None))
1029}
1030
1031fn migrate(ctx: &CliContext, args: MigrateArgs) -> anyhow::Result<()> {
1032 let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1033 workspace_not_initialised_error(
1034 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1035 )
1036 })?;
1037
1038 let gen1 = has_legacy_root_layout(&root);
1045 if gen1 && !args.dry_run {
1046 migrate_legacy_pipeline(&root).map_err(|e| {
1047 CliError::new(
1048 ExitKind::Generic,
1049 "PROJECTION_MIGRATE_FAILED",
1050 format!("could not convert root-folder (gen-1) pipeline layout: {e}"),
1051 )
1052 .with_details(json!({ "error": e.to_string() }))
1053 })?;
1054 }
1055
1056 let configs = if gen1 && args.dry_run {
1057 read_legacy_pipeline_configs(&root).map_err(migrate_load_err)?
1058 } else {
1059 load_legacy_pipeline_configs(&root).map_err(migrate_load_err)?
1060 };
1061
1062 let mut migrated = migrate_gen2_bindings(&configs).map_err(map_migrate_err)?;
1069
1070 let mut already_v2 = 0usize;
1077 if !(gen1 && args.dry_run) {
1078 let generations = load_projection_generations(&root).map_err(migrate_load_err)?;
1079 for (mem, name, generation) in generations {
1080 let binding_id = format!("{mem}/{name}");
1081 match generation {
1082 ProjectionGeneration::V2 => already_v2 += 1,
1083 ProjectionGeneration::V1(v1) => {
1084 let consumed = v1.source_facets.clone();
1085 let binding = fold_v1_binding(&binding_id, &mem, v1.as_ref(), &configs)
1086 .map_err(map_migrate_err)?;
1087 migrated.push(memstead_base::binding_migrate::MigratedBinding {
1088 id: binding_id,
1089 mem,
1090 name,
1091 ingest_name: String::new(),
1092 consumed_facets: consumed,
1093 binding,
1094 notes: Vec::new(),
1095 });
1096 }
1097 ProjectionGeneration::VersionLess => {
1098 if !migrated.iter().any(|m| m.mem == mem && m.name == name) {
1099 return Err(CliError::new(
1100 ExitKind::Validation,
1101 "PROJECTION_MIGRATE_INERT_PROJECTION",
1102 format!(
1103 "projection `{binding_id}` is a version-less gen-2 file no \
1104 ingest schedules — inert leftovers the loader refuses; delete \
1105 .memstead/projections/{mem}/{name}.json (or add an ingest) and \
1106 re-run `projection migrate`"
1107 ),
1108 )
1109 .with_details(json!({ "binding": binding_id }))
1110 .into());
1111 }
1112 }
1113 }
1114 }
1115 migrated.sort_by(|a, b| a.id.cmp(&b.id));
1116
1117 let consumed: Vec<(String, String)> = migrated
1121 .iter()
1122 .flat_map(|m| m.consumed_facets.iter().map(|f| (m.mem.clone(), f.clone())))
1123 .collect();
1124 check_all_consumed(&configs, &consumed).map_err(map_migrate_err)?;
1125 }
1126
1127 let mut warnings: Vec<serde_json::Value> = Vec::new();
1133 for m in &migrated {
1134 if let Err(refusals) = validate_binding(&m.binding) {
1135 for r in refusals {
1136 warnings.push(json!({
1137 "binding": m.id,
1138 "kind": "capability",
1139 "message": r.to_string(),
1140 }));
1141 }
1142 }
1143 for note in &m.notes {
1144 warnings.push(json!({
1145 "binding": m.id,
1146 "kind": "note",
1147 "message": note,
1148 }));
1149 }
1150 }
1151
1152 if !args.dry_run {
1157 for m in &migrated {
1158 write_binding(&root, &m.mem, &m.name, &m.binding).map_err(|e| {
1159 CliError::new(
1160 ExitKind::Generic,
1161 "PROJECTION_MIGRATE_FAILED",
1162 format!("could not write binding `{}`: {e}", m.id),
1163 )
1164 .with_details(json!({ "binding": m.id, "error": e.to_string() }))
1165 })?;
1166 if !m.ingest_name.is_empty() {
1167 delete_ingest(&root, &m.ingest_name).map_err(|e| {
1168 CliError::new(
1169 ExitKind::Generic,
1170 "PROJECTION_MIGRATE_FAILED",
1171 format!("could not remove merged ingest `{}`: {e}", m.ingest_name),
1172 )
1173 .with_details(json!({ "ingest": m.ingest_name, "error": e.to_string() }))
1174 })?;
1175 }
1176 }
1177 remove_mediums_and_facets_trees(&root).map_err(|e| {
1178 CliError::new(
1179 ExitKind::Generic,
1180 "PROJECTION_MIGRATE_FAILED",
1181 format!("could not remove the emptied mediums/facets trees: {e}"),
1182 )
1183 .with_details(json!({ "error": e.to_string() }))
1184 })?;
1185 }
1186
1187 let ((seeded, cursors_deferred), proposal) = if args.dry_run {
1191 ((Vec::new(), None), None)
1192 } else {
1193 (
1194 consume_reconcile_cursors(ctx, &root)?,
1195 propose_workspace_toml(&root),
1196 )
1197 };
1198
1199 let bindings: Vec<&str> = migrated.iter().map(|m| m.id.as_str()).collect();
1200 if ctx.json {
1201 print_json(&json!({
1202 "ok": true,
1203 "dry_run": args.dry_run,
1204 "migrated": migrated.len(),
1205 "already_v2": already_v2,
1206 "bindings": bindings,
1207 "warnings": warnings,
1208 "cursors_seeded": seeded,
1209 "cursors_deferred": cursors_deferred,
1210 "workspace_toml_proposal": proposal,
1211 }))?;
1212 } else {
1213 let verb = if args.dry_run {
1214 "Would migrate"
1215 } else {
1216 "Migrated"
1217 };
1218 let mut out = format!(
1219 "# Projection migration\n\n{verb} {} binding(s) to v2 ({already_v2} already v2):\n",
1220 migrated.len()
1221 );
1222 for id in &bindings {
1223 out.push_str(&format!("- `{id}`\n"));
1224 }
1225 if !warnings.is_empty() {
1226 out.push_str("\n## Warnings\n\n");
1227 for w in &warnings {
1228 out.push_str(&format!(
1229 "- [{}] `{}`: {}\n",
1230 w["kind"].as_str().unwrap_or(""),
1231 w["binding"].as_str().unwrap_or(""),
1232 w["message"].as_str().unwrap_or(""),
1233 ));
1234 }
1235 }
1236 if !seeded.is_empty() {
1237 out.push_str("\n## Baselines seeded from reconcile-cursors.json\n\n");
1238 for key in &seeded {
1239 out.push_str(&format!("- `{key}`\n"));
1240 }
1241 }
1242 if let Some(notice) = &cursors_deferred {
1243 out.push_str(&format!("\n## Reconcile cursors deferred\n\n{notice}\n"));
1244 }
1245 if let Some(block) = &proposal {
1246 out.push('\n');
1247 out.push_str(block);
1248 }
1249 if !args.dry_run {
1250 out.push_str(
1251 "\nEach projection file was converted to a v2 single-record binding in place \
1252 (medium + facet content folded inline, source names preserved verbatim); \
1253 merged ingests and the emptied mediums/ and facets/ trees were removed.\n",
1254 );
1255 }
1256 print_markdown(&out);
1257 }
1258 Ok(())
1259}
1260
1261fn invalid_binding_id(binding_id: &str) -> CliError {
1265 CliError::new(
1266 ExitKind::Validation,
1267 "PROJECTION_INVALID_NAME",
1268 format!(
1269 "invalid binding id '{}': expected `<mem>/<stem>` with each half a single path \
1270 component (no extra separators, traversal segments, ':' or NUL)",
1271 binding_id.escape_default()
1272 ),
1273 )
1274 .with_details(json!({ "binding": binding_id }))
1275}
1276
1277fn enable_failed(binding_id: &str, err: StoreError) -> CliError {
1282 CliError::new(
1283 ExitKind::Generic,
1284 "PROJECTION_ENABLE_FAILED",
1285 format!("could not enable operation on binding `{binding_id}`: {err}"),
1286 )
1287 .with_details(json!({ "binding": binding_id, "error": err.to_string() }))
1288}
1289
1290fn enable(ctx: &CliContext, args: EnableArgs) -> anyhow::Result<()> {
1291 let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1292 workspace_not_initialised_error(
1293 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1294 )
1295 })?;
1296
1297 let binding_id = args.binding;
1298 let op = args.operation;
1299
1300 let (mem, stem) = binding_id
1304 .split_once('/')
1305 .filter(|(m, n)| !m.is_empty() && !n.is_empty())
1306 .filter(|(m, n)| is_single_component(m) && is_single_component(n))
1307 .ok_or_else(|| invalid_binding_id(&binding_id))?;
1308 let mem = mem.to_string();
1309 let stem = stem.to_string();
1310
1311 let binding_path = root
1315 .join(".memstead")
1316 .join("projections")
1317 .join(&mem)
1318 .join(format!("{stem}.json"));
1319 if !binding_path.exists() {
1320 return Err(CliError::new(
1321 ExitKind::NotFound,
1322 "PROJECTION_NOT_FOUND",
1323 format!(
1324 "no binding `{binding_id}` at .memstead/projections/{mem}/{stem}.json — \
1325 scaffold one with `projection init` or migrate a legacy workspace with \
1326 `projection migrate`"
1327 ),
1328 )
1329 .with_details(json!({ "binding": binding_id }))
1330 .into());
1331 }
1332 if let Ok(configs) = load_pipeline_configs(&root)
1336 && configs
1337 .quarantined
1338 .iter()
1339 .any(|q| format!("{}/{}", q.mem, q.name) == binding_id)
1340 {
1341 return Err(binding_miss_error(&configs, &binding_id).into());
1342 }
1343 let mut binding =
1344 read_binding(&root, &mem, &stem).map_err(|e| enable_failed(&binding_id, e))?;
1345
1346 let already = match op {
1350 EnableOperationArg::Build => binding.operations.build.is_some(),
1351 EnableOperationArg::Sync => binding.operations.sync.is_some(),
1352 EnableOperationArg::Verify => binding.operations.verify.is_some(),
1353 };
1354 if already {
1355 return Err(CliError::new(
1356 ExitKind::Validation,
1357 "PROJECTION_OP_ALREADY_ENABLED",
1358 format!(
1359 "operation `{}` is already enabled on binding `{binding_id}` — nothing to do",
1360 op.name()
1361 ),
1362 )
1363 .with_details(json!({ "binding": binding_id, "operation": op.name() }))
1364 .into());
1365 }
1366
1367 let batch_size = binding
1371 .operations
1372 .build
1373 .as_ref()
1374 .map_or(20, |b| b.batch_size);
1375 match op {
1376 EnableOperationArg::Build => {
1377 binding.operations.build = Some(BuildOperation {
1378 mode: BuildMode::Discovery,
1379 trigger: IngestTrigger::Loop,
1380 batch_size,
1381 post_actions: None,
1382 });
1383 }
1384 EnableOperationArg::Sync => {
1385 binding.operations.sync = Some(SyncOperation {
1386 trigger: IngestTrigger::Manual,
1387 batch_size,
1388 });
1389 }
1390 EnableOperationArg::Verify => {
1391 binding.operations.verify = Some(VerifyOperation {
1392 trigger: IngestTrigger::Manual,
1393 batch_size,
1394 adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1395 full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1396 });
1397 }
1398 }
1399
1400 if let Err(refusals) = validate_binding(&binding)
1407 && let Some(err) = refusals.iter().find(|r| {
1408 matches!(
1409 r,
1410 CapabilityError::OperationOutOfScope { operation, .. } if *operation == op.name()
1411 )
1412 })
1413 {
1414 return Err(CliError::new(
1415 ExitKind::Validation,
1416 "PROJECTION_CAPABILITY_UNSUPPORTED",
1417 err.to_string(),
1418 )
1419 .with_details(json!({ "binding": binding_id, "operation": op.name() }))
1420 .into());
1421 }
1422
1423 write_binding(&root, &mem, &stem, &binding).map_err(|e| enable_failed(&binding_id, e))?;
1424
1425 let mut operations: Vec<&str> = Vec::new();
1426 if binding.operations.build.is_some() {
1427 operations.push("build");
1428 }
1429 if binding.operations.sync.is_some() {
1430 operations.push("sync");
1431 }
1432 if binding.operations.verify.is_some() {
1433 operations.push("verify");
1434 }
1435
1436 if ctx.json {
1437 print_json(&json!({
1438 "binding": binding_id,
1439 "enabled": op.name(),
1440 "operations": operations,
1441 }))?;
1442 } else {
1443 print_markdown(&format!(
1444 "# Projection enable\n\nEnabled `{}` on binding `{binding_id}`.\n\nOperations: {}\n",
1445 op.name(),
1446 operations.join(", ")
1447 ));
1448 }
1449 Ok(())
1450}
1451
1452fn map_resolve_err(binding_id: &str, err: ResolveError) -> CliError {
1456 let message = err.to_string();
1457 let mapped = match err {
1458 ResolveError::MalformedProjectionRef { .. } => {
1459 CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
1460 }
1461 _ => CliError::new(ExitKind::Generic, "PROJECTION_ADVANCE_FAILED", message),
1462 };
1463 mapped.with_details(json!({ "binding": binding_id }))
1464}
1465
1466fn map_advance_err(binding_id: &str, err: AdvanceError) -> CliError {
1471 let message = err.to_string();
1472 match &err {
1473 AdvanceError::MalformedId(_) => {
1474 CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
1475 .with_details(json!({ "binding": binding_id }))
1476 }
1477 AdvanceError::UnknownArtifact {
1478 artifacts,
1479 suggestions,
1480 ..
1481 } => {
1482 let corrected: serde_json::Map<String, serde_json::Value> = suggestions
1486 .iter()
1487 .map(|(supplied, corrected)| {
1488 (
1489 supplied.clone(),
1490 serde_json::Value::String(corrected.clone()),
1491 )
1492 })
1493 .collect();
1494 CliError::new(
1495 ExitKind::Validation,
1496 "PROJECTION_ADVANCE_UNKNOWN_ARTIFACT",
1497 message,
1498 )
1499 .with_details(json!({
1500 "binding": binding_id,
1501 "unknown_artifacts": artifacts,
1502 "corrected_artifacts": corrected,
1503 }))
1504 }
1505 AdvanceError::Store(_) | AdvanceError::Engine(_) => {
1506 CliError::new(ExitKind::Generic, "PROJECTION_ADVANCE_FAILED", message)
1507 .with_details(json!({ "binding": binding_id }))
1508 }
1509 }
1510}
1511
1512fn advance(ctx: &CliContext, args: AdvanceArgs) -> anyhow::Result<()> {
1513 let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1514 workspace_not_initialised_error(
1515 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1516 )
1517 })?;
1518
1519 let binding_id = args.binding;
1520
1521 let dispositions: std::collections::BTreeMap<String, DispositionInput> =
1524 serde_json::from_str(&args.dispositions).map_err(|e| {
1525 CliError::new(
1526 ExitKind::Validation,
1527 "PROJECTION_INVALID_DISPOSITIONS",
1528 format!(
1529 "--dispositions must be a JSON object mapping artifact id → either a \
1530 disposition string (e.g. \"worked\") or an object \
1531 {{\"disposition\": \"excluded\", \"rationale\": \"...\"}}: {e}"
1532 ),
1533 )
1534 .with_details(json!({ "error": e.to_string() }))
1535 })?;
1536
1537 let configs = load_pipeline_configs(&root).map_err(|e| {
1539 CliError::new(
1540 ExitKind::Generic,
1541 "PROJECTION_ADVANCE_FAILED",
1542 format!("could not load pipeline config: {e}"),
1543 )
1544 .with_details(json!({ "error": e.to_string() }))
1545 })?;
1546 let record = configs
1547 .bindings
1548 .iter()
1549 .find(|r| format!("{}/{}", r.mem, r.name) == binding_id)
1550 .ok_or_else(|| binding_miss_error(&configs, &binding_id))?;
1551
1552 if record.config.operations.sync.is_none() {
1556 return Err(CliError::new(
1557 ExitKind::Validation,
1558 "PROJECTION_SYNC_NOT_ENABLED",
1559 format!(
1560 "binding `{binding_id}` has no sync operation — enable it with \
1561 `memstead projection enable sync {binding_id}`"
1562 ),
1563 )
1564 .with_details(json!({ "binding": binding_id }))
1565 .into());
1566 }
1567
1568 let resolved = resolve_binding_run(&binding_id, &record.config)
1569 .map_err(|e| map_resolve_err(&binding_id, e))?;
1570
1571 let mut cli_engine = ctx.cli_engine_at(&root)?;
1574 let engine = cli_engine.base_mut();
1575
1576 let outcome = advance_baseline(engine, &root, &resolved, &dispositions)
1577 .map_err(|e| map_advance_err(&binding_id, e))?;
1578
1579 if ctx.json {
1580 print_json(&json!({
1581 "binding": outcome.binding,
1582 "completed": outcome.completed,
1583 "disposed": outcome.disposed,
1584 "pending": outcome.pending,
1585 "remainder": outcome.remainder,
1586 "tokens_written": outcome.tokens_written,
1587 "warnings": outcome.warnings,
1588 }))?;
1589 } else {
1590 let mut out = format!(
1591 "# Projection advance\n\nBinding `{}`: {} artifact(s) disposed, {} remaining.\n",
1592 outcome.binding, outcome.disposed, outcome.pending
1593 );
1594 if outcome.completed {
1595 out.push_str("\nEvery presented artifact is disposed — the sync baseline advanced.\n");
1596 if !outcome.tokens_written.is_empty() {
1597 out.push_str("\nBaseline tokens written:\n");
1598 for key in &outcome.tokens_written {
1599 out.push_str(&format!("- `{key}`\n"));
1600 }
1601 }
1602 } else {
1603 out.push_str(
1604 "\nRemainder still pending — re-run `projection advance` after judging the rest \
1605 (a brief re-render shows what is left).\n",
1606 );
1607 }
1608 if !outcome.warnings.is_empty() {
1609 out.push_str("\n## Warnings\n\n");
1610 for w in &outcome.warnings {
1611 out.push_str(&format!("- {w}\n"));
1612 }
1613 }
1614 print_markdown(&out);
1615 }
1616 Ok(())
1617}
1618
1619fn map_exclude_err(binding_id: &str, err: ExcludeError) -> CliError {
1624 let message = err.to_string();
1625 match &err {
1626 ExcludeError::MalformedId(_) => {
1627 CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
1628 .with_details(json!({ "binding": binding_id }))
1629 }
1630 ExcludeError::NotSourceMember { artifacts, .. } => CliError::new(
1631 ExitKind::Validation,
1632 "PROJECTION_EXCLUDE_NOT_SOURCE_MEMBER",
1633 message,
1634 )
1635 .with_details(json!({ "binding": binding_id, "not_source_members": artifacts })),
1636 ExcludeError::Store(_) => {
1637 CliError::new(ExitKind::Generic, "PROJECTION_EXCLUDE_FAILED", message)
1638 .with_details(json!({ "binding": binding_id }))
1639 }
1640 }
1641}
1642
1643fn exclude(ctx: &CliContext, args: ExcludeArgs) -> anyhow::Result<()> {
1644 let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1645 workspace_not_initialised_error(
1646 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1647 )
1648 })?;
1649
1650 let binding_id = args.binding;
1651
1652 let exclusions: std::collections::BTreeMap<String, String> =
1655 serde_json::from_str(&args.exclusions).map_err(|e| {
1656 CliError::new(
1657 ExitKind::Validation,
1658 "PROJECTION_INVALID_EXCLUSIONS",
1659 format!(
1660 "--exclusions must be a JSON object mapping in-scope artifact id → \
1661 rationale string: {e}"
1662 ),
1663 )
1664 .with_details(json!({ "error": e.to_string() }))
1665 })?;
1666
1667 let configs = load_pipeline_configs(&root).map_err(|e| {
1669 CliError::new(
1670 ExitKind::Generic,
1671 "PROJECTION_EXCLUDE_FAILED",
1672 format!("could not load pipeline config: {e}"),
1673 )
1674 .with_details(json!({ "error": e.to_string() }))
1675 })?;
1676 let record = configs
1677 .bindings
1678 .iter()
1679 .find(|r| format!("{}/{}", r.mem, r.name) == binding_id)
1680 .ok_or_else(|| binding_miss_error(&configs, &binding_id))?;
1681
1682 let resolved = resolve_binding_run(&binding_id, &record.config)
1683 .map_err(|e| map_resolve_err(&binding_id, e))?;
1684
1685 let outcome = record_exclusions(&root, &resolved, &exclusions)
1686 .map_err(|e| map_exclude_err(&binding_id, e))?;
1687
1688 if ctx.json {
1689 print_json(&json!({
1690 "binding": outcome.binding,
1691 "excluded": outcome.excluded,
1692 "added": outcome.added,
1693 }))?;
1694 } else {
1695 print_markdown(&format!(
1696 "# Projection exclude\n\nBinding `{}`: {} artifact(s) newly excluded, \
1697 {} in the ledger.\n",
1698 outcome.binding, outcome.added, outcome.excluded
1699 ));
1700 }
1701 Ok(())
1702}
1703
1704fn render_full_resync_note(decision: &FullResyncDecision) -> String {
1710 match decision {
1711 FullResyncDecision::Disabled => String::new(),
1712 FullResyncDecision::NotDue { .. } => String::new(),
1713 FullResyncDecision::Forced { walked_facets } => {
1717 let facets = if walked_facets.is_empty() {
1718 "(no primary facets)".to_string()
1719 } else {
1720 walked_facets.join(", ")
1721 };
1722 format!(
1723 "> **Full measurement (`--full`)** — full-enumeration walk over: {facets}. \
1724 Sampling scheduler bypassed; adjudication cap unlimited. Coverage and \
1725 accuracy figures below are computed over the whole source, not sampled.\n\n"
1726 )
1727 }
1728 FullResyncDecision::Due {
1729 walked_facets,
1730 refused,
1731 ..
1732 } => {
1733 let mut s = String::from("> **Scheduled full resync (D3)** — ");
1734 if walked_facets.is_empty() {
1735 s.push_str("no enumerable facet to walk this run.");
1736 } else {
1737 s.push_str(&format!(
1738 "full-enumeration coverage walk fired for: {}.",
1739 walked_facets.join(", ")
1740 ));
1741 }
1742 for r in refused {
1743 s.push_str(&format!(
1744 "\n> **Refused (non-enumerable):** `{}` ({}) — {}",
1745 r.facet, r.medium_type, r.reason
1746 ));
1747 }
1748 s.push_str("\n\n");
1749 s
1750 }
1751 }
1752}
1753
1754fn verify(ctx: &CliContext, args: VerifyArgs) -> anyhow::Result<()> {
1760 let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1761 workspace_not_initialised_error(
1762 "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1763 )
1764 })?;
1765
1766 let binding_id = args.binding;
1767
1768 let configs = load_pipeline_configs(&root).map_err(|e| {
1769 CliError::new(
1770 ExitKind::Generic,
1771 "PROJECTION_VERIFY_FAILED",
1772 format!("could not load pipeline config: {e}"),
1773 )
1774 .with_details(json!({ "error": e.to_string() }))
1775 })?;
1776 let record = configs
1777 .bindings
1778 .iter()
1779 .find(|r| format!("{}/{}", r.mem, r.name) == binding_id)
1780 .ok_or_else(|| binding_miss_error(&configs, &binding_id))?;
1781
1782 let resolved = resolve_binding_run(&binding_id, &record.config)
1783 .map_err(|e| map_resolve_err(&binding_id, e))?;
1784
1785 let mut cli_engine = ctx.cli_engine_at(&root)?;
1789 let engine = cli_engine.base_mut();
1790
1791 let run = if args.full {
1792 verify_binding_full
1793 } else {
1794 verify_binding
1795 };
1796 let outcome = run(engine, &root, &record.config, &resolved).map_err(|e| match &e {
1797 FindingsError::SourceUnreachable { source_name, path } => CliError::new(
1802 ExitKind::Validation,
1803 "SOURCE_UNREACHABLE",
1804 format!(
1805 "verify refused for `{binding_id}`: source '{source_name}' resolves to \
1806 `{path}`, which does not exist — restore or remount the source (or \
1807 repoint its pointer); the recorded `#verified` baseline was left \
1808 untouched"
1809 ),
1810 )
1811 .with_details(json!({
1812 "binding": binding_id,
1813 "source": source_name,
1814 "path": path,
1815 })),
1816 FindingsError::FullWalkNonEnumerable(refusal) => CliError::new(
1822 ExitKind::Validation,
1823 "PROJECTION_CAPABILITY_UNSUPPORTED",
1824 format!("verify --full refused for `{binding_id}`: {e}"),
1825 )
1826 .with_details(json!({
1827 "binding": binding_id,
1828 "facet": refusal.facet,
1829 "medium_type": refusal.medium_type,
1830 "reason": refusal.reason,
1831 })),
1832 _ => CliError::new(
1833 ExitKind::Generic,
1834 "PROJECTION_VERIFY_FAILED",
1835 format!("verify failed for `{binding_id}`: {e}"),
1836 )
1837 .with_details(json!({ "binding": binding_id, "error": e.to_string() })),
1838 })?;
1839
1840 let hashes_backfilled = record_anchor_hash_backfill(
1847 engine,
1848 &resolved.destination_mem,
1849 &outcome,
1850 Some("projection verify: prepared-hash backfill onto hash-less anchors"),
1851 )
1852 .map_err(|e| {
1853 CliError::new(
1854 ExitKind::Generic,
1855 "PROJECTION_VERIFY_BACKFILL_FAILED",
1856 format!(
1857 "verify completed and findings were recorded for `{binding_id}`, but \
1858 recording the prepared-hash backfill onto the anchors sidecar failed: {e}"
1859 ),
1860 )
1861 .with_details(json!({ "binding": binding_id, "error": e.to_string() }))
1862 })?;
1863
1864 let budget = args.budget.unwrap_or(DEFAULT_REPORT_BUDGET);
1867 let report = compute_fidelity_report(engine, &root, &record.config, &resolved, &outcome.key);
1868 let rendered = render_fidelity_report(&report, budget, &args.include);
1869
1870 let verified_baseline = record_verified_baseline(
1874 engine,
1875 &resolved.destination_mem,
1876 &outcome,
1877 Some("projection verify: completed-run #verified baseline"),
1878 )
1879 .map_err(|e| {
1880 CliError::new(
1881 ExitKind::Generic,
1882 "PROJECTION_VERIFY_BASELINE_FAILED",
1883 format!(
1884 "verify completed and findings were recorded for `{binding_id}`, but writing \
1885 the `#verified` baseline failed: {e}"
1886 ),
1887 )
1888 .with_details(json!({ "binding": binding_id, "error": e.to_string() }))
1889 })?;
1890
1891 if ctx.json {
1892 print_json(&json!({
1893 "binding": outcome.binding,
1894 "key": {
1895 "binding_hash": outcome.key.binding_hash,
1896 "source_head": outcome.key.source_head,
1897 },
1898 "recorded": outcome.recorded,
1899 "superseded": outcome.superseded,
1900 "backlog": outcome.backlog,
1901 "full_resync": outcome.full_resync,
1905 "verified_baseline": verified_baseline,
1908 "hash_backfilled": hashes_backfilled,
1913 "report": report,
1914 "report_mode": rendered.mode,
1915 "report_markdown": rendered.markdown,
1916 }))?;
1917 } else {
1918 let baseline_note = if verified_baseline.is_empty() {
1924 String::new()
1925 } else {
1926 format!(
1927 "\n> **Verified baseline recorded** — {}\n",
1928 verified_baseline
1929 .iter()
1930 .map(|k| format!("`{k}`"))
1931 .collect::<Vec<_>>()
1932 .join(", ")
1933 )
1934 };
1935 let backfill_note = if hashes_backfilled == 0 {
1936 String::new()
1937 } else {
1938 format!(
1939 "\n> **Prepared-hash backfill recorded** — {hashes_backfilled} hash-less \
1940 anchor(s) now carry their observed prepared-content hash; subsequent \
1941 verifies adjudicate them deterministically.\n"
1942 )
1943 };
1944 print_markdown(&format!(
1945 "{}{}{}{}",
1946 render_full_resync_note(&outcome.full_resync),
1947 rendered.markdown,
1948 backfill_note,
1949 baseline_note
1950 ));
1951 }
1952 Ok(())
1953}