diff --git a/rust/rac-engine/src/agent_rules.rs b/rust/rac-engine/src/agent_rules.rs index 1710b8bb..c48573b5 100644 --- a/rust/rac-engine/src/agent_rules.rs +++ b/rust/rac-engine/src/agent_rules.rs @@ -14,7 +14,6 @@ use serde_json::{json, Map, Value}; use crate::identity::artifact_identifier; use crate::pycompat::{first_nonempty_line, py_casefold, read_text_universal}; -use crate::relationships::corpus_items; use crate::spec::spec_for; const DECISION_TYPE: &str = "decision"; @@ -179,9 +178,11 @@ fn is_live_decision(artifact: &crate::parse::Artifact) -> bool { } /// `build_agent_rules_block(directory)` → ordered entries + digest. -fn build_projection(directory: &str) -> (Vec, String) { +fn build_projection(directory: &str) -> Result<(Vec, String), String> { let mut entries: Vec = Vec::new(); - for item in corpus_items(directory, true) { + for item in crate::federated_corpus::local_writable_items(directory, true) + .map_err(|error| error.to_string())? + { let Some(spec) = item.spec else { continue }; if spec.name != DECISION_TYPE || !is_live_decision(&item.artifact) { continue; @@ -215,7 +216,7 @@ fn build_projection(directory: &str) -> (Vec, String) { .collect(); let canonical = crate::pyjson::dumps_canonical_sorted(&Value::Array(payload)); let digest = crate::sha256::hexdigest(canonical.as_bytes()); - (entries, digest) + Ok((entries, digest)) } /// `render_managed_block(projection)` — markers + distilled pointers, no @@ -298,7 +299,7 @@ pub fn generate_agent_rules( root: &str, clients: &[String], ) -> Result { - let (entries, digest) = build_projection(directory); + let (entries, digest) = build_projection(directory)?; let block = render_managed_block(&entries, &digest); let mut files: Vec = Vec::new(); @@ -349,8 +350,12 @@ pub fn generate_agent_rules( /// `check_agent_rules(directory, root, clients)` — never writes; compares /// each present target's embedded digest to the live projection. -pub fn check_agent_rules(directory: &str, root: &str, clients: &[String]) -> AgentRulesResult { - let (_, digest) = build_projection(directory); +pub fn check_agent_rules( + directory: &str, + root: &str, + clients: &[String], +) -> Result { + let (_, digest) = build_projection(directory)?; let mut files: Vec = Vec::new(); for target in targets_for(clients) { @@ -371,12 +376,12 @@ pub fn check_agent_rules(directory: &str, root: &str, clients: &[String]) -> Age }); } - AgentRulesResult { + Ok(AgentRulesResult { mode: "check", digest, root: root.to_string(), files, - } + }) } #[cfg(test)] diff --git a/rust/rac-engine/src/commands.rs b/rust/rac-engine/src/commands.rs index 94468d27..be1afaec 100644 --- a/rust/rac-engine/src/commands.rs +++ b/rust/rac-engine/src/commands.rs @@ -148,6 +148,7 @@ pub fn validate_directory(directory: &str, recursive: bool) -> DirectoryValidati .collect(); let okf_entries: Vec = entries .iter() + .filter(|item| item.origin.layer == crate::corpus::Layer::Local) .map(|item| OkfEntry { path: &item.path, artifact_type: item @@ -166,6 +167,100 @@ pub fn validate_directory(directory: &str, recursive: bool) -> DirectoryValidati } } +/// Structural validation over the effective projection of one already-loaded +/// composition. Inherited errors were collapsed by the loader; their warnings +/// remain parent-owned and are not repeated in every child. +pub(crate) fn validate_directory_from_items( + directory: &str, + recursive: bool, + entries: &[crate::relationships::CorpusItem], +) -> DirectoryValidation { + let overrides = load_overrides(directory); + let provider = load_ticketing_provider(directory); + use rayon::prelude::*; + let files: Vec = entries + .par_iter() + .map(|item| { + let artifact_type = item + .spec + .map(|spec| spec.name.clone()) + .unwrap_or_else(|| "unknown".to_string()); + if item.spec.is_none() { + return FileValidation { + path: item.path.clone(), + artifact_type, + status: STATUS_SKIPPED, + issues: Vec::new(), + }; + } + let issues = if item.origin.layer == crate::corpus::Layer::Inherited { + Vec::new() + } else { + apply_overrides( + validate(&item.artifact, provider.as_deref(), Some(&artifact_type)), + &artifact_type, + &overrides, + ) + }; + let status = if has_errors(&issues) { + STATUS_INVALID + } else { + STATUS_VALID + }; + FileValidation { + path: item.path.clone(), + artifact_type, + status, + issues, + } + }) + .collect(); + let okf_entries: Vec = entries + .iter() + .filter(|item| item.origin.layer == crate::corpus::Layer::Local) + .map(|item| OkfEntry { + path: &item.path, + artifact_type: item.spec.map(|spec| spec.name.as_str()).unwrap_or("unknown"), + file_name: item.path.rsplit('/').next().unwrap_or(&item.path), + }) + .collect(); + DirectoryValidation { + directory: directory.to_string(), + recursive, + files, + okf: Some(check_okf_conformance(&okf_entries, &overrides)), + } +} + +fn load_composed_or_exit( + directory: &str, + recursive: bool, +) -> Result, i32> { + match crate::federated_corpus::load_composed_corpus(directory, recursive) { + Ok(corpus) => Ok(corpus), + Err(error) => { + eprintln!("decided: {error}"); + Err(EXIT_VALIDATION_FAILED) + } + } +} + +fn refuse_read_only_target(path: &str) -> Option { + match crate::federated_corpus::is_read_only_materialised_path(path) { + Ok(false) => None, + Ok(true) => { + eprintln!( + "decided: refusing to write inside the inherited read-only parent materialisation: {path}" + ); + Some(EXIT_VALIDATION_FAILED) + } + Err(error) => { + eprintln!("decided: {error}"); + Some(EXIT_VALIDATION_FAILED) + } + } +} + /// A fingerprint of the ancestor-walked `.decided/config.yaml` governing /// `directory` — the per-file cache key's config half (ADR-106). fn config_fingerprint(directory: &str) -> String { @@ -476,12 +571,37 @@ pub fn cmd_validate(args: &ValidateArgs) -> i32 { if args.corpus.is_some() { return usage_error("--corpus applies to stdin ('-') or a single file"); } + let composed = crate::federated_corpus::load_composed_corpus( + &args.file, + !args.top_level, + ); // The cache reuses per-file results across runs (ADR-106), // byte-identical to the uncached path; on by default per ADR-112. - let result = if crate::derived_cache::cache_enabled(args.cache) { - validate_directory_incremental(&args.file, !args.top_level, args.verify) - } else { - validate_directory(&args.file, !args.top_level) + let result = match composed { + Ok(Some(composed)) => { + let items: Vec<_> = composed.effective().cloned().collect(); + validate_directory_from_items(&args.file, !args.top_level, &items) + } + Ok(None) if crate::derived_cache::cache_enabled(args.cache) => { + validate_directory_incremental(&args.file, !args.top_level, args.verify) + } + Ok(None) => validate_directory(&args.file, !args.top_level), + Err(error) => DirectoryValidation { + directory: args.file.clone(), + recursive: !args.top_level, + files: vec![FileValidation { + path: crate::federation::MANIFEST_RELATIVE_PATH.to_string(), + artifact_type: "corpus-manifest".to_string(), + status: STATUS_INVALID, + issues: vec![Issue::new( + "error", + error.stable_code(), + error.to_string(), + None, + )], + }], + okf: None, + }, }; if args.sarif { emit(output::render_validate_sarif(&result)); @@ -736,7 +856,13 @@ pub fn cmd_relationships(args: &RelationshipsArgs) -> i32 { if args.validate { let report = if is_dir { - validate_relationships(&args.path, !args.top_level) + match load_composed_or_exit(&args.path, !args.top_level) { + Ok(Some(composed)) => { + composed.validate_relationships(&args.path, !args.top_level) + } + Ok(None) => validate_relationships(&args.path, !args.top_level), + Err(code) => return code, + } } else { validate_relationships_file(&args.path) }; @@ -883,9 +1009,23 @@ pub fn cmd_decisions_for(args: &DecisionsForArgs) -> i32 { if !Path::new(&args.directory).is_dir() { return usage_error(&format!("not a directory: {}", args.directory)); } - let result = crate::retrieve::decisions_for_path(&args.directory, &args.path, !args.top_level); + let composed = match load_composed_or_exit(&args.directory, !args.top_level) { + Ok(composed) => composed, + Err(code) => return code, + }; + let result = if let Some(composed) = &composed { + let items: Vec<_> = composed.effective().cloned().collect(); + let rows = crate::retrieve::scope_rows_from_items(&items); + crate::retrieve::decisions_for_path_with_rows(&rows, &args.directory, &args.path) + } else { + crate::retrieve::decisions_for_path(&args.directory, &args.path, !args.top_level) + }; if args.json { - emit(output::render_decisions_for_json(&result)); + emit(if let Some(composed) = &composed { + output::render_decisions_for_json_with_composed(&result, composed) + } else { + output::render_decisions_for_json(&result) + }); } else { emit(output::render_decisions_for_human(&result)); } @@ -919,15 +1059,27 @@ pub fn cmd_gate(args: &GateArgs) -> i32 { if args.code && !args.full && args.base.is_none() { return usage_error("a diff base is required for --code unless --full is supplied"); } - let report = match crate::gate::build_gate_with_code( - &args.directory, - !args.top_level, + let composed = match load_composed_or_exit(&args.directory, !args.top_level) { + Ok(composed) => composed, + Err(code) => return code, + }; + let code_options = || { args.code.then_some(crate::gate::CodeGateOptions { repository: &args.repository, base: args.base.as_deref(), full_tree: args.full, - }), - ) { + }) + }; + let report = match if let Some(composed) = &composed { + crate::gate::build_gate_with_composed( + &args.directory, + !args.top_level, + code_options(), + composed, + ) + } else { + crate::gate::build_gate_with_code(&args.directory, !args.top_level, code_options()) + } { Ok(report) => report, Err(exc) => { eprintln!("decided: {}", exc.message()); @@ -966,13 +1118,33 @@ pub fn cmd_sentry(args: &SentryArgs) -> i32 { if !Path::new(&args.directory).is_dir() { return usage_error(&format!("not a directory: {}", args.directory)); } - let report = match crate::sentry::analyze( - &args.directory, - &args.repository, - !args.top_level, - args.base.as_deref(), - args.full, - ) { + let composed = match load_composed_or_exit(&args.directory, !args.top_level) { + Ok(composed) => composed, + Err(code) => return code, + }; + let composed_items: Vec<_> = composed + .as_ref() + .map(|corpus| corpus.effective().cloned().collect()) + .unwrap_or_default(); + let report = match if let Some(composed) = &composed { + crate::sentry::analyze_with_items( + &args.directory, + &args.repository, + args.base.as_deref(), + args.full, + &composed_items, + true, + composed.read_only_root(), + ) + } else { + crate::sentry::analyze( + &args.directory, + &args.repository, + !args.top_level, + args.base.as_deref(), + args.full, + ) + } { Ok(report) => report, Err(message) => return usage_error(&message), }; @@ -1319,8 +1491,14 @@ fn cmd_agent_rules(args: &ExportArgs) -> i32 { // Invalid --client values were already rejected by the argv parser // (argparse choices), so `unknown_clients` is unreachable here. let root = crate::agent_rules::agent_rules_root(&args.directory, args.out.as_deref()); + if let Some(code) = refuse_read_only_target(&root) { + return code; + } let result = if args.check { - crate::agent_rules::check_agent_rules(&args.directory, &root, &args.client) + match crate::agent_rules::check_agent_rules(&args.directory, &root, &args.client) { + Ok(result) => result, + Err(exc) => return usage_error(&format!("cannot read corpus: {exc}")), + } } else { match crate::agent_rules::generate_agent_rules(&args.directory, &root, &args.client) { Ok(result) => result, @@ -1413,17 +1591,89 @@ pub struct ResolveArgs { pub top_level: bool, } +fn composed_resolution( + corpus: &crate::composition::ComposedCorpus, + artifact_id: &str, +) -> Result { + let reference = crate::pycompat::py_strip(artifact_id); + match corpus.resolve(reference) { + Ok(item) => { + let entry = crate::resolve::identity_entry_from_item(item); + Ok(crate::resolve::ResolutionResult { + artifact_id: artifact_id.to_string(), + outcome: crate::resolve::OUTCOME_RESOLVED, + artifact: Some(crate::resolve::resolved_from_entry(&entry)), + duplicate_paths: Vec::new(), + }) + } + Err(crate::composition::LookupError::NotFound) => Ok(crate::resolve::ResolutionResult { + artifact_id: artifact_id.to_string(), + outcome: crate::resolve::OUTCOME_NOT_FOUND, + artifact: None, + duplicate_paths: Vec::new(), + }), + Err(crate::composition::LookupError::Ambiguous(keys)) => { + let mut paths: Vec = keys + .iter() + .filter_map(|key| corpus.item(key)) + .map(|item| { + format!( + "{}::{}", + item.artifact_path.source, item.artifact_path.relative_path + ) + }) + .collect(); + paths.sort(); + Ok(crate::resolve::ResolutionResult { + artifact_id: artifact_id.to_string(), + outcome: crate::resolve::OUTCOME_DUPLICATE, + artifact: None, + duplicate_paths: paths, + }) + } + Err(error) => Err(error), + } +} + pub fn cmd_resolve(args: &ResolveArgs) -> i32 { if !Path::new(&args.directory).is_dir() { return usage_error(&format!("not a directory: {}", args.directory)); } - let result = crate::resolve::resolve_artifact(&args.directory, &args.id, !args.top_level); + let composed = match load_composed_or_exit(&args.directory, !args.top_level) { + Ok(composed) => composed, + Err(code) => return code, + }; + let result = if let Some(composed) = &composed { + match composed_resolution(composed, &args.id) { + Ok(result) => result, + Err(crate::composition::LookupError::QualifiedCanonicalRequired) => { + eprintln!( + "decided: qualified references require a canonical artifact ID after `::`: {}", + args.id + ); + return EXIT_VALIDATION_FAILED; + } + Err(_) => { + eprintln!("decided: invalid qualified artifact reference: {}", args.id); + return EXIT_VALIDATION_FAILED; + } + } + } else { + crate::resolve::resolve_artifact(&args.directory, &args.id, !args.top_level) + }; if args.json { - emit(output::render_resolve_json(&result)); + emit(if let Some(composed) = &composed { + output::render_resolve_json_with_composed(&result, composed) + } else { + output::render_resolve_json(&result) + }); } else if result.outcome == crate::resolve::OUTCOME_RESOLVED { - emit(output::render_resolve_human( - result.artifact.as_ref().expect("resolved implies artifact"), - )); + let artifact = result.artifact.as_ref().expect("resolved implies artifact"); + emit(if composed.is_some() { + output::render_resolve_human_with_origin(artifact) + } else { + output::render_resolve_human(artifact) + }); } else if result.outcome == crate::resolve::OUTCOME_DUPLICATE { let found: Vec = result .duplicate_paths @@ -1555,7 +1805,32 @@ pub fn cmd_find(args: &FindArgs) -> i32 { if !Path::new(&args.directory).is_dir() { return usage_error(&format!("not a directory: {}", args.directory)); } - let mut result = if crate::derived_cache::cache_enabled(args.cache) { + let composed = match load_composed_or_exit(&args.directory, !args.top_level) { + Ok(composed) => composed, + Err(code) => return code, + }; + let mut result = if let Some(composed) = &composed { + let entries = composed.effective_index(); + if args.decisions { + let live_paths: Vec = composed + .effective() + .filter(|item| { + item.spec.map(|spec| spec.name.as_str()) == Some("decision") + && crate::resolve::is_live_decision(&item.artifact) + }) + .map(|item| item.path.clone()) + .collect(); + crate::read_model::find_decisions_in(&entries, &live_paths, &args.query) + } else { + crate::resolve::search_index_filtered( + &entries, + &args.query, + args.artifact_type.as_deref(), + &args.tags, + args.live, + ) + } + } else if crate::derived_cache::cache_enabled(args.cache) { // Default store reuse (ADR-112): serve from the persistent index // store instead of a fresh walk, byte-identical to the walk below. find_from_store(args) @@ -1573,10 +1848,16 @@ pub fn cmd_find(args: &FindArgs) -> i32 { args.live, ) }; - annotate_search_recency(&mut result.matches, &args.directory); + if composed.is_none() { + annotate_search_recency(&mut result.matches, &args.directory); + } let render_started = crate::timing::start(); let rendered = if args.json { - output::render_find_json(&result, args.explain) + if let Some(composed) = &composed { + output::render_find_json_with_composed(&result, args.explain, composed) + } else { + output::render_find_json(&result, args.explain) + } } else { output::render_find_human(&result, args.explain) }; @@ -1608,20 +1889,55 @@ pub fn cmd_diagnose(args: &DiagnoseArgs) -> i32 { if !Path::new(&args.directory).is_dir() { return usage_error(&format!("not a directory: {}", args.directory)); } - let diagnosis = crate::resolve::diagnose_artifact( - &args.directory, - &args.query, - &args.target, - crate::resolve::DiagnoseOptions { - artifact_type: args.artifact_type.as_deref(), - recursive: !args.top_level, - tags: &args.tags, - live_only: args.live, - surface_limit: args.surface_limit, - }, - ); + let composed = match load_composed_or_exit(&args.directory, !args.top_level) { + Ok(composed) => composed, + Err(code) => return code, + }; + let diagnosis = if let Some(composed) = &composed { + let identity = composed.identity_index(); + let mut effective = composed.effective_index(); + for entry in &mut effective { + let Some(key) = &entry.key else { continue }; + if let Some(identity_entry) = identity + .iter() + .find(|candidate| candidate.key.as_ref() == Some(key)) + { + entry.aliases = identity_entry.aliases.clone(); + } + } + let target_is_effective = composed + .resolve(crate::pycompat::py_strip(&args.target)) + .ok() + .is_some_and(|target| effective.iter().any(|entry| entry.key.as_ref() == Some(&target.key))); + crate::resolve::diagnose_index( + if target_is_effective { &effective } else { &identity }, + &args.query, + &args.target, + args.artifact_type.as_deref(), + &args.tags, + args.live, + args.surface_limit, + ) + } else { + crate::resolve::diagnose_artifact( + &args.directory, + &args.query, + &args.target, + crate::resolve::DiagnoseOptions { + artifact_type: args.artifact_type.as_deref(), + recursive: !args.top_level, + tags: &args.tags, + live_only: args.live, + surface_limit: args.surface_limit, + }, + ) + }; if args.json { - emit(output::render_diagnosis_json(&diagnosis)); + emit(if composed.is_some() { + output::render_diagnosis_json_with_origin(&diagnosis) + } else { + output::render_diagnosis_json(&diagnosis) + }); } else { emit(output::render_diagnosis_human(&diagnosis)); } @@ -1655,14 +1971,30 @@ pub fn cmd_retrieve(args: &RetrieveArgs) -> i32 { if args.budget < 1 { return usage_error(&format!("--budget must be at least 1, got {}", args.budget)); } - let payload = crate::retrieve::retrieve_grounding( - &args.directory, - &args.task, - args.scope.as_deref(), - args.top_k, - args.budget, - !args.all, - ); + let composed = match load_composed_or_exit(&args.directory, true) { + Ok(composed) => composed, + Err(code) => return code, + }; + let payload = if let Some(composed) = &composed { + crate::retrieve::retrieve_grounding_from_composed( + &args.directory, + &args.task, + args.scope.as_deref(), + args.top_k, + args.budget, + !args.all, + composed, + ) + } else { + crate::retrieve::retrieve_grounding( + &args.directory, + &args.task, + args.scope.as_deref(), + args.top_k, + args.budget, + !args.all, + ) + }; let serialized = crate::budget::serialize(&payload, args.budget); if args.json { emit(serialized); @@ -1915,6 +2247,9 @@ pub struct NewArgs { /// exit 1 — all stderr `decided: `. pub fn cmd_new(args: &NewArgs) -> i32 { use crate::scaffold::ScaffoldError; + if let Some(code) = refuse_read_only_target(&args.output_path) { + return code; + } let created = match crate::scaffold::create_artifact(&args.artifact_type, &args.output_path) { Ok(created) => created, Err( @@ -2026,6 +2361,9 @@ pub fn cmd_init(args: &InitArgs) -> i32 { if !Path::new(&args.directory).is_dir() { return usage_error(&format!("not a directory: {}", args.directory)); } + if let Some(code) = refuse_read_only_target(&args.directory) { + return code; + } let result = match crate::scaffold::init_repository( &args.directory, &args.key, @@ -2069,6 +2407,9 @@ pub fn cmd_quickstart(args: &QuickstartArgs) -> i32 { if !Path::new(&args.directory).is_dir() { return usage_error(&format!("not a directory: {}", args.directory)); } + if let Some(code) = refuse_read_only_target(&args.directory) { + return code; + } let result = match crate::scaffold::quickstart(&args.directory, &args.key, &args.artifact_type) { Ok(result) => result, @@ -2108,6 +2449,9 @@ pub fn cmd_migrate(args: &MigrateArgs) -> i32 { if !Path::new(&args.directory).is_dir() { return usage_error(&format!("not a directory: {}", args.directory)); } + if let Some(code) = refuse_read_only_target(&args.directory) { + return code; + } if args.target == "layout" { return migrate_layout(args); } @@ -2206,8 +2550,39 @@ pub fn cmd_rename(args: &RenameArgs) -> i32 { if !Path::new(&args.directory).is_dir() { return usage_error(&format!("not a directory: {}", args.directory)); } - let plan = - crate::rename::compute_rename(&args.directory, &args.old, &args.new, !args.top_level); + if let Some(code) = refuse_read_only_target(&args.directory) { + return code; + } + let composed = match load_composed_or_exit(&args.directory, !args.top_level) { + Ok(Some(composed)) => { + if composed + .resolve(crate::pycompat::py_strip(&args.old)) + .ok() + .is_some_and(|item| item.origin.layer == crate::corpus::Layer::Inherited) + { + eprintln!( + "decided: refusing to rename inherited read-only artifact {}", + args.old + ); + return EXIT_VALIDATION_FAILED; + } + Some(composed) + } + Ok(None) => None, + Err(code) => return code, + }; + let plan = if let Some(corpus) = &composed { + let local: Vec<_> = corpus.local_items().cloned().collect(); + crate::rename::compute_rename_from_items( + &args.directory, + &args.old, + &args.new, + !args.top_level, + &local, + ) + } else { + crate::rename::compute_rename(&args.directory, &args.old, &args.new, !args.top_level) + }; if !plan.ok { if args.json { diff --git a/rust/rac-engine/src/composition.rs b/rust/rac-engine/src/composition.rs index 5f196297..d8b87f37 100644 --- a/rust/rac-engine/src/composition.rs +++ b/rust/rac-engine/src/composition.rs @@ -7,6 +7,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; +use std::path::{Path, PathBuf}; use crate::corpus::{ArtifactKey, ArtifactPath, Layer}; use crate::pycompat::py_casefold; @@ -15,7 +16,7 @@ use crate::relationships::{ validation_row_from_item, CorpusItem, Relationship, RelationshipValidation, ResolutionCandidate, ResolutionIndex, ValidationRow, }; -use crate::resolve::is_live_decision; +use crate::resolve::{entry_from_item, identity_entry_from_item, is_live_decision, IndexEntry}; pub const FINDING_CANONICAL_COLLISION: &str = "cross-corpus-canonical-id-collision"; pub const FINDING_INVALID_OVERRIDE: &str = "cross-corpus-invalid-override"; @@ -226,6 +227,35 @@ pub struct ValidatedOverride { pub rationale: ArtifactKey, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum OverrideRole { + Overridden, + Replacement, +} + +impl OverrideRole { + pub const fn as_str(self) -> &'static str { + match self { + Self::Overridden => "overridden", + Self::Replacement => "replacement", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct ArtifactOverrideProvenance { + pub state: OverrideRole, + pub parent: ArtifactKey, + pub replacement: ArtifactKey, + pub rationale: ArtifactKey, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComposedProvenance { + pub origin: crate::corpus::ArtifactOrigin, + pub overrides: Vec, +} + /// Exact lookup failure against the composed effective view. #[derive(Debug, Clone, PartialEq, Eq)] pub enum LookupError { @@ -239,6 +269,8 @@ pub enum LookupError { /// and effective corpora are stable ordered projections over it. pub struct ComposedCorpus { items: Vec, + child_source: Option, + read_only_root: Option, local: Vec, effective: Vec, parent: Option, @@ -256,7 +288,18 @@ impl ComposedCorpus { /// before manifest activation. pub fn local(mut items: Vec) -> Self { items.sort_by(stable_item_order); - Self::build(items, None, Vec::new(), HashMap::new()) + let child_source = items + .iter() + .find(|item| item.origin.layer == Layer::Local) + .map(|item| item.origin.source.clone()); + Self::build( + items, + child_source, + None, + None, + Vec::new(), + HashMap::new(), + ) } /// Compose one writable child with one already-verified read-only parent. @@ -268,7 +311,22 @@ impl ComposedCorpus { ) -> Self { local.append(&mut inherited); local.sort_by(stable_item_order); - Self::build(local, Some(parent), overrides, HashMap::new()) + let child_source = local + .iter() + .find(|item| item.origin.layer == Layer::Local) + .map(|item| item.origin.source.clone()); + let read_only_root = local + .iter() + .find(|item| item.origin.layer == Layer::Inherited) + .map(|item| item.locator.corpus.repository_root.clone()); + Self::build( + local, + child_source, + read_only_root, + Some(parent), + overrides, + HashMap::new(), + ) } /// Compose from verification-time snapshots. Captured bytes are owned by @@ -283,8 +341,41 @@ impl ComposedCorpus { ) -> Self { local.append(&mut inherited); local.sort_by(stable_item_order); + let child_source = local + .iter() + .find(|item| item.origin.layer == Layer::Local) + .map(|item| item.origin.source.clone()); + let read_only_root = local + .iter() + .find(|item| item.origin.layer == Layer::Inherited) + .map(|item| item.locator.corpus.repository_root.clone()); Self::build( local, + child_source, + read_only_root, + Some(parent), + overrides, + captured_content.into_iter().collect(), + ) + } + + /// Verified-loader constructor. The explicit child identity survives an + /// inherited-only composition where no local artifact can carry it. + pub fn compose_verified( + mut local: Vec, + mut inherited: Vec, + child_source: String, + read_only_root: PathBuf, + parent: ParentIdentity, + overrides: Vec, + captured_content: impl IntoIterator)>, + ) -> Self { + local.append(&mut inherited); + local.sort_by(stable_item_order); + Self::build( + local, + Some(child_source), + Some(read_only_root), Some(parent), overrides, captured_content.into_iter().collect(), @@ -293,6 +384,8 @@ impl ComposedCorpus { fn build( items: Vec, + child_source: Option, + read_only_root: Option, parent: Option, mut declarations: Vec, mut captured_content: HashMap>, @@ -389,6 +482,8 @@ impl ComposedCorpus { Self { items, + child_source, + read_only_root, local, effective, parent, @@ -418,10 +513,50 @@ impl ComposedCorpus { self.parent.as_ref() } + pub fn child_source(&self) -> Option<&str> { + self.child_source.as_deref() + } + + pub fn read_only_root(&self) -> Option<&Path> { + self.read_only_root.as_deref() + } + pub fn overrides(&self) -> &[ValidatedOverride] { &self.overrides } + /// Shared additive provenance for every public projection. Override roles + /// attach only to the retained parent and effective replacement; a + /// rationale-only artifact is not itself marked as overridden. + pub fn provenance_for(&self, key: &ArtifactKey) -> Option { + let item = self.item(key)?; + let mut overrides: Vec<_> = self + .overrides + .iter() + .filter_map(|mapping| { + let state = if &mapping.parent == key { + OverrideRole::Overridden + } else if &mapping.replacement == key { + OverrideRole::Replacement + } else { + return None; + }; + Some(ArtifactOverrideProvenance { + state, + parent: mapping.parent.clone(), + replacement: mapping.replacement.clone(), + rationale: mapping.rationale.clone(), + }) + }) + .collect(); + overrides.sort(); + overrides.dedup(); + Some(ComposedProvenance { + origin: item.origin.clone(), + overrides, + }) + } + pub fn findings(&self) -> &[CompositionFinding] { &self.findings } @@ -441,6 +576,82 @@ impl ComposedCorpus { self.captured_content.get(key).map(Vec::as_slice) } + /// Iterate the exact snapshot bytes retained by stable identity. This is + /// the bounded handoff used by long-lived readers which must serve an + /// inherited body without reopening its materialisation path. + pub fn captured_contents(&self) -> impl Iterator { + self.captured_content + .iter() + .map(|(key, bytes)| (key, bytes.as_slice())) + } + + /// Search/ranking projection over only the effective corpus. Inbound graph + /// counts come from this composition's source-aware resolver, including + /// qualified cross-source edges and canonical override redirects. + pub fn effective_index(&self) -> Vec { + let key_by_path: HashMap<&ArtifactPath, &ArtifactKey> = self + .items + .iter() + .map(|item| (&item.artifact_path, &item.key)) + .collect(); + let mut inbound: HashMap<&ArtifactKey, i64> = HashMap::new(); + for relationship in self.relationships() { + let Some(path) = relationship.resolved_artifact.as_ref() else { + continue; + }; + if let Some(key) = key_by_path.get(path) { + *inbound.entry(*key).or_insert(0) += 1; + } + } + self.effective() + .map(|item| entry_from_item(item, inbound.get(&item.key).copied().unwrap_or(0))) + .collect() + } + + /// Exact-identity projection over the retained catalog. Unqualified + /// aliases occur only on effective items; overridden parent history keeps + /// only its qualified canonical address, and replacement rows receive the + /// explicitly authorized parent-canonical redirect. + pub fn identity_index(&self) -> Vec { + let effective: BTreeSet<&ArtifactKey> = self.effective().map(|item| &item.key).collect(); + let mut entries: Vec = self + .catalog() + .map(|item| { + let mut entry = identity_entry_from_item(item); + if !effective.contains(&item.key) { + entry.aliases.clear(); + } + if item.origin.layer == Layer::Inherited { + if let Some(parent) = &self.parent { + entry + .aliases + .push(format!("{}::{}", parent.alias, item.key.canonical_id)); + } + } + entry + }) + .collect(); + let entry_by_key: HashMap = entries + .iter() + .enumerate() + .filter_map(|(index, entry)| entry.key.clone().map(|key| (key, index))) + .collect(); + for mapping in &self.overrides { + let Some(index) = entry_by_key.get(&mapping.replacement).copied() else { + continue; + }; + let alias = mapping.parent.canonical_id.clone(); + if !entries[index] + .aliases + .iter() + .any(|existing| py_casefold(existing) == py_casefold(&alias)) + { + entries[index].aliases.push(alias); + } + } + entries + } + /// Resolve against the effective unqualified view, or the retained parent /// catalog when the reference is explicitly qualified. pub fn resolve(&self, reference: &str) -> Result<&CorpusItem, LookupError> { diff --git a/rust/rac-engine/src/federated_corpus.rs b/rust/rac-engine/src/federated_corpus.rs new file mode 100644 index 00000000..215d46e3 --- /dev/null +++ b/rust/rac-engine/src/federated_corpus.rs @@ -0,0 +1,503 @@ +//! Verified parent snapshots -> the one source-aware composed read model. +//! +//! A repository without `.decided/corpus.md` returns `Ok(None)` so released +//! command paths remain untouched. A configured repository is snapshotted +//! once: inherited Markdown is parsed only from the exact bytes verified by +//! [`crate::federation::verify_parent`], and the local walk excludes that +//! materialisation subtree before any derived model is built. + +use std::fmt; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::classify::classify; +use crate::composition::{ + ComposedCorpus, OverrideDeclaration, OverrideSyntaxError, ParentIdentity, + FINDING_INVALID_OVERRIDE, +}; +use crate::corpus::{CorpusLayer, PhysicalArtifactLocator, PhysicalCorpusLocator}; +use crate::federation::{verify_parent, ParentCorpusError, SnapshotFile, VerifiedParent}; +use crate::parse::parse_bytes; +use crate::relationships::{ + relationship_severity, validation_from_rows, validation_row_from_item, CorpusItem, + ISSUE_SCOPE_TARGET_NOT_FOUND, +}; +use crate::spec::spec_for; +use crate::validate::{apply_overrides, has_errors, SeverityOverrides}; + +pub const PARENT_CORPUS_INVALID: &str = "parent-corpus-invalid"; +pub const FEDERATED_CORPUS_SNAPSHOT_FAILED: &str = "federated-corpus-snapshot-failed"; + +/// One stable, displayable failure at the verification/composition boundary. +#[derive(Debug)] +pub enum FederatedCorpusError { + Parent(ParentCorpusError), + ParentInvalid { + source: String, + path: PathBuf, + detail: String, + }, + LocalSnapshot { + path: PathBuf, + message: String, + }, + Composition { + code: &'static str, + path: PathBuf, + message: String, + }, +} + +impl FederatedCorpusError { + pub fn stable_code(&self) -> &str { + match self { + Self::Parent(error) => error.stable_code(), + Self::ParentInvalid { .. } => PARENT_CORPUS_INVALID, + Self::LocalSnapshot { .. } => FEDERATED_CORPUS_SNAPSHOT_FAILED, + Self::Composition { code, .. } => code, + } + } + + pub fn path(&self) -> Option<&Path> { + match self { + Self::Parent(error) => error.path.as_deref(), + Self::ParentInvalid { path, .. } | Self::Composition { path, .. } => Some(path), + Self::LocalSnapshot { path, .. } => Some(path), + } + } +} + +impl fmt::Display for FederatedCorpusError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Parent(error) => { + let mut message = error.message.clone(); + if let Some(path) = &error.path { + let stable = if path.ends_with(crate::federation::MANIFEST_RELATIVE_PATH) { + crate::federation::MANIFEST_RELATIVE_PATH + } else if path.ends_with(crate::federation::CONFIG_RELATIVE_PATH) { + crate::federation::CONFIG_RELATIVE_PATH + } else { + "declared parent materialisation" + }; + message = message.replace(&path.display().to_string(), stable); + } + write!(formatter, "{}: {message}", error.stable_code()) + } + Self::ParentInvalid { source, detail, .. } => write!( + formatter, + "{PARENT_CORPUS_INVALID}: parent source '{source}' is invalid: {detail}" + ), + Self::LocalSnapshot { path, message } => write!( + formatter, + "{FEDERATED_CORPUS_SNAPSHOT_FAILED}: cannot snapshot {}: {message}", + path.display() + ), + Self::Composition { code, message, .. } => write!(formatter, "{code}: {message}"), + } + } +} + +impl std::error::Error for FederatedCorpusError {} + +impl From for FederatedCorpusError { + fn from(error: ParentCorpusError) -> Self { + Self::Parent(error) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawOverrides { + version: u32, + items: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawOverride { + parent: String, + #[serde(rename = "with")] + replacement: String, + rationale: String, +} + +fn invalid_override(verified: &VerifiedParent, message: impl Into) -> FederatedCorpusError { + FederatedCorpusError::Composition { + code: FINDING_INVALID_OVERRIDE, + path: verified.manifest_path.clone(), + message: message.into(), + } +} + +fn parse_overrides( + verified: &VerifiedParent, +) -> Result, FederatedCorpusError> { + let Some(value) = verified.overrides.clone() else { + return Ok(Vec::new()); + }; + let raw: RawOverrides = serde_yaml::from_value(value).map_err(|error| { + invalid_override( + verified, + format!("override declarations are malformed: {error}"), + ) + })?; + if raw.version != 1 { + return Err(invalid_override( + verified, + format!( + "override declarations use unsupported version {}; expected 1", + raw.version + ), + )); + } + raw.items + .into_iter() + .map(|item| { + OverrideDeclaration::parse(&item.parent, &item.replacement, &item.rationale) + .map_err(|error| override_syntax_error(verified, &item.parent, error)) + }) + .collect() +} + +fn override_syntax_error( + verified: &VerifiedParent, + parent: &str, + error: OverrideSyntaxError, +) -> FederatedCorpusError { + invalid_override( + verified, + format!("override for '{parent}' is invalid: {error}"), + ) +} + +fn yaml_string(value: &serde_yaml::Value) -> Option { + value.as_str().map(str::to_string) +} + +fn severity(value: &serde_yaml::Value) -> Option { + match value { + serde_yaml::Value::Bool(false) => Some("off".to_string()), + serde_yaml::Value::Bool(true) => Some("on".to_string()), + _ => yaml_string(value), + } +} + +fn parent_policy(config: &[u8]) -> (Option, SeverityOverrides) { + let Ok(value) = serde_yaml::from_slice::(config) else { + return (None, SeverityOverrides::default()); + }; + let provider = value + .get("ticketing") + .and_then(|section| section.get("provider")) + .and_then(yaml_string); + let mut overrides = SeverityOverrides::default(); + if let Some(rules) = value + .get("validation") + .and_then(|section| section.get("rules")) + .and_then(serde_yaml::Value::as_mapping) + { + for (key, value) in rules { + let (Some(key), Some(value)) = (key.as_str(), severity(value)) else { + continue; + }; + if matches!(value.as_str(), "error" | "warning" | "off") { + overrides.rules.push((key.to_string(), value)); + } + } + } + if let Some(types) = value + .get("validation") + .and_then(|section| section.get("types")) + .and_then(serde_yaml::Value::as_mapping) + { + for (key, value) in types { + let (Some(key), Some(value)) = (key.as_str(), severity(value)) else { + continue; + }; + if matches!(value.as_str(), "error" | "warning") { + overrides.types.push((key.to_string(), value)); + } + } + } + (provider, overrides) +} + +fn parent_invalid(verified: &VerifiedParent, detail: impl Into) -> FederatedCorpusError { + let corpus = format!( + "{}/{}", + verified.declaration.root.trim_end_matches('/'), + verified.declaration.corpus.trim_start_matches('/') + ); + FederatedCorpusError::ParentInvalid { + source: verified.declaration.source.clone(), + path: verified.manifest_path.clone(), + detail: format!( + "{}; validate the parent directly with `decided validate {corpus}` and \ + `decided relationships {corpus} --validate`", + detail.into() + ), + } +} + +fn validate_parent( + verified: &VerifiedParent, + items: &[CorpusItem], +) -> Result<(), FederatedCorpusError> { + let (provider, overrides) = parent_policy(&verified.config_bytes); + for item in items { + let Some(spec) = item.spec else { + continue; + }; + let issues = apply_overrides( + crate::validate::validate(&item.artifact, provider.as_deref(), Some(&spec.name)), + &spec.name, + &overrides, + ); + if let Some(issue) = issues.iter().find(|issue| issue.severity == "error") { + return Err(parent_invalid( + verified, + format!( + "structural error {} in {}: {}", + issue.code, item.artifact_path.relative_path, issue.message + ), + )); + } + debug_assert!(!has_errors(&issues)); + } + + let okf_entries: Vec> = items + .iter() + .map(|item| crate::validate::OkfEntry { + path: &item.path, + artifact_type: item + .spec + .map(|spec| spec.name.as_str()) + .unwrap_or("unknown"), + file_name: item.path.rsplit('/').next().unwrap_or(&item.path), + }) + .collect(); + let okf = crate::validate::check_okf_conformance(&okf_entries, &overrides); + if let Some(finding) = okf + .findings + .iter() + .find(|finding| finding.severity == "error") + { + return Err(parent_invalid( + verified, + format!("OKF error {} in {}", finding.code, finding.path), + )); + } + + let rows: Vec<_> = items.iter().map(validation_row_from_item).collect(); + let corpus_root = verified.corpus_root.to_string_lossy(); + let relationships = validation_from_rows(&corpus_root, &rows, true); + if let Some(issue) = relationships.issues.iter().find(|issue| { + issue.code != ISSUE_SCOPE_TARGET_NOT_FOUND && relationship_severity(&issue.code) == "error" + }) { + return Err(parent_invalid( + verified, + format!("relationship error {}", issue.code), + )); + } + Ok(()) +} + +fn inherited_items(verified: &VerifiedParent) -> Vec { + let origin = CorpusLayer::inherited( + verified.declaration.source.clone(), + verified.declaration.alias.clone(), + verified.digest.clone(), + ) + .origin(); + let corpus_locator = PhysicalCorpusLocator::new( + verified.materialisation_root.clone(), + verified.corpus_root.clone(), + ); + verified + .files + .iter() + .map(|file| { + let artifact = parse_bytes(&file.bytes, &file.relative_path); + let spec = spec_for(&classify(&artifact).artifact_type); + CorpusItem::new( + file.relative_path.clone(), + file.relative_path.clone(), + artifact, + spec, + origin.clone(), + PhysicalArtifactLocator::new(corpus_locator.clone(), file.absolute_path.clone()), + ) + }) + .collect() +} + +fn capture_local_files( + directory: &str, + recursive: bool, + verified: &VerifiedParent, +) -> Result, FederatedCorpusError> { + let mut files = Vec::new(); + for entry in crate::walk::find_markdown_files(directory, recursive) + .into_iter() + .filter(|entry| !verified.contains_materialised_path(&entry.abs)) + { + let relative_path = entry.rel(); + let bytes = + std::fs::read(&entry.abs).map_err(|error| FederatedCorpusError::LocalSnapshot { + path: PathBuf::from(&relative_path), + message: error.to_string(), + })?; + files.push(SnapshotFile { + relative_path, + absolute_path: entry.abs, + bytes, + }); + } + Ok(files) +} + +fn local_items_from_snapshot( + directory: &str, + verified: &VerifiedParent, + files: &[SnapshotFile], +) -> (Vec, Vec<(crate::corpus::ArtifactKey, Vec)>) { + let origin = CorpusLayer::local(verified.child_source.clone()).origin(); + let corpus_locator = PhysicalCorpusLocator::local(directory); + let mut contents = Vec::with_capacity(files.len()); + let mut items = Vec::with_capacity(files.len()); + for file in files { + let artifact = parse_bytes(&file.bytes, &file.relative_path); + let spec = spec_for(&classify(&artifact).artifact_type); + let item = CorpusItem::new( + file.relative_path.clone(), + file.relative_path.clone(), + artifact, + spec, + origin.clone(), + PhysicalArtifactLocator::new(corpus_locator.clone(), file.absolute_path.clone()), + ); + contents.push((item.key.clone(), file.bytes.clone())); + items.push(item); + } + (items, contents) +} + +/// Load the central federated read model for `directory`. +/// +/// `Ok(None)` is the deliberate no-manifest compatibility result. Every +/// configured consumer must use the returned composition rather than walking +/// the child and parent independently. +pub fn load_composed_corpus( + directory: &str, + recursive: bool, +) -> Result, FederatedCorpusError> { + let child_root = crate::validate::repository_root(directory); + let Some(verified) = verify_parent(&child_root)? else { + return Ok(None); + }; + + compose_verified_generation(directory, recursive, &verified).map(Some) +} + +/// Return the writable local layer for a configured corpus, or the released +/// single-corpus walk when no federation manifest exists. Mutation and +/// local-only projection code uses this boundary so a repository-root walk +/// can never treat a vendored parent as writable child input. +pub fn local_writable_items( + directory: &str, + recursive: bool, +) -> Result, FederatedCorpusError> { + match load_composed_corpus(directory, recursive)? { + Some(corpus) => Ok(corpus.local_items().cloned().collect()), + None => Ok(crate::relationships::corpus_items(directory, recursive)), + } +} + +/// Compose from an already-verified logical generation without re-running +/// verification or reopening inherited paths. Cache/freshness readers use +/// this handoff to keep one pin check and one byte snapshot per generation. +pub fn compose_verified_generation( + directory: &str, + recursive: bool, + verified: &VerifiedParent, +) -> Result { + let child_files = capture_local_files(directory, recursive, verified)?; + compose_verified_generation_from_snapshot(directory, verified, &child_files) +} + +/// Compose a logical generation whose child and parent bytes were already +/// captured. Neither layer is reopened by this adapter. +pub fn compose_verified_generation_from_snapshot( + directory: &str, + verified: &VerifiedParent, + child_files: &[SnapshotFile], +) -> Result { + let overrides = parse_overrides(verified)?; + let inherited = inherited_items(verified); + validate_parent(verified, &inherited)?; + let (local, mut captured) = local_items_from_snapshot(directory, verified, child_files); + captured.extend( + inherited + .iter() + .zip(&verified.files) + .map(|(item, file)| (item.key.clone(), file.bytes.clone())), + ); + let parent = ParentIdentity::new( + verified.declaration.source.clone(), + verified.declaration.alias.clone(), + ) + .map_err(|error| override_syntax_error(verified, &verified.declaration.alias, error))?; + let corpus = ComposedCorpus::compose_verified( + local, + inherited, + verified.child_source.clone(), + verified.materialisation_root.clone(), + parent, + overrides, + captured, + ); + if let Some(finding) = corpus.findings().first() { + return Err(FederatedCorpusError::Composition { + code: finding.code, + path: verified.manifest_path.clone(), + message: format!( + "{} (child source '{}', parent source '{}')", + finding.message, verified.child_source, verified.declaration.source + ), + }); + } + Ok(corpus) +} + +/// Whether a mutation target lies in a configured parent's read-only +/// materialisation. The manifest search is path-ancestor based (rather than +/// nearest-config based) so a target inside the parent checkout still finds +/// the child repository's governing manifest. +pub fn is_read_only_materialised_path( + target: impl AsRef, +) -> Result { + let target = target.as_ref(); + let absolute = if target.is_absolute() { + target.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(target) + }; + let start = if absolute.is_dir() { + absolute.as_path() + } else { + absolute.parent().unwrap_or(absolute.as_path()) + }; + let Some(child_root) = start.ancestors().find(|ancestor| { + ancestor + .join(crate::federation::MANIFEST_RELATIVE_PATH) + .is_file() + }) else { + return Ok(false); + }; + Ok(verify_parent(child_root)? + .is_some_and(|verified| verified.contains_materialised_path(&absolute))) +} diff --git a/rust/rac-engine/src/gate.rs b/rust/rac-engine/src/gate.rs index 5016808b..e4c49495 100644 --- a/rust/rac-engine/src/gate.rs +++ b/rust/rac-engine/src/gate.rs @@ -282,6 +282,8 @@ pub struct GateFinding { pub path: String, pub line: Option, pub message: String, + pub decision_id: Option, + pub origin: Option, } pub struct GateReport { @@ -376,6 +378,25 @@ pub fn build_gate_with_code( directory: &str, recursive: bool, code: Option>, +) -> Result { + build_gate_internal(directory, recursive, code, None) +} + +/// Unified gate over one verified effective composition. +pub fn build_gate_with_composed( + directory: &str, + recursive: bool, + code: Option>, + corpus: &crate::composition::ComposedCorpus, +) -> Result { + build_gate_internal(directory, recursive, code, Some(corpus)) +} + +fn build_gate_internal( + directory: &str, + recursive: bool, + code: Option>, + composed: Option<&crate::composition::ComposedCorpus>, ) -> Result { // The oracle raises from load_enforcement_policy first, then // load_overrides — mirror that order so a doubly-malformed config @@ -383,10 +404,24 @@ pub fn build_gate_with_code( let policy = load_enforcement_policy(directory)?; check_overrides(directory)?; - let validation = validate_directory(directory, recursive); - let relationships: RelationshipValidation = validate_relationships(directory, recursive); - let items = corpus_items(directory, recursive); - let portfolio = portfolio_from_corpus(directory, &items, recursive); + let items: Vec<_> = composed + .map(|corpus| corpus.effective().cloned().collect()) + .unwrap_or_else(|| corpus_items(directory, recursive)); + let validation = composed.map_or_else( + || validate_directory(directory, recursive), + |_| crate::commands::validate_directory_from_items(directory, recursive, &items), + ); + let relationships: RelationshipValidation = composed.map_or_else( + || validate_relationships(directory, recursive), + |corpus| corpus.validate_relationships(directory, recursive), + ); + // Parent review advisories remain parent-owned. Validation, + // relationships, and code enforcement use the effective corpus, but a + // child gate reviews only its writable local layer. + let review_items: Vec<_> = composed + .map(|corpus| corpus.local_items().cloned().collect()) + .unwrap_or_else(|| items.clone()); + let portfolio = portfolio_from_corpus(directory, &review_items, recursive); let review: ReviewReport = review_from_portfolio(directory, portfolio, recursive); let mut findings: Vec = Vec::new(); @@ -397,6 +432,8 @@ pub fn build_gate_with_code( path: String, line: Option, message: String, + decision_id: Option, + origin: Option, default: &'static str| { if let Some(enforcement) = policy.classify(&code, default) { findings.push(GateFinding { @@ -407,6 +444,8 @@ pub fn build_gate_with_code( path, line, message, + decision_id, + origin, }); } }; @@ -419,7 +458,17 @@ pub fn build_gate_with_code( } else { ENFORCEMENT_ADVISORY }; - add(SOURCE_VALIDATE, code, severity, path, line, message, default); + add( + SOURCE_VALIDATE, + code, + severity, + path, + line, + message, + None, + None, + default, + ); } // Relationships: every issue fails `--validate` today, so blocking by @@ -435,6 +484,8 @@ pub fn build_gate_with_code( uri, None, message, + None, + None, ENFORCEMENT_BLOCKING, ); } @@ -459,18 +510,33 @@ pub fn build_gate_with_code( issue.path.clone(), None, message, + None, + None, default, ); } if let Some(options) = code { - match crate::sentry::analyze( - directory, - options.repository, - recursive, - options.base, - options.full_tree, - ) { + let sentry = if let Some(corpus) = composed { + crate::sentry::analyze_with_items( + directory, + options.repository, + options.base, + options.full_tree, + &items, + true, + corpus.read_only_root(), + ) + } else { + crate::sentry::analyze( + directory, + options.repository, + recursive, + options.base, + options.full_tree, + ) + }; + match sentry { Ok(report) => { code_coverage = Some(CodeCoverage { live_decisions: report.live_decisions, @@ -483,13 +549,31 @@ pub fn build_gate_with_code( eligible_coverage_percent: report.eligible_coverage_percent(), }); for finding in report.findings { + let decision_id = finding.decision_id.clone(); + let origin = finding.origin.clone(); + let message = if composed.is_some() { + let context = [finding.decision_id.as_deref(), finding.rule_id.as_deref()] + .into_iter() + .flatten() + .collect::>() + .join(" "); + if context.is_empty() { + finding.message + } else { + format!("{context}: {}", finding.message) + } + } else { + finding.message + }; add( SOURCE_SENTRY, finding.code.to_string(), "error".to_string(), finding.path, finding.line, - finding.message, + message, + decision_id, + origin, ENFORCEMENT_BLOCKING, ); } @@ -502,6 +586,8 @@ pub fn build_gate_with_code( directory.to_string(), None, message, + None, + None, ENFORCEMENT_BLOCKING, ); } diff --git a/rust/rac-engine/src/lib.rs b/rust/rac-engine/src/lib.rs index 01550ff0..b9eaacee 100644 --- a/rust/rac-engine/src/lib.rs +++ b/rust/rac-engine/src/lib.rs @@ -35,6 +35,7 @@ //! - `output`: human/JSON/SARIF renderers per command. //! - `commands`: CLI command entry points (argv already parsed). //! - `federation`: strict offline parent-manifest and byte-snapshot verification. +//! - `federated_corpus`: one verified source-aware composition loader. //! - `cli`: argv parsing and exit codes matching the oracle's argparse //! surface (PORT-CONTRACT.d/01). @@ -79,6 +80,7 @@ pub mod doctor; pub mod mdhtml; pub mod export; pub mod federation; +pub mod federated_corpus; pub mod portal; pub mod agent_rules; pub mod okf; diff --git a/rust/rac-engine/src/markdown.rs b/rust/rac-engine/src/markdown.rs index 2b9d7bca..93c6a897 100644 --- a/rust/rac-engine/src/markdown.rs +++ b/rust/rac-engine/src/markdown.rs @@ -2456,6 +2456,35 @@ pub fn parse_file(path: &str) -> Product { parse_file_with_cap(path, max_file_bytes()) } +/// Parse already-captured Markdown bytes without reopening their source path. +/// +/// Federation verifies a parent digest over exact bytes, then passes that +/// snapshot through this seam so parsing cannot race a mutable checkout. +pub fn parse_bytes(data: &[u8], source_path: &str) -> Product { + parse_bytes_with_cap(data, source_path, max_file_bytes()) +} + +/// [`parse_bytes`] with an explicit byte cap (testing seam). +pub fn parse_bytes_with_cap(data: &[u8], source_path: &str, cap: u128) -> Product { + if data.len() as u128 > cap { + return degraded_product(source_path, vec![oversize_issue(cap, "file")]); + } + match std::str::from_utf8(data) { + Ok(text) => parse_with_cap(text, source_path, cap), + Err(_) => { + let text = String::from_utf8_lossy(data); + let mut product = parse_with_cap(&text, source_path, cap); + product.parse_issues.push(Issue { + severity: "warning", + code: "non-utf8-content", + message: "artifact is not valid UTF-8; decoded lossily".to_string(), + line: Some(1), + }); + product + } + } +} + /// `parse_file` with an explicit byte cap (testing seam). pub fn parse_file_with_cap(path: &str, cap: u128) -> Product { let size = match std::fs::metadata(path) { @@ -2477,18 +2506,5 @@ pub fn parse_file_with_cap(path: &str, cap: u128) -> Product { if data.len() as u128 > cap { return degraded_product(path, vec![oversize_issue(cap, "file")]); } - match String::from_utf8(data) { - Ok(text) => parse_with_cap(&text, path, cap), - Err(e) => { - let text = String::from_utf8_lossy(e.as_bytes()).into_owned(); - let mut product = parse_with_cap(&text, path, cap); - product.parse_issues.push(Issue { - severity: "warning", - code: "non-utf8-content", - message: "artifact is not valid UTF-8; decoded lossily".to_string(), - line: Some(1), - }); - product - } - } + parse_bytes_with_cap(&data, path, cap) } diff --git a/rust/rac-engine/src/output.rs b/rust/rac-engine/src/output.rs index 9503b693..869b2ba9 100644 --- a/rust/rac-engine/src/output.rs +++ b/rust/rac-engine/src/output.rs @@ -476,6 +476,7 @@ struct SarifResult { message: String, uri: String, line: Option, + properties: Option, } fn sarif_document(mut results: Vec) -> String { @@ -525,6 +526,9 @@ fn sarif_document(mut results: Vec) -> String { "locations".into(), Value::Array(vec![Value::Object(location)]), ); + if let Some(properties) = &r.properties { + m.insert("properties".into(), properties.clone()); + } Value::Object(m) }) .collect(); @@ -565,6 +569,7 @@ pub fn render_validate_sarif(result: &DirectoryValidation) -> String { message: issue.message.clone(), uri: quote_uri(&file.path), line: issue.line, + properties: None, }); } } @@ -576,6 +581,7 @@ pub fn render_validate_sarif(result: &DirectoryValidation) -> String { message: finding.message.clone(), uri: quote_uri(&finding.path), line: None, + properties: None, }); } } @@ -649,6 +655,7 @@ pub fn render_relationships_sarif(validation: &RelationshipValidation) -> String message, uri, line: None, + properties: None, } }) .collect(); @@ -2217,6 +2224,21 @@ pub fn render_decisions_for_json(result: &ScopeLookupResult) -> String { dumps_indent2(&scope_lookup_value(result)) } +pub fn render_decisions_for_json_with_origin(result: &ScopeLookupResult) -> String { + dumps_indent2(&crate::retrieve::scope_lookup_value_with_origin( + result, true, + )) +} + +pub fn render_decisions_for_json_with_composed( + result: &ScopeLookupResult, + corpus: &crate::composition::ComposedCorpus, +) -> String { + dumps_indent2(&crate::retrieve::scope_lookup_value_with_composed( + result, corpus, + )) +} + // --- review ------------------------------------------------------------------ fn priority_label(priority: i64) -> &'static str { @@ -2397,6 +2419,7 @@ pub fn render_review_sarif(r: &ReviewReport) -> String { }, uri: quote_uri(&issue.path), line: None, + properties: None, }) .collect(); sarif_document(results) @@ -2470,6 +2493,12 @@ fn gate_finding_value(f: &GateFinding) -> Value { m.insert("path".into(), json!(f.path)); m.insert("line".into(), json!(f.line)); m.insert("message".into(), json!(f.message)); + if let Some(decision_id) = &f.decision_id { + m.insert("decision_id".into(), json!(decision_id)); + } + if let Some(origin) = &f.origin { + m.insert("provenance".into(), artifact_origin_value(origin)); + } Value::Object(m) } @@ -2520,6 +2549,10 @@ pub fn render_gate_sarif(report: &GateReport) -> String { message: f.message.clone(), uri: quote_uri(&f.path), line: f.line, + properties: f + .origin + .as_ref() + .map(artifact_origin_value), }) .collect(); sarif_document(results) @@ -2564,6 +2597,21 @@ pub fn render_sentry_human(report: &SentryReport) -> String { finding.message )); lines.push(format!(" decision: {}", finding.decision_path)); + if let Some(decision_id) = &finding.decision_id { + lines.push(format!(" decision id: {decision_id}")); + } + if let Some(origin) = &finding.origin { + lines.push(format!( + " source: {} ({}){}", + origin.source, + origin.layer.as_str(), + origin + .pin + .as_ref() + .map(|pin| format!(", {pin}")) + .unwrap_or_default() + )); + } } lines.push(String::new()); if report.ok() { @@ -2582,14 +2630,20 @@ pub fn render_sentry_json(report: &SentryReport) -> String { .findings .iter() .map(|finding| { - json!({ - "code": finding.code, - "decision_path": finding.decision_path, - "rule_id": finding.rule_id, - "path": finding.path, - "line": finding.line, - "message": finding.message, - }) + let mut value = Map::new(); + value.insert("code".into(), json!(finding.code)); + value.insert("decision_path".into(), json!(finding.decision_path)); + if let Some(decision_id) = &finding.decision_id { + value.insert("decision_id".into(), json!(decision_id)); + } + if let Some(origin) = &finding.origin { + value.insert("provenance".into(), artifact_origin_value(origin)); + } + value.insert("rule_id".into(), json!(finding.rule_id)); + value.insert("path".into(), json!(finding.path)); + value.insert("line".into(), json!(finding.line)); + value.insert("message".into(), json!(finding.message)); + Value::Object(value) }) .collect(); dumps_indent2(&json!({ @@ -2629,6 +2683,10 @@ pub fn render_sentry_sarif(report: &SentryReport) -> String { ), uri: quote_uri(&finding.path), line: finding.line, + properties: finding + .origin + .as_ref() + .map(artifact_origin_value), }) .collect(), ) @@ -2918,6 +2976,21 @@ pub fn render_resolve_human(artifact: &ResolvedArtifact) -> String { ) } +pub fn render_resolve_human_with_origin(artifact: &ResolvedArtifact) -> String { + let mut rendered = render_resolve_human(artifact); + if let Some(origin) = &artifact.origin { + rendered.push_str(&format!( + "\nSource: {}\nLayer: {}", + origin.source, + origin.layer.as_str() + )); + if let Some(pin) = &origin.pin { + rendered.push_str(&format!("\nPin: {pin}")); + } + } + rendered +} + /// `ResolutionResult.to_dict()` for the failure outcomes — the `decided resolve /// --json` error body, also served as the MCP structured lookup error /// (`errors.from_resolution`, ADR-034). @@ -2934,6 +3007,57 @@ pub fn resolution_error_value(result: &ResolutionResult) -> Value { /// `render_resolve_json` — `ResolutionResult.to_dict()` with `indent=2`. pub fn render_resolve_json(result: &ResolutionResult) -> String { + render_resolve_json_with_origin(result, false) +} + +pub fn artifact_origin_value(origin: &crate::corpus::ArtifactOrigin) -> Value { + let mut provenance = Map::new(); + provenance.insert("source".into(), json!(origin.source)); + provenance.insert("layer".into(), json!(origin.layer.as_str())); + if let Some(pin) = &origin.pin { + provenance.insert("pin".into(), json!(pin)); + } + Value::Object(provenance) +} + +fn artifact_key_value(key: &crate::corpus::ArtifactKey) -> Value { + json!({"source": key.source, "id": key.canonical_id}) +} + +pub fn composed_provenance_value( + provenance: &crate::composition::ComposedProvenance, +) -> Value { + let mut value = artifact_origin_value(&provenance.origin) + .as_object() + .cloned() + .expect("artifact origin is an object"); + if !provenance.overrides.is_empty() { + value.insert( + "overrides".into(), + Value::Array( + provenance + .overrides + .iter() + .map(|mapping| { + json!({ + "state": mapping.state.as_str(), + "parent": artifact_key_value(&mapping.parent), + "replacement": artifact_key_value(&mapping.replacement), + "rationale": artifact_key_value(&mapping.rationale), + }) + }) + .collect(), + ), + ); + } + Value::Object(value) +} + +/// Federated resolution JSON with additive source/layer/pin provenance. +pub fn render_resolve_json_with_origin( + result: &ResolutionResult, + include_origin: bool, +) -> String { if result.outcome != OUTCOME_RESOLVED { return dumps_indent2(&resolution_error_value(result)); } @@ -2944,11 +3068,43 @@ pub fn render_resolve_json(result: &ResolutionResult) -> String { m.insert("type".into(), json!(artifact.artifact_type)); m.insert("title".into(), json!(artifact.title)); m.insert("path".into(), json!(artifact.path)); + if include_origin { + if let Some(origin) = &artifact.origin { + m.insert("provenance".into(), artifact_origin_value(origin)); + } + } // section/snippet/evidence/recency/tags are never set on the // resolution path — the keys stay absent. dumps_indent2(&Value::Object(m)) } +pub fn render_resolve_json_with_composed( + result: &ResolutionResult, + corpus: &crate::composition::ComposedCorpus, +) -> String { + if result.outcome != OUTCOME_RESOLVED { + return dumps_indent2(&resolution_error_value(result)); + } + let artifact = result.artifact.as_ref().expect("resolved implies artifact"); + let mut value = Map::new(); + value.insert("schema_version".into(), json!("1")); + value.insert("id".into(), json!(artifact.id)); + value.insert("type".into(), json!(artifact.artifact_type)); + value.insert("title".into(), json!(artifact.title)); + value.insert("path".into(), json!(artifact.path)); + if let Some(provenance) = artifact + .key + .as_ref() + .and_then(|key| corpus.provenance_for(key)) + { + value.insert( + "provenance".into(), + composed_provenance_value(&provenance), + ); + } + dumps_indent2(&Value::Object(value)) +} + /// The match `recency` dict: `{last_committed, age_days, stale}`, all three /// keys always present, each null when unknown. pub fn recency_value(recency: &Recency) -> Value { @@ -2988,11 +3144,24 @@ pub fn evidence_value(e: &Evidence) -> Value { /// are absent, never null (except `title`). Shared by the CLI `decided find` /// renderers and the MCP tool payloads. pub fn find_match_value(m: &ResolvedArtifact, include_evidence: bool) -> Value { + find_match_value_with_origin(m, include_evidence, false) +} + +pub fn find_match_value_with_origin( + m: &ResolvedArtifact, + include_evidence: bool, + include_origin: bool, +) -> Value { let mut obj = Map::new(); obj.insert("id".into(), json!(m.id)); obj.insert("type".into(), json!(m.artifact_type)); obj.insert("title".into(), json!(m.title)); obj.insert("path".into(), json!(m.path)); + if include_origin { + if let Some(origin) = &m.origin { + obj.insert("provenance".into(), artifact_origin_value(origin)); + } + } if let Some(section) = &m.section { obj.insert("section".into(), json!(section)); } @@ -3110,6 +3279,14 @@ pub fn render_retrieve_human(payload: &Value) -> String { /// type, match_count, matches}`. Shared by `render_find_json` (which wraps /// it in `indent=2` dumps) and the MCP search payloads (budget serializer). pub fn search_result_value(result: &SearchResult, include_evidence: bool) -> Value { + search_result_value_with_origin(result, include_evidence, false) +} + +pub fn search_result_value_with_origin( + result: &SearchResult, + include_evidence: bool, + include_origin: bool, +) -> Value { let mut m = Map::new(); m.insert("schema_version".into(), json!("1")); m.insert("query".into(), json!(result.query)); @@ -3121,7 +3298,7 @@ pub fn search_result_value(result: &SearchResult, include_evidence: bool) -> Val result .matches .iter() - .map(|mm| find_match_value(mm, include_evidence)) + .map(|mm| find_match_value_with_origin(mm, include_evidence, include_origin)) .collect(), ), ); @@ -3133,8 +3310,45 @@ pub fn render_find_json(result: &SearchResult, explain: bool) -> String { dumps_indent2(&search_result_value(result, explain)) } +pub fn render_find_json_with_origin(result: &SearchResult, explain: bool) -> String { + dumps_indent2(&search_result_value_with_origin(result, explain, true)) +} + +pub fn render_find_json_with_composed( + result: &SearchResult, + explain: bool, + corpus: &crate::composition::ComposedCorpus, +) -> String { + let mut payload = search_result_value_with_origin(result, explain, true); + if let Some(matches) = payload.get_mut("matches").and_then(Value::as_array_mut) { + for (matched, artifact) in matches.iter_mut().zip(&result.matches) { + let Some(provenance) = artifact + .key + .as_ref() + .and_then(|key| corpus.provenance_for(key)) + else { + continue; + }; + if let Some(object) = matched.as_object_mut() { + object.insert( + "provenance".into(), + composed_provenance_value(&provenance), + ); + } + } + } + dumps_indent2(&payload) +} + /// Additive named-target explain-miss payload (`decided diagnose --json`). pub fn diagnosis_value(diagnosis: &SearchDiagnosis) -> Value { + diagnosis_value_with_origin(diagnosis, false) +} + +pub fn diagnosis_value_with_origin( + diagnosis: &SearchDiagnosis, + include_origin: bool, +) -> Value { let mut m = Map::new(); m.insert("schema_version".into(), json!("1")); m.insert("query".into(), json!(diagnosis.query)); @@ -3149,7 +3363,10 @@ pub fn diagnosis_value(diagnosis: &SearchDiagnosis) -> Value { m.insert("rank".into(), json!(diagnosis.rank)); m.insert("outranked_by".into(), json!(diagnosis.outranked_by)); if let Some(artifact) = &diagnosis.artifact { - m.insert("artifact".into(), find_match_value(artifact, true)); + m.insert( + "artifact".into(), + find_match_value_with_origin(artifact, true, include_origin), + ); } if !diagnosis.duplicate_paths.is_empty() { m.insert("duplicate_paths".into(), json!(diagnosis.duplicate_paths)); @@ -3161,6 +3378,10 @@ pub fn render_diagnosis_json(diagnosis: &SearchDiagnosis) -> String { dumps_indent2(&diagnosis_value(diagnosis)) } +pub fn render_diagnosis_json_with_origin(diagnosis: &SearchDiagnosis) -> String { + dumps_indent2(&diagnosis_value_with_origin(diagnosis, true)) +} + pub fn render_diagnosis_human(diagnosis: &SearchDiagnosis) -> String { let mut lines = vec![ format!("Target: {}", diagnosis.target), diff --git a/rust/rac-engine/src/parse.rs b/rust/rac-engine/src/parse.rs index 288ad83b..8ba64da3 100644 --- a/rust/rac-engine/src/parse.rs +++ b/rust/rac-engine/src/parse.rs @@ -67,6 +67,10 @@ fn from_frontmatter(i: crate::frontmatter::Issue) -> Issue { #[derive(Debug, Clone)] pub struct Artifact { pub product: Product, + /// Exact decoded source retained when parsing an already-captured + /// snapshot. Consumers needing raw fenced blocks use this instead of + /// reopening `product.source_path`. + pub source_text: Option, /// `product.metadata` — `None` for legacy (no-frontmatter) documents and /// for envelope-fatal frontmatter. pub metadata: Option, @@ -92,7 +96,7 @@ impl Artifact { } } -fn attach_metadata(product: Product) -> Artifact { +fn attach_metadata(product: Product, source_text: Option) -> Artifact { // markdown::parse populated metadata_issues only for the unterminated // (`raw is None`) case; complete the `raw is not None` arm here. let mut metadata_issues: Vec = @@ -106,6 +110,7 @@ fn attach_metadata(product: Product) -> Artifact { let parse_issues = product.parse_issues.iter().map(from_markdown).collect(); Artifact { product, + source_text, metadata, metadata_issues, parse_issues, @@ -114,10 +119,21 @@ fn attach_metadata(product: Product) -> Artifact { /// `decided.core.markdown.parse(text, source_path)` with metadata attached. pub fn parse_text(text: &str, source_path: &str) -> Artifact { - attach_metadata(markdown::parse(text, source_path)) + attach_metadata(markdown::parse(text, source_path), Some(text.to_string())) +} + +/// Parse already-captured bytes with metadata attached, without reopening the +/// source path. This is the verification-to-composition handoff for inherited +/// Markdown snapshots. +pub fn parse_bytes(bytes: &[u8], source_path: &str) -> Artifact { + let source_text = String::from_utf8_lossy(bytes).into_owned(); + attach_metadata( + markdown::parse_bytes(bytes, source_path), + Some(source_text), + ) } /// `decided.core.markdown.parse_file(path)` with metadata attached. pub fn parse_file(path: &str) -> Artifact { - attach_metadata(markdown::parse_file(path)) + attach_metadata(markdown::parse_file(path), None) } diff --git a/rust/rac-engine/src/rename.rs b/rust/rac-engine/src/rename.rs index c349c1bf..aebf6d09 100644 --- a/rust/rac-engine/src/rename.rs +++ b/rust/rac-engine/src/rename.rs @@ -696,6 +696,29 @@ pub fn compute_rename( old_ref: &str, new_ref: &str, recursive: bool, +) -> RenamePlan { + compute_rename_internal(directory, old_ref, new_ref, recursive, None) +} + +/// Compute a rename against an explicitly bounded writable layer. Federated +/// command paths pass `ComposedCorpus::local_items()` so a root-level walk +/// cannot plan edits inside the materialised parent. +pub fn compute_rename_from_items( + directory: &str, + old_ref: &str, + new_ref: &str, + recursive: bool, + items: &[crate::relationships::CorpusItem], +) -> RenamePlan { + compute_rename_internal(directory, old_ref, new_ref, recursive, Some(items)) +} + +fn compute_rename_internal( + directory: &str, + old_ref: &str, + new_ref: &str, + recursive: bool, + supplied_items: Option<&[crate::relationships::CorpusItem]>, ) -> RenamePlan { let new_ref = py_strip(new_ref).to_string(); if !valid_new_ref(&new_ref) { @@ -716,7 +739,14 @@ pub fn compute_rename( } }; - let items = corpus_items(directory, recursive); + let owned_items; + let items = match supplied_items { + Some(items) => items, + None => { + owned_items = corpus_items(directory, recursive); + &owned_items + } + }; let rows: Vec = items .iter() .map(crate::relationships::validation_row_from_item) @@ -781,7 +811,7 @@ pub fn compute_rename( } }; - let mut edits = match reference_edits(&items, &root, old_ref, &new_ref) { + let mut edits = match reference_edits(items, &root, old_ref, &new_ref) { Ok(edits) => edits, Err(issue) => { return refused( diff --git a/rust/rac-engine/src/resolve.rs b/rust/rac-engine/src/resolve.rs index 81e314fb..c7e073b3 100644 --- a/rust/rac-engine/src/resolve.rs +++ b/rust/rac-engine/src/resolve.rs @@ -646,15 +646,34 @@ fn bm25f(fields: &FieldTokens, terms: &[String], stats: &CorpusStats) -> f64 { score } +fn stable_entry_order( + left: &IndexEntry, + right: &IndexEntry, + federated: bool, +) -> std::cmp::Ordering { + if federated { + left.artifact_path + .cmp(&right.artifact_path) + .then_with(|| left.path.cmp(&right.path)) + } else { + left.path.cmp(&right.path) + } +} + /// `_competition_ranks`: 1-based ranks aligned with `scores`; ties (EXACT -/// f64 equality) share a rank, ordered by `(-score, path)`. -fn competition_ranks(scores: &[f64], paths: &[&str]) -> Vec { +/// f64 equality) share a rank. Federated indexes use `(source, relative_path)` +/// while single-source indexes retain the released display-path ordering. +fn competition_ranks( + scores: &[f64], + entries: &[&IndexEntry], + federated: bool, +) -> Vec { let mut ordered: Vec = (0..scores.len()).collect(); ordered.sort_by(|&a, &b| { scores[b] .partial_cmp(&scores[a]) .expect("finite score") - .then_with(|| paths[a].cmp(paths[b])) + .then_with(|| stable_entry_order(entries[a], entries[b], federated)) }); let mut ranks = vec![0; scores.len()]; let mut previous: Option = None; @@ -999,9 +1018,16 @@ pub(crate) fn rank_and_build( .iter() .map(|(entry, _, _)| entry.inbound_count as f64) .collect(); - let paths: Vec<&str> = matched.iter().map(|(entry, _, _)| entry.path.as_str()).collect(); - let lexical_rank = competition_ranks(&bm25_scores, &paths); - let graph_rank = competition_ranks(&inbound_scores, &paths); + let entries: Vec<&IndexEntry> = matched.iter().map(|(entry, _, _)| *entry).collect(); + let mut sources: Vec<&str> = entries + .iter() + .filter_map(|entry| entry.origin.as_ref().map(|origin| origin.source.as_str())) + .collect(); + sources.sort_unstable(); + sources.dedup(); + let federated = sources.len() > 1; + let lexical_rank = competition_ranks(&bm25_scores, &entries, federated); + let graph_rank = competition_ranks(&inbound_scores, &entries, federated); let strongest_bm25 = bm25_scores.iter().copied().fold(0.0_f64, f64::max); let graph_gate_applied: Vec = bm25_scores .iter() @@ -1034,7 +1060,7 @@ pub(crate) fn rank_and_build( fused_sort_keys[b] .partial_cmp(&fused_sort_keys[a]) .expect("finite fused") - .then_with(|| paths[a].cmp(paths[b])) + .then_with(|| stable_entry_order(entries[a], entries[b], federated)) }); crate::timing::emit_since( "search.final_sort", @@ -1211,11 +1237,33 @@ mod tests { #[test] fn competition_ranks_share_on_exact_equality() { let scores = vec![2.0, 2.0, 1.0]; - let paths = vec!["b", "a", "c"]; - let ranks = competition_ranks(&scores, &paths); + let entries = [ + test_entry("ADR-001", "One", "b", "body", 0), + test_entry("ADR-002", "Two", "a", "body", 0), + test_entry("ADR-003", "Three", "c", "body", 0), + ]; + let entries: Vec<&IndexEntry> = entries.iter().collect(); + let ranks = competition_ranks(&scores, &entries, false); assert_eq!(ranks, vec![1, 1, 3]); } + #[test] + fn federated_ties_order_by_source_then_relative_path() { + let mut parent = test_entry("STD-001", "Policy", "same.md", "body", 0); + parent.artifact_path = Some(ArtifactPath::new("z-parent", "same.md")); + let mut local = test_entry("APP-001", "Policy", "same.md", "body", 0); + local.artifact_path = Some(ArtifactPath::new("a-child", "same.md")); + + assert_eq!( + stable_entry_order(&local, &parent, true), + std::cmp::Ordering::Less + ); + assert_eq!( + stable_entry_order(&local, &parent, false), + std::cmp::Ordering::Equal + ); + } + #[test] fn graph_gate_uses_inclusive_eighty_five_percent_floor() { assert!(!graph_gate_allows(8.499_999, 10.0)); diff --git a/rust/rac-engine/src/retrieve.rs b/rust/rac-engine/src/retrieve.rs index 9b4f32de..38adc2d6 100644 --- a/rust/rac-engine/src/retrieve.rs +++ b/rust/rac-engine/src/retrieve.rs @@ -18,7 +18,7 @@ //! `**/` zero-or-more whole segments, `[...]` classes, `.`-collapse and //! `..`-rejection in path normalisation). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use serde_json::{json, Map, Value}; @@ -467,6 +467,15 @@ pub fn decisions_for_path(directory: &str, path: &str, recursive: bool) -> Scope /// `ScopeLookupResult.to_dict()` — `{schema_version, query, in_repository, /// decisions}` in Python dict insertion order. pub fn scope_lookup_value(result: &ScopeLookupResult) -> Value { + scope_lookup_value_with_origin(result, false) +} + +/// Federated scope payload. Origin is opt-in so a repository without a +/// manifest retains the released JSON shape byte for byte. +pub fn scope_lookup_value_with_origin( + result: &ScopeLookupResult, + include_origin: bool, +) -> Value { let mut payload = Map::new(); payload.insert("schema_version".to_string(), json!("1")); payload.insert("query".to_string(), json!(result.query)); @@ -481,6 +490,14 @@ pub fn scope_lookup_value(result: &ScopeLookupResult) -> Value { m.insert("status".to_string(), json!(d.status)); m.insert("path".to_string(), json!(d.path)); m.insert("matching_entry".to_string(), json!(d.matching_entry)); + if include_origin { + if let Some(origin) = &d.origin { + m.insert( + "provenance".to_string(), + crate::output::artifact_origin_value(origin), + ); + } + } Value::Object(m) }) .collect(); @@ -488,6 +505,34 @@ pub fn scope_lookup_value(result: &ScopeLookupResult) -> Value { Value::Object(payload) } +pub fn scope_lookup_value_with_composed( + result: &ScopeLookupResult, + corpus: &crate::composition::ComposedCorpus, +) -> Value { + let mut payload = scope_lookup_value_with_origin(result, true); + if let Some(decisions) = payload + .get_mut("decisions") + .and_then(Value::as_array_mut) + { + for (value, decision) in decisions.iter_mut().zip(&result.decisions) { + let Some(provenance) = decision + .key + .as_ref() + .and_then(|key| corpus.provenance_for(key)) + else { + continue; + }; + if let Some(object) = value.as_object_mut() { + object.insert( + "provenance".to_string(), + crate::output::composed_provenance_value(&provenance), + ); + } + } + } + payload +} + /// `decisions_for_path` over ALREADY-DERIVED scope rows (ADR-103): the /// read-model arm of the MCP `find_decisions` path mode, byte-identical to /// the fresh walk for the same corpus state. @@ -582,6 +627,142 @@ struct ItemBuilder { provenance: Map, } +struct ComposedItemBuilder { + key: ArtifactKey, + id: String, + item_type: String, + title: Option, + status: String, + path: String, + provenance: Map, +} + +#[allow(clippy::too_many_arguments)] +fn add_composed_item( + items: &mut Vec, + index_of: &mut HashMap, + key: &ArtifactKey, + path: &ArtifactPath, + origin: &ArtifactOrigin, + channel: &str, + item_id: &str, + item_type: &str, + title: Option<&str>, + status: &str, + matching_entry: Option<&str>, + superseded: Option<&str>, + evidence: Option, +) { + let index = match index_of.get(key) { + Some(index) => *index, + None => { + let mut provenance = Map::new(); + provenance.insert("channels".to_string(), json!([])); + provenance.insert("source".to_string(), json!(origin.source)); + provenance.insert("layer".to_string(), json!(origin.layer.as_str())); + if let Some(pin) = &origin.pin { + provenance.insert("pin".to_string(), json!(pin)); + } + items.push(ComposedItemBuilder { + key: key.clone(), + id: item_id.to_string(), + item_type: item_type.to_string(), + title: title.map(str::to_string), + status: status.to_string(), + path: path.relative_path.clone(), + provenance, + }); + index_of.insert(key.clone(), items.len() - 1); + items.len() - 1 + } + }; + let provenance = &mut items[index].provenance; + let channels = provenance + .get_mut("channels") + .and_then(Value::as_array_mut) + .expect("channels array"); + if !channels.iter().any(|value| value.as_str() == Some(channel)) { + channels.push(json!(channel)); + } + if let Some(entry) = matching_entry { + provenance + .entry("matching_entry".to_string()) + .or_insert_with(|| json!(entry)); + } + if let Some(replaced_id) = superseded { + let replaced = provenance + .entry("superseded".to_string()) + .or_insert_with(|| json!([])) + .as_array_mut() + .expect("superseded array"); + if !replaced + .iter() + .any(|value| value.as_str() == Some(replaced_id)) + { + replaced.push(json!(replaced_id)); + } + } + if let Some(evidence) = evidence { + provenance + .entry("evidence".to_string()) + .or_insert(evidence); + } +} + +fn composed_successor_map( + relationships: &[Relationship], +) -> HashMap> { + let mut successors: HashMap> = HashMap::new(); + for relationship in relationships { + if relationship.relationship != SUPERSEDES { + continue; + } + let (Some(source), Some(target)) = ( + relationship.source_artifact.as_ref(), + relationship.resolved_artifact.as_ref(), + ) else { + continue; + }; + successors + .entry(target.clone()) + .or_default() + .push(source.clone()); + } + for paths in successors.values_mut() { + paths.sort(); + paths.dedup(); + } + successors +} + +fn composed_live_successors( + path: &ArtifactPath, + successors: &HashMap>, + is_retired: &dyn Fn(&ArtifactPath) -> bool, + visited: &mut HashSet, +) -> Vec { + let mut result = Vec::new(); + let Some(paths) = successors.get(path) else { + return result; + }; + for successor in paths { + if !visited.insert(successor.clone()) { + continue; + } + if is_retired(successor) { + result.extend(composed_live_successors( + successor, + successors, + is_retired, + visited, + )); + } else { + result.push(successor.clone()); + } + } + result +} + #[allow(clippy::too_many_arguments)] fn add_item( items: &mut Vec, @@ -700,6 +881,178 @@ pub fn retrieve_grounding( ) } +/// Grounding over the one verified composed snapshot. Identity, deduplication, +/// successor traversal, excerpts, and provenance are all source-aware; no +/// inherited path is reopened after verification. +pub fn retrieve_grounding_from_composed( + directory: &str, + task: &str, + scope: Option<&str>, + top_k: i64, + budget: i64, + live_only: bool, + corpus: &crate::composition::ComposedCorpus, +) -> Value { + let top_k = top_k.max(1); + let effective: Vec<_> = corpus.effective().cloned().collect(); + let entries = corpus.effective_index(); + let keyword = search_index(&entries, task, None, &[]); + let scope_rows = scope_rows_from_items(&effective); + let relationships = corpus.relationships(); + let by_path: HashMap = effective + .iter() + .map(|item| (item.artifact_path.clone(), item)) + .collect(); + let is_retired = |path: &ArtifactPath| -> bool { + by_path.get(path).is_some_and(|item| { + let artifact_type = item + .spec + .map(|spec| spec.name.as_str()) + .unwrap_or("unknown"); + is_retired_status(artifact_type, &artifact_status(&item.artifact)) + }) + }; + + let mut items = Vec::new(); + let mut index_of = HashMap::new(); + let scope = scope.filter(|value| !value.is_empty()); + if let Some(scope_path) = scope { + for governing in governing_decisions(&scope_rows, directory, scope_path) { + let (Some(key), Some(path), Some(origin)) = ( + governing.key.as_ref(), + governing.artifact_path.as_ref(), + governing.origin.as_ref(), + ) else { + continue; + }; + add_composed_item( + &mut items, + &mut index_of, + key, + path, + origin, + CHANNEL_SCOPE, + &governing.id, + DECISION_TYPE, + (!governing.title.is_empty()).then_some(governing.title.as_str()), + &governing.status, + Some(&governing.matching_entry), + None, + None, + ); + } + } + + let successors = if live_only { + composed_successor_map(&relationships) + } else { + Default::default() + }; + for matched in &keyword.matches { + let (Some(key), Some(path), Some(origin)) = ( + matched.key.as_ref(), + matched.artifact_path.as_ref(), + matched.origin.as_ref(), + ) else { + continue; + }; + if live_only && is_retired(path) { + let mut visited = HashSet::from([path.clone()]); + for successor_path in + composed_live_successors(path, &successors, &is_retired, &mut visited) + { + let Some(successor) = by_path.get(&successor_path) else { + continue; + }; + let artifact_type = successor + .spec + .map(|spec| spec.name.as_str()) + .unwrap_or("unknown"); + add_composed_item( + &mut items, + &mut index_of, + &successor.key, + &successor.artifact_path, + &successor.origin, + CHANNEL_SUPERSEDES, + &successor.key.canonical_id, + artifact_type, + successor.artifact.product.title.as_deref(), + &artifact_status(&successor.artifact), + None, + Some(&matched.id), + None, + ); + } + continue; + } + add_composed_item( + &mut items, + &mut index_of, + key, + path, + origin, + CHANNEL_KEYWORD, + &matched.id, + &matched.artifact_type, + matched.title.as_deref(), + by_path + .get(path) + .map(|item| artifact_status(&item.artifact)) + .unwrap_or_default() + .as_str(), + None, + None, + matched.evidence.as_ref().map(crate::output::evidence_value), + ); + } + + items.truncate((top_k as usize).min(items.len())); + let share = if items.is_empty() { + 0 + } else { + budget.div_euclid((top_k.min(items.len() as i64)).max(1)) + }; + let shaped: Vec = items + .into_iter() + .map(|mut item| { + let content = corpus + .content(&item.key) + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .map(|text| text.replace("\r\n", "\n").replace('\r', "\n")) + .unwrap_or_default(); + if let Some(overrides) = corpus + .provenance_for(&item.key) + .and_then(|provenance| { + crate::output::composed_provenance_value(&provenance) + .get("overrides") + .cloned() + }) + { + item.provenance.insert("overrides".to_string(), overrides); + } + let mut value = Map::new(); + value.insert("id".to_string(), json!(item.id)); + value.insert("type".to_string(), json!(item.item_type)); + value.insert("title".to_string(), json!(item.title)); + value.insert("status".to_string(), json!(item.status)); + value.insert("path".to_string(), json!(item.path)); + value.insert("excerpt".to_string(), json!(py_slice_to(&content, share))); + value.insert("provenance".to_string(), Value::Object(item.provenance)); + Value::Object(value) + }) + .collect(); + let mut payload = Map::new(); + payload.insert("schema_version".to_string(), json!("1")); + payload.insert("task".to_string(), json!(task)); + if let Some(scope_path) = scope { + payload.insert("scope".to_string(), json!(scope_path)); + } + payload.insert("live_only".to_string(), json!(live_only)); + payload.insert("items".to_string(), Value::Array(shaped)); + Value::Object(payload) +} + /// Grounding over an already-derived mutation-window snapshot. Only matched, /// governing, and successor paths are read from disk for status/excerpts; the /// corpus itself is never walked or parsed again. diff --git a/rust/rac-engine/src/scaffold.rs b/rust/rac-engine/src/scaffold.rs index f9750039..303696e7 100644 --- a/rust/rac-engine/src/scaffold.rs +++ b/rust/rac-engine/src/scaffold.rs @@ -21,7 +21,7 @@ use std::collections::HashSet; use std::path::Path; use crate::pycompat::py_repr_str; -use crate::relationships::corpus_items; +use crate::relationships::CorpusItem; use crate::spec::available_schemas; use crate::validate::find_config_file; use crate::walk::py_join; @@ -105,6 +105,12 @@ fn id_generation_exhausted() -> ScaffoldError { )) } +fn local_items(directory: &str, recursive: bool) -> Result, ScaffoldError> { + crate::federated_corpus::local_writable_items(directory, recursive).map_err(|error| { + ScaffoldError::MalformedRepositoryConfig(error.to_string()) + }) +} + // --------------------------------------------------------------------------- // Opaque id generation (decided.core.idgen, ADR-026) // --------------------------------------------------------------------------- @@ -635,14 +641,14 @@ fn py_parent(p: &str) -> String { /// oracle-crash class); the native walk is total, so hostile files simply /// contribute whatever identifier they still yield (RAC-KXBPS7SRM6ZB /// REQ-002: creation must succeed). -fn issued_ids(repository_root: &str) -> HashSet { - corpus_items(repository_root, true) +fn issued_ids(repository_root: &str) -> Result, ScaffoldError> { + Ok(local_items(repository_root, true)? .iter() .map(|item| { crate::identity::artifact_identifier(&item.artifact, item.spec, &item.path) .to_uppercase() }) - .collect() + .collect()) } /// `_assign_id` / migrate's `_next_id` — generate, check, retry bounded. @@ -687,7 +693,7 @@ pub fn create_artifact( .and_then(Path::parent) .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|| ".".to_string()); - let mut issued = issued_ids(&repository_root); + let mut issued = issued_ids(&repository_root)?; let artifact_id = assign_id(&config.repository_key, &mut issued)?; let content = format!("{}{body}", render_frontmatter(&artifact_id, artifact_type)); std::fs::write(output_path, content.as_bytes()).map_err(|e| { @@ -732,7 +738,7 @@ pub fn quickstart( // Refuse a non-empty corpus: any entry classified as a known type. The // oracle crashes when this walk hits hostile markdown; the native walk // is total (RAC-KXBPS7SRM6ZB REQ-002 class, documented divergence). - let items = corpus_items(directory, true); + let items = local_items(directory, true)?; if let Some(existing) = items.iter().find(|item| item.spec.is_some()) { return Err(ScaffoldError::CorpusNotEmpty(format!( "corpus already has artifacts (e.g. {}); decided quickstart only \ @@ -820,10 +826,10 @@ pub fn migrate_metadata( .and_then(Path::parent) .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|| ".".to_string()); - let mut issued = issued_ids(&repository_root); + let mut issued = issued_ids(&repository_root)?; let mut files = Vec::new(); - for item in corpus_items(directory, recursive) { + for item in local_items(directory, recursive)? { if item.artifact.metadata.is_some() || !item.artifact.metadata_issues.is_empty() { files.push(FileMigration { path: item.path.clone(), diff --git a/rust/rac-engine/src/sentry.rs b/rust/rac-engine/src/sentry.rs index dcb76517..b8e25cde 100644 --- a/rust/rac-engine/src/sentry.rs +++ b/rust/rac-engine/src/sentry.rs @@ -72,12 +72,25 @@ enum RuleKind { pub struct SentryFinding { pub code: &'static str, pub decision_path: String, + pub decision_id: Option, + pub origin: Option, pub rule_id: Option, pub path: String, pub line: Option, pub message: String, } +fn finding_identity( + item: &CorpusItem, + include_origin: bool, +) -> (Option, Option) { + if include_origin { + (Some(item.key.canonical_id.clone()), Some(item.origin.clone())) + } else { + (None, None) + } +} + #[derive(Debug)] pub struct SentryReport { pub corpus: String, @@ -165,9 +178,12 @@ fn safe_glob(value: &str) -> bool { } fn raw_constraint_section(artifact: &Artifact) -> Result, &'static str> { - let text = match std::fs::read_to_string(&artifact.product.source_path) { - Ok(text) => text, - Err(_) => return Ok(artifact.section("code constraints").map(str::to_string)), + let text = match &artifact.source_text { + Some(text) => text.clone(), + None => match std::fs::read_to_string(&artifact.product.source_path) { + Ok(text) => text, + Err(_) => return Ok(artifact.section("code constraints").map(str::to_string)), + }, }; let heading = Regex::new(r"(?i)^##[ \t]+code[ \t]+constraints[ \t]*#*[ \t]*$").unwrap(); let any_h2 = Regex::new(r"^##(?:[ \t]|$)").unwrap(); @@ -190,11 +206,17 @@ fn raw_constraint_section(artifact: &Artifact) -> Result, &'stati } } -fn parse_document(item: &CorpusItem) -> Result, Box> { +fn parse_document( + item: &CorpusItem, + include_origin: bool, +) -> Result, Box> { + let (decision_id, origin) = finding_identity(item, include_origin); let section = raw_constraint_section(&item.artifact).map_err(|problem| { Box::new(SentryFinding { code: MALFORMED_CONSTRAINTS, decision_path: item.path.clone(), + decision_id: decision_id.clone(), + origin: origin.clone(), rule_id: None, path: item.path.clone(), line: None, @@ -208,6 +230,8 @@ fn parse_document(item: &CorpusItem) -> Result, Box Result, Box Result, Box Result, Box Result, Box Result, Box Result, Box Vec { artifact.clone(), spec_for("decision"), ); - match parse_document(&item) { + match parse_document(&item, false) { Err(finding) => vec![Issue::new("error", finding.code, finding.message, None)], _ => Vec::new(), } } -fn invalid(item: &CorpusItem, rule_id: Option<&str>, message: &str) -> Box { +fn invalid( + item: &CorpusItem, + include_origin: bool, + rule_id: Option<&str>, + message: &str, +) -> Box { + let (decision_id, origin) = finding_identity(item, include_origin); Box::new(SentryFinding { code: INVALID_CONSTRAINT, decision_path: item.path.clone(), + decision_id, + origin, rule_id: rule_id.map(str::to_string), path: item.path.clone(), line: None, @@ -329,7 +395,12 @@ fn invalid(item: &CorpusItem, rule_id: Option<&str>, message: &str) -> Box) { +fn collect_files( + root: &Path, + dir: &Path, + excluded: Option<&Path>, + output: &mut Vec<(String, PathBuf)>, +) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; @@ -337,6 +408,13 @@ fn collect_files(root: &Path, dir: &Path, output: &mut Vec<(String, PathBuf)>) { entries.sort_by_key(|entry| entry.file_name()); for entry in entries { let path = entry.path(); + if excluded.is_some_and(|excluded| { + std::fs::canonicalize(&path) + .ok() + .is_some_and(|path| path == excluded || path.starts_with(excluded)) + }) { + continue; + } let name = entry.file_name(); if name == ".git" { continue; @@ -345,7 +423,7 @@ fn collect_files(root: &Path, dir: &Path, output: &mut Vec<(String, PathBuf)>) { continue; }; if file_type.is_dir() && !file_type.is_symlink() { - collect_files(root, &path, output); + collect_files(root, &path, excluded, output); } else if file_type.is_file() { if let Ok(relative) = path.strip_prefix(root) { output.push((relative.to_string_lossy().replace('\\', "/"), path)); @@ -447,6 +525,22 @@ pub fn analyze( recursive: bool, base: Option<&str>, full_tree: bool, +) -> Result { + let items = corpus_items(corpus, recursive); + analyze_with_items(corpus, repository, base, full_tree, &items, false, None) +} + +/// Sentry over the effective items from one composed snapshot. `excluded` +/// prevents code enumeration from entering the verified read-only parent +/// materialisation. +pub fn analyze_with_items( + corpus: &str, + repository: &str, + base: Option<&str>, + full_tree: bool, + items: &[CorpusItem], + include_origin: bool, + excluded: Option<&Path>, ) -> Result { let repository_path = Path::new(repository); if !repository_path.is_dir() { @@ -460,12 +554,11 @@ pub fn analyze( } else { Some(changed_lines(repository_path, base.unwrap())?) }; - let items = corpus_items(corpus, recursive); let live: Vec<&CorpusItem> = items.iter().filter(|item| is_live_decision(item)).collect(); let mut documents = Vec::new(); let mut findings = Vec::new(); for item in &live { - match parse_document(item) { + match parse_document(item, include_origin) { Ok(Some(document)) => documents.push((*item, document)), Ok(None) => {} Err(finding) => findings.push(*finding), @@ -473,7 +566,7 @@ pub fn analyze( } let mut files = Vec::new(); - collect_files(repository_path, repository_path, &mut files); + collect_files(repository_path, repository_path, excluded, &mut files); for (item, document) in &documents { for rule in &document.rules { let matcher = Glob::new(&rule.path_glob).unwrap().compile_matcher(); @@ -614,6 +707,12 @@ pub fn analyze( } } } + if !include_origin { + for finding in &mut findings { + finding.decision_id = None; + finding.origin = None; + } + } findings.sort_by(|a, b| { a.path .cmp(&b.path) @@ -655,9 +754,12 @@ fn rule_finding( line: Option, message: String, ) -> SentryFinding { + let (decision_id, origin) = finding_identity(item, true); SentryFinding { code, decision_path: item.path.clone(), + decision_id, + origin, rule_id: Some(rule.id.clone()), path: path.to_string(), line, @@ -677,6 +779,18 @@ mod tests { assert!(!valid_rule_id("no--delete")); } + #[test] + fn captured_markdown_keeps_code_constraint_fences() { + let text = "---\nschema_version: 1\nid: ADR-001\ntype: decision\n---\n# Guardrail\n\n## Status\n\nAccepted\n\n## Code Constraints\n\n```yaml\nversion: 1\neligibility: eligible\nrules:\n - id: no-marker\n kind: forbid_pattern\n path_glob: \"src/**/*.rs\"\n pattern: \"forbidden\"\n```\n"; + let artifact = crate::parse::parse_bytes(text.as_bytes(), "decisions/guardrail.md"); + + assert_eq!( + raw_constraint_section(&artifact).unwrap().unwrap(), + "\n```yaml\nversion: 1\neligibility: eligible\nrules:\n - id: no-marker\n kind: forbid_pattern\n path_glob: \"src/**/*.rs\"\n pattern: \"forbidden\"\n```" + ); + assert!(validate_artifact(&artifact).is_empty()); + } + #[test] fn import_adapters_extract_targets() { assert_eq!( diff --git a/rust/rac-engine/tests/composition.rs b/rust/rac-engine/tests/composition.rs index aaae0b80..c62a7307 100644 --- a/rust/rac-engine/tests/composition.rs +++ b/rust/rac-engine/tests/composition.rs @@ -264,6 +264,18 @@ fn a_valid_override_redirects_only_the_parent_canonical_id_and_retains_history() corpus.content(&inherited_key), Some(&b"exact verified parent bytes"[..]) ); + let parent_provenance = corpus.provenance_for(&inherited_key).unwrap(); + assert_eq!(parent_provenance.origin.layer, Layer::Inherited); + assert_eq!(parent_provenance.overrides.len(), 1); + assert_eq!(parent_provenance.overrides[0].state.as_str(), "overridden"); + assert_eq!(parent_provenance.overrides[0].replacement, replacement_key); + let replacement_provenance = corpus.provenance_for(&replacement_key).unwrap(); + assert_eq!(replacement_provenance.origin.layer, Layer::Local); + assert_eq!(replacement_provenance.overrides.len(), 1); + assert_eq!( + replacement_provenance.overrides[0].state.as_str(), + "replacement" + ); assert!(!corpus .relationships() .iter()