From 0f0139eef101a96d9c3a5c8a319210658300a04a Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 30 Aug 2026 08:46:52 +0100 Subject: [PATCH 1/9] feat(engine): add source-aware corpus identity substrate Signed-off-by: Tom Ballard --- .../designs/corpus-federation-mechanism.md | 32 ++ .../requirements/corpus-source-identity.md | 2 +- rust/decided-mcp/src/graph.rs | 6 + rust/rac-engine/src/corpus.rs | 327 ++++++++++++++++++ rust/rac-engine/src/delta_generation.rs | 105 ++++-- rust/rac-engine/src/derived.rs | 37 ++ rust/rac-engine/src/export.rs | 34 +- rust/rac-engine/src/freshness.rs | 12 +- rust/rac-engine/src/index_store.rs | 8 + rust/rac-engine/src/lib.rs | 1 + rust/rac-engine/src/parallel_build.rs | 81 ++++- rust/rac-engine/src/portfolio.rs | 4 +- rust/rac-engine/src/relationships.rs | 154 ++++++++- rust/rac-engine/src/rename.rs | 4 +- rust/rac-engine/src/resolve.rs | 28 +- rust/rac-engine/src/sentry.rs | 10 +- rust/rac-engine/tests/index_store_vectors.rs | 2 + .../tests/source_aware_substrate.rs | 190 ++++++++++ 18 files changed, 940 insertions(+), 97 deletions(-) create mode 100644 rust/rac-engine/src/corpus.rs create mode 100644 rust/rac-engine/tests/source_aware_substrate.rs diff --git a/decisions/designs/corpus-federation-mechanism.md b/decisions/designs/corpus-federation-mechanism.md index eca25956..6206e32a 100644 --- a/decisions/designs/corpus-federation-mechanism.md +++ b/decisions/designs/corpus-federation-mechanism.md @@ -145,6 +145,19 @@ unsupported transitive declaration, and a stale digest are distinct stable error findings. No parent artifact enters the effective corpus until all four checks pass. +The version-one byte contract is fixed. The hash begins with the ASCII domain +separator `asdecided-corpus-digest-v1\0`. Every following value is framed as a +one-byte tag, an unsigned 64-bit big-endian byte length, and the exact value +bytes. Tag `0x01` carries the UTF-8 parent source, tag `0x02` carries the exact +governing config bytes, and each Markdown file contributes tag `0x03` with its +corpus-relative POSIX UTF-8 path followed by tag `0x04` with its exact content +bytes. Files are ordered by those path bytes. No newline, Unicode, YAML, or +Markdown normalisation occurs. The operator surface is the read-only command +`decided corpus digest --root --corpus `; it reads +the source from the bounded parent config and prints `sha256:` followed by 64 +lowercase hexadecimal characters. The command never edits the manifest or +parent and never performs network I/O. + ### One source-aware read model The engine introduces source-aware equivalents of its path-only concepts: @@ -221,6 +234,12 @@ An absent, ambiguous, cross-type, retired-rationale, or parent-to-parent mapping is a validation error. There is no implicit child-wins or parent-wins rule. +All three override operands are canonical IDs. `parent` is qualified; `with` +and `rationale` are canonical local IDs and do not accept aliases. An override +redirects only the parent's canonical ID in the effective unqualified view; +it does not turn the parent's legacy or title aliases into aliases of the +replacement. + ### Validation semantics The parent is validated as a source corpus before overlay. A structural or @@ -261,12 +280,25 @@ They do not copy artifact bodies, excerpts, override mappings, or the full response provenance. Because ADR-127 pins the current path-only shape, ADR-141 must amend that decision explicitly before these fields ship. +Public `path` values remain corpus-relative paths within the artifact's owning +source; they never expose or encode the vendored or submodule checkout path. +The accompanying `source` disambiguates equal paths across layers. Federated +CLI and MCP records carry `source`, `layer`, and `pin` in their existing +provenance object, with `pin` present only for inherited records. Export +records carry the same facts in their projection-specific metadata. A +repository without a manifest retains its existing shapes byte for byte. + Default reads use the effective combined corpus. Human-facing diagnostic and export commands may request `--local-only` to inspect the child layer. MCP and enforcement do not expose a local-only bypass: an agent connected to a federated repository and `decided gate --code` both receive inherited governance. +The first increment exposes `--local-only` on viewer, documents, and graph +exports. Other human diagnostic reads may adopt the same projection later; +they are not required for the first implementation and must never weaken MCP, +routing, or enforcement. + ### Code scope and enforcement Inherited live decisions participate in `decisions-for`, diff --git a/decisions/requirements/corpus-source-identity.md b/decisions/requirements/corpus-source-identity.md index c0364882..5822c40e 100644 --- a/decisions/requirements/corpus-source-identity.md +++ b/decisions/requirements/corpus-source-identity.md @@ -7,7 +7,7 @@ type: requirement ## Status -Proposed +Accepted Classification: `[internal]` — merge N corpora with zero collisions and give federated artifacts one source identity across every surface. Feature E of the diff --git a/rust/decided-mcp/src/graph.rs b/rust/decided-mcp/src/graph.rs index f3e2354c..1d81ec90 100644 --- a/rust/decided-mcp/src/graph.rs +++ b/rust/decided-mcp/src/graph.rs @@ -191,6 +191,9 @@ impl GraphView { artifact_id: artifact_id.to_string(), outcome: OUTCOME_RESOLVED, artifact: Some(ResolvedArtifact { + key: entry.key.clone(), + artifact_path: entry.artifact_path.clone(), + origin: entry.origin.clone(), id: entry.id.clone(), artifact_type: entry.artifact_type.clone(), title: entry.title.clone(), @@ -406,6 +409,9 @@ impl GraphView { fn identity_projection(entry: &IndexEntry) -> IndexEntry { IndexEntry { + key: entry.key.clone(), + artifact_path: entry.artifact_path.clone(), + origin: entry.origin.clone(), id: entry.id.clone(), artifact_type: entry.artifact_type.clone(), title: entry.title.clone(), diff --git a/rust/rac-engine/src/corpus.rs b/rust/rac-engine/src/corpus.rs new file mode 100644 index 00000000..3caaefe3 --- /dev/null +++ b/rust/rac-engine/src/corpus.rs @@ -0,0 +1,327 @@ +//! Stable corpus and artifact identity for the source-aware read model. +//! +//! Stable identity deliberately excludes checkout paths. Runtime filesystem +//! locators are separate types so moving an otherwise identical checkout +//! cannot alter an [`ArtifactKey`] or [`ArtifactPath`] (ADR-135/ADR-138). + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::scaffold::{load_repository_identity, ScaffoldError}; + +/// Whether an artifact belongs to the writable child or a read-only parent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Layer { + Local, + Inherited, +} + +impl Layer { + pub const fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::Inherited => "inherited", + } + } + + pub const fn is_writable(self) -> bool { + matches!(self, Self::Local) + } +} + +/// Stable identity and provenance shared by every artifact in one layer. +/// +/// `pin` and `alias` are absent for the local layer. An inherited layer has +/// both: the full verified digest and the child-local readable alias. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct CorpusLayer { + pub source: String, + pub layer: Layer, + #[serde(skip_serializing_if = "Option::is_none")] + pub pin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub alias: Option, +} + +impl CorpusLayer { + pub fn local(source: impl Into) -> Self { + Self { + source: source.into(), + layer: Layer::Local, + pin: None, + alias: None, + } + } + + pub fn inherited( + source: impl Into, + alias: impl Into, + pin: impl Into, + ) -> Self { + Self { + source: source.into(), + layer: Layer::Inherited, + pin: Some(pin.into()), + alias: Some(alias.into()), + } + } + + pub fn origin(&self) -> ArtifactOrigin { + ArtifactOrigin { + source: self.source.clone(), + layer: self.layer, + pin: self.pin.clone(), + alias: self.alias.clone(), + } + } +} + +/// Stable global artifact identity: `(source, canonical_id)`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct ArtifactKey { + pub source: String, + pub canonical_id: String, +} + +impl ArtifactKey { + pub fn new(source: impl Into, canonical_id: impl Into) -> Self { + Self { + source: source.into(), + canonical_id: canonical_id.into(), + } + } +} + +/// Stable global path identity: `(source, corpus-relative path)`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct ArtifactPath { + pub source: String, + pub relative_path: String, +} + +impl ArtifactPath { + pub fn new(source: impl Into, relative_path: impl Into) -> Self { + Self { + source: source.into(), + relative_path: relative_path.into(), + } + } +} + +/// Stable provenance attached to a parsed or derived artifact. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct ArtifactOrigin { + pub source: String, + pub layer: Layer, + #[serde(skip_serializing_if = "Option::is_none")] + pub pin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub alias: Option, +} + +impl ArtifactOrigin { + pub fn key(&self, canonical_id: impl Into) -> ArtifactKey { + ArtifactKey::new(self.source.clone(), canonical_id) + } + + pub fn path(&self, relative_path: impl Into) -> ArtifactPath { + ArtifactPath::new(self.source.clone(), relative_path) + } +} + +impl From<&ArtifactOrigin> for CorpusLayer { + fn from(origin: &ArtifactOrigin) -> Self { + Self { + source: origin.source.clone(), + layer: origin.layer, + pin: origin.pin.clone(), + alias: origin.alias.clone(), + } + } +} + +/// Runtime-only filesystem location of one corpus layer. +/// +/// These paths must never be serialized as stable identity or used as a +/// deterministic tie-break. Federation verification owns canonicalisation; +/// this substrate only keeps the already-selected locations distinct from +/// provenance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PhysicalCorpusLocator { + pub repository_root: PathBuf, + pub corpus_root: PathBuf, +} + +impl PhysicalCorpusLocator { + pub fn new(repository_root: impl Into, corpus_root: impl Into) -> Self { + Self { + repository_root: repository_root.into(), + corpus_root: corpus_root.into(), + } + } + + pub fn local(corpus_root: impl Into) -> Self { + let corpus_root = corpus_root.into(); + Self::new(repository_root_for(&corpus_root), corpus_root) + } +} + +/// Runtime-only filesystem location of one artifact. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PhysicalArtifactLocator { + pub corpus: PhysicalCorpusLocator, + pub path: PathBuf, +} + +impl PhysicalArtifactLocator { + pub fn new(corpus: PhysicalCorpusLocator, path: impl Into) -> Self { + Self { + corpus, + path: path.into(), + } + } +} + +/// The exact non-federated source derivation shared by exports and the local +/// source-aware layer: explicit `corpus.source`, then lower-case +/// `repository_key`, then the released directory-basename fallback. +pub fn compatible_corpus_source(directory: &str) -> Result { + let Some(identity) = load_repository_identity(directory)? else { + return Ok(compatible_corpus_name(directory)); + }; + if let Some(source) = identity.corpus_source { + return Ok(source); + } + if let Some(repository_key) = identity.repository_key { + return Ok(repository_key + .strip_suffix('\n') + .unwrap_or(&repository_key) + .to_ascii_lowercase()); + } + Ok(compatible_corpus_name(directory)) +} + +/// Build the dormant single local layer without making configuration errors +/// newly observable on legacy read paths. Strict federation loading uses its +/// own fallible verification gate before composition. +pub fn compatible_local_layer(directory: &str) -> CorpusLayer { + let source = + compatible_corpus_source(directory).unwrap_or_else(|_| compatible_corpus_name(directory)); + CorpusLayer::local(source) +} + +pub(crate) fn compatible_corpus_name(directory: &str) -> String { + let normalized = crate::walk::normalize_root(directory); + let trimmed = normalized.trim_end_matches('/'); + let name = trimmed.rsplit('/').next().unwrap_or(""); + if name.is_empty() || name == "." || name == ".." { + directory.to_string() + } else { + name.to_string() + } +} + +fn repository_root_for(corpus_root: &Path) -> PathBuf { + let start = if corpus_root.is_dir() { + corpus_root + } else { + corpus_root.parent().unwrap_or(corpus_root) + }; + crate::validate::repository_root(&start.to_string_lossy()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn composite_keys_order_by_source_then_local_identity() { + let mut keys = [ + ArtifactKey::new("zeta/standards", "ADR-001"), + ArtifactKey::new("acme/app", "ADR-002"), + ArtifactKey::new("acme/app", "ADR-001"), + ]; + keys.sort(); + assert_eq!( + keys.iter() + .map(|key| (key.source.as_str(), key.canonical_id.as_str())) + .collect::>(), + vec![ + ("acme/app", "ADR-001"), + ("acme/app", "ADR-002"), + ("zeta/standards", "ADR-001"), + ] + ); + + let mut paths = [ + ArtifactPath::new("zeta/standards", "a.md"), + ArtifactPath::new("acme/app", "z.md"), + ArtifactPath::new("acme/app", "a.md"), + ]; + paths.sort(); + assert_eq!(paths[0], ArtifactPath::new("acme/app", "a.md")); + assert_eq!(paths[2], ArtifactPath::new("zeta/standards", "a.md")); + } + + #[test] + fn provenance_serde_is_stable_and_omits_absent_local_fields() { + assert_eq!( + serde_json::to_string(&ArtifactKey::new("acme/app", "APP-001")).unwrap(), + r#"{"source":"acme/app","canonical_id":"APP-001"}"# + ); + assert_eq!( + serde_json::to_string(&ArtifactPath::new("acme/app", "decisions/adr-001.md")).unwrap(), + r#"{"source":"acme/app","relative_path":"decisions/adr-001.md"}"# + ); + let local = CorpusLayer::local("acme/app").origin(); + assert_eq!( + serde_json::to_string(&local).unwrap(), + r#"{"source":"acme/app","layer":"local"}"# + ); + assert_eq!( + serde_json::from_str::(r#"{"source":"acme/app","layer":"local"}"#) + .unwrap(), + local + ); + + let inherited = + CorpusLayer::inherited("acme/standards", "standards", "sha256:0123456789abcdef") + .origin(); + assert_eq!( + serde_json::to_string(&inherited).unwrap(), + r#"{"source":"acme/standards","layer":"inherited","pin":"sha256:0123456789abcdef","alias":"standards"}"# + ); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&inherited).unwrap()) + .unwrap(), + inherited + ); + } + + #[test] + fn stable_identity_does_not_include_clone_location() { + let layer = + CorpusLayer::inherited("acme/standards", "standards", "sha256:0123456789abcdef"); + let left = PhysicalArtifactLocator::new( + PhysicalCorpusLocator::new("/clone-a", "/clone-a/vendor/standards/decisions"), + "/clone-a/vendor/standards/decisions/decisions/adr-001.md", + ); + let right = PhysicalArtifactLocator::new( + PhysicalCorpusLocator::new("/clone-b", "/clone-b/vendor/standards/decisions"), + "/clone-b/vendor/standards/decisions/decisions/adr-001.md", + ); + + assert_ne!(left, right); + assert_eq!( + layer.origin().key("ADR-001"), + ArtifactKey::new("acme/standards", "ADR-001") + ); + assert_eq!( + layer.origin().path("decisions/adr-001.md"), + ArtifactPath::new("acme/standards", "decisions/adr-001.md") + ); + assert!(!serde_json::to_string(&layer).unwrap().contains("clone-")); + } +} diff --git a/rust/rac-engine/src/delta_generation.rs b/rust/rac-engine/src/delta_generation.rs index 42774cb4..d182e37c 100644 --- a/rust/rac-engine/src/delta_generation.rs +++ b/rust/rac-engine/src/delta_generation.rs @@ -37,6 +37,8 @@ use crate::retrieve::{scope_rows_from_items, ScopeRow}; #[derive(Clone)] pub struct IdentityRow { pub entry: IndexEntry, + pub artifact_path: crate::corpus::ArtifactPath, + pub origin: crate::corpus::ArtifactOrigin, pub status: String, } @@ -44,6 +46,8 @@ impl IdentityRow { fn from_item(item: &CorpusItem) -> Self { Self { entry: identity_entry_from_item(item), + artifact_path: item.artifact_path.clone(), + origin: item.origin.clone(), status: artifact_status(&item.artifact), } } @@ -171,6 +175,26 @@ impl IdentityGeneration { self.base.get(path).map(|row| row.status.as_str()) } + pub fn artifact_path_for_path(&self, path: &str) -> Option<&crate::corpus::ArtifactPath> { + if let Some(row) = self.upserts.get(path) { + return Some(&row.artifact_path); + } + if self.tombstones.contains(path) { + return None; + } + self.base.get(path).map(|row| &row.artifact_path) + } + + pub fn origin_for_path(&self, path: &str) -> Option<&crate::corpus::ArtifactOrigin> { + if let Some(row) = self.upserts.get(path) { + return Some(&row.origin); + } + if self.tombstones.contains(path) { + return None; + } + self.base.get(path).map(|row| &row.origin) + } + pub fn entries(&self) -> Vec { self.base .keys() @@ -444,31 +468,38 @@ fn resolve_graph_row(row: &ValidationRow, identity: &IdentityGeneration) -> Vec< for (section, targets) in &row.edges { let external = edge_spec(section).is_some_and(|spec| spec.external); for target in targets { - let (resolved_path, issue) = if external { - (None, None) + let (resolved_path, resolved_artifact, issue) = if external { + (None, None, None) } else { let result = identity.resolve(target); match result.outcome { - OUTCOME_NOT_FOUND => (None, Some(ISSUE_TARGET_NOT_FOUND.to_string())), - OUTCOME_DUPLICATE => (None, Some(ISSUE_TARGET_AMBIGUOUS.to_string())), + OUTCOME_NOT_FOUND => { + (None, None, Some(ISSUE_TARGET_NOT_FOUND.to_string())) + } + OUTCOME_DUPLICATE => { + (None, None, Some(ISSUE_TARGET_AMBIGUOUS.to_string())) + } OUTCOME_RESOLVED => { let path = result .artifact .expect("resolved identity has artifact") .path; if path == row.path { - (None, Some(ISSUE_SELF_REFERENCE.to_string())) + (None, None, Some(ISSUE_SELF_REFERENCE.to_string())) } else { - (Some(path), None) + let artifact_path = identity.artifact_path_for_path(&path).cloned(); + (Some(path), artifact_path, None) } } _ => unreachable!("identity resolution outcome"), } }; edges.push(Relationship { + source_artifact: Some(row.artifact_path.clone()), source_path: row.path.clone(), relationship: section.clone(), target: target.clone(), + resolved_artifact, resolved_path, issue, }); @@ -1196,7 +1227,45 @@ impl DeltaGeneration { /// validation and is therefore suitable for durable compaction. pub fn materialize_derived(&self, directory: &str, recursive: bool) -> DerivedIndex { let (index_entries, field_tokens) = self.search.entries_and_fields(&self.graph); + let compatibility_layer = crate::corpus::compatible_local_layer(directory); + let source_artifacts: Vec = index_entries + .iter() + .map(|entry| { + let path = self + .identity + .artifact_path_for_path(&entry.path) + .cloned() + .unwrap_or_else(|| { + crate::corpus::ArtifactPath::new( + compatibility_layer.source.clone(), + entry.path.clone(), + ) + }); + let origin = self + .identity + .origin_for_path(&entry.path) + .cloned() + .unwrap_or_else(|| compatibility_layer.origin()); + crate::derived::SourceAwareArtifact { + key: origin.key(entry.id.clone()), + path, + origin, + display_path: entry.path.clone(), + } + }) + .collect(); + let mut layers: Vec = source_artifacts + .iter() + .map(|artifact| crate::corpus::CorpusLayer::from(&artifact.origin)) + .collect(); + layers.sort(); + layers.dedup(); + if layers.is_empty() { + layers.push(compatibility_layer); + } DerivedIndex { + layers, + source_artifacts, index_entries, field_tokens, relationships: self.graph.relationships(), @@ -1219,11 +1288,7 @@ mod tests { let text = DOC.replace("ADR-1", id).replace("Accepted", status); let artifact = crate::parse::parse_text(&text, path); let spec = crate::spec::spec_for(&crate::classify::classify(&artifact).artifact_type); - CorpusItem { - path: path.to_string(), - artifact, - spec, - } + CorpusItem::compatible_local_file(path, artifact, spec) } fn tagged_item(path: &str, id: &str, status: &str, tag: &str) -> CorpusItem { @@ -1237,11 +1302,7 @@ mod tests { ); let artifact = crate::parse::parse_text(&text, path); let spec = crate::spec::spec_for(&crate::classify::classify(&artifact).artifact_type); - CorpusItem { - path: path.to_string(), - artifact, - spec, - } + CorpusItem::compatible_local_file(path, artifact, spec) } fn related_item(path: &str, id: &str, target: &str) -> CorpusItem { @@ -1250,11 +1311,7 @@ mod tests { ); let artifact = crate::parse::parse_text(&text, path); let spec = crate::spec::spec_for(&crate::classify::classify(&artifact).artifact_type); - CorpusItem { - path: path.to_string(), - artifact, - spec, - } + CorpusItem::compatible_local_file(path, artifact, spec) } fn scoped_item(path: &str, id: &str, status: &str, scope: Option<&str>) -> CorpusItem { @@ -1267,11 +1324,7 @@ mod tests { .replace("\n## Status", &format!("{scope}\n\n## Status")); let artifact = crate::parse::parse_text(&text, path); let spec = crate::spec::spec_for(&crate::classify::classify(&artifact).artifact_type); - CorpusItem { - path: path.to_string(), - artifact, - spec, - } + CorpusItem::compatible_local_file(path, artifact, spec) } fn scope_signature(rows: Vec) -> Vec<(String, String, String, String, Vec)> { diff --git a/rust/rac-engine/src/derived.rs b/rust/rac-engine/src/derived.rs index 1431b02a..7f2d44a0 100644 --- a/rust/rac-engine/src/derived.rs +++ b/rust/rac-engine/src/derived.rs @@ -7,6 +7,7 @@ use serde_json::Value; +use crate::corpus::{ArtifactKey, ArtifactOrigin, ArtifactPath, CorpusLayer}; use crate::relationships::{corpus_items, relationships_from_corpus, CorpusItem, Relationship}; use crate::resolve::{entry_from_item, field_tokens_of, is_live_decision, FieldTokens, IndexEntry}; use crate::retrieve::{scope_rows_from_items, ScopeRow}; @@ -16,8 +17,24 @@ pub const SCHEMA_VERSION: &str = "3"; pub(crate) const DECISION_TYPE: &str = "decision"; +/// The source-aware identity projection parallel to the existing searchable +/// rows. It remains in memory while the frozen v1 store is the compatibility +/// format; the versioned store cutover will persist these exact values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceAwareArtifact { + pub key: ArtifactKey, + pub path: ArtifactPath, + pub origin: ArtifactOrigin, + /// Released display path retained independently from stable path identity. + pub display_path: String, +} + /// The expensive derived structures for one corpus snapshot. pub struct DerivedIndex { + /// Stable layer identities represented in this generation. + pub layers: Vec, + /// Per-document source identity in the same order as `index_entries`. + pub source_artifacts: Vec, /// Repository index rows in walk (sorted-path) order — docid order. pub index_entries: Vec, /// Per-entry BM25F field-token vectors, parallel to `index_entries`. @@ -54,6 +71,24 @@ pub fn build_derived_index_from_items( }) .collect(); let field_tokens: Vec = index_entries.iter().map(field_tokens_of).collect(); + let source_artifacts: Vec = items + .iter() + .map(|item| SourceAwareArtifact { + key: item.key.clone(), + path: item.artifact_path.clone(), + origin: item.origin.clone(), + display_path: item.path.clone(), + }) + .collect(); + let mut layers: Vec = items + .iter() + .map(|item| CorpusLayer::from(&item.origin)) + .collect(); + layers.sort(); + layers.dedup(); + if layers.is_empty() { + layers.push(crate::corpus::compatible_local_layer(directory)); + } let live_decision_paths: Vec = items .iter() .filter(|item| { @@ -64,6 +99,8 @@ pub fn build_derived_index_from_items( .collect(); let summary = crate::portfolio::portfolio_from_corpus(directory, items, recursive); DerivedIndex { + layers, + source_artifacts, index_entries, field_tokens, relationships, diff --git a/rust/rac-engine/src/export.rs b/rust/rac-engine/src/export.rs index f65500b0..ac6e45d2 100644 --- a/rust/rac-engine/src/export.rs +++ b/rust/rac-engine/src/export.rs @@ -8,7 +8,7 @@ use crate::pycompat::py_strip; use crate::relationships::{ corpus_items, edge_spec, relationships_from_corpus, CorpusItem, }; -use crate::scaffold::{load_repository_identity, ScaffoldError}; +use crate::scaffold::ScaffoldError; use crate::spec::ArtifactSpec; use crate::validate::load_ticketing_provider; @@ -23,6 +23,12 @@ const DOCUMENTS_SCHEMA: &str = include_str!("../assets/schemas/export-documents-v1.schema.json"); const GRAPH_SCHEMA: &str = include_str!("../assets/schemas/export-graph-v1.schema.json"); +/// Released display name; unlike source identity, this remains tied to the +/// caller's directory spelling. +fn corpus_name(directory: &str) -> String { + crate::corpus::compatible_corpus_name(directory) +} + /// Return the packaged Draft 2020-12 contract for an export projection. /// /// These bytes are the public resource surfaced by `decided export --schema`; @@ -36,35 +42,11 @@ pub fn export_schema(name: &str) -> Option<&'static str> { } } -/// `_corpus_name(directory)`. -fn corpus_name(directory: &str) -> String { - let normalized = crate::walk::normalize_root(directory); - let trimmed = normalized.trim_end_matches('/'); - let name = trimmed.rsplit('/').next().unwrap_or(""); - if name.is_empty() || name == "." || name == ".." { - directory.to_string() - } else { - name.to_string() - } -} - /// The one non-federated source derivation shared by every JSON projection: /// explicit `corpus.source`, then the lower-case repository key, then the /// released directory-basename fallback (ADR-135). pub fn corpus_source(directory: &str) -> Result { - let Some(identity) = load_repository_identity(directory)? else { - return Ok(corpus_name(directory)); - }; - if let Some(source) = identity.corpus_source { - return Ok(source); - } - if let Some(repository_key) = identity.repository_key { - return Ok(repository_key - .strip_suffix('\n') - .unwrap_or(&repository_key) - .to_ascii_lowercase()); - } - Ok(corpus_name(directory)) + crate::corpus::compatible_corpus_source(directory) } fn first_line(raw: &str) -> String { diff --git a/rust/rac-engine/src/freshness.rs b/rust/rac-engine/src/freshness.rs index 65b13aca..242b6ede 100644 --- a/rust/rac-engine/src/freshness.rs +++ b/rust/rac-engine/src/freshness.rs @@ -310,7 +310,8 @@ impl FreshnessTracker { self.items.remove(rel); // removed } } - let (parsed, workers) = crate::parallel_build::parallel_parse_paths(&present); + let (parsed, workers) = + crate::parallel_build::parallel_parse_paths(&self.root_str, &present); self.last_parse_workers = workers; self.last_parse_files = present.len(); for item in parsed { @@ -333,7 +334,8 @@ impl FreshnessTracker { fn reparse_full(&mut self) { let root = PathBuf::from(&self.root_str); let paths: Vec = self.manifest.iter().map(|(rel, _)| root.join(rel)).collect(); - let (parsed, workers) = crate::parallel_build::parallel_parse_paths(&paths); + let (parsed, workers) = + crate::parallel_build::parallel_parse_paths(&self.root_str, &paths); self.last_parse_workers = workers; self.last_parse_files = paths.len(); self.items = parsed @@ -374,7 +376,8 @@ impl FreshnessTracker { .filter(|rel| current.contains(rel.as_str())) .map(|rel| root.join(rel)) .collect(); - let (parsed, workers) = crate::parallel_build::parallel_parse_paths(&present); + let (parsed, workers) = + crate::parallel_build::parallel_parse_paths(&self.root_str, &present); self.last_parse_workers = workers; self.last_parse_files = present.len(); let parsed: BTreeMap = parsed @@ -490,7 +493,8 @@ impl FreshnessTracker { .iter() .map(|(rel, _)| root.join(rel)) .collect(); - let (parsed, workers) = crate::parallel_build::parallel_parse_paths(&paths); + let (parsed, workers) = + crate::parallel_build::parallel_parse_paths(&self.root_str, &paths); self.last_parse_workers = workers; self.last_parse_files = paths.len(); let parsed: BTreeMap = parsed diff --git a/rust/rac-engine/src/index_store.rs b/rust/rac-engine/src/index_store.rs index e8c03bdc..a25ec94b 100644 --- a/rust/rac-engine/src/index_store.rs +++ b/rust/rac-engine/src/index_store.rs @@ -540,6 +540,9 @@ impl MmapIndexReader { let aliases = reader.text_list()?; let tags = reader.text_list()?; Ok(IndexEntry { + key: None, + artifact_path: None, + origin: None, id, artifact_type, title, @@ -563,6 +566,9 @@ impl MmapIndexReader { let inbound = reader.u32()?; let sections = self.read_sections(docid)?; Ok(IndexEntry { + key: None, + artifact_path: None, + origin: None, id, artifact_type, title, @@ -773,9 +779,11 @@ impl MmapIndexReader { let mut result = Vec::with_capacity(count.min(1 << 20) as usize); for _ in 0..count { result.push(Relationship { + source_artifact: None, source_path: reader.text()?, relationship: reader.text()?, target: reader.text()?, + resolved_artifact: None, resolved_path: reader.opt_text()?, issue: reader.opt_text()?, }); diff --git a/rust/rac-engine/src/lib.rs b/rust/rac-engine/src/lib.rs index 1774a4da..308f3afa 100644 --- a/rust/rac-engine/src/lib.rs +++ b/rust/rac-engine/src/lib.rs @@ -46,6 +46,7 @@ pub mod walk; pub mod parse; pub mod classify; pub mod identity; +pub mod corpus; pub mod validate; pub mod relationships; pub mod diff; diff --git a/rust/rac-engine/src/parallel_build.rs b/rust/rac-engine/src/parallel_build.rs index 744f4d20..8f1f60f8 100644 --- a/rust/rac-engine/src/parallel_build.rs +++ b/rust/rac-engine/src/parallel_build.rs @@ -16,6 +16,9 @@ use std::path::PathBuf; +use crate::corpus::{ + compatible_local_layer, ArtifactOrigin, PhysicalArtifactLocator, PhysicalCorpusLocator, +}; use crate::derived::{build_derived_index_from_items, DerivedIndex, DECISION_TYPE}; use crate::relationships::{relationships_from_corpus, CorpusItem}; use crate::resolve::{entry_from_item, field_tokens_of, is_live_decision, IndexEntry}; @@ -80,17 +83,24 @@ struct DocFragment { scope_row: Option, } -fn fragment_for(path_display: &str) -> DocFragment { +fn fragment_for( + entry: &crate::walk::WalkEntry, + origin: &ArtifactOrigin, + physical_corpus: &PhysicalCorpusLocator, +) -> DocFragment { if std::env::var_os(FAULT_ENV).is_some() { panic!("parallel-build worker fault (injected)"); } - let artifact = crate::parse::parse_file(path_display); + let artifact = crate::parse::parse_file(&entry.display); let spec = crate::spec::spec_for(&crate::classify::classify(&artifact).artifact_type); - let item = CorpusItem { - path: path_display.to_string(), + let item = CorpusItem::new( + entry.display.clone(), + entry.rel(), artifact, spec, - }; + origin.clone(), + PhysicalArtifactLocator::new(physical_corpus.clone(), entry.abs.clone()), + ); let index_entry = entry_from_item(&item, 0); let field_tokens = field_tokens_of(&index_entry); let live = item.spec.map(|s| s.name == DECISION_TYPE).unwrap_or(false) @@ -106,7 +116,12 @@ fn fragment_for(path_display: &str) -> DocFragment { } /// Fan the parse + per-doc derive across `n_workers`, or None on any fault. -fn fragments_parallel(paths: &[String], n_workers: usize) -> Option> { +fn fragments_parallel( + entries: &[crate::walk::WalkEntry], + n_workers: usize, + origin: &ArtifactOrigin, + physical_corpus: &PhysicalCorpusLocator, +) -> Option> { let pool = rayon::ThreadPoolBuilder::new() .num_threads(n_workers) .build() @@ -114,9 +129,9 @@ fn fragments_parallel(paths: &[String], n_workers: usize) -> Option>() }) })); @@ -130,6 +145,7 @@ fn fragments_parallel(paths: &[String], n_workers: usize) -> Option, directory: &str, recursive: bool) -> DerivedIndex { let mut items = Vec::with_capacity(fragments.len()); let mut index_entries = Vec::with_capacity(fragments.len()); + let mut source_artifacts = Vec::with_capacity(fragments.len()); let mut field_tokens = Vec::with_capacity(fragments.len()); let mut live_decision_paths = Vec::new(); let mut scope_rows = Vec::new(); @@ -140,6 +156,12 @@ fn reproduce(fragments: Vec, directory: &str, recursive: bool) -> D if let Some(row) = fragment.scope_row { scope_rows.push(row); } + source_artifacts.push(crate::derived::SourceAwareArtifact { + key: fragment.item.key.clone(), + path: fragment.item.artifact_path.clone(), + origin: fragment.item.origin.clone(), + display_path: fragment.item.path.clone(), + }); items.push(fragment.item); index_entries.push(fragment.index_entry); field_tokens.push(fragment.field_tokens); @@ -156,7 +178,18 @@ fn reproduce(fragments: Vec, directory: &str, recursive: bool) -> D entry.inbound_count = inbound.get(entry.path.as_str()).copied().unwrap_or(0); } let summary = crate::portfolio::portfolio_from_corpus(directory, &items, recursive); + let mut layers: Vec = items + .iter() + .map(|item| crate::corpus::CorpusLayer::from(&item.origin)) + .collect(); + layers.sort(); + layers.dedup(); + if layers.is_empty() { + layers.push(crate::corpus::compatible_local_layer(directory)); + } DerivedIndex { + layers, + source_artifacts, index_entries, field_tokens, relationships, @@ -174,13 +207,12 @@ pub fn build_derived_index_parallel( workers: Option, ) -> (DerivedIndex, BuildStats) { let t0 = std::time::Instant::now(); - let paths: Vec = crate::walk::find_markdown_files(directory, recursive) - .into_iter() - .map(|e| e.display) - .collect(); - let n_workers = resolve_workers(workers, paths.len()); + let entries = crate::walk::find_markdown_files(directory, recursive); + let n_workers = resolve_workers(workers, entries.len()); + let origin = compatible_local_layer(directory).origin(); + let physical_corpus = PhysicalCorpusLocator::local(directory); let fragments = if n_workers > 1 { - fragments_parallel(&paths, n_workers) + fragments_parallel(&entries, n_workers, &origin, &physical_corpus) } else { None }; @@ -233,7 +265,10 @@ pub fn emit_build_timing(stats: &BuildStats) { /// The paths type alias for the freshness tracker's explicit-list parse /// (INDEX-PLAN B6): parse a known path list through the one true per-file /// path, parallel when it pays; entries in list order. -pub fn parallel_parse_paths(paths: &[PathBuf]) -> (Vec, usize) { +pub fn parallel_parse_paths(root: &str, paths: &[PathBuf]) -> (Vec, usize) { + let corpus_root = PathBuf::from(root); + let origin = compatible_local_layer(root).origin(); + let physical_corpus = PhysicalCorpusLocator::local(&corpus_root); let displays: Vec = paths .iter() .map(|p| p.to_string_lossy().into_owned()) @@ -245,11 +280,21 @@ pub fn parallel_parse_paths(paths: &[PathBuf]) -> (Vec, usize) { let artifact = crate::parse::parse_file(path); let spec = crate::spec::spec_for(&crate::classify::classify(&artifact).artifact_type); - CorpusItem { - path: path.clone(), + let relative_path = PathBuf::from(path) + .strip_prefix(&corpus_root) + .unwrap_or_else(|_| std::path::Path::new(path)) + .iter() + .map(|part| part.to_string_lossy()) + .collect::>() + .join("/"); + CorpusItem::new( + path.clone(), + relative_path, artifact, spec, - } + origin.clone(), + PhysicalArtifactLocator::new(physical_corpus.clone(), path), + ) }) .collect(); let workers = std::thread::available_parallelism() diff --git a/rust/rac-engine/src/portfolio.rs b/rust/rac-engine/src/portfolio.rs index 5216921f..6b588734 100644 --- a/rust/rac-engine/src/portfolio.rs +++ b/rust/rac-engine/src/portfolio.rs @@ -5,7 +5,7 @@ use crate::classify::missing_sections; use crate::pycompat::py_round; use crate::relationships::{ - summary_from_rows, validation_from_rows, validation_row, CorpusItem, RelationshipSummary, + summary_from_rows, validation_from_rows, CorpusItem, RelationshipSummary, ValidationRow, ISSUE_SELF_REFERENCE, ISSUE_TARGET_AMBIGUOUS, ISSUE_TARGET_NOT_FOUND, }; use crate::validate::{apply_overrides, has_errors, load_overrides, py_title, validate, SeverityOverrides}; @@ -99,7 +99,7 @@ pub fn portfolio_row(item: &CorpusItem) -> PortfolioRow { .spec .map(|s| s.name.clone()) .unwrap_or_else(|| "unknown".to_string()); - let vrow = validation_row(&path, &item.artifact, item.spec); + let vrow = crate::relationships::validation_row_from_item(item); match item.spec { None => PortfolioRow { path, diff --git a/rust/rac-engine/src/relationships.rs b/rust/rac-engine/src/relationships.rs index 6e7ab0af..8c99ca13 100644 --- a/rust/rac-engine/src/relationships.rs +++ b/rust/rac-engine/src/relationships.rs @@ -10,6 +10,10 @@ use std::collections::HashMap; use std::path::PathBuf; use crate::classify::classify; +use crate::corpus::{ + compatible_local_layer, ArtifactKey, ArtifactOrigin, ArtifactPath, + PhysicalArtifactLocator, PhysicalCorpusLocator, +}; use crate::identity::{artifact_identifier, artifact_identifiers, strip_list_marker}; use crate::parse::{parse_file, Artifact}; use crate::pycompat::{py_casefold, py_splitlines, py_strip}; @@ -265,6 +269,9 @@ fn is_retired(artifact: &Artifact, spec: &ArtifactSpec) -> bool { #[derive(Debug, Clone)] pub struct ValidationRow { + pub key: ArtifactKey, + pub artifact_path: ArtifactPath, + pub origin: ArtifactOrigin, pub path: String, /// Artifact type name, or None for an Unknown/untyped document. pub spec_name: Option, @@ -282,10 +289,56 @@ pub fn validation_row( artifact: &Artifact, spec: Option<&ArtifactSpec>, ) -> ValidationRow { + let parent = std::path::Path::new(path) + .parent() + .and_then(std::path::Path::to_str) + .unwrap_or("."); + let layer = compatible_local_layer(parent); + let origin = layer.origin(); + let relative_path = std::path::Path::new(path) + .file_name() + .and_then(std::ffi::OsStr::to_str) + .unwrap_or(path); let identifiers = artifact_identifiers(artifact, spec, path); let canonical_id = artifact_identifier(artifact, spec, path); + validation_row_with_identity( + path, + artifact, + spec, + origin.key(canonical_id), + origin.path(relative_path), + origin, + identifiers, + ) +} + +pub(crate) fn validation_row_from_item(item: &CorpusItem) -> ValidationRow { + validation_row_with_identity( + &item.path, + &item.artifact, + item.spec, + item.key.clone(), + item.artifact_path.clone(), + item.origin.clone(), + artifact_identifiers(&item.artifact, item.spec, &item.path), + ) +} + +fn validation_row_with_identity( + path: &str, + artifact: &Artifact, + spec: Option<&ArtifactSpec>, + key: ArtifactKey, + artifact_path: ArtifactPath, + origin: ArtifactOrigin, + identifiers: Vec, +) -> ValidationRow { + let canonical_id = key.canonical_id.clone(); match spec { None => ValidationRow { + key, + artifact_path, + origin, path: path.to_string(), spec_name: None, canonical_id, @@ -295,6 +348,9 @@ pub fn validation_row( edges: Vec::new(), }, Some(spec) => ValidationRow { + key, + artifact_path, + origin, path: path.to_string(), spec_name: Some(spec.name.clone()), canonical_id, @@ -911,11 +967,7 @@ pub fn build_relationship_report(directory: &str, recursive: bool) -> Relationsh pub fn build_relationship_report_file(path: &str) -> RelationshipReport { let artifact = parse_file(path); let spec = spec_for(&classify(&artifact).artifact_type); - let items = vec![CorpusItem { - path: path.to_string(), - artifact, - spec, - }]; + let items = vec![CorpusItem::compatible_local_file(path, artifact, spec)]; build_report(path, items, false) } @@ -923,14 +975,70 @@ pub fn build_relationship_report_file(path: &str) -> RelationshipReport { // Corpus entry points // --------------------------------------------------------------------------- -/// One parsed + classified item: `(display path, artifact, spec)`. +/// One parsed + classified item with stable identity and a separate runtime +/// locator. `path` remains the released display string. #[derive(Clone)] pub struct CorpusItem { + pub key: ArtifactKey, + pub artifact_path: ArtifactPath, + pub origin: ArtifactOrigin, + pub locator: PhysicalArtifactLocator, pub path: String, pub artifact: Artifact, pub spec: Option<&'static ArtifactSpec>, } +impl CorpusItem { + pub fn new( + path: String, + relative_path: String, + artifact: Artifact, + spec: Option<&'static ArtifactSpec>, + origin: ArtifactOrigin, + locator: PhysicalArtifactLocator, + ) -> Self { + let canonical_id = artifact_identifier(&artifact, spec, &path); + Self { + key: origin.key(canonical_id), + artifact_path: origin.path(relative_path), + origin, + locator, + path, + artifact, + spec, + } + } + + /// Compatibility constructor for single-file and synthetic validation + /// seams that do not begin with a corpus walk. + pub fn compatible_local_file( + path: &str, + artifact: Artifact, + spec: Option<&'static ArtifactSpec>, + ) -> Self { + let file = std::path::Path::new(path); + let corpus_root = file.parent().unwrap_or_else(|| std::path::Path::new(".")); + let corpus_root_text = corpus_root.to_string_lossy(); + let origin = compatible_local_layer(&corpus_root_text).origin(); + let relative_path = file + .file_name() + .and_then(std::ffi::OsStr::to_str) + .unwrap_or(path) + .to_string(); + Self::new( + path.to_string(), + relative_path, + artifact, + spec, + origin, + PhysicalArtifactLocator::new( + PhysicalCorpusLocator::local(corpus_root), + file, + ), + ) + } +} + /// `_corpus_items(directory, recursive)` — the sorted-path walk, parsed and /// classified. /// @@ -941,16 +1049,22 @@ pub struct CorpusItem { /// Rendering stays sequential over the ordered results. pub fn corpus_items(directory: &str, recursive: bool) -> Vec { use rayon::prelude::*; + let origin = compatible_local_layer(directory).origin(); + let physical_corpus = PhysicalCorpusLocator::local(directory); find_markdown_files(directory, recursive) .into_par_iter() .map(|entry| { + let relative_path = entry.rel(); let artifact = parse_file(&entry.display); let spec = spec_for(&classify(&artifact).artifact_type); - CorpusItem { - path: entry.display, + CorpusItem::new( + entry.display, + relative_path, artifact, spec, - } + origin.clone(), + PhysicalArtifactLocator::new(physical_corpus.clone(), entry.abs), + ) }) .collect() } @@ -958,7 +1072,7 @@ pub fn corpus_items(directory: &str, recursive: bool) -> Vec { fn rows_from_items(items: &[CorpusItem]) -> Vec { items .iter() - .map(|item| validation_row(&item.path, &item.artifact, item.spec)) + .map(validation_row_from_item) .collect() } @@ -991,7 +1105,7 @@ pub fn validate_document_against_corpus( if py_casefold(&ident) == proposed_ident { continue; // the on-disk counterpart of the document being edited } - rows.push(validation_row(&item.path, &item.artifact, item.spec)); + rows.push(validation_row_from_item(item)); } rows.push(validation_row(source_path, artifact, spec)); let result = validation_from_rows(directory, &rows, recursive); @@ -1017,9 +1131,15 @@ pub fn validate_document_against_corpus( /// uniquely to another artifact. #[derive(Debug, Clone)] pub struct Relationship { + /// Stable source-aware endpoint. `None` only when reconstructed from the + /// pre-federation v1 persistent store; the v2 cutover owns its codec. + pub source_artifact: Option, pub source_path: String, pub relationship: String, pub target: String, + /// Stable resolved endpoint, present for a uniquely resolved internal + /// edge built from the in-memory read model. + pub resolved_artifact: Option, pub resolved_path: Option, pub issue: Option, } @@ -1031,15 +1151,21 @@ pub fn resolve_relationships( index: &ResolutionIndex, ) -> Vec { let mut out = Vec::new(); + let artifact_paths: HashMap<&str, &ArtifactPath> = rows + .iter() + .map(|row| (row.path.as_str(), &row.artifact_path)) + .collect(); for row in rows { for (section, refs) in &row.edges { let external = edge_spec(section).is_some_and(|e| e.external); for reference in refs { if external { out.push(Relationship { + source_artifact: Some(row.artifact_path.clone()), source_path: row.path.clone(), relationship: section.clone(), target: reference.clone(), + resolved_artifact: None, resolved_path: None, issue: None, }); @@ -1057,10 +1183,16 @@ pub fn resolve_relationships( (None, Some(ISSUE_SELF_REFERENCE.to_string())) } }; + let resolved_artifact = resolved + .as_deref() + .and_then(|path| artifact_paths.get(path).copied()) + .cloned(); out.push(Relationship { + source_artifact: Some(row.artifact_path.clone()), source_path: row.path.clone(), relationship: section.clone(), target: reference.clone(), + resolved_artifact, resolved_path: resolved, issue, }); diff --git a/rust/rac-engine/src/rename.rs b/rust/rac-engine/src/rename.rs index 58c5a2fd..3f88ee15 100644 --- a/rust/rac-engine/src/rename.rs +++ b/rust/rac-engine/src/rename.rs @@ -15,7 +15,7 @@ use std::path::{Path, PathBuf}; use crate::pycompat::{py_casefold, py_is_space, py_splitlines, py_strip}; use crate::relationships::{ - corpus_items, resolution_index_from_rows, validation_row, CorpusItem, ValidationRow, + corpus_items, resolution_index_from_rows, CorpusItem, ValidationRow, }; use crate::spec::RELATIONSHIP_SECTIONS; @@ -719,7 +719,7 @@ pub fn compute_rename( let items = corpus_items(directory, recursive); let rows: Vec = items .iter() - .map(|item| validation_row(&item.path, &item.artifact, item.spec)) + .map(crate::relationships::validation_row_from_item) .collect(); let index = resolution_index_from_rows(&rows); let mut targets: Vec<&str> = index diff --git a/rust/rac-engine/src/resolve.rs b/rust/rac-engine/src/resolve.rs index a015074c..130d0c90 100644 --- a/rust/rac-engine/src/resolve.rs +++ b/rust/rac-engine/src/resolve.rs @@ -19,12 +19,13 @@ use std::collections::{HashMap, HashSet}; +use crate::corpus::{ArtifactKey, ArtifactOrigin, ArtifactPath}; use crate::identity::{artifact_identifier, artifact_identifiers}; use crate::markdown::SearchSection; use crate::parse::Artifact; use crate::pycompat::{first_nonempty_line, py_casefold, py_round, py_strip}; use crate::relationships::{ - corpus_items, edge_spec, resolution_index_from_rows, validation_row, CorpusItem, + corpus_items, edge_spec, resolution_index_from_rows, validation_row_from_item, CorpusItem, }; use crate::spec::spec_for; @@ -116,6 +117,11 @@ fn tf(term: &str, tokens: &[String]) -> i64 { /// One searchable row of the repository index. #[derive(Debug, Clone)] pub struct IndexEntry { + /// Source-aware identity is present for in-memory rows. The frozen v1 + /// store reconstructs `None` until the versioned v2 codec cutover. + pub key: Option, + pub artifact_path: Option, + pub origin: Option, pub id: String, pub artifact_type: String, pub title: Option, @@ -139,6 +145,9 @@ pub(crate) fn identity_entry_from_item(item: &CorpusItem) -> IndexEntry { .map(|s| s.name.clone()) .unwrap_or_else(|| "unknown".to_string()); IndexEntry { + key: Some(item.key.clone()), + artifact_path: Some(item.artifact_path.clone()), + origin: Some(item.origin.clone()), id: artifact_identifier(&item.artifact, item.spec, &item.path), artifact_type, title: item.artifact.product.title.clone(), @@ -170,7 +179,7 @@ pub(crate) fn entry_from_item(item: &CorpusItem, inbound: i64) -> IndexEntry { fn inbound_counts(items: &[CorpusItem]) -> HashMap { let rows: Vec<_> = items .iter() - .map(|item| validation_row(&item.path, &item.artifact, item.spec)) + .map(validation_row_from_item) .collect(); let index = resolution_index_from_rows(&rows); let mut counts: HashMap = HashMap::new(); @@ -212,6 +221,9 @@ pub fn index_from_items(items: &[CorpusItem]) -> Vec { /// One resolved artifact / search match (`ResolvedArtifact`). #[derive(Debug, Clone)] pub struct ResolvedArtifact { + pub key: Option, + pub artifact_path: Option, + pub origin: Option, pub id: String, pub artifact_type: String, pub title: Option, @@ -270,6 +282,9 @@ pub struct ResolutionResult { pub(crate) fn resolved_from_entry(entry: &IndexEntry) -> ResolvedArtifact { ResolvedArtifact { + key: entry.key.clone(), + artifact_path: entry.artifact_path.clone(), + origin: entry.origin.clone(), id: entry.id.clone(), artifact_type: entry.artifact_type.clone(), title: entry.title.clone(), @@ -1036,6 +1051,9 @@ pub(crate) fn rank_and_build( let fused_raw = fused[index]; let bm25_raw = bm25_scores[index]; ResolvedArtifact { + key: entry.key.clone(), + artifact_path: entry.artifact_path.clone(), + origin: entry.origin.clone(), id: entry.id.clone(), artifact_type: entry.artifact_type.clone(), title: entry.title.clone(), @@ -1145,6 +1163,9 @@ mod tests { fn test_entry(id: &str, title: &str, path: &str, body: &str, inbound: i64) -> IndexEntry { IndexEntry { + key: None, + artifact_path: None, + origin: None, id: id.to_string(), artifact_type: "decision".to_string(), title: Some(title.to_string()), @@ -1322,6 +1343,9 @@ mod tests { #[test] fn persisted_field_matching_preserves_tiers_and_snippets() { let entry = IndexEntry { + key: None, + artifact_path: None, + origin: None, id: "RAC-EXAMPLE1234".to_string(), artifact_type: "requirement".to_string(), title: Some("Search latency".to_string()), diff --git a/rust/rac-engine/src/sentry.rs b/rust/rac-engine/src/sentry.rs index 5d6d8c8f..dcb76517 100644 --- a/rust/rac-engine/src/sentry.rs +++ b/rust/rac-engine/src/sentry.rs @@ -307,11 +307,11 @@ fn parse_document(item: &CorpusItem) -> Result, Box Vec { - let item = CorpusItem { - path: String::new(), - artifact: artifact.clone(), - spec: spec_for("decision"), - }; + let item = CorpusItem::compatible_local_file( + "", + artifact.clone(), + spec_for("decision"), + ); match parse_document(&item) { Err(finding) => vec![Issue::new("error", finding.code, finding.message, None)], _ => Vec::new(), diff --git a/rust/rac-engine/tests/index_store_vectors.rs b/rust/rac-engine/tests/index_store_vectors.rs index d18d4af5..f1730e31 100644 --- a/rust/rac-engine/tests/index_store_vectors.rs +++ b/rust/rac-engine/tests/index_store_vectors.rs @@ -248,6 +248,8 @@ fn corruption_gates(cache_dir: &Path, corpus_hash: &str, seg_dir: &Path) { // A same-hash rewrite over a CORRUPT store replaces it (self-heal). fs::write(&target, &original[..10]).unwrap(); let derived = rac_engine::derived::DerivedIndex { + layers: Vec::new(), + source_artifacts: Vec::new(), index_entries: Vec::new(), field_tokens: Vec::new(), relationships: Vec::new(), diff --git a/rust/rac-engine/tests/source_aware_substrate.rs b/rust/rac-engine/tests/source_aware_substrate.rs new file mode 100644 index 00000000..24398ea2 --- /dev/null +++ b/rust/rac-engine/tests/source_aware_substrate.rs @@ -0,0 +1,190 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use rac_engine::corpus::{ArtifactKey, ArtifactPath, Layer}; +use rac_engine::identity::{artifact_identifier, artifact_identifiers}; + +const DECISION: &str = r#"--- +schema_version: 1 +id: APP-KWJ4VMKVSS65 +type: decision +--- +# ADR-001: Keep It Local + +## Status + +Accepted + +## Context + +The fixture needs one decision. + +## Decision + +Keep the released projection exact. + +## Consequences + +The source-aware fields remain internal. +"#; + +const REQUIREMENT: &str = r#"--- +schema_version: 1 +id: APP-KWJ8S53D06CH +type: requirement +--- +# Requirement: Preserve Identity + +## Status + +Accepted + +## Problem + +Endpoints need stable source-aware paths. + +## Requirements + +- [REQ-001] The endpoint MUST retain its corpus source. + +## Related Decisions + +- APP-KWJ4VMKVSS65 +"#; + +fn scratch(tag: &str) -> PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = + std::env::temp_dir().join(format!("asdecided-{tag}-{}-{unique}", std::process::id())); + fs::create_dir_all(root.join(".decided")).unwrap(); + fs::create_dir_all(root.join("decisions")).unwrap(); + fs::write( + root.join(".decided/config.yaml"), + "repository_key: APP\ncorpus:\n source: acme/app\n", + ) + .unwrap(); + fs::write(root.join("decisions/adr-001.md"), DECISION).unwrap(); + root +} + +fn corpus_arg(root: &Path) -> String { + root.join("decisions").to_string_lossy().into_owned() +} + +#[test] +fn stable_identity_survives_equivalent_clone_roots() { + let left_root = scratch("clone-a"); + let right_root = scratch("clone-b"); + let left = rac_engine::relationships::corpus_items(&corpus_arg(&left_root), true); + let right = rac_engine::relationships::corpus_items(&corpus_arg(&right_root), true); + + assert_eq!(left.len(), 1); + assert_eq!(right.len(), 1); + assert_eq!(left[0].origin, right[0].origin); + assert_eq!( + left[0].key, + ArtifactKey::new("acme/app", "APP-KWJ4VMKVSS65") + ); + assert_eq!(left[0].key, right[0].key); + assert_eq!( + left[0].artifact_path, + ArtifactPath::new("acme/app", "adr-001.md") + ); + assert_eq!(left[0].artifact_path, right[0].artifact_path); + assert_eq!(left[0].origin.layer, Layer::Local); + assert_ne!(left[0].locator.path, right[0].locator.path); + + let _ = fs::remove_dir_all(left_root); + let _ = fs::remove_dir_all(right_root); +} + +#[test] +fn no_manifest_keeps_the_released_index_projection_byte_exact() { + let root = scratch("parity"); + assert!(!root.join(".decided/corpus.md").exists()); + let directory = corpus_arg(&root); + let items = rac_engine::relationships::corpus_items(&directory, true); + + // This is the complete path-only projection used before the source-aware + // substrate. It intentionally does not inspect the new identity fields. + let legacy = rac_engine::index::RepositoryIndex { + directory: directory.clone(), + recursive: true, + artifacts: items + .iter() + .map(|item| rac_engine::index::IndexEntry { + id: artifact_identifier(&item.artifact, item.spec, &item.path), + artifact_type: rac_engine::classify::classify(&item.artifact).artifact_type, + title: item.artifact.product.title.clone(), + path: item.path.clone(), + aliases: artifact_identifiers(&item.artifact, item.spec, &item.path), + }) + .collect(), + }; + let source_aware = rac_engine::index::build_repository_index(&directory, true); + + assert_eq!( + rac_engine::output::render_index_json(&source_aware), + rac_engine::output::render_index_json(&legacy) + ); + assert_eq!( + rac_engine::output::render_index_human(&source_aware), + rac_engine::output::render_index_human(&legacy) + ); + + let derived = rac_engine::derived::build_derived_index(&directory, true); + assert_eq!(derived.layers.len(), 1); + assert_eq!(derived.layers[0].source, "acme/app"); + assert_eq!(derived.source_artifacts.len(), derived.index_entries.len()); + assert_eq!(derived.source_artifacts[0].key, items[0].key); + assert_eq!(derived.source_artifacts[0].path, items[0].artifact_path); + assert_eq!(derived.index_entries[0].key, Some(items[0].key.clone())); + assert_eq!( + derived.index_entries[0].artifact_path, + Some(items[0].artifact_path.clone()) + ); + let resolved = + rac_engine::resolve::resolve_in_index(&derived.index_entries, "APP-KWJ4VMKVSS65") + .artifact + .expect("source-aware resolved artifact"); + assert_eq!(resolved.key, Some(items[0].key.clone())); + assert_eq!(resolved.origin, Some(items[0].origin.clone())); + + // The frozen v1 codec remains untouched in this substrate-only change. + assert_eq!(rac_engine::index_store::STORE_LAYOUT_VERSION, "v1"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn validation_and_relationships_retain_source_aware_endpoints() { + let root = scratch("relationships"); + fs::write(root.join("decisions/req-002.md"), REQUIREMENT).unwrap(); + let directory = corpus_arg(&root); + let items = rac_engine::relationships::corpus_items(&directory, true); + let rows = rac_engine::relationships::rows_from_corpus_items(&items); + + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].key.source, "acme/app"); + assert_eq!(rows[1].artifact_path.source, "acme/app"); + assert!(rows.iter().all(|row| row.origin.layer == Layer::Local)); + + let relationships = rac_engine::relationships::relationships_from_corpus(&items); + let edge = relationships + .iter() + .find(|edge| edge.relationship == "related_decisions") + .expect("related decision edge"); + assert_eq!( + edge.source_artifact, + Some(ArtifactPath::new("acme/app", "req-002.md")) + ); + assert_eq!( + edge.resolved_artifact, + Some(ArtifactPath::new("acme/app", "adr-001.md")) + ); + + let _ = fs::remove_dir_all(root); +} From 0f52adc187766c4bc4f610723afc0187d2589f4a Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 30 Aug 2026 08:46:53 +0100 Subject: [PATCH 2/9] fix(engine): preserve parallel input ordering contract Signed-off-by: Tom Ballard --- rust/rac-engine/src/parallel_build.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/rac-engine/src/parallel_build.rs b/rust/rac-engine/src/parallel_build.rs index 8f1f60f8..ff57b70e 100644 --- a/rust/rac-engine/src/parallel_build.rs +++ b/rust/rac-engine/src/parallel_build.rs @@ -117,7 +117,7 @@ fn fragment_for( /// Fan the parse + per-doc derive across `n_workers`, or None on any fault. fn fragments_parallel( - entries: &[crate::walk::WalkEntry], + paths: &[crate::walk::WalkEntry], n_workers: usize, origin: &ArtifactOrigin, physical_corpus: &PhysicalCorpusLocator, @@ -129,7 +129,7 @@ fn fragments_parallel( let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { pool.install(|| { use rayon::prelude::*; - entries + paths .par_iter() .map(|entry| fragment_for(entry, origin, physical_corpus)) .collect::>() From fbf74cd5d93b84bd3dbd8669e3d64b3fdbb66e4e Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 30 Aug 2026 08:46:54 +0100 Subject: [PATCH 3/9] feat(federation): verify materialised parent corpora Signed-off-by: Tom Ballard --- docs/cli.md | 38 + rust/decided-mcp/tests/docs_contract.rs | 1 + rust/decided/tests/cli.rs | 84 ++ rust/rac-engine/src/cli.rs | 98 +- rust/rac-engine/src/commands.rs | 21 + rust/rac-engine/src/federation.rs | 1351 ++++++++++++++++++++ rust/rac-engine/src/lib.rs | 2 + rust/rac-engine/src/scaffold.rs | 19 +- rust/rac-engine/tests/federation_loader.rs | 265 ++++ 9 files changed, 1863 insertions(+), 16 deletions(-) create mode 100644 rust/rac-engine/src/federation.rs create mode 100644 rust/rac-engine/tests/federation_loader.rs diff --git a/docs/cli.md b/docs/cli.md index 7aad7df7..e8a54236 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -34,6 +34,44 @@ These apply across every command. --- +## corpus digest + +Calculate the canonical pin for a parent corpus that is already materialised +on disk. The command is read-only: it does not clone, fetch, update, write, or +repin the parent. + +```bash +decided corpus digest --root vendor/standards --corpus decisions +``` + +`--root` is the parent repository root and bounds configuration discovery to +exactly `/.decided/config.yaml`; the command never inherits a config from +an ancestor. `--corpus` is a relative directory below that root. The config +must declare an explicit valid `corpus.source`. On success stdout is exactly a +full lowercase pin followed by a newline: + +```text +sha256:899d5cdfa52b90a157b018dceb20f4f2901e0d56c91b089c12286c0b8b7b3325 +``` + +Digest version 1 hashes the fixed domain bytes +`asdecided-corpus-digest-v1\0`, then length-framed records. Each record is a +one-byte tag, an unsigned 64-bit big-endian byte length, and the raw payload: + +1. tag `0x01`: parent `corpus.source` UTF-8 bytes; +2. tag `0x02`: exact governing `.decided/config.yaml` bytes; then +3. for every discovered Markdown file in corpus-relative POSIX UTF-8 path + order, tag `0x03` for the path bytes and tag `0x04` for its exact content + bytes. + +Checkout location, timestamps, filesystem iteration order, hidden paths, and +non-`.md` files do not enter the digest. Absolute or `..` corpus paths, path +escape, and traversed symlinks are rejected with stable `parent-corpus-*` +errors. Exit `0` means the digest was calculated; exit `1` means the bounded +materialisation could not be safely snapshotted. + +--- + ## validate Validate an artifact — or every artifact in a directory — for structural and diff --git a/rust/decided-mcp/tests/docs_contract.rs b/rust/decided-mcp/tests/docs_contract.rs index 1f80b58e..55d373b4 100644 --- a/rust/decided-mcp/tests/docs_contract.rs +++ b/rust/decided-mcp/tests/docs_contract.rs @@ -54,6 +54,7 @@ const SUPPORTED_DECIDED_COMMANDS: &[&str] = &[ "retrieve", "sentry", "herald", + "corpus", ]; fn documented_decided_command(line: &str) -> Option<&str> { diff --git a/rust/decided/tests/cli.rs b/rust/decided/tests/cli.rs index 833c8b43..125dadf4 100644 --- a/rust/decided/tests/cli.rs +++ b/rust/decided/tests/cli.rs @@ -171,3 +171,87 @@ fn export_rejects_an_invalid_configured_corpus_source() { fs::remove_dir_all(root).expect("remove CLI smoke corpus"); } + +#[test] +fn corpus_digest_prints_the_canonical_read_only_pin() { + let root = scratch_root(); + fs::create_dir_all(root.join(".decided")).unwrap(); + fs::create_dir_all(root.join("decisions/sub")).unwrap(); + fs::write( + root.join(".decided/config.yaml"), + b"repository_key: STD\ncorpus:\n source: acme/standards\n", + ) + .unwrap(); + fs::write(root.join("decisions/a.md"), b"alpha\n").unwrap(); + fs::write(root.join("decisions/sub/b.md"), b"beta\r\n").unwrap(); + fs::write(root.join("decisions/ignored.MD"), b"ignored\n").unwrap(); + let before_config = fs::read(root.join(".decided/config.yaml")).unwrap(); + let before_a = fs::read(root.join("decisions/a.md")).unwrap(); + let root_text = root.to_string_lossy().into_owned(); + + let output = run(&[ + "corpus", + "digest", + "--root", + &root_text, + "--corpus", + "decisions", + ]); + assert!( + output.status.success(), + "stdout={}, stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + output.stdout, + b"sha256:899d5cdfa52b90a157b018dceb20f4f2901e0d56c91b089c12286c0b8b7b3325\n" + ); + assert!(output.stderr.is_empty()); + assert_eq!(fs::read(root.join(".decided/config.yaml")).unwrap(), before_config); + assert_eq!(fs::read(root.join("decisions/a.md")).unwrap(), before_a); + + fs::remove_dir_all(root).expect("remove CLI digest corpus"); +} + +#[test] +fn corpus_digest_bounds_config_and_rejects_escaping_corpus_paths() { + let root = scratch_root(); + fs::create_dir_all(root.join("parent/decisions")).unwrap(); + fs::write(root.join("parent/decisions/a.md"), b"alpha\n").unwrap(); + fs::create_dir_all(root.join(".decided")).unwrap(); + fs::write( + root.join(".decided/config.yaml"), + b"repository_key: CHILD\ncorpus:\n source: acme/child\n", + ) + .unwrap(); + let parent = root.join("parent").to_string_lossy().into_owned(); + + let missing = run(&[ + "corpus", + "digest", + "--root", + &parent, + "--corpus", + "decisions", + ]); + assert_eq!(missing.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&missing.stderr).contains("parent-corpus-config-missing") + ); + + let escaping = run(&[ + "corpus", + "digest", + "--root", + &parent, + "--corpus", + "../decisions", + ]); + assert_eq!(escaping.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&escaping.stderr).contains("parent-corpus-path-escape") + ); + + fs::remove_dir_all(root).expect("remove CLI digest corpus"); +} diff --git a/rust/rac-engine/src/cli.rs b/rust/rac-engine/src/cli.rs index 08c99fb3..6bc6c12c 100644 --- a/rust/rac-engine/src/cli.rs +++ b/rust/rac-engine/src/cli.rs @@ -5,16 +5,16 @@ //! (decision 9) — stdout stays byte-identical (empty on errors). use crate::commands::{ - cmd_coverage, cmd_decisions_for, cmd_diagnose, cmd_diff, cmd_doctor, cmd_eval, cmd_export, - cmd_find, cmd_gate, cmd_herald, cmd_hook, cmd_improve, cmd_index, cmd_init, cmd_inspect, - cmd_mcp_stats, cmd_migrate, cmd_new, cmd_portfolio, cmd_quickstart, cmd_relationships, - cmd_rename, cmd_resolve, cmd_retrieve, cmd_review, cmd_schema, cmd_sentry, cmd_skill, - cmd_stats, cmd_telemetry, cmd_templates, cmd_usage, cmd_validate, CoverageArgs, - DecisionsForArgs, DiagnoseArgs, DiffArgs, DoctorArgs, EvalArgs, ExportArgs, FindArgs, - GateArgs, HeraldArgs, HookArgs, ImproveArgs, IndexArgs, InitArgs, InspectArgs, McpStatsArgs, - MigrateArgs, NewArgs, PortfolioArgs, QuickstartArgs, RelationshipsArgs, RenameArgs, - ResolveArgs, RetrieveArgs, ReviewArgs, SchemaArgs, SentryArgs, SkillArgs, StatsArgs, - TelemetryArgs, TemplatesArgs, UsageArgs, ValidateArgs, WatchkeeperArgs, + cmd_corpus_digest, cmd_coverage, cmd_decisions_for, cmd_diagnose, cmd_diff, cmd_doctor, + cmd_eval, cmd_export, cmd_find, cmd_gate, cmd_herald, cmd_hook, cmd_improve, cmd_index, + cmd_init, cmd_inspect, cmd_mcp_stats, cmd_migrate, cmd_new, cmd_portfolio, cmd_quickstart, + cmd_relationships, cmd_rename, cmd_resolve, cmd_retrieve, cmd_review, cmd_schema, cmd_sentry, + cmd_skill, cmd_stats, cmd_telemetry, cmd_templates, cmd_usage, cmd_validate, + CorpusDigestArgs, CoverageArgs, DecisionsForArgs, DiagnoseArgs, DiffArgs, DoctorArgs, EvalArgs, + ExportArgs, FindArgs, GateArgs, HeraldArgs, HookArgs, ImproveArgs, IndexArgs, InitArgs, + InspectArgs, McpStatsArgs, MigrateArgs, NewArgs, PortfolioArgs, QuickstartArgs, + RelationshipsArgs, RenameArgs, ResolveArgs, RetrieveArgs, ReviewArgs, SchemaArgs, SentryArgs, + SkillArgs, StatsArgs, TelemetryArgs, TemplatesArgs, UsageArgs, ValidateArgs, WatchkeeperArgs, }; use crate::commands::cmd_watchkeeper; use crate::output::rac_version; @@ -156,7 +156,10 @@ fn run_dispatch(args: &[String]) -> u8 { // Native-only additions dispatch but are deliberately NOT in SUBCOMMANDS: // the retired Python oracle's `invalid choice` bytes remain pinned by the // bounded compatibility suite. - if !matches!(first.as_str(), "retrieve" | "sentry" | "herald" | "diagnose") + if !matches!( + first.as_str(), + "retrieve" | "sentry" | "herald" | "diagnose" | "corpus" + ) && !SUBCOMMANDS.contains(&first.as_str()) { return argparse_error("decided", &invalid_choice_message(first)); @@ -232,6 +235,7 @@ fn run_dispatch(args: &[String]) -> u8 { "quickstart" => run_quickstart(&rest), "rename" => run_rename(&rest), "migrate" => run_migrate(&rest), + "corpus" => run_corpus(&rest), other => { eprintln!("decided-rs: subcommand '{other}' is not yet implemented"); 2 @@ -278,6 +282,78 @@ fn take_opt_value( } } +fn run_corpus(rest: &[&String]) -> u8 { + let prog = "decided corpus"; + let mut action: Option = None; + let mut root: Option = None; + let mut corpus: Option = None; + let mut extras: Vec = Vec::new(); + let mut positional_only = false; + + let mut i = 0; + while i < rest.len() { + let arg = rest[i].as_str(); + if positional_only || arg == "-" || !arg.starts_with('-') { + if action.is_none() { + if arg != "digest" { + return argparse_error( + prog, + &format!( + "argument action: invalid choice: '{arg}' (choose from 'digest')" + ), + ); + } + action = Some(arg.to_string()); + } else { + extras.push(arg.to_string()); + } + i += 1; + continue; + } + match arg { + "--" => positional_only = true, + other if other == "--root" || other.starts_with("--root=") => { + match take_opt_value(prog, "--root", other, rest, &mut i) { + Ok(value) => root = Some(value), + Err(code) => return code, + } + } + other if other == "--corpus" || other.starts_with("--corpus=") => { + match take_opt_value(prog, "--corpus", other, rest, &mut i) { + Ok(value) => corpus = Some(value), + Err(code) => return code, + } + } + other => extras.push(other.to_string()), + } + i += 1; + } + + if action.is_none() { + return argparse_error(prog, "the following arguments are required: action"); + } + if root.is_none() || corpus.is_none() { + let missing = match (root.is_none(), corpus.is_none()) { + (true, true) => "--root, --corpus", + (true, false) => "--root", + (false, true) => "--corpus", + (false, false) => unreachable!(), + }; + return argparse_error( + prog, + &format!("the following arguments are required: {missing}"), + ); + } + if !extras.is_empty() { + return unrecognized(&extras); + } + + cmd_corpus_digest(&CorpusDigestArgs { + root: root.expect("checked above"), + corpus: corpus.expect("checked above"), + }) as u8 +} + fn run_validate(rest: &[&String]) -> u8 { let prog = "decided validate"; let mut file: Option = None; diff --git a/rust/rac-engine/src/commands.rs b/rust/rac-engine/src/commands.rs index b7244cd6..94468d27 100644 --- a/rust/rac-engine/src/commands.rs +++ b/rust/rac-engine/src/commands.rs @@ -2244,6 +2244,27 @@ pub fn cmd_rename(args: &RenameArgs) -> i32 { EXIT_OK } +pub struct CorpusDigestArgs { + pub root: String, + pub corpus: String, +} + +/// Read-only operator calculation for the canonical parent corpus pin. The +/// implementation consumes only local bytes below `root` and cannot write, +/// fetch, refresh, or repin anything. +pub fn cmd_corpus_digest(args: &CorpusDigestArgs) -> i32 { + match crate::federation::calculate_parent_digest(&args.root, &args.corpus) { + Ok(result) => { + emit(result.digest); + EXIT_OK + } + Err(error) => { + eprintln!("decided: {error}"); + EXIT_VALIDATION_FAILED + } + } +} + pub struct TelemetryArgs { /// Validated positional choice; argparse default is `status`. pub action: String, diff --git a/rust/rac-engine/src/federation.rs b/rust/rac-engine/src/federation.rs new file mode 100644 index 00000000..c71ad2f5 --- /dev/null +++ b/rust/rac-engine/src/federation.rs @@ -0,0 +1,1351 @@ +//! Verified, offline parent-corpus materialisation (ADR-133 through ADR-135). +//! +//! This module owns only the declaration and byte-snapshot boundary. It does +//! not compose artifacts, resolve relationships, or give any read consumer a +//! second directory-overlay path. A successful [`verify_parent`] call returns +//! the exact config and Markdown bytes which were hashed, so later stages can +//! parse the verified snapshot without re-reading mutable parent files. + +use serde::Deserialize; +use std::fmt; +use std::path::{Component, Path, PathBuf}; + +use crate::markdown::consumed_events; +use crate::sha256::Sha256; + +pub const MANIFEST_RELATIVE_PATH: &str = ".decided/corpus.md"; +pub const CONFIG_RELATIVE_PATH: &str = ".decided/config.yaml"; +pub const DIGEST_PREFIX: &str = "sha256:"; + +/// Fixed domain bytes for parent-corpus digest version 1. +/// +/// The complete v1 preimage is: +/// +/// ```text +/// "asdecided-corpus-digest-v1\0" +/// frame(0x01, source UTF-8) +/// frame(0x02, raw .decided/config.yaml bytes) +/// for each corpus-relative path in UTF-8 byte order: +/// frame(0x03, path UTF-8) +/// frame(0x04, raw file bytes) +/// ``` +/// +/// A frame is its one-byte tag, an unsigned 64-bit big-endian payload length, +/// and the payload. Tags plus explicit lengths make every tuple boundary +/// unambiguous; locations, metadata, and timestamps never enter the preimage. +pub const DIGEST_V1_DOMAIN: &[u8] = b"asdecided-corpus-digest-v1\0"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParentCorpusErrorCode { + MalformedManifest, + MultipleParents, + MaterialisationMissing, + ParentCorpusMissing, + ParentConfigMissing, + ChildConfigMissing, + InvalidConfig, + ChildSourceMissing, + ParentSourceMissing, + SourceMismatch, + SourceCollision, + PathEscape, + SymlinkTraversal, + TransitiveInheritance, + SnapshotFailed, + DigestMismatch, +} + +impl ParentCorpusErrorCode { + pub const fn as_str(self) -> &'static str { + match self { + Self::MalformedManifest => "parent-corpus-malformed-manifest", + Self::MultipleParents => "parent-corpus-multiple-parents", + Self::MaterialisationMissing => "parent-corpus-materialisation-missing", + Self::ParentCorpusMissing => "parent-corpus-missing", + Self::ParentConfigMissing => "parent-corpus-config-missing", + Self::ChildConfigMissing => "parent-corpus-child-config-missing", + Self::InvalidConfig => "parent-corpus-config-invalid", + Self::ChildSourceMissing => "parent-corpus-child-source-missing", + Self::ParentSourceMissing => "parent-corpus-source-missing", + Self::SourceMismatch => "parent-corpus-source-mismatch", + Self::SourceCollision => "parent-corpus-source-collision", + Self::PathEscape => "parent-corpus-path-escape", + Self::SymlinkTraversal => "parent-corpus-symlink-traversal", + Self::TransitiveInheritance => "parent-corpus-transitive-inheritance", + Self::SnapshotFailed => "parent-corpus-snapshot-failed", + Self::DigestMismatch => "parent-corpus-digest-mismatch", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParentCorpusError { + pub code: ParentCorpusErrorCode, + pub message: String, + pub path: Option, +} + +impl ParentCorpusError { + fn new(code: ParentCorpusErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + path: None, + } + } + + fn at( + code: ParentCorpusErrorCode, + path: impl Into, + message: impl Into, + ) -> Self { + Self { + code, + message: message.into(), + path: Some(path.into()), + } + } + + pub const fn stable_code(&self) -> &'static str { + self.code.as_str() + } +} + +impl fmt::Display for ParentCorpusError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.stable_code(), self.message) + } +} + +impl std::error::Error for ParentCorpusError {} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawParentDeclaration { + version: u32, + alias: String, + source: String, + root: String, + corpus: String, + digest: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParentDeclaration { + pub version: u32, + pub alias: String, + pub source: String, + pub root: String, + pub corpus: String, + pub digest: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CorpusManifest { + pub path: PathBuf, + pub bytes: Vec, + pub inherits: ParentDeclaration, + /// Parsed but not interpreted here. Override resolution belongs to the + /// central composition layer; retaining the value prevents a second + /// Markdown section parser from drifting from this manifest boundary. + pub overrides: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotFile { + pub relative_path: String, + pub absolute_path: PathBuf, + pub bytes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParentDigest { + pub source: String, + pub config_path: PathBuf, + pub config_bytes: Vec, + pub corpus_root: PathBuf, + pub files: Vec, + /// Full `sha256:<64 lowercase hex>` value. + pub digest: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedParent { + pub manifest_path: PathBuf, + pub manifest_bytes: Vec, + pub declaration: ParentDeclaration, + pub child_repository_root: PathBuf, + pub child_source: String, + pub materialisation_root: PathBuf, + pub corpus_root: PathBuf, + pub config_path: PathBuf, + pub config_bytes: Vec, + pub files: Vec, + /// The verified, full `sha256:<64 lowercase hex>` pin. + pub digest: String, + /// Parsed but not interpreted by the materialisation boundary. + pub overrides: Option, +} + +impl VerifiedParent { + /// True when `path` resolves inside the read-only materialisation subtree. + /// Local walks use this predicate to prevent the same Markdown byte from + /// entering both the child and inherited layers. + pub fn contains_materialised_path(&self, path: &Path) -> bool { + canonical_or_absolute(path).is_some_and(|candidate| { + candidate == self.materialisation_root + || candidate.starts_with(&self.materialisation_root) + }) + } + + /// Remove entries that resolve beneath the verified parent subtree. + pub fn exclude_materialisation(&self, entries: Vec, path_of: F) -> Vec + where + F: Fn(&T) -> &Path, + { + entries + .into_iter() + .filter(|entry| !self.contains_materialised_path(path_of(entry))) + .collect() + } +} + +fn malformed(path: &Path, reason: impl Into) -> ParentCorpusError { + ParentCorpusError::at( + ParentCorpusErrorCode::MalformedManifest, + path, + format!( + "malformed federation manifest {}: {}", + path.display(), + reason.into() + ), + ) +} + +fn normalize_newlines(text: &str) -> String { + text.replace("\r\n", "\n").replace('\r', "\n") +} + +fn is_top_level_atx_h2(lines: &[&str], line: i64) -> bool { + if line < 0 { + return false; + } + let Some(raw) = lines.get(line as usize) else { + return false; + }; + let indent = raw.bytes().take_while(|byte| *byte == b' ').count(); + if indent > 3 { + return false; + } + let rest = &raw[indent..]; + rest.starts_with("## ") || rest.starts_with("##\t") +} + +fn is_top_level_heading(lines: &[&str], line: i64) -> bool { + if line < 0 { + return false; + } + let index = line as usize; + let Some(raw) = lines.get(index) else { + return false; + }; + let indent = raw.bytes().take_while(|byte| *byte == b' ').count(); + if indent > 3 { + return false; + } + let rest = &raw[indent..]; + if rest.starts_with('#') { + return true; + } + // Top-level Setext heading. Container-prefixed underlines do not match. + lines.get(index + 1).is_some_and(|underline| { + let indent = underline.bytes().take_while(|byte| *byte == b' ').count(); + if indent > 3 { + return false; + } + let underline = underline[indent..].trim_end(); + !underline.is_empty() + && (underline.bytes().all(|byte| byte == b'=') + || underline.bytes().all(|byte| byte == b'-')) + }) +} + +fn heading_sections(text: &str, name: &str) -> Vec<(usize, usize)> { + let events = consumed_events(text); + let lines: Vec<&str> = text.lines().collect(); + let mut sections = Vec::new(); + for (index, event) in events.iter().enumerate() { + if !event.heading + || event.tag != "h2" + || event.content != name + || !is_top_level_atx_h2(&lines, event.line) + { + continue; + } + let start = event.line.max(0) as usize + 1; + let end = events[index + 1..] + .iter() + .find(|next| { + next.heading + && next.line >= 0 + && matches!(next.tag, "h1" | "h2") + && is_top_level_heading(&lines, next.line) + }) + .map_or_else(|| text.lines().count(), |next| next.line as usize); + sections.push((start, end)); + } + sections +} + +fn reject_misspelled_operational_headings( + path: &Path, + text: &str, +) -> Result<(), ParentCorpusError> { + let lines: Vec<&str> = text.lines().collect(); + for event in consumed_events(text) { + if !event.heading || event.tag != "h2" || !is_top_level_atx_h2(&lines, event.line) { + continue; + } + if (event.content.eq_ignore_ascii_case("inherits") && event.content != "inherits") + || (event.content.eq_ignore_ascii_case("overrides") && event.content != "overrides") + { + return Err(malformed( + path, + format!( + "federation heading must use exact lowercase spelling: ## {}", + event.content.to_ascii_lowercase() + ), + )); + } + } + Ok(()) +} + +fn fence_open(line: &str) -> Option<(u8, usize, &str)> { + let indent = line.bytes().take_while(|byte| *byte == b' ').count(); + if indent > 3 { + return None; + } + let rest = &line[indent..]; + let marker = *rest.as_bytes().first()?; + if marker != b'`' && marker != b'~' { + return None; + } + let count = rest.bytes().take_while(|byte| *byte == marker).count(); + if count < 3 { + return None; + } + Some((marker, count, rest[count..].trim())) +} + +fn fence_close(line: &str, marker: u8, count: usize) -> bool { + let indent = line.bytes().take_while(|byte| *byte == b' ').count(); + if indent > 3 { + return false; + } + let rest = &line[indent..]; + let seen = rest.bytes().take_while(|byte| *byte == marker).count(); + seen >= count && rest[seen..].trim().is_empty() +} + +fn fenced_yaml_blocks( + path: &Path, + text: &str, + start: usize, + end: usize, +) -> Result, ParentCorpusError> { + let lines: Vec<&str> = text.lines().collect(); + let mut blocks = Vec::new(); + let mut index = start; + while index < end.min(lines.len()) { + let Some((marker, count, info)) = fence_open(lines[index]) else { + index += 1; + continue; + }; + let content_start = index + 1; + index += 1; + while index < end.min(lines.len()) && !fence_close(lines[index], marker, count) { + index += 1; + } + if index >= end.min(lines.len()) { + return Err(malformed(path, "unterminated fenced block")); + } + if info == "yaml" { + blocks.push(lines[content_start..index].join("\n")); + } + index += 1; + } + Ok(blocks) +} + +fn valid_alias(alias: &str) -> bool { + let bytes = alias.as_bytes(); + !bytes.is_empty() + && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit()) + && (bytes[bytes.len() - 1].is_ascii_lowercase() || bytes[bytes.len() - 1].is_ascii_digit()) + && bytes.iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.') + }) +} + +fn validate_relative_path(value: &str, field: &str) -> Result<(), String> { + let path = Path::new(value); + if value.is_empty() { + return Err(format!("'{field}' must be a non-empty relative path")); + } + if path.is_absolute() { + return Err(format!("'{field}' must not be absolute")); + } + for component in path.components() { + match component { + Component::ParentDir => return Err(format!("'{field}' must not contain '..'")), + Component::Prefix(_) | Component::RootDir => { + return Err(format!("'{field}' must be repository-relative")) + } + Component::Normal(_) | Component::CurDir => {} + } + } + Ok(()) +} + +fn parse_declaration(path: &Path, yaml: &str) -> Result { + let value: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(|error| { + malformed( + path, + format!("## inherits YAML must be one mapping: {error}"), + ) + })?; + if value.as_sequence().is_some_and(|parents| parents.len() > 1) { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::MultipleParents, + path, + "the first federation increment accepts exactly one direct parent mapping", + )); + } + if !value.is_mapping() { + return Err(malformed(path, "## inherits YAML must be one mapping")); + } + let raw: RawParentDeclaration = serde_yaml::from_value(value).map_err(|error| { + malformed( + path, + format!("## inherits YAML must be one mapping: {error}"), + ) + })?; + if raw.version != 1 { + return Err(malformed( + path, + format!("unsupported inheritance manifest version: {}", raw.version), + )); + } + if !valid_alias(&raw.alias) { + return Err(malformed( + path, + "'alias' must be a lowercase local name containing only letters, digits, '.', '_', or '-'", + )); + } + if !crate::scaffold::valid_corpus_source(&raw.source) { + return Err(malformed( + path, + "'source' must be a lower-case slash-namespaced corpus identity", + )); + } + validate_relative_path(&raw.root, "root") + .map_err(|reason| ParentCorpusError::at(ParentCorpusErrorCode::PathEscape, path, reason))?; + validate_relative_path(&raw.corpus, "corpus") + .map_err(|reason| ParentCorpusError::at(ParentCorpusErrorCode::PathEscape, path, reason))?; + let hash = raw.digest.strip_prefix(DIGEST_PREFIX).unwrap_or(""); + if hash.len() != 64 + || !hash + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(malformed( + path, + "'digest' must be sha256: followed by exactly 64 lowercase hexadecimal characters", + )); + } + Ok(ParentDeclaration { + version: raw.version, + alias: raw.alias, + source: raw.source, + root: raw.root, + corpus: raw.corpus, + digest: raw.digest, + }) +} + +/// Parse the fixed operational manifest. Absence means no parent; presence is +/// strict and cannot be partially interpreted. +pub fn load_manifest(repository_root: &Path) -> Result, ParentCorpusError> { + let path = repository_root.join(MANIFEST_RELATIVE_PATH); + let metadata = match std::fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::MalformedManifest, + &path, + format!( + "cannot inspect federation manifest {}: {error}", + path.display() + ), + )); + } + }; + ensure_no_symlink_components(repository_root, &path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SymlinkTraversal, + &path, + format!( + "federation manifest must be a regular file: {}", + path.display() + ), + )); + } + let bytes = std::fs::read(&path).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::MalformedManifest, + &path, + format!( + "cannot read federation manifest {}: {error}", + path.display() + ), + ) + })?; + let raw = std::str::from_utf8(&bytes) + .map_err(|_| malformed(&path, "manifest must be valid UTF-8"))?; + let text = normalize_newlines(raw); + reject_misspelled_operational_headings(&path, &text)?; + + let inherits = heading_sections(&text, "inherits"); + if inherits.len() > 1 { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::MultipleParents, + &path, + "the first federation increment accepts exactly one ## inherits declaration", + )); + } + let Some((start, end)) = inherits.first().copied() else { + return Err(malformed( + &path, + "missing exact lowercase heading '## inherits'", + )); + }; + let blocks = fenced_yaml_blocks(&path, &text, start, end)?; + if blocks.len() > 1 { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::MultipleParents, + &path, + "## inherits must contain exactly one fenced YAML mapping", + )); + } + let Some(yaml) = blocks.first() else { + return Err(malformed( + &path, + "## inherits must contain exactly one fenced yaml block", + )); + }; + let inherits = parse_declaration(&path, yaml)?; + + let override_sections = heading_sections(&text, "overrides"); + if override_sections.len() > 1 { + return Err(malformed(&path, "## overrides may appear at most once")); + } + let overrides = if let Some((start, end)) = override_sections.first().copied() { + let blocks = fenced_yaml_blocks(&path, &text, start, end)?; + if blocks.len() != 1 { + return Err(malformed( + &path, + "## overrides must contain exactly one fenced yaml block", + )); + } + let value: serde_yaml::Value = serde_yaml::from_str(&blocks[0]).map_err(|error| { + malformed( + &path, + format!("## overrides YAML must be one mapping: {error}"), + ) + })?; + if !value.is_mapping() { + return Err(malformed(&path, "## overrides YAML must be one mapping")); + } + Some(value) + } else { + None + }; + + Ok(Some(CorpusManifest { + path, + bytes, + inherits, + overrides, + })) +} + +fn lexical_components(path: &Path) -> Result, ()> { + let mut out = Vec::new(); + for component in path.components() { + match component { + Component::Normal(value) => out.push(value), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => return Err(()), + } + } + Ok(out) +} + +fn checked_relative_join( + boundary: &Path, + relative: &str, + field: &str, +) -> Result { + let components = lexical_components(Path::new(relative)).map_err(|_| { + ParentCorpusError::new( + ParentCorpusErrorCode::PathEscape, + format!("parent {field} must be a relative path without '..': {relative}"), + ) + })?; + if components.is_empty() && relative != "." { + return Err(ParentCorpusError::new( + ParentCorpusErrorCode::PathEscape, + format!("parent {field} must be a non-empty relative path"), + )); + } + let mut joined = boundary.to_path_buf(); + for component in components { + joined.push(component); + } + Ok(joined) +} + +fn ensure_no_symlink_components(boundary: &Path, target: &Path) -> Result<(), ParentCorpusError> { + let relative = target.strip_prefix(boundary).map_err(|_| { + ParentCorpusError::at( + ParentCorpusErrorCode::PathEscape, + target, + format!( + "parent path escapes repository boundary: {}", + target.display() + ), + ) + })?; + let mut current = boundary.to_path_buf(); + for component in relative.components() { + let Component::Normal(value) = component else { + continue; + }; + current.push(value); + match std::fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SymlinkTraversal, + ¤t, + format!("parent path traverses a symlink: {}", current.display()), + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + ¤t, + format!("cannot inspect parent path {}: {error}", current.display()), + )); + } + } + } + Ok(()) +} + +fn canonical_confined( + boundary: &Path, + candidate: &Path, + missing_code: ParentCorpusErrorCode, + kind: &str, +) -> Result { + ensure_no_symlink_components(boundary, candidate)?; + let canonical = std::fs::canonicalize(candidate).map_err(|error| { + ParentCorpusError::at( + missing_code, + candidate, + format!( + "parent {kind} is unavailable at {}: {error}", + candidate.display() + ), + ) + })?; + if canonical != boundary && !canonical.starts_with(boundary) { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::PathEscape, + candidate, + format!( + "parent {kind} escapes repository boundary: {}", + candidate.display() + ), + )); + } + Ok(canonical) +} + +fn canonical_or_absolute(path: &Path) -> Option { + if let Ok(canonical) = std::fs::canonicalize(path) { + return Some(canonical); + } + if path.is_absolute() { + Some(path.to_path_buf()) + } else { + std::env::current_dir().ok().map(|cwd| cwd.join(path)) + } +} + +fn snapshot_directory( + corpus_root: &Path, + directory: &Path, + components: &mut Vec, + output: &mut Vec, +) -> Result<(), ParentCorpusError> { + let entries = std::fs::read_dir(directory).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + directory, + format!( + "cannot read parent corpus directory {}: {error}", + directory.display() + ), + ) + })?; + let mut entries = entries.collect::, _>>().map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + directory, + format!( + "cannot enumerate parent corpus directory {}: {error}", + directory.display() + ), + ) + })?; + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + // Match the engine's corpus discovery boundary: paths which cannot be + // represented as UTF-8 are not discovered as artifacts and therefore + // do not enter the digest. + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + if name.starts_with('.') { + continue; + } + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + &path, + format!( + "cannot inspect parent corpus entry {}: {error}", + path.display() + ), + ) + })?; + if metadata.file_type().is_symlink() { + if name.ends_with(".md") { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SymlinkTraversal, + &path, + format!( + "parent Markdown artifact must not be a symlink: {}", + path.display() + ), + )); + } + // A symlinked directory is never traversed and therefore cannot + // contribute bytes to the snapshot. + continue; + } + components.push(name.clone()); + if metadata.is_dir() { + snapshot_directory(corpus_root, &path, components, output)?; + } else if name.ends_with(".md") { + if !metadata.is_file() { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + &path, + format!( + "parent Markdown artifact is not a regular file: {}", + path.display() + ), + )); + } + let canonical = std::fs::canonicalize(&path).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + &path, + format!("cannot resolve parent artifact {}: {error}", path.display()), + ) + })?; + if !canonical.starts_with(corpus_root) { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::PathEscape, + &path, + format!("parent artifact escapes corpus root: {}", path.display()), + )); + } + let bytes = std::fs::read(&canonical).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + &path, + format!("cannot read parent artifact {}: {error}", path.display()), + ) + })?; + output.push(SnapshotFile { + relative_path: components.join("/"), + absolute_path: canonical, + bytes, + }); + } + components.pop(); + } + Ok(()) +} + +fn write_frame(hasher: &mut Sha256, tag: u8, payload: &[u8]) { + hasher.update(&[tag]); + hasher.update(&(payload.len() as u64).to_be_bytes()); + hasher.update(payload); +} + +/// Pure digest over an already captured byte snapshot. File order at the API +/// boundary is ignored; paths are always folded in canonical UTF-8 order. +pub fn digest_snapshot(source: &str, config_bytes: &[u8], files: &[SnapshotFile]) -> String { + let mut hasher = Sha256::new(); + hasher.update(DIGEST_V1_DOMAIN); + write_frame(&mut hasher, 0x01, source.as_bytes()); + write_frame(&mut hasher, 0x02, config_bytes); + let mut files: Vec<&SnapshotFile> = files.iter().collect(); + files.sort_by_key(|file| file.relative_path.as_str()); + for file in files { + write_frame(&mut hasher, 0x03, file.relative_path.as_bytes()); + write_frame(&mut hasher, 0x04, &file.bytes); + } + format!("{DIGEST_PREFIX}{}", hasher.hexdigest()) +} + +fn read_bounded_source(config_path: &Path) -> Result<(String, Vec), ParentCorpusError> { + let bytes = std::fs::read(config_path).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::InvalidConfig, + config_path, + format!( + "cannot read parent config {}: {error}", + config_path.display() + ), + ) + })?; + let text = std::str::from_utf8(&bytes).map_err(|_| { + ParentCorpusError::at( + ParentCorpusErrorCode::InvalidConfig, + config_path, + format!( + "parent config must be valid UTF-8: {}", + config_path.display() + ), + ) + })?; + // Parse the exact file rather than ancestor-walking: the materialisation + // root is the provenance boundary chosen by the manifest/operator. + let identity = crate::scaffold::parse_identity_config(&config_path.to_string_lossy(), text) + .map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::InvalidConfig, + config_path, + error.message().to_string(), + ) + })?; + let source = identity.corpus_source.ok_or_else(|| { + ParentCorpusError::at( + ParentCorpusErrorCode::ParentSourceMissing, + config_path, + format!( + "parent config {} must declare an explicit corpus.source", + config_path.display() + ), + ) + })?; + Ok((source, bytes)) +} + +fn snapshot_at(root: &Path, corpus_relative: &str) -> Result { + let root_input = if root.is_absolute() { + root.to_path_buf() + } else { + std::env::current_dir() + .map_err(|error| { + ParentCorpusError::new( + ParentCorpusErrorCode::SnapshotFailed, + format!("cannot determine current directory: {error}"), + ) + })? + .join(root) + }; + if std::fs::symlink_metadata(&root_input) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SymlinkTraversal, + &root_input, + format!( + "parent materialisation root must not be a symlink: {}", + root_input.display() + ), + )); + } + let root = std::fs::canonicalize(&root_input).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::MaterialisationMissing, + &root_input, + format!( + "parent materialisation is unavailable at {}: {error}", + root_input.display() + ), + ) + })?; + if !root.is_dir() { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::MaterialisationMissing, + &root, + format!( + "parent materialisation is not a directory: {}", + root.display() + ), + )); + } + validate_relative_path(corpus_relative, "corpus") + .map_err(|reason| ParentCorpusError::new(ParentCorpusErrorCode::PathEscape, reason))?; + + let config_candidate = root.join(CONFIG_RELATIVE_PATH); + let config_path = canonical_confined( + &root, + &config_candidate, + ParentCorpusErrorCode::ParentConfigMissing, + "config", + )?; + if !config_path.is_file() { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::ParentConfigMissing, + &config_path, + format!( + "parent config is not a regular file: {}", + config_path.display() + ), + )); + } + let corpus_candidate = checked_relative_join(&root, corpus_relative, "corpus")?; + let corpus_root = canonical_confined( + &root, + &corpus_candidate, + ParentCorpusErrorCode::ParentCorpusMissing, + "corpus", + )?; + if !corpus_root.is_dir() { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::ParentCorpusMissing, + &corpus_root, + format!( + "parent corpus is not a directory: {}", + corpus_root.display() + ), + )); + } + + let (source, config_bytes) = read_bounded_source(&config_path)?; + let mut files = Vec::new(); + snapshot_directory(&corpus_root, &corpus_root, &mut Vec::new(), &mut files)?; + // Directory recursion is already component-sorted. Pin this explicitly so + // a future traversal refactor cannot alter the digest contract. + files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + let digest = digest_snapshot(&source, &config_bytes, &files); + Ok(ParentDigest { + source, + config_path, + config_bytes, + corpus_root, + files, + digest, + }) +} + +/// Calculate a parent pin from a bounded materialisation root and a relative +/// corpus directory. This is the pure operator surface behind +/// `decided corpus digest`; it never writes, fetches, or updates a pin. +pub fn calculate_parent_digest( + root: impl AsRef, + corpus_relative: &str, +) -> Result { + snapshot_at(root.as_ref(), corpus_relative) +} + +fn exact_config_source( + config_path: &Path, + missing_config: ParentCorpusErrorCode, + missing_source: ParentCorpusErrorCode, + owner: &str, +) -> Result { + if !config_path.is_file() { + return Err(ParentCorpusError::at( + missing_config, + config_path, + format!("{owner} config is missing: {}", config_path.display()), + )); + } + let identity = + crate::scaffold::read_identity_config(&config_path.to_string_lossy()).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::InvalidConfig, + config_path, + error.message().to_string(), + ) + })?; + identity.corpus_source.ok_or_else(|| { + ParentCorpusError::at( + missing_source, + config_path, + format!("{owner} config must declare an explicit corpus.source"), + ) + }) +} + +/// Verify the optional direct parent rooted at `child_repository_root`. +/// Nothing from the parent is returned until containment, topology, source, +/// and digest checks have all succeeded. +pub fn verify_parent( + child_repository_root: impl AsRef, +) -> Result, ParentCorpusError> { + let input = child_repository_root.as_ref(); + let child_root = std::fs::canonicalize(input).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::PathEscape, + input, + format!( + "child repository root is unavailable at {}: {error}", + input.display() + ), + ) + })?; + if !child_root.is_dir() { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::PathEscape, + &child_root, + format!( + "child repository root is not a directory: {}", + child_root.display() + ), + )); + } + let Some(manifest) = load_manifest(&child_root)? else { + return Ok(None); + }; + + let child_config = child_root.join(CONFIG_RELATIVE_PATH); + ensure_no_symlink_components(&child_root, &child_config)?; + let child_source = exact_config_source( + &child_config, + ParentCorpusErrorCode::ChildConfigMissing, + ParentCorpusErrorCode::ChildSourceMissing, + "child", + )?; + + let materialisation_candidate = + checked_relative_join(&child_root, &manifest.inherits.root, "root")?; + let materialisation_root = canonical_confined( + &child_root, + &materialisation_candidate, + ParentCorpusErrorCode::MaterialisationMissing, + "materialisation", + )?; + if materialisation_root == child_root || !materialisation_root.is_dir() { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::PathEscape, + &materialisation_candidate, + "parent materialisation root must be a directory strictly inside the child repository", + )); + } + + let parent_manifest_path = materialisation_root.join(MANIFEST_RELATIVE_PATH); + ensure_no_symlink_components(&materialisation_root, &parent_manifest_path)?; + if load_manifest(&materialisation_root)?.is_some() { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::TransitiveInheritance, + &parent_manifest_path, + format!( + "parent source '{}' declares its own inheritance; transitive federation is not supported", + manifest.inherits.source + ), + )); + } + + let digest = snapshot_at(&materialisation_root, &manifest.inherits.corpus)?; + if digest.source != manifest.inherits.source { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SourceMismatch, + &digest.config_path, + format!( + "manifest source '{}' does not match parent corpus.source '{}'", + manifest.inherits.source, digest.source + ), + )); + } + if child_source == digest.source { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SourceCollision, + &manifest.path, + format!( + "child and parent must declare distinct corpus.source values; both are '{}'", + child_source + ), + )); + } + if digest.digest != manifest.inherits.digest { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::DigestMismatch, + &manifest.path, + format!( + "declared parent digest '{}' does not match verified digest '{}'", + manifest.inherits.digest, digest.digest + ), + )); + } + + Ok(Some(VerifiedParent { + manifest_path: manifest.path, + manifest_bytes: manifest.bytes, + declaration: manifest.inherits, + child_repository_root: child_root, + child_source, + materialisation_root, + corpus_root: digest.corpus_root, + config_path: digest.config_path, + config_bytes: digest.config_bytes, + files: digest.files, + digest: digest.digest, + overrides: manifest.overrides, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static COUNTER: AtomicUsize = AtomicUsize::new(0); + + fn scratch(name: &str) -> PathBuf { + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let root = std::env::temp_dir().join(format!( + "asdecided-federation-{name}-{}-{n}", + std::process::id() + )); + fs::create_dir_all(&root).unwrap(); + root + } + + fn write_parent(root: &Path) { + fs::create_dir_all(root.join(".decided")).unwrap(); + fs::create_dir_all(root.join("decisions/sub")).unwrap(); + fs::write( + root.join(".decided/config.yaml"), + b"repository_key: STD\ncorpus:\n source: acme/standards\n", + ) + .unwrap(); + fs::write(root.join("decisions/a.md"), b"alpha\n").unwrap(); + fs::write(root.join("decisions/sub/b.md"), b"beta\r\n").unwrap(); + fs::write(root.join("decisions/ignored.MD"), b"ignored\n").unwrap(); + fs::create_dir_all(root.join("decisions/.hidden")).unwrap(); + fs::write(root.join("decisions/.hidden/secret.md"), b"hidden\n").unwrap(); + } + + fn write_child(root: &Path, digest: &str) { + fs::create_dir_all(root.join(".decided")).unwrap(); + fs::write( + root.join(".decided/config.yaml"), + b"repository_key: APP\ncorpus:\n source: acme/app\n", + ) + .unwrap(); + fs::write( + root.join(".decided/corpus.md"), + format!( + "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: standards\nsource: acme/standards\nroot: vendor/standards\ncorpus: decisions\ndigest: {digest}\n```\n" + ), + ) + .unwrap(); + } + + #[test] + fn digest_v1_known_vector_and_exact_snapshot() { + let root = scratch("digest-vector"); + write_parent(&root); + let result = calculate_parent_digest(&root, "decisions").unwrap(); + assert_eq!(result.source, "acme/standards"); + assert_eq!( + result + .files + .iter() + .map(|file| file.relative_path.as_str()) + .collect::>(), + ["a.md", "sub/b.md"] + ); + assert_eq!(result.files[1].bytes, b"beta\r\n"); + assert_eq!( + result.digest, + "sha256:899d5cdfa52b90a157b018dceb20f4f2901e0d56c91b089c12286c0b8b7b3325" + ); + let mut reversed = result.files.clone(); + reversed.reverse(); + assert_eq!( + digest_snapshot(&result.source, &result.config_bytes, &reversed), + result.digest + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn manifest_requires_exact_lowercase_heading_and_one_mapping() { + let root = scratch("manifest"); + fs::create_dir_all(root.join(".decided")).unwrap(); + fs::write( + root.join(".decided/corpus.md"), + "# Corpus\n\n## Inherits\n\n```yaml\nversion: 1\n```\n", + ) + .unwrap(); + let error = load_manifest(&root).unwrap_err(); + assert_eq!(error.code, ParentCorpusErrorCode::MalformedManifest); + assert!(error.message.contains("exact lowercase")); + + fs::write( + root.join(".decided/corpus.md"), + "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\n```\n\n```yaml\nversion: 1\n```\n", + ) + .unwrap(); + assert_eq!( + load_manifest(&root).unwrap_err().code, + ParentCorpusErrorCode::MultipleParents + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn verifies_one_direct_parent_and_excludes_its_tree() { + let child = scratch("verify"); + let parent = child.join("vendor/standards"); + write_parent(&parent); + let pin = calculate_parent_digest(&parent, "decisions") + .unwrap() + .digest; + write_child(&child, &pin); + let verified = verify_parent(&child).unwrap().unwrap(); + assert_eq!(verified.digest, pin); + assert_eq!(verified.child_source, "acme/app"); + assert!(verified.contains_materialised_path(&parent.join("decisions/a.md"))); + assert!(!verified.contains_materialised_path(&child.join("local.md"))); + let entries = vec![child.join("local.md"), parent.join("decisions/a.md")]; + let retained = verified.exclude_materialisation(entries, |path| path.as_path()); + assert_eq!(retained, [child.join("local.md")]); + fs::remove_dir_all(child).unwrap(); + } + + #[test] + fn stale_pin_and_source_mismatch_are_distinct() { + let child = scratch("mismatch"); + let parent = child.join("vendor/standards"); + write_parent(&parent); + let pin = calculate_parent_digest(&parent, "decisions") + .unwrap() + .digest; + write_child(&child, &pin); + fs::write(parent.join("decisions/a.md"), b"changed\n").unwrap(); + assert_eq!( + verify_parent(&child).unwrap_err().code, + ParentCorpusErrorCode::DigestMismatch + ); + + write_child( + &child, + &calculate_parent_digest(&parent, "decisions") + .unwrap() + .digest, + ); + let manifest = fs::read_to_string(child.join(".decided/corpus.md")) + .unwrap() + .replace("source: acme/standards", "source: acme/other"); + fs::write(child.join(".decided/corpus.md"), manifest).unwrap(); + assert_eq!( + verify_parent(&child).unwrap_err().code, + ParentCorpusErrorCode::SourceMismatch + ); + fs::remove_dir_all(child).unwrap(); + } + + #[test] + fn transitive_parent_is_rejected_before_overlay() { + let child = scratch("transitive"); + let parent = child.join("vendor/standards"); + write_parent(&parent); + let pin = calculate_parent_digest(&parent, "decisions") + .unwrap() + .digest; + write_child(&child, &pin); + fs::write( + parent.join(".decided/corpus.md"), + "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: upstream\nsource: acme/upstream\nroot: vendor/upstream\ncorpus: decisions\ndigest: sha256:0000000000000000000000000000000000000000000000000000000000000000\n```\n", + ) + .unwrap(); + assert_eq!( + verify_parent(&child).unwrap_err().code, + ParentCorpusErrorCode::TransitiveInheritance + ); + fs::remove_dir_all(child).unwrap(); + } + + #[cfg(unix)] + #[test] + fn symlinked_materialisation_and_artifact_are_rejected() { + use std::os::unix::fs::symlink; + + let child = scratch("symlink-root"); + let outside = scratch("outside"); + write_parent(&outside); + fs::create_dir_all(child.join("vendor")).unwrap(); + symlink(&outside, child.join("vendor/standards")).unwrap(); + write_child( + &child, + &calculate_parent_digest(&outside, "decisions") + .unwrap() + .digest, + ); + assert_eq!( + verify_parent(&child).unwrap_err().code, + ParentCorpusErrorCode::SymlinkTraversal + ); + fs::remove_dir_all(&child).unwrap(); + fs::remove_dir_all(&outside).unwrap(); + + let root = scratch("symlink-file"); + write_parent(&root); + fs::write(root.join("target.md"), "target").unwrap(); + symlink("../target.md", root.join("decisions/link.md")).unwrap(); + assert_eq!( + calculate_parent_digest(&root, "decisions") + .unwrap_err() + .code, + ParentCorpusErrorCode::SymlinkTraversal + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn no_manifest_is_inert() { + let root = scratch("no-manifest"); + assert!(verify_parent(&root).unwrap().is_none()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/rust/rac-engine/src/lib.rs b/rust/rac-engine/src/lib.rs index 308f3afa..47fb079b 100644 --- a/rust/rac-engine/src/lib.rs +++ b/rust/rac-engine/src/lib.rs @@ -34,6 +34,7 @@ //! - `watchkeeper`: the watchkeeper report and review verdict. //! - `output`: human/JSON/SARIF renderers per command. //! - `commands`: CLI command entry points (argv already parsed). +//! - `federation`: strict offline parent-manifest and byte-snapshot verification. //! - `cli`: argv parsing and exit codes matching the oracle's argparse //! surface (PORT-CONTRACT.d/01). @@ -76,6 +77,7 @@ pub mod sentry; pub mod doctor; pub mod mdhtml; pub mod export; +pub mod federation; pub mod portal; pub mod agent_rules; pub mod okf; diff --git a/rust/rac-engine/src/scaffold.rs b/rust/rac-engine/src/scaffold.rs index b267eb66..f9750039 100644 --- a/rust/rac-engine/src/scaffold.rs +++ b/rust/rac-engine/src/scaffold.rs @@ -209,7 +209,7 @@ fn valid_repository_key(key: &str) -> bool { /// A configured corpus source is a stable, lower-case, slash-namespaced /// identity such as `asdecided/core` (ADR-135). Each segment starts and ends /// with an ASCII letter or digit; `.`, `_`, and `-` are allowed internally. -fn valid_corpus_source(source: &str) -> bool { +pub(crate) fn valid_corpus_source(source: &str) -> bool { fn valid_segment(segment: &str) -> bool { let bytes = segment.as_bytes(); let endpoint = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit(); @@ -263,12 +263,13 @@ fn yaml_get<'a>( /// Read the two independent identity values from one config. Unknown sections /// remain additive; the recognised identity fields are strict so an invalid /// configured source can never silently fall through to another namespace. -fn read_identity_config(config_path: &str) -> Result { +pub(crate) fn parse_identity_config( + config_path: &str, + text: &str, +) -> Result { use crate::frontmatter::Yaml; - let text = std::fs::read_to_string(config_path) - .map_err(|e| malformed_config(config_path, &format!("invalid YAML: {e}")))?; - let data = crate::frontmatter::yaml_load_config(&text) + let data = crate::frontmatter::yaml_load_config(text) .map_err(|problem| malformed_config(config_path, &format!("invalid YAML: {problem}")))?; let Yaml::Map(pairs) = data else { return Err(malformed_config( @@ -332,6 +333,14 @@ fn read_identity_config(config_path: &str) -> Result Result { + let text = std::fs::read_to_string(config_path) + .map_err(|e| malformed_config(config_path, &format!("invalid YAML: {e}")))?; + parse_identity_config(config_path, &text) +} + /// `_read_config(config_path)` — strict read of one config file: YAML must /// parse (the invalid-YAML reason embeds this engine's own problem text — /// the oracle embeds PyYAML's; stderr-only divergence class), the root must diff --git a/rust/rac-engine/tests/federation_loader.rs b/rust/rac-engine/tests/federation_loader.rs new file mode 100644 index 00000000..8c866dae --- /dev/null +++ b/rust/rac-engine/tests/federation_loader.rs @@ -0,0 +1,265 @@ +use rac_engine::federation::{ + calculate_parent_digest, load_manifest, verify_parent, ParentCorpusErrorCode, +}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static COUNTER: AtomicUsize = AtomicUsize::new(0); + +fn scratch(name: &str) -> PathBuf { + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let path = std::env::temp_dir().join(format!( + "asdecided-federation-integration-{name}-{}-{n}", + std::process::id() + )); + fs::create_dir_all(&path).unwrap(); + path +} + +fn parent_at(root: &Path, source: &str) { + fs::create_dir_all(root.join(".decided")).unwrap(); + fs::create_dir_all(root.join("decisions/nested")).unwrap(); + fs::write( + root.join(".decided/config.yaml"), + format!("repository_key: STD\ncorpus:\n source: {source}\n"), + ) + .unwrap(); + fs::write(root.join("decisions/one.md"), b"one\n").unwrap(); + fs::write(root.join("decisions/nested/two.md"), b"two\n").unwrap(); +} + +fn child_manifest(root: &Path, source: &str, pin: &str, parent_source: &str) { + fs::create_dir_all(root.join(".decided")).unwrap(); + fs::write( + root.join(".decided/config.yaml"), + format!("repository_key: APP\ncorpus:\n source: {source}\n"), + ) + .unwrap(); + fs::write( + root.join(".decided/corpus.md"), + format!( + "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: standards\nsource: {parent_source}\nroot: vendor/standards\ncorpus: decisions\ndigest: {pin}\n```\n\n## overrides\n\n```yaml\nversion: 1\nitems: []\n```\n" + ), + ) + .unwrap(); +} + +#[test] +fn clone_location_and_metadata_do_not_change_the_pin() { + let first = scratch("clone-a"); + let second = scratch("clone-b"); + parent_at(&first, "acme/standards"); + parent_at(&second, "acme/standards"); + + let first_pin = calculate_parent_digest(&first, "decisions").unwrap().digest; + let second_pin = calculate_parent_digest(&second, "decisions") + .unwrap() + .digest; + assert_eq!(first_pin, second_pin); + + fs::remove_dir_all(first).unwrap(); + fs::remove_dir_all(second).unwrap(); +} + +#[test] +fn config_and_markdown_bytes_are_both_pin_inputs() { + let root = scratch("pin-inputs"); + parent_at(&root, "acme/standards"); + let original = calculate_parent_digest(&root, "decisions").unwrap().digest; + + fs::write(root.join("decisions/one.md"), b"ONE\n").unwrap(); + let content_changed = calculate_parent_digest(&root, "decisions").unwrap().digest; + assert_ne!(original, content_changed); + + fs::write(root.join("decisions/one.md"), b"one\n").unwrap(); + fs::write( + root.join(".decided/config.yaml"), + b"repository_key: STD\ncorpus:\n source: acme/standards\n# reviewed\n", + ) + .unwrap(); + let config_changed = calculate_parent_digest(&root, "decisions").unwrap().digest; + assert_ne!(original, config_changed); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn missing_materialisation_and_missing_corpus_are_distinct() { + let child = scratch("missing-materialisation"); + fs::create_dir_all(child.join(".decided")).unwrap(); + fs::write( + child.join(".decided/config.yaml"), + b"repository_key: APP\ncorpus:\n source: acme/app\n", + ) + .unwrap(); + child_manifest( + &child, + "acme/app", + "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "acme/standards", + ); + assert_eq!( + verify_parent(&child).unwrap_err().code, + ParentCorpusErrorCode::MaterialisationMissing + ); + + let parent = child.join("vendor/standards"); + parent_at(&parent, "acme/standards"); + fs::remove_dir_all(parent.join("decisions")).unwrap(); + assert_eq!( + verify_parent(&child).unwrap_err().code, + ParentCorpusErrorCode::ParentCorpusMissing + ); + fs::remove_dir_all(child).unwrap(); +} + +#[test] +fn source_mismatch_and_source_collision_are_distinct() { + let child = scratch("sources"); + let parent = child.join("vendor/standards"); + parent_at(&parent, "acme/standards"); + let pin = calculate_parent_digest(&parent, "decisions") + .unwrap() + .digest; + + child_manifest(&child, "acme/app", &pin, "acme/other"); + assert_eq!( + verify_parent(&child).unwrap_err().code, + ParentCorpusErrorCode::SourceMismatch + ); + + child_manifest(&child, "acme/standards", &pin, "acme/standards"); + assert_eq!( + verify_parent(&child).unwrap_err().code, + ParentCorpusErrorCode::SourceCollision + ); + fs::remove_dir_all(child).unwrap(); +} + +#[test] +fn absolute_and_parent_relative_manifest_paths_never_load() { + let child = scratch("unsafe-paths"); + fs::create_dir_all(child.join(".decided")).unwrap(); + let manifest = |root: &str, corpus: &str| { + format!( + "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: standards\nsource: acme/standards\nroot: {root}\ncorpus: {corpus}\ndigest: sha256:0000000000000000000000000000000000000000000000000000000000000000\n```\n" + ) + }; + fs::write( + child.join(".decided/corpus.md"), + manifest("/tmp/parent", "decisions"), + ) + .unwrap(); + assert_eq!( + load_manifest(&child).unwrap_err().code, + ParentCorpusErrorCode::PathEscape + ); + fs::write( + child.join(".decided/corpus.md"), + manifest("vendor/standards", "../decisions"), + ) + .unwrap(); + assert_eq!( + load_manifest(&child).unwrap_err().code, + ParentCorpusErrorCode::PathEscape + ); + fs::remove_dir_all(child).unwrap(); +} + +#[test] +fn operational_headings_in_examples_do_not_declare_a_parent() { + let root = scratch("quoted-heading"); + fs::create_dir_all(root.join(".decided")).unwrap(); + fs::write( + root.join(".decided/corpus.md"), + "# Corpus\n\n```markdown\n## inherits\n```\n\n> ## inherits\n", + ) + .unwrap(); + let error = load_manifest(&root).unwrap_err(); + assert_eq!(error.code, ParentCorpusErrorCode::MalformedManifest); + assert!(error.message.contains("missing exact lowercase")); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn nested_headings_do_not_end_the_inherits_section() { + let root = scratch("nested-heading"); + fs::create_dir_all(root.join(".decided")).unwrap(); + fs::write( + root.join(".decided/corpus.md"), + "# Corpus\n\n## inherits\n\n> ## Example\n\n```yaml\nversion: 1\nalias: standards\nsource: acme/standards\nroot: vendor/standards\ncorpus: decisions\ndigest: sha256:0000000000000000000000000000000000000000000000000000000000000000\n```\n", + ) + .unwrap(); + let manifest = load_manifest(&root).unwrap().unwrap(); + assert_eq!(manifest.inherits.alias, "standards"); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn a_verified_result_retains_manifest_and_snapshot_bytes() { + let child = scratch("retained-bytes"); + let parent = child.join("vendor/standards"); + parent_at(&parent, "acme/standards"); + let calculated = calculate_parent_digest(&parent, "decisions").unwrap(); + child_manifest(&child, "acme/app", &calculated.digest, "acme/standards"); + + let verified = verify_parent(&child).unwrap().unwrap(); + assert_eq!(verified.config_bytes, calculated.config_bytes); + assert_eq!(verified.files, calculated.files); + assert_eq!( + verified + .files + .iter() + .find(|file| file.relative_path == "one.md") + .unwrap() + .bytes, + b"one\n" + ); + assert!(verified.overrides.is_some()); + assert_eq!( + verified.manifest_bytes, + fs::read(child.join(".decided/corpus.md")).unwrap() + ); + fs::remove_dir_all(child).unwrap(); +} + +#[cfg(unix)] +#[test] +fn symlink_in_the_declared_corpus_path_is_rejected() { + use std::os::unix::fs::symlink; + + let child = scratch("corpus-symlink"); + let parent = child.join("vendor/standards"); + fs::create_dir_all(parent.join(".decided")).unwrap(); + fs::create_dir_all(parent.join("real-decisions")).unwrap(); + fs::write( + parent.join(".decided/config.yaml"), + b"repository_key: STD\ncorpus:\n source: acme/standards\n", + ) + .unwrap(); + symlink("real-decisions", parent.join("decisions")).unwrap(); + + let error = calculate_parent_digest(&parent, "decisions").unwrap_err(); + assert_eq!(error.code, ParentCorpusErrorCode::SymlinkTraversal); + fs::remove_dir_all(child).unwrap(); +} + +#[cfg(unix)] +#[test] +fn symlinked_parent_config_is_rejected_before_reading_bytes() { + use std::os::unix::fs::symlink; + + let root = scratch("config-symlink"); + fs::create_dir_all(root.join(".decided")).unwrap(); + fs::create_dir_all(root.join("decisions")).unwrap(); + fs::write( + root.join("outside-config.yaml"), + b"repository_key: STD\ncorpus:\n source: acme/standards\n", + ) + .unwrap(); + symlink("../outside-config.yaml", root.join(".decided/config.yaml")).unwrap(); + + let error = calculate_parent_digest(&root, "decisions").unwrap_err(); + assert_eq!(error.code, ParentCorpusErrorCode::SymlinkTraversal); + fs::remove_dir_all(root).unwrap(); +} From 9ca7ab6c4bd357330b33a58b85b4c1ac183b72f8 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 30 Aug 2026 08:46:55 +0100 Subject: [PATCH 4/9] feat(engine): compose federated corpus Signed-off-by: Tom Ballard --- rust/rac-engine/src/composition.rs | 785 +++++++++++++++++++++++++++ rust/rac-engine/src/index_store.rs | 3 + rust/rac-engine/src/lib.rs | 1 + rust/rac-engine/src/relationships.rs | 375 ++++++++----- rust/rac-engine/src/rename.rs | 2 +- rust/rac-engine/src/resolve.rs | 15 +- rust/rac-engine/src/retrieve.rs | 16 + rust/rac-engine/tests/composition.rs | 494 +++++++++++++++++ 8 files changed, 1554 insertions(+), 137 deletions(-) create mode 100644 rust/rac-engine/src/composition.rs create mode 100644 rust/rac-engine/tests/composition.rs diff --git a/rust/rac-engine/src/composition.rs b/rust/rac-engine/src/composition.rs new file mode 100644 index 00000000..5f196297 --- /dev/null +++ b/rust/rac-engine/src/composition.rs @@ -0,0 +1,785 @@ +//! Central source-aware corpus composition (ADR-136 through ADR-138). +//! +//! This module is intentionally dormant at the command boundary. The parent +//! verifier supplies already-validated items and declaration values later; +//! composition owns the single catalog/effective overlay every reader will +//! consume. It performs no filesystem or network work. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fmt; + +use crate::corpus::{ArtifactKey, ArtifactPath, Layer}; +use crate::pycompat::py_casefold; +use crate::relationships::{ + resolution_index_from_rows, resolve_relationships, validation_from_rows_with_index, + validation_row_from_item, CorpusItem, Relationship, RelationshipValidation, + ResolutionCandidate, ResolutionIndex, ValidationRow, +}; +use crate::resolve::is_live_decision; + +pub const FINDING_CANONICAL_COLLISION: &str = "cross-corpus-canonical-id-collision"; +pub const FINDING_INVALID_OVERRIDE: &str = "cross-corpus-invalid-override"; + +/// A canonical identifier used by a local override operand. +/// +/// It deliberately cannot contain the qualified-reference delimiter. Whether +/// the value is truly canonical is then established by a direct lookup in the +/// appropriate layer; artifact aliases never participate in that lookup. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CanonicalId(String); + +impl CanonicalId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() || value.trim() != value { + return Err(OverrideSyntaxError::InvalidCanonicalId); + } + if value.contains("::") { + return Err(OverrideSyntaxError::QualifiedLocalId); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for CanonicalId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +/// A child-local parent alias plus a canonical parent identifier. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct QualifiedCanonicalId { + alias: String, + canonical_id: CanonicalId, +} + +impl QualifiedCanonicalId { + pub fn new( + alias: impl Into, + canonical_id: CanonicalId, + ) -> Result { + let alias = alias.into(); + if !valid_source_alias(&alias) { + return Err(OverrideSyntaxError::InvalidSourceAlias); + } + Ok(Self { + alias, + canonical_id, + }) + } + + pub fn parse(value: &str) -> Result { + let Some((alias, canonical_id)) = value.split_once("::") else { + return Err(OverrideSyntaxError::ParentMustBeQualified); + }; + if canonical_id.contains("::") { + return Err(OverrideSyntaxError::InvalidQualifiedId); + } + Self::new(alias, CanonicalId::new(canonical_id)?) + } + + pub fn alias(&self) -> &str { + &self.alias + } + + pub fn canonical_id(&self) -> &CanonicalId { + &self.canonical_id + } +} + +impl fmt::Display for QualifiedCanonicalId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}::{}", self.alias, self.canonical_id) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverrideSyntaxError { + InvalidCanonicalId, + QualifiedLocalId, + ParentMustBeQualified, + InvalidQualifiedId, + InvalidSourceAlias, +} + +impl fmt::Display for OverrideSyntaxError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidCanonicalId => "canonical id must be non-empty and unpadded", + Self::QualifiedLocalId => "local canonical id must not be qualified", + Self::ParentMustBeQualified => "parent canonical id must be qualified", + Self::InvalidQualifiedId => "qualified id must contain exactly one `::` delimiter", + Self::InvalidSourceAlias => "source alias must be lowercase and path-free", + }) + } +} + +impl std::error::Error for OverrideSyntaxError {} + +/// The source identity and child-local alias of the one verified parent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParentIdentity { + pub source: String, + pub alias: String, +} + +impl ParentIdentity { + pub fn new( + source: impl Into, + alias: impl Into, + ) -> Result { + let alias = alias.into(); + if !valid_source_alias(&alias) { + return Err(OverrideSyntaxError::InvalidSourceAlias); + } + Ok(Self { + source: source.into(), + alias, + }) + } +} + +/// One typed, canonical-only declaration from `.decided/corpus.md`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct OverrideDeclaration { + pub parent: QualifiedCanonicalId, + pub replacement: CanonicalId, + pub rationale: CanonicalId, +} + +impl OverrideDeclaration { + pub fn parse( + parent: &str, + replacement: &str, + rationale: &str, + ) -> Result { + Ok(Self { + parent: QualifiedCanonicalId::parse(parent)?, + replacement: CanonicalId::new(replacement)?, + rationale: CanonicalId::new(rationale)?, + }) + } +} + +/// Why an override declaration did not become effective. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum InvalidOverrideReason { + ParentAliasMismatch, + DuplicateParent, + ParentNotFound, + ParentAmbiguous, + ReplacementNotFound, + ReplacementAmbiguous, + ReplacementNotLocal, + Chained, + TypeMismatch, + RationaleNotFound, + RationaleAmbiguous, + RationaleNotLocal, + RationaleNotDecision, + RationaleNotLive, +} + +impl InvalidOverrideReason { + pub const fn as_str(self) -> &'static str { + match self { + Self::ParentAliasMismatch => "parent-alias-mismatch", + Self::DuplicateParent => "duplicate-parent", + Self::ParentNotFound => "parent-not-found", + Self::ParentAmbiguous => "parent-ambiguous", + Self::ReplacementNotFound => "replacement-not-found", + Self::ReplacementAmbiguous => "replacement-ambiguous", + Self::ReplacementNotLocal => "replacement-not-local", + Self::Chained => "chained", + Self::TypeMismatch => "type-mismatch", + Self::RationaleNotFound => "rationale-not-found", + Self::RationaleAmbiguous => "rationale-ambiguous", + Self::RationaleNotLocal => "rationale-not-local", + Self::RationaleNotDecision => "rationale-not-decision", + Self::RationaleNotLive => "rationale-not-live", + } + } +} + +/// One deterministic composition finding. These are kept separate from the +/// released path-only relationship finding model until federation activates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompositionFinding { + pub code: &'static str, + pub message: String, + pub reason: Option, + pub artifacts: Vec, + pub paths: Vec, +} + +/// A validated policy redirect. All three endpoints are stable artifact keys. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedOverride { + pub declaration: OverrideDeclaration, + pub parent: ArtifactKey, + pub replacement: ArtifactKey, + pub rationale: ArtifactKey, +} + +/// Exact lookup failure against the composed effective view. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LookupError { + NotFound, + Ambiguous(Vec), + InvalidQualifiedReference, + QualifiedCanonicalRequired, +} + +/// The one composed read model. `items` owns the catalog exactly once; local +/// and effective corpora are stable ordered projections over it. +pub struct ComposedCorpus { + items: Vec, + local: Vec, + effective: Vec, + parent: Option, + overrides: Vec, + findings: Vec, + catalog_rows: Vec, + effective_rows: Vec, + resolution_index: ResolutionIndex, + item_by_key: HashMap, + captured_content: HashMap>, +} + +impl ComposedCorpus { + /// A local-only composition useful to consumers adopting the central model + /// before manifest activation. + pub fn local(mut items: Vec) -> Self { + items.sort_by(stable_item_order); + Self::build(items, None, Vec::new(), HashMap::new()) + } + + /// Compose one writable child with one already-verified read-only parent. + pub fn compose( + mut local: Vec, + mut inherited: Vec, + parent: ParentIdentity, + overrides: Vec, + ) -> Self { + local.append(&mut inherited); + local.sort_by(stable_item_order); + Self::build(local, Some(parent), overrides, HashMap::new()) + } + + /// Compose from verification-time snapshots. Captured bytes are owned by + /// this read model and served by stable key, so a consumer never reopens a + /// mutable parent path after digest verification. + pub fn compose_with_content( + mut local: Vec, + mut inherited: Vec, + parent: ParentIdentity, + overrides: Vec, + captured_content: impl IntoIterator)>, + ) -> Self { + local.append(&mut inherited); + local.sort_by(stable_item_order); + Self::build( + local, + Some(parent), + overrides, + captured_content.into_iter().collect(), + ) + } + + fn build( + items: Vec, + parent: Option, + mut declarations: Vec, + mut captured_content: HashMap>, + ) -> Self { + let local: Vec = items + .iter() + .enumerate() + .filter_map(|(index, item)| (item.origin.layer == Layer::Local).then_some(index)) + .collect(); + let inherited: Vec = items + .iter() + .enumerate() + .filter_map(|(index, item)| (item.origin.layer == Layer::Inherited).then_some(index)) + .collect(); + + let local_canonical = canonical_index(&items, &local); + let inherited_canonical = canonical_index(&items, &inherited); + declarations.sort(); + + let parent_counts = declarations + .iter() + .fold(BTreeMap::new(), |mut counts, declaration| { + let key = ( + declaration.parent.alias().to_string(), + py_casefold(declaration.parent.canonical_id().as_str()), + ); + *counts.entry(key).or_insert(0usize) += 1; + counts + }); + let declared_parent_ids: BTreeSet = declarations + .iter() + .map(|declaration| py_casefold(declaration.parent.canonical_id().as_str())) + .collect(); + + let mut findings = Vec::new(); + let mut valid = Vec::new(); + for declaration in declarations { + match validate_override( + &declaration, + parent.as_ref(), + &items, + &local_canonical, + &inherited_canonical, + &parent_counts, + &declared_parent_ids, + ) { + Ok(validated) => valid.push(validated), + Err((reason, item_indices)) => findings.push(invalid_override_finding( + &declaration, + reason, + &items, + &item_indices, + )), + } + } + + valid.sort_by(|left, right| left.declaration.cmp(&right.declaration)); + let cleared_collisions: BTreeSet<(ArtifactKey, ArtifactKey)> = valid + .iter() + .filter(|mapping| { + py_casefold(&mapping.parent.canonical_id) + == py_casefold(&mapping.replacement.canonical_id) + }) + .map(|mapping| (mapping.parent.clone(), mapping.replacement.clone())) + .collect(); + findings.extend(collision_findings( + &items, + &local_canonical, + &inherited_canonical, + &cleared_collisions, + )); + findings.sort_by(finding_order); + + let overridden: BTreeSet = + valid.iter().map(|mapping| mapping.parent.clone()).collect(); + let effective: Vec = items + .iter() + .enumerate() + .filter_map(|(index, item)| (!overridden.contains(&item.key)).then_some(index)) + .collect(); + let catalog_rows: Vec = items.iter().map(validation_row_from_item).collect(); + let effective_rows: Vec = effective + .iter() + .map(|index| catalog_rows[*index].clone()) + .collect(); + let resolution_index = + composed_resolution_index(&catalog_rows, &effective_rows, parent.as_ref(), &valid); + let item_by_key: HashMap = items + .iter() + .enumerate() + .map(|(index, item)| (item.key.clone(), index)) + .collect(); + captured_content.retain(|key, _| item_by_key.contains_key(key)); + + Self { + items, + local, + effective, + parent, + overrides: valid, + findings, + catalog_rows, + effective_rows, + resolution_index, + item_by_key, + captured_content, + } + } + + pub fn local_items(&self) -> impl ExactSizeIterator { + self.local.iter().map(|index| &self.items[*index]) + } + + pub fn catalog(&self) -> impl ExactSizeIterator { + self.items.iter() + } + + pub fn effective(&self) -> impl ExactSizeIterator { + self.effective.iter().map(|index| &self.items[*index]) + } + + pub fn parent(&self) -> Option<&ParentIdentity> { + self.parent.as_ref() + } + + pub fn overrides(&self) -> &[ValidatedOverride] { + &self.overrides + } + + pub fn findings(&self) -> &[CompositionFinding] { + &self.findings + } + + pub fn is_overridden(&self, key: &ArtifactKey) -> bool { + self.overrides.iter().any(|mapping| &mapping.parent == key) + } + + pub fn item(&self, key: &ArtifactKey) -> Option<&CorpusItem> { + self.item_by_key.get(key).map(|index| &self.items[*index]) + } + + /// Exact verification-time Markdown bytes, when the composition was built + /// from captured snapshots. Runtime filesystem locators remain available + /// on `item` for operations that explicitly need physical provenance. + pub fn content(&self, key: &ArtifactKey) -> Option<&[u8]> { + self.captured_content.get(key).map(Vec::as_slice) + } + + /// 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> { + if reference.contains("::") { + self.validate_qualified_reference(reference)?; + } + let candidates = self.resolution_index.get_reference(reference); + match candidates { + [] => Err(LookupError::NotFound), + [candidate] => self.item(&candidate.key).ok_or(LookupError::NotFound), + many => Err(LookupError::Ambiguous( + many.iter().map(|candidate| candidate.key.clone()).collect(), + )), + } + } + + fn validate_qualified_reference(&self, reference: &str) -> Result<(), LookupError> { + let Some((alias, canonical_id)) = reference.split_once("::") else { + return Err(LookupError::InvalidQualifiedReference); + }; + if canonical_id.is_empty() || canonical_id.contains("::") { + return Err(LookupError::InvalidQualifiedReference); + } + let Some(parent) = &self.parent else { + return Err(LookupError::NotFound); + }; + if alias != parent.alias { + return Err(LookupError::NotFound); + } + let canonical_fold = py_casefold(canonical_id); + let canonical_exists = self.items.iter().any(|item| { + item.origin.layer == Layer::Inherited + && item.origin.source == parent.source + && py_casefold(&item.key.canonical_id) == canonical_fold + }); + if canonical_exists { + return Ok(()); + } + let alias_exists = self.items.iter().any(|item| { + item.origin.layer == Layer::Inherited + && item.origin.source == parent.source + && crate::identity::artifact_identifiers(&item.artifact, item.spec, &item.path) + .iter() + .any(|identifier| py_casefold(identifier) == canonical_fold) + }); + if alias_exists { + Err(LookupError::QualifiedCanonicalRequired) + } else { + Err(LookupError::NotFound) + } + } + + /// Resolve all effective declared edges through the same index as exact + /// lookup, retaining qualified access to overridden parent history. + pub fn relationships(&self) -> Vec { + resolve_relationships(&self.effective_rows, &self.resolution_index) + } + + /// Resolve declared edges for every retained catalog record, including an + /// overridden parent's immutable history. Export uses this projection; + /// live reads and enforcement continue to use `relationships`. + pub fn catalog_relationships(&self) -> Vec { + resolve_relationships(&self.catalog_rows, &self.resolution_index) + } + + /// Run the existing relationship validator over source-aware keys. The + /// child repository root is intentionally supplied here so inherited + /// filesystem scope is checked against child code. + pub fn validate_relationships( + &self, + child_directory: &str, + recursive: bool, + ) -> RelationshipValidation { + validation_from_rows_with_index( + child_directory, + &self.effective_rows, + &self.catalog_rows, + recursive, + &self.resolution_index, + false, + ) + } +} + +fn valid_source_alias(alias: &str) -> bool { + let mut characters = alias.chars(); + let Some(first) = characters.next() else { + return false; + }; + first.is_ascii_lowercase() + && characters.all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || matches!(character, '-' | '_' | '.') + }) +} + +fn stable_item_order(left: &CorpusItem, right: &CorpusItem) -> std::cmp::Ordering { + left.artifact_path + .cmp(&right.artifact_path) + .then_with(|| left.key.cmp(&right.key)) +} + +fn canonical_index(items: &[CorpusItem], indices: &[usize]) -> BTreeMap> { + let mut index: BTreeMap> = BTreeMap::new(); + for item_index in indices { + index + .entry(py_casefold(&items[*item_index].key.canonical_id)) + .or_default() + .push(*item_index); + } + index +} + +#[allow(clippy::too_many_arguments)] +fn validate_override( + declaration: &OverrideDeclaration, + parent: Option<&ParentIdentity>, + items: &[CorpusItem], + local_canonical: &BTreeMap>, + inherited_canonical: &BTreeMap>, + parent_counts: &BTreeMap<(String, String), usize>, + declared_parent_ids: &BTreeSet, +) -> Result)> { + let parent_id = py_casefold(declaration.parent.canonical_id().as_str()); + let replacement_id = py_casefold(declaration.replacement.as_str()); + let rationale_id = py_casefold(declaration.rationale.as_str()); + let Some(parent_identity) = parent else { + return Err((InvalidOverrideReason::ParentNotFound, Vec::new())); + }; + if declaration.parent.alias() != parent_identity.alias { + return Err((InvalidOverrideReason::ParentAliasMismatch, Vec::new())); + } + if parent_counts + .get(&(declaration.parent.alias().to_string(), parent_id.clone())) + .copied() + .unwrap_or_default() + > 1 + { + return Err((InvalidOverrideReason::DuplicateParent, Vec::new())); + } + + let parent_matches: Vec = inherited_canonical + .get(&parent_id) + .into_iter() + .flatten() + .copied() + .filter(|index| items[*index].origin.source == parent_identity.source) + .collect(); + let parent_index = match parent_matches.as_slice() { + [] => return Err((InvalidOverrideReason::ParentNotFound, Vec::new())), + [index] => *index, + many => return Err((InvalidOverrideReason::ParentAmbiguous, many.to_vec())), + }; + + let replacement_matches = local_canonical + .get(&replacement_id) + .cloned() + .unwrap_or_default(); + let replacement_index = match replacement_matches.as_slice() { + [] if inherited_canonical.contains_key(&replacement_id) => { + return Err(( + InvalidOverrideReason::ReplacementNotLocal, + inherited_canonical[&replacement_id].clone(), + )); + } + [] => return Err((InvalidOverrideReason::ReplacementNotFound, Vec::new())), + [index] => *index, + many => return Err((InvalidOverrideReason::ReplacementAmbiguous, many.to_vec())), + }; + if replacement_id != parent_id && declared_parent_ids.contains(&replacement_id) { + return Err(( + InvalidOverrideReason::Chained, + vec![parent_index, replacement_index], + )); + } + + let parent_type = items[parent_index].spec.map(|spec| spec.name.as_str()); + let replacement_type = items[replacement_index].spec.map(|spec| spec.name.as_str()); + if parent_type.is_none() || parent_type != replacement_type { + return Err(( + InvalidOverrideReason::TypeMismatch, + vec![parent_index, replacement_index], + )); + } + + let rationale_matches = local_canonical + .get(&rationale_id) + .cloned() + .unwrap_or_default(); + let rationale_index = match rationale_matches.as_slice() { + [] if inherited_canonical.contains_key(&rationale_id) => { + return Err(( + InvalidOverrideReason::RationaleNotLocal, + inherited_canonical[&rationale_id].clone(), + )); + } + [] => return Err((InvalidOverrideReason::RationaleNotFound, Vec::new())), + [index] => *index, + many => return Err((InvalidOverrideReason::RationaleAmbiguous, many.to_vec())), + }; + if items[rationale_index].spec.map(|spec| spec.name.as_str()) != Some("decision") { + return Err(( + InvalidOverrideReason::RationaleNotDecision, + vec![rationale_index], + )); + } + if !is_live_decision(&items[rationale_index].artifact) { + return Err(( + InvalidOverrideReason::RationaleNotLive, + vec![rationale_index], + )); + } + + Ok(ValidatedOverride { + declaration: declaration.clone(), + parent: items[parent_index].key.clone(), + replacement: items[replacement_index].key.clone(), + rationale: items[rationale_index].key.clone(), + }) +} + +fn invalid_override_finding( + declaration: &OverrideDeclaration, + reason: InvalidOverrideReason, + items: &[CorpusItem], + item_indices: &[usize], +) -> CompositionFinding { + let mut ordered: Vec = item_indices.to_vec(); + ordered.sort_by(|left, right| stable_item_order(&items[*left], &items[*right])); + ordered.dedup(); + CompositionFinding { + code: FINDING_INVALID_OVERRIDE, + message: format!( + "override {} -> {} ({}) is invalid: {}", + declaration.parent, + declaration.replacement, + declaration.rationale, + reason.as_str() + ), + reason: Some(reason), + artifacts: ordered + .iter() + .map(|index| items[*index].key.clone()) + .collect(), + paths: ordered + .iter() + .map(|index| items[*index].artifact_path.clone()) + .collect(), + } +} + +fn collision_findings( + items: &[CorpusItem], + local: &BTreeMap>, + inherited: &BTreeMap>, + cleared: &BTreeSet<(ArtifactKey, ArtifactKey)>, +) -> Vec { + let mut findings = Vec::new(); + for (canonical_fold, local_indices) in local { + let Some(parent_indices) = inherited.get(canonical_fold) else { + continue; + }; + let fully_cleared = local_indices.len() == 1 + && parent_indices.len() == 1 + && cleared.contains(&( + items[parent_indices[0]].key.clone(), + items[local_indices[0]].key.clone(), + )); + if fully_cleared { + continue; + } + let mut indices: Vec = local_indices + .iter() + .chain(parent_indices) + .copied() + .collect(); + indices.sort_by(|left, right| stable_item_order(&items[*left], &items[*right])); + let display_id = indices + .first() + .map(|index| items[*index].key.canonical_id.as_str()) + .unwrap_or(canonical_fold); + findings.push(CompositionFinding { + code: FINDING_CANONICAL_COLLISION, + message: format!( + "canonical id {display_id} occurs in both local and inherited corpora" + ), + reason: None, + artifacts: indices + .iter() + .map(|index| items[*index].key.clone()) + .collect(), + paths: indices + .iter() + .map(|index| items[*index].artifact_path.clone()) + .collect(), + }); + } + findings +} + +fn finding_order(left: &CompositionFinding, right: &CompositionFinding) -> std::cmp::Ordering { + left.code + .cmp(right.code) + .then_with(|| left.paths.cmp(&right.paths)) + .then_with(|| left.artifacts.cmp(&right.artifacts)) + .then_with(|| left.reason.cmp(&right.reason)) + .then_with(|| left.message.cmp(&right.message)) +} + +fn composed_resolution_index( + catalog_rows: &[ValidationRow], + effective_rows: &[ValidationRow], + parent: Option<&ParentIdentity>, + overrides: &[ValidatedOverride], +) -> ResolutionIndex { + let mut index = resolution_index_from_rows(effective_rows); + let rows_by_key: HashMap<&ArtifactKey, &ValidationRow> = + catalog_rows.iter().map(|row| (&row.key, row)).collect(); + + if let Some(parent) = parent { + for row in catalog_rows.iter().filter(|row| { + row.origin.layer == Layer::Inherited && row.origin.source == parent.source + }) { + let qualified = format!("{}::{}", parent.alias, row.canonical_id); + index.insert( + ResolutionIndex::reference_key(&qualified), + ResolutionCandidate::from_row(row, qualified), + ); + } + } + for mapping in overrides { + let Some(replacement) = rows_by_key.get(&mapping.replacement) else { + continue; + }; + index.insert( + py_casefold(&mapping.parent.canonical_id), + ResolutionCandidate::from_row(replacement, mapping.parent.canonical_id.clone()), + ); + } + index +} diff --git a/rust/rac-engine/src/index_store.rs b/rust/rac-engine/src/index_store.rs index a25ec94b..f314ab71 100644 --- a/rust/rac-engine/src/index_store.rs +++ b/rust/rac-engine/src/index_store.rs @@ -801,6 +801,9 @@ impl MmapIndexReader { let mut rows = Vec::with_capacity(count.min(1 << 20) as usize); for _ in 0..count { rows.push(crate::retrieve::ScopeRow { + key: None, + artifact_path: None, + origin: None, id: reader.text()?, title: reader.text()?, status: reader.text()?, diff --git a/rust/rac-engine/src/lib.rs b/rust/rac-engine/src/lib.rs index 47fb079b..01550ff0 100644 --- a/rust/rac-engine/src/lib.rs +++ b/rust/rac-engine/src/lib.rs @@ -48,6 +48,7 @@ pub mod parse; pub mod classify; pub mod identity; pub mod corpus; +pub mod composition; pub mod validate; pub mod relationships; pub mod diff; diff --git a/rust/rac-engine/src/relationships.rs b/rust/rac-engine/src/relationships.rs index 8c99ca13..0908a458 100644 --- a/rust/rac-engine/src/relationships.rs +++ b/rust/rac-engine/src/relationships.rs @@ -362,23 +362,58 @@ fn validation_row_with_identity( } } -/// Insertion-ordered `{casefold(ident) -> [(path, ident)]}` index. +/// One source-aware resolution candidate. +/// +/// `path` is retained solely as the released display value. Identity and +/// deterministic ordering use `key` and `artifact_path`, never a checkout +/// path (ADR-135/ADR-136). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolutionCandidate { + pub key: ArtifactKey, + pub artifact_path: ArtifactPath, + pub path: String, + pub identifier: String, +} + +impl ResolutionCandidate { + pub(crate) fn from_row(row: &ValidationRow, identifier: String) -> Self { + Self { + key: row.key.clone(), + artifact_path: row.artifact_path.clone(), + path: row.path.clone(), + identifier, + } + } +} + +/// Insertion-ordered `{casefold(ident) -> [source-aware candidate]}` index. pub struct ResolutionIndex { order: Vec, - map: HashMap>, + map: HashMap>, } impl ResolutionIndex { - fn new() -> Self { + pub(crate) fn new() -> Self { ResolutionIndex { order: Vec::new(), map: HashMap::new(), } } - fn insert(&mut self, key: String, value: (String, String)) { + pub(crate) fn insert(&mut self, key: String, value: ResolutionCandidate) { match self.map.get_mut(&key) { - Some(v) => v.push(value), + Some(v) => { + if !v.iter().any(|candidate| { + candidate.key == value.key && candidate.artifact_path == value.artifact_path + }) { + v.push(value); + v.sort_by(|left, right| { + left.artifact_path + .cmp(&right.artifact_path) + .then_with(|| left.key.cmp(&right.key)) + }); + } + } None => { self.map.insert(key.clone(), vec![value]); self.order.push(key); @@ -386,11 +421,24 @@ impl ResolutionIndex { } } - pub fn get(&self, key: &str) -> &[(String, String)] { + pub fn get(&self, key: &str) -> &[ResolutionCandidate] { self.map.get(key).map(|v| v.as_slice()).unwrap_or(&[]) } - fn values(&self) -> impl Iterator> { + pub(crate) fn reference_key(reference: &str) -> String { + match reference.split_once("::") { + Some((alias, canonical_id)) if !canonical_id.contains("::") => { + format!("{alias}::{}", py_casefold(canonical_id)) + } + _ => py_casefold(reference), + } + } + + pub(crate) fn get_reference(&self, reference: &str) -> &[ResolutionCandidate] { + self.get(&Self::reference_key(reference)) + } + + fn values(&self) -> impl Iterator> { self.order.iter().map(|k| &self.map[k]) } } @@ -399,7 +447,10 @@ pub fn resolution_index_from_rows(rows: &[ValidationRow]) -> ResolutionIndex { let mut index = ResolutionIndex::new(); for row in rows { for ident in &row.identifiers { - index.insert(py_casefold(ident), (row.path.clone(), ident.clone())); + index.insert( + py_casefold(ident), + ResolutionCandidate::from_row(row, ident.clone()), + ); } } index @@ -491,20 +542,20 @@ pub(crate) fn normalized_scope_path(entry: &str) -> Option { fn resolved_unique<'a>( index: &'a ResolutionIndex, reference: &str, - source_path: &str, -) -> Option<&'a str> { - let targets = index.get(&py_casefold(reference)); - if targets.len() != 1 || targets[0].0 == source_path { + source_key: &ArtifactKey, +) -> Option<&'a ResolutionCandidate> { + let targets = index.get_reference(reference); + if targets.len() != 1 || targets[0].key == *source_key { return None; } - Some(&targets[0].0) + Some(&targets[0]) } /// Outcome of resolving one internal reference against the index: checked /// empty -> not found, then multiple -> ambiguous, then same-path -> self, /// else uniquely resolved. Shared by the issue and `Relationship` loops. enum ReferenceResolution<'a> { - Resolved(&'a str), + Resolved(&'a ResolutionCandidate), NotFound, Ambiguous, SelfRef, @@ -513,17 +564,17 @@ enum ReferenceResolution<'a> { fn classify_reference<'a>( index: &'a ResolutionIndex, reference: &str, - source_path: &str, + source_key: &ArtifactKey, ) -> ReferenceResolution<'a> { - let targets = index.get(&py_casefold(reference)); + let targets = index.get_reference(reference); if targets.is_empty() { ReferenceResolution::NotFound } else if targets.len() > 1 { ReferenceResolution::Ambiguous - } else if targets[0].0 == source_path { + } else if targets[0].key == *source_key { ReferenceResolution::SelfRef } else { - ReferenceResolution::Resolved(&targets[0].0) + ReferenceResolution::Resolved(&targets[0]) } } @@ -536,64 +587,69 @@ fn resolve_references( (checked, issues) } -/// Tarjan SCC over the sorted-adjacency graph; components of size > 1, -/// each sorted, ordered by first element. -fn cyclic_components(adjacency: &[(String, Vec)]) -> Vec> { - let adj: HashMap<&str, &Vec> = - adjacency.iter().map(|(k, v)| (k.as_str(), v)).collect(); - let mut nodes: Vec<&str> = adjacency +/// Tarjan SCC over source-aware keys. Traversal and output are ordered by the +/// corresponding stable `(source, relative_path)`, not display paths. +fn cyclic_components( + adjacency: &[(ArtifactKey, Vec)], + paths: &HashMap, +) -> Vec> { + let adj: HashMap> = adjacency.iter().cloned().collect(); + let mut nodes: Vec = adjacency .iter() - .flat_map(|(k, vs)| std::iter::once(k.as_str()).chain(vs.iter().map(|v| v.as_str()))) + .flat_map(|(key, values)| std::iter::once(key.clone()).chain(values.iter().cloned())) .collect(); - nodes.sort(); + nodes.sort_by(|left, right| paths[left].cmp(&paths[right]).then_with(|| left.cmp(right))); nodes.dedup(); - struct State<'a> { - indices: HashMap<&'a str, usize>, - lowlink: HashMap<&'a str, usize>, - on_stack: std::collections::HashSet<&'a str>, - stack: Vec<&'a str>, + struct State { + indices: HashMap, + lowlink: HashMap, + on_stack: std::collections::HashSet, + stack: Vec, counter: usize, - components: Vec>, + components: Vec>, } - fn strongconnect<'a>( - v: &'a str, - adj: &HashMap<&'a str, &'a Vec>, - st: &mut State<'a>, + fn strongconnect( + v: &ArtifactKey, + adj: &HashMap>, + paths: &HashMap, + st: &mut State, ) { - st.indices.insert(v, st.counter); - st.lowlink.insert(v, st.counter); + st.indices.insert(v.clone(), st.counter); + st.lowlink.insert(v.clone(), st.counter); st.counter += 1; - st.stack.push(v); - st.on_stack.insert(v); + st.stack.push(v.clone()); + st.on_stack.insert(v.clone()); if let Some(neighbors) = adj.get(v) { - for w in neighbors.iter() { - let w = w.as_str(); + for w in neighbors { if !st.indices.contains_key(w) { - strongconnect(w, adj, st); + strongconnect(w, adj, paths, st); let lw = st.lowlink[w]; let lv = st.lowlink[v]; - st.lowlink.insert(v, lv.min(lw)); + st.lowlink.insert(v.clone(), lv.min(lw)); } else if st.on_stack.contains(w) { let iw = st.indices[w]; let lv = st.lowlink[v]; - st.lowlink.insert(v, lv.min(iw)); + st.lowlink.insert(v.clone(), lv.min(iw)); } } } if st.lowlink[v] == st.indices[v] { - let mut component: Vec = Vec::new(); + let mut component: Vec = Vec::new(); loop { let w = st.stack.pop().expect("stack nonempty"); - st.on_stack.remove(w); - component.push(w.to_string()); - if w == v { + st.on_stack.remove(&w); + let complete = w == *v; + component.push(w); + if complete { break; } } if component.len() > 1 { - component.sort(); + component.sort_by(|left, right| { + paths[left].cmp(&paths[right]).then_with(|| left.cmp(right)) + }); st.components.push(component); } } @@ -609,19 +665,35 @@ fn cyclic_components(adjacency: &[(String, Vec)]) -> Vec> { }; for node in &nodes { if !st.indices.contains_key(node) { - strongconnect(node, &adj, &mut st); + strongconnect(node, &adj, paths, &mut st); } } - st.components.sort_by(|a, b| a[0].cmp(&b[0])); + st.components.sort_by(|left, right| { + paths[&left[0]] + .cmp(&paths[&right[0]]) + .then_with(|| left[0].cmp(&right[0])) + }); st.components } -fn cycle_issues(rows: &[ValidationRow], index: &ResolutionIndex) -> Vec { +fn cycle_issues( + rows: &[ValidationRow], + target_rows: &[ValidationRow], + index: &ResolutionIndex, +) -> Vec { + let paths: HashMap = target_rows + .iter() + .map(|row| (row.key.clone(), row.artifact_path.clone())) + .collect(); + let display_paths: HashMap<&ArtifactKey, &str> = target_rows + .iter() + .map(|row| (&row.key, row.path.as_str())) + .collect(); // Sorted acyclic edge kinds — today only `supersedes`. let mut issues = Vec::new(); for kind in ["supersedes"] { // `_acyclic_adjacency`: {source -> sorted unique resolved non-self targets}. - let mut adjacency: Vec<(String, Vec)> = Vec::new(); + let mut adjacency: Vec<(ArtifactKey, Vec)> = Vec::new(); for row in rows { if row.spec_name.is_none() { continue; @@ -632,27 +704,34 @@ fn cycle_issues(rows: &[ValidationRow], index: &ResolutionIndex) -> Vec = Vec::new(); + let mut targets: Vec = Vec::new(); for reference in refs { - if let Some(t) = resolved_unique(index, reference, &row.path) { - if !targets.iter().any(|x| x == t) { - targets.push(t.to_string()); + if let Some(target) = resolved_unique(index, reference, &row.key) { + if !targets.iter().any(|key| key == &target.key) { + targets.push(target.key.clone()); } } } if !targets.is_empty() { - targets.sort(); - adjacency.push((row.path.clone(), targets)); + targets.sort_by(|left, right| { + paths[left].cmp(&paths[right]).then_with(|| left.cmp(right)) + }); + adjacency.push((row.key.clone(), targets)); } } - for component in cyclic_components(&adjacency) { + for component in cyclic_components(&adjacency, &paths) { issues.push(RelationshipIssue { code: ISSUE_RELATIONSHIP_CYCLE.to_string(), source_path: None, relationship: Some(kind.to_string()), target: None, identifier: None, - paths: Some(component), + paths: Some( + component + .iter() + .map(|key| display_paths[key].to_string()) + .collect(), + ), }); } } @@ -699,41 +778,65 @@ pub fn validation_from_rows( directory: &str, rows: &[ValidationRow], recursive: bool, +) -> RelationshipValidation { + let index = resolution_index_from_rows(rows); + validation_from_rows_with_index(directory, rows, rows, recursive, &index, true) +} + +/// Source-aware validation core used by the composed read model. The caller +/// supplies the one resolution index so qualified references and override +/// redirects cannot diverge between graph construction and validation. +pub(crate) fn validation_from_rows_with_index( + directory: &str, + rows: &[ValidationRow], + target_rows: &[ValidationRow], + recursive: bool, + index: &ResolutionIndex, + include_duplicate_identifiers: bool, ) -> RelationshipValidation { let mut issues: Vec = Vec::new(); // Duplicate identifiers first, sorted by display identifier (casefold). - let mut ident_index = ResolutionIndex::new(); - for row in rows { - ident_index.insert( - py_casefold(&row.canonical_id), - (row.path.clone(), row.canonical_id.clone()), - ); - } - let mut duplicates: Vec<(String, Vec)> = Vec::new(); - for entries in ident_index.values() { - if entries.len() > 1 { - let display = entries - .iter() - .min_by(|a, b| a.0.cmp(&b.0)) - .expect("nonempty") - .1 - .clone(); - let mut paths: Vec = entries.iter().map(|(p, _)| p.clone()).collect(); - paths.sort(); - duplicates.push((display, paths)); + if include_duplicate_identifiers { + let mut ident_index = ResolutionIndex::new(); + for row in rows { + ident_index.insert( + py_casefold(&row.canonical_id), + ResolutionCandidate::from_row(row, row.canonical_id.clone()), + ); + } + let mut duplicates: Vec<(String, Vec)> = Vec::new(); + for entries in ident_index.values() { + if entries.len() > 1 { + let display = entries + .iter() + .min_by(|left, right| left.artifact_path.cmp(&right.artifact_path)) + .expect("nonempty") + .identifier + .clone(); + let mut paths: Vec<&ResolutionCandidate> = entries.iter().collect(); + paths.sort_by(|left, right| { + left.artifact_path + .cmp(&right.artifact_path) + .then_with(|| left.key.cmp(&right.key)) + }); + duplicates.push(( + display, + paths.into_iter().map(|entry| entry.path.clone()).collect(), + )); + } + } + duplicates.sort_by_cached_key(|entry| py_casefold(&entry.0)); + for (display, dup_paths) in duplicates { + issues.push(RelationshipIssue { + code: ISSUE_DUPLICATE_IDENTIFIER.to_string(), + source_path: None, + relationship: None, + target: None, + identifier: Some(display), + paths: Some(dup_paths), + }); } - } - duplicates.sort_by_cached_key(|a| py_casefold(&a.0)); - for (display, dup_paths) in duplicates { - issues.push(RelationshipIssue { - code: ISSUE_DUPLICATE_IDENTIFIER.to_string(), - source_path: None, - relationship: None, - target: None, - identifier: Some(display), - paths: Some(dup_paths), - }); } // Edge-legality: unsupported declared sections (canonical order per row). @@ -753,9 +856,8 @@ pub fn validation_from_rows( } } - let index = resolution_index_from_rows(rows); - let by_path: HashMap<&str, &ValidationRow> = - rows.iter().map(|r| (r.path.as_str(), r)).collect(); + let by_key: HashMap<&ArtifactKey, &ValidationRow> = + target_rows.iter().map(|row| (&row.key, row)).collect(); // Range violations. for row in rows { @@ -770,10 +872,13 @@ pub fn validation_from_rows( continue; } for reference in refs { - let Some(target) = resolved_unique(&index, reference, &row.path) else { + let Some(target) = resolved_unique(index, reference, &row.key) else { continue; }; - let Some(target_spec) = by_path[target].spec_name.as_deref() else { + let Some(target_row) = by_key.get(&target.key) else { + continue; + }; + let Some(target_spec) = target_row.spec_name.as_deref() else { continue; }; if !edge.range.contains(&target_spec) { @@ -801,10 +906,13 @@ pub fn validation_from_rows( continue; } for reference in refs { - let Some(target) = resolved_unique(&index, reference, &row.path) else { + let Some(target) = resolved_unique(index, reference, &row.key) else { continue; }; - if by_path[target].retired { + if by_key + .get(&target.key) + .is_some_and(|target_row| target_row.retired) + { issues.push(RelationshipIssue::reference( ISSUE_TARGET_SUPERSEDED, &row.path, @@ -817,10 +925,10 @@ pub fn validation_from_rows( } // Acyclicity. - issues.extend(cycle_issues(rows, &index)); + issues.extend(cycle_issues(rows, target_rows, index)); // Referential integrity. - let (checked, ref_issues) = resolve_references(rows, &index); + let (checked, ref_issues) = resolve_references(rows, index); issues.extend(ref_issues); // Code-scope existence (appended last). @@ -893,16 +1001,24 @@ fn resolution_labels( ) -> HashMap { // Resolution index over every alias of every item, in item order. let mut index = ResolutionIndex::new(); - let mut info: HashMap<&str, (String, Option<&'static ArtifactSpec>, Option)> = + let mut info: HashMap<&ArtifactKey, (String, Option<&'static ArtifactSpec>, Option)> = HashMap::new(); for item in items { let identifiers = artifact_identifiers(&item.artifact, item.spec, &item.path); for ident in &identifiers { - index.insert(py_casefold(ident), (item.path.clone(), ident.clone())); + index.insert( + py_casefold(ident), + ResolutionCandidate { + key: item.key.clone(), + artifact_path: item.artifact_path.clone(), + path: item.path.clone(), + identifier: ident.clone(), + }, + ); } let canonical = artifact_identifier(&item.artifact, item.spec, &item.path); info.insert( - item.path.as_str(), + &item.key, (canonical, item.spec, item.artifact.product.title.clone()), ); } @@ -915,7 +1031,8 @@ fn resolution_labels( continue; } let entries = index.get(&key); - let mut distinct: Vec<&str> = entries.iter().map(|(p, _)| p.as_str()).collect(); + let mut distinct: Vec<&ArtifactKey> = + entries.iter().map(|entry| &entry.key).collect(); distinct.sort(); distinct.dedup(); if distinct.len() != 1 { @@ -1151,10 +1268,6 @@ pub fn resolve_relationships( index: &ResolutionIndex, ) -> Vec { let mut out = Vec::new(); - let artifact_paths: HashMap<&str, &ArtifactPath> = rows - .iter() - .map(|row| (row.path.as_str(), &row.artifact_path)) - .collect(); for row in rows { for (section, refs) in &row.edges { let external = edge_spec(section).is_some_and(|e| e.external); @@ -1171,22 +1284,23 @@ pub fn resolve_relationships( }); continue; } - let (resolved, issue) = match classify_reference(index, reference, &row.path) { - ReferenceResolution::Resolved(target) => (Some(target.to_string()), None), + let (resolved, resolved_artifact, issue) = + match classify_reference(index, reference, &row.key) { + ReferenceResolution::Resolved(target) => ( + Some(target.path.clone()), + Some(target.artifact_path.clone()), + None, + ), ReferenceResolution::NotFound => { - (None, Some(ISSUE_TARGET_NOT_FOUND.to_string())) + (None, None, Some(ISSUE_TARGET_NOT_FOUND.to_string())) } ReferenceResolution::Ambiguous => { - (None, Some(ISSUE_TARGET_AMBIGUOUS.to_string())) + (None, None, Some(ISSUE_TARGET_AMBIGUOUS.to_string())) } ReferenceResolution::SelfRef => { - (None, Some(ISSUE_SELF_REFERENCE.to_string())) + (None, None, Some(ISSUE_SELF_REFERENCE.to_string())) } }; - let resolved_artifact = resolved - .as_deref() - .and_then(|path| artifact_paths.get(path).copied()) - .cloned(); out.push(Relationship { source_artifact: Some(row.artifact_path.clone()), source_path: row.path.clone(), @@ -1229,9 +1343,14 @@ pub struct RelationshipSummary { fn resolve_references_full( rows: &[ValidationRow], index: &ResolutionIndex, -) -> (usize, Vec, std::collections::HashSet) { +) -> ( + usize, + Vec, + std::collections::HashSet, +) { let mut issues = Vec::new(); - let mut resolved_targets: std::collections::HashSet = std::collections::HashSet::new(); + let mut resolved_targets: std::collections::HashSet = + std::collections::HashSet::new(); let mut checked = 0usize; for row in rows { if row.spec_name.is_none() { @@ -1243,9 +1362,9 @@ fn resolve_references_full( } for reference in refs { checked += 1; - let code = match classify_reference(index, reference, &row.path) { + let code = match classify_reference(index, reference, &row.key) { ReferenceResolution::Resolved(target) => { - resolved_targets.insert(target.to_string()); + resolved_targets.insert(target.key.clone()); continue; } ReferenceResolution::NotFound => ISSUE_TARGET_NOT_FOUND, @@ -1276,23 +1395,23 @@ pub fn summary_from_rows(rows: &[ValidationRow]) -> RelationshipSummary { let broken = ref_issues.len(); let valid = checked - broken; - let known_paths: Vec<&str> = rows + let known_keys: Vec<&ArtifactKey> = rows .iter() .filter(|r| r.spec_name.is_some()) - .map(|r| r.path.as_str()) + .map(|r| &r.key) .collect(); - let orphaned = known_paths + let orphaned = known_keys .iter() - .filter(|p| !resolved_targets.contains(**p)) + .filter(|key| !resolved_targets.contains(**key)) .count(); let artifacts_with_rels = rows .iter() .filter(|r| r.spec_name.is_some() && !r.edges.is_empty()) .count(); - let coverage = if known_paths.is_empty() { + let coverage = if known_keys.is_empty() { 1.0 } else { - crate::pycompat::py_round(artifacts_with_rels as f64 / known_paths.len() as f64, 4) + crate::pycompat::py_round(artifacts_with_rels as f64 / known_keys.len() as f64, 4) }; RelationshipSummary { total: checked, diff --git a/rust/rac-engine/src/rename.rs b/rust/rac-engine/src/rename.rs index 3f88ee15..c349c1bf 100644 --- a/rust/rac-engine/src/rename.rs +++ b/rust/rac-engine/src/rename.rs @@ -725,7 +725,7 @@ pub fn compute_rename( let mut targets: Vec<&str> = index .get(&py_casefold(old_ref)) .iter() - .map(|(path, _)| path.as_str()) + .map(|candidate| candidate.path.as_str()) .collect::>() .into_iter() .collect(); diff --git a/rust/rac-engine/src/resolve.rs b/rust/rac-engine/src/resolve.rs index 130d0c90..81e314fb 100644 --- a/rust/rac-engine/src/resolve.rs +++ b/rust/rac-engine/src/resolve.rs @@ -173,16 +173,15 @@ pub(crate) fn entry_from_item(item: &CorpusItem, inbound: i64) -> IndexEntry { } } -/// `inbound_counts_from_corpus`: `{path -> count of resolved edges pointing -/// at it}` — resolved, unique, non-self edges only; external edges (ADR-087) -/// never resolve. -fn inbound_counts(items: &[CorpusItem]) -> HashMap { +/// `inbound_counts_from_corpus`: `{ArtifactKey -> count}` for resolved, +/// unique, non-self edges. External edges (ADR-087) never resolve. +fn inbound_counts(items: &[CorpusItem]) -> HashMap { let rows: Vec<_> = items .iter() .map(validation_row_from_item) .collect(); let index = resolution_index_from_rows(&rows); - let mut counts: HashMap = HashMap::new(); + let mut counts: HashMap = HashMap::new(); for row in &rows { for (section, refs) in &row.edges { let external = edge_spec(section).map(|e| e.external).unwrap_or(false); @@ -191,8 +190,8 @@ fn inbound_counts(items: &[CorpusItem]) -> HashMap { } for r in refs { let targets = index.get(&py_casefold(r)); - if targets.len() == 1 && targets[0].0 != row.path { - *counts.entry(targets[0].0.clone()).or_insert(0) += 1; + if targets.len() == 1 && targets[0].key != row.key { + *counts.entry(targets[0].key.clone()).or_insert(0) += 1; } } } @@ -210,7 +209,7 @@ pub fn index_from_items(items: &[CorpusItem]) -> Vec { let inbound = inbound_counts(items); items .iter() - .map(|item| entry_from_item(item, *inbound.get(&item.path).unwrap_or(&0))) + .map(|item| entry_from_item(item, *inbound.get(&item.key).unwrap_or(&0))) .collect() } diff --git a/rust/rac-engine/src/retrieve.rs b/rust/rac-engine/src/retrieve.rs index e71eeba9..9b4f32de 100644 --- a/rust/rac-engine/src/retrieve.rs +++ b/rust/rac-engine/src/retrieve.rs @@ -24,6 +24,7 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Map, Value}; use crate::budget::py_slice_to; +use crate::corpus::{ArtifactKey, ArtifactOrigin, ArtifactPath}; use crate::identity::artifact_identifier; use crate::pycompat::{py_casefold, py_strip, read_text_universal}; use crate::relationships::{ @@ -341,6 +342,12 @@ fn normalize_query(path: &str, root: &Path) -> Option { /// One live decision's declared `## Applies To` scope (`ScopeRow`). #[derive(Clone)] pub struct ScopeRow { + /// Absent only when reconstructed from the frozen v1 persistent store. + pub key: Option, + /// Absent only when reconstructed from the frozen v1 persistent store. + pub artifact_path: Option, + /// Absent only when reconstructed from the frozen v1 persistent store. + pub origin: Option, pub id: String, pub title: String, pub status: String, @@ -366,6 +373,9 @@ pub fn scope_rows_from_items(items: &[CorpusItem]) -> Vec { continue; } rows.push(ScopeRow { + key: Some(item.key.clone()), + artifact_path: Some(item.artifact_path.clone()), + origin: Some(item.origin.clone()), id: artifact_identifier(&item.artifact, Some(spec), &item.path), title: item.artifact.product.title.clone().unwrap_or_default(), status: artifact_status(&item.artifact), @@ -379,6 +389,9 @@ pub fn scope_rows_from_items(items: &[CorpusItem]) -> Vec { /// One governing decision (`GoverningDecision` — the fields retrieve and /// `decided decisions-for` read). pub struct GoverningDecision { + pub key: Option, + pub artifact_path: Option, + pub origin: Option, pub id: String, pub title: String, pub status: String, @@ -397,6 +410,9 @@ fn governing_decisions(rows: &[ScopeRow], directory: &str, path: &str) -> Vec ParentIdentity { + ParentIdentity::new(PARENT_SOURCE, PARENT_ALIAS).unwrap() +} + +fn item( + layer: Layer, + relative_path: &str, + id: &str, + artifact_type: &str, + status: &str, + relationships: &str, +) -> CorpusItem { + let required = match artifact_type { + "decision" => { + r#" +## Context + +Composition fixture. + +## Decision + +Keep resolution deterministic. + +## Consequences + +Every endpoint remains source-aware. +"# + } + "requirement" => { + r#" +## Problem + +Composition needs one resolver. + +## Requirements + +- [REQ-001] Resolution MUST remain deterministic. +"# + } + other => panic!("unsupported fixture type {other}"), + }; + let text = format!( + "---\nschema_version: 1\ntype: {artifact_type}\n---\n# {id}\n\n## ID\n\n{id}\n\n## Status\n\n{status}\n{required}\n{relationships}\n" + ); + let source = match layer { + Layer::Local => LOCAL_SOURCE, + Layer::Inherited => PARENT_SOURCE, + }; + let origin = match layer { + Layer::Local => CorpusLayer::local(source).origin(), + Layer::Inherited => { + CorpusLayer::inherited(source, PARENT_ALIAS, "sha256:0123456789abcdef").origin() + } + }; + let display = format!("/runtime/{source}/{relative_path}"); + CorpusItem::new( + display.clone(), + relative_path.to_string(), + parse_text(&text, &display), + spec_for(artifact_type), + origin, + PhysicalArtifactLocator::new( + PhysicalCorpusLocator::new( + format!("/runtime/{source}"), + format!("/runtime/{source}/decisions"), + ), + display, + ), + ) +} + +fn declaration(parent_id: &str, replacement: &str, rationale: &str) -> OverrideDeclaration { + OverrideDeclaration::parse( + &format!("{PARENT_ALIAS}::{parent_id}"), + replacement, + rationale, + ) + .unwrap() +} + +fn lookup_error(corpus: &ComposedCorpus, reference: &str) -> LookupError { + match corpus.resolve(reference) { + Ok(item) => panic!( + "{reference} unexpectedly resolved to {}", + item.key.canonical_id + ), + Err(error) => error, + } +} + +#[test] +fn collisions_are_sourced_and_never_pick_a_layer() { + let local = item( + Layer::Local, + "z-local.md", + "SHARED-001", + "requirement", + "Accepted", + "", + ); + let inherited = item( + Layer::Inherited, + "a-parent.md", + "SHARED-001", + "requirement", + "Accepted", + "", + ); + let corpus = ComposedCorpus::compose(vec![local], vec![inherited], parent(), Vec::new()); + + assert_eq!(corpus.findings().len(), 1); + assert_eq!(corpus.findings()[0].code, FINDING_CANONICAL_COLLISION); + assert_eq!( + corpus.findings()[0].artifacts, + vec![ + ArtifactKey::new(LOCAL_SOURCE, "SHARED-001"), + ArtifactKey::new(PARENT_SOURCE, "SHARED-001"), + ] + ); + assert_eq!( + lookup_error(&corpus, "SHARED-001"), + LookupError::Ambiguous(vec![ + ArtifactKey::new(LOCAL_SOURCE, "SHARED-001"), + ArtifactKey::new(PARENT_SOURCE, "SHARED-001"), + ]) + ); + assert_eq!( + corpus + .catalog() + .map(|entry| entry.artifact_path.clone()) + .collect::>(), + vec![ + ArtifactPath::new(LOCAL_SOURCE, "z-local.md"), + ArtifactPath::new(PARENT_SOURCE, "a-parent.md"), + ] + ); +} + +#[test] +fn aliases_are_unique_only_and_qualification_requires_a_canonical_id() { + let local = item( + Layer::Local, + "shared.md", + "APP-001", + "requirement", + "Accepted", + "", + ); + let inherited = item( + Layer::Inherited, + "shared.md", + "STD-001", + "requirement", + "Accepted", + "", + ); + let corpus = ComposedCorpus::compose(vec![local], vec![inherited], parent(), Vec::new()); + + assert!(matches!( + corpus.resolve("shared"), + Err(LookupError::Ambiguous(keys)) if keys.len() == 2 + )); + assert_eq!( + lookup_error(&corpus, "standards::shared"), + LookupError::QualifiedCanonicalRequired + ); + assert_eq!( + lookup_error(&corpus, "Standards::STD-001"), + LookupError::NotFound + ); + let resolved = corpus.resolve("standards::std-001").unwrap(); + assert_eq!(resolved.key, ArtifactKey::new(PARENT_SOURCE, "STD-001")); +} + +#[test] +fn a_valid_override_redirects_only_the_parent_canonical_id_and_retains_history() { + let replacement = item( + Layer::Local, + "replacement.md", + "APP-REQ", + "requirement", + "Accepted", + "", + ); + let rationale = item( + Layer::Local, + "rationale.md", + "APP-ADR", + "decision", + "Accepted", + "", + ); + let inherited = item( + Layer::Inherited, + "parent-policy.md", + "STD-REQ", + "requirement", + "Accepted", + "## Related Decisions\n\n- APP-ADR\n", + ); + let replacement_key = replacement.key.clone(); + let inherited_key = inherited.key.clone(); + let corpus = ComposedCorpus::compose_with_content( + vec![replacement, rationale], + vec![inherited], + parent(), + vec![declaration("STD-REQ", "APP-REQ", "APP-ADR")], + vec![ + (replacement_key.clone(), b"exact local bytes".to_vec()), + ( + inherited_key.clone(), + b"exact verified parent bytes".to_vec(), + ), + ], + ); + + assert!(corpus.findings().is_empty()); + assert_eq!(corpus.local_items().len(), 2); + assert_eq!(corpus.catalog().len(), 3); + assert_eq!(corpus.effective().len(), 2); + assert_eq!( + corpus.resolve("STD-REQ").unwrap().key, + ArtifactKey::new(LOCAL_SOURCE, "APP-REQ") + ); + assert_eq!( + corpus.resolve("standards::STD-REQ").unwrap().key, + ArtifactKey::new(PARENT_SOURCE, "STD-REQ") + ); + assert_eq!( + lookup_error(&corpus, "parent-policy"), + LookupError::NotFound + ); + assert_eq!( + lookup_error(&corpus, "standards::parent-policy"), + LookupError::QualifiedCanonicalRequired + ); + assert!(corpus.is_overridden(&ArtifactKey::new(PARENT_SOURCE, "STD-REQ"))); + assert_eq!( + corpus.overrides()[0].rationale, + ArtifactKey::new(LOCAL_SOURCE, "APP-ADR") + ); + assert_eq!( + corpus.content(&replacement_key), + Some(&b"exact local bytes"[..]) + ); + assert_eq!( + corpus.content(&inherited_key), + Some(&b"exact verified parent bytes"[..]) + ); + assert!(!corpus + .relationships() + .iter() + .any(|edge| edge.source_artifact + == Some(ArtifactPath::new(PARENT_SOURCE, "parent-policy.md")))); + assert!(corpus + .catalog_relationships() + .iter() + .any(|edge| edge.source_artifact + == Some(ArtifactPath::new(PARENT_SOURCE, "parent-policy.md")))); +} + +#[test] +fn a_same_id_override_is_the_only_way_to_clear_its_collision() { + let replacement = item( + Layer::Local, + "replacement.md", + "POLICY-001", + "requirement", + "Accepted", + "", + ); + let rationale = item( + Layer::Local, + "rationale.md", + "APP-ADR", + "decision", + "Accepted", + "", + ); + let inherited = item( + Layer::Inherited, + "policy.md", + "POLICY-001", + "requirement", + "Accepted", + "", + ); + let corpus = ComposedCorpus::compose( + vec![replacement, rationale], + vec![inherited], + parent(), + vec![declaration("POLICY-001", "POLICY-001", "APP-ADR")], + ); + + assert!(corpus.findings().is_empty()); + assert_eq!( + corpus.resolve("POLICY-001").unwrap().key, + ArtifactKey::new(LOCAL_SOURCE, "POLICY-001") + ); + assert_eq!( + corpus.resolve("standards::POLICY-001").unwrap().key, + ArtifactKey::new(PARENT_SOURCE, "POLICY-001") + ); +} + +#[test] +fn override_operands_are_canonical_local_and_decision_backed() { + assert_eq!( + CanonicalId::new("standards::STD-REQ"), + Err(OverrideSyntaxError::QualifiedLocalId) + ); + + let replacement = item( + Layer::Local, + "replacement-alias.md", + "APP-REQ", + "requirement", + "Accepted", + "", + ); + let draft_rationale = item( + Layer::Local, + "rationale.md", + "APP-ADR", + "decision", + "Proposed", + "", + ); + let inherited = item( + Layer::Inherited, + "policy.md", + "STD-REQ", + "requirement", + "Accepted", + "", + ); + let alias_operand = ComposedCorpus::compose( + vec![replacement.clone(), draft_rationale.clone()], + vec![inherited.clone()], + parent(), + vec![declaration("STD-REQ", "replacement-alias", "APP-ADR")], + ); + assert_eq!(alias_operand.findings()[0].code, FINDING_INVALID_OVERRIDE); + assert_eq!( + alias_operand.findings()[0].reason, + Some(InvalidOverrideReason::ReplacementNotFound) + ); + + let dead_rationale = ComposedCorpus::compose( + vec![replacement, draft_rationale], + vec![inherited], + parent(), + vec![declaration("STD-REQ", "APP-REQ", "APP-ADR")], + ); + assert_eq!(dead_rationale.findings()[0].code, FINDING_INVALID_OVERRIDE); + assert_eq!( + dead_rationale.findings()[0].reason, + Some(InvalidOverrideReason::RationaleNotLive) + ); +} + +#[test] +fn cross_source_relationships_resolve_to_typed_endpoints_and_check_types() { + let local = item( + Layer::Local, + "child-requirement.md", + "APP-REQ", + "requirement", + "Accepted", + "## Related Decisions\n\n- standards::STD-ADR\n", + ); + let parent_decision = item( + Layer::Inherited, + "parent-decision.md", + "STD-ADR", + "decision", + "Accepted", + "", + ); + let corpus = ComposedCorpus::compose(vec![local], vec![parent_decision], parent(), Vec::new()); + let edge = corpus + .relationships() + .into_iter() + .find(|edge| edge.relationship == "related_decisions") + .unwrap(); + assert_eq!( + edge.source_artifact, + Some(ArtifactPath::new(LOCAL_SOURCE, "child-requirement.md")) + ); + assert_eq!( + edge.resolved_artifact, + Some(ArtifactPath::new(PARENT_SOURCE, "parent-decision.md")) + ); + assert!(edge.issue.is_none()); + + let uppercase_alias = ComposedCorpus::compose( + vec![item( + Layer::Local, + "uppercase.md", + "APP-UPPER", + "requirement", + "Accepted", + "## Related Decisions\n\n- Standards::STD-ADR\n", + )], + vec![item( + Layer::Inherited, + "parent-decision.md", + "STD-ADR", + "decision", + "Accepted", + "", + )], + parent(), + Vec::new(), + ); + assert_eq!( + uppercase_alias.relationships()[0].issue.as_deref(), + Some(ISSUE_TARGET_NOT_FOUND) + ); + + let wrong_type = item( + Layer::Inherited, + "parent-requirement.md", + "STD-REQ", + "requirement", + "Accepted", + "", + ); + let local = item( + Layer::Local, + "child-requirement.md", + "APP-REQ", + "requirement", + "Accepted", + "## Related Decisions\n\n- STD-REQ\n", + ); + let corpus = ComposedCorpus::compose(vec![local], vec![wrong_type], parent(), Vec::new()); + assert!(corpus + .validate_relationships(".", true) + .issues + .iter() + .any(|issue| issue.code == ISSUE_TARGET_TYPE_MISMATCH)); +} + +#[test] +fn cross_source_cycles_are_computed_over_artifact_keys() { + let local = item( + Layer::Local, + "local.md", + "APP-ADR", + "decision", + "Accepted", + "## Supersedes\n\n- standards::STD-ADR\n", + ); + let inherited = item( + Layer::Inherited, + "parent.md", + "STD-ADR", + "decision", + "Accepted", + "## Supersedes\n\n- APP-ADR\n", + ); + let corpus = ComposedCorpus::compose(vec![local], vec![inherited], parent(), Vec::new()); + let validation = corpus.validate_relationships(".", true); + let cycle = validation + .issues + .iter() + .find(|issue| issue.code == ISSUE_RELATIONSHIP_CYCLE) + .expect("cross-source cycle"); + assert_eq!( + cycle.paths.as_ref().unwrap(), + &vec![ + "/runtime/acme/app/local.md".to_string(), + "/runtime/acme/standards/parent.md".to_string(), + ] + ); +} From e95d406040a704613485728c1cfd95b47c6c2ec7 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 30 Aug 2026 08:46:56 +0100 Subject: [PATCH 5/9] feat(engine): activate corpus federation Signed-off-by: Tom Ballard --- rust/rac-engine/src/agent_rules.rs | 23 +- rust/rac-engine/src/commands.rs | 477 +++++++++++++++++++--- rust/rac-engine/src/composition.rs | 217 +++++++++- rust/rac-engine/src/federated_corpus.rs | 503 ++++++++++++++++++++++++ rust/rac-engine/src/gate.rs | 112 +++++- rust/rac-engine/src/lib.rs | 2 + rust/rac-engine/src/markdown.rs | 44 ++- rust/rac-engine/src/output.rs | 241 +++++++++++- rust/rac-engine/src/parse.rs | 22 +- rust/rac-engine/src/rename.rs | 34 +- rust/rac-engine/src/resolve.rs | 66 +++- rust/rac-engine/src/retrieve.rs | 355 ++++++++++++++++- rust/rac-engine/src/scaffold.rs | 22 +- rust/rac-engine/src/sentry.rs | 146 ++++++- rust/rac-engine/tests/composition.rs | 12 + 15 files changed, 2137 insertions(+), 139 deletions(-) create mode 100644 rust/rac-engine/src/federated_corpus.rs 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() From 7f6a4afab80adc65b88e83eb260f6f1ee9f7e563 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 30 Aug 2026 08:46:58 +0100 Subject: [PATCH 6/9] feat(cache): persist verified federated generations Signed-off-by: Tom Ballard --- rust/rac-engine/src/commands.rs | 178 ++++- rust/rac-engine/src/composition.rs | 37 +- rust/rac-engine/src/delta_generation.rs | 34 +- rust/rac-engine/src/derived.rs | 150 +++- rust/rac-engine/src/derived_cache.rs | 748 +++++++++++++++++- rust/rac-engine/src/federation.rs | 115 ++- rust/rac-engine/src/freshness.rs | 4 +- rust/rac-engine/src/index_store.rs | 507 ++++++++++-- rust/rac-engine/src/output.rs | 10 +- rust/rac-engine/src/parallel_build.rs | 30 +- rust/rac-engine/src/portfolio.rs | 63 +- rust/rac-engine/src/read_model.rs | 32 +- rust/rac-engine/src/relationships.rs | 89 ++- rust/rac-engine/src/resolve.rs | 4 +- rust/rac-engine/src/retrieve.rs | 20 +- rust/rac-engine/src/validate.rs | 29 +- rust/rac-engine/tests/composition.rs | 44 +- rust/rac-engine/tests/federated_cache.rs | 599 ++++++++++++++ rust/rac-engine/tests/federation_loader.rs | 5 + rust/rac-engine/tests/index_store_vectors.rs | 94 ++- .../tests/source_aware_substrate.rs | 4 +- rust/spec/index-contracts.json | 6 +- rust/spec/index-store-format.md | 103 ++- 23 files changed, 2713 insertions(+), 192 deletions(-) create mode 100644 rust/rac-engine/tests/federated_cache.rs diff --git a/rust/rac-engine/src/commands.rs b/rust/rac-engine/src/commands.rs index be1afaec..282f1388 100644 --- a/rust/rac-engine/src/commands.rs +++ b/rust/rac-engine/src/commands.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; +use crate::corpus::{ArtifactOrigin, CorpusLayer}; use crate::output; use crate::parse::{parse_file, parse_text, Artifact, Issue}; use crate::relationships::{ @@ -59,6 +60,10 @@ pub struct FileValidation { pub artifact_type: String, pub status: &'static str, pub issues: Vec, + /// Stable source and layer identity for rows built from a composed corpus. + /// Released single-corpus validation leaves this absent so its rendered + /// output remains byte-identical. + pub origin: Option, } pub struct DirectoryValidation { @@ -126,6 +131,7 @@ pub fn validate_directory(directory: &str, recursive: bool) -> DirectoryValidati artifact_type, status: STATUS_SKIPPED, issues: Vec::new(), + origin: None, }; } let issues = apply_overrides( @@ -143,6 +149,7 @@ pub fn validate_directory(directory: &str, recursive: bool) -> DirectoryValidati artifact_type, status, issues, + origin: None, } }) .collect(); @@ -191,6 +198,7 @@ pub(crate) fn validate_directory_from_items( artifact_type, status: STATUS_SKIPPED, issues: Vec::new(), + origin: Some(item.origin.clone()), }; } let issues = if item.origin.layer == crate::corpus::Layer::Inherited { @@ -212,6 +220,7 @@ pub(crate) fn validate_directory_from_items( artifact_type, status, issues, + origin: Some(item.origin.clone()), } }) .collect(); @@ -448,6 +457,7 @@ pub fn validate_directory_incremental_in( line: i.line.map(i64::from), }) .collect(), + origin: None, }); let file_name = entry .display @@ -565,6 +575,25 @@ fn read_validate_input(target: &str) -> Result { read_named_file(target) } +/// Recover the declared inherited identity for a composed-corpus load error. +/// A malformed or unreadable manifest has no trustworthy provenance, while a +/// successfully parsed declaration can still identify failures from later +/// materialisation, pin, or composition checks. +fn manifest_failure_origin(directory: &str) -> Option { + let repository_root = crate::validate::repository_root(directory); + let manifest = crate::federation::load_manifest(&repository_root) + .ok() + .flatten()?; + Some( + CorpusLayer::inherited( + manifest.inherits.source, + manifest.inherits.alias, + manifest.inherits.digest, + ) + .origin(), + ) +} + pub fn cmd_validate(args: &ValidateArgs) -> i32 { // Directory? Validate every recognized artifact beneath it. if args.file != "-" && Path::new(&args.file).is_dir() { @@ -586,22 +615,26 @@ pub fn cmd_validate(args: &ValidateArgs) -> i32 { 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, - }, + Err(error) => { + let origin = manifest_failure_origin(&args.file); + 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, + )], + origin, + }], + okf: None, + } + } }; if args.sarif { emit(output::render_validate_sarif(&result)); @@ -2751,3 +2784,116 @@ pub fn cmd_telemetry(args: &TelemetryArgs) -> i32 { } EXIT_OK } + +#[cfg(test)] +mod validation_provenance_tests { + use std::fs; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + static COUNTER: AtomicUsize = AtomicUsize::new(0); + + fn scratch() -> PathBuf { + let count = COUNTER.fetch_add(1, Ordering::SeqCst); + let root = std::env::temp_dir().join(format!( + "asdecided-validation-provenance-{}-{count}", + std::process::id() + )); + fs::create_dir_all(root.join(".decided")).unwrap(); + fs::create_dir_all(root.join("decisions")).unwrap(); + root + } + + fn validation(origin: Option) -> DirectoryValidation { + DirectoryValidation { + directory: "decisions".to_string(), + recursive: true, + files: vec![FileValidation { + path: "decisions/example.md".to_string(), + artifact_type: "Decision".to_string(), + status: STATUS_INVALID, + issues: vec![Issue::new( + "error", + "missing-title", + "Title is required.".to_string(), + Some(3), + )], + origin, + }], + okf: None, + } + } + + #[test] + fn composed_validation_adds_machine_provenance_only() { + let legacy = validation(None); + let composed = validation(Some( + CorpusLayer::inherited( + "acme/standards", + "standards", + "sha256:0123456789abcdef", + ) + .origin(), + )); + + assert_eq!( + output::render_validate_dir_human(&legacy), + output::render_validate_dir_human(&composed) + ); + + let legacy_json: serde_json::Value = + serde_json::from_str(&output::render_validate_dir_json(&legacy)).unwrap(); + assert!(legacy_json["files"][0].get("provenance").is_none()); + let composed_json: serde_json::Value = + serde_json::from_str(&output::render_validate_dir_json(&composed)).unwrap(); + assert_eq!( + composed_json["files"][0]["provenance"], + serde_json::json!({ + "layer": "inherited", + "pin": "sha256:0123456789abcdef", + "source": "acme/standards", + }) + ); + + let legacy_sarif: serde_json::Value = + serde_json::from_str(&output::render_validate_sarif(&legacy)).unwrap(); + assert!(legacy_sarif["runs"][0]["results"][0] + .get("properties") + .is_none()); + let composed_sarif: serde_json::Value = + serde_json::from_str(&output::render_validate_sarif(&composed)).unwrap(); + assert_eq!( + composed_sarif["runs"][0]["results"][0]["properties"], + composed_json["files"][0]["provenance"] + ); + } + + #[test] + fn parsed_manifest_identity_provenances_later_load_failures() { + let root = scratch(); + fs::write( + root.join(".decided/config.yaml"), + "repository_key: APP\ncorpus:\n source: acme/app\n", + ) + .unwrap(); + let pin = format!("sha256:{}", "0".repeat(64)); + fs::write( + root.join(crate::federation::MANIFEST_RELATIVE_PATH), + format!( + "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: standards\n\ + source: acme/standards\nroot: vendor/standards\ncorpus: decisions\n\ + digest: {pin}\n```\n" + ), + ) + .unwrap(); + + let origin = manifest_failure_origin(&root.join("decisions").to_string_lossy()).unwrap(); + assert_eq!(origin.source, "acme/standards"); + assert_eq!(origin.layer, crate::corpus::Layer::Inherited); + assert_eq!(origin.alias.as_deref(), Some("standards")); + assert_eq!(origin.pin.as_deref(), Some(pin.as_str())); + + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/rust/rac-engine/src/composition.rs b/rust/rac-engine/src/composition.rs index d8b87f37..71fd5229 100644 --- a/rust/rac-engine/src/composition.rs +++ b/rust/rac-engine/src/composition.rs @@ -13,8 +13,8 @@ use crate::corpus::{ArtifactKey, ArtifactPath, Layer}; use crate::pycompat::py_casefold; use crate::relationships::{ resolution_index_from_rows, resolve_relationships, validation_from_rows_with_index, - validation_row_from_item, CorpusItem, Relationship, RelationshipValidation, - ResolutionCandidate, ResolutionIndex, ValidationRow, + validation_row_from_item, CorpusItem, Relationship, RelationshipSummary, + RelationshipValidation, ResolutionCandidate, ResolutionIndex, ValidationRow, }; use crate::resolve::{entry_from_item, identity_entry_from_item, is_live_decision, IndexEntry}; @@ -717,6 +717,27 @@ impl ComposedCorpus { resolve_relationships(&self.catalog_rows, &self.resolution_index) } + /// Portfolio relationship metrics through the same qualified/redirect + /// index as lookup and graph construction. + pub fn relationship_summary(&self) -> RelationshipSummary { + let mut summary = crate::relationships::summary_from_rows_with_index( + &self.effective_rows, + &self.resolution_index, + true, + ); + let before = summary.issues.len(); + summary.issues.retain(|issue| { + !issue.origin.as_ref().is_some_and(|origin| { + origin.layer == Layer::Inherited + && crate::relationships::relationship_severity(&issue.code) != "error" + }) + }); + let parent_owned = before - summary.issues.len(); + summary.broken -= parent_owned; + summary.valid += parent_owned; + summary + } + /// Run the existing relationship validator over source-aware keys. The /// child repository root is intentionally supplied here so inherited /// filesystem scope is checked against child code. @@ -725,14 +746,22 @@ impl ComposedCorpus { child_directory: &str, recursive: bool, ) -> RelationshipValidation { - validation_from_rows_with_index( + let mut validation = validation_from_rows_with_index( child_directory, &self.effective_rows, &self.catalog_rows, recursive, &self.resolution_index, false, - ) + true, + ); + validation.issues.retain(|issue| { + !issue.origin.as_ref().is_some_and(|origin| { + origin.layer == Layer::Inherited + && crate::relationships::relationship_severity(&issue.code) != "error" + }) + }); + validation } } diff --git a/rust/rac-engine/src/delta_generation.rs b/rust/rac-engine/src/delta_generation.rs index d182e37c..0d3566bf 100644 --- a/rust/rac-engine/src/delta_generation.rs +++ b/rust/rac-engine/src/delta_generation.rs @@ -1228,12 +1228,18 @@ impl DeltaGeneration { pub fn materialize_derived(&self, directory: &str, recursive: bool) -> DerivedIndex { let (index_entries, field_tokens) = self.search.entries_and_fields(&self.graph); let compatibility_layer = crate::corpus::compatible_local_layer(directory); + let normalized_root = crate::walk::normalize_root(directory); + let display_prefix = format!("{normalized_root}/"); let source_artifacts: Vec = index_entries .iter() .map(|entry| { + let identity_path = entry + .path + .strip_prefix(&display_prefix) + .unwrap_or(&entry.path); let path = self .identity - .artifact_path_for_path(&entry.path) + .artifact_path_for_path(identity_path) .cloned() .unwrap_or_else(|| { crate::corpus::ArtifactPath::new( @@ -1243,7 +1249,7 @@ impl DeltaGeneration { }); let origin = self .identity - .origin_for_path(&entry.path) + .origin_for_path(identity_path) .cloned() .unwrap_or_else(|| compatibility_layer.origin()); crate::derived::SourceAwareArtifact { @@ -1263,13 +1269,35 @@ impl DeltaGeneration { if layers.is_empty() { layers.push(compatibility_layer); } + let identity_entries = index_entries + .iter() + .cloned() + .map(|mut entry| { + entry.search_sections.clear(); + entry.inbound_count = 0; + entry + }) + .collect(); + let live_decision_paths = self.scope.live_paths(); + let live_path_set: std::collections::HashSet<&str> = + live_decision_paths.iter().map(String::as_str).collect(); + let live_decision_keys = source_artifacts + .iter() + .filter(|artifact| live_path_set.contains(artifact.display_path.as_str())) + .map(|artifact| artifact.key.clone()) + .collect(); DerivedIndex { layers, source_artifacts, + resolution: Box::new(crate::derived::ResolutionProjection { + entries: identity_entries, + canonical_redirects: Vec::new(), + }), index_entries, field_tokens, relationships: self.graph.relationships(), - live_decision_paths: self.scope.live_paths(), + live_decision_keys, + live_decision_paths, portfolio_summary: self.summary.value(directory, recursive), scope_rows: self.scope.rows(), } diff --git a/rust/rac-engine/src/derived.rs b/rust/rac-engine/src/derived.rs index 7f2d44a0..e3546a01 100644 --- a/rust/rac-engine/src/derived.rs +++ b/rust/rac-engine/src/derived.rs @@ -7,6 +7,7 @@ use serde_json::Value; +use crate::composition::ComposedCorpus; use crate::corpus::{ArtifactKey, ArtifactOrigin, ArtifactPath, CorpusLayer}; use crate::relationships::{corpus_items, relationships_from_corpus, CorpusItem, Relationship}; use crate::resolve::{entry_from_item, field_tokens_of, is_live_decision, FieldTokens, IndexEntry}; @@ -17,9 +18,8 @@ pub const SCHEMA_VERSION: &str = "3"; pub(crate) const DECISION_TYPE: &str = "decision"; -/// The source-aware identity projection parallel to the existing searchable -/// rows. It remains in memory while the frozen v1 store is the compatibility -/// format; the versioned store cutover will persist these exact values. +/// The source-aware identity projection parallel to the searchable rows. The +/// v2 store persists and reconstructs these exact values. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SourceAwareArtifact { pub key: ArtifactKey, @@ -29,12 +29,39 @@ pub struct SourceAwareArtifact { pub display_path: String, } +/// One validated canonical redirect retained by a composed generation. +/// +/// The declaration's spelling belongs to the manifest/composition boundary; +/// persistence needs only the three stable endpoints which were validated by +/// that boundary. Keeping this projection in the read model lets a warm store +/// reproduce the same override semantics without reparsing raw YAML. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct CanonicalRedirect { + pub parent: ArtifactKey, + pub replacement: ArtifactKey, + pub rationale: ArtifactKey, +} + +/// Point-resolution state is boxed as one coherent projection. Besides +/// keeping the main derived bundle compact, this makes it difficult for a +/// caller to update authorized aliases without the matching redirect rows. +pub struct ResolutionProjection { + pub entries: Vec, + pub canonical_redirects: Vec, +} + /// The expensive derived structures for one corpus snapshot. pub struct DerivedIndex { /// Stable layer identities represented in this generation. pub layers: Vec, /// Per-document source identity in the same order as `index_entries`. pub source_artifacts: Vec, + /// The central composition layer's exact point-resolution projection. + /// + /// Unlike the searchable effective set, this may retain qualified parent + /// rows and attach an overridden parent's canonical id to its local + /// replacement. Its aliases are therefore authoritative for `resolve`. + pub resolution: Box, /// Repository index rows in walk (sorted-path) order — docid order. pub index_entries: Vec, /// Per-entry BM25F field-token vectors, parallel to `index_entries`. @@ -42,6 +69,9 @@ pub struct DerivedIndex { /// without re-keying, and paths are unique within a walk.) pub field_tokens: Vec, pub relationships: Vec, + /// Stable identities used for liveness filtering in composed stores. + pub live_decision_keys: Vec, + /// Released display-path projection retained for single-corpus callers. pub live_decision_paths: Vec, /// The `get_summary` portfolio dict (ADR-103) — the JSON payload the /// store persists verbatim in `portfolio.seg`. @@ -58,19 +88,32 @@ pub fn build_derived_index_from_items( // Resolve the graph once; inbound degree is counted off the resolved // edges exactly as `inbound_counts_from_relationships` does. let relationships = relationships_from_corpus(items); - let mut inbound: std::collections::HashMap<&str, i64> = std::collections::HashMap::new(); + let mut inbound: std::collections::HashMap<&ArtifactPath, i64> = + std::collections::HashMap::new(); for rel in &relationships { - if let Some(resolved) = &rel.resolved_path { - *inbound.entry(resolved.as_str()).or_insert(0) += 1; + if let Some(resolved) = &rel.resolved_artifact { + *inbound.entry(resolved).or_insert(0) += 1; } } let index_entries: Vec = items .iter() .map(|item| { - entry_from_item(item, inbound.get(item.path.as_str()).copied().unwrap_or(0)) + entry_from_item( + item, + inbound.get(&item.artifact_path).copied().unwrap_or(0), + ) }) .collect(); let field_tokens: Vec = index_entries.iter().map(field_tokens_of).collect(); + let identity_entries: Vec = index_entries + .iter() + .cloned() + .map(|mut entry| { + entry.search_sections.clear(); + entry.inbound_count = 0; + entry + }) + .collect(); let source_artifacts: Vec = items .iter() .map(|item| SourceAwareArtifact { @@ -97,19 +140,112 @@ pub fn build_derived_index_from_items( }) .map(|item| item.path.clone()) .collect(); + let live_decision_keys: Vec = items + .iter() + .filter(|item| { + item.spec.map(|s| s.name == DECISION_TYPE).unwrap_or(false) + && is_live_decision(&item.artifact) + }) + .map(|item| item.key.clone()) + .collect(); let summary = crate::portfolio::portfolio_from_corpus(directory, items, recursive); DerivedIndex { layers, source_artifacts, + resolution: Box::new(ResolutionProjection { + entries: identity_entries, + canonical_redirects: Vec::new(), + }), index_entries, field_tokens, relationships, + live_decision_keys, live_decision_paths, portfolio_summary: crate::output::portfolio_summary_value(&summary), scope_rows: scope_rows_from_items(items), } } +/// Build the persistable projection from the one authoritative composed +/// corpus and its already-captured governing inputs. This adapter performs no +/// corpus walk and does not rebuild a source-blind relationship overlay. +pub(crate) fn build_derived_index_from_composed( + stable_directory: &str, + child_directory: &str, + recursive: bool, + layers: &[CorpusLayer], + child_config_bytes: &[u8], + composed: &ComposedCorpus, +) -> DerivedIndex { + let items: Vec = composed.effective().cloned().collect(); + let index_entries = composed.effective_index(); + let field_tokens: Vec = index_entries.iter().map(field_tokens_of).collect(); + let source_artifacts: Vec = items + .iter() + .map(|item| SourceAwareArtifact { + key: item.key.clone(), + path: item.artifact_path.clone(), + origin: item.origin.clone(), + display_path: item.path.clone(), + }) + .collect(); + let live_decision_paths: Vec = items + .iter() + .filter(|item| { + item.spec.map(|spec| spec.name == DECISION_TYPE).unwrap_or(false) + && is_live_decision(&item.artifact) + }) + .map(|item| item.path.clone()) + .collect(); + let live_decision_keys: Vec = items + .iter() + .filter(|item| { + item.spec.map(|spec| spec.name == DECISION_TYPE).unwrap_or(false) + && is_live_decision(&item.artifact) + }) + .map(|item| item.key.clone()) + .collect(); + let relationships = composed.relationships(); + let relationship_summary = composed.relationship_summary(); + let relationships_ok = composed + .validate_relationships(child_directory, recursive) + .ok(); + let overrides = crate::validate::overrides_from_config_bytes(child_config_bytes); + let portfolio = crate::portfolio::portfolio_from_corpus_with_analysis( + stable_directory, + &items, + recursive, + &overrides, + relationship_summary, + relationships_ok, + ); + let canonical_redirects = composed + .overrides() + .iter() + .map(|mapping| CanonicalRedirect { + parent: mapping.parent.clone(), + replacement: mapping.replacement.clone(), + rationale: mapping.rationale.clone(), + }) + .collect(); + + DerivedIndex { + layers: layers.to_vec(), + source_artifacts, + resolution: Box::new(ResolutionProjection { + entries: composed.identity_index(), + canonical_redirects, + }), + index_entries, + field_tokens, + relationships, + live_decision_keys, + live_decision_paths, + portfolio_summary: crate::output::portfolio_summary_value(&portfolio), + scope_rows: scope_rows_from_items(&items), + } +} + /// Build the derived structures fresh from one corpus walk (the miss path). pub fn build_derived_index(directory: &str, recursive: bool) -> DerivedIndex { let items = corpus_items(directory, recursive); diff --git a/rust/rac-engine/src/derived_cache.rs b/rust/rac-engine/src/derived_cache.rs index c4889226..3dd98100 100644 --- a/rust/rac-engine/src/derived_cache.rs +++ b/rust/rac-engine/src/derived_cache.rs @@ -7,6 +7,7 @@ //! change latency, never an answer or an exit code (ADR-080). use std::collections::BTreeSet; +use std::fmt; use std::path::{Path, PathBuf}; use rayon::prelude::*; @@ -235,7 +236,58 @@ fn write_marker(cache_dir: &Path, corpus_hash: &str, store_written: bool) -> boo /// reopened (ADR-080 — never a failure). pub enum ReadModel { View(MmapIndexReader), - Fresh(DerivedIndex), + Fresh(Box), +} + +impl ReadModel { + /// Exact point resolution through the central identity projection. The + /// mapped and fresh arms therefore preserve qualified parent aliases and + /// canonical override redirects identically. + pub fn resolve(&self, reference: &str) -> crate::resolve::ResolutionResult { + match self { + Self::View(reader) => crate::read_model::store_resolve(reader, reference), + Self::Fresh(derived) => { + crate::resolve::resolve_in_index(&derived.resolution.entries, reference) + } + } + } + + /// Catalog identity rows matching one stable `(source, canonical_id)`. + pub fn identity_entries_for_key( + &self, + key: &crate::corpus::ArtifactKey, + ) -> Result, crate::index_format::IndexFormatError> { + match self { + Self::View(reader) => reader + .docids_for_key(key)? + .into_iter() + .map(|docid| reader.identity_entry(docid)) + .collect(), + Self::Fresh(derived) => Ok(derived + .resolution + .entries + .iter() + .filter(|entry| entry.key.as_ref() == Some(key)) + .cloned() + .collect()), + } + } + + pub fn canonical_redirect( + &self, + parent: &crate::corpus::ArtifactKey, + ) -> Result, crate::index_format::IndexFormatError> + { + match self { + Self::View(reader) => reader.canonical_redirect(parent), + Self::Fresh(derived) => Ok(derived + .resolution + .canonical_redirects + .iter() + .find(|redirect| &redirect.parent == parent) + .cloned()), + } + } } pub struct DerivedIndexCache { @@ -336,7 +388,7 @@ impl DerivedIndexCache { return ReadModel::View(view); } } - ReadModel::Fresh(derived) + ReadModel::Fresh(Box::new(derived)) } /// Whether a store directory currently exists for `corpus_hash`. @@ -345,6 +397,698 @@ impl DerivedIndexCache { } } +// --------------------------------------------------------------------------- +// Federated logical generations and request-boundary freshness (ADR-143). +// --------------------------------------------------------------------------- + +/// Domain for the logical composed-generation key. This is independent from +/// the parent pin domain: it identifies all child + declaration + verified +/// parent inputs which can alter the effective read model. +pub const FEDERATED_GENERATION_DOMAIN: &[u8] = b"asdecided-federated-generation-v1\0"; + +fn generation_frame(hasher: &mut crate::sha256::Sha256, tag: u8, bytes: &[u8]) { + hasher.update(&[tag]); + hasher.update(&(bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); +} + +fn legacy_child_corpus_hash( + directory: &str, + recursive: bool, +) -> String { + let mut hasher = crate::sha256::Sha256::new(); + for entry in find_markdown_files(directory, recursive) { + let rel = entry.components.join("/"); + hasher.update(rel.as_bytes()); + hasher.update(b"\0"); + hasher.update(crate::index_store::content_hash(&entry.abs).as_bytes()); + hasher.update(b"\0"); + } + hasher.hexdigest() +} + +fn child_snapshot_hash(files: &[crate::federation::SnapshotFile]) -> String { + let mut hasher = crate::sha256::Sha256::new(); + for file in files { + hasher.update(file.relative_path.as_bytes()); + hasher.update(b"\0"); + hasher.update(crate::sha256::hexdigest(&file.bytes).as_bytes()); + hasher.update(b"\0"); + } + hasher.hexdigest() +} + +fn stable_child_corpus_path( + repository_root: &Path, + corpus_root: &Path, +) -> Result { + let repository_root = std::fs::canonicalize(repository_root).map_err(|error| { + FederatedCacheError::ChildSnapshot { + logical_path: "child repository".to_string(), + message: error.to_string(), + } + })?; + let corpus_root = std::fs::canonicalize(corpus_root).map_err(|error| { + FederatedCacheError::ChildSnapshot { + logical_path: "child corpus".to_string(), + message: error.to_string(), + } + })?; + let relative = corpus_root.strip_prefix(&repository_root).map_err(|_| { + FederatedCacheError::ChildSnapshot { + logical_path: "child corpus".to_string(), + message: "child corpus is outside the child repository".to_string(), + } + })?; + let path = relative + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + Ok(if path.is_empty() { ".".to_string() } else { path }) +} + +fn capture_child_snapshot( + directory: &str, + recursive: bool, + parent: &crate::federation::VerifiedParent, +) -> Result, FederatedCacheError> { + find_markdown_files(directory, recursive) + .into_iter() + .filter(|entry| !parent.contains_materialised_path(&entry.abs)) + .map(|entry| { + let bytes = std::fs::read(&entry.abs).map_err(|error| { + FederatedCacheError::ChildSnapshot { + logical_path: entry.rel(), + message: error.to_string(), + } + })?; + Ok(crate::federation::SnapshotFile { + relative_path: entry.rel(), + absolute_path: entry.abs, + bytes, + }) + }) + .collect() +} + +/// Stable, typed failures at the verified generation/build boundary. +#[derive(Debug)] +pub enum FederatedCacheError { + Parent(crate::federation::ParentCorpusError), + ChildSnapshot { + logical_path: String, + message: String, + }, + Composition { code: String, message: String }, + InvalidModel { message: String }, +} + +impl FederatedCacheError { + pub fn stable_code(&self) -> &str { + match self { + Self::Parent(error) => error.stable_code(), + Self::ChildSnapshot { .. } => "federated-child-snapshot-failed", + Self::Composition { code, .. } => code, + Self::InvalidModel { .. } => "federated-cache-invalid-model", + } + } + + /// Adapt the central composition layer's stable finding into the cache + /// boundary without erasing its code. + pub fn composition(code: impl Into, message: impl Into) -> Self { + Self::Composition { + code: code.into(), + message: message.into(), + } + } +} + +impl From for FederatedCacheError { + fn from(error: crate::federation::ParentCorpusError) -> Self { + Self::Parent(error) + } +} + +impl fmt::Display for FederatedCacheError { + 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 logical_path = 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 { + "federation input" + }; + message = message.replace(&path.display().to_string(), logical_path); + } + write!(formatter, "{}: {message}", error.stable_code()) + } + Self::ChildSnapshot { + logical_path, + message, + .. + } => { + write!(formatter, "{}: {logical_path}: {message}", self.stable_code()) + } + Self::Composition { code, message } => write!(formatter, "{code}: {message}"), + Self::InvalidModel { message } => { + write!(formatter, "federated-cache-invalid-model: {message}") + } + } + } +} + +impl std::error::Error for FederatedCacheError {} + +/// Explicit inputs and stable identities for one verified composed +/// generation. Filesystem locations are observability inputs only and do not +/// enter `cache_key` except through their exact bytes or relative identities. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FederatedGenerationIdentity { + pub cache_key: String, + pub child_corpus_hash: String, + /// Child-repository-relative corpus root; never a checkout path. + pub child_corpus_path: String, + pub recursive: bool, + pub layers: Vec, + pub watched_roots: Vec, + pub watched_files: Vec, +} + +/// A logical cache generation. The legacy variant deliberately preserves the +/// released single-corpus key when `.decided/corpus.md` is absent. +#[derive(Debug, Clone, PartialEq)] +pub enum LogicalGeneration { + Legacy { corpus_hash: String }, + Federated { + identity: FederatedGenerationIdentity, + parent: Box, + child_files: Vec, + }, +} + +impl LogicalGeneration { + pub fn cache_key(&self) -> &str { + match self { + Self::Legacy { corpus_hash } => corpus_hash, + Self::Federated { identity, .. } => &identity.cache_key, + } + } + + pub fn verified_parent(&self) -> Option<&crate::federation::VerifiedParent> { + match self { + Self::Legacy { .. } => None, + Self::Federated { parent, .. } => Some(parent), + } + } + + pub fn identity(&self) -> Option<&FederatedGenerationIdentity> { + match self { + Self::Legacy { .. } => None, + Self::Federated { identity, .. } => Some(identity), + } + } + + /// Exact local bytes which produced this federated generation key. The + /// central composer must consume these rows rather than walking again. + pub fn child_files(&self) -> Option<&[crate::federation::SnapshotFile]> { + match self { + Self::Legacy { .. } => None, + Self::Federated { child_files, .. } => Some(child_files), + } + } +} + +/// Verify the optional parent before constructing a cache key. A federated +/// key cannot be obtained from declaration bytes alone: the loader must first +/// prove that the materialised parent still matches its source and pin. +pub fn capture_logical_generation( + child_repository_root: impl AsRef, + child_corpus: &str, + recursive: bool, +) -> Result { + let verified = crate::federation::verify_parent(child_repository_root)?; + let Some(parent) = verified else { + return Ok(LogicalGeneration::Legacy { + corpus_hash: legacy_child_corpus_hash(child_corpus, recursive), + }); + }; + + capture_federated_generation(parent, child_corpus, recursive) +} + +fn capture_federated_generation( + parent: crate::federation::VerifiedParent, + child_corpus: &str, + recursive: bool, +) -> Result { + let child_files = capture_child_snapshot(child_corpus, recursive, &parent)?; + let child_hash = child_snapshot_hash(&child_files); + let child_corpus_path = stable_child_corpus_path( + &parent.child_repository_root, + Path::new(child_corpus), + )?; + + let local_layer = crate::corpus::CorpusLayer::local(parent.child_source.clone()); + let inherited_layer = crate::corpus::CorpusLayer::inherited( + parent.declaration.source.clone(), + parent.declaration.alias.clone(), + parent.digest.clone(), + ); + let override_payload = match &parent.override_mapping_bytes { + Some(bytes) => { + let mut payload = Vec::with_capacity(bytes.len() + 1); + payload.push(1); + payload.extend_from_slice(bytes); + payload + } + None => vec![0], + }; + let mut hasher = crate::sha256::Sha256::new(); + hasher.update(FEDERATED_GENERATION_DOMAIN); + generation_frame(&mut hasher, 0x01, child_hash.as_bytes()); + generation_frame(&mut hasher, 0x02, parent.child_source.as_bytes()); + generation_frame(&mut hasher, 0x03, &parent.child_config_bytes); + generation_frame(&mut hasher, 0x04, &parent.manifest_bytes); + generation_frame(&mut hasher, 0x05, parent.declaration.source.as_bytes()); + generation_frame(&mut hasher, 0x06, parent.digest.as_bytes()); + generation_frame(&mut hasher, 0x07, &parent.config_bytes); + generation_frame(&mut hasher, 0x08, parent.declaration.alias.as_bytes()); + generation_frame(&mut hasher, 0x09, &override_payload); + generation_frame(&mut hasher, 0x0a, local_layer.layer.as_str().as_bytes()); + generation_frame( + &mut hasher, + 0x0b, + inherited_layer.layer.as_str().as_bytes(), + ); + generation_frame(&mut hasher, 0x0c, &[u8::from(recursive)]); + generation_frame(&mut hasher, 0x0d, child_corpus_path.as_bytes()); + let cache_key = hasher.hexdigest(); + + let mut watched_files = Vec::with_capacity(parent.files.len() + child_files.len() + 3); + watched_files.push(parent.child_config_path.clone()); + watched_files.push(parent.manifest_path.clone()); + watched_files.push(parent.config_path.clone()); + watched_files.extend(child_files.iter().map(|file| file.absolute_path.clone())); + watched_files.extend(parent.files.iter().map(|file| file.absolute_path.clone())); + let watched_roots = vec![PathBuf::from(child_corpus), parent.corpus_root.clone()]; + + Ok(LogicalGeneration::Federated { + identity: FederatedGenerationIdentity { + cache_key, + child_corpus_hash: child_hash, + child_corpus_path, + recursive, + layers: vec![local_layer, inherited_layer], + watched_roots, + watched_files, + }, + parent: Box::new(parent), + child_files, + }) +} + +/// Why a request produced the returned model. A relevant input change is +/// always a complete recomposition; delta mutation is intentionally not used +/// across a federation boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FederatedCacheRefresh { + WarmReuse, + StoreHit, + Recomposed, +} + +/// One central composition plus its persistable projection. A cache miss may +/// persist `model`; every first read (including a store hit) retains +/// `composed` so downstream consumers never reconstruct an overlay. +pub struct FederatedCacheBuild { + pub composed: C, + pub model: DerivedIndex, +} + +impl FederatedCacheBuild { + pub fn new(composed: C, model: DerivedIndex) -> Self { + Self { composed, model } + } +} + +/// One successfully verified read session. The model and exact logical inputs +/// are borrowed together, preventing callers from serving a retained model +/// after a later parent-verification failure. +pub struct FederatedCacheRead<'a, C = ()> { + pub refresh: FederatedCacheRefresh, + pub model: &'a ReadModel, + pub generation: &'a LogicalGeneration, + /// The authoritative central composition built from this generation's + /// captured bytes. MCP and other consumers borrow this directly. + pub composed: &'a C, +} + +impl FederatedCacheRead<'_, C> { + /// Captured inherited bytes addressed by stable source-relative path. + pub fn inherited_bytes(&self, path: &crate::corpus::ArtifactPath) -> Option<&[u8]> { + self.generation + .verified_parent()? + .artifact_bytes(path) + } + + /// Existing `get_artifact` decoding over captured bytes, without a second + /// filesystem read through the public source-relative `path` field. + pub fn inherited_text(&self, path: &crate::corpus::ArtifactPath) -> Option { + self.generation.verified_parent()?.artifact_text(path) + } + + pub fn override_mapping(&self) -> Option<&serde_yaml::Value> { + self.generation.verified_parent()?.overrides.as_ref() + } +} + +/// Server-lifetime cache gate for a composed corpus. Every request captures a +/// fresh verified generation before it can return the resident model. A +/// verification error leaves the old model retained but inaccessible: callers +/// receive the error and therefore cannot serve a stale formerly-valid parent. +pub struct FederatedCacheTracker { + cache: DerivedIndexCache, + current_key: Option, + model: Option, + generation: Option, + composed: Option, +} + +impl FederatedCacheTracker { + pub fn new(cache_dir: PathBuf) -> Self { + Self { + cache: DerivedIndexCache { cache_dir }, + current_key: None, + model: None, + generation: None, + composed: None, + } + } + + fn open_generation(&self, cache_key: &str) -> Option { + if !marker_valid(&self.cache.cache_dir, cache_key) { + return None; + } + match open_store(&self.cache.cache_dir, cache_key, SCHEMA_VERSION) { + Some(view) => Some(ReadModel::View(view)), + None => { + remove_store(&self.cache.cache_dir, cache_key); + None + } + } + } + + fn persist(&self, cache_key: &str, derived: DerivedIndex) -> ReadModel { + let written = write_store(&self.cache.cache_dir, cache_key, SCHEMA_VERSION, &derived); + if write_marker(&self.cache.cache_dir, cache_key, written) { + if let Some(view) = open_store(&self.cache.cache_dir, cache_key, SCHEMA_VERSION) { + return ReadModel::View(view); + } + } + ReadModel::Fresh(Box::new(derived)) + } + + pub(crate) fn read_or_recompose( + &mut self, + child_repository_root: impl AsRef, + child_corpus: &str, + recursive: bool, + build: F, + ) -> Result, FederatedCacheError> + where + F: FnOnce(&LogicalGeneration) -> Result, FederatedCacheError>, + { + // Reverification and exact input capture happen before either the + // resident-model fast path or a persistent-store lookup. + let generation = + capture_logical_generation(child_repository_root, child_corpus, recursive)?; + let cache_key = generation.cache_key().to_string(); + if self.current_key.as_deref() == Some(cache_key.as_str()) { + if self.generation.as_ref() != Some(&generation) { + let built = build(&generation)?; + validate_federated_model(&generation, &built.model)?; + self.composed = Some(built.composed); + } + // Replace even an equal generation with the just-verified strict + // snapshot so the returned content handle is request-current. + self.generation = Some(generation); + return Ok(FederatedCacheRead { + refresh: FederatedCacheRefresh::WarmReuse, + model: self.model.as_ref().expect("current key has a model"), + generation: self.generation.as_ref().expect("generation installed"), + composed: self.composed.as_ref().expect("current key has a composition"), + }); + } + + // A process-local miss and a persistent store hit both compose once + // from this exact verified generation. The store can replace only the + // expensive derived projection, never the authoritative corpus. + let built = build(&generation)?; + validate_federated_model(&generation, &built.model)?; + let cold = self.current_key.is_none(); + let (refresh, model) = if cold { + match self.open_generation(&cache_key) { + Some(model) => (FederatedCacheRefresh::StoreHit, model), + None => ( + FederatedCacheRefresh::Recomposed, + self.persist(&cache_key, built.model), + ), + } + } else { + // A changed manifest/config/corpus/pin/override is a federation + // topology change. Rebuild the complete composed model rather + // than applying a source-blind document delta. + ( + FederatedCacheRefresh::Recomposed, + self.persist(&cache_key, built.model), + ) + }; + self.current_key = Some(cache_key); + self.model = Some(model); + self.generation = Some(generation); + self.composed = Some(built.composed); + Ok(FederatedCacheRead { + refresh, + model: self.model.as_ref().expect("model just installed"), + generation: self.generation.as_ref().expect("generation just installed"), + composed: self.composed.as_ref().expect("composition just installed"), + }) + } + + pub fn current_key(&self) -> Option<&str> { + self.current_key.as_deref() + } +} + +impl FederatedCacheTracker { + /// Read the authoritative composed corpus and its persisted projection + /// from one exact verified generation. Callers cannot supply an alternate + /// overlay or reopen either corpus between cache-key capture and build. + pub fn read_composed( + &mut self, + child_repository_root: impl AsRef, + child_corpus: &str, + recursive: bool, + ) -> Result, FederatedCacheError> + { + self.read_or_recompose( + child_repository_root, + child_corpus, + recursive, + |generation| { + let Some(identity) = generation.identity() else { + return Err(FederatedCacheError::InvalidModel { + message: "composed cache reads require .decided/corpus.md".to_string(), + }); + }; + let parent = generation + .verified_parent() + .expect("federated identity has a verified parent"); + let child_files = generation + .child_files() + .expect("federated identity has captured child files"); + let composed = crate::federated_corpus::compose_verified_generation_from_snapshot( + child_corpus, + parent, + child_files, + ) + .map_err(|error| { + FederatedCacheError::composition(error.stable_code(), error.to_string()) + })?; + let model = crate::derived::build_derived_index_from_composed( + &identity.child_corpus_path, + child_corpus, + identity.recursive, + &identity.layers, + &parent.child_config_bytes, + &composed, + ); + Ok(FederatedCacheBuild::new(composed, model)) + }, + ) + } +} + +fn stable_public_path(path: &str) -> bool { + !path.is_empty() + && !Path::new(path).is_absolute() + && !Path::new(path) + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) +} + +fn validate_federated_model( + generation: &LogicalGeneration, + derived: &DerivedIndex, +) -> Result<(), FederatedCacheError> { + let Some(identity) = generation.identity() else { + return Ok(()); + }; + let mut expected_layers = identity.layers.clone(); + expected_layers.sort(); + let mut actual_layers = derived.layers.clone(); + actual_layers.sort(); + if actual_layers != expected_layers { + return Err(FederatedCacheError::InvalidModel { + message: "derived layers do not match the verified generation".to_string(), + }); + } + let expected_sources: std::collections::BTreeSet<&str> = expected_layers + .iter() + .map(|layer| layer.source.as_str()) + .collect(); + let expected_by_source: std::collections::BTreeMap<&str, &crate::corpus::CorpusLayer> = + expected_layers + .iter() + .map(|layer| (layer.source.as_str(), layer)) + .collect(); + let invalid_path = |source: &str, relative_path: &str, display_path: &str| { + !expected_sources.contains(source) + || !stable_public_path(relative_path) + || display_path != relative_path + }; + if derived.source_artifacts.len() != derived.index_entries.len() + || derived + .source_artifacts + .iter() + .zip(&derived.index_entries) + .any(|(artifact, entry)| { + invalid_path( + &artifact.path.source, + &artifact.path.relative_path, + &artifact.display_path, + ) || artifact.key.source != artifact.origin.source + || artifact.path.source != artifact.origin.source + || expected_by_source.get(artifact.origin.source.as_str()).copied() + != Some(&crate::corpus::CorpusLayer::from(&artifact.origin)) + || entry.key.as_ref() != Some(&artifact.key) + || entry.artifact_path.as_ref() != Some(&artifact.path) + || entry.origin.as_ref() != Some(&artifact.origin) + || entry.path != artifact.display_path + }) + { + return Err(FederatedCacheError::InvalidModel { + message: "search rows contain missing, physical, or mismatched source identity" + .to_string(), + }); + } + let mut resolution_sources = std::collections::BTreeSet::new(); + for entry in &derived.resolution.entries { + let (Some(key), Some(path), Some(origin)) = + (&entry.key, &entry.artifact_path, &entry.origin) + else { + return Err(FederatedCacheError::InvalidModel { + message: "identity projection is missing source provenance".to_string(), + }); + }; + if invalid_path(&path.source, &path.relative_path, &entry.path) + || key.source != origin.source + || path.source != origin.source + || expected_by_source.get(origin.source.as_str()).copied() + != Some(&crate::corpus::CorpusLayer::from(origin)) + { + return Err(FederatedCacheError::InvalidModel { + message: "identity projection contains physical or mismatched paths".to_string(), + }); + } + resolution_sources.insert(key.source.as_str()); + } + if resolution_sources != expected_sources { + return Err(FederatedCacheError::InvalidModel { + message: "identity projection does not retain both verified sources".to_string(), + }); + } + if derived.live_decision_keys.len() != derived.live_decision_paths.len() + || derived + .live_decision_keys + .iter() + .any(|key| !expected_sources.contains(key.source.as_str())) + || derived.scope_rows.iter().any(|row| { + row.key + .as_ref() + .is_none_or(|key| !expected_sources.contains(key.source.as_str())) + || row.artifact_path.as_ref().is_none_or(|path| { + !expected_sources.contains(path.source.as_str()) + || !stable_public_path(&path.relative_path) + || row.path != path.relative_path + }) + || row.origin.as_ref().is_none_or(|origin| { + row.key.as_ref().is_none_or(|key| key.source != origin.source) + || row + .artifact_path + .as_ref() + .is_none_or(|path| path.source != origin.source) + || expected_by_source.get(origin.source.as_str()).copied() + != Some(&crate::corpus::CorpusLayer::from(origin)) + }) + }) + { + return Err(FederatedCacheError::InvalidModel { + message: "scope or liveness projection is not source-aware".to_string(), + }); + } + let identity_keys: std::collections::BTreeSet<&crate::corpus::ArtifactKey> = derived + .resolution + .entries + .iter() + .filter_map(|entry| entry.key.as_ref()) + .collect(); + if derived.resolution.canonical_redirects.iter().any(|redirect| { + !identity_keys.contains(&redirect.parent) + || !identity_keys.contains(&redirect.replacement) + || !identity_keys.contains(&redirect.rationale) + }) { + return Err(FederatedCacheError::InvalidModel { + message: "canonical redirect endpoints are absent from the identity projection" + .to_string(), + }); + } + for relationship in &derived.relationships { + for endpoint in [ + relationship.source_artifact.as_ref(), + relationship.resolved_artifact.as_ref(), + ] + .into_iter() + .flatten() + { + if !expected_sources.contains(endpoint.source.as_str()) + || !stable_public_path(&endpoint.relative_path) + { + return Err(FederatedCacheError::InvalidModel { + message: "relationship projection contains an invalid endpoint".to_string(), + }); + } + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/rac-engine/src/federation.rs b/rust/rac-engine/src/federation.rs index c71ad2f5..0a5f9fcd 100644 --- a/rust/rac-engine/src/federation.rs +++ b/rust/rac-engine/src/federation.rs @@ -149,6 +149,10 @@ pub struct CorpusManifest { /// central composition layer; retaining the value prevents a second /// Markdown section parser from drifting from this manifest boundary. pub overrides: Option, + /// Normalised YAML payload of the optional override mapping. The exact + /// manifest bytes remain authoritative; this separate payload makes the + /// mapping an explicit logical-generation input (ADR-143). + pub override_mapping_bytes: Option>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -176,6 +180,8 @@ pub struct VerifiedParent { pub declaration: ParentDeclaration, pub child_repository_root: PathBuf, pub child_source: String, + pub child_config_path: PathBuf, + pub child_config_bytes: Vec, pub materialisation_root: PathBuf, pub corpus_root: PathBuf, pub config_path: PathBuf, @@ -185,9 +191,37 @@ pub struct VerifiedParent { pub digest: String, /// Parsed but not interpreted by the materialisation boundary. pub overrides: Option, + /// Normalised YAML payload of the override mapping, if declared. + pub override_mapping_bytes: Option>, } impl VerifiedParent { + /// Return the verification-time snapshot row for a stable inherited path. + /// Callers must not reinterpret `relative_path` beneath the child root. + pub fn snapshot_file( + &self, + path: &crate::corpus::ArtifactPath, + ) -> Option<&SnapshotFile> { + if path.source != self.declaration.source { + return None; + } + self.files + .iter() + .find(|file| file.relative_path == path.relative_path) + } + + /// Exact bytes hashed by parent verification for this inherited artifact. + pub fn artifact_bytes(&self, path: &crate::corpus::ArtifactPath) -> Option<&[u8]> { + self.snapshot_file(path).map(|file| file.bytes.as_slice()) + } + + /// Strict UTF-8 plus universal-newline decoding used by `get_artifact`. + /// Invalid UTF-8 remains unreadable, matching the existing public tool. + pub fn artifact_text(&self, path: &crate::corpus::ArtifactPath) -> Option { + let text = std::str::from_utf8(self.artifact_bytes(path)?).ok()?; + Some(text.replace("\r\n", "\n").replace('\r', "\n")) + } + /// True when `path` resolves inside the read-only materialisation subtree. /// Local walks use this predicate to prevent the same Markdown byte from /// entering both the child and inherited layers. @@ -552,33 +586,36 @@ pub fn load_manifest(repository_root: &Path) -> Result, P if override_sections.len() > 1 { return Err(malformed(&path, "## overrides may appear at most once")); } - let overrides = if let Some((start, end)) = override_sections.first().copied() { - let blocks = fenced_yaml_blocks(&path, &text, start, end)?; - if blocks.len() != 1 { - return Err(malformed( - &path, - "## overrides must contain exactly one fenced yaml block", - )); - } - let value: serde_yaml::Value = serde_yaml::from_str(&blocks[0]).map_err(|error| { - malformed( - &path, - format!("## overrides YAML must be one mapping: {error}"), - ) - })?; - if !value.is_mapping() { - return Err(malformed(&path, "## overrides YAML must be one mapping")); - } - Some(value) - } else { - None - }; + let (overrides, override_mapping_bytes) = + if let Some((start, end)) = override_sections.first().copied() { + let blocks = fenced_yaml_blocks(&path, &text, start, end)?; + if blocks.len() != 1 { + return Err(malformed( + &path, + "## overrides must contain exactly one fenced yaml block", + )); + } + let value: serde_yaml::Value = + serde_yaml::from_str(&blocks[0]).map_err(|error| { + malformed( + &path, + format!("## overrides YAML must be one mapping: {error}"), + ) + })?; + if !value.is_mapping() { + return Err(malformed(&path, "## overrides YAML must be one mapping")); + } + (Some(value), Some(blocks[0].as_bytes().to_vec())) + } else { + (None, None) + }; Ok(Some(CorpusManifest { path, bytes, inherits, overrides, + override_mapping_bytes, })) } @@ -989,7 +1026,7 @@ fn exact_config_source( missing_config: ParentCorpusErrorCode, missing_source: ParentCorpusErrorCode, owner: &str, -) -> Result { +) -> Result<(String, Vec), ParentCorpusError> { if !config_path.is_file() { return Err(ParentCorpusError::at( missing_config, @@ -997,21 +1034,42 @@ fn exact_config_source( format!("{owner} config is missing: {}", config_path.display()), )); } - let identity = - crate::scaffold::read_identity_config(&config_path.to_string_lossy()).map_err(|error| { + let bytes = std::fs::read(config_path).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::InvalidConfig, + config_path, + format!( + "cannot read {owner} config {}: {error}", + config_path.display() + ), + ) + })?; + let text = std::str::from_utf8(&bytes).map_err(|_| { + ParentCorpusError::at( + ParentCorpusErrorCode::InvalidConfig, + config_path, + format!( + "{owner} config must be valid UTF-8: {}", + config_path.display() + ), + ) + })?; + let identity = crate::scaffold::parse_identity_config(&config_path.to_string_lossy(), text) + .map_err(|error| { ParentCorpusError::at( ParentCorpusErrorCode::InvalidConfig, config_path, error.message().to_string(), ) })?; - identity.corpus_source.ok_or_else(|| { + let source = identity.corpus_source.ok_or_else(|| { ParentCorpusError::at( missing_source, config_path, format!("{owner} config must declare an explicit corpus.source"), ) - }) + })?; + Ok((source, bytes)) } /// Verify the optional direct parent rooted at `child_repository_root`. @@ -1047,7 +1105,7 @@ pub fn verify_parent( let child_config = child_root.join(CONFIG_RELATIVE_PATH); ensure_no_symlink_components(&child_root, &child_config)?; - let child_source = exact_config_source( + let (child_source, child_config_bytes) = exact_config_source( &child_config, ParentCorpusErrorCode::ChildConfigMissing, ParentCorpusErrorCode::ChildSourceMissing, @@ -1121,6 +1179,8 @@ pub fn verify_parent( declaration: manifest.inherits, child_repository_root: child_root, child_source, + child_config_path: child_config, + child_config_bytes, materialisation_root, corpus_root: digest.corpus_root, config_path: digest.config_path, @@ -1128,6 +1188,7 @@ pub fn verify_parent( files: digest.files, digest: digest.digest, overrides: manifest.overrides, + override_mapping_bytes: manifest.override_mapping_bytes, })) } diff --git a/rust/rac-engine/src/freshness.rs b/rust/rac-engine/src/freshness.rs index 242b6ede..80f02c57 100644 --- a/rust/rac-engine/src/freshness.rs +++ b/rust/rac-engine/src/freshness.rs @@ -26,7 +26,7 @@ use crate::relationships::CorpusItem; /// or the re-derived snapshot bundle (the delta window). pub enum TrackerModel { View(MmapIndexReader), - Snapshot(DerivedIndex), + Snapshot(Box), /// P6 production generation. The document overlay is immutable for the /// lifetime of this served model and is published only after every /// incremental projection has been staged successfully. @@ -362,7 +362,7 @@ impl FreshnessTracker { } let derived = build_derived_index_from_items(&self.root_str, &self.ordered_items(), true); - self.model = Some(TrackerModel::Snapshot(derived)); + self.model = Some(TrackerModel::Snapshot(Box::new(derived))); self.serving_generation += 1; } diff --git a/rust/rac-engine/src/index_store.rs b/rust/rac-engine/src/index_store.rs index f314ab71..de00d3b5 100644 --- a/rust/rac-engine/src/index_store.rs +++ b/rust/rac-engine/src/index_store.rs @@ -14,7 +14,8 @@ use std::path::{Path, PathBuf}; use memmap2::Mmap; use serde_json::Value; -use crate::derived::DerivedIndex; +use crate::corpus::{ArtifactKey, ArtifactOrigin, ArtifactPath, CorpusLayer, Layer}; +use crate::derived::{CanonicalRedirect, DerivedIndex, SourceAwareArtifact}; use crate::index_format::{ encode_segment, segment_payload, write_indexed, IndexFormatError, IndexedSegment, Reader, Writer, @@ -28,10 +29,14 @@ use crate::walk::find_markdown_files; pub const FIELDS: [&str; 6] = ["id", "title", "path", "heading", "body", "tags"]; pub const STORE_DIRNAME: &str = "store"; -pub const STORE_LAYOUT_VERSION: &str = "v1"; +/// The source-aware store layout. The pre-federation `store/v1` tree is +/// deliberately never opened as v2; it therefore degrades to an ordinary +/// cache miss instead of being reinterpreted without provenance (ADR-143). +pub const STORE_LAYOUT_VERSION: &str = "v2"; const SEG_HEADER: &str = "header.seg"; const SEG_ENTRIES: &str = "entries.seg"; +const SEG_IDENTITIES: &str = "identities.seg"; const SEG_SECTIONS: &str = "sections.seg"; const SEG_TOKENS: &str = "tokens.seg"; const SEG_TERMDICT: &str = "termdict.seg"; @@ -42,10 +47,15 @@ const SEG_SCOPE: &str = "scope.seg"; const SEG_PORTFOLIO: &str = "portfolio.seg"; const SEG_ALIASMAP: &str = "aliasmap.seg"; const SEG_PATHMAP: &str = "pathmap.seg"; +const SEG_KEYMAP: &str = "keymap.seg"; +const SEG_ARTIFACTPATHMAP: &str = "artifactpathmap.seg"; +const SEG_LAYERS: &str = "layers.seg"; +const SEG_REDIRECTS: &str = "redirects.seg"; -const ALL_SEGMENTS: [&str; 12] = [ +const ALL_SEGMENTS: [&str; 17] = [ SEG_HEADER, SEG_ENTRIES, + SEG_IDENTITIES, SEG_SECTIONS, SEG_TOKENS, SEG_TERMDICT, @@ -56,6 +66,10 @@ const ALL_SEGMENTS: [&str; 12] = [ SEG_PORTFOLIO, SEG_ALIASMAP, SEG_PATHMAP, + SEG_KEYMAP, + SEG_ARTIFACTPATHMAP, + SEG_LAYERS, + SEG_REDIRECTS, ]; /// The pinned scoring-constant fingerprint (spec/index-store-format.md §3.1). @@ -107,6 +121,145 @@ pub fn store_dir(cache_dir: &Path, corpus_hash: &str) -> PathBuf { // Writer — one DerivedIndex -> a directory of segment files, atomically. // --------------------------------------------------------------------------- +fn write_presence(writer: &mut Writer, present: bool) -> Result<(), IndexFormatError> { + writer.u32(u64::from(present)) +} + +fn read_presence(reader: &mut Reader<'_>, field: &str) -> Result { + match reader.u32()? { + 0 => Ok(false), + 1 => Ok(true), + value => Err(IndexFormatError(format!( + "invalid {field} presence flag: {value}" + ))), + } +} + +fn write_artifact_key( + writer: &mut Writer, + key: Option<&ArtifactKey>, +) -> Result<(), IndexFormatError> { + write_presence(writer, key.is_some())?; + if let Some(key) = key { + writer.text(&key.source)?; + writer.text(&key.canonical_id)?; + } + Ok(()) +} + +fn read_artifact_key(reader: &mut Reader<'_>) -> Result, IndexFormatError> { + if !read_presence(reader, "artifact-key")? { + return Ok(None); + } + Ok(Some(ArtifactKey::new(reader.text()?, reader.text()?))) +} + +fn write_artifact_path( + writer: &mut Writer, + path: Option<&ArtifactPath>, +) -> Result<(), IndexFormatError> { + write_presence(writer, path.is_some())?; + if let Some(path) = path { + writer.text(&path.source)?; + writer.text(&path.relative_path)?; + } + Ok(()) +} + +fn read_artifact_path(reader: &mut Reader<'_>) -> Result, IndexFormatError> { + if !read_presence(reader, "artifact-path")? { + return Ok(None); + } + Ok(Some(ArtifactPath::new(reader.text()?, reader.text()?))) +} + +fn write_origin( + writer: &mut Writer, + origin: Option<&ArtifactOrigin>, +) -> Result<(), IndexFormatError> { + write_presence(writer, origin.is_some())?; + if let Some(origin) = origin { + writer.text(&origin.source)?; + writer.text(origin.layer.as_str())?; + writer.opt_text(origin.pin.as_deref())?; + writer.opt_text(origin.alias.as_deref())?; + } + Ok(()) +} + +fn read_origin(reader: &mut Reader<'_>) -> Result, IndexFormatError> { + if !read_presence(reader, "artifact-origin")? { + return Ok(None); + } + let source = reader.text()?; + let layer = match reader.text()?.as_str() { + "local" => Layer::Local, + "inherited" => Layer::Inherited, + other => return Err(IndexFormatError(format!("invalid corpus layer: {other}"))), + }; + Ok(Some(ArtifactOrigin { + source, + layer, + pin: reader.opt_text()?, + alias: reader.opt_text()?, + })) +} + +fn write_layer(writer: &mut Writer, layer: &CorpusLayer) -> Result<(), IndexFormatError> { + writer.text(&layer.source)?; + writer.text(layer.layer.as_str())?; + writer.opt_text(layer.pin.as_deref())?; + writer.opt_text(layer.alias.as_deref())?; + Ok(()) +} + +fn read_layer(reader: &mut Reader<'_>) -> Result { + let source = reader.text()?; + let layer = match reader.text()?.as_str() { + "local" => Layer::Local, + "inherited" => Layer::Inherited, + other => return Err(IndexFormatError(format!("invalid corpus layer: {other}"))), + }; + Ok(CorpusLayer { + source, + layer, + pin: reader.opt_text()?, + alias: reader.opt_text()?, + }) +} + +fn write_identity_entry( + writer: &mut Writer, + entry: &IndexEntry, +) -> Result<(), IndexFormatError> { + writer.text(&entry.id)?; + writer.text(&entry.artifact_type)?; + writer.opt_text(entry.title.as_deref())?; + writer.text(&entry.path)?; + writer.text_list(&entry.aliases)?; + writer.text_list(&entry.tags)?; + write_artifact_key(writer, entry.key.as_ref())?; + write_artifact_path(writer, entry.artifact_path.as_ref())?; + write_origin(writer, entry.origin.as_ref())?; + Ok(()) +} + +fn read_identity_entry(reader: &mut Reader<'_>) -> Result { + Ok(IndexEntry { + id: reader.text()?, + artifact_type: reader.text()?, + title: reader.opt_text()?, + path: reader.text()?, + aliases: reader.text_list()?, + tags: reader.text_list()?, + key: read_artifact_key(reader)?, + artifact_path: read_artifact_path(reader)?, + origin: read_origin(reader)?, + search_sections: Vec::new(), + inbound_count: 0, + }) +} + fn encode_segments( corpus_hash: &str, bundle_version: &str, @@ -114,6 +267,23 @@ fn encode_segments( ) -> Result)>, IndexFormatError> { let entries = &derived.index_entries; let field_tokens = &derived.field_tokens; + if derived.source_artifacts.len() != entries.len() { + return Err(IndexFormatError( + "source-aware artifact rows are not parallel to index entries".into(), + )); + } + for entry in &derived.resolution.entries { + if entry.key.is_none() || entry.artifact_path.is_none() || entry.origin.is_none() { + return Err(IndexFormatError( + "v2 identity rows require key, artifact path, and origin".into(), + )); + } + } + if derived.live_decision_keys.len() != derived.live_decision_paths.len() { + return Err(IndexFormatError( + "live decision keys are not parallel to display paths".into(), + )); + } // Global vocabulary -> sorted term dictionary -> term id (code-point // order — BTreeMap keys iterate sorted). @@ -135,10 +305,13 @@ fn encode_segments( let mut section_rows: Vec> = Vec::with_capacity(entries.len()); let mut token_rows: Vec> = Vec::with_capacity(entries.len()); let mut postings_lists: Vec> = vec![Vec::new(); termdict.len()]; - // Casefolded identifier -> ascending docids (consecutive-dup guarded). - let mut alias_docids: BTreeMap> = BTreeMap::new(); - for (docid, entry) in entries.iter().enumerate() { + let source_artifact = &derived.source_artifacts[docid]; + if source_artifact.display_path != entry.path { + return Err(IndexFormatError( + "source-aware display path does not match index entry".into(), + )); + } let docid = docid as u32; let fields = &field_tokens[docid as usize]; let lengths: Vec = FIELDS @@ -149,13 +322,6 @@ fn encode_segments( length_sums[i] += value; } - for alias in &entry.aliases { - let docids = alias_docids.entry(py_casefold(alias)).or_default(); - if docids.last() != Some(&docid) { - docids.push(docid); - } - } - let mut row = Writer::new(); row.text(&entry.id)?; row.text(&entry.artifact_type)?; @@ -167,6 +333,9 @@ fn encode_segments( for value in &lengths { row.u32(*value)?; } + write_artifact_key(&mut row, Some(&source_artifact.key))?; + write_artifact_path(&mut row, Some(&source_artifact.path))?; + write_origin(&mut row, Some(&source_artifact.origin))?; entry_rows.push(row.payload()); let mut sec = Writer::new(); @@ -197,9 +366,24 @@ fn encode_segments( let n_entries = entries.len() as u64; let n_terms = termdict.len() as u64; - let mut out: Vec<(&'static str, Vec)> = Vec::with_capacity(12); + let mut out: Vec<(&'static str, Vec)> = Vec::with_capacity(17); out.push((SEG_ENTRIES, encode_segment(&write_indexed(&entry_rows)?))); drop(entry_rows); + let identity_rows: Vec> = derived + .resolution + .entries + .iter() + .map(|entry| { + let mut row = Writer::new(); + write_identity_entry(&mut row, entry)?; + Ok(row.payload()) + }) + .collect::>()?; + out.push(( + SEG_IDENTITIES, + encode_segment(&write_indexed(&identity_rows)?), + )); + drop(identity_rows); out.push((SEG_SECTIONS, encode_segment(&write_indexed(§ion_rows)?))); drop(section_rows); out.push((SEG_TOKENS, encode_segment(&write_indexed(&token_rows)?))); @@ -228,6 +412,19 @@ fn encode_segments( out.push((SEG_TERMDICT, encode_segment(&write_indexed(&termdict_rows)?))); drop(termdict_rows); + // The central identity projection, not the searchable effective rows, + // owns exact point resolution. In federation this retains qualified + // parent aliases and canonical override redirects. + let mut alias_docids: BTreeMap> = BTreeMap::new(); + for (docid, entry) in derived.resolution.entries.iter().enumerate() { + let docid = docid as u32; + for alias in &entry.aliases { + let docids = alias_docids.entry(py_casefold(alias)).or_default(); + if docids.last() != Some(&docid) { + docids.push(docid); + } + } + } let aliasmap_rows: Vec> = alias_docids .iter() .map(|(key, docids)| { @@ -260,6 +457,69 @@ fn encode_segments( out.push((SEG_PATHMAP, encode_segment(&write_indexed(&pathmap_rows)?))); drop(pathmap_rows); + let mut key_docids: BTreeMap<&ArtifactKey, Vec> = BTreeMap::new(); + for (docid, entry) in derived.resolution.entries.iter().enumerate() { + key_docids + .entry(entry.key.as_ref().expect("identity key checked")) + .or_default() + .push(docid as u32); + } + let keymap_rows: Vec> = key_docids + .iter() + .map(|(key, docids)| { + let mut w = Writer::new(); + w.text(&key.source)?; + w.text(&key.canonical_id)?; + w.u32_list(docids)?; + Ok(w.payload()) + }) + .collect::>()?; + out.push((SEG_KEYMAP, encode_segment(&write_indexed(&keymap_rows)?))); + + let mut artifact_path_pairs: Vec<(&ArtifactPath, u32)> = derived + .resolution + .entries + .iter() + .enumerate() + .map(|(docid, entry)| { + ( + entry.artifact_path.as_ref().expect("identity path checked"), + docid as u32, + ) + }) + .collect(); + artifact_path_pairs.sort_by(|(left, _), (right, _)| left.cmp(right)); + let artifactpathmap_rows: Vec> = artifact_path_pairs + .iter() + .map(|(path, docid)| { + let mut w = Writer::new(); + w.text(&path.source)?; + w.text(&path.relative_path)?; + w.u32(u64::from(*docid))?; + Ok(w.payload()) + }) + .collect::>()?; + out.push(( + SEG_ARTIFACTPATHMAP, + encode_segment(&write_indexed(&artifactpathmap_rows)?), + )); + + let mut layers = Writer::new(); + layers.u32(derived.layers.len() as u64)?; + for layer in &derived.layers { + write_layer(&mut layers, layer)?; + } + out.push((SEG_LAYERS, encode_segment(&layers.payload()))); + + let mut redirects = Writer::new(); + redirects.u32(derived.resolution.canonical_redirects.len() as u64)?; + for redirect in &derived.resolution.canonical_redirects { + write_artifact_key(&mut redirects, Some(&redirect.parent))?; + write_artifact_key(&mut redirects, Some(&redirect.replacement))?; + write_artifact_key(&mut redirects, Some(&redirect.rationale))?; + } + out.push((SEG_REDIRECTS, encode_segment(&redirects.payload()))); + let mut relationships = Writer::new(); relationships.u32(derived.relationships.len() as u64)?; for rel in &derived.relationships { @@ -268,16 +528,29 @@ fn encode_segments( relationships.text(&rel.target)?; relationships.opt_text(rel.resolved_path.as_deref())?; relationships.opt_text(rel.issue.as_deref())?; + write_artifact_path(&mut relationships, rel.source_artifact.as_ref())?; + write_artifact_path(&mut relationships, rel.resolved_artifact.as_ref())?; } out.push((SEG_RELATIONSHIPS, encode_segment(&relationships.payload()))); let mut live = Writer::new(); - live.text_list(&derived.live_decision_paths)?; + live.u32(derived.live_decision_keys.len() as u64)?; + for (key, path) in derived + .live_decision_keys + .iter() + .zip(&derived.live_decision_paths) + { + write_artifact_key(&mut live, Some(key))?; + live.text(path)?; + } out.push((SEG_LIVE, encode_segment(&live.payload()))); let mut scope = Writer::new(); scope.u32(derived.scope_rows.len() as u64)?; for row in &derived.scope_rows { + write_artifact_key(&mut scope, row.key.as_ref())?; + write_artifact_path(&mut scope, row.artifact_path.as_ref())?; + write_origin(&mut scope, row.origin.as_ref())?; scope.text(&row.id)?; scope.text(&row.title)?; scope.text(&row.status)?; @@ -532,26 +805,11 @@ impl MmapIndexReader { /// The lightweight identity row (no sections, no inbound). pub fn identity_entry(&self, docid: u32) -> Result { - let mut reader = self.indexed(SEG_ENTRIES)?.row(docid)?; - let id = reader.text()?; - let artifact_type = reader.text()?; - let title = reader.opt_text()?; - let path = reader.text()?; - let aliases = reader.text_list()?; - let tags = reader.text_list()?; - Ok(IndexEntry { - key: None, - artifact_path: None, - origin: None, - id, - artifact_type, - title, - path, - aliases, - search_sections: Vec::new(), - inbound_count: 0, - tags, - }) + read_identity_entry(&mut self.indexed(SEG_IDENTITIES)?.row(docid)?) + } + + pub fn identity_count(&self) -> Result { + Ok(self.indexed(SEG_IDENTITIES)?.count()) } /// The full index row: identity plus searchable sections and inbound. @@ -564,11 +822,17 @@ impl MmapIndexReader { let aliases = reader.text_list()?; let tags = reader.text_list()?; let inbound = reader.u32()?; + for _ in 0..6 { + reader.u32()?; // field lengths + } + let key = read_artifact_key(&mut reader)?; + let artifact_path = read_artifact_path(&mut reader)?; + let origin = read_origin(&mut reader)?; let sections = self.read_sections(docid)?; Ok(IndexEntry { - key: None, - artifact_path: None, - origin: None, + key, + artifact_path, + origin, id, artifact_type, title, @@ -580,6 +844,26 @@ impl MmapIndexReader { }) } + /// Stable source-aware identity parallel to one persisted index row. + pub fn source_artifact(&self, docid: u32) -> Result { + let entry = self.identity_entry(docid)?; + let key = entry + .key + .ok_or_else(|| IndexFormatError("v2 entry is missing its artifact key".into()))?; + let path = entry + .artifact_path + .ok_or_else(|| IndexFormatError("v2 entry is missing its artifact path".into()))?; + let origin = entry + .origin + .ok_or_else(|| IndexFormatError("v2 entry is missing its origin".into()))?; + Ok(SourceAwareArtifact { + key, + path, + origin, + display_path: entry.path, + }) + } + fn read_sections( &self, docid: u32, @@ -773,26 +1057,151 @@ impl MmapIndexReader { Ok(None) } + /// Resolve the stable `(source, canonical_id)` identity to every matching + /// docid. More than one result remains observable as an ambiguity rather + /// than acquiring iteration-order precedence. + pub fn docids_for_key(&self, key: &ArtifactKey) -> Result, IndexFormatError> { + let segment = self.indexed(SEG_KEYMAP)?; + let (mut lo, mut hi) = (0u32, segment.count()); + while lo < hi { + let mid = (lo + hi) / 2; + let mut reader = segment.row(mid)?; + let source = reader.text()?; + let canonical_id = reader.text()?; + match (source.as_str(), canonical_id.as_str()) + .cmp(&(key.source.as_str(), key.canonical_id.as_str())) + { + std::cmp::Ordering::Less => lo = mid + 1, + std::cmp::Ordering::Greater => hi = mid, + std::cmp::Ordering::Equal => return reader.u32_list(), + } + } + Ok(Vec::new()) + } + + /// Return one stable-key match only when it is unambiguous. + pub fn docid_for_key(&self, key: &ArtifactKey) -> Result, IndexFormatError> { + let docids = self.docids_for_key(key)?; + Ok((docids.len() == 1).then(|| docids[0])) + } + + /// Resolve the stable `(source, corpus-relative path)` identity to a + /// docid without using a checkout or display path. + pub fn docid_for_artifact_path( + &self, + path: &ArtifactPath, + ) -> Result, IndexFormatError> { + let segment = self.indexed(SEG_ARTIFACTPATHMAP)?; + let (mut lo, mut hi) = (0u32, segment.count()); + while lo < hi { + let mid = (lo + hi) / 2; + let mut reader = segment.row(mid)?; + let source = reader.text()?; + let relative_path = reader.text()?; + match (source.as_str(), relative_path.as_str()) + .cmp(&(path.source.as_str(), path.relative_path.as_str())) + { + std::cmp::Ordering::Less => lo = mid + 1, + std::cmp::Ordering::Greater => hi = mid, + std::cmp::Ordering::Equal => return Ok(Some(reader.u32()?)), + } + } + Ok(None) + } + + /// Stable layer identities represented by this persisted generation. + pub fn layers(&self) -> Result, IndexFormatError> { + let mut reader = Reader::new(self.payload(SEG_LAYERS)); + let count = reader.u32()?; + let mut layers = Vec::with_capacity(count.min(1 << 20) as usize); + for _ in 0..count { + layers.push(read_layer(&mut reader)?); + } + Ok(layers) + } + + /// Validated canonical redirects retained by the composed generation. + pub fn canonical_redirects(&self) -> Result, IndexFormatError> { + let mut reader = Reader::new(self.payload(SEG_REDIRECTS)); + let count = reader.u32()?; + let mut redirects = Vec::with_capacity(count.min(1 << 20) as usize); + for _ in 0..count { + let parent = read_artifact_key(&mut reader)? + .ok_or_else(|| IndexFormatError("redirect parent is absent".into()))?; + let replacement = read_artifact_key(&mut reader)? + .ok_or_else(|| IndexFormatError("redirect replacement is absent".into()))?; + let rationale = read_artifact_key(&mut reader)? + .ok_or_else(|| IndexFormatError("redirect rationale is absent".into()))?; + redirects.push(CanonicalRedirect { + parent, + replacement, + rationale, + }); + } + Ok(redirects) + } + + pub fn canonical_redirect( + &self, + parent: &ArtifactKey, + ) -> Result, IndexFormatError> { + Ok(self + .canonical_redirects()? + .into_iter() + .find(|redirect| &redirect.parent == parent)) + } + pub fn relationships(&self) -> Result, IndexFormatError> { let mut reader = Reader::new(self.payload(SEG_RELATIONSHIPS)); let count = reader.u32()?; let mut result = Vec::with_capacity(count.min(1 << 20) as usize); for _ in 0..count { + let source_path = reader.text()?; + let relationship = reader.text()?; + let target = reader.text()?; + let resolved_path = reader.opt_text()?; + let issue = reader.opt_text()?; + let source_artifact = read_artifact_path(&mut reader)?; + let resolved_artifact = read_artifact_path(&mut reader)?; result.push(Relationship { - source_artifact: None, - source_path: reader.text()?, - relationship: reader.text()?, - target: reader.text()?, - resolved_artifact: None, - resolved_path: reader.opt_text()?, - issue: reader.opt_text()?, + source_artifact, + source_path, + relationship, + target, + resolved_artifact, + resolved_path, + issue, }); } Ok(result) } + fn live_decisions(&self) -> Result, IndexFormatError> { + let mut reader = Reader::new(self.payload(SEG_LIVE)); + let count = reader.u32()?; + let mut rows = Vec::with_capacity(count.min(1 << 20) as usize); + for _ in 0..count { + let key = read_artifact_key(&mut reader)? + .ok_or_else(|| IndexFormatError("live decision key is absent".into()))?; + rows.push((key, reader.text()?)); + } + Ok(rows) + } + + pub fn live_decision_keys(&self) -> Result, IndexFormatError> { + Ok(self + .live_decisions()? + .into_iter() + .map(|(key, _)| key) + .collect()) + } + pub fn live_decision_paths(&self) -> Result, IndexFormatError> { - Reader::new(self.payload(SEG_LIVE)).text_list() + Ok(self + .live_decisions()? + .into_iter() + .map(|(_, path)| path) + .collect()) } pub fn scope_rows(&self) -> Result, IndexFormatError> { @@ -801,9 +1210,9 @@ impl MmapIndexReader { let mut rows = Vec::with_capacity(count.min(1 << 20) as usize); for _ in 0..count { rows.push(crate::retrieve::ScopeRow { - key: None, - artifact_path: None, - origin: None, + key: read_artifact_key(&mut reader)?, + artifact_path: read_artifact_path(&mut reader)?, + origin: read_origin(&mut reader)?, id: reader.text()?, title: reader.text()?, status: reader.text()?, diff --git a/rust/rac-engine/src/output.rs b/rust/rac-engine/src/output.rs index 869b2ba9..f9a37662 100644 --- a/rust/rac-engine/src/output.rs +++ b/rust/rac-engine/src/output.rs @@ -411,6 +411,9 @@ pub fn render_validate_dir_json(result: &DirectoryValidation) -> String { "issues".into(), Value::Array(f.issues.iter().map(issue_value).collect()), ); + if let Some(origin) = &f.origin { + m.insert("provenance".into(), artifact_origin_value(origin)); + } Value::Object(m) }) .collect(); @@ -569,7 +572,7 @@ pub fn render_validate_sarif(result: &DirectoryValidation) -> String { message: issue.message.clone(), uri: quote_uri(&file.path), line: issue.line, - properties: None, + properties: file.origin.as_ref().map(artifact_origin_value), }); } } @@ -655,7 +658,7 @@ pub fn render_relationships_sarif(validation: &RelationshipValidation) -> String message, uri, line: None, - properties: None, + properties: issue.origin.as_ref().map(artifact_origin_value), } }) .collect(); @@ -761,6 +764,9 @@ fn relationship_issue_value(issue: &RelationshipIssue) -> Value { m.insert("target".into(), json!(issue.target)); m.insert("code".into(), json!(issue.code)); } + if let Some(origin) = &issue.origin { + m.insert("provenance".into(), artifact_origin_value(origin)); + } Value::Object(m) } diff --git a/rust/rac-engine/src/parallel_build.rs b/rust/rac-engine/src/parallel_build.rs index ff57b70e..f22efb8f 100644 --- a/rust/rac-engine/src/parallel_build.rs +++ b/rust/rac-engine/src/parallel_build.rs @@ -147,10 +147,12 @@ fn reproduce(fragments: Vec, directory: &str, recursive: bool) -> D let mut index_entries = Vec::with_capacity(fragments.len()); let mut source_artifacts = Vec::with_capacity(fragments.len()); let mut field_tokens = Vec::with_capacity(fragments.len()); + let mut live_decision_keys = Vec::new(); let mut live_decision_paths = Vec::new(); let mut scope_rows = Vec::new(); for fragment in fragments { if fragment.is_live_decision { + live_decision_keys.push(fragment.item.key.clone()); live_decision_paths.push(fragment.index_entry.path.clone()); } if let Some(row) = fragment.scope_row { @@ -168,14 +170,20 @@ fn reproduce(fragments: Vec, directory: &str, recursive: bool) -> D } // The cross-document steps: graph resolution, inbound fill, portfolio. let relationships = relationships_from_corpus(&items); - let mut inbound: std::collections::HashMap<&str, i64> = std::collections::HashMap::new(); + let mut inbound: std::collections::HashMap<&crate::corpus::ArtifactPath, i64> = + std::collections::HashMap::new(); for rel in &relationships { - if let Some(resolved) = &rel.resolved_path { - *inbound.entry(resolved.as_str()).or_insert(0) += 1; + if let Some(resolved) = &rel.resolved_artifact { + *inbound.entry(resolved).or_insert(0) += 1; } } for entry in &mut index_entries { - entry.inbound_count = inbound.get(entry.path.as_str()).copied().unwrap_or(0); + entry.inbound_count = entry + .artifact_path + .as_ref() + .and_then(|path| inbound.get(path)) + .copied() + .unwrap_or(0); } let summary = crate::portfolio::portfolio_from_corpus(directory, &items, recursive); let mut layers: Vec = items @@ -187,12 +195,26 @@ fn reproduce(fragments: Vec, directory: &str, recursive: bool) -> D if layers.is_empty() { layers.push(crate::corpus::compatible_local_layer(directory)); } + let identity_entries = index_entries + .iter() + .cloned() + .map(|mut entry| { + entry.search_sections.clear(); + entry.inbound_count = 0; + entry + }) + .collect(); DerivedIndex { layers, source_artifacts, + resolution: Box::new(crate::derived::ResolutionProjection { + entries: identity_entries, + canonical_redirects: Vec::new(), + }), index_entries, field_tokens, relationships, + live_decision_keys, live_decision_paths, portfolio_summary: crate::output::portfolio_summary_value(&summary), scope_rows, diff --git a/rust/rac-engine/src/portfolio.rs b/rust/rac-engine/src/portfolio.rs index 6b588734..b94d5229 100644 --- a/rust/rac-engine/src/portfolio.rs +++ b/rust/rac-engine/src/portfolio.rs @@ -142,7 +142,20 @@ pub fn portfolio_from_corpus( items: &[CorpusItem], recursive: bool, ) -> PortfolioSummary { - let rows: Vec = items.iter().map(portfolio_row).collect(); + let rows: Vec = items + .iter() + .map(|item| { + let mut row = portfolio_row(item); + if item.origin.layer == crate::corpus::Layer::Inherited { + // Parent structural warnings and completeness advisories are + // emitted by the parent corpus, not duplicated into every + // child summary. Parent errors already block composition. + row.validate_issues.clear(); + row.missing_recommended.clear(); + } + row + }) + .collect(); portfolio_from_rows(directory, &rows, recursive) } @@ -153,6 +166,48 @@ pub fn portfolio_from_rows( ) -> PortfolioSummary { let validation_rows: Vec = rows.iter().map(|r| r.validation.clone()).collect(); let overrides: SeverityOverrides = load_overrides(directory); + let rel_summary = summary_from_rows(&validation_rows); + let relationships_ok = + validation_from_rows(directory, &validation_rows, recursive).ok(); + portfolio_from_rows_with_analysis( + directory, + rows, + recursive, + &overrides, + rel_summary, + relationships_ok, + ) +} + +/// Build a portfolio from the central composed relationship projection and +/// the exact child-config snapshot belonging to the generation. +pub(crate) fn portfolio_from_corpus_with_analysis( + directory: &str, + items: &[CorpusItem], + recursive: bool, + overrides: &SeverityOverrides, + relationship_summary: RelationshipSummary, + relationships_ok: bool, +) -> PortfolioSummary { + let rows: Vec = items.iter().map(portfolio_row).collect(); + portfolio_from_rows_with_analysis( + directory, + &rows, + recursive, + overrides, + relationship_summary, + relationships_ok, + ) +} + +fn portfolio_from_rows_with_analysis( + directory: &str, + rows: &[PortfolioRow], + recursive: bool, + overrides: &SeverityOverrides, + rel_summary: RelationshipSummary, + relationships_ok: bool, +) -> PortfolioSummary { let mut by_type: Vec<(String, usize)> = BY_TYPE_ORDER.iter().map(|t| (t.to_string(), 0)).collect(); @@ -181,7 +236,7 @@ pub fn portfolio_from_rows( } path_to_identifier.insert(row.path.clone(), row.identifier.clone()); - let issues = apply_overrides(row.validate_issues.clone(), &row.artifact_type, &overrides); + let issues = apply_overrides(row.validate_issues.clone(), &row.artifact_type, overrides); if has_errors(&issues) { invalid_count += 1; let error_codes: Vec = issues @@ -216,10 +271,6 @@ pub fn portfolio_from_rows( } } - let rel_summary = summary_from_rows(&validation_rows); - let relationships_ok = - validation_from_rows(directory, &validation_rows, recursive).ok(); - for issue in &rel_summary.issues { let source = issue.source_path.clone().unwrap_or_default(); let label = py_title(&issue.relationship.clone().unwrap_or_default().replace('_', " ")); diff --git a/rust/rac-engine/src/read_model.rs b/rust/rac-engine/src/read_model.rs index a5fc8a31..942ed493 100644 --- a/rust/rac-engine/src/read_model.rs +++ b/rust/rac-engine/src/read_model.rs @@ -185,12 +185,14 @@ pub fn store_search( /// Live-decision topic search served from the store (ADR-067): the /// decision-typed search, then the liveness filter over the precomputed -/// live-decision paths — `ReadModelView.find_decisions`. +/// source-aware live-decision keys — `ReadModelView.find_decisions`. pub fn store_find_decisions(reader: &MmapIndexReader, topic: &str) -> SearchResult { let mut result = store_search(reader, topic, Some("decision"), &[], false); - let live: std::collections::HashSet = - reader.live_decision_paths().unwrap_or_default().into_iter().collect(); - result.matches.retain(|m| live.contains(&m.path)); + let live_keys: std::collections::HashSet<_> = + reader.live_decision_keys().unwrap_or_default().into_iter().collect(); + result + .matches + .retain(|m| m.key.as_ref().is_some_and(|key| live_keys.contains(key))); result } @@ -215,7 +217,7 @@ pub fn store_resolve( if docids.len() > 1 { let mut paths: Vec = docids .iter() - .filter_map(|&docid| reader.entry_path(docid).ok()) + .filter_map(|&docid| reader.identity_entry(docid).ok().map(|entry| entry.path)) .collect(); paths.sort(); return crate::resolve::ResolutionResult { @@ -251,7 +253,7 @@ pub fn store_resolve( pub fn store_identity_entries( reader: &MmapIndexReader, ) -> Vec { - (0..reader.doc_count) + (0..reader.identity_count().unwrap_or(0)) .filter_map(|docid| reader.identity_entry(docid).ok()) .collect() } @@ -262,8 +264,26 @@ pub fn find_decisions_in( entries: &[crate::resolve::IndexEntry], live_paths: &[String], topic: &str, +) -> SearchResult { + find_decisions_in_source_aware(entries, &[], live_paths, topic) +} + +/// Source-aware live-decision filter for a composed cold model. Empty +/// `live_keys` retains the released display-path behaviour. +pub fn find_decisions_in_source_aware( + entries: &[crate::resolve::IndexEntry], + live_keys: &[crate::corpus::ArtifactKey], + live_paths: &[String], + topic: &str, ) -> SearchResult { let mut result = crate::resolve::search_index(entries, topic, Some("decision"), &[]); + if !live_keys.is_empty() { + let live: std::collections::HashSet<_> = live_keys.iter().collect(); + result + .matches + .retain(|m| m.key.as_ref().is_some_and(|key| live.contains(key))); + return result; + } let live: std::collections::HashSet<&str> = live_paths.iter().map(String::as_str).collect(); result.matches.retain(|m| live.contains(m.path.as_str())); result diff --git a/rust/rac-engine/src/relationships.rs b/rust/rac-engine/src/relationships.rs index 0908a458..bc28463c 100644 --- a/rust/rac-engine/src/relationships.rs +++ b/rust/rac-engine/src/relationships.rs @@ -464,6 +464,12 @@ pub fn resolution_index_from_rows(rows: &[ValidationRow]) -> ResolutionIndex { pub struct RelationshipIssue { pub code: String, pub source_path: Option, + /// Stable source-relative identity for the artifact which declared the + /// relationship. Absent on released single-corpus output and findings + /// which span multiple artifacts. + pub source_artifact: Option, + /// Additive source/layer/pin provenance on composed findings only. + pub origin: Option, pub relationship: Option, pub target: Option, pub identifier: Option, @@ -475,12 +481,29 @@ impl RelationshipIssue { RelationshipIssue { code: code.to_string(), source_path: Some(source_path.to_string()), + source_artifact: None, + origin: None, relationship: Some(relationship.to_string()), target: Some(target.to_string()), identifier: None, paths: None, } } + + fn reference_from_row( + code: &str, + row: &ValidationRow, + relationship: &str, + target: &str, + include_provenance: bool, + ) -> Self { + let mut issue = Self::reference(code, &row.path, relationship, target); + if include_provenance { + issue.source_artifact = Some(row.artifact_path.clone()); + issue.origin = Some(row.origin.clone()); + } + issue + } } #[derive(Debug)] @@ -582,8 +605,9 @@ fn classify_reference<'a>( fn resolve_references( rows: &[ValidationRow], index: &ResolutionIndex, + include_provenance: bool, ) -> (usize, Vec) { - let (checked, issues, _) = resolve_references_full(rows, index); + let (checked, issues, _) = resolve_references_full(rows, index, include_provenance); (checked, issues) } @@ -723,6 +747,8 @@ fn cycle_issues( issues.push(RelationshipIssue { code: ISSUE_RELATIONSHIP_CYCLE.to_string(), source_path: None, + source_artifact: None, + origin: None, relationship: Some(kind.to_string()), target: None, identifier: None, @@ -738,7 +764,11 @@ fn cycle_issues( issues } -fn scope_validation_issues(directory: &str, rows: &[ValidationRow]) -> Vec { +fn scope_validation_issues( + directory: &str, + rows: &[ValidationRow], + include_provenance: bool, +) -> Vec { let root: PathBuf = repository_root(directory); let mut issues = Vec::new(); for row in rows { @@ -761,11 +791,12 @@ fn scope_validation_issues(directory: &str, rows: &[ValidationRow]) -> Vec RelationshipValidation { let index = resolution_index_from_rows(rows); - validation_from_rows_with_index(directory, rows, rows, recursive, &index, true) + validation_from_rows_with_index(directory, rows, rows, recursive, &index, true, false) } /// Source-aware validation core used by the composed read model. The caller @@ -793,6 +824,7 @@ pub(crate) fn validation_from_rows_with_index( recursive: bool, index: &ResolutionIndex, include_duplicate_identifiers: bool, + include_provenance: bool, ) -> RelationshipValidation { let mut issues: Vec = Vec::new(); @@ -831,6 +863,8 @@ pub(crate) fn validation_from_rows_with_index( issues.push(RelationshipIssue { code: ISSUE_DUPLICATE_IDENTIFIER.to_string(), source_path: None, + source_artifact: None, + origin: None, relationship: None, target: None, identifier: Some(display), @@ -848,6 +882,8 @@ pub(crate) fn validation_from_rows_with_index( issues.push(RelationshipIssue { code: ISSUE_EDGE_UNSUPPORTED.to_string(), source_path: Some(row.path.clone()), + source_artifact: include_provenance.then(|| row.artifact_path.clone()), + origin: include_provenance.then(|| row.origin.clone()), relationship: Some(snake(section)), target: None, identifier: None, @@ -882,11 +918,12 @@ pub(crate) fn validation_from_rows_with_index( continue; }; if !edge.range.contains(&target_spec) { - issues.push(RelationshipIssue::reference( + issues.push(RelationshipIssue::reference_from_row( ISSUE_TARGET_TYPE_MISMATCH, - &row.path, + row, section, reference, + include_provenance, )); } } @@ -913,11 +950,12 @@ pub(crate) fn validation_from_rows_with_index( .get(&target.key) .is_some_and(|target_row| target_row.retired) { - issues.push(RelationshipIssue::reference( + issues.push(RelationshipIssue::reference_from_row( ISSUE_TARGET_SUPERSEDED, - &row.path, + row, section, reference, + include_provenance, )); } } @@ -928,11 +966,11 @@ pub(crate) fn validation_from_rows_with_index( issues.extend(cycle_issues(rows, target_rows, index)); // Referential integrity. - let (checked, ref_issues) = resolve_references(rows, index); + let (checked, ref_issues) = resolve_references(rows, index, include_provenance); issues.extend(ref_issues); // Code-scope existence (appended last). - issues.extend(scope_validation_issues(directory, rows)); + issues.extend(scope_validation_issues(directory, rows, include_provenance)); RelationshipValidation { directory: directory.to_string(), @@ -1248,8 +1286,8 @@ pub fn validate_document_against_corpus( /// uniquely to another artifact. #[derive(Debug, Clone)] pub struct Relationship { - /// Stable source-aware endpoint. `None` only when reconstructed from the - /// pre-federation v1 persistent store; the v2 cutover owns its codec. + /// Stable source-aware endpoint. `None` remains representable for + /// unresolved/external edges and legacy compatibility fixtures. pub source_artifact: Option, pub source_path: String, pub relationship: String, @@ -1343,6 +1381,7 @@ pub struct RelationshipSummary { fn resolve_references_full( rows: &[ValidationRow], index: &ResolutionIndex, + include_provenance: bool, ) -> ( usize, Vec, @@ -1371,7 +1410,13 @@ fn resolve_references_full( ReferenceResolution::Ambiguous => ISSUE_TARGET_AMBIGUOUS, ReferenceResolution::SelfRef => ISSUE_SELF_REFERENCE, }; - issues.push(RelationshipIssue::reference(code, &row.path, section, reference)); + issues.push(RelationshipIssue::reference_from_row( + code, + row, + section, + reference, + include_provenance, + )); } } } @@ -1380,6 +1425,18 @@ fn resolve_references_full( /// `summary_from_rows(rows)`. pub fn summary_from_rows(rows: &[ValidationRow]) -> RelationshipSummary { + let index = resolution_index_from_rows(rows); + summary_from_rows_with_index(rows, &index, false) +} + +/// Relationship summary against an already-authoritative resolution index. +/// The composed read model uses this seam so qualified aliases and override +/// redirects cannot diverge from point lookup or graph construction. +pub(crate) fn summary_from_rows_with_index( + rows: &[ValidationRow], + index: &ResolutionIndex, + include_provenance: bool, +) -> RelationshipSummary { if rows.is_empty() { return RelationshipSummary { total: 0, @@ -1390,8 +1447,8 @@ pub fn summary_from_rows(rows: &[ValidationRow]) -> RelationshipSummary { issues: Vec::new(), }; } - let index = resolution_index_from_rows(rows); - let (checked, ref_issues, resolved_targets) = resolve_references_full(rows, &index); + let (checked, ref_issues, resolved_targets) = + resolve_references_full(rows, index, include_provenance); let broken = ref_issues.len(); let valid = checked - broken; diff --git a/rust/rac-engine/src/resolve.rs b/rust/rac-engine/src/resolve.rs index c7e073b3..e7001cf4 100644 --- a/rust/rac-engine/src/resolve.rs +++ b/rust/rac-engine/src/resolve.rs @@ -117,8 +117,8 @@ fn tf(term: &str, tokens: &[String]) -> i64 { /// One searchable row of the repository index. #[derive(Debug, Clone)] pub struct IndexEntry { - /// Source-aware identity is present for in-memory rows. The frozen v1 - /// store reconstructs `None` until the versioned v2 codec cutover. + /// Source-aware identity is present for in-memory and v2 persisted rows. + /// `None` remains representable only for legacy compatibility fixtures. pub key: Option, pub artifact_path: Option, pub origin: Option, diff --git a/rust/rac-engine/src/retrieve.rs b/rust/rac-engine/src/retrieve.rs index 38adc2d6..9a6fbc8a 100644 --- a/rust/rac-engine/src/retrieve.rs +++ b/rust/rac-engine/src/retrieve.rs @@ -423,8 +423,24 @@ fn governing_decisions(rows: &[ScopeRow], directory: &str, path: &str) -> Vec = matches + .iter() + .filter_map(|decision| decision.origin.as_ref().map(|origin| origin.source.as_str())) + .collect(); + sources.sort_unstable(); + sources.dedup(); + let federated = sources.len() > 1; matches.sort_by(|a, b| { - (py_casefold(&a.id), &a.path).cmp(&(py_casefold(&b.id), &b.path)) + py_casefold(&a.id) + .cmp(&py_casefold(&b.id)) + .then_with(|| { + if federated { + a.artifact_path.cmp(&b.artifact_path) + } else { + a.path.cmp(&b.path) + } + }) + .then_with(|| a.path.cmp(&b.path)) }); matches } @@ -1155,7 +1171,7 @@ pub fn retrieve_grounding_from_store( .docid_for_path(path) .ok() .flatten() - .and_then(|docid| reader.identity_entry(docid).ok()) + .and_then(|docid| reader.full_entry(docid).ok()) }, status_of, ) diff --git a/rust/rac-engine/src/validate.rs b/rust/rac-engine/src/validate.rs index d2af35f0..a4377b9f 100644 --- a/rust/rac-engine/src/validate.rs +++ b/rust/rac-engine/src/validate.rs @@ -979,12 +979,8 @@ fn parse_severity_map(section: Option<&Yaml>, allowed: &[&str]) -> Vec<(String, out } -/// `load_overrides(start_dir)` (ADR-053). -pub fn load_overrides(start_dir: &str) -> SeverityOverrides { - let Some(pairs) = load_config_mapping(start_dir) else { - return SeverityOverrides::default(); - }; - let Some(Yaml::Map(section)) = yaml_map_get(&pairs, "validation") else { +fn overrides_from_mapping(pairs: &[(Yaml, Yaml)]) -> SeverityOverrides { + let Some(Yaml::Map(section)) = yaml_map_get(pairs, "validation") else { return SeverityOverrides::default(); }; SeverityOverrides { @@ -996,6 +992,27 @@ pub fn load_overrides(start_dir: &str) -> SeverityOverrides { } } +/// `load_overrides(start_dir)` (ADR-053). +pub fn load_overrides(start_dir: &str) -> SeverityOverrides { + let Some(pairs) = load_config_mapping(start_dir) else { + return SeverityOverrides::default(); + }; + overrides_from_mapping(&pairs) +} + +/// Parse validation overrides from the exact governing config snapshot. +/// Federated cache builds use this seam so portfolio validation cannot observe +/// config bytes newer than the logical generation it describes. +pub fn overrides_from_config_bytes(bytes: &[u8]) -> SeverityOverrides { + let Ok(text) = std::str::from_utf8(bytes) else { + return SeverityOverrides::default(); + }; + let (Some(pairs), _issues) = load_frontmatter_mapping(text) else { + return SeverityOverrides::default(); + }; + overrides_from_mapping(&pairs) +} + /// `load_freshness_threshold(start_dir)` (ADR-045): the /// `freshness.stale_after_days` from the nearest `.decided/config.yaml`. /// Defaults to 180 when there is no config, no `freshness` mapping, or the diff --git a/rust/rac-engine/tests/composition.rs b/rust/rac-engine/tests/composition.rs index c62a7307..a60a00e9 100644 --- a/rust/rac-engine/tests/composition.rs +++ b/rust/rac-engine/tests/composition.rs @@ -7,7 +7,8 @@ use rac_engine::corpus::{ }; use rac_engine::parse::parse_text; use rac_engine::relationships::{ - CorpusItem, ISSUE_RELATIONSHIP_CYCLE, ISSUE_TARGET_NOT_FOUND, ISSUE_TARGET_TYPE_MISMATCH, + CorpusItem, ISSUE_RELATIONSHIP_CYCLE, ISSUE_SELF_REFERENCE, ISSUE_TARGET_NOT_FOUND, + ISSUE_TARGET_TYPE_MISMATCH, }; use rac_engine::spec::spec_for; @@ -504,3 +505,44 @@ fn cross_source_cycles_are_computed_over_artifact_keys() { ] ); } + +#[test] +fn inherited_warnings_remain_parent_owned_while_local_findings_are_sourced() { + let local = item( + Layer::Local, + "local.md", + "APP-ADR", + "decision", + "Accepted", + "## Related Decisions\n\n- APP-ADR\n", + ); + let inherited = item( + Layer::Inherited, + "parent.md", + "STD-ADR", + "decision", + "Accepted", + "## Related Decisions\n\n- STD-ADR\n", + ); + let corpus = ComposedCorpus::compose(vec![local], vec![inherited], parent(), Vec::new()); + let validation = corpus.validate_relationships(".", true); + assert_eq!(validation.issues.len(), 1); + let issue = &validation.issues[0]; + assert_eq!(issue.code, ISSUE_SELF_REFERENCE); + assert_eq!(issue.origin.as_ref().unwrap().layer, Layer::Local); + assert_eq!( + issue.source_artifact.as_ref().unwrap(), + &ArtifactPath::new(LOCAL_SOURCE, "local.md") + ); + + let summary = corpus.relationship_summary(); + assert_eq!(summary.issues.len(), 1); + assert_eq!(summary.broken, 1); + assert_eq!(summary.valid, 1); + let json: serde_json::Value = serde_json::from_str( + &rac_engine::output::render_relationship_validation_json(&validation), + ) + .unwrap(); + assert_eq!(json["issues"][0]["provenance"]["source"], LOCAL_SOURCE); + assert_eq!(json["issues"][0]["provenance"]["layer"], "local"); +} diff --git a/rust/rac-engine/tests/federated_cache.rs b/rust/rac-engine/tests/federated_cache.rs new file mode 100644 index 00000000..a121ff49 --- /dev/null +++ b/rust/rac-engine/tests/federated_cache.rs @@ -0,0 +1,599 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use rac_engine::corpus::{ArtifactKey, Layer}; +use rac_engine::derived_cache::{ + capture_logical_generation, FederatedCacheError, FederatedCacheRefresh, + FederatedCacheTracker, LogicalGeneration, ReadModel, +}; +use rac_engine::federation::{calculate_parent_digest, ParentCorpusErrorCode}; +use rac_engine::index_store::{corpus_content_hash, store_dir, STORE_LAYOUT_VERSION}; + +static COUNTER: AtomicUsize = AtomicUsize::new(0); + +const CHILD_DECISION: &str = r#"--- +schema_version: 1 +id: APP-KWJ4VMKVSS65 +type: decision +--- +# ADR-001: Child Policy + +## Status + +Accepted + +## Context + +The child needs one local decision. + +## Decision + +Keep the local layer. + +## Consequences + +The cache fixture is deterministic. + +## Applies To + +- `src/**` +"#; + +const PARENT_DECISION: &str = r#"--- +schema_version: 1 +id: STD-KWJ4VMKVSS66 +type: decision +--- +# ADR-002: Parent Standard + +## Status + +Accepted + +## Context + +The parent needs one inherited decision. + +## Decision + +Keep the inherited layer. + +## Consequences + +The pin changes with these bytes. + +## Applies To + +- `src/**` +"#; + +fn scratch(tag: &str) -> PathBuf { + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let root = std::env::temp_dir().join(format!( + "asdecided-federated-cache-{tag}-{}-{n}", + std::process::id() + )); + fs::create_dir_all(&root).unwrap(); + root +} + +fn write_parent(root: &Path) { + fs::create_dir_all(root.join(".decided")).unwrap(); + fs::create_dir_all(root.join("decisions")).unwrap(); + fs::write( + root.join(".decided/config.yaml"), + b"repository_key: STD\ncorpus:\n source: acme/standards\n", + ) + .unwrap(); + fs::write(root.join("decisions/parent.md"), PARENT_DECISION).unwrap(); +} + +fn write_manifest(child: &Path, pin: &str, override_note: &str) { + fs::write( + child.join(".decided/corpus.md"), + format!( + "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: standards\nsource: acme/standards\nroot: vendor/standards\ncorpus: decisions\ndigest: {pin}\n```\n\n## overrides\n\n```yaml\nversion: 1\nitems: []\n```\n\n\n" + ), + ) + .unwrap(); +} + +fn write_override_manifest(child: &Path, pin: &str) { + fs::write( + child.join(".decided/corpus.md"), + format!( + "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: standards\nsource: acme/standards\nroot: vendor/standards\ncorpus: decisions\ndigest: {pin}\n```\n\n## overrides\n\n```yaml\nversion: 1\nitems:\n - parent: standards::STD-KWJ4VMKVSS66\n with: APP-KWJ4VMKVSS65\n rationale: APP-KWJ4VMKVSS65\n```\n" + ), + ) + .unwrap(); +} + +fn fixture(tag: &str) -> (PathBuf, PathBuf, String) { + let child = scratch(tag); + let parent = child.join("vendor/standards"); + fs::create_dir_all(child.join(".decided")).unwrap(); + fs::create_dir_all(child.join("decisions")).unwrap(); + fs::write( + child.join(".decided/config.yaml"), + b"repository_key: APP\ncorpus:\n source: acme/app\n", + ) + .unwrap(); + fs::write(child.join("decisions/child.md"), CHILD_DECISION).unwrap(); + write_parent(&parent); + let pin = calculate_parent_digest(&parent, "decisions") + .unwrap() + .digest; + write_manifest(&child, &pin, "initial"); + (child, parent, pin) +} + +fn child_corpus(child: &Path) -> String { + child.join("decisions").to_string_lossy().into_owned() +} + +fn shared_decision(id: &str) -> String { + format!( + "---\nschema_version: 1\nid: {id}\ntype: decision\n---\n# ADR-900: Shared Policy\n\n## Status\n\nAccepted\n\n## Context\n\nEqualmarker context.\n\n## Decision\n\nEqualmarker decision.\n\n## Consequences\n\nEqualmarker consequence.\n\n## Applies To\n\n- `src/**`\n" + ) +} + +fn assert_digest_mismatch(error: FederatedCacheError) { + match error { + FederatedCacheError::Parent(error) => { + assert_eq!(error.code, ParentCorpusErrorCode::DigestMismatch) + } + other => panic!("expected digest mismatch, got {other}"), + } +} + +fn assert_two_layers(model: &ReadModel) { + let layers = match model { + ReadModel::View(view) => view.layers().unwrap(), + ReadModel::Fresh(derived) => derived.layers.clone(), + }; + assert_eq!(layers.len(), 2); + assert_eq!(layers[0].source, "acme/app"); + assert_eq!(layers[0].layer, Layer::Local); + assert_eq!(layers[1].source, "acme/standards"); + assert_eq!(layers[1].layer, Layer::Inherited); + assert!(layers[1].pin.as_deref().unwrap().starts_with("sha256:")); + let qualified = model.resolve("standards::STD-KWJ4VMKVSS66"); + assert_eq!(qualified.outcome, rac_engine::resolve::OUTCOME_RESOLVED); + assert_eq!( + qualified.artifact.unwrap().key, + Some(ArtifactKey::new("acme/standards", "STD-KWJ4VMKVSS66")) + ); + let unqualified = model.resolve("STD-KWJ4VMKVSS66"); + assert_eq!(unqualified.outcome, rac_engine::resolve::OUTCOME_RESOLVED); + assert_eq!( + unqualified.artifact.unwrap().key, + Some(ArtifactKey::new("acme/standards", "STD-KWJ4VMKVSS66")) + ); +} + +fn assert_override_redirect(model: &ReadModel) { + let qualified = model.resolve("standards::STD-KWJ4VMKVSS66"); + assert_eq!(qualified.outcome, rac_engine::resolve::OUTCOME_RESOLVED); + assert_eq!( + qualified.artifact.unwrap().key, + Some(ArtifactKey::new("acme/standards", "STD-KWJ4VMKVSS66")) + ); + let redirected = model.resolve("STD-KWJ4VMKVSS66"); + assert_eq!(redirected.outcome, rac_engine::resolve::OUTCOME_RESOLVED); + assert_eq!( + redirected.artifact.unwrap().key, + Some(ArtifactKey::new("acme/app", "APP-KWJ4VMKVSS65")) + ); + assert_eq!( + model + .canonical_redirect(&ArtifactKey::new("acme/standards", "STD-KWJ4VMKVSS66")) + .unwrap() + .unwrap() + .replacement, + ArtifactKey::new("acme/app", "APP-KWJ4VMKVSS65") + ); +} + +fn full_entries(model: &ReadModel) -> Vec { + match model { + ReadModel::Fresh(derived) => derived.index_entries.clone(), + ReadModel::View(reader) => (0..reader.doc_count) + .map(|docid| reader.full_entry(docid).unwrap()) + .collect(), + } +} + +fn scope_rows(model: &ReadModel) -> Vec { + match model { + ReadModel::Fresh(derived) => derived.scope_rows.clone(), + ReadModel::View(reader) => reader.scope_rows().unwrap(), + } +} + +fn live_keys(model: &ReadModel) -> Vec { + match model { + ReadModel::Fresh(derived) => derived.live_decision_keys.clone(), + ReadModel::View(reader) => reader.live_decision_keys().unwrap(), + } +} + +fn portfolio(model: &ReadModel) -> serde_json::Value { + match model { + ReadModel::Fresh(derived) => derived.portfolio_summary.clone(), + ReadModel::View(reader) => reader.portfolio_summary().unwrap(), + } +} + +fn search(model: &ReadModel, query: &str) -> rac_engine::resolve::SearchResult { + match model { + ReadModel::Fresh(derived) => { + rac_engine::resolve::search_index(&derived.index_entries, query, None, &[]) + } + ReadModel::View(reader) => { + rac_engine::read_model::store_search(reader, query, None, &[], false) + } + } +} + +#[test] +fn cold_warm_and_cross_process_store_hits_keep_source_provenance() { + let (child, _parent, _pin) = fixture("cold-warm"); + let cache = scratch("cold-warm-cache"); + let directory = child_corpus(&child); + let mut tracker = FederatedCacheTracker::new(cache.clone()); + + let read = tracker.read_composed(&child, &directory, true).unwrap(); + assert_eq!(read.refresh, FederatedCacheRefresh::Recomposed); + let identity = read.generation.identity().expect("federated identity"); + assert_eq!(identity.watched_roots.len(), 2); + assert!(identity + .watched_files + .iter() + .any(|path| path.ends_with(".decided/corpus.md"))); + assert!(identity + .watched_files + .iter() + .any(|path| path.ends_with("decisions/parent.md"))); + assert_two_layers(read.model); + let inherited_path = rac_engine::corpus::ArtifactPath::new("acme/standards", "parent.md"); + assert_eq!( + read.inherited_bytes(&inherited_path), + Some(PARENT_DECISION.as_bytes()) + ); + assert_eq!( + read.inherited_text(&inherited_path).as_deref(), + Some(PARENT_DECISION) + ); + assert!(read.override_mapping().is_some()); + + let read = tracker.read_composed(&child, &directory, true).unwrap(); + assert_eq!(read.refresh, FederatedCacheRefresh::WarmReuse); + assert_two_layers(read.model); + assert_eq!( + read.inherited_bytes(&inherited_path), + Some(PARENT_DECISION.as_bytes()) + ); + + drop(tracker); + let mut reopened = FederatedCacheTracker::new(cache.clone()); + let read = reopened.read_composed(&child, &directory, true).unwrap(); + assert_eq!(read.refresh, FederatedCacheRefresh::StoreHit); + assert_two_layers(read.model); + assert_eq!( + read.inherited_bytes(&inherited_path), + Some(PARENT_DECISION.as_bytes()) + ); + assert_eq!(read.composed.catalog().len(), 2); + + fs::remove_dir_all(child).unwrap(); + fs::remove_dir_all(cache).unwrap(); +} + +#[test] +fn every_composed_input_invalidates_and_an_invalid_parent_is_never_served() { + let (child, parent, pin) = fixture("invalidation"); + let cache = scratch("invalidation-cache"); + let directory = child_corpus(&child); + let mut tracker = FederatedCacheTracker::new(cache); + + let _ = tracker.read_composed(&child, &directory, true).unwrap(); + let first_key = tracker.current_key().unwrap().to_string(); + + write_manifest(&child, &pin, "override-changed"); + let read = tracker.read_composed(&child, &directory, true).unwrap(); + assert_eq!(read.refresh, FederatedCacheRefresh::Recomposed); + assert_ne!(tracker.current_key().unwrap(), first_key); + + fs::write( + child.join(".decided/config.yaml"), + b"repository_key: APP\ncorpus:\n source: acme/app\n# child config input\n", + ) + .unwrap(); + let before = tracker.current_key().unwrap().to_string(); + let _ = tracker.read_composed(&child, &directory, true).unwrap(); + assert_ne!(tracker.current_key().unwrap(), before); + + fs::write( + child.join("decisions/child.md"), + format!("{CHILD_DECISION}\n\n"), + ) + .unwrap(); + let before = tracker.current_key().unwrap().to_string(); + let _ = tracker.read_composed(&child, &directory, true).unwrap(); + assert_ne!(tracker.current_key().unwrap(), before); + + fs::write( + parent.join("decisions/parent.md"), + format!("{PARENT_DECISION}\n\n"), + ) + .unwrap(); + let served_key = tracker.current_key().unwrap().to_string(); + let error = match tracker.read_composed(&child, &directory, true) { + Err(error) => error, + Ok(_) => panic!("stale parent bytes must not return the retained model"), + }; + assert!(!error.to_string().contains(&child.to_string_lossy().to_string())); + assert_digest_mismatch(error); + assert_eq!(tracker.current_key().unwrap(), served_key); + + let repinned = calculate_parent_digest(&parent, "decisions") + .unwrap() + .digest; + write_manifest(&child, &repinned, "override-changed"); + let read = tracker.read_composed(&child, &directory, true).unwrap(); + assert_eq!(read.refresh, FederatedCacheRefresh::Recomposed); + + fs::write( + parent.join(".decided/config.yaml"), + b"repository_key: STD\ncorpus:\n source: acme/standards\n# parent config input\n", + ) + .unwrap(); + let error = match tracker.read_composed(&child, &directory, true) { + Err(error) => error, + Ok(_) => panic!("stale parent config must not return the retained model"), + }; + assert_digest_mismatch(error); + let repinned = calculate_parent_digest(&parent, "decisions") + .unwrap() + .digest; + write_manifest(&child, &repinned, "override-changed"); + let read = tracker.read_composed(&child, &directory, true).unwrap(); + assert_eq!(read.refresh, FederatedCacheRefresh::Recomposed); + + fs::remove_dir_all(child).unwrap(); +} + +#[test] +fn a_valid_v1_directory_is_an_explicit_miss_under_the_v2_layout() { + assert_eq!(STORE_LAYOUT_VERSION, "v2"); + let (child, _parent, _pin) = fixture("layout-miss"); + let cache = scratch("layout-miss-cache"); + let directory = child_corpus(&child); + let mut tracker = FederatedCacheTracker::new(cache.clone()); + let _ = tracker.read_composed(&child, &directory, true).unwrap(); + let key = tracker.current_key().unwrap().to_string(); + drop(tracker); + + let v2 = store_dir(&cache, &key); + let v1 = cache.join("store/v1").join(&key); + fs::create_dir_all(v1.parent().unwrap()).unwrap(); + fs::rename(&v2, &v1).unwrap(); + assert!(v1.is_dir()); + assert!(!v2.exists()); + + let mut reopened = FederatedCacheTracker::new(cache.clone()); + let read = reopened.read_composed(&child, &directory, true).unwrap(); + assert_eq!(read.refresh, FederatedCacheRefresh::Recomposed); + assert!(v1.is_dir(), "v1 remains disposable and is never decoded"); + assert!(store_dir(&cache, &key).is_dir()); + + fs::remove_dir_all(child).unwrap(); + fs::remove_dir_all(cache).unwrap(); +} + +#[test] +fn shared_cache_reuses_clone_independent_source_relative_rows() { + let (first, _first_parent, first_pin) = fixture("clone-a"); + let (second, _second_parent, second_pin) = fixture("clone-b"); + assert_eq!(first_pin, second_pin); + let cache = scratch("clone-cache"); + let first_directory = child_corpus(&first); + let second_directory = child_corpus(&second); + + let mut first_tracker = FederatedCacheTracker::new(cache.clone()); + let first_read = first_tracker + .read_composed(&first, &first_directory, true) + .unwrap(); + assert_eq!(first_read.refresh, FederatedCacheRefresh::Recomposed); + let first_key = first_tracker.current_key().unwrap().to_string(); + drop(first_tracker); + + let mut second_tracker = FederatedCacheTracker::new(cache.clone()); + let second_read = second_tracker + .read_composed(&second, &second_directory, true) + .unwrap(); + assert_eq!(second_read.refresh, FederatedCacheRefresh::StoreHit); + assert_eq!(second_read.generation.cache_key(), first_key); + assert_eq!(portfolio(second_read.model)["directory"], "decisions"); + assert!(!portfolio(second_read.model) + .to_string() + .contains(&first.to_string_lossy().to_string())); + for entry in full_entries(second_read.model) { + assert!(matches!(entry.path.as_str(), "child.md" | "parent.md")); + assert!(!entry.path.contains(&second.to_string_lossy().to_string())); + let artifact_path = entry.artifact_path.expect("source-aware path"); + assert_eq!(entry.path, artifact_path.relative_path); + } + let rows = scope_rows(second_read.model); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|row| { + row.key.is_some() + && row.origin.is_some() + && row + .artifact_path + .as_ref() + .is_some_and(|path| path.relative_path == row.path) + })); + let mut keys = live_keys(second_read.model); + keys.sort(); + assert_eq!( + keys, + vec![ + ArtifactKey::new("acme/app", "APP-KWJ4VMKVSS65"), + ArtifactKey::new("acme/standards", "STD-KWJ4VMKVSS66"), + ] + ); + + fs::remove_dir_all(first).unwrap(); + fs::remove_dir_all(second).unwrap(); + fs::remove_dir_all(cache).unwrap(); +} + +#[test] +fn recursive_mode_and_exact_child_snapshot_are_generation_inputs() { + let (child, _parent, _pin) = fixture("snapshot"); + fs::create_dir_all(child.join("decisions/nested")).unwrap(); + fs::write(child.join("decisions/nested/extra.md"), CHILD_DECISION).unwrap(); + let directory = child_corpus(&child); + let recursive = capture_logical_generation(&child, &directory, true).unwrap(); + let top_level = capture_logical_generation(&child, &directory, false).unwrap(); + assert_ne!(recursive.cache_key(), top_level.cache_key()); + assert!(recursive.identity().unwrap().recursive); + assert!(!top_level.identity().unwrap().recursive); + assert_eq!(recursive.identity().unwrap().child_corpus_path, "decisions"); + + fs::remove_file(child.join("decisions/nested/extra.md")).unwrap(); + let captured = capture_logical_generation(&child, &directory, true).unwrap(); + let first_key = captured.cache_key().to_string(); + fs::write( + child.join("decisions/child.md"), + CHILD_DECISION.replace("Child Policy", "Changed After Capture"), + ) + .unwrap(); + let captured_composition = rac_engine::federated_corpus::compose_verified_generation_from_snapshot( + &directory, + captured.verified_parent().unwrap(), + captured.child_files().unwrap(), + ) + .unwrap(); + assert_eq!( + captured_composition + .resolve("APP-KWJ4VMKVSS65") + .unwrap() + .artifact + .product + .title + .as_deref(), + Some("ADR-001: Child Policy") + ); + + let cache = scratch("snapshot-cache"); + let mut tracker = FederatedCacheTracker::new(cache.clone()); + let read = tracker.read_composed(&child, &directory, true).unwrap(); + assert_eq!(read.refresh, FederatedCacheRefresh::Recomposed); + assert_ne!(read.generation.cache_key(), first_key); + let child_entry = full_entries(read.model) + .into_iter() + .find(|entry| entry.id == "APP-KWJ4VMKVSS65") + .unwrap(); + assert_eq!( + child_entry.title.as_deref(), + Some("ADR-001: Changed After Capture") + ); + + fs::remove_dir_all(child).unwrap(); + fs::remove_dir_all(cache).unwrap(); +} + +#[test] +fn equal_public_paths_keep_source_ties_identical_cold_and_warm() { + let (child, parent, _pin) = fixture("equal-path"); + fs::remove_file(child.join("decisions/child.md")).unwrap(); + fs::remove_file(parent.join("decisions/parent.md")).unwrap(); + fs::write( + child.join("decisions/shared.md"), + shared_decision("APP-KWJ4VMKVSS65"), + ) + .unwrap(); + fs::write( + parent.join("decisions/shared.md"), + shared_decision("STD-KWJ4VMKVSS66"), + ) + .unwrap(); + let pin = calculate_parent_digest(&parent, "decisions") + .unwrap() + .digest; + write_manifest(&child, &pin, "equal-path"); + let directory = child_corpus(&child); + let cache = scratch("equal-path-cache"); + + let mut cold_tracker = FederatedCacheTracker::new(cache.clone()); + let cold = cold_tracker + .read_composed(&child, &directory, true) + .unwrap(); + let cold_sources: Vec = search(cold.model, "equalmarker") + .matches + .into_iter() + .map(|item| item.artifact_path.unwrap().source) + .collect(); + assert_eq!(cold_sources, vec!["acme/app", "acme/standards"]); + let rows = scope_rows(cold.model); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|row| row.path == "shared.md")); + assert_ne!(rows[0].key, rows[1].key); + assert_eq!(live_keys(cold.model).len(), 2); + drop(cold_tracker); + + let mut warm_tracker = FederatedCacheTracker::new(cache.clone()); + let warm = warm_tracker + .read_composed(&child, &directory, true) + .unwrap(); + assert_eq!(warm.refresh, FederatedCacheRefresh::StoreHit); + let warm_sources: Vec = search(warm.model, "equalmarker") + .matches + .into_iter() + .map(|item| item.artifact_path.unwrap().source) + .collect(); + assert_eq!(warm_sources, cold_sources); + + fs::remove_dir_all(child).unwrap(); + fs::remove_dir_all(cache).unwrap(); +} + +#[test] +fn override_redirects_are_identical_on_cold_and_store_hits() { + let (child, _parent, pin) = fixture("override-redirect"); + let directory = child_corpus(&child); + let cache = scratch("override-redirect-cache"); + write_override_manifest(&child, &pin); + let mut tracker = FederatedCacheTracker::new(cache.clone()); + let cold = tracker.read_composed(&child, &directory, true).unwrap(); + assert_eq!(cold.composed.catalog().len(), 2); + assert_eq!(cold.composed.effective().len(), 1); + assert_override_redirect(cold.model); + drop(tracker); + + let mut reopened = FederatedCacheTracker::new(cache.clone()); + let warm = reopened.read_composed(&child, &directory, true).unwrap(); + assert_eq!(warm.refresh, FederatedCacheRefresh::StoreHit); + assert_override_redirect(warm.model); + + fs::remove_dir_all(child).unwrap(); + fs::remove_dir_all(cache).unwrap(); +} + +#[test] +fn no_manifest_keeps_the_single_corpus_content_key() { + let root = scratch("legacy-key"); + fs::create_dir_all(root.join("decisions")).unwrap(); + fs::write(root.join("decisions/child.md"), CHILD_DECISION).unwrap(); + let directory = child_corpus(&root); + let expected = corpus_content_hash(&directory, true); + let generation = capture_logical_generation(&root, &directory, true).unwrap(); + assert!(matches!(generation, LogicalGeneration::Legacy { .. })); + assert_eq!(generation.cache_key(), expected); + assert!(generation.identity().is_none()); + assert!(generation.verified_parent().is_none()); + fs::remove_dir_all(root).unwrap(); +} diff --git a/rust/rac-engine/tests/federation_loader.rs b/rust/rac-engine/tests/federation_loader.rs index 8c866dae..b8e30241 100644 --- a/rust/rac-engine/tests/federation_loader.rs +++ b/rust/rac-engine/tests/federation_loader.rs @@ -204,6 +204,10 @@ fn a_verified_result_retains_manifest_and_snapshot_bytes() { child_manifest(&child, "acme/app", &calculated.digest, "acme/standards"); let verified = verify_parent(&child).unwrap().unwrap(); + assert_eq!( + verified.child_config_bytes, + fs::read(child.join(".decided/config.yaml")).unwrap() + ); assert_eq!(verified.config_bytes, calculated.config_bytes); assert_eq!(verified.files, calculated.files); assert_eq!( @@ -216,6 +220,7 @@ fn a_verified_result_retains_manifest_and_snapshot_bytes() { b"one\n" ); assert!(verified.overrides.is_some()); + assert!(verified.override_mapping_bytes.is_some()); assert_eq!( verified.manifest_bytes, fs::read(child.join(".decided/corpus.md")).unwrap() diff --git a/rust/rac-engine/tests/index_store_vectors.rs b/rust/rac-engine/tests/index_store_vectors.rs index f1730e31..7250fb12 100644 --- a/rust/rac-engine/tests/index_store_vectors.rs +++ b/rust/rac-engine/tests/index_store_vectors.rs @@ -1,7 +1,7 @@ -//! Index-store golden vectors (INDEX-PLAN B2) — the native writer must be -//! byte-identical to the oracle's store over the pinned fixture corpora -//! (`rust/spec/gen_vectors_index.py`), and the reader must fail closed on -//! every corruption class and reproduce a fresh build on the good path. +//! Index-store vectors (INDEX-PLAN B2). The v2 source-aware layout retains +//! byte parity for unchanged v1 segments, pins deterministic whole-store +//! output across two writes, and round-trips provenance. The reader must fail +//! closed on every corruption class and reproduce a fresh build. use std::fs; use std::path::{Path, PathBuf}; @@ -63,7 +63,7 @@ fn fingerprint_matches_oracle() { /// repo-relative directory — and cwd is process-global, so everything /// cwd-dependent lives in this single test. #[test] -fn store_bytes_match_oracle_goldens() { +fn v2_store_is_deterministic_and_retains_unchanged_segment_vectors() { std::env::set_current_dir(repo_root()).expect("chdir repo root"); let vectors = vectors(); for (name, corpus) in vectors["corpora"].as_object().unwrap() { @@ -87,10 +87,27 @@ fn store_bytes_match_oracle_goldens() { seen.sort(); let golden = corpus["segments"].as_object().unwrap(); let mut expected_names: Vec = golden.keys().cloned().collect(); + expected_names.extend( + [ + "artifactpathmap.seg", + "identities.seg", + "keymap.seg", + "layers.seg", + "redirects.seg", + ] + .into_iter() + .map(str::to_string), + ); expected_names.sort(); assert_eq!(seen, expected_names, "segment file set for {name}"); for (seg, meta) in golden { + if matches!( + seg.as_str(), + "entries.seg" | "relationships.seg" | "live.seg" | "scope.seg" + ) { + continue; // v2 appends source-aware identity to these rows + } let bytes = fs::read(seg_dir.join(seg)).unwrap(); assert_eq!( rac_engine::sha256::hexdigest(&bytes), @@ -102,6 +119,23 @@ fn store_bytes_match_oracle_goldens() { } } + let second_cache = scratch_dir(&format!("golden-second-{name}")); + assert!(write_store( + &second_cache, + expected_hash, + SCHEMA_VERSION, + &derived + )); + let second_dir = store_dir(&second_cache, expected_hash); + for segment in &seen { + assert_eq!( + fs::read(seg_dir.join(segment)).unwrap(), + fs::read(second_dir.join(segment)).unwrap(), + "v2 segment {segment} of {name} is not deterministic" + ); + } + let _ = fs::remove_dir_all(&second_cache); + // Reader round-trip: the mapped base reproduces the fresh build. let reader = open_store(&cache_dir, expected_hash, SCHEMA_VERSION).expect("open"); reader_reproduces_fresh_build(&reader, &derived); @@ -126,8 +160,9 @@ fn store_bytes_match_oracle_goldens() { "warm != cold for {query:?} over {name}" ); } - let cold = rac_engine::read_model::find_decisions_in( + let cold = rac_engine::read_model::find_decisions_in_source_aware( &derived.index_entries, + &derived.live_decision_keys, &derived.live_decision_paths, "widget", ); @@ -151,6 +186,10 @@ fn reader_reproduces_fresh_build( derived: &rac_engine::derived::DerivedIndex, ) { assert_eq!(reader.doc_count as usize, derived.index_entries.len()); + assert_eq!( + reader.identity_count().unwrap() as usize, + derived.resolution.entries.len() + ); for (docid, entry) in derived.index_entries.iter().enumerate() { let docid = docid as u32; let full = reader.full_entry(docid).unwrap(); @@ -160,6 +199,9 @@ fn reader_reproduces_fresh_build( assert_eq!(full.path, entry.path); assert_eq!(full.aliases, entry.aliases); assert_eq!(full.tags, entry.tags); + assert_eq!(full.key, entry.key); + assert_eq!(full.artifact_path, entry.artifact_path); + assert_eq!(full.origin, entry.origin); assert_eq!(full.inbound_count, entry.inbound_count); assert_eq!(full.search_sections.len(), entry.search_sections.len()); for (a, b) in full.search_sections.iter().zip(&entry.search_sections) { @@ -179,6 +221,26 @@ fn reader_reproduces_fresh_build( // Path map answers this doc. assert_eq!(reader.docid_for_path(&entry.path).unwrap(), Some(docid)); } + for (docid, entry) in derived.resolution.entries.iter().enumerate() { + let docid = docid as u32; + let identity = reader.identity_entry(docid).unwrap(); + assert_eq!(identity.id, entry.id); + assert_eq!(identity.path, entry.path); + assert_eq!(identity.aliases, entry.aliases); + assert_eq!(identity.key, entry.key); + assert_eq!(identity.artifact_path, entry.artifact_path); + assert_eq!(identity.origin, entry.origin); + let source = reader.source_artifact(docid).unwrap(); + assert_eq!(source.key, entry.key.clone().unwrap()); + assert_eq!(source.path, entry.artifact_path.clone().unwrap()); + assert!(reader.docids_for_key(&source.key).unwrap().contains(&docid)); + assert_eq!(reader.docid_for_artifact_path(&source.path).unwrap(), Some(docid)); + } + assert_eq!(reader.layers().unwrap(), derived.layers); + assert_eq!( + reader.canonical_redirects().unwrap(), + derived.resolution.canonical_redirects + ); // Relationships / live / scope / portfolio round-trip. let rels = reader.relationships().unwrap(); assert_eq!(rels.len(), derived.relationships.len()); @@ -188,14 +250,23 @@ fn reader_reproduces_fresh_build( assert_eq!(a.target, b.target); assert_eq!(a.resolved_path, b.resolved_path); assert_eq!(a.issue, b.issue); + assert_eq!(a.source_artifact, b.source_artifact); + assert_eq!(a.resolved_artifact, b.resolved_artifact); } assert_eq!( reader.live_decision_paths().unwrap(), derived.live_decision_paths ); + assert_eq!( + reader.live_decision_keys().unwrap(), + derived.live_decision_keys + ); let scope = reader.scope_rows().unwrap(); assert_eq!(scope.len(), derived.scope_rows.len()); for (a, b) in scope.iter().zip(&derived.scope_rows) { + assert_eq!(a.key, b.key); + assert_eq!(a.artifact_path, b.artifact_path); + assert_eq!(a.origin, b.origin); assert_eq!(a.id, b.id); assert_eq!(a.title, b.title); assert_eq!(a.status, b.status); @@ -204,6 +275,12 @@ fn reader_reproduces_fresh_build( } assert_eq!(reader.portfolio_summary().unwrap(), derived.portfolio_summary); assert_eq!(reader.docid_for_path("no/such/path.md").unwrap(), None); + assert_eq!( + reader + .docid_for_key(&rac_engine::corpus::ArtifactKey::new("none/source", "none")) + .unwrap(), + None + ); assert!(reader.alias_docids("no-such-alias").unwrap().is_empty()); } @@ -250,9 +327,14 @@ fn corruption_gates(cache_dir: &Path, corpus_hash: &str, seg_dir: &Path) { let derived = rac_engine::derived::DerivedIndex { layers: Vec::new(), source_artifacts: Vec::new(), + resolution: Box::new(rac_engine::derived::ResolutionProjection { + entries: Vec::new(), + canonical_redirects: Vec::new(), + }), index_entries: Vec::new(), field_tokens: Vec::new(), relationships: Vec::new(), + live_decision_keys: Vec::new(), live_decision_paths: Vec::new(), portfolio_summary: serde_json::json!({}), scope_rows: Vec::new(), diff --git a/rust/rac-engine/tests/source_aware_substrate.rs b/rust/rac-engine/tests/source_aware_substrate.rs index 24398ea2..18b0d660 100644 --- a/rust/rac-engine/tests/source_aware_substrate.rs +++ b/rust/rac-engine/tests/source_aware_substrate.rs @@ -153,8 +153,8 @@ fn no_manifest_keeps_the_released_index_projection_byte_exact() { assert_eq!(resolved.key, Some(items[0].key.clone())); assert_eq!(resolved.origin, Some(items[0].origin.clone())); - // The frozen v1 codec remains untouched in this substrate-only change. - assert_eq!(rac_engine::index_store::STORE_LAYOUT_VERSION, "v1"); + // ADR-143 gates source-aware persisted rows behind an explicit layout. + assert_eq!(rac_engine::index_store::STORE_LAYOUT_VERSION, "v2"); let _ = fs::remove_dir_all(root); } diff --git a/rust/spec/index-contracts.json b/rust/spec/index-contracts.json index 2a38a5f6..97beed4d 100644 --- a/rust/spec/index-contracts.json +++ b/rust/spec/index-contracts.json @@ -16,15 +16,15 @@ }, "index_store": { "module": "services/index_store.py", - "contract": "12 segments, layouts and gates per spec/index-store-format.md §1–§5. write_store atomic (temp dir + fsync + os.replace); existing dir probed via full reader open, replaced when unopenable; remove_store best-effort. MmapIndexReader validates all 12 framings + header gates on open. Fold over EMPTY_DELTA: base is the whole answer; resolve() reproduces resolve_in_index outcomes (not-found / duplicate with sorted paths / resolved) from the aliasmap; search() reproduces search_index bytes from postings-served candidates; find_decisions = decision-typed search then live-paths filter. ReadModelView lazily materialises; equality is against a FRESH BUILD, never a self round-trip.", + "contract": "17 source-aware v2 segments, layouts and gates per spec/index-store-format.md §1–§5; store/v1 is never opened and is an ordinary miss. write_store is atomic (temp dir + fsync + rename); existing dir is probed via full reader open and replaced when unopenable; remove_store is best-effort. MmapIndexReader validates all 17 framings + header gates and reconstructs the distinct effective-search and central identity projections, CorpusLayer, ArtifactKey, ArtifactPath, ArtifactOrigin, validated canonical redirects, source-aware liveness/scope, and relationship endpoints. Composite key/path maps never infer local precedence. Fold over EMPTY_DELTA: base is the whole answer; search reproduces fresh bytes from postings-served candidates. Equality is against a FRESH BUILD, never a self round trip.", "scoring": "BM25F from store integers: n and per-field Σ from header (avglen = one division per query), df from binary-searched prefix ranges over the sorted termdict unioned through postings, tf by counting a doc's forward token ids within the prefix id range [bisect_left(term), bisect_left(successor)) where successor increments the last char's code point. Summation order = FIELDS order = (id,title,path,heading,body,tags). Duplicate-token df dedup is an ORACLE DEFECT (PORT-CONTRACT.d/10 §0a): the native engine keeps walk semantics (per-occurrence df) on both paths.", "negative_cases": "corrupt segment, truncation, bundle-version bump, scoring-fingerprint change, hash mismatch, unwritable cache dir, marker-present-store-missing — every one degrades to fresh build; marker cleared + store removed when marker claimed an unusable store." }, "derived_cache": { "module": "services/derived_cache.py", - "contract": "SCHEMA_VERSION '3'. DerivedIndex = index_entries + relationships + field_tokens_by_path + live_decision_paths + portfolio_summary + scope_rows, all pure functions of the sorted-path snapshot; build order: resolution_index once -> relationships -> inbound counts -> index -> tokens/live/portfolio/scope. ScopeRow only for live decisions declaring '## Applies To' (SCOPE_SECTIONS) entries, flattened in scan order. governing_decisions matches over precomputed rows byte-identically to scope.decisions_for_path ((id.casefold(), path) sort, first covering entry). load_or_build: manifest root key -> open .fseg (unless verify) -> stat_scan (content_confirm_all when verify or no manifest) -> corpus_hash_from_manifest -> best-effort manifest write -> marker gate -> open store else remove_store + parallel cold build -> write store -> write marker -> reopen view, else return the fresh DerivedIndex itself.", + "contract": "SCHEMA_VERSION '3'. DerivedIndex includes layers, source_artifacts parallel to effective index_entries, a separate authoritative identity_entries projection, validated canonical redirects, relationships, field tokens, source-aware live keys, portfolio summary, and source-aware scope rows. The single-corpus load_or_build path is unchanged apart from selecting store/v2. FederatedCacheTracker verifies the materialised parent before every resident or persistent hit, captures exact child path/bytes once, and passes that same snapshot to a typed fallible central composer. Its framed logical key covers the captured child corpus, child source/config, exact manifest, parent source/pin/config, alias/layers, override mapping, recursive mode, and stable repository-relative corpus root. A successful read couples the persisted projection, exact verified generation, and authoritative central composition; inherited and local bodies remain captured and public paths are source-relative, never reopened under the child. A relevant change fully recomposes; failed verification or composition returns an error and never returns the retained model.", "cache_dir": "DECIDED_CACHE_DIR > $XDG_CACHE_HOME/rac/derived > ~/.cache/rac/derived > /rac-cache/rac/derived. CLI gate: args.cache AND not DECIDED_NO_CACHE (any non-empty value disables).", - "notes": "identity_entries projection elides sections/inbound/tags (fresh path) — the store's identity rows DO carry tags; resolution reads neither." + "notes": "identity_entries elide sections and inbound while retaining tags and only the aliases authorized by central composition. Qualified parent aliases and canonical override redirects therefore have identical fresh and mapped behavior." }, "freshness": { "module": "services/freshness.py", diff --git a/rust/spec/index-store-format.md b/rust/spec/index-store-format.md index 727d3fae..2772d5f1 100644 --- a/rust/spec/index-store-format.md +++ b/rust/spec/index-store-format.md @@ -1,31 +1,32 @@ # Index store on-disk format (ADR-104 / ADR-106 / ADR-112) -Durable byte-level specification of the persistent derived-index store, -extracted from the frozen Python oracle (`src/asdecided/services/index_format.py`, -`index_store.py`, `derived_cache.py`, `freshness.py`) for the native port -(roadmap:native-derived-index). **Store byte-identity is the chosen parity -surface**: for the same corpus bytes the Rust writer must produce a store -directory whose every segment file is byte-identical to the oracle's. -Everything below is deterministic — no timestamps, no pids, no floats on -disk (temp-file *names* embed pid/random bytes but never survive the -`os.replace`). +Durable byte-level specification of the persistent derived-index store. The +v1 format was extracted from the frozen Python oracle; ADR-143 cuts the native +engine over to a source-aware v2 layout. Unchanged segment families retain +their byte vectors, while v2 entries, relationships, liveness, scope, layers, +and composite maps carry federation identity. Old `store/v1` directories are cache misses +and are never decoded as v2. Everything below is deterministic — no +timestamps, no pids, no floats on disk (temp-file *names* embed pid/random +bytes but never survive the atomic rename). ## 1. Cache directory layout ``` / # default_cache_dir(), §8 .json # marker (schema gate), §7 - store/v1// # one store dir per corpus hash - header.seg entries.seg sections.seg tokens.seg termdict.seg + store/v2// # one store dir per logical generation + header.seg entries.seg identities.seg sections.seg tokens.seg termdict.seg postings.seg relationships.seg live.seg scope.seg portfolio.seg - aliasmap.seg pathmap.seg # exactly these 12 files + aliasmap.seg pathmap.seg keymap.seg artifactpathmap.seg layers.seg + redirects.seg # exactly these 17 files validate/v1/.vseg # per-root validation rows, §9 manifest/v1/.fseg # per-root stat manifest, §10 ``` -- `STORE_DIRNAME = "store"`, `STORE_LAYOUT_VERSION = "v1"`. -- `corpus_hash` = the corpus content hash (§6), lowercase hex sha256. -- Writes are atomic: segments land in `store/v1/..tmp--`, +- `STORE_DIRNAME = "store"`, `STORE_LAYOUT_VERSION = "v2"`. +- `generation_hash` is the single-corpus content hash (§6) when there is no + federation manifest, or the framed composed-generation hash (§6.1). +- Writes are atomic: segments land in `store/v2/..tmp--`, each file `fsync`ed, the dir fsynced (best-effort), then `os.replace`d onto the final name. `.vseg`/`.fseg` writes use the same temp+replace shape with one file. @@ -74,19 +75,34 @@ rows concatenated row blobs (no per-row length; offsets delimit) Reader gates: offset table must fit the payload; `row(k)` requires `0 <= k < count` and `data_start + offset <= len`. -## 3. The 12 read-model segments +## 3. The 17 read-model segments -Docids are assigned in `index_entries` order — the corpus walk's -sorted-path order (`sorted(Path)` over `find_markdown_files`, §6). All -per-doc segments are indexed by that docid. +Search docids are assigned in effective `index_entries` order. Identity +docids are assigned independently in the central composition layer's exact +point-resolution order. In a non-federated corpus both projections retain the +corpus walk's sorted-path order (`sorted(Path)` over +`find_markdown_files`, §6). Field order is a parity contract: `FIELDS = ("id", "title", "path", "heading", "body", "tags")`. - **entries.seg** (indexed, one row per doc): `text id | text type | opt_text title | text path | text_list aliases | - text_list tags | u32 inbound_count | 6 × u32 per-field token counts` + text_list tags | u32 inbound_count | 6 × u32 per-field token counts | + opt ArtifactKey | opt ArtifactPath | opt ArtifactOrigin` (field lengths in FIELDS order, `len(field_tokens[name])`). + Each `opt` uses a `u32` 0/1 presence flag. `ArtifactKey` is + `text source | text canonical_id`; `ArtifactPath` is + `text source | text relative_path`; `ArtifactOrigin` is + `text source | text layer | opt_text pin | opt_text alias`, where layer is + exactly `local` or `inherited`. Fresh v2 rows always carry all three values; + absence remains decodable only for compatibility fixtures. +- **identities.seg** (indexed, one row per authorized point-resolution row): + `text id | text type | opt_text title | text path | text_list aliases | + text_list tags | opt ArtifactKey | opt ArtifactPath | opt ArtifactOrigin`. + It may retain an overridden parent under its qualified canonical alias while + attaching that parent's unqualified canonical id to the local replacement. + It deliberately has no search sections, inbound count, or field lengths. - **sections.seg** (indexed): `u32 nsections` then per section `text heading | text_list lines`. - **tokens.seg** (indexed): 6 × `u32_list` of term ids, FIELDS order, @@ -100,16 +116,33 @@ Field order is a parity contract: id set). - **aliasmap.seg** (indexed): rows sorted by casefolded key (Python `str.casefold`, code-point sort); row = `text key | u32_list docids` - (ascending, consecutive-duplicate-guarded). Keys are the casefold of - every entry alias. + (ascending, consecutive-duplicate-guarded). Keys and docids refer to + `identities.seg`, so qualified parent lookup and override redirects survive + a warm open exactly as composed. - **pathmap.seg** (indexed): rows sorted by path *string* (not Path order); row = `text path | u32 docid`. +- **keymap.seg** (indexed): rows sorted by `(source, canonical_id)`; row = + `text source | text canonical_id | u32_list identity_docids`. Multiple + docids retain a deterministic ambiguity instead of acquiring precedence. +- **artifactpathmap.seg** (indexed): rows sorted by + `(source, relative_path)`; row = + `text source | text relative_path | u32 identity_docid`. - **relationships.seg** (plain): `u32 count` then per relationship `text source_path | text relationship | text target | - opt_text resolved_path | opt_text issue`. -- **live.seg** (plain): `text_list live_decision_paths`. + opt_text resolved_path | opt_text issue | opt ArtifactPath source_artifact | + opt ArtifactPath resolved_artifact`. +- **layers.seg** (plain): `u32 count` then per stable layer + `text source | text layer | opt_text pin | opt_text alias`. +- **redirects.seg** (plain): `u32 count` then per validated mapping three + required ArtifactKeys in order: parent, replacement, rationale. Each key is + encoded with the standard `u32 present=1 | text source | text canonical_id` + shape. Raw manifest YAML is never persisted as resolution authority. +- **live.seg** (plain): `u32 count` then per live decision + `required ArtifactKey | text display_path`. The key is the filtering + authority; the display path is retained for released single-corpus output. - **scope.seg** (plain): `u32 count` then per row - `text id | text title | text status | text path | + `opt ArtifactKey | opt ArtifactPath | opt ArtifactOrigin | + text id | text title | text status | text path | text_list scope_entries`. - **portfolio.seg** (plain): one `text` — the portfolio summary dict as `json.dumps(obj, ensure_ascii=False)` (compact-with-spaces default @@ -121,7 +154,7 @@ Field order is a parity contract: ### 3.1 Header gates on open -After framing checks on all 12 segments, the reader reads header.seg and +After framing checks on all 17 segments, the reader reads header.seg and fails closed on: stored hash ≠ requested corpus hash; stored bundle ≠ `SCHEMA_VERSION = "3"`; stored fingerprint ≠ the compiled-in `scoring_fingerprint()`. Fingerprint string (pinned): @@ -167,6 +200,24 @@ hexdigest is the corpus hash. `corpus_hash_from_manifest` reproduces this from cached per-file hashes (re-hashing any file absent from the manifest) — byte-identical for every non-S5 state. +### 6.1 Federated logical generation hash + +A repository with a verified parent uses domain +`asdecided-federated-generation-v1\0` and one-byte-tag + u64-big-endian-length +frames over: the hash of the exact captured child path/byte snapshot; child +source; exact child config bytes; exact manifest bytes; parent source; +verified full parent digest; exact parent config bytes; parent alias; an +explicitly presence-tagged override-mapping payload; the `local` / `inherited` +layer labels; the recursive/top-level flag; and the child-repository-relative +corpus root. Parent verification and child capture precede key construction +and every cache reuse. The central composer consumes those captured child +bytes and the verified parent's captured bytes without a second walk or read. +Changing any child corpus/config/mode/root, manifest, alias, override, parent +config/source, parent artifact, or pin input therefore selects a different +generation. Checkout paths never enter the key or a public artifact path. A +repository without the manifest continues to use the corpus hash above +unchanged. + ## 7. Marker file `/.json` — written AFTER the store lands From 4ece86c37d7dd6b5b4a434bf264ec4c09b73528a Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 30 Aug 2026 08:46:59 +0100 Subject: [PATCH 7/9] feat(mcp): serve verified federated corpora Signed-off-by: Tom Ballard --- rust/decided-mcp/src/graph.rs | 321 ++++++++++++-- rust/decided-mcp/src/main.rs | 261 ++++++++++-- rust/decided-mcp/src/tools.rs | 398 +++++++++++++++++- rust/decided-mcp/tests/federation.rs | 341 +++++++++++++++ rust/fixtures/eval/README.md | 24 +- rust/fixtures/eval/federation/baseline.json | 23 + .../federation/child/.decided/config.yaml | 4 + .../eval/federation/child/.decided/corpus.md | 19 + .../child/decisions/local-quantum-notes.md | 26 ++ .../vendor/standards/.decided/config.yaml | 4 + .../decisions/compaction-decoy-01.md | 27 ++ .../decisions/compaction-decoy-02.md | 27 ++ .../decisions/compaction-decoy-03.md | 27 ++ .../decisions/compaction-decoy-04.md | 27 ++ .../decisions/compaction-decoy-05.md | 27 ++ .../decisions/compaction-decoy-06.md | 27 ++ .../decisions/ledger-reference-hub.md | 27 ++ .../quantum-ledger-compaction-anchor.md | 26 ++ .../decisions/service-standard-001.md | 30 ++ .../decisions/service-standard-002.md | 30 ++ .../decisions/service-standard-003.md | 30 ++ .../decisions/service-standard-004.md | 30 ++ .../decisions/service-standard-005.md | 30 ++ .../decisions/service-standard-006.md | 30 ++ .../decisions/service-standard-007.md | 30 ++ .../decisions/service-standard-008.md | 30 ++ .../decisions/service-standard-009.md | 30 ++ .../decisions/service-standard-010.md | 30 ++ .../decisions/service-standard-011.md | 30 ++ .../decisions/service-standard-012.md | 30 ++ .../decisions/service-standard-013.md | 30 ++ .../decisions/service-standard-014.md | 30 ++ .../decisions/service-standard-015.md | 30 ++ .../decisions/service-standard-016.md | 30 ++ .../decisions/service-standard-017.md | 30 ++ .../decisions/service-standard-018.md | 30 ++ .../decisions/service-standard-019.md | 30 ++ .../decisions/service-standard-020.md | 30 ++ .../decisions/service-standard-021.md | 30 ++ .../decisions/service-standard-022.md | 30 ++ .../decisions/service-standard-023.md | 30 ++ .../decisions/service-standard-024.md | 30 ++ .../decisions/service-standard-025.md | 30 ++ .../decisions/service-standard-026.md | 30 ++ .../decisions/service-standard-027.md | 30 ++ .../decisions/service-standard-028.md | 30 ++ .../decisions/service-standard-029.md | 30 ++ .../decisions/service-standard-030.md | 30 ++ .../decisions/service-standard-031.md | 30 ++ .../decisions/service-standard-032.md | 30 ++ .../fixtures/eval/federation/eval-config.json | 14 + rust/fixtures/eval/federation/queries.json | 21 + rust/rac-engine/src/budget.rs | 119 +++++- rust/rac-engine/src/commands.rs | 178 +++++++- rust/rac-engine/src/composition.rs | 117 +++++ rust/rac-engine/src/coverage.rs | 74 +++- rust/rac-engine/src/derived_cache.rs | 44 +- rust/rac-engine/src/doctor.rs | 186 ++++++-- rust/rac-engine/src/eval.rs | 87 +++- rust/rac-engine/src/herald.rs | 61 ++- rust/rac-engine/src/index.rs | 30 +- rust/rac-engine/src/output.rs | 158 +++++-- rust/rac-engine/src/portfolio.rs | 95 ++++- rust/rac-engine/src/relationships.rs | 61 +++ rust/rac-engine/src/resolve.rs | 2 +- rust/rac-engine/src/review.rs | 64 ++- rust/rac-engine/src/stats.rs | 40 +- rust/rac-engine/tests/federated_cache.rs | 112 ++++- .../tests/source_aware_substrate.rs | 36 ++ 69 files changed, 3868 insertions(+), 227 deletions(-) create mode 100644 rust/decided-mcp/tests/federation.rs create mode 100644 rust/fixtures/eval/federation/baseline.json create mode 100644 rust/fixtures/eval/federation/child/.decided/config.yaml create mode 100644 rust/fixtures/eval/federation/child/.decided/corpus.md create mode 100644 rust/fixtures/eval/federation/child/decisions/local-quantum-notes.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/.decided/config.yaml create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-01.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-02.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-03.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-04.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-05.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-06.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/ledger-reference-hub.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/quantum-ledger-compaction-anchor.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-001.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-002.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-003.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-004.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-005.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-006.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-007.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-008.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-009.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-010.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-011.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-012.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-013.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-014.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-015.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-016.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-017.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-018.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-019.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-020.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-021.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-022.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-023.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-024.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-025.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-026.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-027.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-028.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-029.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-030.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-031.md create mode 100644 rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-032.md create mode 100644 rust/fixtures/eval/federation/eval-config.json create mode 100644 rust/fixtures/eval/federation/queries.json diff --git a/rust/decided-mcp/src/graph.rs b/rust/decided-mcp/src/graph.rs index 1d81ec90..691c150c 100644 --- a/rust/decided-mcp/src/graph.rs +++ b/rust/decided-mcp/src/graph.rs @@ -2,6 +2,7 @@ //! and adjacency indexes are built once per freshness generation, then reused //! by every graph call until the corpus changes. +use rac_engine::corpus::{ArtifactKey, ArtifactOrigin, ArtifactPath, Layer}; use rac_engine::freshness::TrackerModel; use rac_engine::relationships::{corpus_items, relationships_from_corpus, Relationship}; use rac_engine::resolve::{index_from_items, IndexEntry, ResolutionResult, ResolvedArtifact}; @@ -26,6 +27,22 @@ fn relationship_order(section: &str) -> usize { RELATIONSHIP_SECTIONS.len() } +fn stable_entry_order(left: &IndexEntry, right: &IndexEntry) -> std::cmp::Ordering { + left.artifact_path + .cmp(&right.artifact_path) + .then_with(|| left.path.cmp(&right.path)) + .then_with(|| left.id.cmp(&right.id)) +} + +fn public_path(entry: &IndexEntry, federated: bool) -> String { + if federated { + if let Some(path) = &entry.artifact_path { + return path.relative_path.clone(); + } + } + entry.path.clone() +} + pub struct OutgoingReferences { /// Section (snake_case) → raw stored targets, first-seen section order. pub by_section: Vec<(String, Vec)>, @@ -47,6 +64,8 @@ impl OutgoingReferences { } pub struct IncomingReference { + pub key: Option, + pub origin: Option, pub id: String, pub artifact_type: String, pub title: Option, @@ -61,6 +80,8 @@ pub struct IncomingReferences { } pub struct NeighborhoodNode { + pub key: Option, + pub origin: Option, pub id: String, pub artifact_type: String, pub title: Option, @@ -79,9 +100,11 @@ pub struct GraphView { relationships: Vec, aliases: HashMap>, entry_by_path: HashMap, + entry_by_artifact_path: HashMap, outgoing_by_source: Vec>, incoming_by_target: Vec>, adjacency: Vec>, + federated: bool, } impl GraphView { @@ -111,11 +134,25 @@ impl GraphView { Self::new(index_from_items(&corpus), relationships_from_corpus(&corpus)) } + pub fn from_composed(corpus: &rac_engine::composition::ComposedCorpus) -> Self { + Self::new(corpus.identity_index(), corpus.catalog_relationships()) + } + pub fn new(entries: Vec, relationships: Vec) -> Self { let mut aliases: HashMap> = HashMap::new(); let mut entry_by_path = HashMap::with_capacity(entries.len()); + let mut entry_by_artifact_path = HashMap::with_capacity(entries.len()); + let federated = entries.iter().any(|entry| { + entry + .origin + .as_ref() + .is_some_and(|origin| origin.layer == Layer::Inherited) + }); for (index, entry) in entries.iter().enumerate() { entry_by_path.insert(entry.path.clone(), index); + if let Some(path) = &entry.artifact_path { + entry_by_artifact_path.insert(path.clone(), index); + } for alias in &entry.aliases { let targets = aliases .entry(rac_engine::pycompat::py_casefold(alias)) @@ -130,18 +167,32 @@ impl GraphView { let mut incoming_by_target = vec![Vec::new(); entries.len()]; let mut adjacency = vec![Vec::new(); entries.len()]; for (index, relationship) in relationships.iter().enumerate() { - let Some(&source_index) = entry_by_path.get(&relationship.source_path) else { + let source_index = relationship + .source_artifact + .as_ref() + .and_then(|path| entry_by_artifact_path.get(path)) + .or_else(|| entry_by_path.get(&relationship.source_path)) + .copied(); + let Some(source_index) = source_index else { continue; }; outgoing_by_source[source_index].push(index); - let Some(target) = relationship.resolved_path.as_deref() else { - continue; - }; - let Some(&target_index) = entry_by_path.get(target) else { + let target_index = relationship + .resolved_artifact + .as_ref() + .and_then(|path| entry_by_artifact_path.get(path)) + .copied() + .or_else(|| { + relationship + .resolved_path + .as_deref() + .and_then(|path| entry_by_path.get(path).copied()) + }); + let Some(target_index) = target_index else { continue; }; incoming_by_target[target_index].push(index); - if relationship.source_path == target { + if source_index == target_index { continue; } let rank = relationship_order(&relationship.relationship); @@ -154,12 +205,23 @@ impl GraphView { relationships, aliases, entry_by_path, + entry_by_artifact_path, outgoing_by_source, incoming_by_target, adjacency, + federated, } } + fn entry_index(&self, artifact: &ResolvedArtifact) -> Option { + artifact + .artifact_path + .as_ref() + .and_then(|path| self.entry_by_artifact_path.get(path)) + .copied() + .or_else(|| self.entry_by_path.get(&artifact.path).copied()) + } + pub fn resolve(&self, artifact_id: &str) -> ResolutionResult { use rac_engine::resolve::{OUTCOME_DUPLICATE, OUTCOME_NOT_FOUND, OUTCOME_RESOLVED}; @@ -176,7 +238,15 @@ impl GraphView { if matches.len() > 1 { let mut paths: Vec = matches .iter() - .map(|index| self.entries[*index].path.clone()) + .map(|index| { + let entry = &self.entries[*index]; + if self.federated { + if let Some(path) = &entry.artifact_path { + return format!("{}::{}", path.source, path.relative_path); + } + } + entry.path.clone() + }) .collect(); paths.sort(); return ResolutionResult { @@ -197,7 +267,7 @@ impl GraphView { id: entry.id.clone(), artifact_type: entry.artifact_type.clone(), title: entry.title.clone(), - path: entry.path.clone(), + path: public_path(entry, self.federated), section: None, snippet: None, evidence: None, @@ -208,11 +278,10 @@ impl GraphView { } } - pub fn outgoing(&self, source_path: &str) -> OutgoingReferences { + pub fn outgoing(&self, artifact: &ResolvedArtifact) -> OutgoingReferences { let indexes = self - .entry_by_path - .get(source_path) - .map(|index| self.outgoing_by_source[*index].as_slice()) + .entry_index(artifact) + .map(|index| self.outgoing_by_source[index].as_slice()) .unwrap_or(&[]); let mut by_section: Vec<(String, Vec)> = Vec::new(); for index in indexes.iter().take(MAX_RELATED_EDGES) { @@ -234,30 +303,37 @@ impl GraphView { } } - pub fn incoming(&self, target_path: &str) -> IncomingReferences { - let indexes = self - .entry_by_path - .get(target_path) - .map(|index| self.incoming_by_target[*index].as_slice()) + pub fn incoming(&self, artifact: &ResolvedArtifact) -> IncomingReferences { + let target_index = self.entry_index(artifact); + let indexes = target_index + .map(|index| self.incoming_by_target[index].as_slice()) .unwrap_or(&[]); let mut incoming = Vec::new(); let mut total = 0usize; for index in indexes { let relationship = &self.relationships[*index]; - if relationship.source_path == target_path { - continue; - } - let Some(entry_index) = self.entry_by_path.get(&relationship.source_path) else { + let entry_index = relationship + .source_artifact + .as_ref() + .and_then(|path| self.entry_by_artifact_path.get(path)) + .or_else(|| self.entry_by_path.get(&relationship.source_path)) + .copied(); + let Some(entry_index) = entry_index else { continue; }; + if Some(entry_index) == target_index { + continue; + } total += 1; if incoming.len() < MAX_RELATED_EDGES { - let entry = &self.entries[*entry_index]; + let entry = &self.entries[entry_index]; incoming.push(IncomingReference { + key: entry.key.clone(), + origin: entry.origin.clone(), id: entry.id.clone(), artifact_type: entry.artifact_type.clone(), title: entry.title.clone(), - path: relationship.source_path.clone(), + path: public_path(entry, self.federated), section: relationship.relationship.clone(), target: relationship.target.clone(), }); @@ -276,9 +352,9 @@ impl GraphView { } } - pub fn neighborhood(&self, origin_path: &str, depth: i64) -> Neighborhood { + pub fn neighborhood(&self, artifact: &ResolvedArtifact, depth: i64) -> Neighborhood { let depth = depth.clamp(0, MAX_TRAVERSAL_DEPTH); - let Some(&origin_index) = self.entry_by_path.get(origin_path) else { + let Some(origin_index) = self.entry_index(artifact) else { return Neighborhood { nodes: Vec::new(), truncated: false, @@ -295,11 +371,14 @@ impl GraphView { for current_depth in 1..=depth { let mut next_frontier = Vec::new(); let mut sorted_frontier = frontier.clone(); - sorted_frontier.sort_by(|a, b| self.entries[*a].path.cmp(&self.entries[*b].path)); + sorted_frontier.sort_by(|a, b| { + stable_entry_order(&self.entries[*a], &self.entries[*b]) + }); for entry_index in &sorted_frontier { let mut neighbors = self.adjacency[*entry_index].clone(); neighbors.sort_by(|a, b| { - (&self.entries[a.0].path, a.1).cmp(&(&self.entries[b.0].path, b.1)) + stable_entry_order(&self.entries[a.0], &self.entries[b.0]) + .then_with(|| a.1.cmp(&b.1)) }); neighbors.dedup(); for (neighbor_index, rank) in neighbors { @@ -331,18 +410,21 @@ impl GraphView { } discovered.sort_by(|a, b| { - (a.0, a.1, &a.2, &self.entries[a.3].path) - .cmp(&(b.0, b.1, &b.2, &self.entries[b.3].path)) + (a.0, a.1, &a.2) + .cmp(&(b.0, b.1, &b.2)) + .then_with(|| stable_entry_order(&self.entries[a.3], &self.entries[b.3])) }); let mut nodes: Vec = discovered .into_iter() .map(|(hops, _rank, _id, entry_index)| { let entry = &self.entries[entry_index]; NeighborhoodNode { + key: entry.key.clone(), + origin: entry.origin.clone(), id: entry.id.clone(), artifact_type: entry.artifact_type.clone(), title: entry.title.clone(), - path: entry.path.clone(), + path: public_path(entry, self.federated), hops, } }) @@ -361,6 +443,10 @@ impl GraphView { self.relationships.len() } + pub fn is_federated(&self) -> bool { + self.federated + } + /// Approximate owned heap payload, excluding hash-table control bytes. pub fn estimated_payload_bytes(&self) -> usize { let entry_bytes: usize = self @@ -373,6 +459,15 @@ impl GraphView { + entry.path.len() + entry.aliases.iter().map(String::len).sum::() + entry.tags.iter().map(String::len).sum::() + + entry + .artifact_path + .as_ref() + .map_or(0, |path| path.source.len() + path.relative_path.len()) + + entry.origin.as_ref().map_or(0, |origin| { + origin.source.len() + + origin.pin.as_ref().map_or(0, String::len) + + origin.alias.as_ref().map_or(0, String::len) + }) }) .sum(); let relationship_bytes: usize = self @@ -384,10 +479,23 @@ impl GraphView { + relationship.target.len() + relationship.resolved_path.as_ref().map_or(0, String::len) + relationship.issue.as_ref().map_or(0, String::len) + + relationship + .source_artifact + .as_ref() + .map_or(0, |path| path.source.len() + path.relative_path.len()) + + relationship + .resolved_artifact + .as_ref() + .map_or(0, |path| path.source.len() + path.relative_path.len()) }) .sum(); let map_key_bytes = self.aliases.keys().map(String::len).sum::() - + self.entry_by_path.keys().map(String::len).sum::(); + + self.entry_by_path.keys().map(String::len).sum::() + + self + .entry_by_artifact_path + .keys() + .map(|path| path.source.len() + path.relative_path.len()) + .sum::(); let vector_payload_bytes = self .outgoing_by_source .iter() @@ -407,6 +515,122 @@ impl GraphView { } } +#[cfg(test)] +mod tests { + use super::*; + use rac_engine::corpus::{ArtifactKey, CorpusLayer}; + + fn entry( + layer: CorpusLayer, + id: &str, + relative_path: &str, + physical_path: &str, + ) -> IndexEntry { + let origin = layer.origin(); + IndexEntry { + key: Some(ArtifactKey::new(&origin.source, id)), + artifact_path: Some(origin.path(relative_path)), + origin: Some(origin), + id: id.to_string(), + artifact_type: "Decision".to_string(), + title: None, + path: physical_path.to_string(), + aliases: vec![id.to_string()], + search_sections: Vec::new(), + inbound_count: 0, + tags: Vec::new(), + } + } + + fn resolved(entry: &IndexEntry) -> ResolvedArtifact { + ResolvedArtifact { + key: entry.key.clone(), + artifact_path: entry.artifact_path.clone(), + origin: entry.origin.clone(), + id: entry.id.clone(), + artifact_type: entry.artifact_type.clone(), + title: entry.title.clone(), + path: entry.path.clone(), + section: None, + snippet: None, + evidence: None, + recency: None, + tags: Vec::new(), + } + } + + #[test] + fn source_aware_endpoints_do_not_alias_physical_or_display_paths() { + let parent = entry( + CorpusLayer::inherited("acme/standards", "standards", "sha256:0123"), + "ADR-PARENT", + "decisions/shared.md", + "/checkout/vendor/decisions/shared.md", + ); + let local = entry( + CorpusLayer::local("acme/app"), + "ADR-LOCAL", + "decisions/shared.md", + "/checkout/decisions/shared.md", + ); + let relationship = Relationship { + source_artifact: parent.artifact_path.clone(), + source_path: parent.path.clone(), + relationship: "depends_on".to_string(), + target: "ADR-LOCAL".to_string(), + resolved_artifact: local.artifact_path.clone(), + resolved_path: Some(local.path.clone()), + issue: None, + }; + let parent_artifact = resolved(&parent); + let local_artifact = resolved(&local); + let view = GraphView::new(vec![parent, local], vec![relationship]); + + assert!(view.is_federated()); + assert_eq!(view.outgoing(&parent_artifact).total, 1); + assert_eq!(view.outgoing(&local_artifact).total, 0); + + let incoming = view.incoming(&local_artifact); + assert_eq!(incoming.total, 1); + assert_eq!(incoming.items[0].id, "ADR-PARENT"); + assert_eq!(incoming.items[0].path, "decisions/shared.md"); + assert_eq!( + incoming.items[0] + .origin + .as_ref() + .map(|origin| origin.source.as_str()), + Some("acme/standards") + ); + } + + #[test] + fn federated_ambiguity_paths_remain_source_distinct() { + let mut parent = entry( + CorpusLayer::inherited("acme/standards", "standards", "sha256:0123"), + "ADR-PARENT", + "decisions/shared.md", + "/checkout/vendor/decisions/shared.md", + ); + let mut local = entry( + CorpusLayer::local("acme/app"), + "ADR-LOCAL", + "decisions/shared.md", + "/checkout/decisions/shared.md", + ); + parent.aliases.push("shared-alias".to_string()); + local.aliases.push("shared-alias".to_string()); + let result = GraphView::new(vec![parent, local], Vec::new()).resolve("shared-alias"); + assert_eq!(result.outcome, rac_engine::resolve::OUTCOME_DUPLICATE); + assert_eq!( + result.duplicate_paths, + vec![ + "acme/app::decisions/shared.md".to_string(), + "acme/standards::decisions/shared.md".to_string(), + ] + ); + } +} + fn identity_projection(entry: &IndexEntry) -> IndexEntry { IndexEntry { key: entry.key.clone(), @@ -429,13 +653,17 @@ fn identity_projection(entry: &IndexEntry) -> IndexEntry { #[derive(Default)] pub struct GraphCache { generation: Option, + federated_generation: Option, view: Option, builds: u64, } impl GraphCache { pub fn view_for(&mut self, generation: u64, model: &TrackerModel) -> &GraphView { - if self.generation != Some(generation) || self.view.is_none() { + if self.generation != Some(generation) + || self.federated_generation.is_some() + || self.view.is_none() + { let started = rac_engine::timing::start(); let replacement = GraphView::from_model(model); rac_engine::timing::emit_since( @@ -449,11 +677,40 @@ impl GraphCache { ); self.view = Some(replacement); self.generation = Some(generation); + self.federated_generation = None; self.builds += 1; } self.view.as_ref().expect("graph view built") } + pub fn view_for_composed( + &mut self, + generation: &str, + corpus: &rac_engine::composition::ComposedCorpus, + ) -> &GraphView { + if self.federated_generation.as_deref() != Some(generation) + || self.generation.is_some() + || self.view.is_none() + { + let started = rac_engine::timing::start(); + let replacement = GraphView::from_composed(corpus); + rac_engine::timing::emit_since( + "graph.view_build", + started, + &[ + ("entries", replacement.entry_count() as u64), + ("relationships", replacement.relationship_count() as u64), + ("payload_bytes", replacement.estimated_payload_bytes() as u64), + ], + ); + self.view = Some(replacement); + self.generation = None; + self.federated_generation = Some(generation.to_string()); + self.builds += 1; + } + self.view.as_ref().expect("federated graph view built") + } + #[cfg(test)] pub fn builds(&self) -> u64 { self.builds diff --git a/rust/decided-mcp/src/main.rs b/rust/decided-mcp/src/main.rs index 21a9d28e..a057b8da 100644 --- a/rust/decided-mcp/src/main.rs +++ b/rust/decided-mcp/src/main.rs @@ -29,9 +29,63 @@ const TOOLS_LIST_RESULT: &str = include_str!("tools_list_result.json"); pub(crate) struct ServerState { tracker: Option, + federated_tracker: Option< + rac_engine::derived_cache::FederatedCacheTracker< + rac_engine::composition::ComposedCorpus, + >, + >, graph_cache: graph::GraphCache, } +enum RequestRead<'a> { + Legacy { + generation: Option, + model: Option<&'a rac_engine::freshness::TrackerModel>, + }, + FederatedCached( + rac_engine::derived_cache::FederatedCacheRead< + 'a, + rac_engine::composition::ComposedCorpus, + >, + ), + FederatedFresh { + generation: rac_engine::derived_cache::LogicalGeneration, + composed: Box, + }, +} + +impl RequestRead<'_> { + fn legacy(&self) -> (Option, Option<&rac_engine::freshness::TrackerModel>) { + match self { + Self::Legacy { generation, model } => (*generation, *model), + _ => (None, None), + } + } + + fn composed(&self) -> Option<&rac_engine::composition::ComposedCorpus> { + match self { + Self::FederatedCached(read) => Some(read.composed), + Self::FederatedFresh { composed, .. } => Some(composed), + Self::Legacy { .. } => None, + } + } + + fn cached_model(&self) -> Option<&rac_engine::derived_cache::ReadModel> { + match self { + Self::FederatedCached(read) => Some(read.model), + _ => None, + } + } + + fn logical_generation(&self) -> Option<&rac_engine::derived_cache::LogicalGeneration> { + match self { + Self::FederatedCached(read) => Some(read.generation), + Self::FederatedFresh { generation, .. } => Some(generation), + Self::Legacy { .. } => None, + } + } +} + /// The SDK's logging notification for an unparseable input line (§1) — /// note the field order: method, params, jsonrpc. const PARSE_ERROR_NOTIFICATION: &str = "{\"method\":\"notifications/message\",\"params\":{\"level\":\"error\",\"logger\":\"mcp.server.exception_handler\",\"data\":\"Internal Server Error\"},\"jsonrpc\":\"2.0\"}"; @@ -146,8 +200,16 @@ fn main() { } else { None }; + let federated_tracker = if rac_engine::derived_cache::cache_enabled(cache) { + Some(rac_engine::derived_cache::FederatedCacheTracker::new( + rac_engine::derived_cache::default_cache_dir(), + )) + } else { + None + }; let mut state = ServerState { tracker, + federated_tracker, graph_cache: graph::GraphCache::default(), }; // Audit recorder (ADR-084): built from the `.decided/config.yaml` audit stanza, @@ -185,8 +247,26 @@ fn main() { /// Startup diagnostic (stderr only; declared-normalized in parity, §0). fn check_corpus(root: &str) { - let entries = rac_engine::resolve::build_index(root, true); - if !entries.iter().any(|e| e.artifact_type != "unknown") { + let has_artifacts = if federation_configured(root) { + let repository_root = rac_engine::validate::repository_root(root); + let generation = rac_engine::derived_cache::capture_logical_generation( + &repository_root, + root, + true, + ) + .unwrap_or_else(|error| usage_error(&error.to_string())); + let composed = rac_engine::derived_cache::compose_logical_generation(root, &generation) + .unwrap_or_else(|error| usage_error(&error.to_string())); + let has_artifacts = composed + .effective() + .any(|item| item.spec.is_some()); + has_artifacts + } else { + rac_engine::resolve::build_index(root, true) + .iter() + .any(|entry| entry.artifact_type != "unknown") + }; + if !has_artifacts { eprintln!( "decided-mcp: no AsDecided artifacts found under '{root}'. Point --root at a \ directory containing RAC Markdown artifacts, or run 'decided init' to initialize \ @@ -195,6 +275,12 @@ a new repository. The server is running; get_summary will report the empty state } } +fn federation_configured(root: &str) -> bool { + rac_engine::validate::repository_root(root) + .join(rac_engine::federation::MANIFEST_RELATIVE_PATH) + .is_file() +} + fn serve( root: &str, state: &mut ServerState, @@ -457,15 +543,57 @@ fn dispatch( ) { return Err(format!("Unknown tool: {name}")); } - // Freshen the read-model once per call (the corpus-change check every - // tool answer rides, ADR-105); without the tracker every arm re-walks. - let (generation, model) = match state.tracker.as_mut() { - Some(tracker) => { - let (generation, model) = tracker.read_model_with_generation(false); - (Some(generation), Some(model)) + let ServerState { + tracker, + federated_tracker, + graph_cache, + } = state; + // A configured repository enters the verified generation boundary on + // every call. Cache-off skips persistence, never parent verification or + // composition. A failed re-verification returns an error before the + // previously valid model can be observed. + let request = if federation_configured(root) { + let repository_root = rac_engine::validate::repository_root(root); + match federated_tracker.as_mut() { + Some(tracker) => RequestRead::FederatedCached( + tracker + .read_composed(&repository_root, root, true) + .map_err(|error| error.to_string())?, + ), + None => { + let generation = rac_engine::derived_cache::capture_logical_generation( + &repository_root, + root, + true, + ) + .map_err(|error| error.to_string())?; + let composed = rac_engine::derived_cache::compose_logical_generation( + root, + &generation, + ) + .map_err(|error| error.to_string())?; + RequestRead::FederatedFresh { + generation, + composed: Box::new(composed), + } + } + } + } else { + match tracker.as_mut() { + Some(tracker) => { + let (generation, model) = tracker.read_model_with_generation(false); + RequestRead::Legacy { + generation: Some(generation), + model: Some(model), + } + } + None => RequestRead::Legacy { + generation: None, + model: None, + }, } - None => (None, None), }; + let (generation, model) = request.legacy(); // Audit args mirror server.py's per-tool `observed(...)` shapes exactly // (insertion order = recorded key order): non-default arguments ride the // record only when supplied. `sidecar::observe` keeps the telemetry seam @@ -483,7 +611,16 @@ fn dispatch( let audit_args = json!({ "id": a_str(&a, 0, "") }); Ok(sidecar::observe(name, || { audit::observe(recorder, principal, name, audit_args, || { - tools::get_artifact(root, model, &a_str(&a, 0, ""), effective) + if let Some(corpus) = request.composed() { + tools::get_artifact_composed( + root, + corpus, + &a_str(&a, 0, ""), + effective, + ) + } else { + tools::get_artifact(root, model, &a_str(&a, 0, ""), effective) + } }) })) } @@ -511,15 +648,28 @@ fn dispatch( let audit_args = Value::Object(m); Ok(sidecar::observe(name, || { audit::observe(recorder, principal, name, audit_args, || { - tools::search_artifacts( - root, - model, - &query, - artifact_type.as_deref(), - &tags, - live_only, - server_budget, - ) + if let Some(corpus) = request.composed() { + tools::search_artifacts_composed( + root, + request.cached_model(), + corpus, + &query, + artifact_type.as_deref(), + &tags, + live_only, + server_budget, + ) + } else { + tools::search_artifacts( + root, + model, + &query, + artifact_type.as_deref(), + &tags, + live_only, + server_budget, + ) + } }) })) } @@ -556,9 +706,15 @@ fn dispatch( let audit_args = Value::Object(m); Ok(sidecar::observe(name, || { audit::observe(recorder, principal, name, audit_args, || { - tools::retrieve_grounding( - root, model, &task, &scope, top_k, effective, live_only, - ) + if let Some(corpus) = request.composed() { + tools::retrieve_grounding_composed( + root, corpus, &task, &scope, top_k, effective, live_only, + ) + } else { + tools::retrieve_grounding( + root, model, &task, &scope, top_k, effective, live_only, + ) + } }) })) } @@ -578,7 +734,23 @@ fn dispatch( let audit_args = Value::Object(m); Ok(sidecar::observe(name, || { audit::observe(recorder, principal, name, audit_args, || { - tools::find_decisions_tool(root, model, &topic, path.as_deref(), server_budget) + if let Some(corpus) = request.composed() { + tools::find_decisions_tool_composed( + root, + corpus, + &topic, + path.as_deref(), + server_budget, + ) + } else { + tools::find_decisions_tool( + root, + model, + &topic, + path.as_deref(), + server_budget, + ) + } }) })) } @@ -592,16 +764,32 @@ fn dispatch( let depth = a_int(&a, 1, 1); let audit_args = json!({ "id": id.clone(), "depth": depth }); let fresh_graph; - let graph_view = match (generation, model) { - (Some(generation), Some(model)) => state.graph_cache.view_for(generation, model), - _ => { - fresh_graph = graph::GraphView::fresh(root); - &fresh_graph + let graph_view = if let (Some(corpus), Some(logical)) = + (request.composed(), request.logical_generation()) + { + graph_cache.view_for_composed(logical.cache_key(), corpus) + } else { + match (generation, model) { + (Some(generation), Some(model)) => graph_cache.view_for(generation, model), + _ => { + fresh_graph = graph::GraphView::fresh(root); + &fresh_graph + } } }; Ok(sidecar::observe(name, || { audit::observe(recorder, principal, name, audit_args, || { - tools::get_related(graph_view, &id, depth, server_budget) + if let Some(corpus) = request.composed() { + tools::get_related_composed( + graph_view, + corpus, + &id, + depth, + server_budget, + ) + } else { + tools::get_related(graph_view, &id, depth, server_budget) + } }) })) } @@ -611,7 +799,18 @@ fn dispatch( let audit_args = json!({}); Ok(sidecar::observe(name, || { audit::observe(recorder, principal, name, audit_args, || { - tools::get_summary(root, model, server_budget) + if let (Some(corpus), Some(generation)) = + (request.composed(), request.logical_generation()) + { + tools::get_summary_composed( + root, + generation, + corpus, + server_budget, + ) + } else { + tools::get_summary(root, model, server_budget) + } }) })) } @@ -669,6 +868,7 @@ mod tests { "/definitely-not-a-decided-corpus", None, )), + federated_tracker: None, graph_cache: graph::GraphCache::default(), }; let result = dispatch( @@ -697,6 +897,7 @@ mod tests { &root, Some(10), )), + federated_tracker: None, graph_cache: graph::GraphCache::default(), }; let arguments = json!({"id": "FIX-0DEC1GRAPH00", "depth": 2}); diff --git a/rust/decided-mcp/src/tools.rs b/rust/decided-mcp/src/tools.rs index 4b31030d..635ad8ff 100644 --- a/rust/decided-mcp/src/tools.rs +++ b/rust/decided-mcp/src/tools.rs @@ -18,6 +18,62 @@ use rac_engine::resolve::{ }; use serde_json::{json, Map, Value}; +fn federation_enabled(root: &str) -> bool { + rac_engine::validate::repository_root(root) + .join(rac_engine::federation::MANIFEST_RELATIVE_PATH) + .is_file() +} + +fn fixed_origin( + origin: Option<&rac_engine::corpus::ArtifactOrigin>, + enabled: bool, +) -> Option> { + let origin = origin.filter(|_| enabled)?; + let mut provenance = Map::new(); + 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)); + } + Some(provenance) +} + +fn attach_origin( + value: &mut Value, + origin: Option<&rac_engine::corpus::ArtifactOrigin>, + enabled: bool, +) { + let Some(provenance) = fixed_origin(origin, enabled) else { + return; + }; + if let Some(record) = value.as_object_mut() { + record.insert("provenance".to_string(), Value::Object(provenance)); + } +} + +fn composed_provenance( + corpus: &rac_engine::composition::ComposedCorpus, + key: Option<&rac_engine::corpus::ArtifactKey>, +) -> Option> { + let provenance = key.and_then(|key| corpus.provenance_for(key))?; + rac_engine::output::composed_provenance_value(&provenance) + .as_object() + .cloned() +} + +fn attach_composed_provenance( + value: &mut Value, + corpus: &rac_engine::composition::ComposedCorpus, + key: Option<&rac_engine::corpus::ArtifactKey>, +) { + let Some(provenance) = composed_provenance(corpus, key) else { + return; + }; + if let Some(record) = value.as_object_mut() { + record.insert("provenance".to_string(), Value::Object(provenance)); + } +} + /// The additive empty-corpus guidance the server layers over the summary. const EMPTY_GUIDANCE: &str = "This repository has no AsDecided artifacts yet. The user can create the \ first one with `decided quickstart`, or with `decided init` then \ @@ -50,8 +106,35 @@ fn artifact_value(m: &ResolvedArtifact) -> Map { } } -fn search_result_payload(result: &SearchResult) -> Value { - output::search_result_value(result, true) +fn search_result_payload(result: &SearchResult, include_origin: bool) -> Value { + let mut payload = output::search_result_value(result, true); + if let Some(matches) = payload + .as_object_mut() + .and_then(|object| object.get_mut("matches")) + .and_then(Value::as_array_mut) + { + for (record, artifact) in matches.iter_mut().zip(&result.matches) { + attach_origin(record, artifact.origin.as_ref(), include_origin); + } + } + payload +} + +fn composed_search_result_payload( + result: &SearchResult, + corpus: &rac_engine::composition::ComposedCorpus, +) -> Value { + let mut payload = output::search_result_value(result, true); + if let Some(matches) = payload + .as_object_mut() + .and_then(|object| object.get_mut("matches")) + .and_then(Value::as_array_mut) + { + for (record, artifact) in matches.iter_mut().zip(&result.matches) { + attach_composed_provenance(record, corpus, artifact.key.as_ref()); + } + } + payload } /// The per-call budget clamp (ADR-113): a call may only *lower* the server @@ -113,10 +196,17 @@ pub fn get_artifact( payload.insert(k, v); } let status = artifact_status(&rac_engine::parse::parse_text(&content, &artifact.path)); - let mut prov = Map::new(); + let include_origin = federation_enabled(root); + let mut prov = fixed_origin(artifact.origin.as_ref(), include_origin).unwrap_or_default(); prov.insert("status".to_string(), json!(status)); - for (k, v) in provenance::artifact_provenance(root, &artifact.path) { - prov.insert(k, v); + if artifact + .origin + .as_ref() + .is_none_or(|origin| origin.layer != rac_engine::corpus::Layer::Inherited) + { + for (k, v) in provenance::artifact_provenance(root, &artifact.path) { + prov.insert(k, v); + } } // Pinned key order: {schema_version, **artifact, content, provenance}. payload.insert("content".to_string(), json!(content)); @@ -124,6 +214,56 @@ pub fn get_artifact( serialize(&Value::Object(payload), budget) } +pub fn get_artifact_composed( + root: &str, + corpus: &rac_engine::composition::ComposedCorpus, + artifact_id: &str, + budget: i64, +) -> String { + let result = resolve_in_index(&corpus.identity_index(), artifact_id); + let Some(artifact) = result + .artifact + .as_ref() + .filter(|_| result.outcome == OUTCOME_RESOLVED) + else { + return serialize(&output::resolution_error_value(&result), budget); + }; + let Some(key) = artifact.key.as_ref() else { + return serialize(&unreadable_payload(&artifact.id, &artifact.path), budget); + }; + let Some(content) = corpus + .content(key) + .and_then(|bytes| String::from_utf8(bytes.to_vec()).ok()) + .map(|text| text.replace("\r\n", "\n").replace('\r', "\n")) + else { + return serialize(&unreadable_payload(&artifact.id, &artifact.path), budget); + }; + let mut payload = Map::new(); + payload.insert("schema_version".to_string(), json!("1")); + for (key, value) in artifact_value(artifact) { + payload.insert(key, value); + } + payload.insert("content".to_string(), json!(content)); + + let mut provenance = composed_provenance(corpus, Some(key)).unwrap_or_default(); + let status = corpus + .item(key) + .map(|item| artifact_status(&item.artifact)) + .unwrap_or_default(); + provenance.insert("status".to_string(), json!(status)); + if let Some(item) = corpus + .item(key) + .filter(|item| item.origin.layer == rac_engine::corpus::Layer::Local) + { + let physical = item.locator.path.to_string_lossy(); + for (name, value) in provenance::artifact_provenance(root, &physical) { + provenance.insert(name, value); + } + } + payload.insert("provenance".to_string(), Value::Object(provenance)); + serialize(&Value::Object(payload), budget) +} + pub fn search_artifacts( root: &str, model: Option<&TrackerModel>, @@ -153,7 +293,52 @@ pub fn search_artifacts( } }; rac_engine::commands::annotate_search_recency(&mut result.matches, root); - serialize(&search_result_payload(&result), budget) + serialize( + &search_result_payload(&result, federation_enabled(root)), + budget, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn search_artifacts_composed( + root: &str, + cached: Option<&rac_engine::derived_cache::ReadModel>, + corpus: &rac_engine::composition::ComposedCorpus, + query: &str, + artifact_type: Option<&str>, + tags: &[String], + live_only: bool, + budget: i64, +) -> String { + let mut result = match cached { + Some(rac_engine::derived_cache::ReadModel::View(reader)) => { + rac_engine::read_model::store_search( + reader, + query, + artifact_type, + tags, + live_only, + ) + } + Some(rac_engine::derived_cache::ReadModel::Fresh(derived)) => { + search_index_filtered( + &derived.index_entries, + query, + artifact_type, + tags, + live_only, + ) + } + None => search_index_filtered( + &corpus.effective_index(), + query, + artifact_type, + tags, + live_only, + ), + }; + rac_engine::commands::annotate_composed_search_recency(&mut result.matches, root, corpus); + serialize(&composed_search_result_payload(&result, corpus), budget) } pub fn find_decisions_tool( @@ -165,32 +350,41 @@ pub fn find_decisions_tool( ) -> String { // Python truthiness: a non-empty `path` selects path mode. if let Some(p) = path.filter(|p| !p.is_empty()) { + let include_origin = federation_enabled(root); // Path mode builds through the same read-model as every other tool // (ADR-103), served from precomputed scope rows. let payload = match model { Some(TrackerModel::View(reader)) => { let rows = reader.scope_rows().unwrap_or_default(); - rac_engine::retrieve::scope_lookup_value( + rac_engine::retrieve::scope_lookup_value_with_origin( &rac_engine::retrieve::decisions_for_path_with_rows(&rows, root, p), + include_origin, + ) + } + Some(TrackerModel::Snapshot(derived)) => { + rac_engine::retrieve::scope_lookup_value_with_origin( + &rac_engine::retrieve::decisions_for_path_with_rows( + &derived.scope_rows, + root, + p, + ), + include_origin, ) } - Some(TrackerModel::Snapshot(derived)) => rac_engine::retrieve::scope_lookup_value( - &rac_engine::retrieve::decisions_for_path_with_rows( - &derived.scope_rows, - root, - p, - ), - ), Some(TrackerModel::Delta(generation)) => { - rac_engine::retrieve::scope_lookup_value( + rac_engine::retrieve::scope_lookup_value_with_origin( &rac_engine::retrieve::decisions_for_path_with_rows( &generation.scope.rows(), root, p, ), + include_origin, ) } - None => rac_engine::retrieve::find_decisions_path_payload(root, p), + None => rac_engine::retrieve::scope_lookup_value_with_origin( + &rac_engine::retrieve::decisions_for_path(root, p, true), + include_origin, + ), }; return serialize(&payload, budget); } @@ -210,7 +404,45 @@ pub fn find_decisions_tool( ), None => find_decisions(root, topic, true), }; - let mut payload = search_result_payload(&result); + let mut payload = search_result_payload(&result, federation_enabled(root)); + payload + .as_object_mut() + .expect("object") + .insert("filter".to_string(), json!("live-decisions")); + serialize(&payload, budget) +} + +pub fn find_decisions_tool_composed( + root: &str, + corpus: &rac_engine::composition::ComposedCorpus, + topic: &str, + path: Option<&str>, + budget: i64, +) -> String { + if let Some(path) = path.filter(|path| !path.is_empty()) { + let items: Vec<_> = corpus.effective().cloned().collect(); + let rows = rac_engine::retrieve::scope_rows_from_items(&items); + let result = rac_engine::retrieve::decisions_for_path_with_rows(&rows, root, path); + return serialize( + &rac_engine::retrieve::scope_lookup_value_with_composed(&result, corpus), + budget, + ); + } + + let entries = corpus.effective_index(); + let live: std::collections::HashSet<_> = corpus + .effective() + .filter(|item| { + item.spec.map(|spec| spec.name.as_str()) == Some("decision") + && rac_engine::resolve::is_live_decision(&item.artifact) + }) + .map(|item| item.key.clone()) + .collect(); + let mut result = search_index_filtered(&entries, topic, Some("decision"), &[], false); + result + .matches + .retain(|artifact| artifact.key.as_ref().is_some_and(|key| live.contains(key))); + let mut payload = composed_search_result_payload(&result, corpus); payload .as_object_mut() .expect("object") @@ -223,6 +455,26 @@ pub fn get_related( artifact_id: &str, depth: i64, budget: i64, +) -> String { + get_related_inner(graph_view, None, artifact_id, depth, budget) +} + +pub fn get_related_composed( + graph_view: &graph::GraphView, + corpus: &rac_engine::composition::ComposedCorpus, + artifact_id: &str, + depth: i64, + budget: i64, +) -> String { + get_related_inner(graph_view, Some(corpus), artifact_id, depth, budget) +} + +fn get_related_inner( + graph_view: &graph::GraphView, + corpus: Option<&rac_engine::composition::ComposedCorpus>, + artifact_id: &str, + depth: i64, + budget: i64, ) -> String { let graph_started = rac_engine::timing::start(); let result = graph_view.resolve(artifact_id); @@ -233,8 +485,9 @@ pub fn get_related( else { return serialize(&output::resolution_error_value(&result), budget); }; - let outgoing = graph_view.outgoing(&artifact.path); - let incoming_result = graph_view.incoming(&artifact.path); + let include_origin = graph_view.is_federated(); + let outgoing = graph_view.outgoing(artifact); + let incoming_result = graph_view.incoming(artifact); let incoming: Vec = incoming_result .items .iter() @@ -250,7 +503,13 @@ pub fn get_related( ev.insert("relationship".to_string(), json!(r.section)); ev.insert("target".to_string(), json!(r.target)); m.insert("evidence".to_string(), Value::Object(ev)); - Value::Object(m) + let mut value = Value::Object(m); + if let Some(corpus) = corpus { + attach_composed_provenance(&mut value, corpus, r.key.as_ref()); + } else { + attach_origin(&mut value, r.origin.as_ref(), include_origin); + } + value }) .collect(); let mut payload = Map::new(); @@ -258,11 +517,17 @@ pub fn get_related( for (k, v) in artifact_value(artifact) { payload.insert(k, v); } + if let Some(provenance) = corpus + .and_then(|corpus| composed_provenance(corpus, artifact.key.as_ref())) + .or_else(|| fixed_origin(artifact.origin.as_ref(), include_origin)) + { + payload.insert("provenance".to_string(), Value::Object(provenance)); + } payload.insert("outgoing".to_string(), outgoing.to_value()); payload.insert("incoming".to_string(), Value::Array(incoming)); let mut neighborhood_truncated = false; if depth > 1 { - let hood = graph_view.neighborhood(&artifact.path, depth); + let hood = graph_view.neighborhood(artifact, depth); let nodes: Vec = hood .nodes .iter() @@ -274,7 +539,13 @@ pub fn get_related( m.insert("title".to_string(), opt_str(&n.title)); m.insert("path".to_string(), json!(n.path)); m.insert("hops".to_string(), json!(n.hops)); - Value::Object(m) + let mut value = Value::Object(m); + if let Some(corpus) = corpus { + attach_composed_provenance(&mut value, corpus, n.key.as_ref()); + } else { + attach_origin(&mut value, n.origin.as_ref(), include_origin); + } + value }) .collect(); payload.insert("neighborhood".to_string(), Value::Array(nodes)); @@ -395,6 +666,42 @@ pub fn get_summary(root: &str, model: Option<&TrackerModel>, budget: i64) -> Str serialize(&Value::Object(payload), budget) } +pub fn get_summary_composed( + root: &str, + generation: &rac_engine::derived_cache::LogicalGeneration, + corpus: &rac_engine::composition::ComposedCorpus, + budget: i64, +) -> String { + let identity = generation + .identity() + .expect("federated request has generation identity"); + let parent = generation + .verified_parent() + .expect("federated request has verified parent"); + let items: Vec<_> = corpus.effective().cloned().collect(); + let overrides = rac_engine::validate::overrides_from_config_bytes(&parent.child_config_bytes); + let summary = rac_engine::portfolio::portfolio_from_corpus_with_analysis( + &identity.child_corpus_path, + &items, + identity.recursive, + &overrides, + corpus.relationship_summary(), + corpus.validate_relationships(root, identity.recursive).ok(), + ); + let mut payload = rac_engine::output::portfolio_summary_value(&summary); + if payload + .get("empty") + .and_then(Value::as_bool) + .unwrap_or(false) + { + payload + .as_object_mut() + .expect("portfolio payload is an object") + .insert("guidance".to_string(), json!(EMPTY_GUIDANCE)); + } + serialize(&payload, budget) +} + pub fn retrieve_grounding( root: &str, model: Option<&TrackerModel>, @@ -434,6 +741,28 @@ pub fn retrieve_grounding( serialize(&payload, effective) } +pub fn retrieve_grounding_composed( + root: &str, + corpus: &rac_engine::composition::ComposedCorpus, + task: &str, + scope: &str, + top_k: i64, + effective: i64, + live_only: bool, +) -> String { + let scope = if scope.is_empty() { None } else { Some(scope) }; + let payload = rac_engine::retrieve::retrieve_grounding_from_composed( + root, + task, + scope, + top_k, + effective, + live_only, + corpus, + ); + serialize(&payload, effective) +} + #[cfg(test)] mod tests { use super::*; @@ -446,6 +775,31 @@ mod tests { ) } + #[test] + fn fixed_origin_is_additive_only_in_a_federated_context() { + let local = rac_engine::corpus::CorpusLayer::local("acme/app").origin(); + assert!(fixed_origin(Some(&local), false).is_none()); + assert_eq!( + Value::Object(fixed_origin(Some(&local), true).unwrap()), + json!({"source": "acme/app", "layer": "local"}) + ); + + let inherited = rac_engine::corpus::CorpusLayer::inherited( + "acme/standards", + "standards", + "sha256:0123", + ) + .origin(); + assert_eq!( + Value::Object(fixed_origin(Some(&inherited), true).unwrap()), + json!({ + "source": "acme/standards", + "layer": "inherited", + "pin": "sha256:0123" + }) + ); + } + #[test] fn delta_point_and_search_routes_use_incremental_generations() { let root = diff --git a/rust/decided-mcp/tests/federation.rs b/rust/decided-mcp/tests/federation.rs new file mode 100644 index 00000000..0c2e20e4 --- /dev/null +++ b/rust/decided-mcp/tests/federation.rs @@ -0,0 +1,341 @@ +use serde_json::{json, Value}; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static COUNTER: AtomicUsize = AtomicUsize::new(0); + +fn scratch(tag: &str) -> PathBuf { + let sequence = COUNTER.fetch_add(1, Ordering::SeqCst); + let root = std::env::temp_dir().join(format!( + "decided-mcp-federation-{tag}-{}-{sequence}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("create federation scratch directory"); + root +} + +fn copy_tree(source: &Path, target: &Path) { + fs::create_dir_all(target).expect("create copied directory"); + for entry in fs::read_dir(source).expect("read fixture directory") { + let entry = entry.expect("read fixture entry"); + let destination = target.join(entry.file_name()); + if entry.file_type().expect("fixture file type").is_dir() { + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).expect("copy fixture file"); + } + } +} + +fn eval_fixture(tag: &str) -> PathBuf { + let target = scratch(tag); + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../fixtures/eval/federation/child"); + copy_tree(&source, &target); + target +} + +fn request(id: usize, name: &str, arguments: Value) -> String { + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": {"name": name, "arguments": arguments} + }) + .to_string() +} + +fn run(root: &Path, extra_args: &[&str], requests: &[String]) -> Vec { + let mut child = Command::new(env!("CARGO_BIN_EXE_decided-mcp")) + .arg("--root") + .arg(root) + .args(extra_args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn decided-mcp"); + { + let stdin = child.stdin.as_mut().expect("server stdin"); + for request in requests { + writeln!(stdin, "{request}").expect("write MCP request"); + } + } + drop(child.stdin.take()); + let output = child.wait_with_output().expect("wait for decided-mcp"); + assert!( + output.status.success(), + "server failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("UTF-8 MCP output") + .lines() + .map(|line| serde_json::from_str(line).expect("JSON-RPC response")) + .collect() +} + +fn tool_text(frame: &Value) -> &str { + frame + .pointer("/result/content/0/text") + .and_then(Value::as_str) + .expect("tool text") +} + +fn tool_value(frame: &Value) -> Value { + serde_json::from_str(tool_text(frame)).expect("tool payload JSON") +} + +#[test] +fn all_six_tools_share_one_verified_composition_with_or_without_cache() { + let repository = eval_fixture("six-tools"); + let corpus = repository.join("decisions"); + let requests = vec![ + request( + 1, + "get_artifact", + json!({"id": "standards::FEDEVAL-000000000001"}), + ), + request( + 2, + "search_artifacts", + json!({"query": "quantum ledger compaction"}), + ), + request( + 3, + "retrieve_grounding", + json!({"task": "quantum ledger compaction", "top_k": 3}), + ), + request( + 4, + "find_decisions", + json!({"topic": "quantum ledger compaction"}), + ), + request( + 5, + "get_related", + json!({"id": "standards::FEDEVAL-000000000002", "depth": 2}), + ), + request(6, "get_summary", json!({})), + ]; + + let uncached = run(&corpus, &["--no-cache"], &requests); + let cached = run(&corpus, &[], &requests); + assert_eq!( + uncached.iter().map(tool_text).collect::>(), + cached.iter().map(tool_text).collect::>() + ); + + let artifact = tool_value(&cached[0]); + assert_eq!(artifact["provenance"]["source"], json!("eval/standards")); + assert_eq!(artifact["provenance"]["layer"], json!("inherited")); + assert!(artifact["provenance"]["pin"] + .as_str() + .is_some_and(|pin| pin.starts_with("sha256:"))); + + let search = tool_value(&cached[1]); + assert_eq!(search["matches"][0]["id"], json!("FEDEVAL-000000000001")); + assert_eq!( + search["matches"][0]["provenance"]["source"], + json!("eval/standards") + ); + assert!(search["matches"].as_array().unwrap().iter().any(|record| { + record["provenance"]["source"] == json!("eval/child") + && record.get("recency").is_some() + })); + assert!(search["matches"].as_array().unwrap().iter().all(|record| { + record["provenance"]["source"] != json!("eval/standards") + || record.get("recency").is_none() + })); + let grounding = tool_value(&cached[2]); + assert_eq!( + grounding["items"][0]["provenance"]["layer"], + json!("inherited") + ); + assert_eq!(tool_value(&cached[5])["artifacts"]["total"], json!(41)); + fs::remove_dir_all(repository).expect("remove six-tool fixture"); +} + +fn decision(id: &str, title: &str) -> String { + format!( + "---\nschema_version: 1\nid: {id}\ntype: decision\n---\n# {title}\n\n## Status\n\nAccepted\n\n## Context\n\nA reviewed context.\n\n## Decision\n\nKeep the reviewed rule.\n\n## Consequences\n\nThe rule is deterministic.\n" + ) +} + +fn override_fixture() -> PathBuf { + let child = scratch("override"); + let parent = child.join("vendor/standards"); + fs::create_dir_all(parent.join(".decided")).expect("parent config directory"); + fs::create_dir_all(parent.join("decisions")).expect("parent corpus directory"); + fs::create_dir_all(child.join(".decided")).expect("child config directory"); + fs::create_dir_all(child.join("decisions")).expect("child corpus directory"); + fs::write( + parent.join(".decided/config.yaml"), + "repository_key: STD\ncorpus:\n source: acme/standards\n", + ) + .expect("parent config"); + fs::write( + parent.join("decisions/parent.md"), + decision("STD-01JY4M8X2QZ7", "Parent Policy"), + ) + .expect("parent decision"); + fs::write( + child.join(".decided/config.yaml"), + "repository_key: APP\ncorpus:\n source: acme/app\n", + ) + .expect("child config"); + fs::write( + child.join("decisions/replacement.md"), + decision("APP-01JY4M8X2QZ8", "Local Replacement"), + ) + .expect("replacement decision"); + fs::write( + child.join("decisions/rationale.md"), + decision("APP-01JY4M8X2QZ9", "Override Rationale"), + ) + .expect("rationale decision"); + let digest = rac_engine::federation::calculate_parent_digest(&parent, "decisions") + .expect("calculate parent digest") + .digest; + fs::write( + child.join(".decided/corpus.md"), + format!( + "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: standards\nsource: acme/standards\nroot: vendor/standards\ncorpus: decisions\ndigest: {digest}\n```\n\n## overrides\n\n```yaml\nversion: 1\nitems:\n - parent: standards::STD-01JY4M8X2QZ7\n with: APP-01JY4M8X2QZ8\n rationale: APP-01JY4M8X2QZ9\n```\n" + ), + ) + .expect("child manifest"); + child +} + +#[test] +fn qualified_history_and_canonical_redirect_keep_complete_override_provenance() { + let repository = override_fixture(); + let corpus = repository.join("decisions"); + let frames = run( + &corpus, + &["--no-cache"], + &[ + request( + 1, + "get_artifact", + json!({"id": "standards::STD-01JY4M8X2QZ7"}), + ), + request(2, "get_artifact", json!({"id": "STD-01JY4M8X2QZ7"})), + ], + ); + let parent = tool_value(&frames[0]); + let replacement = tool_value(&frames[1]); + assert_eq!(parent["id"], json!("STD-01JY4M8X2QZ7")); + assert_eq!(parent["provenance"]["overrides"][0]["state"], json!("overridden")); + assert_eq!(replacement["id"], json!("APP-01JY4M8X2QZ8")); + let mapping = &replacement["provenance"]["overrides"][0]; + assert_eq!(mapping["state"], json!("replacement")); + assert_eq!(mapping["parent"]["source"], json!("acme/standards")); + assert_eq!(mapping["replacement"]["source"], json!("acme/app")); + assert_eq!(mapping["rationale"]["id"], json!("APP-01JY4M8X2QZ9")); + fs::remove_dir_all(repository).expect("remove override fixture"); +} + +#[test] +fn tight_budgets_keep_parent_and_replacement_override_provenance_atomic() { + let repository = override_fixture(); + let corpus = repository.join("decisions"); + let mut requests = Vec::new(); + let mut expectations = Vec::new(); + for budget in [384, 512, 768] { + requests.push(request( + requests.len() + 1, + "get_artifact", + json!({"id": "standards::STD-01JY4M8X2QZ7", "budget": budget}), + )); + expectations.push((budget, "overridden")); + requests.push(request( + requests.len() + 1, + "get_artifact", + json!({"id": "STD-01JY4M8X2QZ7", "budget": budget}), + )); + expectations.push((budget, "replacement")); + } + + let frames = run(&corpus, &["--no-cache"], &requests); + for (frame, (budget, state)) in frames.iter().zip(expectations) { + let text = tool_text(frame); + assert!( + text.chars().count() <= budget, + "{} characters exceeded budget {budget}", + text.chars().count() + ); + let value = tool_value(frame); + if value["error"] == json!(rac_engine::budget::BUDGET_ERROR) { + continue; + } + let mapping = &value["provenance"]["overrides"][0]; + assert_eq!(mapping["state"], json!(state)); + assert_eq!(mapping["parent"]["source"], json!("acme/standards")); + assert_eq!(mapping["parent"]["id"], json!("STD-01JY4M8X2QZ7")); + assert_eq!(mapping["replacement"]["source"], json!("acme/app")); + assert_eq!(mapping["replacement"]["id"], json!("APP-01JY4M8X2QZ8")); + assert_eq!(mapping["rationale"]["source"], json!("acme/app")); + assert_eq!(mapping["rationale"]["id"], json!("APP-01JY4M8X2QZ9")); + } + fs::remove_dir_all(repository).expect("remove tight-budget override fixture"); +} + +#[test] +fn stale_parent_blocks_the_next_request_instead_of_serving_the_old_generation() { + let repository = eval_fixture("stale-parent"); + let corpus = repository.join("decisions"); + let mut child = Command::new(env!("CARGO_BIN_EXE_decided-mcp")) + .arg("--root") + .arg(&corpus) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn long-lived decided-mcp"); + let mut stdin = child.stdin.take().expect("server stdin"); + let mut stdout = BufReader::new(child.stdout.take().expect("server stdout")); + let query = request( + 1, + "search_artifacts", + json!({"query": "quantum ledger compaction"}), + ); + writeln!(stdin, "{query}").expect("write first request"); + stdin.flush().expect("flush first request"); + let mut first_line = String::new(); + stdout.read_line(&mut first_line).expect("read first response"); + let first: Value = serde_json::from_str(first_line.trim()).expect("first response JSON"); + assert_eq!(first["result"]["isError"], json!(false)); + + let parent_file = repository + .join("vendor/standards/decisions/quantum-ledger-compaction-anchor.md"); + fs::write(&parent_file, "changed after verification\n").expect("mutate parent bytes"); + let second = request( + 2, + "search_artifacts", + json!({"query": "quantum ledger compaction"}), + ); + writeln!(stdin, "{second}").expect("write second request"); + stdin.flush().expect("flush second request"); + let mut second_line = String::new(); + stdout + .read_line(&mut second_line) + .expect("read second response"); + let second: Value = serde_json::from_str(second_line.trim()).expect("second response JSON"); + assert_eq!(second["result"]["isError"], json!(true)); + assert!(tool_text(&second).contains("parent-corpus-digest-mismatch")); + assert!(!tool_text(&second).contains("FEDEVAL-000000000001")); + + drop(stdin); + let output = child.wait_with_output().expect("wait for long-lived server"); + assert!( + output.status.success(), + "server failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + fs::remove_dir_all(repository).expect("remove stale-parent fixture"); +} diff --git a/rust/fixtures/eval/README.md b/rust/fixtures/eval/README.md index 519cf7ed..f27559f8 100644 --- a/rust/fixtures/eval/README.md +++ b/rust/fixtures/eval/README.md @@ -1,8 +1,8 @@ # Grounding retrieval benchmark fixture (v0.23.0, WS1) This directory is the versioned fixture the `decided eval` grounding benchmark -scores. It is a dev/CI surface, not a RAC artifact corpus — nothing here is part -of the product knowledge under `rac/`. +scores. It is a dev/CI surface, not an AsDecided artifact corpus — nothing here +is part of the repository's product-knowledge corpus. ## Layout @@ -27,14 +27,26 @@ of the product knowledge under `rac/`. category's `p_at_1` / `r_at_5`. Per-tool figures are diagnostic. - `baseline.json` — the committed `metrics` baseline, written by `decided eval --update-baseline` (human-only; CI never rebaselines). +- `federation/` — the ADR-139 DecisionGrounding track. Its child inherits a + 40-artifact standards parent containing a precise inherited match, six + lexical near-matches, and a 32-inbound-edge hard negative. The hard negative + has graph rank 1 but remains outside the top-five window because the v0.28 + lexical floor clamps its graph contribution. ## Running ```sh -rac eval # human-readable scorecard -rac eval --json # full scorecard JSON -rac eval --check # CI gate: exit 0 pass / 1 regression / 2 usage error -rac eval --update-baseline # human-only re-baseline +decided eval # human-readable scorecard +decided eval --json # full scorecard JSON +decided eval --check # CI gate: exit 0 pass / 1 regression / 2 usage error +decided eval --update-baseline # human-only re-baseline + +# ADR-139 large-parent/hard-negative track +decided eval --check \ + --root rust/fixtures/eval/federation/child/decisions \ + --queries rust/fixtures/eval/federation/queries.json \ + --baseline rust/fixtures/eval/federation/baseline.json \ + --config rust/fixtures/eval/federation/eval-config.json ``` ## Calibration diff --git a/rust/fixtures/eval/federation/baseline.json b/rust/fixtures/eval/federation/baseline.json new file mode 100644 index 00000000..70b07341 --- /dev/null +++ b/rust/fixtures/eval/federation/baseline.json @@ -0,0 +1,23 @@ +{ + "overall": { + "p_at_1": 1.0, + "p_at_3": 0.333333, + "p_at_5": 0.2, + "r_at_1": 1.0, + "r_at_3": 1.0, + "r_at_5": 1.0, + "negative_violations": 0 + }, + "by_category": { + "federated_large_parent": { + "p_at_1": 1.0, + "r_at_5": 1.0 + } + }, + "by_tool": { + "search_artifacts": { + "p_at_1": 1.0, + "r_at_5": 1.0 + } + } +} diff --git a/rust/fixtures/eval/federation/child/.decided/config.yaml b/rust/fixtures/eval/federation/child/.decided/config.yaml new file mode 100644 index 00000000..55289303 --- /dev/null +++ b/rust/fixtures/eval/federation/child/.decided/config.yaml @@ -0,0 +1,4 @@ +repository_key: CHILD +corpus: + source: eval/child + diff --git a/rust/fixtures/eval/federation/child/.decided/corpus.md b/rust/fixtures/eval/federation/child/.decided/corpus.md new file mode 100644 index 00000000..d3994654 --- /dev/null +++ b/rust/fixtures/eval/federation/child/.decided/corpus.md @@ -0,0 +1,19 @@ +# DecisionGrounding federation fixture + +## inherits + +```yaml +version: 1 +alias: standards +source: eval/standards +root: vendor/standards +corpus: decisions +digest: sha256:4657f93e3c7480636cc3d52907c45bfb3b1edf573e8975c2cda46a271672b624 +``` + +## overrides + +```yaml +version: 1 +items: [] +``` diff --git a/rust/fixtures/eval/federation/child/decisions/local-quantum-notes.md b/rust/fixtures/eval/federation/child/decisions/local-quantum-notes.md new file mode 100644 index 00000000..c8bc8089 --- /dev/null +++ b/rust/fixtures/eval/federation/child/decisions/local-quantum-notes.md @@ -0,0 +1,26 @@ +--- +schema_version: 1 +id: FEDEVAL-000000000003 +type: decision +--- +# Local Quantum Ledger Notes + +## Status + +Accepted + +## Context + +The child application keeps operational notes for its quantum ledger client. + +## Decision + +Use the organisation standard whenever ledger compaction is configured. + +## Consequences + +The local repository does not redefine the parent compaction policy. + +## Category + +Technical diff --git a/rust/fixtures/eval/federation/child/vendor/standards/.decided/config.yaml b/rust/fixtures/eval/federation/child/vendor/standards/.decided/config.yaml new file mode 100644 index 00000000..0a35072c --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/.decided/config.yaml @@ -0,0 +1,4 @@ +repository_key: STANDARDS +corpus: + source: eval/standards + diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-01.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-01.md new file mode 100644 index 00000000..2e27dcee --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-01.md @@ -0,0 +1,27 @@ +--- +schema_version: 1 +id: FEDEVAL-000000000101 +type: decision +--- +# Quantum Ledger Compaction Planning + +## Status + +Accepted + +## Context + +This standard covers a neighbouring operational concern and uses anchor +vocabulary, but does not select the signed checkpoint. + +## Decision + +Teams document their quantum ledger compaction planning procedure separately. + +## Consequences + +The document is a deliberate lexical near-match in the grounding benchmark. + +## Category + +Technical diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-02.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-02.md new file mode 100644 index 00000000..92146e2c --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-02.md @@ -0,0 +1,27 @@ +--- +schema_version: 1 +id: FEDEVAL-000000000102 +type: decision +--- +# Quantum Ledger Compaction Operations + +## Status + +Accepted + +## Context + +This standard covers a neighbouring operational concern and uses anchor +vocabulary, but does not select the signed checkpoint. + +## Decision + +Teams document their quantum ledger compaction operations procedure separately. + +## Consequences + +The document is a deliberate lexical near-match in the grounding benchmark. + +## Category + +Technical diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-03.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-03.md new file mode 100644 index 00000000..255d22ad --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-03.md @@ -0,0 +1,27 @@ +--- +schema_version: 1 +id: FEDEVAL-000000000103 +type: decision +--- +# Quantum Ledger Compaction Observability + +## Status + +Accepted + +## Context + +This standard covers a neighbouring operational concern and uses anchor +vocabulary, but does not select the signed checkpoint. + +## Decision + +Teams document their quantum ledger compaction observability procedure separately. + +## Consequences + +The document is a deliberate lexical near-match in the grounding benchmark. + +## Category + +Technical diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-04.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-04.md new file mode 100644 index 00000000..1f168940 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-04.md @@ -0,0 +1,27 @@ +--- +schema_version: 1 +id: FEDEVAL-000000000104 +type: decision +--- +# Quantum Ledger Compaction Capacity + +## Status + +Accepted + +## Context + +This standard covers a neighbouring operational concern and uses anchor +vocabulary, but does not select the signed checkpoint. + +## Decision + +Teams document their quantum ledger compaction capacity procedure separately. + +## Consequences + +The document is a deliberate lexical near-match in the grounding benchmark. + +## Category + +Technical diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-05.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-05.md new file mode 100644 index 00000000..a62421b0 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-05.md @@ -0,0 +1,27 @@ +--- +schema_version: 1 +id: FEDEVAL-000000000105 +type: decision +--- +# Quantum Ledger Compaction Scheduling + +## Status + +Accepted + +## Context + +This standard covers a neighbouring operational concern and uses anchor +vocabulary, but does not select the signed checkpoint. + +## Decision + +Teams document their quantum ledger compaction scheduling procedure separately. + +## Consequences + +The document is a deliberate lexical near-match in the grounding benchmark. + +## Category + +Technical diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-06.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-06.md new file mode 100644 index 00000000..0bc4b847 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/compaction-decoy-06.md @@ -0,0 +1,27 @@ +--- +schema_version: 1 +id: FEDEVAL-000000000106 +type: decision +--- +# Quantum Ledger Compaction Recovery + +## Status + +Accepted + +## Context + +This standard covers a neighbouring operational concern and uses anchor +vocabulary, but does not select the signed checkpoint. + +## Decision + +Teams document their quantum ledger compaction recovery procedure separately. + +## Consequences + +The document is a deliberate lexical near-match in the grounding benchmark. + +## Category + +Technical diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/ledger-reference-hub.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/ledger-reference-hub.md new file mode 100644 index 00000000..7f9dc654 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/ledger-reference-hub.md @@ -0,0 +1,27 @@ +--- +schema_version: 1 +id: FEDEVAL-000000000002 +type: decision +--- +# Ledger Reference Hub + +## Status + +Accepted + +## Context + +Many general standards link to this ledger reference index. Its glossary also +mentions quantum compaction anchor terminology without setting that policy. + +## Decision + +Keep a stable ledger reference for broad portfolio navigation. + +## Consequences + +Relationship popularity alone does not make this the compaction policy. + +## Category + +Technical diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/quantum-ledger-compaction-anchor.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/quantum-ledger-compaction-anchor.md new file mode 100644 index 00000000..a71cb51a --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/quantum-ledger-compaction-anchor.md @@ -0,0 +1,26 @@ +--- +schema_version: 1 +id: FEDEVAL-000000000001 +type: decision +--- +# Quantum Ledger Compaction Anchor + +## Status + +Accepted + +## Context + +Every child service needs one precise standard for quantum ledger compaction. + +## Decision + +Quantum ledger compaction MUST use the signed anchor checkpoint before pruning. + +## Consequences + +The exact inherited standard remains the strongest lexical grounding match. + +## Category + +Technical diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-001.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-001.md new file mode 100644 index 00000000..9e6d8b33 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-001.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001001 +type: decision +--- +# Service Reliability Standard 001 + +## Status + +Accepted + +## Context + +Portfolio service 001 needs a stable navigation reference for operational guidance. + +## Decision + +Service 001 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-002.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-002.md new file mode 100644 index 00000000..15695daf --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-002.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001002 +type: decision +--- +# Service Reliability Standard 002 + +## Status + +Accepted + +## Context + +Portfolio service 002 needs a stable navigation reference for operational guidance. + +## Decision + +Service 002 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-003.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-003.md new file mode 100644 index 00000000..b3d8d917 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-003.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001003 +type: decision +--- +# Service Reliability Standard 003 + +## Status + +Accepted + +## Context + +Portfolio service 003 needs a stable navigation reference for operational guidance. + +## Decision + +Service 003 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-004.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-004.md new file mode 100644 index 00000000..83b60ac1 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-004.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001004 +type: decision +--- +# Service Reliability Standard 004 + +## Status + +Accepted + +## Context + +Portfolio service 004 needs a stable navigation reference for operational guidance. + +## Decision + +Service 004 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-005.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-005.md new file mode 100644 index 00000000..88934aaa --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-005.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001005 +type: decision +--- +# Service Reliability Standard 005 + +## Status + +Accepted + +## Context + +Portfolio service 005 needs a stable navigation reference for operational guidance. + +## Decision + +Service 005 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-006.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-006.md new file mode 100644 index 00000000..59c8b356 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-006.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001006 +type: decision +--- +# Service Reliability Standard 006 + +## Status + +Accepted + +## Context + +Portfolio service 006 needs a stable navigation reference for operational guidance. + +## Decision + +Service 006 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-007.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-007.md new file mode 100644 index 00000000..14405139 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-007.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001007 +type: decision +--- +# Service Reliability Standard 007 + +## Status + +Accepted + +## Context + +Portfolio service 007 needs a stable navigation reference for operational guidance. + +## Decision + +Service 007 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-008.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-008.md new file mode 100644 index 00000000..d14b2109 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-008.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001008 +type: decision +--- +# Service Reliability Standard 008 + +## Status + +Accepted + +## Context + +Portfolio service 008 needs a stable navigation reference for operational guidance. + +## Decision + +Service 008 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-009.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-009.md new file mode 100644 index 00000000..a0aba56b --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-009.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001009 +type: decision +--- +# Service Reliability Standard 009 + +## Status + +Accepted + +## Context + +Portfolio service 009 needs a stable navigation reference for operational guidance. + +## Decision + +Service 009 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-010.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-010.md new file mode 100644 index 00000000..e05b6069 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-010.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001010 +type: decision +--- +# Service Reliability Standard 010 + +## Status + +Accepted + +## Context + +Portfolio service 010 needs a stable navigation reference for operational guidance. + +## Decision + +Service 010 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-011.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-011.md new file mode 100644 index 00000000..1d0db351 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-011.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001011 +type: decision +--- +# Service Reliability Standard 011 + +## Status + +Accepted + +## Context + +Portfolio service 011 needs a stable navigation reference for operational guidance. + +## Decision + +Service 011 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-012.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-012.md new file mode 100644 index 00000000..4b32cbd4 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-012.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001012 +type: decision +--- +# Service Reliability Standard 012 + +## Status + +Accepted + +## Context + +Portfolio service 012 needs a stable navigation reference for operational guidance. + +## Decision + +Service 012 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-013.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-013.md new file mode 100644 index 00000000..65114482 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-013.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001013 +type: decision +--- +# Service Reliability Standard 013 + +## Status + +Accepted + +## Context + +Portfolio service 013 needs a stable navigation reference for operational guidance. + +## Decision + +Service 013 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-014.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-014.md new file mode 100644 index 00000000..65f1c2fd --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-014.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001014 +type: decision +--- +# Service Reliability Standard 014 + +## Status + +Accepted + +## Context + +Portfolio service 014 needs a stable navigation reference for operational guidance. + +## Decision + +Service 014 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-015.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-015.md new file mode 100644 index 00000000..5066c7cf --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-015.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001015 +type: decision +--- +# Service Reliability Standard 015 + +## Status + +Accepted + +## Context + +Portfolio service 015 needs a stable navigation reference for operational guidance. + +## Decision + +Service 015 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-016.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-016.md new file mode 100644 index 00000000..4e46807e --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-016.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001016 +type: decision +--- +# Service Reliability Standard 016 + +## Status + +Accepted + +## Context + +Portfolio service 016 needs a stable navigation reference for operational guidance. + +## Decision + +Service 016 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-017.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-017.md new file mode 100644 index 00000000..dd1bfba5 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-017.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001017 +type: decision +--- +# Service Reliability Standard 017 + +## Status + +Accepted + +## Context + +Portfolio service 017 needs a stable navigation reference for operational guidance. + +## Decision + +Service 017 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-018.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-018.md new file mode 100644 index 00000000..e3f139bb --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-018.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001018 +type: decision +--- +# Service Reliability Standard 018 + +## Status + +Accepted + +## Context + +Portfolio service 018 needs a stable navigation reference for operational guidance. + +## Decision + +Service 018 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-019.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-019.md new file mode 100644 index 00000000..183ec2e2 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-019.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001019 +type: decision +--- +# Service Reliability Standard 019 + +## Status + +Accepted + +## Context + +Portfolio service 019 needs a stable navigation reference for operational guidance. + +## Decision + +Service 019 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-020.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-020.md new file mode 100644 index 00000000..e050e062 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-020.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001020 +type: decision +--- +# Service Reliability Standard 020 + +## Status + +Accepted + +## Context + +Portfolio service 020 needs a stable navigation reference for operational guidance. + +## Decision + +Service 020 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-021.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-021.md new file mode 100644 index 00000000..97a6d414 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-021.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001021 +type: decision +--- +# Service Reliability Standard 021 + +## Status + +Accepted + +## Context + +Portfolio service 021 needs a stable navigation reference for operational guidance. + +## Decision + +Service 021 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-022.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-022.md new file mode 100644 index 00000000..a6ba9d88 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-022.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001022 +type: decision +--- +# Service Reliability Standard 022 + +## Status + +Accepted + +## Context + +Portfolio service 022 needs a stable navigation reference for operational guidance. + +## Decision + +Service 022 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-023.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-023.md new file mode 100644 index 00000000..47175ad6 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-023.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001023 +type: decision +--- +# Service Reliability Standard 023 + +## Status + +Accepted + +## Context + +Portfolio service 023 needs a stable navigation reference for operational guidance. + +## Decision + +Service 023 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-024.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-024.md new file mode 100644 index 00000000..0a431fcc --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-024.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001024 +type: decision +--- +# Service Reliability Standard 024 + +## Status + +Accepted + +## Context + +Portfolio service 024 needs a stable navigation reference for operational guidance. + +## Decision + +Service 024 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-025.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-025.md new file mode 100644 index 00000000..80f7f7ba --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-025.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001025 +type: decision +--- +# Service Reliability Standard 025 + +## Status + +Accepted + +## Context + +Portfolio service 025 needs a stable navigation reference for operational guidance. + +## Decision + +Service 025 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-026.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-026.md new file mode 100644 index 00000000..5204d021 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-026.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001026 +type: decision +--- +# Service Reliability Standard 026 + +## Status + +Accepted + +## Context + +Portfolio service 026 needs a stable navigation reference for operational guidance. + +## Decision + +Service 026 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-027.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-027.md new file mode 100644 index 00000000..17389ea5 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-027.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001027 +type: decision +--- +# Service Reliability Standard 027 + +## Status + +Accepted + +## Context + +Portfolio service 027 needs a stable navigation reference for operational guidance. + +## Decision + +Service 027 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-028.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-028.md new file mode 100644 index 00000000..e752e7c8 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-028.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001028 +type: decision +--- +# Service Reliability Standard 028 + +## Status + +Accepted + +## Context + +Portfolio service 028 needs a stable navigation reference for operational guidance. + +## Decision + +Service 028 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-029.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-029.md new file mode 100644 index 00000000..42ec6175 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-029.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001029 +type: decision +--- +# Service Reliability Standard 029 + +## Status + +Accepted + +## Context + +Portfolio service 029 needs a stable navigation reference for operational guidance. + +## Decision + +Service 029 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-030.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-030.md new file mode 100644 index 00000000..d57e95ad --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-030.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001030 +type: decision +--- +# Service Reliability Standard 030 + +## Status + +Accepted + +## Context + +Portfolio service 030 needs a stable navigation reference for operational guidance. + +## Decision + +Service 030 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-031.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-031.md new file mode 100644 index 00000000..dab7d0ef --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-031.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001031 +type: decision +--- +# Service Reliability Standard 031 + +## Status + +Accepted + +## Context + +Portfolio service 031 needs a stable navigation reference for operational guidance. + +## Decision + +Service 031 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-032.md b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-032.md new file mode 100644 index 00000000..3bb3fbe6 --- /dev/null +++ b/rust/fixtures/eval/federation/child/vendor/standards/decisions/service-standard-032.md @@ -0,0 +1,30 @@ +--- +schema_version: 1 +id: FEDEVAL-000000001032 +type: decision +--- +# Service Reliability Standard 032 + +## Status + +Accepted + +## Context + +Portfolio service 032 needs a stable navigation reference for operational guidance. + +## Decision + +Service 032 records its reliability boundary and links to the shared reference hub. + +## Consequences + +The relationship makes the hub highly connected without adding query vocabulary. + +## Category + +Technical + +## Related Decisions + +- FEDEVAL-000000000002 diff --git a/rust/fixtures/eval/federation/eval-config.json b/rust/fixtures/eval/federation/eval-config.json new file mode 100644 index 00000000..4e2e9356 --- /dev/null +++ b/rust/fixtures/eval/federation/eval-config.json @@ -0,0 +1,14 @@ +{ + "description": "Federated DecisionGrounding gate: preserve the precise inherited match and admit no hard negative in the top-five window.", + "tolerance": 0.02, + "floors": { + "negative_violations": 0, + "overall": { + "p_at_1": 0.9, + "r_at_5": 0.95 + }, + "by_category": { + "federated_large_parent": {"p_at_1": 0.9, "r_at_5": 0.95} + } + } +} diff --git a/rust/fixtures/eval/federation/queries.json b/rust/fixtures/eval/federation/queries.json new file mode 100644 index 00000000..1d0efb03 --- /dev/null +++ b/rust/fixtures/eval/federation/queries.json @@ -0,0 +1,21 @@ +{ + "description": "DecisionGrounding federation track: one child, a large inherited parent, a highly connected lexical hard negative, and no source preference.", + "cases": [ + { + "id": "FQ01", + "tool": "search_artifacts", + "category": "federated_large_parent", + "query": "quantum ledger compaction anchor", + "relevant": ["FEDEVAL-000000000001"], + "must_not_return": ["FEDEVAL-000000000002"] + }, + { + "id": "FQ02", + "tool": "search_artifacts", + "category": "federated_large_parent", + "query": "signed anchor checkpoint pruning", + "relevant": ["FEDEVAL-000000000001"], + "must_not_return": ["FEDEVAL-000000000002", "FEDEVAL-000000000003"] + } + ] +} diff --git a/rust/rac-engine/src/budget.rs b/rust/rac-engine/src/budget.rs index c54f101b..4c4ddd5f 100644 --- a/rust/rac-engine/src/budget.rs +++ b/rust/rac-engine/src/budget.rs @@ -364,8 +364,7 @@ fn truncate_optional_strategy(payload: &Value, budget: i64) -> (Value, bool) { // These fields are derived context, not the artifact identity itself. Drop // them in a fixed order only after whole-item collection truncation has // been exhausted. The marker tells the caller that context was omitted. - const OPTIONAL: [&str; 10] = [ - "provenance", + const OPTIONAL: [&str; 9] = [ "evidence", "outgoing", "incoming", @@ -378,6 +377,51 @@ fn truncate_optional_strategy(payload: &Value, budget: i64) -> (Value, bool) { ]; let mut candidate = object.clone(); let mut changed = false; + + let has_override_provenance = candidate + .get("provenance") + .and_then(Value::as_object) + .is_some_and(|provenance| provenance.contains_key("overrides")); + // An override record is one provenance fact: parent, replacement, and + // rationale must survive together. If that fixed record cannot fit after + // other reductions, `serialize` returns the explicit budget error. + if candidate.contains_key("provenance") + && !has_override_provenance + && length(&Value::Object(candidate.clone())) > budget + { + let protected = candidate + .get("provenance") + .and_then(Value::as_object) + .filter(|provenance| { + provenance.contains_key("source") && provenance.contains_key("layer") + }) + .map(|provenance| { + let mut fixed = Map::new(); + for key in ["source", "layer", "pin"] { + if let Some(value) = provenance.get(key) { + fixed.insert(key.to_string(), value.clone()); + } + } + Value::Object(fixed) + }); + match protected { + Some(provenance) => { + if candidate.get("provenance") != Some(&provenance) { + candidate.insert("provenance".to_string(), provenance); + changed = true; + } + } + None => { + candidate.remove("provenance"); + changed = true; + } + } + if changed { + candidate.insert(MARKER_TRUNCATED.to_string(), json!(true)); + candidate.insert(MARKER_OMITTED.to_string(), json!(existing_omitted(payload))); + candidate.insert(MARKER_HINT.to_string(), json!(HINT_RELATED)); + } + } for key in OPTIONAL { if candidate.contains_key(key) && length(&Value::Object(candidate.clone())) > budget { candidate.remove(key); @@ -467,6 +511,77 @@ mod tests { assert_eq!(value["error"], json!(BUDGET_ERROR)); } + #[test] + fn federation_origin_survives_optional_context_truncation() { + let payload = json!({ + "schema_version": "1", + "id": "ADR-001", + "content": "x".repeat(20_000), + "provenance": { + "source": "acme/standards", + "layer": "inherited", + "pin": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "status": "Accepted", + "status_history": repeated("history", 32) + } + }); + let text = serialize(&payload, 384); + assert!(char_len(&text) <= 384, "{} characters", char_len(&text)); + let value: Value = serde_json::from_str(&text).expect("budget result is JSON"); + assert_eq!(value["provenance"]["source"], json!("acme/standards")); + assert_eq!(value["provenance"]["layer"], json!("inherited")); + assert_eq!( + value["provenance"]["pin"], + json!("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + ); + } + + fn assert_complete_override_or_budget_error(state: &str, source: &str) { + let payload = json!({ + "schema_version": "1", + "id": "STD-01JY4M8X2QZ7", + "content": "x".repeat(20_000), + "provenance": { + "source": source, + "layer": if state == "overridden" { "inherited" } else { "local" }, + "pin": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "status": "Accepted", + "overrides": [{ + "state": state, + "parent": {"source": "acme/standards", "id": "STD-01JY4M8X2QZ7"}, + "replacement": {"source": "acme/app", "id": "APP-01JY4M8X2QZ8"}, + "rationale": {"source": "acme/app", "id": "APP-01JY4M8X2QZ9"} + }] + } + }); + for budget in [384, 512, 768] { + let text = serialize(&payload, budget); + assert!(char_len(&text) <= budget, "{} characters", char_len(&text)); + let value: Value = serde_json::from_str(&text).expect("budget result is JSON"); + if value["error"] == json!(BUDGET_ERROR) { + continue; + } + let mapping = &value["provenance"]["overrides"][0]; + assert_eq!(mapping["state"], json!(state)); + assert_eq!(mapping["parent"]["source"], json!("acme/standards")); + assert_eq!(mapping["parent"]["id"], json!("STD-01JY4M8X2QZ7")); + assert_eq!(mapping["replacement"]["source"], json!("acme/app")); + assert_eq!(mapping["replacement"]["id"], json!("APP-01JY4M8X2QZ8")); + assert_eq!(mapping["rationale"]["source"], json!("acme/app")); + assert_eq!(mapping["rationale"]["id"], json!("APP-01JY4M8X2QZ9")); + } + } + + #[test] + fn tight_budget_preserves_overridden_parent_provenance_or_errors() { + assert_complete_override_or_budget_error("overridden", "acme/standards"); + } + + #[test] + fn tight_budget_preserves_replacement_provenance_or_errors() { + assert_complete_override_or_budget_error("replacement", "acme/app"); + } + #[test] fn configured_and_per_call_minimums_are_explicit() { assert!(valid_configured_budget(MIN_BUDGET)); diff --git a/rust/rac-engine/src/commands.rs b/rust/rac-engine/src/commands.rs index 282f1388..ee493d0f 100644 --- a/rust/rac-engine/src/commands.rs +++ b/rust/rac-engine/src/commands.rs @@ -511,6 +511,23 @@ pub fn validate_stdin_against_corpus( } } +fn validate_stdin_against_composed( + artifact: &Artifact, + corpus_dir: &str, + source_path: &str, + recursive: bool, + corpus: &crate::composition::ComposedCorpus, +) -> StdinCorpusValidation { + let structural = validate_product(artifact, corpus_dir); + let relationships = + corpus.validate_proposed_document(artifact, source_path, corpus_dir, recursive); + StdinCorpusValidation { + source_path: source_path.to_string(), + structural_issues: structural, + relationship_issues: relationships.issues, + } +} + // --------------------------------------------------------------------------- // cmd_validate // --------------------------------------------------------------------------- @@ -668,7 +685,17 @@ pub fn cmd_validate(args: &ValidateArgs) -> i32 { } else { py_path_str(&args.file) }; - let result = validate_stdin_against_corpus(&artifact, corpus, &source_path, true); + let result = match load_composed_or_exit(corpus, true) { + Ok(Some(composed)) => validate_stdin_against_composed( + &artifact, + corpus, + &source_path, + true, + &composed, + ), + Ok(None) => validate_stdin_against_corpus(&artifact, corpus, &source_path, true), + Err(code) => return code, + }; if args.json { emit(output::render_stdin_corpus_json(&result)); } else { @@ -915,7 +942,15 @@ pub fn cmd_relationships(args: &RelationshipsArgs) -> i32 { // Inspection arm (non --validate): always exit 0. let report = if is_dir { - build_relationship_report(&args.path, !args.top_level) + match load_composed_or_exit(&args.path, !args.top_level) { + Ok(Some(composed)) => crate::relationships::build_relationship_report_from_composed( + &args.path, + !args.top_level, + &composed, + ), + Ok(None) => build_relationship_report(&args.path, !args.top_level), + Err(code) => return code, + } } else { build_relationship_report_file(&args.path) }; @@ -940,7 +975,14 @@ pub fn cmd_stats(args: &StatsArgs) -> i32 { if !Path::new(&args.directory).is_dir() { return usage_error(&format!("not a directory: {}", args.directory)); } - let stats = crate::stats::collect_stats(&args.directory); + let stats = match load_composed_or_exit(&args.directory, true) { + Ok(Some(composed)) => { + let items: Vec<_> = composed.effective().cloned().collect(); + crate::stats::collect_stats_from_items(&args.directory, &items) + } + Ok(None) => crate::stats::collect_stats(&args.directory), + Err(code) => return code, + }; if args.json { emit(output::render_stats_json(&stats)); } else { @@ -968,8 +1010,16 @@ pub fn cmd_portfolio(args: &PortfolioArgs) -> i32 { return usage_error(&format!("not a directory: {}", args.directory)); } let recursive = !args.top_level; - let items = corpus_items(&args.directory, recursive); - let summary = crate::portfolio::portfolio_from_corpus(&args.directory, &items, recursive); + let summary = match load_composed_or_exit(&args.directory, recursive) { + Ok(Some(composed)) => { + crate::portfolio::portfolio_from_composed(&args.directory, &composed, recursive) + } + Ok(None) => { + let items = corpus_items(&args.directory, recursive); + crate::portfolio::portfolio_from_corpus(&args.directory, &items, recursive) + } + Err(code) => return code, + }; if args.json { emit(output::render_portfolio_json(&summary)); } else { @@ -993,7 +1043,15 @@ pub fn cmd_index(args: &IndexArgs) -> i32 { if !Path::new(&args.directory).is_dir() { return usage_error(&format!("not a directory: {}", args.directory)); } - let index = crate::index::build_repository_index(&args.directory, !args.top_level); + let recursive = !args.top_level; + let index = match load_composed_or_exit(&args.directory, recursive) { + Ok(Some(composed)) => { + let items: Vec<_> = composed.effective().cloned().collect(); + crate::index::build_repository_index_from_items(&args.directory, &items, recursive) + } + Ok(None) => crate::index::build_repository_index(&args.directory, recursive), + Err(code) => return code, + }; if args.json { emit(output::render_index_json(&index)); } else { @@ -1016,7 +1074,13 @@ pub fn cmd_coverage(args: &CoverageArgs) -> i32 { if !Path::new(&args.directory).is_dir() { return usage_error(&format!("not a directory: {}", args.directory)); } - let report = crate::coverage::analyze_coverage(&args.directory); + let report = match load_composed_or_exit(&args.directory, true) { + Ok(Some(composed)) => { + crate::coverage::analyze_coverage_from_composed(&args.directory, &composed) + } + Ok(None) => crate::coverage::analyze_coverage(&args.directory), + Err(code) => return code, + }; if args.json { emit(output::render_coverage_json(&report)); } else { @@ -1217,7 +1281,13 @@ pub fn cmd_herald(args: &HeraldArgs) -> i32 { Ok(text) => text.lines().map(str::trim).filter(|line| !line.is_empty()).map(str::to_string).collect::>(), Err(error) => return usage_error(&format!("could not read paths file {}: {error}", args.paths_file)), }; - let report = crate::herald::collect(&args.directory, &paths, !args.top_level); + let report = match load_composed_or_exit(&args.directory, !args.top_level) { + Ok(Some(composed)) => { + crate::herald::collect_from_composed(&args.directory, &paths, &composed) + } + Ok(None) => crate::herald::collect(&args.directory, &paths, !args.top_level), + Err(code) => return code, + }; let body = crate::herald::render(&report, &args.link_base, args.max_inline); if let Err(error) = std::fs::write(&args.out, body) { return usage_error(&format!("could not write Herald output {}: {error}", args.out)); @@ -1330,8 +1400,17 @@ pub fn cmd_doctor(args: &DoctorArgs) -> i32 { if !Path::new(&args.directory).is_dir() { return usage_error(&format!("not a directory: {}", args.directory)); } - let report = - crate::doctor::diagnose(&args.directory, !args.top_level, args.hub_threshold); + let recursive = !args.top_level; + let report = match load_composed_or_exit(&args.directory, recursive) { + Ok(Some(composed)) => crate::doctor::diagnose_composed( + &args.directory, + recursive, + args.hub_threshold, + &composed, + ), + Ok(None) => crate::doctor::diagnose(&args.directory, recursive, args.hub_threshold), + Err(code) => return code, + }; if args.json { emit(output::render_doctor_json(&report)); } else { @@ -1366,7 +1445,17 @@ pub fn cmd_review(args: &ReviewArgs) -> i32 { return usage_error("--stale-after must be a non-negative number of days"); } } - let report = crate::review::build_review(&args.directory, !args.top_level, args.stale_after); + let recursive = !args.top_level; + let report = match load_composed_or_exit(&args.directory, recursive) { + Ok(Some(composed)) => crate::review::build_review_composed( + &args.directory, + recursive, + args.stale_after, + &composed, + ), + Ok(None) => crate::review::build_review(&args.directory, recursive, args.stale_after), + Err(code) => return code, + }; if args.sarif { emit(output::render_review_sarif(&report)); } else if args.json { @@ -1789,6 +1878,69 @@ pub fn annotate_search_recency(matches: &mut [crate::resolve::ResolvedArtifact], ); } +/// Join Git recency onto local matches from a composed corpus without ever +/// attributing the child checkout's history to inherited artifacts. +/// +/// Ranking has already completed when this runs. Inherited records retain a +/// missing `recency` field; local records use their runtime-only physical +/// locator while public paths remain owning-source-relative. +pub fn annotate_composed_search_recency( + matches: &mut [crate::resolve::ResolvedArtifact], + directory: &str, + corpus: &crate::composition::ComposedCorpus, +) { + use crate::corpus::Layer; + use crate::gitinfo; + + let local: Vec<(usize, PathBuf)> = matches + .iter() + .enumerate() + .filter_map(|(index, artifact)| { + let key = artifact.key.as_ref()?; + let item = corpus.item(key)?; + (item.origin.layer == Layer::Local) + .then(|| (index, item.locator.path.clone())) + }) + .collect(); + if local.is_empty() { + return; + } + let local_count = local.len(); + + let timing_started = crate::timing::start(); + let threshold = crate::validate::load_freshness_threshold(directory); + let reference = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0); + let repo_root = gitinfo::repository_root(Path::new(directory)); + let paths: Vec = local.iter().map(|(_, path)| path.clone()).collect(); + let committed = match &repo_root { + Some(root) => gitinfo::last_committed_for_paths_in_repo(root, &paths), + None => paths.into_iter().map(|path| (path, None)).collect(), + }; + for ((index, _), (_, last)) in local.into_iter().zip(committed) { + let staleness = gitinfo::staleness(last.as_deref(), threshold, reference); + matches[index].recency = Some(crate::resolve::Recency { + last_committed: staleness + .last_committed + .as_deref() + .map(gitinfo::isoformat_roundtrip), + age_days: staleness.age_days, + stale: staleness.stale, + }); + } + crate::timing::emit_since( + "git.recency_join", + timing_started, + &[ + ("matches", matches.len() as u64), + ("local_matches", local_count as u64), + ("repository", u64::from(repo_root.is_some())), + ], + ); +} + /// Serve `decided find` from the persistent index store (`_find_from_store`, /// ADR-112): a warm run against an unchanged corpus reads the mapped base; /// a cold run builds fresh, writes the store, and serves either the @@ -1881,7 +2033,9 @@ pub fn cmd_find(args: &FindArgs) -> i32 { args.live, ) }; - if composed.is_none() { + if let Some(composed) = &composed { + annotate_composed_search_recency(&mut result.matches, &args.directory, composed); + } else { annotate_search_recency(&mut result.matches, &args.directory); } let render_started = crate::timing::start(); diff --git a/rust/rac-engine/src/composition.rs b/rust/rac-engine/src/composition.rs index 71fd5229..8b0633e1 100644 --- a/rust/rac-engine/src/composition.rs +++ b/rust/rac-engine/src/composition.rs @@ -710,6 +710,18 @@ impl ComposedCorpus { resolve_relationships(&self.effective_rows, &self.resolution_index) } + /// Child-declared edges resolved against the full composed identity + /// catalog. Diagnostics use this projection to keep their subjects local + /// without losing qualified parent and override resolution. + pub fn local_relationships(&self) -> Vec { + let rows: Vec = self + .local + .iter() + .map(|index| self.catalog_rows[*index].clone()) + .collect(); + resolve_relationships(&rows, &self.resolution_index) + } + /// Resolve declared edges for every retained catalog record, including an /// overridden parent's immutable history. Export uses this projection; /// live reads and enforcement continue to use `relationships`. @@ -738,6 +750,15 @@ impl ComposedCorpus { summary } + pub fn local_relationship_summary(&self) -> RelationshipSummary { + let rows: Vec = self + .local + .iter() + .map(|index| self.catalog_rows[*index].clone()) + .collect(); + crate::relationships::summary_from_rows_with_index(&rows, &self.resolution_index, true) + } + /// Run the existing relationship validator over source-aware keys. The /// child repository root is intentionally supplied here so inherited /// filesystem scope is checked against child code. @@ -763,6 +784,102 @@ impl ComposedCorpus { }); validation } + + pub fn validate_local_relationships( + &self, + child_directory: &str, + recursive: bool, + ) -> RelationshipValidation { + let rows: Vec = self + .local + .iter() + .map(|index| self.catalog_rows[*index].clone()) + .collect(); + validation_from_rows_with_index( + child_directory, + &rows, + &self.catalog_rows, + recursive, + &self.resolution_index, + false, + true, + ) + } + + /// Validate one proposed child document against the composed catalog. + /// Only proposal-owned findings are returned; an on-disk local artifact + /// with the same canonical id is treated as the document being replaced. + pub fn validate_proposed_document( + &self, + artifact: &crate::parse::Artifact, + source_path: &str, + child_directory: &str, + recursive: bool, + ) -> RelationshipValidation { + let source = self.child_source.clone().unwrap_or_else(|| { + crate::corpus::compatible_local_layer(child_directory).source + }); + let origin = crate::corpus::CorpusLayer::local(source).origin(); + let spec = crate::spec::spec_for(&crate::classify::classify(artifact).artifact_type); + let proposed = CorpusItem::new( + source_path.to_string(), + source_path.to_string(), + artifact.clone(), + spec, + origin, + crate::corpus::PhysicalArtifactLocator::new( + crate::corpus::PhysicalCorpusLocator::local(child_directory), + source_path, + ), + ); + let canonical = py_casefold(&proposed.key.canonical_id); + let mut catalog_rows: Vec = self + .catalog_rows + .iter() + .filter(|row| { + !(row.origin.layer == Layer::Local + && py_casefold(&row.key.canonical_id) == canonical) + }) + .cloned() + .collect(); + let mut effective_rows: Vec = self + .effective_rows + .iter() + .filter(|row| { + !(row.origin.layer == Layer::Local + && py_casefold(&row.key.canonical_id) == canonical) + }) + .cloned() + .collect(); + let proposal_row = validation_row_from_item(&proposed); + catalog_rows.push(proposal_row.clone()); + effective_rows.push(proposal_row.clone()); + catalog_rows.sort_by(|left, right| { + left.artifact_path + .cmp(&right.artifact_path) + .then_with(|| left.key.cmp(&right.key)) + }); + effective_rows.sort_by(|left, right| { + left.artifact_path + .cmp(&right.artifact_path) + .then_with(|| left.key.cmp(&right.key)) + }); + let index = composed_resolution_index( + &catalog_rows, + &effective_rows, + self.parent.as_ref(), + &self.overrides, + ); + validation_from_rows_with_index( + child_directory, + &[proposal_row], + &catalog_rows, + recursive, + &index, + false, + true, + ) + } } fn valid_source_alias(alias: &str) -> bool { diff --git a/rust/rac-engine/src/coverage.rs b/rust/rac-engine/src/coverage.rs index e954b3ae..f1a5005a 100644 --- a/rust/rac-engine/src/coverage.rs +++ b/rust/rac-engine/src/coverage.rs @@ -18,8 +18,9 @@ use std::collections::{HashMap, HashSet}; +use crate::corpus::{ArtifactOrigin, ArtifactPath}; use crate::identity::artifact_identifier; -use crate::relationships::{corpus_items, relationships_from_corpus}; +use crate::relationships::{corpus_items, relationships_from_corpus, CorpusItem, Relationship}; pub const GAP_UNSCHEDULED: &str = "unscheduled"; pub const GAP_UNAPPLIED: &str = "unapplied"; @@ -38,6 +39,8 @@ fn missing_text(gap: &str) -> &'static str { #[derive(Debug)] pub struct CoverageGap { pub path: String, + pub artifact_path: Option, + pub origin: Option, pub id: String, pub artifact_type: String, pub gap: &'static str, @@ -77,9 +80,31 @@ fn class_order(gap: &str) -> usize { /// `analyze_coverage(directory)` — always recursive, no writes, no git. pub fn analyze_coverage(directory: &str) -> CoverageReport { let items = corpus_items(directory, true); + let relationships = relationships_from_corpus(&items); + analyze_coverage_from_items(directory, &items, &relationships, false) +} + +/// Coverage over one already-composed effective graph. Source-aware artifact +/// paths prevent equal local/parent relative paths from collapsing into one +/// node; public provenance is additive only on this federated seam. +pub fn analyze_coverage_from_composed( + directory: &str, + corpus: &crate::composition::ComposedCorpus, +) -> CoverageReport { + let items: Vec = corpus.effective().cloned().collect(); + let relationships = corpus.relationships(); + analyze_coverage_from_items(directory, &items, &relationships, true) +} + +fn analyze_coverage_from_items( + directory: &str, + items: &[CorpusItem], + relationships: &[Relationship], + include_provenance: bool, +) -> CoverageReport { // The identity index rows coverage reads: (path, id, type) per artifact, // unknown documents included with type "unknown" (they never gap). - let index: Vec<(String, String, String)> = items + let index: Vec<(String, ArtifactPath, ArtifactOrigin, String, String)> = items .iter() .map(|item| { let artifact_type = item @@ -87,56 +112,64 @@ pub fn analyze_coverage(directory: &str) -> CoverageReport { .map(|s| s.name.clone()) .unwrap_or_else(|| "unknown".to_string()); let id = artifact_identifier(&item.artifact, item.spec, &item.path); - (item.path.clone(), id, artifact_type) + ( + item.path.clone(), + item.artifact_path.clone(), + item.origin.clone(), + id, + artifact_type, + ) }) .collect(); - let type_by_path: HashMap<&str, &str> = index + let type_by_path: HashMap<&ArtifactPath, &str> = index .iter() - .map(|(path, _, artifact_type)| (path.as_str(), artifact_type.as_str())) + .map(|(_, artifact_path, _, _, artifact_type)| (artifact_path, artifact_type.as_str())) .collect(); - let relationships = relationships_from_corpus(&items); // Resolved incoming source types and resolved outgoing target types. - let mut incoming_types: HashMap<&str, HashSet<&str>> = index + let mut incoming_types: HashMap<&ArtifactPath, HashSet<&str>> = index .iter() - .map(|(path, _, _)| (path.as_str(), HashSet::new())) + .map(|(_, artifact_path, _, _, _)| (artifact_path, HashSet::new())) .collect(); - let mut outgoing_types: HashMap<&str, HashSet<&str>> = index + let mut outgoing_types: HashMap<&ArtifactPath, HashSet<&str>> = index .iter() - .map(|(path, _, _)| (path.as_str(), HashSet::new())) + .map(|(_, artifact_path, _, _, _)| (artifact_path, HashSet::new())) .collect(); - for rel in &relationships { - let Some(resolved) = rel.resolved_path.as_deref() else { + for rel in relationships { + let (Some(source), Some(resolved)) = ( + rel.source_artifact.as_ref(), + rel.resolved_artifact.as_ref(), + ) else { continue; }; - if resolved == rel.source_path { + if resolved == source { continue; } - let source_type = type_by_path.get(rel.source_path.as_str()).copied(); + let source_type = type_by_path.get(source).copied(); let target_type = type_by_path.get(resolved).copied(); if let (Some(types), Some(source_type)) = (incoming_types.get_mut(resolved), source_type) { types.insert(source_type); } - if let (Some(types), Some(target_type)) = - (outgoing_types.get_mut(rel.source_path.as_str()), target_type) - { + if let (Some(types), Some(target_type)) = (outgoing_types.get_mut(source), target_type) { types.insert(target_type); } } let mut gaps: Vec = Vec::new(); - for (path, id, artifact_type) in &index { - let incoming = &incoming_types[path.as_str()]; + for (path, artifact_path, origin, id, artifact_type) in &index { + let incoming = &incoming_types[artifact_path]; let gap = match artifact_type.as_str() { "requirement" if !incoming.contains("roadmap") => GAP_UNSCHEDULED, "decision" if !incoming.contains("requirement") && !incoming.contains("roadmap") => { GAP_UNAPPLIED } - "roadmap" if !outgoing_types[path.as_str()].contains("requirement") => GAP_UNSCOPED, + "roadmap" if !outgoing_types[artifact_path].contains("requirement") => GAP_UNSCOPED, _ => continue, }; gaps.push(CoverageGap { path: path.clone(), + artifact_path: include_provenance.then(|| artifact_path.clone()), + origin: include_provenance.then(|| origin.clone()), id: id.clone(), artifact_type: artifact_type.clone(), gap, @@ -148,6 +181,7 @@ pub fn analyze_coverage(directory: &str) -> CoverageReport { gaps.sort_by(|a, b| { class_order(a.gap) .cmp(&class_order(b.gap)) + .then_with(|| a.artifact_path.cmp(&b.artifact_path)) .then_with(|| a.path.cmp(&b.path)) }); CoverageReport { diff --git a/rust/rac-engine/src/derived_cache.rs b/rust/rac-engine/src/derived_cache.rs index 3dd98100..7e4e1787 100644 --- a/rust/rac-engine/src/derived_cache.rs +++ b/rust/rac-engine/src/derived_cache.rs @@ -902,25 +902,15 @@ impl FederatedCacheTracker { child_corpus, recursive, |generation| { - let Some(identity) = generation.identity() else { - return Err(FederatedCacheError::InvalidModel { + let identity = generation.identity().ok_or_else(|| { + FederatedCacheError::InvalidModel { message: "composed cache reads require .decided/corpus.md".to_string(), - }); - }; + } + })?; + let composed = compose_logical_generation(child_corpus, generation)?; let parent = generation .verified_parent() .expect("federated identity has a verified parent"); - let child_files = generation - .child_files() - .expect("federated identity has captured child files"); - let composed = crate::federated_corpus::compose_verified_generation_from_snapshot( - child_corpus, - parent, - child_files, - ) - .map_err(|error| { - FederatedCacheError::composition(error.stable_code(), error.to_string()) - })?; let model = crate::derived::build_derived_index_from_composed( &identity.child_corpus_path, child_corpus, @@ -935,6 +925,30 @@ impl FederatedCacheTracker { } } +/// Compose the authoritative corpus from one already-captured logical +/// generation. This is also the cache-disabled serving boundary: callers get +/// the same verified bytes and overlay semantics without opening or writing a +/// persistent store. +pub fn compose_logical_generation( + child_corpus: &str, + generation: &LogicalGeneration, +) -> Result { + let parent = generation + .verified_parent() + .ok_or_else(|| FederatedCacheError::InvalidModel { + message: "composed generation requires .decided/corpus.md".to_string(), + })?; + let child_files = generation + .child_files() + .expect("federated identity has captured child files"); + crate::federated_corpus::compose_verified_generation_from_snapshot( + child_corpus, + parent, + child_files, + ) + .map_err(|error| FederatedCacheError::composition(error.stable_code(), error.to_string())) +} + fn stable_public_path(path: &str) -> bool { !path.is_empty() && !Path::new(path).is_absolute() diff --git a/rust/rac-engine/src/doctor.rs b/rust/rac-engine/src/doctor.rs index a7649313..232de98f 100644 --- a/rust/rac-engine/src/doctor.rs +++ b/rust/rac-engine/src/doctor.rs @@ -15,7 +15,8 @@ use crate::identity::path_stem; use crate::pycompat::{py_casefold, py_repr_str, py_strip}; use crate::relationships::{ corpus_items, relationship_severity, relationships_from_corpus, validate_relationships, - CorpusItem, RelationshipIssue, ISSUE_DUPLICATE_IDENTIFIER, ISSUE_RELATIONSHIP_CYCLE, + CorpusItem, Relationship, RelationshipIssue, RelationshipValidation, + ISSUE_DUPLICATE_IDENTIFIER, ISSUE_RELATIONSHIP_CYCLE, }; use crate::resolve::{index_from_items, resolve_in_index, IndexEntry, OUTCOME_RESOLVED}; use crate::review::{drift_problem, suspect_drift}; @@ -114,10 +115,63 @@ pub fn diagnose(directory: &str, recursive: bool, hub_threshold: i64) -> DoctorR } } +/// Diagnose only writable child artifacts while resolving their graph through +/// the authoritative composed catalog. Parent warnings and advisories are not +/// copied into each child; parent load/validation failures have already +/// stopped composition at the command boundary. +pub fn diagnose_composed( + directory: &str, + recursive: bool, + hub_threshold: i64, + corpus: &crate::composition::ComposedCorpus, +) -> DoctorReport { + let items: Vec = corpus.local_items().cloned().collect(); + let relationships = corpus.relationships(); + let validation = crate::commands::validate_directory_from_items(directory, recursive, &items); + let relationship_validation = corpus.validate_relationships(directory, recursive); + let identity_index = corpus.identity_index(); + let mut findings = Vec::new(); + findings.extend(validation_findings_from_result(&validation)); + findings.extend(relationship_findings_from_result( + directory, + &relationship_validation, + )); + findings.extend(degree_findings_with_relationships( + &items, + &relationships, + hub_threshold, + )); + findings.extend(injection_findings(&items)); + findings.extend(unlinked_reference_findings_with_index( + &items, + &identity_index, + &relationships, + )); + findings.extend(suspect_artifact_findings(directory, &items)); + findings.sort_by(|a, b| { + severity_rank(a.severity) + .cmp(&severity_rank(b.severity)) + .then_with(|| a.path.cmp(&b.path)) + .then_with(|| a.code.cmp(b.code)) + .then_with(|| a.problem.cmp(&b.problem)) + }); + DoctorReport { + directory: directory.to_string(), + hub_threshold, + findings, + } +} + /// One finding per structurally invalid artifact; the problem names the /// sorted, deduplicated error codes. fn validation_findings(directory: &str, recursive: bool) -> Vec { let result = validate_directory(directory, recursive); + validation_findings_from_result(&result) +} + +fn validation_findings_from_result( + result: &crate::commands::DirectoryValidation, +) -> Vec { let mut findings = Vec::new(); for file in &result.files { if file.status != STATUS_INVALID { @@ -184,6 +238,13 @@ fn issue_problem(issue: &RelationshipIssue) -> String { /// (`RELATIONSHIP_SEVERITY.get(code, SEVERITY_ERROR)`). fn relationship_findings(directory: &str, recursive: bool) -> Vec { let result = validate_relationships(directory, recursive); + relationship_findings_from_result(directory, &result) +} + +fn relationship_findings_from_result( + directory: &str, + result: &RelationshipValidation, +) -> Vec { result .issues .iter() @@ -238,27 +299,43 @@ fn issue_code_static(code: &str) -> &'static str { /// over the resolved edges; the orphan definition matches the portfolio's /// "never a resolved target" count exactly. fn degree_findings(items: &[CorpusItem], hub_threshold: i64) -> Vec { + let relationships = relationships_from_corpus(items); + degree_findings_with_relationships(items, &relationships, hub_threshold) +} + +fn degree_findings_with_relationships( + items: &[CorpusItem], + relationships: &[Relationship], + hub_threshold: i64, +) -> Vec { let known: Vec<&CorpusItem> = items.iter().filter(|i| i.spec.is_some()).collect(); - let mut inbound: std::collections::HashMap<&str, i64> = - known.iter().map(|i| (i.path.as_str(), 0)).collect(); - let mut outbound: std::collections::HashMap<&str, i64> = - known.iter().map(|i| (i.path.as_str(), 0)).collect(); - for rel in relationships_from_corpus(items) { - let Some(resolved) = &rel.resolved_path else { + let mut inbound: std::collections::HashMap<&crate::corpus::ArtifactPath, i64> = known + .iter() + .map(|item| (&item.artifact_path, 0)) + .collect(); + let mut outbound: std::collections::HashMap<&crate::corpus::ArtifactPath, i64> = known + .iter() + .map(|item| (&item.artifact_path, 0)) + .collect(); + for rel in relationships { + let (Some(source), Some(resolved)) = ( + rel.source_artifact.as_ref(), + rel.resolved_artifact.as_ref(), + ) else { continue; // only resolved (unique, non-self) edges }; - if let Some(count) = inbound.get_mut(resolved.as_str()) { + if let Some(count) = inbound.get_mut(resolved) { *count += 1; } - if let Some(count) = outbound.get_mut(rel.source_path.as_str()) { + if let Some(count) = outbound.get_mut(source) { *count += 1; } } let mut findings = Vec::new(); for item in &known { let path = item.path.as_str(); - let in_degree = *inbound.get(path).unwrap_or(&0); - let degree = in_degree + *outbound.get(path).unwrap_or(&0); + let in_degree = *inbound.get(&item.artifact_path).unwrap_or(&0); + let degree = in_degree + *outbound.get(&item.artifact_path).unwrap_or(&0); if in_degree == 0 { findings.push(DoctorFinding { path: path.to_string(), @@ -735,30 +812,46 @@ struct UnlinkedReference { /// one finding per (source, target), sorted by `(source_path, target_id)`. fn detect_unlinked_references(items: &[CorpusItem]) -> Vec { let index: Vec = index_from_items(items); - let by_path: std::collections::HashMap<&str, &IndexEntry> = - index.iter().map(|e| (e.path.as_str(), e)).collect(); - - let mut declared: std::collections::HashMap<&str, std::collections::HashSet> = - std::collections::HashMap::new(); - for rel in relationships_from_corpus(items) { - if let Some(resolved) = rel.resolved_path { - if let Some(item) = items.iter().find(|i| i.path == rel.source_path) { - declared - .entry(item.path.as_str()) - .or_default() - .insert(resolved); - } + let relationships = relationships_from_corpus(items); + detect_unlinked_references_with_index(items, &index, &relationships) +} + +fn detect_unlinked_references_with_index( + items: &[CorpusItem], + identity_index: &[IndexEntry], + relationships: &[Relationship], +) -> Vec { + let sources: Vec = index_from_items(items); + let by_key: std::collections::HashMap<&crate::corpus::ArtifactKey, &IndexEntry> = identity_index + .iter() + .filter_map(|entry| entry.key.as_ref().map(|key| (key, entry))) + .collect(); + + let mut declared: std::collections::HashMap< + crate::corpus::ArtifactPath, + std::collections::HashSet, + > = std::collections::HashMap::new(); + for rel in relationships { + if let (Some(source), Some(resolved)) = + (&rel.source_artifact, &rel.resolved_artifact) + { + declared + .entry(source.clone()) + .or_default() + .insert(resolved.clone()); } } let mut findings: Vec = Vec::new(); - for source in &index { + for source in &sources { + let Some(source_path) = source.artifact_path.as_ref() else { + continue; + }; let self_aliases: std::collections::HashSet = source.aliases.iter().map(|a| py_casefold(a)).collect(); let empty = std::collections::HashSet::new(); - let already = declared.get(source.path.as_str()).unwrap_or(&empty); - let mut seen_targets: std::collections::HashSet = - std::collections::HashSet::new(); + let already = declared.get(source_path).unwrap_or(&empty); + let mut seen_targets = std::collections::HashSet::new(); for section in &source.search_sections { let heading = py_casefold(py_strip(§ion.heading)); if RELATIONSHIP_HEADINGS.contains(&heading.as_str()) { @@ -769,18 +862,23 @@ fn detect_unlinked_references(items: &[CorpusItem]) -> Vec { if self_aliases.contains(&py_casefold(&token)) { continue; // self-reference } - let result = resolve_in_index(&index, &token); + let result = resolve_in_index(identity_index, &token); if result.outcome != OUTCOME_RESOLVED { continue; // not a unique corpus artifact } let target = result.artifact.expect("resolved implies artifact"); - if target.path == source.path || already.contains(&target.path) { + let (Some(target_key), Some(target_path)) = + (target.key.as_ref(), target.artifact_path.as_ref()) + else { + continue; + }; + if source.key.as_ref() == Some(target_key) || already.contains(target_path) { continue; } - if !seen_targets.insert(target.path.clone()) { + if !seen_targets.insert(target_key.clone()) { continue; // one finding per (source, target) pair } - let target_entry = by_path[target.path.as_str()]; + let target_entry = by_key[target_key]; findings.push(UnlinkedReference { source_path: source.path.clone(), target_id: target.id.clone(), @@ -822,3 +920,27 @@ fn unlinked_reference_findings(items: &[CorpusItem]) -> Vec { }) .collect() } + +fn unlinked_reference_findings_with_index( + items: &[CorpusItem], + identity_index: &[IndexEntry], + relationships: &[Relationship], +) -> Vec { + detect_unlinked_references_with_index(items, identity_index, relationships) + .into_iter() + .map(|record| DoctorFinding { + path: record.source_path, + code: CODE_UNLINKED_REFERENCE, + severity: SEVERITY_WARNING, + problem: format!( + "body references {} but declares no {} link to it", + record.matched_token, record.related_section + ), + fix: format!( + "Add `{}` under `## {}` if the link is intended — a suggestion to \ + review; RAC writes no edge (ADR-082).", + record.suggested_line, record.related_section + ), + }) + .collect() +} diff --git a/rust/rac-engine/src/eval.rs b/rust/rac-engine/src/eval.rs index 68fd9ac0..00d81bd8 100644 --- a/rust/rac-engine/src/eval.rs +++ b/rust/rac-engine/src/eval.rs @@ -18,6 +18,8 @@ use std::path::Path; use serde_json::{Map, Value}; +use crate::composition::ComposedCorpus; +use crate::corpus::ArtifactPath; use crate::pycompat::{py_repr_str, py_round}; use crate::pyjson::py_float; use crate::relationships::{corpus_items, relationships_from_corpus, Relationship}; @@ -349,9 +351,60 @@ fn related_returned(root: &str, case: &QueryCase) -> EvalResult> { Ok(incoming_ids(&relationships, &identity_by_path, &artifact.path)) } -fn returned_ids(root: &str, entries: &[IndexEntry], case: &QueryCase) -> EvalResult> { +fn related_returned_composed( + corpus: &ComposedCorpus, + root: &str, + case: &QueryCase, +) -> EvalResult> { + const MAX_RELATED_EDGES: usize = 1000; + let artifact = corpus.resolve(&case.query).map_err(|_| { + EvalUsageError(format!( + "get_related case {}: query {} did not resolve to an artifact in {}", + py_repr_str(&case.id), + py_repr_str(&case.query), + py_repr_str(root) + )) + })?; + let relationships = corpus.relationships(); + let identity_by_path: HashMap<&ArtifactPath, &str> = corpus + .effective() + .map(|item| (&item.artifact_path, item.key.canonical_id.as_str())) + .collect(); + let mut incoming = Vec::new(); + for relationship in &relationships { + if relationship.resolved_artifact.as_ref() != Some(&artifact.artifact_path) + || relationship.source_artifact.as_ref() == Some(&artifact.artifact_path) + { + continue; + } + let Some(source_path) = relationship.source_artifact.as_ref() else { + continue; + }; + let Some(id) = identity_by_path.get(source_path) else { + continue; + }; + if incoming.len() < MAX_RELATED_EDGES { + incoming.push(( + relationship_order(&relationship.relationship), + (*id).to_string(), + source_path.clone(), + )); + } + } + incoming.sort_by(|left, right| (left.0, &left.1, &left.2).cmp(&(right.0, &right.1, &right.2))); + Ok(incoming.into_iter().map(|(_, id, _)| id).collect()) +} + +fn returned_ids( + root: &str, + entries: &[IndexEntry], + composed: Option<&ComposedCorpus>, + case: &QueryCase, +) -> EvalResult> { if case.tool == TOOL_SEARCH { Ok(search_returned(entries, case)) + } else if let Some(corpus) = composed { + related_returned_composed(corpus, root, case) } else { related_returned(root, case) } @@ -452,6 +505,19 @@ pub fn corpus_hash(root: &str) -> String { format!("sha256:{}", digest.hexdigest()) } +fn composed_corpus_hash(corpus: &ComposedCorpus) -> String { + let mut digest = Sha256::new(); + for item in corpus.catalog() { + digest.update(item.artifact_path.source.as_bytes()); + digest.update(b"\0"); + digest.update(item.artifact_path.relative_path.as_bytes()); + digest.update(b"\0"); + digest.update(corpus.content(&item.key).unwrap_or_default()); + digest.update(b"\0"); + } + format!("sha256:{}", digest.hexdigest()) +} + /// `query_set_hash(path)` — `sha256:` over the raw file bytes. pub fn query_set_hash(path: &str) -> String { format!( @@ -468,11 +534,16 @@ pub fn run_eval(root: &str, queries_path: &str) -> EvalResult { return usage(format!("corpus not found or not a directory: {root}")); } let cases = load_query_set(queries_path)?; - let entries = build_index(root, true); + let composed = crate::federated_corpus::load_composed_corpus(root, true) + .map_err(|error| EvalUsageError(error.to_string()))?; + let entries = composed + .as_ref() + .map(ComposedCorpus::effective_index) + .unwrap_or_else(|| build_index(root, true)); let mut results: Vec = Vec::with_capacity(cases.len()); for case in cases { - let returned = returned_ids(root, &entries, &case)?; + let returned = returned_ids(root, &entries, composed.as_ref(), &case)?; results.push(score_case(returned, case)); } let n_queries = results.len() as i64; @@ -491,7 +562,15 @@ pub fn run_eval(root: &str, queries_path: &str) -> EvalResult { "lore_version".into(), Value::String(crate::output::rac_version()), ); - metadata.insert("corpus_hash".into(), Value::String(corpus_hash(root))); + metadata.insert( + "corpus_hash".into(), + Value::String( + composed + .as_ref() + .map(composed_corpus_hash) + .unwrap_or_else(|| corpus_hash(root)), + ), + ); metadata.insert( "query_set_hash".into(), Value::String(query_set_hash(queries_path)), diff --git a/rust/rac-engine/src/herald.rs b/rust/rac-engine/src/herald.rs index 10c76102..e57373e3 100644 --- a/rust/rac-engine/src/herald.rs +++ b/rust/rac-engine/src/herald.rs @@ -2,13 +2,16 @@ use std::collections::{BTreeMap, BTreeSet}; -use crate::retrieve::decisions_for_path; +use crate::corpus::{ArtifactKey, ArtifactOrigin, Layer}; +use crate::retrieve::{decisions_for_path, decisions_for_path_with_rows, scope_rows_from_items}; pub const MARKER: &str = ""; const PATHS_SHOWN: usize = 3; #[derive(Debug)] pub struct HeraldDecision { + pub key: Option, + pub origin: Option, pub id: String, pub title: String, pub status: String, @@ -39,6 +42,8 @@ pub fn collect(corpus: &str, paths: &[String], recursive: bool) -> HeraldReport let entry = merged .entry(decision.id.clone()) .or_insert_with(|| HeraldDecision { + key: decision.key, + origin: None, id: decision.id, title: decision.title, status: decision.status, @@ -55,6 +60,45 @@ pub fn collect(corpus: &str, paths: &[String], recursive: bool) -> HeraldReport } } +/// Herald collection over one effective composed scope projection. Stable +/// ArtifactKey deduplication prevents equal canonical ids or relative paths +/// in different sources from collapsing. +pub fn collect_from_composed( + corpus_directory: &str, + paths: &[String], + corpus: &crate::composition::ComposedCorpus, +) -> HeraldReport { + let items: Vec<_> = corpus.effective().cloned().collect(); + let rows = scope_rows_from_items(&items); + let mut merged: BTreeMap = BTreeMap::new(); + let changed: BTreeSet<&String> = paths + .iter() + .filter(|path| !path.trim().is_empty()) + .collect(); + for path in changed { + for decision in decisions_for_path_with_rows(&rows, corpus_directory, path).decisions { + let Some(key) = decision.key.clone() else { + continue; + }; + let entry = merged.entry(key.clone()).or_insert_with(|| HeraldDecision { + key: Some(key), + origin: decision.origin, + id: decision.id, + title: decision.title, + status: decision.status, + path: decision.path, + matching_entries: BTreeSet::new(), + changed_paths: BTreeSet::new(), + }); + entry.matching_entries.insert(decision.matching_entry); + entry.changed_paths.insert(path.clone()); + } + } + HeraldReport { + decisions: merged.into_values().collect(), + } +} + fn bullet(decision: &HeraldDecision, link_base: &str) -> String { let scopes = decision .matching_entries @@ -78,9 +122,18 @@ fn bullet(decision: &HeraldDecision, link_base: &str) -> String { } else { format!("{}/{}", link_base.trim_end_matches('/'), decision.path) }; + let label = if decision + .origin + .as_ref() + .is_some_and(|origin| origin.layer == Layer::Inherited) + { + format!("**{} — {}**", decision.id, decision.title) + } else { + format!("**[{} — {}]({link})**", decision.id, decision.title) + }; format!( - "- **[{} — {}]({})** ({}) — applies to {} — changed: {}", - decision.id, decision.title, link, decision.status, scopes, changed + "- {label} ({}) — applies to {} — changed: {}", + decision.status, scopes, changed ) } @@ -136,6 +189,8 @@ mod tests { fn decision(id: &str) -> HeraldDecision { HeraldDecision { + key: None, + origin: None, id: id.to_string(), title: format!("{id} title"), status: "Accepted".to_string(), diff --git a/rust/rac-engine/src/index.rs b/rust/rac-engine/src/index.rs index 2d549c55..404faef4 100644 --- a/rust/rac-engine/src/index.rs +++ b/rust/rac-engine/src/index.rs @@ -5,8 +5,9 @@ //! writes the derived cache (spec/index-contracts.json `index-command`). use crate::classify::classify; +use crate::corpus::ArtifactOrigin; use crate::identity::{artifact_identifier, artifact_identifiers}; -use crate::relationships::corpus_items; +use crate::relationships::{corpus_items, CorpusItem}; /// One row in the repository manifest: structural identity only. pub struct IndexEntry { @@ -15,6 +16,8 @@ pub struct IndexEntry { pub title: Option, pub path: String, pub aliases: Vec, + /// Present only when the row came from an explicit composed projection. + pub origin: Option, } /// Deterministic inventory of every artifact in a repository. @@ -24,8 +27,12 @@ pub struct RepositoryIndex { pub artifacts: Vec, } -pub fn build_repository_index(directory: &str, recursive: bool) -> RepositoryIndex { - let items = corpus_items(directory, recursive); +fn repository_index_from_items( + directory: &str, + items: &[CorpusItem], + recursive: bool, + include_origin: bool, +) -> RepositoryIndex { let artifacts = items .iter() .map(|it| IndexEntry { @@ -34,6 +41,7 @@ pub fn build_repository_index(directory: &str, recursive: bool) -> RepositoryInd title: it.artifact.product.title.clone(), path: it.path.clone(), aliases: artifact_identifiers(&it.artifact, it.spec, &it.path), + origin: include_origin.then(|| it.origin.clone()), }) .collect(); RepositoryIndex { @@ -42,3 +50,19 @@ pub fn build_repository_index(directory: &str, recursive: bool) -> RepositoryInd artifacts, } } + +/// Deterministic inventory over a caller-selected projection. Federation +/// passes the effective items from its authoritative composition here; this +/// adapter never walks or constructs an overlay independently. +pub fn build_repository_index_from_items( + directory: &str, + items: &[CorpusItem], + recursive: bool, +) -> RepositoryIndex { + repository_index_from_items(directory, items, recursive, true) +} + +pub fn build_repository_index(directory: &str, recursive: bool) -> RepositoryIndex { + let items = corpus_items(directory, recursive); + repository_index_from_items(directory, &items, recursive, false) +} diff --git a/rust/rac-engine/src/output.rs b/rust/rac-engine/src/output.rs index f9a37662..d9d028e3 100644 --- a/rust/rac-engine/src/output.rs +++ b/rust/rac-engine/src/output.rs @@ -697,6 +697,9 @@ pub fn render_relationships_json(report: &RelationshipReport) -> String { m.insert("path".into(), json!(artifact.path)); m.insert("type".into(), json!(artifact.type_name)); m.insert("relationships".into(), Value::Object(relationships)); + if let Some(origin) = &artifact.origin { + m.insert("provenance".into(), artifact_origin_value(origin)); + } Value::Object(m) }) .collect(); @@ -727,7 +730,11 @@ pub fn render_relationships_human(report: &RelationshipReport) -> String { for artifact in &report.artifacts { lines.push(String::new()); - lines.push(artifact.path.clone()); + lines.push(format!( + "{}{}", + artifact.path, + human_origin_suffix(artifact.origin.as_ref()) + )); for (section, refs) in &artifact.relationships { lines.push(format!(" {}:", relationship_label(section))); for reference in refs { @@ -1559,14 +1566,31 @@ pub fn render_templates_json(names: &[&str]) -> String { const EMPTY_CORPUS_HINT: &str = "No artifacts yet — create your first with: decided quickstart"; -fn invalid_files_json(items: &[(&str, &[String])]) -> Value { +fn human_origin_suffix(origin: Option<&crate::corpus::ArtifactOrigin>) -> String { + let Some(origin) = origin else { + return String::new(); + }; + let pin = origin + .pin + .as_ref() + .map(|pin| format!(" · {pin}")) + .unwrap_or_default(); + format!(" [{} · {}{pin}]", origin.source, origin.layer.as_str()) +} + +fn invalid_files_json( + items: &[(&str, &[String], Option<&crate::corpus::ArtifactOrigin>)], +) -> Value { Value::Array( items .iter() - .map(|(path, codes)| { + .map(|(path, codes, origin)| { let mut m = Map::new(); m.insert("file".into(), json!(path)); m.insert("errors".into(), json!(codes)); + if let Some(origin) = origin { + m.insert("provenance".into(), artifact_origin_value(origin)); + } Value::Object(m) }) .collect(), @@ -1604,6 +1628,9 @@ pub fn render_stats_json(s: &PortfolioStats) -> String { let mut m = Map::new(); m.insert("name".into(), json!(f.name)); m.insert("requirements".into(), json!(f.requirements)); + if let Some(origin) = &f.origin { + m.insert("provenance".into(), artifact_origin_value(origin)); + } Value::Object(m) } None => Value::Null, @@ -1618,15 +1645,18 @@ pub fn render_stats_json(s: &PortfolioStats) -> String { let mut m = Map::new(); m.insert("name".into(), json!(f.name)); m.insert("requirements".into(), json!(f.requirements)); + if let Some(origin) = &f.origin { + m.insert("provenance".into(), artifact_origin_value(origin)); + } Value::Object(m) }) .collect(), ), ); - let invalid: Vec<(&str, &[String])> = s + let invalid: Vec<(&str, &[String], Option<&crate::corpus::ArtifactOrigin>)> = s .invalid() .iter() - .map(|f| (f.path.as_str(), f.error_codes.as_slice())) + .map(|f| (f.path.as_str(), f.error_codes.as_slice(), f.origin.as_ref())) .collect(); payload.insert("invalid".into(), invalid_files_json(&invalid)); @@ -1646,7 +1676,12 @@ pub fn render_stats_json(s: &PortfolioStats) -> String { payload.insert("decisions".into(), Value::Object(m)); } - let mut family = |key: &str, count: usize, valid: usize, invalid: Vec<(&str, &[String])>| { + let mut family = | + key: &str, + count: usize, + valid: usize, + invalid: Vec<(&str, &[String], Option<&crate::corpus::ArtifactOrigin>)>, + | { let mut m = Map::new(); m.insert("count".into(), json!(count)); m.insert("valid".into(), json!(valid)); @@ -1655,26 +1690,26 @@ pub fn render_stats_json(s: &PortfolioStats) -> String { }; if !s.roadmaps.is_empty() { - let invalid: Vec<(&str, &[String])> = s + let invalid: Vec<(&str, &[String], Option<&crate::corpus::ArtifactOrigin>)> = s .invalid_roadmaps() .iter() - .map(|r| (r.path.as_str(), r.error_codes.as_slice())) + .map(|r| (r.path.as_str(), r.error_codes.as_slice(), r.origin.as_ref())) .collect(); family("roadmaps", s.roadmap_count(), s.valid_roadmaps(), invalid); } if !s.prompts.is_empty() { - let invalid: Vec<(&str, &[String])> = s + let invalid: Vec<(&str, &[String], Option<&crate::corpus::ArtifactOrigin>)> = s .invalid_prompts() .iter() - .map(|p| (p.path.as_str(), p.error_codes.as_slice())) + .map(|p| (p.path.as_str(), p.error_codes.as_slice(), p.origin.as_ref())) .collect(); family("prompts", s.prompt_count(), s.valid_prompts(), invalid); } if !s.designs.is_empty() { - let invalid: Vec<(&str, &[String])> = s + let invalid: Vec<(&str, &[String], Option<&crate::corpus::ArtifactOrigin>)> = s .invalid_designs() .iter() - .map(|d| (d.path.as_str(), d.error_codes.as_slice())) + .map(|d| (d.path.as_str(), d.error_codes.as_slice(), d.origin.as_ref())) .collect(); family("designs", s.design_count(), s.valid_designs(), invalid); } @@ -1692,6 +1727,9 @@ pub fn render_stats_json(s: &PortfolioStats) -> String { fm.insert("file".into(), json!(u.path)); fm.insert("name".into(), json!(u.name)); fm.insert("confidence".into(), crate::pyjson::py_float(py_round(u.confidence, 2))); + if let Some(origin) = &u.origin { + fm.insert("provenance".into(), artifact_origin_value(origin)); + } Value::Object(fm) }) .collect(), @@ -1712,13 +1750,21 @@ pub fn render_stats_json(s: &PortfolioStats) -> String { } /// ` ` invalid-list line. -fn invalid_reason_line(path: &str, error_codes: &[String]) -> String { +fn invalid_reason_line( + path: &str, + error_codes: &[String], + origin: Option<&crate::corpus::ArtifactOrigin>, +) -> String { let reasons = if error_codes.is_empty() { "unknown".to_string() } else { error_codes.join(", ") }; - format!(" {} \u{2014} {reasons}", red(path)) + format!( + " {} \u{2014} {reasons}{}", + red(path), + human_origin_suffix(origin) + ) } pub fn render_stats_human(s: &PortfolioStats) -> String { @@ -1736,14 +1782,28 @@ pub fn render_stats_human(s: &PortfolioStats) -> String { String::new(), ]; - let mut missing_block = |label: &str, names: &[&str]| { - lines.push(format!("{label}: {}", names.len())); - for name in names { - lines.push(format!(" - {name}")); + let mut missing_block = |label: &str, features: &[&crate::stats::FeatureStat]| { + lines.push(format!("{label}: {}", features.len())); + for feature in features { + lines.push(format!( + " - {}{}", + feature.name, + human_origin_suffix(feature.origin.as_ref()) + )); } }; - missing_block("Features Missing Metrics", &s.missing_metrics()); - missing_block("Features Missing Risks", &s.missing_risks()); + let missing_metrics: Vec<_> = s + .features + .iter() + .filter(|feature| feature.success_metrics == 0) + .collect(); + let missing_risks: Vec<_> = s + .features + .iter() + .filter(|feature| feature.risks == 0) + .collect(); + missing_block("Features Missing Metrics", &missing_metrics); + missing_block("Features Missing Risks", &missing_risks); lines.push(format!( "Average Requirements Per Feature: {}", py_format_1f(s.average_requirements()) @@ -1751,8 +1811,10 @@ pub fn render_stats_human(s: &PortfolioStats) -> String { match s.largest_feature() { Some(f) => lines.push(format!( - "Largest Feature: {} ({} requirements)", - f.name, f.requirements + "Largest Feature: {} ({} requirements){}", + f.name, + f.requirements, + human_origin_suffix(f.origin.as_ref()) )), None => lines.push("Largest Feature: (none)".to_string()), } @@ -1765,7 +1827,12 @@ pub fn render_stats_human(s: &PortfolioStats) -> String { if !by_feature.is_empty() { let width = by_feature.iter().map(|f| f.name.chars().count()).max().unwrap_or(0) + 4; for f in &by_feature { - lines.push(format!("{}{}", ljust(&f.name, width), f.requirements)); + lines.push(format!( + "{}{}{}", + ljust(&f.name, width), + f.requirements, + human_origin_suffix(f.origin.as_ref()) + )); } } else { lines.push("(none)".to_string()); @@ -1776,7 +1843,11 @@ pub fn render_stats_human(s: &PortfolioStats) -> String { lines.push(String::new()); lines.push(bold(&format!("Invalid Features ({})", invalid.len()))); for f in &invalid { - lines.push(invalid_reason_line(&f.path, &f.error_codes)); + lines.push(invalid_reason_line( + &f.path, + &f.error_codes, + f.origin.as_ref(), + )); } } @@ -1812,7 +1883,11 @@ pub fn render_stats_human(s: &PortfolioStats) -> String { lines.push(String::new()); lines.push(bold(&format!("{invalid_label} ({})", invalid.len()))); for r in invalid { - lines.push(invalid_reason_line(&r.path, &r.error_codes)); + lines.push(invalid_reason_line( + &r.path, + &r.error_codes, + r.origin.as_ref(), + )); } } }; @@ -1838,7 +1913,11 @@ pub fn render_stats_human(s: &PortfolioStats) -> String { "{count} {noun} matched no known artifact schema (not errors — see ADR-010):" )); for u in &s.unrecognized { - lines.push(format!(" {}", u.path)); + lines.push(format!( + " {}{}", + u.path, + human_origin_suffix(u.origin.as_ref()) + )); } } @@ -1922,7 +2001,11 @@ pub fn render_portfolio_human(s: &PortfolioSummary) -> String { } else { yellow("!") }; - lines.push(format!(" {icon} {}", item.identifier)); + lines.push(format!( + " {icon} {}{}", + item.identifier, + human_origin_suffix(item.origin.as_ref()) + )); lines.push(format!(" {}", item.message)); } } else { @@ -1980,11 +2063,12 @@ pub fn render_index_human(index: &crate::index::RepositoryIndex) -> String { let title_w = width(&|e| title_of(e).chars().count()); for e in &index.artifacts { lines.push(format!( - " {} {} {} {}", + " {} {} {} {}{}", ljust(&e.id, id_w), ljust(&e.artifact_type, type_w), ljust(&title_of(e), title_w), - e.path + e.path, + human_origin_suffix(e.origin.as_ref()) )); } lines.join("\n") @@ -2003,6 +2087,9 @@ pub fn render_index_json(index: &crate::index::RepositoryIndex) -> String { m.insert("title".into(), json!(e.title)); m.insert("path".into(), json!(e.path)); m.insert("aliases".into(), json!(e.aliases)); + if let Some(origin) = &e.origin { + m.insert("provenance".into(), artifact_origin_value(origin)); + } Value::Object(m) }) .collect(); @@ -2069,6 +2156,9 @@ pub fn portfolio_summary_value(s: &PortfolioSummary) -> Value { m.insert("severity".into(), json!(item.severity)); m.insert("code".into(), json!(item.code)); m.insert("message".into(), json!(item.message)); + if let Some(origin) = &item.origin { + m.insert("provenance".into(), artifact_origin_value(origin)); + } Value::Object(m) }) .collect(); @@ -2124,7 +2214,12 @@ pub fn render_coverage_human(report: &CoverageReport) -> String { } lines.push(format!("{heading}: {}", members.len())); for gap in members { - lines.push(format!(" {} {}", gap.id, gap.path)); + lines.push(format!( + " {} {}{}", + gap.id, + gap.path, + human_origin_suffix(gap.origin.as_ref()) + )); } lines.push(String::new()); } @@ -2154,6 +2249,9 @@ pub fn render_coverage_json(report: &CoverageReport) -> String { m.insert("type".into(), json!(g.artifact_type)); m.insert("gap".into(), json!(g.gap)); m.insert("missing".into(), json!(g.missing)); + if let Some(origin) = &g.origin { + m.insert("provenance".into(), artifact_origin_value(origin)); + } Value::Object(m) }) .collect(); diff --git a/rust/rac-engine/src/portfolio.rs b/rust/rac-engine/src/portfolio.rs index b94d5229..0f44e984 100644 --- a/rust/rac-engine/src/portfolio.rs +++ b/rust/rac-engine/src/portfolio.rs @@ -32,6 +32,7 @@ pub struct AttentionItem { pub severity: String, pub code: String, pub message: String, + pub origin: Option, } #[derive(Debug)] @@ -91,6 +92,7 @@ pub struct PortfolioRow { validate_issues: Vec, recommended_slots: usize, missing_recommended: Vec, + origin: crate::corpus::ArtifactOrigin, } pub fn portfolio_row(item: &CorpusItem) -> PortfolioRow { @@ -109,6 +111,7 @@ pub fn portfolio_row(item: &CorpusItem) -> PortfolioRow { validate_issues: Vec::new(), recommended_slots: 0, missing_recommended: Vec::new(), + origin: item.origin.clone(), }, Some(spec) => { let (_, missing_rec) = missing_sections(&item.artifact, spec); @@ -122,6 +125,7 @@ pub fn portfolio_row(item: &CorpusItem) -> PortfolioRow { validate_issues: validate(&item.artifact, None, Some(&artifact_type)), recommended_slots: spec.recommended.len(), missing_recommended: missing_rec, + origin: item.origin.clone(), } } } @@ -159,6 +163,48 @@ pub fn portfolio_from_corpus( portfolio_from_rows(directory, &rows, recursive) } +/// Portfolio over the authoritative composed effective view. Relationship +/// metrics and validation come from the same source-aware resolver as lookup +/// and enforcement rather than an items-only overlay. +pub fn portfolio_from_composed( + directory: &str, + corpus: &crate::composition::ComposedCorpus, + recursive: bool, +) -> PortfolioSummary { + let items: Vec = corpus.effective().cloned().collect(); + let overrides = load_overrides(directory); + portfolio_from_corpus_with_analysis( + directory, + &items, + recursive, + &overrides, + corpus.relationship_summary(), + corpus.validate_relationships(directory, recursive).ok(), + ) +} + +/// Local-subject portfolio used by doctor/review. Child rows resolve through +/// the composed catalog, while inherited warnings and advisories remain owned +/// by the parent corpus. +pub fn local_portfolio_from_composed( + directory: &str, + corpus: &crate::composition::ComposedCorpus, + recursive: bool, +) -> PortfolioSummary { + let items: Vec = corpus.local_items().cloned().collect(); + let overrides = load_overrides(directory); + portfolio_from_corpus_with_analysis( + directory, + &items, + recursive, + &overrides, + corpus.local_relationship_summary(), + corpus + .validate_local_relationships(directory, recursive) + .ok(), + ) +} + pub fn portfolio_from_rows( directory: &str, rows: &[PortfolioRow], @@ -176,12 +222,13 @@ pub fn portfolio_from_rows( &overrides, rel_summary, relationships_ok, + false, ) } /// Build a portfolio from the central composed relationship projection and /// the exact child-config snapshot belonging to the generation. -pub(crate) fn portfolio_from_corpus_with_analysis( +pub fn portfolio_from_corpus_with_analysis( directory: &str, items: &[CorpusItem], recursive: bool, @@ -189,7 +236,17 @@ pub(crate) fn portfolio_from_corpus_with_analysis( relationship_summary: RelationshipSummary, relationships_ok: bool, ) -> PortfolioSummary { - let rows: Vec = items.iter().map(portfolio_row).collect(); + let rows: Vec = items + .iter() + .map(|item| { + let mut row = portfolio_row(item); + if item.origin.layer == crate::corpus::Layer::Inherited { + row.validate_issues.clear(); + row.missing_recommended.clear(); + } + row + }) + .collect(); portfolio_from_rows_with_analysis( directory, &rows, @@ -197,6 +254,7 @@ pub(crate) fn portfolio_from_corpus_with_analysis( overrides, relationship_summary, relationships_ok, + true, ) } @@ -207,6 +265,7 @@ fn portfolio_from_rows_with_analysis( overrides: &SeverityOverrides, rel_summary: RelationshipSummary, relationships_ok: bool, + include_provenance: bool, ) -> PortfolioSummary { let mut by_type: Vec<(String, usize)> = @@ -227,6 +286,10 @@ fn portfolio_from_rows_with_analysis( let mut unknown_paths: Vec = Vec::new(); let mut path_to_identifier: std::collections::HashMap = std::collections::HashMap::new(); + let mut artifact_path_to_identifier: std::collections::HashMap< + crate::corpus::ArtifactPath, + String, + > = std::collections::HashMap::new(); for row in rows { bump(&mut by_type, &row.artifact_type); @@ -235,6 +298,10 @@ fn portfolio_from_rows_with_analysis( continue; } path_to_identifier.insert(row.path.clone(), row.identifier.clone()); + artifact_path_to_identifier.insert( + row.validation.artifact_path.clone(), + row.identifier.clone(), + ); let issues = apply_overrides(row.validate_issues.clone(), &row.artifact_type, overrides); if has_errors(&issues) { @@ -250,6 +317,7 @@ fn portfolio_from_rows_with_analysis( severity: "error".to_string(), code: ATTENTION_INVALID.to_string(), message: format!("Validation errors: {}", error_codes.join(", ")), + origin: include_provenance.then(|| row.origin.clone()), }); } else { valid_count += 1; @@ -267,6 +335,7 @@ fn portfolio_from_rows_with_analysis( severity: "warning".to_string(), code: ATTENTION_MISSING_RECOMMENDED.to_string(), message: format!("Missing recommended sections: {}", names.join(", ")), + origin: include_provenance.then(|| row.origin.clone()), }); } } @@ -275,10 +344,19 @@ fn portfolio_from_rows_with_analysis( let source = issue.source_path.clone().unwrap_or_default(); let label = py_title(&issue.relationship.clone().unwrap_or_default().replace('_', " ")); let phrase = rel_issue_phrase(&issue.code); - let identifier = path_to_identifier - .get(&source) - .cloned() - .unwrap_or_else(|| source.clone()); + let identifier = if include_provenance { + issue + .source_artifact + .as_ref() + .and_then(|path| artifact_path_to_identifier.get(path)) + .cloned() + .unwrap_or_else(|| source.clone()) + } else { + path_to_identifier + .get(&source) + .cloned() + .unwrap_or_else(|| source.clone()) + }; attention.push(AttentionItem { path: source, identifier, @@ -288,6 +366,11 @@ fn portfolio_from_rows_with_analysis( "{label} {phrase}: {}", issue.target.clone().unwrap_or_default() ), + origin: if include_provenance { + issue.origin.clone() + } else { + None + }, }); } diff --git a/rust/rac-engine/src/relationships.rs b/rust/rac-engine/src/relationships.rs index bc28463c..a77753fc 100644 --- a/rust/rac-engine/src/relationships.rs +++ b/rust/rac-engine/src/relationships.rs @@ -989,6 +989,7 @@ pub(crate) fn validation_from_rows_with_index( pub struct ArtifactRelationships { pub path: String, pub type_name: String, + pub origin: Option, /// `(snake_section, refs)` in `spec.optional` order. pub relationships: Vec<(String, Vec)>, } @@ -1101,6 +1102,7 @@ fn build_report(directory: &str, items: Vec, recursive: bool) -> Rel artifacts.push(ArtifactRelationships { path: item.path.clone(), type_name: spec.name.clone(), + origin: None, relationships, }); } @@ -1115,6 +1117,65 @@ fn build_report(directory: &str, items: Vec, recursive: bool) -> Rel } } +/// Relationship inspection over the authoritative effective view. Labels +/// resolve through `ComposedCorpus::resolve`, so qualified aliases and +/// canonical override redirects match every other reader. +pub fn build_relationship_report_from_composed( + directory: &str, + recursive: bool, + corpus: &crate::composition::ComposedCorpus, +) -> RelationshipReport { + let items: Vec<&CorpusItem> = corpus.effective().collect(); + let mut artifacts = Vec::new(); + for item in &items { + let Some(spec) = item.spec else { + continue; + }; + let relationships = extract_relationships_full(&item.artifact, spec); + if !relationships.is_empty() { + artifacts.push(ArtifactRelationships { + path: item.path.clone(), + type_name: spec.name.clone(), + origin: Some(item.origin.clone()), + relationships, + }); + } + } + let mut labels = HashMap::new(); + for artifact in &artifacts { + for (_, references) in &artifact.relationships { + for reference in references { + let folded = py_casefold(reference); + if labels.contains_key(&folded) { + continue; + } + let Ok(item) = corpus.resolve(reference) else { + continue; + }; + let type_name = item.spec.map(|spec| spec.name.as_str()).unwrap_or("unknown"); + let display = item + .artifact + .product + .title + .as_deref() + .filter(|title| !title.is_empty()) + .unwrap_or(&item.key.canonical_id); + labels.insert( + folded, + format!("{display} ({type_name} · {})", item.key.canonical_id), + ); + } + } + } + RelationshipReport { + directory: directory.to_string(), + recursive, + total_files: items.len(), + artifacts, + labels, + } +} + pub fn build_relationship_report(directory: &str, recursive: bool) -> RelationshipReport { build_report(directory, corpus_items(directory, recursive), recursive) } diff --git a/rust/rac-engine/src/resolve.rs b/rust/rac-engine/src/resolve.rs index e7001cf4..05897249 100644 --- a/rust/rac-engine/src/resolve.rs +++ b/rust/rac-engine/src/resolve.rs @@ -1148,7 +1148,7 @@ pub fn artifact_status(artifact: &Artifact) -> String { } /// `agent_rules.is_live_decision`: Accepted and not retired. -pub(crate) fn is_live_decision(artifact: &Artifact) -> bool { +pub fn is_live_decision(artifact: &Artifact) -> bool { let status = py_casefold(&artifact_status(artifact)); if status != "accepted" { return false; diff --git a/rust/rac-engine/src/review.rs b/rust/rac-engine/src/review.rs index eecdd1d0..53147892 100644 --- a/rust/rac-engine/src/review.rs +++ b/rust/rac-engine/src/review.rs @@ -120,6 +120,7 @@ pub fn review_from_portfolio( severity, code, message, + origin: _, } = item; let priority = attention_priority(code); let action = if code == ATTENTION_INVALID { @@ -187,6 +188,39 @@ pub fn build_review( report } +pub fn build_review_composed( + directory: &str, + recursive: bool, + stale_after_days: Option, + corpus: &crate::composition::ComposedCorpus, +) -> ReviewReport { + let items: Vec = corpus.local_items().cloned().collect(); + let portfolio = crate::portfolio::local_portfolio_from_composed(directory, corpus, recursive); + let mut report = review_from_portfolio(directory, portfolio, recursive); + let child_source = corpus.child_source(); + let local_relationships: Vec = corpus + .local_relationships() + .into_iter() + .filter(|relationship| { + relationship + .resolved_artifact + .as_ref() + .is_some_and(|path| Some(path.source.as_str()) == child_source) + }) + .collect(); + let mut advisories = drift_findings_from_relationships(directory, &local_relationships); + if let Some(window) = stale_after_days { + if let Some(finding) = cadence_finding(directory, &items, window) { + advisories.push(finding); + } + } + if !advisories.is_empty() { + report.issues.extend(advisories); + sort_issues(&mut report.issues); + } + report +} + // --- git-native drift -------------------------------------------------------- pub(crate) struct DriftRecord { @@ -205,13 +239,20 @@ pub(crate) fn suspect_drift(directory: &str, items: &[CorpusItem]) -> Vec Vec { if resolved.is_empty() { return Vec::new(); } let mut involved: Vec = Vec::new(); let mut seen_paths: std::collections::HashSet = std::collections::HashSet::new(); - for rel in &resolved { + for rel in resolved { for p in [&rel.source_path, rel.resolved_path.as_ref().unwrap()] { if seen_paths.insert(p.clone()) { involved.push(PathBuf::from(p)); @@ -226,7 +267,7 @@ pub(crate) fn suspect_drift(directory: &str, items: &[CorpusItem]) -> Vec = Vec::new(); let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); - for rel in &resolved { + for rel in resolved { let target_path = rel.resolved_path.clone().unwrap(); let source_when = committed.get(&rel.source_path).and_then(|v| v.clone()); let target_when = committed.get(&target_path).and_then(|v| v.clone()); @@ -277,6 +318,25 @@ fn drift_findings(directory: &str, items: &[CorpusItem]) -> Vec { .collect() } +fn drift_findings_from_relationships( + directory: &str, + relationships: &[crate::relationships::Relationship], +) -> Vec { + suspect_drift_from_relationships(directory, relationships) + .into_iter() + .map(|record| ReviewIssue { + priority: PRIORITY_SUSPECT_DRIFT, + severity: "warning".to_string(), + path: record.source_path.clone(), + identifier: crate::identity::path_stem(&record.source_path), + code: REVIEW_SUSPECT_ARTIFACT.to_string(), + message: drift_problem(&record), + action: format!("Run: decided doctor {directory}"), + impact: impact_for(REVIEW_SUSPECT_ARTIFACT).to_string(), + }) + .collect() +} + pub(crate) fn drift_problem(record: &DriftRecord) -> String { format!( "references {} which changed more recently (target last committed {}, this artifact {}) — review recommended", diff --git a/rust/rac-engine/src/stats.rs b/rust/rac-engine/src/stats.rs index 4454f6a4..cc3341e0 100644 --- a/rust/rac-engine/src/stats.rs +++ b/rust/rac-engine/src/stats.rs @@ -5,9 +5,10 @@ //! plus declared relationship-presence counts. Pure and deterministic. use crate::classify::classify; +use crate::corpus::ArtifactOrigin; use crate::parse::Artifact; use crate::pycompat::first_nonempty_line; -use crate::relationships::corpus_items; +use crate::relationships::{corpus_items, CorpusItem}; use crate::spec::{spec_for, ArtifactSpec, RELATIONSHIP_SECTIONS}; use crate::validate::validate; @@ -20,6 +21,8 @@ pub struct FeatureStat { pub requirements: usize, pub success_metrics: usize, pub risks: usize, + /// Present only when the row came from an explicit composed projection. + pub origin: Option, } /// Per-file result for a Decision artifact. @@ -28,6 +31,8 @@ pub struct DecisionStat { pub name: String, pub status: Option, pub category: Option, + /// Present only when the row came from an explicit composed projection. + pub origin: Option, } /// Lightweight validity stat for roadmap/prompt/design. @@ -36,6 +41,8 @@ pub struct ValidityStat { pub name: String, pub valid: bool, pub error_codes: Vec, + /// Present only when the row came from an explicit composed projection. + pub origin: Option, } /// Per-file result for a document that matched no known schema. @@ -43,6 +50,8 @@ pub struct UnrecognizedStat { pub path: String, pub name: String, pub confidence: f64, + /// Present only when the row came from an explicit composed projection. + pub origin: Option, } pub struct PortfolioStats { @@ -319,8 +328,11 @@ fn decision_metadata( (status, category) } -/// `collect_stats(directory)`. -pub fn collect_stats(directory: &str) -> PortfolioStats { +fn collect_stats_from_projection( + directory: &str, + items: &[CorpusItem], + include_origin: bool, +) -> PortfolioStats { let mut stats = PortfolioStats { directory: directory.to_string(), features: Vec::new(), @@ -335,9 +347,10 @@ pub fn collect_stats(directory: &str) -> PortfolioStats { // re-ordered canonically at the end. let mut rel_counts: Vec<(String, usize)> = Vec::new(); - for item in corpus_items(directory, true) { + for item in items { let artifact = &item.artifact; let path = &item.path; + let origin = include_origin.then(|| item.origin.clone()); let name = artifact_name(artifact, path); let classification = classify(artifact); let type_name = classification.artifact_type.as_str(); @@ -361,6 +374,7 @@ pub fn collect_stats(directory: &str) -> PortfolioStats { name, status, category, + origin, }); } "roadmap" => { @@ -370,6 +384,7 @@ pub fn collect_stats(directory: &str) -> PortfolioStats { name, valid: codes.is_empty(), error_codes: codes, + origin, }); } "prompt" => { @@ -379,6 +394,7 @@ pub fn collect_stats(directory: &str) -> PortfolioStats { name, valid: codes.is_empty(), error_codes: codes, + origin, }); } "design" => { @@ -388,6 +404,7 @@ pub fn collect_stats(directory: &str) -> PortfolioStats { name, valid: codes.is_empty(), error_codes: codes, + origin, }); } "unknown" => { @@ -395,6 +412,7 @@ pub fn collect_stats(directory: &str) -> PortfolioStats { path: path.clone(), name, confidence: classification.confidence, + origin, }); } _ => { @@ -407,6 +425,7 @@ pub fn collect_stats(directory: &str) -> PortfolioStats { requirements: artifact.product.requirements.len(), success_metrics: artifact.product.success_metrics.len(), risks: artifact.product.risks.len(), + origin, }); } } @@ -421,6 +440,19 @@ pub fn collect_stats(directory: &str) -> PortfolioStats { stats } +/// Aggregate a caller-selected projection without performing another corpus +/// walk. Federation passes its effective items here so counts and provenance +/// describe the same authoritative overlay as other read consumers. +pub fn collect_stats_from_items(directory: &str, items: &[CorpusItem]) -> PortfolioStats { + collect_stats_from_projection(directory, items, true) +} + +/// `collect_stats(directory)`. +pub fn collect_stats(directory: &str) -> PortfolioStats { + let items = corpus_items(directory, true); + collect_stats_from_projection(directory, &items, false) +} + #[cfg(test)] mod tests { use super::neg_name_gt; diff --git a/rust/rac-engine/tests/federated_cache.rs b/rust/rac-engine/tests/federated_cache.rs index a121ff49..d9705169 100644 --- a/rust/rac-engine/tests/federated_cache.rs +++ b/rust/rac-engine/tests/federated_cache.rs @@ -37,7 +37,7 @@ The cache fixture is deterministic. ## Applies To -- `src/**` +- src/** "#; const PARENT_DECISION: &str = r#"--- @@ -65,7 +65,7 @@ The pin changes with these bytes. ## Applies To -- `src/**` +- src/** "#; fn scratch(tag: &str) -> PathBuf { @@ -134,7 +134,7 @@ fn child_corpus(child: &Path) -> String { fn shared_decision(id: &str) -> String { format!( - "---\nschema_version: 1\nid: {id}\ntype: decision\n---\n# ADR-900: Shared Policy\n\n## Status\n\nAccepted\n\n## Context\n\nEqualmarker context.\n\n## Decision\n\nEqualmarker decision.\n\n## Consequences\n\nEqualmarker consequence.\n\n## Applies To\n\n- `src/**`\n" + "---\nschema_version: 1\nid: {id}\ntype: decision\n---\n# ADR-900: Shared Policy\n\n## Status\n\nAccepted\n\n## Context\n\nEqualmarker context.\n\n## Decision\n\nEqualmarker decision.\n\n## Consequences\n\nEqualmarker consequence.\n\n## Applies To\n\n- src/**\n" ) } @@ -597,3 +597,109 @@ fn no_manifest_keeps_the_single_corpus_content_key() { assert!(generation.verified_parent().is_none()); fs::remove_dir_all(root).unwrap(); } + +#[test] +fn ancillary_readers_share_effective_and_local_composed_projections() { + let (child, _parent, _pin) = fixture("ancillary-readers"); + let directory = child_corpus(&child); + let corpus = rac_engine::federated_corpus::load_composed_corpus(&directory, true) + .unwrap() + .unwrap(); + let effective: Vec<_> = corpus.effective().cloned().collect(); + + let index = rac_engine::index::build_repository_index_from_items(&directory, &effective, true); + assert_eq!(index.artifacts.len(), 2); + assert!(index.artifacts.iter().all(|row| row.origin.is_some())); + let stats = rac_engine::stats::collect_stats_from_items(&directory, &effective); + assert_eq!(stats.decisions.len(), 2); + assert!(stats.decisions.iter().all(|row| row.origin.is_some())); + + let portfolio = rac_engine::portfolio::portfolio_from_composed(&directory, &corpus, true); + assert_eq!(portfolio.total_artifacts(), 2); + let coverage = rac_engine::coverage::analyze_coverage_from_composed(&directory, &corpus); + assert_eq!(coverage.gaps.len(), 2); + assert!(coverage.gaps.iter().all(|gap| gap.origin.is_some())); + let relationships = rac_engine::relationships::build_relationship_report_from_composed( + &directory, + true, + &corpus, + ); + assert_eq!(relationships.total_files, 2); + assert!(relationships + .artifacts + .iter() + .all(|artifact| artifact.origin.is_some())); + + let doctor = rac_engine::doctor::diagnose_composed(&directory, true, 20, &corpus); + assert!(doctor + .findings + .iter() + .all(|finding| finding.path != "parent.md")); + let review = rac_engine::review::build_review_composed(&directory, true, None, &corpus); + assert_eq!(review.portfolio.total_artifacts(), 1); + assert!(review.issues.iter().all(|issue| issue.path != "parent.md")); + + let herald = rac_engine::herald::collect_from_composed( + &directory, + &["src/main.rs".to_string()], + &corpus, + ); + let scope_rows = rac_engine::retrieve::scope_rows_from_items(&effective); + assert_eq!(scope_rows.len(), 2); + assert_eq!(scope_rows[0].scope_entries, vec!["src/**".to_string()]); + assert_eq!(herald.decisions.len(), 2); + let body = rac_engine::herald::render(&herald, "https://example.test/child", 10); + assert!(body.contains("https://example.test/child/child.md")); + assert!(!body.contains("https://example.test/child/parent.md")); + + let proposal = rac_engine::parse::parse_text( + &CHILD_DECISION.replace( + "## Consequences", + "## Related Decisions\n\n- standards::STD-KWJ4VMKVSS66\n\n## Consequences", + ), + "-", + ); + let validation = corpus.validate_proposed_document(&proposal, "-", &directory, true); + assert!(validation.issues.is_empty()); + + fs::remove_dir_all(child).unwrap(); +} + +#[test] +fn composed_portfolio_disambiguates_equal_relative_paths_by_source() { + let (child, parent, _pin) = fixture("portfolio-equal-paths"); + fs::remove_file(child.join("decisions/child.md")).unwrap(); + fs::remove_file(parent.join("decisions/parent.md")).unwrap(); + fs::write( + child.join("decisions/shared.md"), + CHILD_DECISION.replace( + "## Consequences", + "## Related Decisions\n\n- APP-DOES-NOT-EXIST\n\n## Consequences", + ), + ) + .unwrap(); + fs::write(parent.join("decisions/shared.md"), PARENT_DECISION).unwrap(); + let pin = calculate_parent_digest(&parent, "decisions") + .unwrap() + .digest; + write_manifest(&child, &pin, "equal-relative-paths"); + + let directory = child_corpus(&child); + let corpus = rac_engine::federated_corpus::load_composed_corpus(&directory, true) + .unwrap() + .unwrap(); + let portfolio = rac_engine::portfolio::portfolio_from_composed(&directory, &corpus, true); + let relationship = portfolio + .attention + .iter() + .find(|item| item.code == rac_engine::portfolio::ATTENTION_BROKEN_RELATIONSHIP) + .expect("local broken relationship attention"); + assert_eq!(relationship.path, "shared.md"); + assert_eq!(relationship.identifier, "APP-KWJ4VMKVSS65"); + assert_eq!( + relationship.origin.as_ref().map(|origin| origin.source.as_str()), + Some("acme/app") + ); + + fs::remove_dir_all(child).unwrap(); +} diff --git a/rust/rac-engine/tests/source_aware_substrate.rs b/rust/rac-engine/tests/source_aware_substrate.rs index 18b0d660..df4a2823 100644 --- a/rust/rac-engine/tests/source_aware_substrate.rs +++ b/rust/rac-engine/tests/source_aware_substrate.rs @@ -121,6 +121,7 @@ fn no_manifest_keeps_the_released_index_projection_byte_exact() { title: item.artifact.product.title.clone(), path: item.path.clone(), aliases: artifact_identifiers(&item.artifact, item.spec, &item.path), + origin: None, }) .collect(), }; @@ -159,6 +160,41 @@ fn no_manifest_keeps_the_released_index_projection_byte_exact() { let _ = fs::remove_dir_all(root); } +#[test] +fn explicit_item_projections_add_index_and_stats_provenance() { + let root = scratch("read-projections"); + fs::write(root.join("decisions/req-002.md"), REQUIREMENT).unwrap(); + let directory = corpus_arg(&root); + let items = rac_engine::relationships::corpus_items(&directory, true); + + let legacy_index = rac_engine::index::build_repository_index(&directory, true); + assert!(legacy_index.artifacts.iter().all(|row| row.origin.is_none())); + assert!(!rac_engine::output::render_index_json(&legacy_index).contains("provenance")); + let projected_index = + rac_engine::index::build_repository_index_from_items(&directory, &items, true); + assert_eq!( + projected_index.artifacts[0].origin.as_ref(), + Some(&items[0].origin) + ); + assert!(rac_engine::output::render_index_json(&projected_index).contains("provenance")); + assert!(rac_engine::output::render_index_human(&projected_index).contains("acme/app · local")); + + let legacy_stats = rac_engine::stats::collect_stats(&directory); + assert!(legacy_stats.features.iter().all(|row| row.origin.is_none())); + assert!(!rac_engine::output::render_stats_json(&legacy_stats).contains("provenance")); + let projected_stats = rac_engine::stats::collect_stats_from_items(&directory, &items); + let feature = projected_stats.features.first().expect("requirement feature"); + let matching_item = items + .iter() + .find(|item| item.path == feature.path) + .expect("projected feature item"); + assert_eq!(feature.origin.as_ref(), Some(&matching_item.origin)); + assert!(rac_engine::output::render_stats_json(&projected_stats).contains("provenance")); + assert!(rac_engine::output::render_stats_human(&projected_stats).contains("acme/app · local")); + + let _ = fs::remove_dir_all(root); +} + #[test] fn validation_and_relationships_retain_source_aware_endpoints() { let root = scratch("relationships"); From c27b6c4bf303c5949334ce5549c31f6b5ec69ef5 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 30 Aug 2026 08:47:00 +0100 Subject: [PATCH 8/9] fix(mcp): fail closed across federation topology changes Signed-off-by: Tom Ballard --- rust/decided-mcp/src/audit.rs | 122 ++++++-- rust/decided-mcp/src/graph.rs | 240 ++++++++-------- rust/decided-mcp/src/main.rs | 345 +++++++++++++++++------ rust/decided-mcp/src/sidecar.rs | 2 +- rust/decided-mcp/src/tools.rs | 34 +-- rust/decided-mcp/tests/federation.rs | 285 ++++++++++++++++++- rust/decided-mcp/tests/http_transport.rs | 121 ++++++++ rust/rac-engine/src/composition.rs | 54 +++- rust/rac-engine/tests/composition.rs | 67 +++++ 9 files changed, 1012 insertions(+), 258 deletions(-) diff --git a/rust/decided-mcp/src/audit.rs b/rust/decided-mcp/src/audit.rs index 860f8080..c5d44f22 100644 --- a/rust/decided-mcp/src/audit.rs +++ b/rust/decided-mcp/src/audit.rs @@ -254,31 +254,37 @@ fn activation_message(recorder: &Recorder) -> String { ) } -/// Run `call`, record one audit event, and return the payload unchanged -/// (ADR-084: audit is observability outside the response contract). With no -/// recorder this is exactly `call()`. Under `on_write_error: block` a failed -/// write refuses the call with a structured `audit-unavailable` error. -pub fn observe( +/// Result-preserving audit boundary for failures which happen before a tool +/// can produce its structured payload (for example strict parent verification +/// or composition). These failures still emit exactly one event with an empty +/// returned set before the MCP layer serializes the error response. +pub fn observe_result( recorder: Option<&mut Recorder>, request_principal: Option<&str>, tool: &str, args: Value, - call: impl FnOnce() -> String, -) -> String { + call: impl FnOnce() -> Result, +) -> Result { let Some(recorder) = recorder else { return call(); }; let stripped = request_principal.map(str::trim).filter(|s| !s.is_empty()); let asserted = stripped.is_some(); - let principal = stripped.map(str::to_string).unwrap_or_else(|| recorder.principal.clone()); + let principal = stripped + .map(str::to_string) + .unwrap_or_else(|| recorder.principal.clone()); let started = Instant::now(); - let payload = call(); + let result = call(); + let (returned, result_outcome) = match &result { + Ok(payload) => (returned_records(payload), outcome(payload)), + Err(_) => (Vec::new(), "error"), + }; let event = build_event( recorder, tool, args, - returned_records(&payload), - outcome(&payload), + returned, + result_outcome, started, &principal, asserted, @@ -288,9 +294,11 @@ pub fn observe( err.insert("schema_version".into(), Value::String(SCHEMA_VERSION.into())); err.insert("error".into(), Value::String("audit-unavailable".into())); err.insert("tool".into(), Value::String(tool.into())); - return dumps_compact(&Value::Object(err)); + // Preserve the established block-on-write wire behavior: the audit + // refusal is a structured tool payload, not the unrecorded result. + return Ok(dumps_compact(&Value::Object(err))); } - payload + result } #[allow(clippy::too_many_arguments)] @@ -338,6 +346,8 @@ fn outcome(payload: &str) -> &'static str { /// The audit schema deliberately keeps a small reference rather than copying /// result records: `id` is the stable identity, `resolved` records the /// resolution state, and `provenance.path` points back into the served corpus. +/// Federated records add only the fixed source, layer, and pin identity fields +/// authorised by ADR-141; response bodies and optional provenance stay out. /// The extractor covers every result collection emitted by the MCP tools. Raw /// outgoing relationship text is intentionally excluded because it can be an /// unresolved declaration rather than a returned artifact. @@ -365,10 +375,16 @@ fn returned_records(payload: &str) -> Vec { } let mut seen = std::collections::HashSet::new(); records.retain(|record| { - record - .get("id") + let Some(id) = record.get("id").and_then(Value::as_str) else { + return false; + }; + let source = record + .get("provenance") + .and_then(Value::as_object) + .and_then(|provenance| provenance.get("source")) .and_then(Value::as_str) - .is_some_and(|id| seen.insert(id.to_string())) + .unwrap_or(""); + seen.insert((source.to_string(), id.to_string())) }); records } @@ -379,11 +395,22 @@ fn returned_record(object: &Map) -> Option { .get("resolved") .and_then(Value::as_bool) .unwrap_or(true); - let provenance = object - .get("path") - .and_then(Value::as_str) - .map(|path| json!({ "path": path })) - .unwrap_or(Value::Null); + let mut provenance = Map::new(); + if let Some(path) = object.get("path").and_then(Value::as_str) { + provenance.insert("path".to_string(), json!(path)); + } + if let Some(response_provenance) = object.get("provenance").and_then(Value::as_object) { + for key in ["source", "layer", "pin"] { + if let Some(value) = response_provenance.get(key) { + provenance.insert(key.to_string(), value.clone()); + } + } + } + let provenance = if provenance.is_empty() { + Value::Null + } else { + Value::Object(provenance) + }; Some(json!({ "id": id, "resolved": resolved, @@ -503,6 +530,59 @@ mod tests { assert!(returned_records(r#"{"error":{"code":-1},"id":"A"}"#).is_empty()); } + #[test] + fn returned_records_keep_bounded_federation_identity_and_dedupe_by_source() { + let payload = json!({ + "matches": [ + { + "id": "ADR-001", + "path": "decisions/adr-001.md", + "provenance": { + "source": "acme/app", + "layer": "local", + "status": "Accepted", + "status_history": ["must not enter audit"] + } + }, + { + "id": "ADR-001", + "path": "decisions/adr-001.md", + "provenance": { + "source": "acme/standards", + "layer": "inherited", + "pin": "sha256:0123", + "evidence": "must not enter audit" + } + } + ] + }) + .to_string(); + assert_eq!( + returned_records(&payload), + vec![ + json!({ + "id": "ADR-001", + "resolved": true, + "provenance": { + "path": "decisions/adr-001.md", + "source": "acme/app", + "layer": "local" + } + }), + json!({ + "id": "ADR-001", + "resolved": true, + "provenance": { + "path": "decisions/adr-001.md", + "source": "acme/standards", + "layer": "inherited", + "pin": "sha256:0123" + } + }) + ] + ); + } + #[test] fn activation_message_declares_path_scope_and_failure_mode() { let recorder = Recorder { diff --git a/rust/decided-mcp/src/graph.rs b/rust/decided-mcp/src/graph.rs index 691c150c..879d46ab 100644 --- a/rust/decided-mcp/src/graph.rs +++ b/rust/decided-mcp/src/graph.rs @@ -94,16 +94,20 @@ pub struct Neighborhood { pub truncated: bool, } +struct RelationshipProjection { + relationships: Vec, + outgoing_by_source: Vec>, + incoming_by_target: Vec>, + adjacency: Vec>, +} + /// Immutable graph projection for one logical corpus generation. pub struct GraphView { entries: Vec, - relationships: Vec, - aliases: HashMap>, entry_by_path: HashMap, entry_by_artifact_path: HashMap, - outgoing_by_source: Vec>, - incoming_by_target: Vec>, - adjacency: Vec>, + effective_graph: RelationshipProjection, + historical_graph: Option, federated: bool, } @@ -135,11 +139,22 @@ impl GraphView { } pub fn from_composed(corpus: &rac_engine::composition::ComposedCorpus) -> Self { - Self::new(corpus.identity_index(), corpus.catalog_relationships()) + Self::new_with_history( + corpus.identity_index(), + corpus.relationships(), + Some(corpus.catalog_relationships()), + ) } pub fn new(entries: Vec, relationships: Vec) -> Self { - let mut aliases: HashMap> = HashMap::new(); + Self::new_with_history(entries, relationships, None) + } + + fn new_with_history( + entries: Vec, + relationships: Vec, + historical_relationships: Option>, + ) -> Self { let mut entry_by_path = HashMap::with_capacity(entries.len()); let mut entry_by_artifact_path = HashMap::with_capacity(entries.len()); let federated = entries.iter().any(|entry| { @@ -153,19 +168,42 @@ impl GraphView { if let Some(path) = &entry.artifact_path { entry_by_artifact_path.insert(path.clone(), index); } - for alias in &entry.aliases { - let targets = aliases - .entry(rac_engine::pycompat::py_casefold(alias)) - .or_default(); - if !targets.contains(&index) { - targets.push(index); - } - } } - let mut outgoing_by_source = vec![Vec::new(); entries.len()]; - let mut incoming_by_target = vec![Vec::new(); entries.len()]; - let mut adjacency = vec![Vec::new(); entries.len()]; + let effective_graph = Self::relationship_projection( + entries.len(), + &entry_by_path, + &entry_by_artifact_path, + relationships, + ); + let historical_graph = historical_relationships.map(|relationships| { + Self::relationship_projection( + entries.len(), + &entry_by_path, + &entry_by_artifact_path, + relationships, + ) + }); + + Self { + entries, + entry_by_path, + entry_by_artifact_path, + effective_graph, + historical_graph, + federated, + } + } + + fn relationship_projection( + entry_count: usize, + entry_by_path: &HashMap, + entry_by_artifact_path: &HashMap, + relationships: Vec, + ) -> RelationshipProjection { + let mut outgoing_by_source = vec![Vec::new(); entry_count]; + let mut incoming_by_target = vec![Vec::new(); entry_count]; + let mut adjacency = vec![Vec::new(); entry_count]; for (index, relationship) in relationships.iter().enumerate() { let source_index = relationship .source_artifact @@ -200,16 +238,21 @@ impl GraphView { adjacency[target_index].push((source_index, rank)); } - Self { - entries, + RelationshipProjection { relationships, - aliases, - entry_by_path, - entry_by_artifact_path, outgoing_by_source, incoming_by_target, adjacency, - federated, + } + } + + fn graph(&self, historical: bool) -> &RelationshipProjection { + if historical { + self.historical_graph + .as_ref() + .unwrap_or(&self.effective_graph) + } else { + &self.effective_graph } } @@ -223,69 +266,22 @@ impl GraphView { } pub fn resolve(&self, artifact_id: &str) -> ResolutionResult { - use rac_engine::resolve::{OUTCOME_DUPLICATE, OUTCOME_NOT_FOUND, OUTCOME_RESOLVED}; - - let wanted = rac_engine::pycompat::py_casefold(rac_engine::pycompat::py_strip(artifact_id)); - let matches = self.aliases.get(&wanted).map(Vec::as_slice).unwrap_or(&[]); - if matches.is_empty() { - return ResolutionResult { - artifact_id: artifact_id.to_string(), - outcome: OUTCOME_NOT_FOUND, - artifact: None, - duplicate_paths: Vec::new(), - }; - } - if matches.len() > 1 { - let mut paths: Vec = matches - .iter() - .map(|index| { - let entry = &self.entries[*index]; - if self.federated { - if let Some(path) = &entry.artifact_path { - return format!("{}::{}", path.source, path.relative_path); - } - } - entry.path.clone() - }) - .collect(); - paths.sort(); - return ResolutionResult { - artifact_id: artifact_id.to_string(), - outcome: OUTCOME_DUPLICATE, - artifact: None, - duplicate_paths: paths, - }; - } - let entry = &self.entries[matches[0]]; - ResolutionResult { - artifact_id: artifact_id.to_string(), - outcome: OUTCOME_RESOLVED, - artifact: Some(ResolvedArtifact { - key: entry.key.clone(), - artifact_path: entry.artifact_path.clone(), - origin: entry.origin.clone(), - id: entry.id.clone(), - artifact_type: entry.artifact_type.clone(), - title: entry.title.clone(), - path: public_path(entry, self.federated), - section: None, - snippet: None, - evidence: None, - recency: None, - tags: entry.tags.clone(), - }), - duplicate_paths: Vec::new(), - } + rac_engine::resolve::resolve_in_index(&self.entries, artifact_id) } - pub fn outgoing(&self, artifact: &ResolvedArtifact) -> OutgoingReferences { + pub fn outgoing( + &self, + artifact: &ResolvedArtifact, + historical: bool, + ) -> OutgoingReferences { + let graph = self.graph(historical); let indexes = self .entry_index(artifact) - .map(|index| self.outgoing_by_source[index].as_slice()) + .map(|index| graph.outgoing_by_source[index].as_slice()) .unwrap_or(&[]); let mut by_section: Vec<(String, Vec)> = Vec::new(); for index in indexes.iter().take(MAX_RELATED_EDGES) { - let relationship = &self.relationships[*index]; + let relationship = &graph.relationships[*index]; match by_section .iter_mut() .find(|(section, _)| *section == relationship.relationship) @@ -303,15 +299,20 @@ impl GraphView { } } - pub fn incoming(&self, artifact: &ResolvedArtifact) -> IncomingReferences { + pub fn incoming( + &self, + artifact: &ResolvedArtifact, + historical: bool, + ) -> IncomingReferences { + let graph = self.graph(historical); let target_index = self.entry_index(artifact); let indexes = target_index - .map(|index| self.incoming_by_target[index].as_slice()) + .map(|index| graph.incoming_by_target[index].as_slice()) .unwrap_or(&[]); let mut incoming = Vec::new(); let mut total = 0usize; for index in indexes { - let relationship = &self.relationships[*index]; + let relationship = &graph.relationships[*index]; let entry_index = relationship .source_artifact .as_ref() @@ -352,7 +353,13 @@ impl GraphView { } } - pub fn neighborhood(&self, artifact: &ResolvedArtifact, depth: i64) -> Neighborhood { + pub fn neighborhood( + &self, + artifact: &ResolvedArtifact, + depth: i64, + historical: bool, + ) -> Neighborhood { + let graph = self.graph(historical); let depth = depth.clamp(0, MAX_TRAVERSAL_DEPTH); let Some(origin_index) = self.entry_index(artifact) else { return Neighborhood { @@ -375,7 +382,7 @@ impl GraphView { stable_entry_order(&self.entries[*a], &self.entries[*b]) }); for entry_index in &sorted_frontier { - let mut neighbors = self.adjacency[*entry_index].clone(); + let mut neighbors = graph.adjacency[*entry_index].clone(); neighbors.sort_by(|a, b| { stable_entry_order(&self.entries[a.0], &self.entries[b.0]) .then_with(|| a.1.cmp(&b.1)) @@ -440,7 +447,7 @@ impl GraphView { } pub fn relationship_count(&self) -> usize { - self.relationships.len() + self.effective_graph.relationships.len() } pub fn is_federated(&self) -> bool { @@ -470,8 +477,8 @@ impl GraphView { }) }) .sum(); - let relationship_bytes: usize = self - .relationships + let relationship_bytes = |relationships: &[Relationship]| -> usize { + relationships .iter() .map(|relationship| { relationship.source_path.len() @@ -488,29 +495,40 @@ impl GraphView { .as_ref() .map_or(0, |path| path.source.len() + path.relative_path.len()) }) - .sum(); - let map_key_bytes = self.aliases.keys().map(String::len).sum::() - + self.entry_by_path.keys().map(String::len).sum::() + .sum() + }; + let relationship_bytes = relationship_bytes(&self.effective_graph.relationships) + + self.historical_graph.as_ref().map_or(0, |graph| { + relationship_bytes(&graph.relationships) + }); + let map_key_bytes = self.entry_by_path.keys().map(String::len).sum::() + self .entry_by_artifact_path .keys() .map(|path| path.source.len() + path.relative_path.len()) .sum::(); - let vector_payload_bytes = self + let projection_payload = |graph: &RelationshipProjection| { + graph .outgoing_by_source .iter() .map(|indexes| indexes.len() * std::mem::size_of::()) .sum::() - + self + + graph .incoming_by_target .iter() .map(|indexes| indexes.len() * std::mem::size_of::()) .sum::() - + self + + graph .adjacency .iter() .map(|neighbors| neighbors.len() * std::mem::size_of::<(usize, usize)>()) - .sum::(); + .sum::() + }; + let vector_payload_bytes = projection_payload(&self.effective_graph) + + self + .historical_graph + .as_ref() + .map_or(0, projection_payload); entry_bytes + relationship_bytes + map_key_bytes + vector_payload_bytes } } @@ -587,10 +605,10 @@ mod tests { let view = GraphView::new(vec![parent, local], vec![relationship]); assert!(view.is_federated()); - assert_eq!(view.outgoing(&parent_artifact).total, 1); - assert_eq!(view.outgoing(&local_artifact).total, 0); + assert_eq!(view.outgoing(&parent_artifact, false).total, 1); + assert_eq!(view.outgoing(&local_artifact, false).total, 0); - let incoming = view.incoming(&local_artifact); + let incoming = view.incoming(&local_artifact, false); assert_eq!(incoming.total, 1); assert_eq!(incoming.items[0].id, "ADR-PARENT"); assert_eq!(incoming.items[0].path, "decisions/shared.md"); @@ -603,32 +621,6 @@ mod tests { ); } - #[test] - fn federated_ambiguity_paths_remain_source_distinct() { - let mut parent = entry( - CorpusLayer::inherited("acme/standards", "standards", "sha256:0123"), - "ADR-PARENT", - "decisions/shared.md", - "/checkout/vendor/decisions/shared.md", - ); - let mut local = entry( - CorpusLayer::local("acme/app"), - "ADR-LOCAL", - "decisions/shared.md", - "/checkout/decisions/shared.md", - ); - parent.aliases.push("shared-alias".to_string()); - local.aliases.push("shared-alias".to_string()); - let result = GraphView::new(vec![parent, local], Vec::new()).resolve("shared-alias"); - assert_eq!(result.outcome, rac_engine::resolve::OUTCOME_DUPLICATE); - assert_eq!( - result.duplicate_paths, - vec![ - "acme/app::decisions/shared.md".to_string(), - "acme/standards::decisions/shared.md".to_string(), - ] - ); - } } fn identity_projection(entry: &IndexEntry) -> IndexEntry { diff --git a/rust/decided-mcp/src/main.rs b/rust/decided-mcp/src/main.rs index a057b8da..eb344390 100644 --- a/rust/decided-mcp/src/main.rs +++ b/rust/decided-mcp/src/main.rs @@ -20,6 +20,7 @@ use args::{Arg, Kind, Param}; use rac_engine::budget; use serde_json::{json, Map, Value}; use std::io::{BufRead, Write}; +use std::path::{Path, PathBuf}; /// The pinned `tools/list` result — the captured ORACLE-NEXT bytes, embedded /// verbatim (schemas, descriptions, pydantic-shaped titles incl. the @@ -28,6 +29,8 @@ use std::io::{BufRead, Write}; const TOOLS_LIST_RESULT: &str = include_str!("tools_list_result.json"); pub(crate) struct ServerState { + repository_root: PathBuf, + federation_seen: bool, tracker: Option, federated_tracker: Option< rac_engine::derived_cache::FederatedCacheTracker< @@ -187,7 +190,10 @@ fn main() { if !std::path::Path::new(&root).is_dir() { usage_error(&format!("not a directory: {root}")); } - check_corpus(&root); + let mut federation_seen = false; + let topology = repository_topology(&root, None, &mut federation_seen) + .unwrap_or_else(|error| usage_error(&error)); + check_corpus(&root, &topology); // Server-lifetime freshness (ADR-105/118): one tracker per server keeps // the derived read-model current through Linux inotify-clean detection or // the authoritative stat fallback, re-deriving only where files changed. @@ -208,6 +214,8 @@ fn main() { None }; let mut state = ServerState { + repository_root: topology.repository_root, + federation_seen, tracker, federated_tracker, graph_cache: graph::GraphCache::default(), @@ -246,11 +254,10 @@ fn main() { } /// Startup diagnostic (stderr only; declared-normalized in parity, §0). -fn check_corpus(root: &str) { - let has_artifacts = if federation_configured(root) { - let repository_root = rac_engine::validate::repository_root(root); +fn check_corpus(root: &str, topology: &RepositoryTopology) { + let has_artifacts = if topology.federated { let generation = rac_engine::derived_cache::capture_logical_generation( - &repository_root, + &topology.repository_root, root, true, ) @@ -275,10 +282,94 @@ a new repository. The server is running; get_summary will report the empty state } } -fn federation_configured(root: &str) -> bool { - rac_engine::validate::repository_root(root) - .join(rac_engine::federation::MANIFEST_RELATIVE_PATH) - .is_file() +struct RepositoryTopology { + repository_root: PathBuf, + federated: bool, +} + +fn marker_present(path: &Path) -> Result { + match std::fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!( + "parent-corpus-malformed-manifest: cannot inspect repository topology {}: {error}", + path.display() + )), + } +} + +/// Discover and then pin the repository topology used by one server. +/// +/// Startup searches for either governing config or federation manifest. Each +/// request supplies the pinned root, so removing config cannot make discovery +/// jump to another ancestor and silently return to the single-corpus model. +/// Manifest presence is inspected with `symlink_metadata`, then parsed by the +/// strict loader; directories, symlinks (including dangling ones), and races +/// therefore fail closed instead of masquerading as absence. +fn repository_topology( + root: &str, + pinned_root: Option<&Path>, + federation_seen: &mut bool, +) -> Result { + let repository_root = if let Some(pinned) = pinned_root { + pinned.to_path_buf() + } else { + let resolved = std::fs::canonicalize(root).map_err(|error| { + format!("cannot resolve MCP corpus root {}: {error}", Path::new(root).display()) + })?; + let mut selected = None; + for ancestor in resolved.ancestors() { + let config = ancestor.join(rac_engine::federation::CONFIG_RELATIVE_PATH); + let manifest = ancestor.join(rac_engine::federation::MANIFEST_RELATIVE_PATH); + if marker_present(&config)? || marker_present(&manifest)? { + selected = Some(ancestor.to_path_buf()); + break; + } + } + selected.unwrap_or(resolved) + }; + + let manifest_path = repository_root.join(rac_engine::federation::MANIFEST_RELATIVE_PATH); + let present = marker_present(&manifest_path)?; + if present { + // Presence itself is sticky. A malformed addition cannot be removed + // to make the next request fall back to a legacy corpus. + *federation_seen = true; + } + let manifest = rac_engine::federation::load_manifest(&repository_root) + .map_err(|error| error.to_string())?; + let federated = manifest.is_some(); + if *federation_seen && !federated { + return Err(format!( + "parent-corpus-malformed-manifest: federation manifest disappeared after this server observed federation: {}", + manifest_path.display() + )); + } + if *federation_seen { + let config_path = repository_root.join(rac_engine::federation::CONFIG_RELATIVE_PATH); + let metadata = std::fs::symlink_metadata(&config_path).map_err(|error| { + format!( + "parent-corpus-child-config-missing: child config is unavailable after this server observed federation: {}: {error}", + config_path.display() + ) + })?; + if metadata.file_type().is_symlink() { + return Err(format!( + "parent-corpus-symlink-traversal: child config must not be a symlink: {}", + config_path.display() + )); + } + if !metadata.is_file() { + return Err(format!( + "parent-corpus-child-config-missing: child config is not a regular file: {}", + config_path.display() + )); + } + } + Ok(RepositoryTopology { + repository_root, + federated, + }) } fn serve( @@ -523,46 +614,32 @@ fn a_bool(args: &[Arg], i: usize, default: bool) -> bool { } } -fn dispatch( +fn read_request<'a>( root: &str, - state: &mut ServerState, - name: &str, - arguments: &Value, - recorder: Option<&mut audit::Recorder>, - principal: Option<&str>, - server_budget: i64, -) -> Result { - if !matches!( - name, - "get_artifact" - | "search_artifacts" - | "retrieve_grounding" - | "find_decisions" - | "get_related" - | "get_summary" - ) { - return Err(format!("Unknown tool: {name}")); - } - let ServerState { - tracker, - federated_tracker, - graph_cache, - } = state; - // A configured repository enters the verified generation boundary on - // every call. Cache-off skips persistence, never parent verification or - // composition. A failed re-verification returns an error before the - // previously valid model can be observed. - let request = if federation_configured(root) { - let repository_root = rac_engine::validate::repository_root(root); + repository_root: &Path, + federation_seen: &mut bool, + tracker: &'a mut Option, + federated_tracker: &'a mut Option< + rac_engine::derived_cache::FederatedCacheTracker< + rac_engine::composition::ComposedCorpus, + >, + >, +) -> Result, String> { + // Every recognized tool enters the same strict topology boundary after + // its allowlisted arguments have been normalized for audit. Cache-off + // skips persistence only; it never skips topology, parent verification, + // or exact-byte composition. + let topology = repository_topology(root, Some(repository_root), federation_seen)?; + if topology.federated { match federated_tracker.as_mut() { - Some(tracker) => RequestRead::FederatedCached( + Some(tracker) => Ok(RequestRead::FederatedCached( tracker - .read_composed(&repository_root, root, true) + .read_composed(&topology.repository_root, root, true) .map_err(|error| error.to_string())?, - ), + )), None => { let generation = rac_engine::derived_cache::capture_logical_generation( - &repository_root, + &topology.repository_root, root, true, ) @@ -572,14 +649,14 @@ fn dispatch( &generation, ) .map_err(|error| error.to_string())?; - RequestRead::FederatedFresh { + Ok(RequestRead::FederatedFresh { generation, composed: Box::new(composed), - } + }) } } } else { - match tracker.as_mut() { + Ok(match tracker.as_mut() { Some(tracker) => { let (generation, model) = tracker.read_model_with_generation(false); RequestRead::Legacy { @@ -591,9 +668,37 @@ fn dispatch( generation: None, model: None, }, - } - }; - let (generation, model) = request.legacy(); + }) + } +} + +fn dispatch( + root: &str, + state: &mut ServerState, + name: &str, + arguments: &Value, + recorder: Option<&mut audit::Recorder>, + principal: Option<&str>, + server_budget: i64, +) -> Result { + if !matches!( + name, + "get_artifact" + | "search_artifacts" + | "retrieve_grounding" + | "find_decisions" + | "get_related" + | "get_summary" + ) { + return Err(format!("Unknown tool: {name}")); + } + let ServerState { + repository_root, + federation_seen, + tracker, + federated_tracker, + graph_cache, + } = state; // Audit args mirror server.py's per-tool `observed(...)` shapes exactly // (insertion order = recorded key order): non-default arguments ride the // record only when supplied. `sidecar::observe` keeps the telemetry seam @@ -609,9 +714,17 @@ fn dispatch( let effective = tools::effective_budget(server_budget, a_int(&a, 1, 0)); budget::validate_call_budget(effective)?; let audit_args = json!({ "id": a_str(&a, 0, "") }); - Ok(sidecar::observe(name, || { - audit::observe(recorder, principal, name, audit_args, || { - if let Some(corpus) = request.composed() { + sidecar::observe(name, || { + audit::observe_result(recorder, principal, name, audit_args, || { + let request = read_request( + root, + repository_root, + federation_seen, + tracker, + federated_tracker, + )?; + let (_, model) = request.legacy(); + Ok(if let Some(corpus) = request.composed() { tools::get_artifact_composed( root, corpus, @@ -620,9 +733,9 @@ fn dispatch( ) } else { tools::get_artifact(root, model, &a_str(&a, 0, ""), effective) - } + }) }) - })) + }) } "search_artifacts" => { let params = [ @@ -646,9 +759,17 @@ fn dispatch( m.insert("live_only".into(), Value::Bool(true)); } let audit_args = Value::Object(m); - Ok(sidecar::observe(name, || { - audit::observe(recorder, principal, name, audit_args, || { - if let Some(corpus) = request.composed() { + sidecar::observe(name, || { + audit::observe_result(recorder, principal, name, audit_args, || { + let request = read_request( + root, + repository_root, + federation_seen, + tracker, + federated_tracker, + )?; + let (_, model) = request.legacy(); + Ok(if let Some(corpus) = request.composed() { tools::search_artifacts_composed( root, request.cached_model(), @@ -669,9 +790,9 @@ fn dispatch( live_only, server_budget, ) - } + }) }) - })) + }) } "retrieve_grounding" => { let params = [ @@ -704,9 +825,17 @@ fn dispatch( m.insert("live_only".into(), Value::Bool(false)); } let audit_args = Value::Object(m); - Ok(sidecar::observe(name, || { - audit::observe(recorder, principal, name, audit_args, || { - if let Some(corpus) = request.composed() { + sidecar::observe(name, || { + audit::observe_result(recorder, principal, name, audit_args, || { + let request = read_request( + root, + repository_root, + federation_seen, + tracker, + federated_tracker, + )?; + let (_, model) = request.legacy(); + Ok(if let Some(corpus) = request.composed() { tools::retrieve_grounding_composed( root, corpus, &task, &scope, top_k, effective, live_only, ) @@ -714,9 +843,9 @@ fn dispatch( tools::retrieve_grounding( root, model, &task, &scope, top_k, effective, live_only, ) - } + }) }) - })) + }) } "find_decisions" => { let params = [ @@ -732,9 +861,17 @@ fn dispatch( m.insert("path".into(), Value::String(p.clone())); } let audit_args = Value::Object(m); - Ok(sidecar::observe(name, || { - audit::observe(recorder, principal, name, audit_args, || { - if let Some(corpus) = request.composed() { + sidecar::observe(name, || { + audit::observe_result(recorder, principal, name, audit_args, || { + let request = read_request( + root, + repository_root, + federation_seen, + tracker, + federated_tracker, + )?; + let (_, model) = request.legacy(); + Ok(if let Some(corpus) = request.composed() { tools::find_decisions_tool_composed( root, corpus, @@ -750,9 +887,9 @@ fn dispatch( path.as_deref(), server_budget, ) - } + }) }) - })) + }) } "get_related" => { let params = [ @@ -763,23 +900,33 @@ fn dispatch( let id = a_str(&a, 0, ""); let depth = a_int(&a, 1, 1); let audit_args = json!({ "id": id.clone(), "depth": depth }); - let fresh_graph; - let graph_view = if let (Some(corpus), Some(logical)) = - (request.composed(), request.logical_generation()) - { - graph_cache.view_for_composed(logical.cache_key(), corpus) - } else { - match (generation, model) { - (Some(generation), Some(model)) => graph_cache.view_for(generation, model), - _ => { - fresh_graph = graph::GraphView::fresh(root); - &fresh_graph - } - } - }; - Ok(sidecar::observe(name, || { - audit::observe(recorder, principal, name, audit_args, || { - if let Some(corpus) = request.composed() { + sidecar::observe(name, || { + audit::observe_result(recorder, principal, name, audit_args, || { + let request = read_request( + root, + repository_root, + federation_seen, + tracker, + federated_tracker, + )?; + let (generation, model) = request.legacy(); + let fresh_graph; + let graph_view = if let (Some(corpus), Some(logical)) = + (request.composed(), request.logical_generation()) + { + graph_cache.view_for_composed(logical.cache_key(), corpus) + } else { + match (generation, model) { + (Some(generation), Some(model)) => { + graph_cache.view_for(generation, model) + } + _ => { + fresh_graph = graph::GraphView::fresh(root); + &fresh_graph + } + } + }; + Ok(if let Some(corpus) = request.composed() { tools::get_related_composed( graph_view, corpus, @@ -789,17 +936,25 @@ fn dispatch( ) } else { tools::get_related(graph_view, &id, depth, server_budget) - } + }) }) - })) + }) } "get_summary" => { let params: [Param; 0] = []; args::validate(name, "get_summaryArguments", ¶ms, arguments)?; let audit_args = json!({}); - Ok(sidecar::observe(name, || { - audit::observe(recorder, principal, name, audit_args, || { - if let (Some(corpus), Some(generation)) = + sidecar::observe(name, || { + audit::observe_result(recorder, principal, name, audit_args, || { + let request = read_request( + root, + repository_root, + federation_seen, + tracker, + federated_tracker, + )?; + let (_, model) = request.legacy(); + Ok(if let (Some(corpus), Some(generation)) = (request.composed(), request.logical_generation()) { tools::get_summary_composed( @@ -810,9 +965,9 @@ fn dispatch( ) } else { tools::get_summary(root, model, server_budget) - } + }) }) - })) + }) } _ => unreachable!("known tool guard and dispatch arms must stay aligned"), } @@ -863,6 +1018,8 @@ mod tests { #[test] fn unknown_tool_does_not_freshen_tracker() { let mut state = ServerState { + repository_root: PathBuf::from("/definitely-not-a-decided-corpus"), + federation_seen: false, tracker: Some(rac_engine::freshness::FreshnessTracker::new( std::path::PathBuf::from("/definitely-not-a-decided-cache"), "/definitely-not-a-decided-corpus", @@ -892,6 +1049,8 @@ mod tests { std::fs::write(corpus.join("requirement-1.md"), requirement("FIX-0REQ1GRAPH00")).unwrap(); let root = corpus.to_string_lossy().into_owned(); let mut state = ServerState { + repository_root: corpus.clone(), + federation_seen: false, tracker: Some(rac_engine::freshness::FreshnessTracker::new( cache.clone(), &root, diff --git a/rust/decided-mcp/src/sidecar.rs b/rust/decided-mcp/src/sidecar.rs index 6cb65a93..a619980f 100644 --- a/rust/decided-mcp/src/sidecar.rs +++ b/rust/decided-mcp/src/sidecar.rs @@ -11,6 +11,6 @@ //! remains a no-op around the read-only protocol. /// The no-op observation seam: time-and-record hooks would wrap `call` here. -pub fn observe String>(_tool: &str, call: F) -> String { +pub fn observe T>(_tool: &str, call: F) -> T { call() } diff --git a/rust/decided-mcp/src/tools.rs b/rust/decided-mcp/src/tools.rs index 635ad8ff..4df7fda0 100644 --- a/rust/decided-mcp/src/tools.rs +++ b/rust/decided-mcp/src/tools.rs @@ -18,12 +18,6 @@ use rac_engine::resolve::{ }; use serde_json::{json, Map, Value}; -fn federation_enabled(root: &str) -> bool { - rac_engine::validate::repository_root(root) - .join(rac_engine::federation::MANIFEST_RELATIVE_PATH) - .is_file() -} - fn fixed_origin( origin: Option<&rac_engine::corpus::ArtifactOrigin>, enabled: bool, @@ -196,8 +190,7 @@ pub fn get_artifact( payload.insert(k, v); } let status = artifact_status(&rac_engine::parse::parse_text(&content, &artifact.path)); - let include_origin = federation_enabled(root); - let mut prov = fixed_origin(artifact.origin.as_ref(), include_origin).unwrap_or_default(); + let mut prov = fixed_origin(artifact.origin.as_ref(), false).unwrap_or_default(); prov.insert("status".to_string(), json!(status)); if artifact .origin @@ -220,7 +213,7 @@ pub fn get_artifact_composed( artifact_id: &str, budget: i64, ) -> String { - let result = resolve_in_index(&corpus.identity_index(), artifact_id); + let result = corpus.resolve_identity(artifact_id); let Some(artifact) = result .artifact .as_ref() @@ -294,7 +287,7 @@ pub fn search_artifacts( }; rac_engine::commands::annotate_search_recency(&mut result.matches, root); serialize( - &search_result_payload(&result, federation_enabled(root)), + &search_result_payload(&result, false), budget, ) } @@ -350,7 +343,7 @@ pub fn find_decisions_tool( ) -> String { // Python truthiness: a non-empty `path` selects path mode. if let Some(p) = path.filter(|p| !p.is_empty()) { - let include_origin = federation_enabled(root); + let include_origin = false; // Path mode builds through the same read-model as every other tool // (ADR-103), served from precomputed scope rows. let payload = match model { @@ -404,7 +397,7 @@ pub fn find_decisions_tool( ), None => find_decisions(root, topic, true), }; - let mut payload = search_result_payload(&result, federation_enabled(root)); + let mut payload = search_result_payload(&result, false); payload .as_object_mut() .expect("object") @@ -477,7 +470,10 @@ fn get_related_inner( budget: i64, ) -> String { let graph_started = rac_engine::timing::start(); - let result = graph_view.resolve(artifact_id); + let result = corpus.map_or_else( + || graph_view.resolve(artifact_id), + |corpus| corpus.resolve_identity(artifact_id), + ); let Some(artifact) = result .artifact .as_ref() @@ -486,8 +482,14 @@ fn get_related_inner( return serialize(&output::resolution_error_value(&result), budget); }; let include_origin = graph_view.is_federated(); - let outgoing = graph_view.outgoing(artifact); - let incoming_result = graph_view.incoming(artifact); + let historical = corpus.is_some_and(|corpus| { + artifact + .key + .as_ref() + .is_some_and(|key| corpus.is_overridden(key)) + }); + let outgoing = graph_view.outgoing(artifact, historical); + let incoming_result = graph_view.incoming(artifact, historical); let incoming: Vec = incoming_result .items .iter() @@ -527,7 +529,7 @@ fn get_related_inner( payload.insert("incoming".to_string(), Value::Array(incoming)); let mut neighborhood_truncated = false; if depth > 1 { - let hood = graph_view.neighborhood(artifact, depth); + let hood = graph_view.neighborhood(artifact, depth, historical); let nodes: Vec = hood .nodes .iter() diff --git a/rust/decided-mcp/tests/federation.rs b/rust/decided-mcp/tests/federation.rs index 0c2e20e4..96b3b99e 100644 --- a/rust/decided-mcp/tests/federation.rs +++ b/rust/decided-mcp/tests/federation.rs @@ -2,7 +2,7 @@ use serde_json::{json, Value}; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; use std::sync::atomic::{AtomicUsize, Ordering}; static COUNTER: AtomicUsize = AtomicUsize::new(0); @@ -90,6 +90,44 @@ fn tool_value(frame: &Value) -> Value { serde_json::from_str(tool_text(frame)).expect("tool payload JSON") } +fn spawn_live(root: &Path, extra_args: &[&str]) -> (Child, ChildStdin, BufReader) { + let mut child = Command::new(env!("CARGO_BIN_EXE_decided-mcp")) + .arg("--root") + .arg(root) + .args(extra_args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn long-lived decided-mcp"); + let stdin = child.stdin.take().expect("server stdin"); + let stdout = BufReader::new(child.stdout.take().expect("server stdout")); + (child, stdin, stdout) +} + +fn live_call( + stdin: &mut ChildStdin, + stdout: &mut BufReader, + call: &str, +) -> Value { + writeln!(stdin, "{call}").expect("write MCP request"); + stdin.flush().expect("flush MCP request"); + let mut line = String::new(); + stdout.read_line(&mut line).expect("read MCP response"); + serde_json::from_str(line.trim()).expect("MCP response JSON") +} + +fn finish_live(child: Child, stdin: ChildStdin, stdout: BufReader) { + drop(stdin); + drop(stdout); + let output = child.wait_with_output().expect("wait for long-lived server"); + assert!( + output.status.success(), + "server failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn all_six_tools_share_one_verified_composition_with_or_without_cache() { let repository = eval_fixture("six-tools"); @@ -166,6 +204,10 @@ fn decision(id: &str, title: &str) -> String { ) } +fn decision_with_relationships(id: &str, title: &str, relationships: &str) -> String { + format!("{}\n{relationships}\n", decision(id, title)) +} + fn override_fixture() -> PathBuf { let child = scratch("override"); let parent = child.join("vendor/standards"); @@ -180,9 +222,18 @@ fn override_fixture() -> PathBuf { .expect("parent config"); fs::write( parent.join("decisions/parent.md"), - decision("STD-01JY4M8X2QZ7", "Parent Policy"), + decision_with_relationships( + "STD-01JY4M8X2QZ7", + "Parent Policy", + "## Related Decisions\n\n- STD-01JY4M8X2QZA", + ), ) .expect("parent decision"); + fs::write( + parent.join("decisions/target.md"), + decision("STD-01JY4M8X2QZA", "Retained Target"), + ) + .expect("parent target decision"); fs::write( child.join(".decided/config.yaml"), "repository_key: APP\ncorpus:\n source: acme/app\n", @@ -211,6 +262,85 @@ fn override_fixture() -> PathBuf { child } +fn alias_collision_fixture() -> PathBuf { + let child = scratch("alias-collision"); + let parent = child.join("vendor/standards"); + fs::create_dir_all(parent.join(".decided")).expect("parent config directory"); + fs::create_dir_all(parent.join("decisions")).expect("parent corpus directory"); + fs::create_dir_all(child.join(".decided")).expect("child config directory"); + fs::create_dir_all(child.join("decisions")).expect("child corpus directory"); + fs::write( + parent.join(".decided/config.yaml"), + "repository_key: STD\ncorpus:\n source: acme/standards\n", + ) + .expect("parent config"); + fs::write( + parent.join("decisions/shared.md"), + decision("STD-01JY4M8X2QZ7", "Parent Shared Policy"), + ) + .expect("parent shared decision"); + fs::write( + child.join(".decided/config.yaml"), + "repository_key: APP\ncorpus:\n source: acme/app\n", + ) + .expect("child config"); + fs::write( + child.join("decisions/shared.md"), + decision("APP-01JY4M8X2QZ8", "Local Shared Policy"), + ) + .expect("local shared decision"); + let digest = rac_engine::federation::calculate_parent_digest(&parent, "decisions") + .expect("calculate parent digest") + .digest; + fs::write( + child.join(".decided/corpus.md"), + format!( + "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: standards\nsource: acme/standards\nroot: vendor/standards\ncorpus: decisions\ndigest: {digest}\n```\n" + ), + ) + .expect("child manifest"); + child +} + +#[test] +fn composed_exact_tools_share_source_aware_ambiguity_and_qualification() { + let repository = alias_collision_fixture(); + let corpus = repository.join("decisions"); + let frames = run( + &corpus, + &["--no-cache"], + &[ + request(1, "get_artifact", json!({"id": "shared"})), + request(2, "get_related", json!({"id": "shared"})), + request(3, "get_artifact", json!({"id": "standards::shared"})), + request( + 4, + "get_artifact", + json!({"id": "other::STD-01JY4M8X2QZ7"}), + ), + request( + 5, + "get_artifact", + json!({"id": "standards::STD-01JY4M8X2QZ7"}), + ), + ], + ); + let artifact_duplicate = tool_value(&frames[0]); + let related_duplicate = tool_value(&frames[1]); + let expected_paths = json!([ + "acme/app::shared.md", + "acme/standards::shared.md" + ]); + assert_eq!(artifact_duplicate["error"], json!("duplicate")); + assert_eq!(related_duplicate["error"], json!("duplicate")); + assert_eq!(artifact_duplicate["paths"], expected_paths); + assert_eq!(related_duplicate["paths"], expected_paths); + assert_eq!(tool_value(&frames[2])["error"], json!("not-found")); + assert_eq!(tool_value(&frames[3])["error"], json!("not-found")); + assert_eq!(tool_value(&frames[4])["id"], json!("STD-01JY4M8X2QZ7")); + fs::remove_dir_all(repository).expect("remove alias collision fixture"); +} + #[test] fn qualified_history_and_canonical_redirect_keep_complete_override_provenance() { let repository = override_fixture(); @@ -225,6 +355,13 @@ fn qualified_history_and_canonical_redirect_keep_complete_override_provenance() json!({"id": "standards::STD-01JY4M8X2QZ7"}), ), request(2, "get_artifact", json!({"id": "STD-01JY4M8X2QZ7"})), + request(3, "get_related", json!({"id": "STD-01JY4M8X2QZ7", "depth": 2})), + request(4, "get_related", json!({"id": "STD-01JY4M8X2QZA", "depth": 2})), + request( + 5, + "get_related", + json!({"id": "standards::STD-01JY4M8X2QZ7", "depth": 2}), + ), ], ); let parent = tool_value(&frames[0]); @@ -237,6 +374,26 @@ fn qualified_history_and_canonical_redirect_keep_complete_override_provenance() assert_eq!(mapping["parent"]["source"], json!("acme/standards")); assert_eq!(mapping["replacement"]["source"], json!("acme/app")); assert_eq!(mapping["rationale"]["id"], json!("APP-01JY4M8X2QZ9")); + + let redirected_graph = tool_value(&frames[2]); + assert_eq!(redirected_graph["id"], json!("APP-01JY4M8X2QZ8")); + assert!(redirected_graph["outgoing"] + .get("related_decisions") + .is_none()); + + let target_graph = tool_value(&frames[3]); + assert!(target_graph["incoming"] + .as_array() + .unwrap() + .iter() + .all(|entry| entry["id"] != json!("STD-01JY4M8X2QZ7"))); + + let parent_history = tool_value(&frames[4]); + assert_eq!(parent_history["id"], json!("STD-01JY4M8X2QZ7")); + assert_eq!( + parent_history["outgoing"]["related_decisions"], + json!(["STD-01JY4M8X2QZA"]) + ); fs::remove_dir_all(repository).expect("remove override fixture"); } @@ -339,3 +496,127 @@ fn stale_parent_blocks_the_next_request_instead_of_serving_the_old_generation() ); fs::remove_dir_all(repository).expect("remove stale-parent fixture"); } + +#[test] +fn deleting_child_config_after_federation_is_seen_fails_closed() { + let repository = eval_fixture("deleted-config"); + let corpus = repository.join("decisions"); + let (child, mut stdin, mut stdout) = spawn_live(&corpus, &[]); + + let first = live_call( + &mut stdin, + &mut stdout, + &request(1, "get_summary", json!({})), + ); + assert_eq!(first["result"]["isError"], json!(false)); + + fs::remove_file(repository.join(".decided/config.yaml")).expect("remove child config"); + let second = live_call( + &mut stdin, + &mut stdout, + &request(2, "get_summary", json!({})), + ); + assert_eq!(second["result"]["isError"], json!(true)); + assert!(tool_text(&second).contains("parent-corpus-child-config-missing")); + assert!(!tool_text(&second).contains("\"total\":41")); + + finish_live(child, stdin, stdout); + fs::remove_dir_all(repository).expect("remove deleted-config fixture"); +} + +#[test] +fn non_regular_or_dangling_manifest_never_falls_back_to_legacy() { + let repository = eval_fixture("non-file-manifest"); + let corpus = repository.join("decisions"); + let manifest = repository.join(".decided/corpus.md"); + let (child, mut stdin, mut stdout) = spawn_live(&corpus, &["--no-cache"]); + + let first = live_call( + &mut stdin, + &mut stdout, + &request(1, "get_summary", json!({})), + ); + assert_eq!(first["result"]["isError"], json!(false)); + + fs::remove_file(&manifest).expect("remove manifest"); + fs::create_dir(&manifest).expect("replace manifest with directory"); + let directory_response = live_call( + &mut stdin, + &mut stdout, + &request(2, "get_summary", json!({})), + ); + assert_eq!(directory_response["result"]["isError"], json!(true)); + assert!(tool_text(&directory_response).contains("parent-corpus-symlink-traversal")); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + fs::remove_dir(&manifest).expect("remove manifest directory"); + symlink("missing-corpus-manifest.md", &manifest).expect("create dangling manifest"); + let dangling_response = live_call( + &mut stdin, + &mut stdout, + &request(3, "get_summary", json!({})), + ); + assert_eq!(dangling_response["result"]["isError"], json!(true)); + assert!(tool_text(&dangling_response).contains("parent-corpus-symlink-traversal")); + } + + finish_live(child, stdin, stdout); + fs::remove_dir_all(repository).expect("remove non-file-manifest fixture"); +} + +#[test] +fn a_manifest_added_to_a_live_server_activates_and_cannot_be_removed() { + let repository = eval_fixture("topology-add-remove"); + let corpus = repository.join("decisions"); + let manifest_path = repository.join(".decided/corpus.md"); + let manifest = fs::read(&manifest_path).expect("read manifest before startup"); + fs::remove_file(&manifest_path).expect("start without manifest"); + let (child, mut stdin, mut stdout) = spawn_live(&corpus, &[]); + + let legacy = live_call( + &mut stdin, + &mut stdout, + &request( + 1, + "search_artifacts", + json!({"query": "quantum ledger compaction"}), + ), + ); + assert_eq!(legacy["result"]["isError"], json!(false)); + assert!(tool_value(&legacy)["matches"] + .as_array() + .unwrap() + .iter() + .all(|item| item["id"] != json!("FEDEVAL-000000000001"))); + + fs::write(&manifest_path, &manifest).expect("add manifest"); + let federated = live_call( + &mut stdin, + &mut stdout, + &request( + 2, + "search_artifacts", + json!({"query": "quantum ledger compaction"}), + ), + ); + assert_eq!(federated["result"]["isError"], json!(false)); + assert_eq!( + tool_value(&federated)["matches"][0]["id"], + json!("FEDEVAL-000000000001") + ); + + fs::remove_file(&manifest_path).expect("remove observed manifest"); + let removed = live_call( + &mut stdin, + &mut stdout, + &request(3, "get_summary", json!({})), + ); + assert_eq!(removed["result"]["isError"], json!(true)); + assert!(tool_text(&removed).contains("federation manifest disappeared")); + assert!(!tool_text(&removed).contains("FEDEVAL-000000000001")); + + finish_live(child, stdin, stdout); + fs::remove_dir_all(repository).expect("remove topology fixture"); +} diff --git a/rust/decided-mcp/tests/http_transport.rs b/rust/decided-mcp/tests/http_transport.rs index 3b7764f5..138d90da 100644 --- a/rust/decided-mcp/tests/http_transport.rs +++ b/rust/decided-mcp/tests/http_transport.rs @@ -109,6 +109,80 @@ impl Server { panic!("HTTP server did not start"); } + fn start_federated(tag: &str) -> Self { + let corpus = scratch(tag); + let parent = corpus.join("vendor/standards"); + let audit_path = corpus.join("audit.jsonl"); + std::fs::create_dir_all(corpus.join(".decided")).unwrap(); + std::fs::create_dir_all(corpus.join("decisions")).unwrap(); + std::fs::create_dir_all(parent.join(".decided")).unwrap(); + std::fs::create_dir_all(parent.join("decisions")).unwrap(); + std::fs::write( + corpus.join(".decided/config.yaml"), + format!( + "repository_key: APP\ncorpus:\n source: acme/app\naudit:\n enabled: true\n path: {}\n", + audit_path.display() + ), + ) + .unwrap(); + std::fs::write( + parent.join(".decided/config.yaml"), + "repository_key: STD\ncorpus:\n source: acme/standards\n", + ) + .unwrap(); + std::fs::write( + parent.join("decisions/parent.md"), + "---\nschema_version: 1\nid: STD-01JY4M8X2QZ7\ntype: decision\n---\n# Parent Audit Policy\n\n## Status\n\nAccepted\n\n## Context\n\nAudit parent context.\n\n## Decision\n\nKeep federation auditable.\n\n## Consequences\n\nFailures are recorded.\n", + ) + .unwrap(); + let digest = rac_engine::federation::calculate_parent_digest(&parent, "decisions") + .unwrap() + .digest; + std::fs::write( + corpus.join(".decided/corpus.md"), + format!( + "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: standards\nsource: acme/standards\nroot: vendor/standards\ncorpus: decisions\ndigest: {digest}\n```\n" + ), + ) + .unwrap(); + + let port = TcpListener::bind(("127.0.0.1", 0)) + .unwrap() + .local_addr() + .unwrap() + .port(); + let root = corpus.join("decisions").to_string_lossy().into_owned(); + let port_text = port.to_string(); + let child = Command::new(env!("CARGO_BIN_EXE_decided-mcp")) + .args([ + "--root", + &root, + "--no-cache", + "--transport", + "http", + "--host", + "127.0.0.1", + "--port", + &port_text, + ]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn federated HTTP server"); + let server = Self { + child, + corpus, + port, + }; + for _ in 0..100 { + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + return server; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("federated HTTP server did not start"); + } + fn post(&self, body: &Value, method_header: &str, name: Option<&str>) -> (String, Value) { self.post_with_version_and_origin(body, method_header, name, CURRENT_VERSION, None) } @@ -552,6 +626,53 @@ fn http_audit_records_every_result_collection() { assert!(returned[6].is_empty()); } +#[test] +fn stale_parent_failure_is_audited_once_before_http_error_response() { + let _guard = serial_http_test(); + let server = Server::start_federated("http-audit-stale-parent"); + let arguments = json!({"query": "parent audit policy"}); + + let (first_status, first) = server.post_with_principal_headers( + &tool_call(1, "search_artifacts", arguments.clone()), + "tools/call", + Some("search_artifacts"), + &[("X-AsDecided-Principal", "alice@example.com")], + ); + assert_eq!(first_status, "HTTP/1.1 200 OK"); + assert_eq!(first["result"]["isError"], json!(false)); + + std::fs::write( + server.corpus.join("vendor/standards/decisions/parent.md"), + "changed after the verified generation\n", + ) + .expect("mutate verified parent"); + let before = server.audit_events().len(); + let (second_status, second) = server.post_with_principal_headers( + &tool_call(2, "search_artifacts", arguments), + "tools/call", + Some("search_artifacts"), + &[("X-AsDecided-Principal", "alice@example.com")], + ); + assert_eq!(second_status, "HTTP/1.1 200 OK"); + assert_eq!(second["result"]["isError"], json!(true)); + assert!(second["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("parent-corpus-digest-mismatch")); + + let events = server.audit_events(); + assert_eq!(events.len(), before + 1, "failure records exactly one event"); + let failure = events.last().unwrap(); + assert_eq!(failure["tool"], json!("search_artifacts")); + assert_eq!(failure["query"], json!({ + "query": "parent audit policy", + "type": null + })); + assert_eq!(failure["outcome"], json!("error")); + assert_eq!(failure["returned"], json!([])); + assert_eq!(failure["principal"], json!("alice@example.com")); +} + #[test] fn http_principal_is_explicit_and_response_independent() { let _guard = serial_http_test(); diff --git a/rust/rac-engine/src/composition.rs b/rust/rac-engine/src/composition.rs index 8b0633e1..e13936a1 100644 --- a/rust/rac-engine/src/composition.rs +++ b/rust/rac-engine/src/composition.rs @@ -16,7 +16,10 @@ use crate::relationships::{ validation_row_from_item, CorpusItem, Relationship, RelationshipSummary, RelationshipValidation, ResolutionCandidate, ResolutionIndex, ValidationRow, }; -use crate::resolve::{entry_from_item, identity_entry_from_item, is_live_decision, IndexEntry}; +use crate::resolve::{ + entry_from_item, identity_entry_from_item, is_live_decision, resolved_from_entry, IndexEntry, + ResolutionResult, OUTCOME_DUPLICATE, OUTCOME_NOT_FOUND, OUTCOME_RESOLVED, +}; pub const FINDING_CANONICAL_COLLISION: &str = "cross-corpus-canonical-id-collision"; pub const FINDING_INVALID_OVERRIDE: &str = "cross-corpus-invalid-override"; @@ -668,6 +671,55 @@ impl ComposedCorpus { } } + /// Resolve one authored reference into the public exact-lookup shape. + /// + /// This adapter deliberately begins at [`Self::resolve`] rather than the + /// flattened identity projection. That preserves the canonical-only + /// contract for qualified parent references: a legacy or filename alias + /// containing `::` cannot bypass `validate_qualified_reference`. + /// Ambiguous paths include the owning source because two legal corpus + /// layers may carry the same corpus-relative path. + pub fn resolve_identity(&self, reference: &str) -> ResolutionResult { + match self.resolve(reference) { + Ok(item) => ResolutionResult { + artifact_id: reference.to_string(), + outcome: OUTCOME_RESOLVED, + artifact: Some(resolved_from_entry(&identity_entry_from_item(item))), + duplicate_paths: Vec::new(), + }, + Err(LookupError::Ambiguous(keys)) => { + let mut paths: Vec = keys + .iter() + .filter_map(|key| self.item(key)) + .map(|item| { + format!( + "{}::{}", + item.artifact_path.source, item.artifact_path.relative_path + ) + }) + .collect(); + paths.sort(); + paths.dedup(); + ResolutionResult { + artifact_id: reference.to_string(), + outcome: OUTCOME_DUPLICATE, + artifact: None, + duplicate_paths: paths, + } + } + Err( + LookupError::NotFound + | LookupError::InvalidQualifiedReference + | LookupError::QualifiedCanonicalRequired, + ) => ResolutionResult { + artifact_id: reference.to_string(), + outcome: OUTCOME_NOT_FOUND, + artifact: None, + duplicate_paths: Vec::new(), + }, + } + } + fn validate_qualified_reference(&self, reference: &str) -> Result<(), LookupError> { let Some((alias, canonical_id)) = reference.split_once("::") else { return Err(LookupError::InvalidQualifiedReference); diff --git a/rust/rac-engine/tests/composition.rs b/rust/rac-engine/tests/composition.rs index a60a00e9..41e7afbf 100644 --- a/rust/rac-engine/tests/composition.rs +++ b/rust/rac-engine/tests/composition.rs @@ -87,6 +87,37 @@ Composition needs one resolver. ) } +fn item_with_canonical_and_legacy( + relative_path: &str, + canonical_id: &str, + legacy_id: &str, +) -> CorpusItem { + let text = format!( + "---\nschema_version: 1\nid: {canonical_id}\ntype: decision\n---\n# Qualified fixture\n\n## ID\n\n{legacy_id}\n\n## Status\n\nAccepted\n\n## Context\n\nComposition fixture.\n\n## Decision\n\nKeep qualification canonical.\n\n## Consequences\n\nAliases cannot bypass qualification.\n" + ); + let origin = CorpusLayer::inherited( + PARENT_SOURCE, + PARENT_ALIAS, + "sha256:0123456789abcdef", + ) + .origin(); + let display = format!("/runtime/{PARENT_SOURCE}/{relative_path}"); + CorpusItem::new( + display.clone(), + relative_path.to_string(), + parse_text(&text, &display), + spec_for("decision"), + origin, + PhysicalArtifactLocator::new( + PhysicalCorpusLocator::new( + format!("/runtime/{PARENT_SOURCE}"), + format!("/runtime/{PARENT_SOURCE}/decisions"), + ), + display, + ), + ) +} + fn declaration(parent_id: &str, replacement: &str, rationale: &str) -> OverrideDeclaration { OverrideDeclaration::parse( &format!("{PARENT_ALIAS}::{parent_id}"), @@ -178,6 +209,15 @@ fn aliases_are_unique_only_and_qualification_requires_a_canonical_id() { corpus.resolve("shared"), Err(LookupError::Ambiguous(keys)) if keys.len() == 2 )); + let duplicate = corpus.resolve_identity("shared"); + assert_eq!(duplicate.outcome, rac_engine::resolve::OUTCOME_DUPLICATE); + assert_eq!( + duplicate.duplicate_paths, + vec![ + "acme/app::shared.md".to_string(), + "acme/standards::shared.md".to_string(), + ] + ); assert_eq!( lookup_error(&corpus, "standards::shared"), LookupError::QualifiedCanonicalRequired @@ -190,6 +230,33 @@ fn aliases_are_unique_only_and_qualification_requires_a_canonical_id() { assert_eq!(resolved.key, ArtifactKey::new(PARENT_SOURCE, "STD-001")); } +#[test] +fn qualified_resolution_rejects_legacy_and_unknown_aliases() { + let inherited = item_with_canonical_and_legacy( + "qualified.md", + "STD-KWJ4VMKVSS66", + "standards::legacy-name", + ); + let corpus = ComposedCorpus::compose(Vec::new(), vec![inherited], parent(), Vec::new()); + + assert_eq!( + corpus.resolve_identity("standards::STD-KWJ4VMKVSS66").outcome, + rac_engine::resolve::OUTCOME_RESOLVED + ); + for invalid in [ + "standards::legacy-name", + "other::STD-KWJ4VMKVSS66", + "standards::UNKNOWN", + "standards::STD::CANONICAL", + ] { + assert_eq!( + corpus.resolve_identity(invalid).outcome, + rac_engine::resolve::OUTCOME_NOT_FOUND, + "{invalid} must not bypass qualified canonical validation" + ); + } +} + #[test] fn a_valid_override_redirects_only_the_parent_canonical_id_and_retains_history() { let replacement = item( From ab8e7aaec0c4896ae82b9369f2fbb1b01234490a Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 30 Aug 2026 08:47:01 +0100 Subject: [PATCH 9/9] feat(export): compose inherited corpus projections Signed-off-by: Tom Ballard --- docs/cli.md | 10 +- docs/export-contracts.md | 55 +- rac-localview/VIEWER_CONTRACT.md | 29 +- rac-localview/src/viewer/App.tsx | 9 +- rac-localview/src/viewer/DetailView.tsx | 18 +- rac-localview/src/viewer/ListView.tsx | 6 +- rac-localview/src/viewer/data.ts | 89 ++- rac-localview/src/viewer/graph.ts | 21 +- rac-localview/src/viewer/types.ts | 28 +- rac-localview/test/App.test.tsx | 16 +- rac-localview/test/federation.test.ts | 61 ++ rac-localview/test/fixtures.ts | 155 +++++ rust/decided/tests/cli.rs | 47 ++ .../portal/asdecided-portal-legacy-shell.html | 74 +++ .../assets/portal/asdecided-portal-shell.html | 18 +- .../schemas/export-documents-v1.schema.json | 109 ++++ .../schemas/export-graph-v1.schema.json | 125 ++++ .../schemas/export-viewer-v1.schema.json | 125 ++++ rust/rac-engine/src/cli.rs | 3 + rust/rac-engine/src/commands.rs | 85 ++- rust/rac-engine/src/export.rs | 508 ++++++++++++++- rust/rac-engine/src/federated_corpus.rs | 138 +++- rust/rac-engine/src/output.rs | 47 +- rust/rac-engine/src/portal.rs | 32 +- .../tests/federated_export_emission.rs | 600 ++++++++++++++++++ rust/rac-engine/tests/federation_loader.rs | 50 ++ rust/rac-engine/tests/okf_v02.rs | 4 + rust/tools/export_schema_contracts.py | 306 ++++++++- 28 files changed, 2643 insertions(+), 125 deletions(-) create mode 100644 rac-localview/test/federation.test.ts create mode 100644 rust/rac-engine/assets/portal/asdecided-portal-legacy-shell.html create mode 100644 rust/rac-engine/tests/federated_export_emission.rs diff --git a/docs/cli.md b/docs/cli.md index e8a54236..3173d47a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -845,17 +845,25 @@ artifacts — existing output is overwritten. - **Input:** `decided export [directory]` — scanned recursively for `*.md` (default: current directory). - **Modes:** *(default)* viewer JSON to stdout · `--html` (self-contained Portal file) · `--okf` (OKF v0.2 Markdown bundle) · `--documents` (JSONL for memory/RAG backends) · `--graph` (typed node+edge JSON for graph backends) · `--schema ` (the packaged JSON Schema, without reading a corpus) · `--agent-rules` (per-client agent-context files; see its own behaviour) -- **Options:** `--out ` (only `--html`/`--okf`/`--agent-rules`; the stdout modes are pipeable) · `--json` (no-op for the default mode) +- **Options:** `--out ` (only `--html`/`--okf`/`--agent-rules`; the stdout modes are pipeable) · `--json` (no-op for the default mode) · `--local-only` (viewer/HTML, documents, and graph projections only) - **Exit codes:** `0` success · `2` not a directory, or `--out` given to a stdout mode ```bash decided export decisions/ # viewer JSON to stdout decided export decisions/ --documents # JSONL, one record per artifact decided export decisions/ --graph # typed node+edge graph +decided export decisions/ --local-only # writable child records only decided export --schema documents # Draft 2020-12 record schema decided export decisions/ --html --out asdecided.html ``` +When `.decided/corpus.md` declares a verified parent, viewer, documents, and +graph exports include inherited records by default. Records and edges carry +their own source, layer, and verified-pin provenance; explicit overrides retain +both the parent history and local replacement. `--local-only` is a human +diagnostic/export projection of the writable child records. OKF bundles and +generated agent rules remain local-only and do not accept the flag. + The three machine-readable payload contracts, compatibility rules, and direct schema links are documented on the [Export contracts](export-contracts.md) page. diff --git a/docs/export-contracts.md b/docs/export-contracts.md index eea0f42c..5a4bf100 100644 --- a/docs/export-contracts.md +++ b/docs/export-contracts.md @@ -29,8 +29,10 @@ The default `decided export` projection is one JSON object containing: - `schema_version` - `corpus`: `name`, `source`, `rac_version`, and `artifact_count` - `artifacts[]`: `id`, `aliases`, `type`, `status`, `title`, `path`, and - `body_html` -- `relationships[]`: `from`, `to`, and the flattened `relates-to` `type` + `body_html`; manifest-backed exports add record `provenance` +- `relationships[]`: `from`, `to`, and the flattened `relates-to` `type`; + manifest-backed exports add source-aware `from_identity`, `to_identity`, and + edge `provenance` `rac_version` is a retained v1 machine key. It carries the version of the AsDecided CLI that produced the payload; it is not a current product or command @@ -47,7 +49,8 @@ non-empty line is independently validated against the documents schema and contains: - `schema_version`, `id`, `type`, `status`, `title`, and Markdown `text` -- `metadata`: `path`, `aliases`, `tags`, and `source` +- `metadata`: `path`, `aliases`, `tags`, and the record-owning `source`; + manifest-backed exports add `provenance` The schema describes one line. A consumer should split the UTF-8 stream on line boundaries and validate each record separately. @@ -57,7 +60,9 @@ boundaries and validate each record separately. `decided export --graph` is one JSON object containing `schema_version`, `source`, `nodes`, and `edges`. Nodes carry `id`, `type`, `status`, and `title`. Edges carry `source`, `target`, `type`, `directed`, `resolved`, `external`, and -nullable `provider` provenance. +nullable `provider` provenance. Manifest-backed nodes and edges add +`provenance`; edges also add source-aware `source_identity` and nullable +`target_identity` objects alongside the retained ID fields. The graph edge `type` is the engine's real relationship kind. It is not the viewer projection's flattened `relates-to` value. @@ -85,11 +90,18 @@ For a non-federated export, AsDecided derives the source in this order: 2. the lower-case `repository_key`; 3. the existing corpus-directory basename when neither value is configured. -The viewer exposes the value as `corpus.source`. Documents records expose it -as `metadata.source`; the graph exposes it as its top-level `source`. A graph -edge's own `source` field remains the source *node ID* and is not corpus -provenance. `corpus.name` remains the existing display value and is not an -identity. +The viewer exposes the child value as `corpus.source`. Documents records expose +their owning value as `metadata.source`; the graph retains the child value as +its top-level `source`. In a manifest-backed export, each record's `provenance` +object carries its own `source` and `layer`, plus the full verified `pin` for an +inherited record. A graph edge's existing `source` field remains the source +*node ID* and is not corpus provenance. `corpus.name` remains the existing +display value and is not an identity. + +Override provenance is an ordered `provenance.overrides` array. Each entry +names its `overridden` or `replacement` role and the source-aware `parent`, +`replacement`, and live local `rationale` identities. The original inherited +record and its local replacement are both exported. The repository key continues to namespace newly generated artifact IDs. It is not globally unique, and different corpora may legitimately use the same key. @@ -99,11 +111,10 @@ never relies on either fallback. ## Aggregating corpora Consumers aggregate documents streams by concatenating their records and -keying each artifact on `(metadata.source, id)`. They aggregate graph exports -by lifting the graph's top-level source onto every node: a node key is -`(graph.source, node.id)`, and each edge endpoint is resolved in that same -namespace before the node and edge sets are unioned. The viewer's -`corpus.source` provides the equivalent namespace for its artifacts. +keying each artifact on `(metadata.source, id)`. For a manifest-backed viewer +or graph export, use `(provenance.source, id)` on every artifact or node and the +explicit source-aware identity objects on edges. The top-level child source +remains the fallback namespace for a non-federated payload. Configure distinct explicit sources whenever repository-key or basename fallbacks could collide. Source identity alone does not make cross-corpus @@ -116,6 +127,10 @@ canonical ID, record body, and verified pin all agree. A different body or pin for the same `(source, id)` is an aggregation conflict, never a last-writer-wins update. +Viewer, documents, and graph exports include the inherited layer by default. +`--local-only` requests the child projection for these modes only. OKF bundles +and generated agent rules remain local-only in the first federation increment. + ### Migration from basename sources Before this contract, documents and graph exports stamped the corpus-directory @@ -135,8 +150,10 @@ All schema objects allow unknown additional properties. This is intentional: an additive producer release must remain readable by an existing consumer. Consumers should ignore fields they do not understand. -Every field emitted today is nevertheless declared and required. Removing a -required field, changing its type incompatibly, or changing its meaning is a -breaking contract change and requires a `schema_version` bump plus a new -versioned schema file. Adding a field requires updating the current schema and -its producer drift test in the same change. +Every unconditional field emitted today is nevertheless declared and required. +Federation-only properties are declared but optional so a no-manifest payload +retains its released bytes. Removing a required field, changing its type +incompatibly, or changing its meaning is a breaking contract change and +requires a `schema_version` bump plus a new versioned schema file. Adding a +field requires updating the current schema and its producer drift test in the +same change. diff --git a/rac-localview/VIEWER_CONTRACT.md b/rac-localview/VIEWER_CONTRACT.md index b99ac55c..9df92091 100644 --- a/rac-localview/VIEWER_CONTRACT.md +++ b/rac-localview/VIEWER_CONTRACT.md @@ -32,7 +32,11 @@ A single JSON document, as emitted by `decided export --json`. "status": "Accepted", "title": "ADR-027: CI test topology", "path": "decisions/decisions/adr-027-ci-test-topology.md", - "body_html": "

" + "body_html": "

", + "provenance": { + "source": "asdecided/core", + "layer": "local" + } } ], "relationships": [ @@ -68,13 +72,14 @@ Ordered by `path`. | field | type | meaning | | ----------- | -------- | ------------------------------------------------------ | -| `id` | string | Opaque stable artifact ID, unique within the corpus (`RAC-KTQ63DSC8SZW`). | +| `id` | string | Opaque stable artifact ID, unique within its owning source (`RAC-KTQ63DSC8SZW`). | | `aliases` | string[] | Human aliases as emitted by Core identity, e.g. `["adr-027", "adr-027-ci-test-topology"]`. May be empty. | | `type` | string | Artifact family (`decision`, `requirement`, …). Open set; the viewer derives its type filter from the values present. | | `status` | string | Lifecycle status in its authored casing (`Accepted`, `Proposed`, `Superseded`, …). Open set — see case handling below. | | `title` | string | Plain text. | | `path` | string | Source path within the repository. Shown as a muted provenance line on the detail view. | | `body_html` | string | The artifact body **rendered to HTML at export time** (see trust model). | +| `provenance` | object | Optional on legacy/no-manifest payloads. Manifest-backed records carry owning `source`, `layer`, inherited `pin`, and any ordered override mappings. | #### Alias display @@ -83,7 +88,10 @@ a **display name**: deterministically, the first alias that differs from the `id`, else the `id` itself. The display name is used on list rows, the detail heading, and related-artifact links; the opaque `id` stays visible on the detail view's provenance line (alongside `path`) -and remains the routing key (`#/artifact/`). +and remains the legacy routing key (`#/artifact/`). For manifest-backed +records, the viewer keys and routes on `(provenance.source, id)`, encoded as one +`::` hash segment. This retains both records in a valid same-ID +override. A payload without provenance keeps its exact bare-ID routes. #### Status case handling @@ -96,11 +104,14 @@ render plain. ### `relationships[]` — edges -Each edge is `{ "from": ID, "to": ID-or-alias, "type": string }` and -reads "`from` `type` `to`". Ordered by (from, to). Core emits **only** -`relates-to`; richer edge typing is a future Core decision. `to` may be -an unresolved alias preserved verbatim — the viewer renders those as -"(not in corpus)" rather than dropping them. +Each edge retains `{ "from": ID, "to": ID-or-alias, "type": string }` and +reads "`from` `type` `to`". A manifest-backed edge also carries +`from_identity` and nullable `to_identity` `{source,id}` objects plus the +declaring artifact's provenance. The viewer uses those identities for graph, +inbound/outbound, and detail links. Ordered by source-aware endpoint identity. +Core emits **only** `relates-to`; richer edge typing is a future Core decision. +`to` may be an unresolved alias preserved verbatim — the viewer renders those +as "(not in corpus)" rather than dropping them. The type set stays open for forward compatibility. The viewer keeps inverse labels for types a future Core might emit (accepted if they @@ -216,7 +227,7 @@ cited ids and aliases in text nodes are linkified). The viewer performs ## 4. Viewer behaviour summary - Read-only; no router dependency — state is hash-based - (`#/` list, `#/artifact/` detail) so deep links work from + (`#/` list, `#/artifact/` detail) so deep links work from `file://`. - List view: every artifact as a row (display name + title + chips); filter toggles for type and status derived from the corpus (status diff --git a/rac-localview/src/viewer/App.tsx b/rac-localview/src/viewer/App.tsx index 32791e4f..4a57d015 100644 --- a/rac-localview/src/viewer/App.tsx +++ b/rac-localview/src/viewer/App.tsx @@ -64,21 +64,24 @@ export function App() { () => (data ? buildIndex(data) : null), [data], ); + const indexRef = useRef(index); + indexRef.current = index; // Editor-host bridge (v0.21.7): announce readiness and apply the host's // reveal requests. Inert in a standalone Portal (no host). useEffect(() => { const unsubscribe = onRevealArtifact((id) => { - setActiveId(id); + const key = indexRef.current?.citationLookup.get(id.toLowerCase()) ?? id; + setActiveId(key); // In the graph view a reveal just roots/highlights the node; it does not // navigate away. Elsewhere it opens the detail page, as before. if (viewRef.current === 'graph') return; - const target = `#/artifact/${encodeURIComponent(id)}`; + const target = `#/artifact/${encodeURIComponent(key)}`; if (window.location.hash === target) { revealedRef.current = null; // already here — nothing to suppress return; } - revealedRef.current = id; + revealedRef.current = key; window.location.hash = target; }); postReady(); diff --git a/rac-localview/src/viewer/DetailView.tsx b/rac-localview/src/viewer/DetailView.tsx index 7c80e0ca..49a42222 100644 --- a/rac-localview/src/viewer/DetailView.tsx +++ b/rac-localview/src/viewer/DetailView.tsx @@ -1,7 +1,12 @@ import { useEffect, useRef } from 'react'; import { KeyboardHint, Panel } from '../components'; import type { CorpusIndex } from './data'; -import { displayName, linkifyCitations } from './data'; +import { + displayName, + linkifyCitations, + relationshipSourceKey, + relationshipTargetKey, +} from './data'; import type { Relationship } from './types'; import { ArtifactChips } from './chips'; @@ -55,10 +60,14 @@ function RelatedGroup({ heading, edges, index, direction }: RelatedGroupProps) {