diff --git a/rust/decided/tests/cli.rs b/rust/decided/tests/cli.rs index f5374052..468ea050 100644 --- a/rust/decided/tests/cli.rs +++ b/rust/decided/tests/cli.rs @@ -35,6 +35,46 @@ fn run(args: &[&str]) -> Output { .expect("run decided") } +#[test] +fn corpus_digest_version_two_is_explicit_and_keeps_v1_as_the_default() { + let root = empty_scratch_root("digest-v2"); + 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: STD\ncorpus:\n source: acme/standards\n", + ) + .unwrap(); + fs::write(root.join("decisions/policy.md"), "policy\n").unwrap(); + let root_text = root.to_string_lossy().into_owned(); + + let v1 = run(&[ + "corpus", + "digest", + "--root", + &root_text, + "--corpus", + "decisions", + ]); + assert!(v1.status.success(), "{}", String::from_utf8_lossy(&v1.stderr)); + assert!(String::from_utf8_lossy(&v1.stdout).starts_with("sha256:")); + + let v2 = run(&[ + "corpus", + "digest", + "--version", + "2", + "--root", + &root_text, + "--corpus", + "decisions", + ]); + assert!(v2.status.success(), "{}", String::from_utf8_lossy(&v2.stderr)); + assert!(String::from_utf8_lossy(&v2.stdout).starts_with("sha256-v2:")); + + fs::remove_dir_all(root).unwrap(); +} + fn empty_scratch_root(label: &str) -> PathBuf { let root = std::env::temp_dir().join(format!("asdecided-cli-{label}-{}", scratch_suffix())); fs::create_dir_all(&root).expect("create empty CLI scratch repository"); diff --git a/rust/rac-engine/src/cli.rs b/rust/rac-engine/src/cli.rs index b89ccb61..2c3f9539 100644 --- a/rust/rac-engine/src/cli.rs +++ b/rust/rac-engine/src/cli.rs @@ -185,7 +185,7 @@ fn run_dispatch(args: &[String]) -> u8 { let order_aware = matches!( first.as_str(), "mcp-stats" | "telemetry" | "usage" | "skill" | "hook" | "eval" | "init" | "quickstart" - | "migrate" | "watchkeeper" + | "migrate" | "watchkeeper" | "corpus" ); if !order_aware { if rest.iter().any(|a| a.as_str() == "--version") { @@ -287,6 +287,7 @@ fn run_corpus(rest: &[&String]) -> u8 { let mut action: Option = None; let mut root: Option = None; let mut corpus: Option = None; + let mut version: u32 = 1; let mut extras: Vec = Vec::new(); let mut positional_only = false; @@ -324,6 +325,18 @@ fn run_corpus(rest: &[&String]) -> u8 { Err(code) => return code, } } + other if other == "--version" || other.starts_with("--version=") => { + match take_opt_value(prog, "--version", other, rest, &mut i) { + Ok(value) if value == "2" => version = 2, + Ok(value) => { + return argparse_error( + prog, + &format!("argument --version: invalid choice: '{value}' (choose from '2')"), + ) + } + Err(code) => return code, + } + } other => extras.push(other.to_string()), } i += 1; @@ -351,6 +364,7 @@ fn run_corpus(rest: &[&String]) -> u8 { cmd_corpus_digest(&CorpusDigestArgs { root: root.expect("checked above"), corpus: corpus.expect("checked above"), + version, }) as u8 } diff --git a/rust/rac-engine/src/commands.rs b/rust/rac-engine/src/commands.rs index 42cf2341..30756978 100644 --- a/rust/rac-engine/src/commands.rs +++ b/rust/rac-engine/src/commands.rs @@ -2899,15 +2899,23 @@ pub fn cmd_rename(args: &RenameArgs) -> i32 { pub struct CorpusDigestArgs { pub root: String, pub corpus: String, + pub version: u32, } /// 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); + let result = if args.version == 2 { + crate::federation::calculate_parent_digest_v2(&args.root, &args.corpus) + .map(|result| result.digest) + } else { + crate::federation::calculate_parent_digest(&args.root, &args.corpus) + .map(|result| result.digest) + }; + match result { + Ok(digest) => { + emit(digest); EXIT_OK } Err(error) => { diff --git a/rust/rac-engine/src/federation.rs b/rust/rac-engine/src/federation.rs index 0a5f9fcd..e2ee834e 100644 --- a/rust/rac-engine/src/federation.rs +++ b/rust/rac-engine/src/federation.rs @@ -1,4 +1,5 @@ -//! Verified, offline parent-corpus materialisation (ADR-133 through ADR-135). +//! Verified, offline parent-corpus materialisation (ADR-134, ADR-135, +//! ADR-144, ADR-145, and ADR-148). //! //! This module owns only the declaration and byte-snapshot boundary. It does //! not compose artifacts, resolve relationships, or give any read consumer a @@ -7,6 +8,7 @@ //! parse the verified snapshot without re-reading mutable parent files. use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::path::{Component, Path, PathBuf}; @@ -16,6 +18,28 @@ 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:"; +pub const DIGEST_V2_PREFIX: &str = "sha256-v2:"; +pub const DIGEST_V2_DOMAIN: &[u8] = b"asdecided-corpus-digest-v2\0"; + +pub const V2_MAX_MANIFEST_BYTES: usize = 1_048_576; +pub const V2_MAX_CONFIG_BYTES: usize = 1_048_576; +pub const V2_MAX_ALIAS_BYTES: usize = 64; +pub const V2_MAX_SOURCE_BYTES: usize = 255; +pub const V2_MAX_PATH_BYTES: usize = 4_096; +pub const V2_MAX_PATH_COMPONENTS: usize = 64; +pub const V2_MAX_PATH_COMPONENT_BYTES: usize = 255; +pub const V2_MAX_YAML_DEPTH: usize = 32; +pub const V2_MAX_YAML_NODES: usize = 16_384; +pub const V2_MAX_DIRECT_PARENTS: usize = 32; +pub const V2_MAX_INHERITANCE_DEPTH: usize = 16; +pub const V2_MAX_INHERITED_SOURCES: usize = 256; +pub const V2_MAX_EDGES: usize = 1_024; +pub const V2_MAX_OVERRIDES: usize = 4_096; +pub const V2_MAX_INHERITED_FILES: usize = 50_000; +pub const V2_MAX_FILE_BYTES: usize = 16 * 1_048_576; +pub const V2_MAX_LOGICAL_BYTES: usize = 256 * 1_048_576; +pub const V2_MAX_PHYSICAL_BYTES: usize = 512 * 1_048_576; +pub const V2_MAX_VISITED_ENTRIES: usize = 200_000; /// Fixed domain bytes for parent-corpus digest version 1. /// @@ -53,6 +77,13 @@ pub enum ParentCorpusErrorCode { TransitiveInheritance, SnapshotFailed, DigestMismatch, + DuplicateParent, + Cycle, + DivergentPin, + OverlappingRoots, + LimitExceeded, + UnsupportedFilesystem, + SnapshotChanged, } impl ParentCorpusErrorCode { @@ -74,6 +105,13 @@ impl ParentCorpusErrorCode { Self::TransitiveInheritance => "parent-corpus-transitive-inheritance", Self::SnapshotFailed => "parent-corpus-snapshot-failed", Self::DigestMismatch => "parent-corpus-digest-mismatch", + Self::DuplicateParent => "corpus-federation-duplicate-parent", + Self::Cycle => "corpus-federation-cycle", + Self::DivergentPin => "corpus-federation-divergent-pin", + Self::OverlappingRoots => "corpus-federation-overlapping-roots", + Self::LimitExceeded => "corpus-federation-limit-exceeded", + Self::UnsupportedFilesystem => "corpus-federation-unsupported-filesystem", + Self::SnapshotChanged => "corpus-federation-snapshot-changed", } } } @@ -130,6 +168,39 @@ struct RawParentDeclaration { digest: String, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawGraphManifest { + version: u32, + parents: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawGraphParentDeclaration { + alias: String, + source: String, + root: String, + corpus: String, + digest: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawGraphOverrides { + version: u32, + items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawGraphOverride { + target: String, + #[serde(rename = "with")] + replacement: String, + rationale: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ParentDeclaration { pub version: u32, @@ -155,6 +226,18 @@ pub struct CorpusManifest { pub override_mapping_bytes: Option>, } +/// Strict manifest-v2 declaration. Parent order is retained only because the +/// exact manifest bytes are authenticated; all semantic consumers use +/// [`parents`](Self::parents) in the canonical order produced by the loader. +#[derive(Debug, Clone, PartialEq)] +pub struct GraphCorpusManifest { + pub path: PathBuf, + pub bytes: Vec, + pub parents: Vec, + pub overrides: Option, + pub override_mapping_bytes: Option>, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SnapshotFile { pub relative_path: String, @@ -173,6 +256,19 @@ pub struct ParentDigest { pub digest: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParentDigestV2 { + pub source: String, + pub config_path: PathBuf, + pub config_bytes: Vec, + pub manifest_path: PathBuf, + pub manifest_bytes: Option>, + pub corpus_root: PathBuf, + pub files: Vec, + /// Full `sha256-v2:<64 lowercase hex>` value. + pub digest: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerifiedParent { pub manifest_path: PathBuf, @@ -195,13 +291,75 @@ pub struct VerifiedParent { pub override_mapping_bytes: Option>, } +/// One unique logical inherited source in a verified v2 closure. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedFederationNode { + pub source: String, + pub digest: String, + pub config_path: PathBuf, + pub config_bytes: Vec, + pub manifest_path: PathBuf, + pub manifest_bytes: Option>, + pub manifest_version: Option, + /// Parsed source-local override declaration. Composition validates and + /// lifts a retained v1 hop into source-aware graph keys. + pub overrides: Option, + pub override_mapping_bytes: Option>, + pub corpus_root: PathBuf, + pub files: Vec, +} + +/// One declared and independently verified physical edge. Equal logical +/// diamond targets therefore still have one row per materialised route. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedFederationEdge { + pub owner_source: String, + pub alias: String, + pub target_source: String, + pub declared_digest: String, + pub canonical_digest: String, + pub root: String, + pub corpus: String, + pub materialisation_root: PathBuf, + pub corpus_root: PathBuf, +} + +/// Immutable, source-aware graph-verification result. Composition and serving +/// build from these captured bytes and must not reopen inherited public paths. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedFederation { + pub repository_root: PathBuf, + pub root_source: String, + pub root_corpus_path: String, + pub root_corpus_root: PathBuf, + pub root_files: Vec, + pub root_config_path: PathBuf, + pub root_config_bytes: Vec, + pub manifest: GraphCorpusManifest, + pub nodes: Vec, + pub edges: Vec, + pub materialisation_roots: Vec, + pub corpus_roots: Vec, +} + +impl VerifiedFederation { + pub fn contains_materialised_path(&self, path: &Path) -> bool { + canonical_or_absolute(path).is_some_and(|candidate| { + self.materialisation_roots + .iter() + .any(|root| candidate == *root || candidate.starts_with(root)) + }) + } + + pub fn node(&self, source: &str) -> Option<&VerifiedFederationNode> { + self.nodes.iter().find(|node| node.source == source) + } +} + 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> { + pub fn snapshot_file(&self, path: &crate::corpus::ArtifactPath) -> Option<&SnapshotFile> { if path.source != self.declaration.source { return None; } @@ -256,6 +414,16 @@ fn malformed(path: &Path, reason: impl Into) -> ParentCorpusError { ) } +fn limit_error(path: &Path, dimension: &str, limit: usize, observed: usize) -> ParentCorpusError { + ParentCorpusError::at( + ParentCorpusErrorCode::LimitExceeded, + path, + format!( + "federation limit exceeded: dimension={dimension}, limit={limit}, observed={observed}" + ), + ) +} + fn normalize_newlines(text: &str) -> String { text.replace("\r\n", "\n").replace('\r', "\n") } @@ -619,147 +787,1129 @@ pub fn load_manifest(repository_root: &Path) -> Result, P })) } -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(()), +fn validate_v2_path(value: &str, field: &str) -> Result<(), String> { + if value.is_empty() { + return Err(format!("'{field}' must be a non-empty POSIX-relative path")); + } + if value.len() > V2_MAX_PATH_BYTES { + return Err(format!("'{field}' exceeds {V2_MAX_PATH_BYTES} UTF-8 bytes")); + } + if value.starts_with('/') + || value.starts_with("//") + || value.contains('\\') + || value.as_bytes().get(1) == Some(&b':') + { + return Err(format!("'{field}' must be POSIX-relative")); + } + let components: Vec<&str> = value.split('/').collect(); + if components.len() > V2_MAX_PATH_COMPONENTS { + return Err(format!( + "'{field}' exceeds {V2_MAX_PATH_COMPONENTS} path components" + )); + } + for component in components { + if component.is_empty() || component == "." || component == ".." { + return Err(format!( + "'{field}' must not contain empty, '.', or '..' components" + )); + } + if component.len() > V2_MAX_PATH_COMPONENT_BYTES { + return Err(format!( + "'{field}' component exceeds {V2_MAX_PATH_COMPONENT_BYTES} UTF-8 bytes" + )); } } - Ok(out) + Ok(()) } -fn checked_relative_join( - boundary: &Path, - relative: &str, +fn validate_v2_path_limits( + path: &Path, + value: &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"), +) -> Result<(), ParentCorpusError> { + if value.len() > V2_MAX_PATH_BYTES { + return Err(limit_error( + path, + &format!("{field}-path-bytes"), + V2_MAX_PATH_BYTES, + V2_MAX_PATH_BYTES + 1, )); } - let mut joined = boundary.to_path_buf(); - for component in components { - joined.push(component); + let components: Vec<&str> = value.split('/').collect(); + if components.len() > V2_MAX_PATH_COMPONENTS { + return Err(limit_error( + path, + &format!("{field}-path-components"), + V2_MAX_PATH_COMPONENTS, + V2_MAX_PATH_COMPONENTS + 1, + )); } - Ok(joined) + if components + .iter() + .any(|component| component.len() > V2_MAX_PATH_COMPONENT_BYTES) + { + return Err(limit_error( + path, + &format!("{field}-path-component-bytes"), + V2_MAX_PATH_COMPONENT_BYTES, + V2_MAX_PATH_COMPONENT_BYTES + 1, + )); + } + Ok(()) } -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()), - )); +fn strict_yaml_lexical_check(path: &Path, yaml: &str) -> Result<(), ParentCorpusError> { + for (line_index, line) in yaml.lines().enumerate() { + let mut single = false; + let mut double = false; + let mut escaped = false; + let mut plain = String::new(); + for character in line.chars() { + if escaped { + escaped = false; + continue; } - 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()), - )); + if double && character == '\\' { + escaped = true; + continue; } + if !double && character == '\'' { + single = !single; + continue; + } + if !single && character == '"' { + double = !double; + continue; + } + if !single && !double && character == '#' { + break; + } + if !single && !double { + plain.push(character); + } + } + let tokens = plain.split(|character: char| { + character.is_whitespace() || matches!(character, ':' | ',' | '[' | ']' | '{' | '}') + }); + if tokens + .clone() + .any(|token| token.starts_with('&') || token.starts_with('*') || token.starts_with('!')) + || plain.trim_start().starts_with("<<:") + || plain.contains(" <<:") + { + return Err(malformed( + path, + format!( + "version 2 YAML forbids anchors, aliases, custom tags, and merge keys (line {})", + line_index + 1 + ), + )); } } 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() - ), - )); +fn yaml_shape(value: &serde_yaml::Value, depth: usize, nodes: &mut usize) -> Result<(), String> { + *nodes += 1; + if *nodes > V2_MAX_YAML_NODES { + return Err(format!("version 2 YAML exceeds {V2_MAX_YAML_NODES} nodes")); } - Ok(canonical) -} - -fn canonical_or_absolute(path: &Path) -> Option { - if let Ok(canonical) = std::fs::canonicalize(path) { - return Some(canonical); + if depth > V2_MAX_YAML_DEPTH { + return Err(format!("version 2 YAML exceeds {V2_MAX_YAML_DEPTH} levels")); } - if path.is_absolute() { - Some(path.to_path_buf()) - } else { - std::env::current_dir().ok().map(|cwd| cwd.join(path)) + match value { + serde_yaml::Value::Sequence(values) => { + for value in values { + yaml_shape(value, depth + 1, nodes)?; + } + } + serde_yaml::Value::Mapping(mapping) => { + for (key, value) in mapping { + if !matches!(key, serde_yaml::Value::String(_)) { + return Err("version 2 YAML mapping keys must be strings".to_string()); + } + yaml_shape(key, depth + 1, nodes)?; + yaml_shape(value, depth + 1, nodes)?; + } + } + serde_yaml::Value::Tagged(_) => { + return Err("version 2 YAML forbids custom tags".to_string()) + } + serde_yaml::Value::Null + | serde_yaml::Value::Bool(_) + | serde_yaml::Value::Number(_) + | serde_yaml::Value::String(_) => {} } + Ok(()) } -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() - ), - ) +fn parse_strict_v2_yaml( + path: &Path, + yaml: &str, + section: &str, +) -> Result { + strict_yaml_lexical_check(path, yaml)?; + let value: serde_yaml::Value = serde_yaml::from_str(yaml) + .map_err(|error| malformed(path, format!("{section} YAML must be one mapping: {error}")))?; + if !value.is_mapping() { + return Err(malformed( + path, + format!("{section} YAML must be one mapping"), + )); + } + let mut nodes = 0; + yaml_shape(&value, 1, &mut nodes).map_err(|reason| { + if reason.contains("nodes") { + limit_error( + path, + "yaml-nodes", + V2_MAX_YAML_NODES, + V2_MAX_YAML_NODES + 1, + ) + } else if reason.contains("levels") { + limit_error( + path, + "yaml-depth", + V2_MAX_YAML_DEPTH, + V2_MAX_YAML_DEPTH + 1, + ) + } else { + malformed(path, reason) + } })?; - let mut entries = entries.collect::, _>>().map_err(|error| { - ParentCorpusError::at( - ParentCorpusErrorCode::SnapshotFailed, - directory, + Ok(value) +} + +fn graph_parent( + path: &Path, + raw: RawGraphParentDeclaration, +) -> Result { + if raw.alias.len() > V2_MAX_ALIAS_BYTES { + return Err(limit_error( + path, + "alias-bytes", + V2_MAX_ALIAS_BYTES, + V2_MAX_ALIAS_BYTES + 1, + )); + } + if !valid_alias(&raw.alias) { + return Err(malformed( + path, format!( - "cannot enumerate parent corpus directory {}: {error}", - directory.display() + "'alias' must match the version 2 lowercase syntax and be at most {V2_MAX_ALIAS_BYTES} bytes" ), - ) + )); + } + if raw.source.len() > V2_MAX_SOURCE_BYTES { + return Err(limit_error( + path, + "source-bytes", + V2_MAX_SOURCE_BYTES, + V2_MAX_SOURCE_BYTES + 1, + )); + } + if !crate::scaffold::valid_corpus_source(&raw.source) { + return Err(malformed( + path, + format!( + "'source' must be a lower-case slash-namespaced identity of at most {V2_MAX_SOURCE_BYTES} bytes" + ), + )); + } + validate_v2_path_limits(path, &raw.root, "root")?; + validate_v2_path_limits(path, &raw.corpus, "corpus")?; + validate_v2_path(&raw.root, "root") + .map_err(|reason| ParentCorpusError::at(ParentCorpusErrorCode::PathEscape, path, reason))?; + validate_v2_path(&raw.corpus, "corpus") + .map_err(|reason| ParentCorpusError::at(ParentCorpusErrorCode::PathEscape, path, reason))?; + let hash = raw.digest.strip_prefix(DIGEST_V2_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-v2: followed by exactly 64 lowercase hexadecimal characters", + )); + } + Ok(ParentDeclaration { + version: 2, + alias: raw.alias, + source: raw.source, + root: raw.root, + corpus: raw.corpus, + digest: raw.digest, + }) +} + +/// Parse manifest version 2 without changing the established v1 parser. +/// `None` means either no manifest or a version-1 manifest, allowing existing +/// consumers to retain their exact path. +pub fn load_graph_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() + ), + )); + } + if metadata.len() > V2_MAX_MANIFEST_BYTES as u64 { + return Err(limit_error( + &path, + "manifest-bytes", + V2_MAX_MANIFEST_BYTES, + V2_MAX_MANIFEST_BYTES + 1, + )); + } + 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_sections = heading_sections(&text, "inherits"); + if inherits_sections.len() != 1 { + return Err(malformed( + &path, + "manifest must contain exactly one exact lowercase '## inherits' heading", + )); + } + let (start, end) = inherits_sections[0]; + let blocks = fenced_yaml_blocks(&path, &text, start, end)?; + if blocks.len() != 1 { + return Err(malformed( + &path, + "## inherits must contain exactly one fenced yaml block", + )); + } + // Inspect only the version scalar before selecting the strict v2 mode. + let probe: serde_yaml::Value = serde_yaml::from_str(&blocks[0]).map_err(|error| { + malformed( + &path, + format!("## inherits YAML must be one mapping: {error}"), + ) + })?; + let version = probe + .as_mapping() + .and_then(|mapping| mapping.get(serde_yaml::Value::String("version".to_string()))) + .and_then(serde_yaml::Value::as_u64); + if version == Some(1) { + return Ok(None); + } + let value = parse_strict_v2_yaml(&path, &blocks[0], "## inherits")?; + let raw: RawGraphManifest = serde_yaml::from_value(value).map_err(|error| { + malformed( + &path, + format!("invalid version 2 inheritance mapping: {error}"), + ) + })?; + if raw.version != 2 { + return Err(malformed( + &path, + format!("unsupported inheritance manifest version: {}", raw.version), + )); + } + if raw.parents.is_empty() { + return Err(malformed(&path, "version 2 parents must not be empty")); + } + if raw.parents.len() > V2_MAX_DIRECT_PARENTS { + return Err(limit_error( + &path, + "direct-parents", + V2_MAX_DIRECT_PARENTS, + V2_MAX_DIRECT_PARENTS + 1, + )); + } + let mut aliases = BTreeSet::new(); + let mut sources = BTreeSet::new(); + let mut parents = Vec::with_capacity(raw.parents.len()); + for raw_parent in raw.parents { + let parent = graph_parent(&path, raw_parent)?; + if !aliases.insert(parent.alias.clone()) { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::DuplicateParent, + &path, + format!("duplicate direct parent alias '{}'", parent.alias), + )); + } + if !sources.insert(parent.source.clone()) { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::DuplicateParent, + &path, + format!("duplicate direct parent source '{}'", parent.source), + )); + } + parents.push(parent); + } + parents.sort_by(|left, right| { + ( + &left.source, + &left.digest, + &left.alias, + &left.root, + &left.corpus, + ) + .cmp(&( + &right.source, + &right.digest, + &right.alias, + &right.root, + &right.corpus, + )) + }); + + let override_sections = heading_sections(&text, "overrides"); + if override_sections.len() > 1 { + return Err(malformed(&path, "## overrides may appear at most once")); + } + 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 = parse_strict_v2_yaml(&path, &blocks[0], "## overrides")?; + let parsed: RawGraphOverrides = serde_yaml::from_value(value.clone()).map_err(|error| { + malformed(&path, format!("invalid version 2 override mapping: {error}")) + })?; + if parsed.version != 2 { + return Err(malformed( + &path, + "## overrides version must match ## inherits version 2", + )); + } + for item in &parsed.items { + if item.target.is_empty() || item.replacement.is_empty() || item.rationale.is_empty() { + return Err(malformed( + &path, + "version 2 override operands must be non-empty canonical references", + )); + } + } + let item_count = parsed.items.len(); + if item_count > V2_MAX_OVERRIDES { + return Err(limit_error( + &path, + "overrides", + V2_MAX_OVERRIDES, + V2_MAX_OVERRIDES + 1, + )); + } + (Some(value), Some(blocks[0].as_bytes().to_vec())) + } else { + (None, None) + }; + + Ok(Some(GraphCorpusManifest { + path, + bytes, + parents, + overrides, + override_mapping_bytes, + })) +} + +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)) + } +} + +#[cfg(target_os = "linux")] +fn ensure_no_mount_boundary(boundary: &Path, target: &Path) -> Result<(), ParentCorpusError> { + fn unescape_mount_path(value: &str) -> String { + value + .replace("\\040", " ") + .replace("\\011", "\t") + .replace("\\012", "\n") + .replace("\\134", "\\") + } + let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::UnsupportedFilesystem, + target, + format!("cannot inspect Linux mount identity: {error}"), + ) + })?; + for line in mountinfo.lines() { + let Some(raw_mount) = line.split_whitespace().nth(4) else { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::UnsupportedFilesystem, + target, + "Linux mount identity record is malformed", + )); + }; + let mount = PathBuf::from(unescape_mount_path(raw_mount)); + if mount != boundary + && mount.starts_with(boundary) + && (target == mount || target.starts_with(&mount)) + { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::UnsupportedFilesystem, + target, + format!( + "federation path crosses mount boundary at {}", + mount.display() + ), + )); + } + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn ensure_no_mount_boundary(_boundary: &Path, _target: &Path) -> Result<(), ParentCorpusError> { + // Stable device/volume identities are checked at every directory and file + // on these platforms. Linux additionally exposes bind-mount identity. + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct StableFileIdentity { + device: u64, + inode: u64, + links: u64, + length: u64, + changed_seconds: i64, + changed_nanos: i64, +} + +#[cfg(unix)] +fn stable_file_identity( + _path: &Path, + metadata: &std::fs::Metadata, +) -> Result { + use std::os::unix::fs::MetadataExt; + Ok(StableFileIdentity { + device: metadata.dev(), + inode: metadata.ino(), + links: metadata.nlink(), + length: metadata.len(), + changed_seconds: metadata.ctime(), + changed_nanos: metadata.ctime_nsec(), + }) +} + +#[cfg(windows)] +fn stable_file_identity( + path: &Path, + _metadata: &std::fs::Metadata, +) -> Result { + use std::ffi::c_void; + use std::mem::MaybeUninit; + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + + #[repr(C)] + struct FileTime { + low: u32, + high: u32, + } + + #[repr(C)] + struct ByHandleFileInformation { + _file_attributes: u32, + _creation_time: FileTime, + _last_access_time: FileTime, + last_write_time: FileTime, + volume_serial_number: u32, + file_size_high: u32, + file_size_low: u32, + number_of_links: u32, + file_index_high: u32, + file_index_low: u32, + } + + #[link(name = "Kernel32")] + unsafe extern "system" { + fn GetFileInformationByHandle( + file: *mut c_void, + information: *mut ByHandleFileInformation, + ) -> i32; + } + + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(path) + .map_err(|_| ())?; + let mut information = MaybeUninit::::uninit(); + // SAFETY: `file` keeps a valid owned handle alive for the call and the + // Win32 function initializes the complete output structure on success. + let succeeded = unsafe { + GetFileInformationByHandle( + file.as_raw_handle().cast::(), + information.as_mut_ptr(), + ) + }; + if succeeded == 0 { + return Err(()); + } + // SAFETY: the successful Win32 call above initialized every field. + let information = unsafe { information.assume_init() }; + let last_write = ((information.last_write_time.high as u64) << 32) + | information.last_write_time.low as u64; + Ok(StableFileIdentity { + device: information.volume_serial_number as u64, + inode: ((information.file_index_high as u64) << 32) + | information.file_index_low as u64, + links: information.number_of_links as u64, + length: ((information.file_size_high as u64) << 32) + | information.file_size_low as u64, + changed_seconds: (last_write / 10_000_000) as i64, + changed_nanos: ((last_write % 10_000_000) * 100) as i64, + }) +} + +#[cfg(not(any(unix, windows)))] +fn stable_file_identity( + _path: &Path, + _metadata: &std::fs::Metadata, +) -> Result { + Err(()) +} + +fn read_stable_regular( + path: &Path, + maximum: usize, + dimension: &str, + inherited: bool, + expected_device: Option, +) -> Result, ParentCorpusError> { + let before = std::fs::symlink_metadata(path).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + path, + format!( + "cannot inspect federation input {}: {error}", + path.display() + ), + ) + })?; + if before.file_type().is_symlink() || !before.is_file() { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SymlinkTraversal, + path, + format!( + "federation input must be a real regular file: {}", + path.display() + ), + )); + } + let before_identity = stable_file_identity(path, &before).map_err(|_| { + ParentCorpusError::at( + ParentCorpusErrorCode::UnsupportedFilesystem, + path, + format!("stable file identity is unavailable for {}", path.display()), + ) + })?; + if inherited && before_identity.links != 1 { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::UnsupportedFilesystem, + path, + format!( + "inherited files must have exactly one hard link: {}", + path.display() + ), + )); + } + if expected_device.is_some_and(|device| device != before_identity.device) { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::UnsupportedFilesystem, + path, + format!( + "federation input crosses a filesystem boundary: {}", + path.display() + ), + )); + } + if before_identity.length > maximum as u64 { + return Err(limit_error(path, dimension, maximum, maximum + 1)); + } + + #[cfg(unix)] + let bytes = { + use std::io::Read; + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + path, + format!("cannot open federation input {}: {error}", path.display()), + ) + })?; + let mut bytes = Vec::with_capacity(before_identity.length as usize); + file.read_to_end(&mut bytes).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + path, + format!("cannot read federation input {}: {error}", path.display()), + ) + })?; + bytes + }; + #[cfg(not(unix))] + let bytes = std::fs::read(path).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + path, + format!("cannot read federation input {}: {error}", path.display()), + ) + })?; + + let after = std::fs::symlink_metadata(path).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotChanged, + path, + format!( + "federation input changed during capture {}: {error}", + path.display() + ), + ) + })?; + let after_identity = stable_file_identity(path, &after).map_err(|_| { + ParentCorpusError::at( + ParentCorpusErrorCode::UnsupportedFilesystem, + path, + format!("stable file identity is unavailable for {}", path.display()), + ) + })?; + if before_identity != after_identity || bytes.len() as u64 != before_identity.length { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotChanged, + path, + format!( + "federation input changed during capture: {}", + path.display() + ), + )); + } + Ok(bytes) +} + +#[derive(Debug, Default)] +struct GraphCounters { + edges: usize, + overrides: usize, + physical_bytes: usize, + visited_entries: usize, + logical_bytes: usize, + logical_files: usize, +} + +fn add_limited( + current: &mut usize, + amount: usize, + limit: usize, + dimension: &str, + path: &Path, +) -> Result<(), ParentCorpusError> { + let next = current.saturating_add(amount); + if next > limit { + return Err(limit_error(path, dimension, limit, limit + 1)); + } + *current = next; + Ok(()) +} + +fn directory_device(path: &Path) -> Result { + let metadata = std::fs::symlink_metadata(path).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + path, + format!( + "cannot inspect federation directory {}: {error}", + path.display() + ), + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SymlinkTraversal, + path, + format!( + "federation directory must be a real directory: {}", + path.display() + ), + )); + } + stable_file_identity(path, &metadata) + .map(|identity| identity.device) + .map_err(|_| { + ParentCorpusError::at( + ParentCorpusErrorCode::UnsupportedFilesystem, + path, + format!( + "stable filesystem identity is unavailable for {}", + path.display() + ), + ) + }) +} + +#[allow(clippy::too_many_arguments)] // The explicit bounds/context prevent accidental v1 reuse. +fn snapshot_directory_v2( + corpus_root: &Path, + directory: &Path, + components: &mut Vec, + exclusions: &[PathBuf], + expected_device: u64, + inherited_files: bool, + charge_limits: bool, + counters: &mut GraphCounters, + output: &mut Vec, +) -> Result<(), ParentCorpusError> { + let directory_before = std::fs::symlink_metadata(directory).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + directory, + format!( + "cannot inspect inherited directory {}: {error}", + directory.display() + ), + ) + })?; + let directory_identity = stable_file_identity(directory, &directory_before).map_err(|_| { + ParentCorpusError::at( + ParentCorpusErrorCode::UnsupportedFilesystem, + directory, + format!( + "stable directory identity is unavailable for {}", + directory.display() + ), + ) + })?; + if directory_identity.device != expected_device { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::UnsupportedFilesystem, + directory, + format!( + "federation directory crosses a filesystem boundary: {}", + directory.display() + ), + )); + } + let entries = std::fs::read_dir(directory).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + directory, + format!( + "cannot read inherited corpus directory {}: {error}", + directory.display() + ), + ) + })?; + let mut entries = entries.collect::, _>>().map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + directory, + format!( + "cannot enumerate inherited corpus directory {}: {error}", + directory.display() + ), + ) + })?; + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + if charge_limits { + add_limited( + &mut counters.visited_entries, + 1, + V2_MAX_VISITED_ENTRIES, + "visited-entries", + directory, + )?; + } + let path = entry.path(); + if exclusions + .iter() + .any(|excluded| path == *excluded || path.starts_with(excluded)) + { + continue; + } + let metadata = std::fs::symlink_metadata(&path).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + &path, + format!("cannot inspect inherited entry {}: {error}", path.display()), + ) + })?; + if metadata.file_type().is_symlink() { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SymlinkTraversal, + &path, + format!( + "inherited corpus must not contain symlinks: {}", + path.display() + ), + )); + } + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + if name.starts_with('.') { + continue; + } + components.push(name.clone()); + if metadata.is_dir() { + snapshot_directory_v2( + corpus_root, + &path, + components, + exclusions, + expected_device, + inherited_files, + charge_limits, + counters, + output, + )?; + } else if name.ends_with(".md") { + if !metadata.is_file() { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + &path, + format!( + "inherited Markdown is not a regular file: {}", + path.display() + ), + )); + } + let bytes = read_stable_regular( + &path, + V2_MAX_FILE_BYTES, + "file-bytes", + inherited_files, + Some(expected_device), + )?; + if charge_limits { + add_limited( + &mut counters.physical_bytes, + bytes.len(), + V2_MAX_PHYSICAL_BYTES, + "physical-bytes", + &path, + )?; + } + output.push(SnapshotFile { + relative_path: components.join("/"), + absolute_path: path, + bytes, + }); + } + components.pop(); + } + let directory_after = std::fs::symlink_metadata(directory).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotChanged, + directory, + format!( + "inherited directory changed during capture {}: {error}", + directory.display() + ), + ) + })?; + let after_identity = stable_file_identity(directory, &directory_after).map_err(|_| { + ParentCorpusError::at( + ParentCorpusErrorCode::UnsupportedFilesystem, + directory, + format!( + "stable directory identity is unavailable for {}", + directory.display() + ), + ) + })?; + if directory_identity != after_identity { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotChanged, + directory, + format!( + "inherited directory changed during capture: {}", + directory.display() + ), + )); + } + output.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + // Every emitted path came from the confined recursive walk. + debug_assert!(output + .iter() + .all(|file| file.absolute_path.starts_with(corpus_root))); + Ok(()) +} + +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()); @@ -867,6 +2017,38 @@ pub fn digest_snapshot(source: &str, config_bytes: &[u8], files: &[SnapshotFile] format!("{DIGEST_PREFIX}{}", hasher.hexdigest()) } +/// Pure canonical version-2 digest over one captured node snapshot. +pub fn digest_snapshot_v2( + source: &str, + config_bytes: &[u8], + manifest_bytes: Option<&[u8]>, + files: &[SnapshotFile], +) -> String { + let mut hasher = Sha256::new(); + hasher.update(DIGEST_V2_DOMAIN); + write_frame(&mut hasher, 0x01, source.as_bytes()); + write_frame(&mut hasher, 0x02, config_bytes); + write_frame( + &mut hasher, + 0x03, + if manifest_bytes.is_some() { + &[0x01] + } else { + &[0x00] + }, + ); + if let Some(manifest) = manifest_bytes { + write_frame(&mut hasher, 0x04, manifest); + } + 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, 0x05, file.relative_path.as_bytes()); + write_frame(&mut hasher, 0x06, &file.bytes); + } + format!("{DIGEST_V2_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( @@ -1021,6 +2203,716 @@ pub fn calculate_parent_digest( snapshot_at(root.as_ref(), corpus_relative) } +#[derive(Debug, Clone, PartialEq)] +enum CapturedManifest { + None, + V1(CorpusManifest), + V2(GraphCorpusManifest), +} + +impl CapturedManifest { + fn bytes(&self) -> Option<&[u8]> { + match self { + Self::None => None, + Self::V1(manifest) => Some(&manifest.bytes), + Self::V2(manifest) => Some(&manifest.bytes), + } + } + + fn direct_parents(&self) -> Vec { + match self { + Self::None => Vec::new(), + Self::V1(manifest) => vec![manifest.inherits.clone()], + Self::V2(manifest) => manifest.parents.clone(), + } + } + + + fn version(&self) -> Option { + match self { + Self::None => None, + Self::V1(_) => Some(1), + Self::V2(_) => Some(2), + } + } + + fn overrides(&self) -> Option<&serde_yaml::Value> { + match self { + Self::None => None, + Self::V1(manifest) => manifest.overrides.as_ref(), + Self::V2(manifest) => manifest.overrides.as_ref(), + } + } + + fn override_mapping_bytes(&self) -> Option<&[u8]> { + match self { + Self::None => None, + Self::V1(manifest) => manifest.override_mapping_bytes.as_deref(), + Self::V2(manifest) => manifest.override_mapping_bytes.as_deref(), + } + } + + fn override_count(&self) -> usize { + self.overrides() + .and_then(serde_yaml::Value::as_mapping) + .and_then(|mapping| { + mapping.get(serde_yaml::Value::String("items".to_string())) + }) + .and_then(serde_yaml::Value::as_sequence) + .map_or(0, Vec::len) + } +} + +#[derive(Debug, Clone, PartialEq)] +struct CapturedNode { + repository_root: PathBuf, + source: String, + config_path: PathBuf, + config_bytes: Vec, + manifest_path: PathBuf, + manifest: CapturedManifest, + corpus_root: PathBuf, + files: Vec, + digest_v2: String, +} + +fn captured_manifest(repository_root: &Path) -> Result { + if let Some(manifest) = load_graph_manifest(repository_root)? { + return Ok(CapturedManifest::V2(manifest)); + } + match load_manifest(repository_root)? { + Some(manifest) => Ok(CapturedManifest::V1(manifest)), + None => Ok(CapturedManifest::None), + } +} + +fn capture_v2_node( + repository_root: &Path, + corpus_relative: &str, + invocation_root: &Path, + counters: &mut GraphCounters, +) -> Result { + let repository_root = std::fs::canonicalize(repository_root).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::MaterialisationMissing, + repository_root, + format!("parent materialisation is unavailable: {error}"), + ) + })?; + if repository_root == invocation_root || !repository_root.starts_with(invocation_root) { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::PathEscape, + &repository_root, + "inherited materialisation must be strictly contained by the invocation repository", + )); + } + let device = directory_device(&repository_root)?; + let config_candidate = repository_root.join(CONFIG_RELATIVE_PATH); + ensure_no_symlink_components(&repository_root, &config_candidate)?; + let config_path = canonical_confined( + &repository_root, + &config_candidate, + ParentCorpusErrorCode::ParentConfigMissing, + "config", + )?; + let config_bytes = read_stable_regular( + &config_path, + V2_MAX_CONFIG_BYTES, + "config-bytes", + true, + Some(device), + )?; + add_limited( + &mut counters.physical_bytes, + config_bytes.len(), + V2_MAX_PHYSICAL_BYTES, + "physical-bytes", + &config_path, + )?; + let config_text = std::str::from_utf8(&config_bytes).map_err(|_| { + ParentCorpusError::at( + ParentCorpusErrorCode::InvalidConfig, + &config_path, + "version 2 governing config must be valid UTF-8", + ) + })?; + parse_strict_v2_yaml(&config_path, config_text, "governing config")?; + let identity = + crate::scaffold::parse_identity_config(&config_path.to_string_lossy(), config_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, + "version 2 graph nodes must declare corpus.source", + ) + })?; + if source.len() > V2_MAX_SOURCE_BYTES { + return Err(limit_error( + &config_path, + "source-bytes", + V2_MAX_SOURCE_BYTES, + V2_MAX_SOURCE_BYTES + 1, + )); + } + + let manifest_path = repository_root.join(MANIFEST_RELATIVE_PATH); + ensure_no_symlink_components(&repository_root, &manifest_path)?; + let manifest_bytes = match std::fs::symlink_metadata(&manifest_path) { + Ok(_) => Some(read_stable_regular( + &manifest_path, + V2_MAX_MANIFEST_BYTES, + "manifest-bytes", + true, + Some(device), + )?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotFailed, + &manifest_path, + format!("cannot inspect inherited manifest: {error}"), + )) + } + }; + if let Some(bytes) = &manifest_bytes { + add_limited( + &mut counters.physical_bytes, + bytes.len(), + V2_MAX_PHYSICAL_BYTES, + "physical-bytes", + &manifest_path, + )?; + } + let manifest = captured_manifest(&repository_root)?; + if manifest.bytes() != manifest_bytes.as_deref() { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotChanged, + &manifest_path, + "federation manifest changed while it was parsed", + )); + } + add_limited( + &mut counters.overrides, + manifest.override_count(), + V2_MAX_OVERRIDES, + "overrides", + &manifest_path, + )?; + + let corpus_candidate = checked_relative_join(&repository_root, corpus_relative, "corpus")?; + let corpus_root = canonical_confined( + &repository_root, + &corpus_candidate, + ParentCorpusErrorCode::ParentCorpusMissing, + "corpus", + )?; + ensure_no_mount_boundary(&repository_root, &corpus_root)?; + if !corpus_root.is_dir() || directory_device(&corpus_root)? != device { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::UnsupportedFilesystem, + &corpus_root, + "inherited corpus must be a real directory on the materialisation filesystem", + )); + } + let exclusions = manifest + .direct_parents() + .iter() + .filter_map(|parent| checked_relative_join(&repository_root, &parent.root, "root").ok()) + .collect::>(); + let mut files = Vec::new(); + snapshot_directory_v2( + &corpus_root, + &corpus_root, + &mut Vec::new(), + &exclusions, + device, + true, + true, + counters, + &mut files, + )?; + let digest_v2 = digest_snapshot_v2(&source, &config_bytes, manifest_bytes.as_deref(), &files); + Ok(CapturedNode { + repository_root, + source, + config_path, + config_bytes, + manifest_path, + manifest, + corpus_root, + files, + digest_v2, + }) +} + +/// Calculate a manifest-bound graph pin. The existing v1 function and output +/// remain unchanged; callers enter this contract explicitly. +pub fn calculate_parent_digest_v2( + root: impl AsRef, + corpus_relative: &str, +) -> Result { + validate_v2_path_limits(root.as_ref(), corpus_relative, "corpus")?; + validate_v2_path(corpus_relative, "corpus") + .map_err(|reason| ParentCorpusError::new(ParentCorpusErrorCode::PathEscape, reason))?; + let input = root.as_ref(); + let root = std::fs::canonicalize(input).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::MaterialisationMissing, + input, + format!("parent materialisation is unavailable: {error}"), + ) + })?; + // `capture_v2_node` requires strict descent because graph nodes are + // inherited. Give the operator capture a synthetic lexical parent while + // retaining the real root as the only filesystem boundary. + let invocation = root.parent().unwrap_or(&root).to_path_buf(); + let mut counters = GraphCounters::default(); + let captured = capture_v2_node(&root, corpus_relative, &invocation, &mut counters)?; + Ok(ParentDigestV2 { + source: captured.source, + config_path: captured.config_path, + config_bytes: captured.config_bytes, + manifest_path: captured.manifest_path, + manifest_bytes: captured.manifest.bytes().map(ToOwned::to_owned), + corpus_root: captured.corpus_root, + files: captured.files, + digest: captured.digest_v2, + }) +} + +struct GraphVerification { + invocation_root: PathBuf, + counters: GraphCounters, + physical_captures: BTreeMap<(PathBuf, String), CapturedNode>, + expanded_physical: BTreeSet<(PathBuf, String)>, + logical_nodes: BTreeMap, + edges: Vec, + materialisation_roots: BTreeSet, + corpus_roots: BTreeSet, +} + +impl GraphVerification { + fn resolve_materialisation( + &self, + owner_root: &Path, + declaration: &ParentDeclaration, + ) -> Result { + let candidate = checked_relative_join(owner_root, &declaration.root, "root")?; + let root = canonical_confined( + owner_root, + &candidate, + ParentCorpusErrorCode::MaterialisationMissing, + "materialisation", + )?; + if root == owner_root || !root.is_dir() || !root.starts_with(&self.invocation_root) { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::PathEscape, + &candidate, + "parent materialisation must be a directory strictly inside its declaring repository and the invocation repository", + )); + } + ensure_no_mount_boundary(owner_root, &root)?; + Ok(root) + } + + fn verify_edges( + &mut self, + owner_source: &str, + owner_root: &Path, + declarations: &[ParentDeclaration], + depth: usize, + active_sources: &mut Vec, + owner_is_v1: bool, + ) -> Result<(), ParentCorpusError> { + if depth > V2_MAX_INHERITANCE_DEPTH { + return Err(limit_error( + owner_root, + "depth", + V2_MAX_INHERITANCE_DEPTH, + V2_MAX_INHERITANCE_DEPTH + 1, + )); + } + let mut siblings: Vec<(PathBuf, &ParentDeclaration)> = Vec::new(); + for declaration in declarations { + add_limited( + &mut self.counters.edges, + 1, + V2_MAX_EDGES, + "edges", + owner_root, + )?; + let materialisation_root = self.resolve_materialisation(owner_root, declaration)?; + for (other_root, other) in &siblings { + let overlap = materialisation_root == *other_root + || materialisation_root.starts_with(other_root) + || other_root.starts_with(&materialisation_root); + if overlap + && !(materialisation_root == *other_root + && declaration.source == other.source + && declaration.digest == other.digest) + { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::OverlappingRoots, + &materialisation_root, + format!( + "sibling parent roots overlap for '{}' and '{}'", + other.source, declaration.source + ), + )); + } + } + siblings.push((materialisation_root.clone(), declaration)); + + let physical_key = (materialisation_root.clone(), declaration.corpus.clone()); + let captured = if let Some(captured) = self.physical_captures.get(&physical_key) { + captured.clone() + } else { + let captured = capture_v2_node( + &materialisation_root, + &declaration.corpus, + &self.invocation_root, + &mut self.counters, + )?; + self.physical_captures + .insert(physical_key.clone(), captured.clone()); + captured + }; + if captured.source != declaration.source { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SourceMismatch, + &captured.config_path, + format!( + "manifest source '{}' does not match inherited corpus.source '{}'", + declaration.source, captured.source + ), + )); + } + let verified_pin = if declaration.version == 1 { + digest_snapshot(&captured.source, &captured.config_bytes, &captured.files) + } else { + captured.digest_v2.clone() + }; + if verified_pin != declaration.digest { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::DigestMismatch, + owner_root.join(MANIFEST_RELATIVE_PATH), + format!( + "declared parent digest '{}' does not match verified digest '{}'", + declaration.digest, verified_pin + ), + )); + } + if owner_is_v1 && !matches!(captured.manifest, CapturedManifest::None) { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::TransitiveInheritance, + &captured.manifest_path, + format!( + "version-1 parent source '{}' declares its own inheritance", + captured.source + ), + )); + } + if active_sources + .iter() + .any(|source| source == &captured.source) + { + let mut route = active_sources.clone(); + route.push(captured.source.clone()); + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::Cycle, + owner_root.join(MANIFEST_RELATIVE_PATH), + format!("federation source cycle: {}", route.join(" -> ")), + )); + } + if let Some(existing) = self.logical_nodes.get(&captured.source) { + if existing.digest != captured.digest_v2 { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::DivergentPin, + owner_root.join(MANIFEST_RELATIVE_PATH), + format!( + "source '{}' verified with divergent pins '{}' and '{}'", + captured.source, existing.digest, captured.digest_v2 + ), + )); + } + } + + self.materialisation_roots + .insert(materialisation_root.clone()); + self.corpus_roots.insert(captured.corpus_root.clone()); + self.edges.push(VerifiedFederationEdge { + owner_source: owner_source.to_string(), + alias: declaration.alias.clone(), + target_source: captured.source.clone(), + declared_digest: declaration.digest.clone(), + canonical_digest: captured.digest_v2.clone(), + root: declaration.root.clone(), + corpus: declaration.corpus.clone(), + materialisation_root, + corpus_root: captured.corpus_root.clone(), + }); + + active_sources.push(captured.source.clone()); + if !self.expanded_physical.contains(&physical_key) { + match &captured.manifest { + CapturedManifest::None => {} + CapturedManifest::V1(manifest) => self.verify_edges( + &captured.source, + &captured.repository_root, + std::slice::from_ref(&manifest.inherits), + depth + 1, + active_sources, + true, + )?, + CapturedManifest::V2(manifest) => self.verify_edges( + &captured.source, + &captured.repository_root, + &manifest.parents, + depth + 1, + active_sources, + false, + )?, + } + self.expanded_physical.insert(physical_key); + } + active_sources.pop(); + + if !self.logical_nodes.contains_key(&captured.source) { + if self.logical_nodes.len() >= V2_MAX_INHERITED_SOURCES { + return Err(limit_error( + &captured.config_path, + "unique-inherited-sources", + V2_MAX_INHERITED_SOURCES, + V2_MAX_INHERITED_SOURCES + 1, + )); + } + add_limited( + &mut self.counters.logical_files, + captured.files.len(), + V2_MAX_INHERITED_FILES, + "inherited-files", + &captured.corpus_root, + )?; + let logical_bytes = captured.config_bytes.len() + + captured.manifest.bytes().map_or(0, <[u8]>::len) + + captured + .files + .iter() + .map(|file| file.bytes.len()) + .sum::(); + add_limited( + &mut self.counters.logical_bytes, + logical_bytes, + V2_MAX_LOGICAL_BYTES, + "logical-bytes", + &captured.corpus_root, + )?; + self.logical_nodes.insert( + captured.source.clone(), + VerifiedFederationNode { + source: captured.source, + digest: captured.digest_v2, + config_path: captured.config_path, + config_bytes: captured.config_bytes, + manifest_path: captured.manifest_path, + manifest_bytes: captured.manifest.bytes().map(ToOwned::to_owned), + manifest_version: captured.manifest.version(), + overrides: captured.manifest.overrides().cloned(), + override_mapping_bytes: captured + .manifest + .override_mapping_bytes() + .map(ToOwned::to_owned), + corpus_root: captured.corpus_root, + files: captured.files, + }, + ); + } + } + Ok(()) + } +} + +/// Verify a version-2 federation graph. Absence and version 1 deliberately +/// return `None` so their established loader and observable behavior are not +/// routed through graph semantics. +pub fn verify_federation( + repository_root: impl AsRef, + root_corpus_relative: &str, +) -> Result, ParentCorpusError> { + let input = repository_root.as_ref(); + let repository_root = std::fs::canonicalize(input).map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::PathEscape, + input, + format!("repository root is unavailable: {error}"), + ) + })?; + let Some(manifest) = load_graph_manifest(&repository_root)? else { + return Ok(None); + }; + validate_v2_path_limits(&repository_root, root_corpus_relative, "corpus")?; + validate_v2_path(root_corpus_relative, "corpus") + .map_err(|reason| ParentCorpusError::new(ParentCorpusErrorCode::PathEscape, reason))?; + let root_config_path = repository_root.join(CONFIG_RELATIVE_PATH); + ensure_no_symlink_components(&repository_root, &root_config_path)?; + let root_config_bytes = read_stable_regular( + &root_config_path, + V2_MAX_CONFIG_BYTES, + "config-bytes", + false, + Some(directory_device(&repository_root)?), + )?; + let config_text = std::str::from_utf8(&root_config_bytes).map_err(|_| { + ParentCorpusError::at( + ParentCorpusErrorCode::InvalidConfig, + &root_config_path, + "version 2 root config must be valid UTF-8", + ) + })?; + parse_strict_v2_yaml(&root_config_path, config_text, "governing config")?; + let identity = + crate::scaffold::parse_identity_config(&root_config_path.to_string_lossy(), config_text) + .map_err(|error| { + ParentCorpusError::at( + ParentCorpusErrorCode::InvalidConfig, + &root_config_path, + error.message().to_string(), + ) + })?; + let root_source = identity.corpus_source.ok_or_else(|| { + ParentCorpusError::at( + ParentCorpusErrorCode::ChildSourceMissing, + &root_config_path, + "version 2 root config must declare corpus.source", + ) + })?; + if root_source.len() > V2_MAX_SOURCE_BYTES { + return Err(limit_error( + &root_config_path, + "source-bytes", + V2_MAX_SOURCE_BYTES, + V2_MAX_SOURCE_BYTES + 1, + )); + } + let root_corpus_candidate = + checked_relative_join(&repository_root, root_corpus_relative, "corpus")?; + let root_corpus_root = canonical_confined( + &repository_root, + &root_corpus_candidate, + ParentCorpusErrorCode::ParentCorpusMissing, + "root corpus", + )?; + let root_device = directory_device(&repository_root)?; + if directory_device(&root_corpus_root)? != root_device { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::UnsupportedFilesystem, + &root_corpus_root, + "root corpus crosses a filesystem boundary", + )); + } + let root_exclusions = manifest + .parents + .iter() + .map(|parent| checked_relative_join(&repository_root, &parent.root, "root")) + .collect::, _>>()?; + let mut root_files = Vec::new(); + let mut root_snapshot_counters = GraphCounters::default(); + snapshot_directory_v2( + &root_corpus_root, + &root_corpus_root, + &mut Vec::new(), + &root_exclusions, + root_device, + false, + false, + &mut root_snapshot_counters, + &mut root_files, + )?; + let stable_manifest_bytes = read_stable_regular( + &manifest.path, + V2_MAX_MANIFEST_BYTES, + "manifest-bytes", + false, + Some(root_device), + )?; + if stable_manifest_bytes != manifest.bytes { + return Err(ParentCorpusError::at( + ParentCorpusErrorCode::SnapshotChanged, + &manifest.path, + "root federation manifest changed while it was parsed", + )); + } + let root_override_count = manifest + .overrides + .as_ref() + .and_then(serde_yaml::Value::as_mapping) + .and_then(|mapping| mapping.get(serde_yaml::Value::String("items".to_string()))) + .and_then(serde_yaml::Value::as_sequence) + .map_or(0, Vec::len); + let mut verification = GraphVerification { + invocation_root: repository_root.clone(), + counters: GraphCounters { + overrides: root_override_count, + ..GraphCounters::default() + }, + physical_captures: BTreeMap::new(), + expanded_physical: BTreeSet::new(), + logical_nodes: BTreeMap::new(), + edges: Vec::new(), + materialisation_roots: BTreeSet::new(), + corpus_roots: BTreeSet::new(), + }; + verification.verify_edges( + &root_source, + &repository_root, + &manifest.parents, + 1, + &mut vec![root_source.clone()], + false, + )?; + verification.edges.sort_by(|left, right| { + ( + &left.owner_source, + &left.target_source, + &left.declared_digest, + &left.alias, + &left.root, + &left.corpus, + &left.materialisation_root, + ) + .cmp(&( + &right.owner_source, + &right.target_source, + &right.declared_digest, + &right.alias, + &right.root, + &right.corpus, + &right.materialisation_root, + )) + }); + Ok(Some(VerifiedFederation { + repository_root, + root_source, + root_corpus_path: root_corpus_relative.to_string(), + root_corpus_root, + root_files, + root_config_path, + root_config_bytes, + manifest, + nodes: verification.logical_nodes.into_values().collect(), + edges: verification.edges, + materialisation_roots: verification.materialisation_roots.into_iter().collect(), + corpus_roots: verification.corpus_roots.into_iter().collect(), + })) +} + fn exact_config_source( config_path: &Path, missing_config: ParentCorpusErrorCode, diff --git a/rust/rac-engine/src/federation_generation.rs b/rust/rac-engine/src/federation_generation.rs new file mode 100644 index 00000000..abbe9c2a --- /dev/null +++ b/rust/rac-engine/src/federation_generation.rs @@ -0,0 +1,457 @@ +//! Canonical closure-generation hashing for graph federation (ADR-148). +//! +//! This module is deliberately filesystem-free. The verified loader owns +//! capture, containment, limits, topology, and semantic validation; this +//! module turns that already-verified logical closure into the exact portable +//! `sha256-v3:` generation used by derived state and `store/v3`. + +use crate::{corpus::ArtifactKey, sha256::Sha256}; + +pub const GENERATION_DOMAIN: &[u8] = b"asdecided-federation-generation-v3\0"; +pub const GRAPH_CONTRACT: &[u8] = b"corpus-federation-graph/v2"; +pub const ARTIFACT_SPEC_FINGERPRINT: &[u8] = b"artifact-spec-registry/v1"; +pub const RELATIONSHIP_DESCRIPTION_FINGERPRINT: &[u8] = b"relationship-description-registry/v1"; +pub const TOKENIZER_RANKING_FINGERPRINT: &[u8] = b"tokenizer-ranking-graph-floor/v1"; +pub const DERIVED_SCHEMA_FINGERPRINT: &[u8] = b"federation-derived/v3"; +pub const STORE_LAYOUT_FINGERPRINT: &[u8] = b"store/v3"; + +/// Exact ADR-144 limit block committed into every version-3 generation. +pub const LIMIT_BLOCK: &[u8] = b"manifest-bytes=1048576\n\ +config-bytes=1048576\n\ +alias-bytes=64\n\ +source-bytes=255\n\ +path-bytes=4096\n\ +path-components=64\n\ +path-component-bytes=255\n\ +yaml-depth=32\n\ +yaml-nodes=16384\n\ +direct-parents=32\n\ +depth=16\n\ +unique-inherited-sources=256\n\ +edges=1024\n\ +overrides=4096\n\ +inherited-files=50000\n\ +file-bytes=16777216\n\ +logical-bytes=268435456\n\ +physical-bytes=536870912\n\ +visited-entries=200000\n"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GenerationFile { + pub relative_path: String, + pub bytes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GenerationNode { + pub source: String, + /// Canonical `sha256-v2:` digest, independent of the declared edge pin. + pub canonical_digest: String, + pub config_bytes: Vec, + pub manifest_bytes: Option>, + pub files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GenerationEdge { + pub owner_source: String, + pub target_source: String, + /// Exact declared `sha256:` or `sha256-v2:` pin text. + pub declared_pin: String, + pub alias: String, + pub root: String, + pub corpus: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GenerationMapping { + /// Parents-before-child, source-lexicographic Kahn rank. The rank selects + /// the ADR-147 total order but is not itself framed into the generation. + pub owner_rank: usize, + pub owner_source: String, + pub target: ArtifactKey, + pub replacement: ArtifactKey, + pub rationale: ArtifactKey, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GenerationRedirect { + pub target: ArtifactKey, + pub terminal: ArtifactKey, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GraphGenerationInput { + pub recursive: bool, + pub root_source: String, + /// Stable POSIX path relative to the root repository. + pub root_corpus_path: String, + pub root_config_bytes: Vec, + pub root_manifest_bytes: Option>, + pub root_files: Vec, + pub inherited_nodes: Vec, + pub edges: Vec, + pub mappings: Vec, + pub terminal_redirects: Vec, +} + +/// Compute ADR-148's canonical closure generation. +/// +/// Every semantic table is independently sorted, so filesystem discovery, +/// traversal, and manifest parent-list order cannot become precedence. Exact +/// manifest bytes remain committed as required by the accepted contract. +pub fn closure_generation(input: &GraphGenerationInput) -> String { + let mut hasher = Sha256::new(); + hasher.update(GENERATION_DOMAIN); + + frame(&mut hasher, 0x01, GRAPH_CONTRACT); + frame(&mut hasher, 0x02, LIMIT_BLOCK); + frame( + &mut hasher, + 0x03, + &[if input.recursive { 0x01 } else { 0x00 }], + ); + frame(&mut hasher, 0x04, input.root_source.as_bytes()); + frame(&mut hasher, 0x05, input.root_corpus_path.as_bytes()); + frame(&mut hasher, 0x06, &input.root_config_bytes); + manifest_frames( + &mut hasher, + 0x07, + 0x08, + input.root_manifest_bytes.as_deref(), + ); + + for file in sorted_files(&input.root_files) { + frame(&mut hasher, 0x09, file.relative_path.as_bytes()); + frame(&mut hasher, 0x0a, &file.bytes); + } + + let mut nodes: Vec<_> = input.inherited_nodes.iter().collect(); + nodes.sort_by(|left, right| { + (&left.source, &left.canonical_digest).cmp(&(&right.source, &right.canonical_digest)) + }); + for node in nodes { + frame(&mut hasher, 0x10, node.source.as_bytes()); + frame(&mut hasher, 0x11, node.canonical_digest.as_bytes()); + frame(&mut hasher, 0x12, &node.config_bytes); + manifest_frames(&mut hasher, 0x13, 0x14, node.manifest_bytes.as_deref()); + for file in sorted_files(&node.files) { + frame(&mut hasher, 0x15, file.relative_path.as_bytes()); + frame(&mut hasher, 0x16, &file.bytes); + } + frame(&mut hasher, 0x17, &[]); + } + + let mut edges: Vec<_> = input.edges.iter().collect(); + edges.sort_by(|left, right| edge_key(left).cmp(&edge_key(right))); + for edge in edges { + frame(&mut hasher, 0x20, edge.owner_source.as_bytes()); + frame(&mut hasher, 0x21, edge.target_source.as_bytes()); + frame(&mut hasher, 0x22, edge.declared_pin.as_bytes()); + frame(&mut hasher, 0x23, edge.alias.as_bytes()); + frame(&mut hasher, 0x24, edge.root.as_bytes()); + frame(&mut hasher, 0x25, edge.corpus.as_bytes()); + frame(&mut hasher, 0x26, &[]); + } + + let mut mappings: Vec<_> = input.mappings.iter().collect(); + mappings.sort_by(|left, right| mapping_key(left).cmp(&mapping_key(right))); + for mapping in mappings { + frame(&mut hasher, 0x30, mapping.owner_source.as_bytes()); + artifact_frames(&mut hasher, 0x31, 0x32, &mapping.target); + artifact_frames(&mut hasher, 0x33, 0x34, &mapping.replacement); + artifact_frames(&mut hasher, 0x35, 0x36, &mapping.rationale); + frame(&mut hasher, 0x37, &[]); + } + + let mut redirects: Vec<_> = input.terminal_redirects.iter().collect(); + redirects.sort_by(|left, right| { + (&left.target.source, &left.target.canonical_id) + .cmp(&(&right.target.source, &right.target.canonical_id)) + }); + for redirect in redirects { + artifact_frames(&mut hasher, 0x38, 0x39, &redirect.target); + artifact_frames(&mut hasher, 0x3a, 0x3b, &redirect.terminal); + frame(&mut hasher, 0x3c, &[]); + } + + frame(&mut hasher, 0x40, ARTIFACT_SPEC_FINGERPRINT); + frame(&mut hasher, 0x41, RELATIONSHIP_DESCRIPTION_FINGERPRINT); + frame(&mut hasher, 0x42, TOKENIZER_RANKING_FINGERPRINT); + frame(&mut hasher, 0x43, DERIVED_SCHEMA_FINGERPRINT); + frame(&mut hasher, 0x44, STORE_LAYOUT_FINGERPRINT); + + format!("sha256-v3:{}", hasher.hexdigest()) +} + +/// Adapt the exact verified closure and its one compiled semantic graph into +/// ADR-148's logical-generation input without reopening either layer. +pub fn generation_input_from_verified( + verified: &crate::federation::VerifiedFederation, + recursive: bool, + composition: &crate::graph_composition::GraphComposition, +) -> GraphGenerationInput { + GraphGenerationInput { + recursive, + root_source: verified.root_source.clone(), + root_corpus_path: verified.root_corpus_path.clone(), + root_config_bytes: verified.root_config_bytes.clone(), + root_manifest_bytes: Some(verified.manifest.bytes.clone()), + root_files: verified.root_files.iter().map(generation_file).collect(), + inherited_nodes: verified + .nodes + .iter() + .map(|node| GenerationNode { + source: node.source.clone(), + canonical_digest: node.digest.clone(), + config_bytes: node.config_bytes.clone(), + manifest_bytes: node.manifest_bytes.clone(), + files: node.files.iter().map(generation_file).collect(), + }) + .collect(), + edges: verified + .edges + .iter() + .map(|edge| GenerationEdge { + owner_source: edge.owner_source.clone(), + target_source: edge.target_source.clone(), + declared_pin: edge.declared_digest.clone(), + alias: edge.alias.clone(), + root: edge.root.clone(), + corpus: edge.corpus.clone(), + }) + .collect(), + mappings: composition + .ordered_overrides() + .iter() + .map(|mapping| GenerationMapping { + owner_rank: mapping.owner_rank, + owner_source: mapping.owner_source.clone(), + target: mapping.target.clone(), + replacement: mapping.replacement.clone(), + rationale: mapping.rationale.clone(), + }) + .collect(), + terminal_redirects: composition + .terminal_redirects() + .iter() + .map(|(target, terminal)| GenerationRedirect { + target: target.clone(), + terminal: terminal.clone(), + }) + .collect(), + } +} + +pub fn closure_generation_from_verified( + verified: &crate::federation::VerifiedFederation, + recursive: bool, + composition: &crate::graph_composition::GraphComposition, +) -> String { + closure_generation(&generation_input_from_verified( + verified, + recursive, + composition, + )) +} + +fn generation_file(file: &crate::federation::SnapshotFile) -> GenerationFile { + GenerationFile { + relative_path: file.relative_path.clone(), + bytes: file.bytes.clone(), + } +} + +fn frame(hasher: &mut Sha256, tag: u8, payload: &[u8]) { + hasher.update(&[tag]); + hasher.update(&(payload.len() as u64).to_be_bytes()); + hasher.update(payload); +} + +fn manifest_frames(hasher: &mut Sha256, presence_tag: u8, bytes_tag: u8, manifest: Option<&[u8]>) { + match manifest { + Some(bytes) => { + frame(hasher, presence_tag, &[0x01]); + frame(hasher, bytes_tag, bytes); + } + None => frame(hasher, presence_tag, &[0x00]), + } +} + +fn artifact_frames(hasher: &mut Sha256, source_tag: u8, id_tag: u8, key: &ArtifactKey) { + frame(hasher, source_tag, key.source.as_bytes()); + frame(hasher, id_tag, key.canonical_id.as_bytes()); +} + +fn sorted_files(files: &[GenerationFile]) -> Vec<&GenerationFile> { + let mut sorted: Vec<_> = files.iter().collect(); + sorted.sort_by(|left, right| { + left.relative_path + .as_bytes() + .cmp(right.relative_path.as_bytes()) + }); + sorted +} + +fn edge_key(edge: &GenerationEdge) -> (&str, &str, &str, &str, &str, &str) { + ( + &edge.owner_source, + &edge.target_source, + &edge.declared_pin, + &edge.alias, + &edge.root, + &edge.corpus, + ) +} + +fn mapping_key( + mapping: &GenerationMapping, +) -> (usize, &str, &str, &str, &str, &str, &str, &str) { + ( + mapping.owner_rank, + &mapping.owner_source, + &mapping.target.source, + &mapping.target.canonical_id, + &mapping.replacement.source, + &mapping.replacement.canonical_id, + &mapping.rationale.source, + &mapping.rationale.canonical_id, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(source: &str, id: &str) -> ArtifactKey { + ArtifactKey::new(source, id) + } + + fn fixture() -> GraphGenerationInput { + GraphGenerationInput { + recursive: true, + root_source: "acme/app".into(), + root_corpus_path: "decisions".into(), + root_config_bytes: b"corpus:\n source: acme/app\n".to_vec(), + root_manifest_bytes: Some( + b"# Corpus\n\n## inherits\n\n```yaml\nversion: 2\n```\n".to_vec(), + ), + root_files: vec![ + GenerationFile { + relative_path: "requirements/b.md".into(), + bytes: b"B".to_vec(), + }, + GenerationFile { + relative_path: "decisions/a.md".into(), + bytes: b"A".to_vec(), + }, + ], + inherited_nodes: vec![GenerationNode { + source: "acme/standards".into(), + canonical_digest: format!("sha256-v2:{}", "1".repeat(64)), + config_bytes: b"corpus:\n source: acme/standards\n".to_vec(), + manifest_bytes: None, + files: vec![GenerationFile { + relative_path: "decisions/standard.md".into(), + bytes: b"standard".to_vec(), + }], + }], + edges: vec![GenerationEdge { + owner_source: "acme/app".into(), + target_source: "acme/standards".into(), + declared_pin: format!("sha256-v2:{}", "1".repeat(64)), + alias: "standards".into(), + root: "vendor/standards".into(), + corpus: "decisions".into(), + }], + mappings: vec![GenerationMapping { + owner_rank: 1, + owner_source: "acme/app".into(), + target: key("acme/standards", "STD-0123456789AB"), + replacement: key("acme/app", "APP-0123456789AB"), + rationale: key("acme/app", "APP-ABCDEFGHJKMN"), + }], + terminal_redirects: vec![GenerationRedirect { + target: key("acme/standards", "STD-0123456789AB"), + terminal: key("acme/app", "APP-0123456789AB"), + }], + } + } + + #[test] + fn generation_has_frozen_known_vector() { + assert_eq!( + closure_generation(&fixture()), + "sha256-v3:2e64f14ddf26d996a5910a844d92ad66e121b1711b9899560c77257134f79c65" + ); + } + + #[test] + fn semantic_table_order_does_not_change_generation() { + let expected = closure_generation(&fixture()); + let mut permuted = fixture(); + permuted.root_files.reverse(); + permuted.inherited_nodes.reverse(); + permuted.edges.reverse(); + permuted.mappings.reverse(); + permuted.terminal_redirects.reverse(); + assert_eq!(closure_generation(&permuted), expected); + } + + #[test] + fn mapping_order_uses_topological_owner_rank_before_source() { + let mut ranked = fixture(); + ranked.mappings.push(GenerationMapping { + owner_rank: 0, + owner_source: "z-parent".into(), + target: key("acme/standards", "STD-ABCDEFGHJKMN"), + replacement: key("z-parent", "STD-ABCDEFGHJKMN"), + rationale: key("z-parent", "DEC-ABCDEFGHJKMN"), + }); + let expected = closure_generation(&ranked); + ranked.mappings.reverse(); + assert_eq!(closure_generation(&ranked), expected); + + ranked.mappings[0].owner_rank = 2; + assert_ne!(closure_generation(&ranked), expected); + } + + #[test] + fn every_answer_affecting_section_changes_generation() { + type GenerationMutation = Box; + + let original = fixture(); + let expected = closure_generation(&original); + + let mut mutations: Vec = vec![ + Box::new(|input| input.recursive = false), + Box::new(|input| input.root_source.push('2')), + Box::new(|input| input.root_corpus_path.push('2')), + Box::new(|input| input.root_config_bytes.push(b'2')), + Box::new(|input| input.root_manifest_bytes.as_mut().unwrap().push(b'2')), + Box::new(|input| input.root_files[0].bytes.push(b'2')), + Box::new(|input| input.inherited_nodes[0].canonical_digest.push('2')), + Box::new(|input| input.inherited_nodes[0].config_bytes.push(b'2')), + Box::new(|input| input.inherited_nodes[0].manifest_bytes = Some(b"nested".to_vec())), + Box::new(|input| input.inherited_nodes[0].files[0].bytes.push(b'2')), + Box::new(|input| input.edges[0].alias.push('2')), + Box::new(|input| input.mappings[0].rationale.canonical_id.push('2')), + Box::new(|input| input.terminal_redirects[0].terminal.canonical_id.push('2')), + ]; + + for mutate in mutations.drain(..) { + let mut changed = original.clone(); + mutate(&mut changed); + assert_ne!(closure_generation(&changed), expected); + } + } + + #[test] + fn manifest_absence_differs_from_empty_manifest() { + let mut absent = fixture(); + absent.root_manifest_bytes = None; + let mut empty = fixture(); + empty.root_manifest_bytes = Some(Vec::new()); + assert_ne!(closure_generation(&absent), closure_generation(&empty)); + } +} diff --git a/rust/rac-engine/src/graph_composition.rs b/rust/rac-engine/src/graph_composition.rs new file mode 100644 index 00000000..c1789f7c --- /dev/null +++ b/rust/rac-engine/src/graph_composition.rs @@ -0,0 +1,1580 @@ +//! Version-2 source-graph composition (ADR-144, ADR-146, ADR-147). +//! +//! This is the semantic half of federation. It accepts an already verified +//! logical topology and parsed snapshot items; it performs no filesystem, +//! manifest, digest, cache, or network work. The v1 [`crate::composition`] +//! API remains unchanged. A version-2 loader can therefore activate this +//! adapter only after it has authenticated the complete closure. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::corpus::{ArtifactKey, Layer}; +use crate::identity::artifact_identifiers; +use crate::pycompat::py_casefold; +use crate::relationships::{ + edge_spec, extract_relationships_full, CorpusItem, Relationship, ISSUE_SELF_REFERENCE, + ISSUE_TARGET_AMBIGUOUS, ISSUE_TARGET_NOT_FOUND, +}; +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_INVALID_GRAPH: &str = "corpus-federation-invalid-graph"; +pub const FINDING_INVALID_OVERRIDE: &str = "corpus-federation-invalid-override"; +pub const FINDING_OVERRIDE_DIVERGENCE: &str = "corpus-federation-override-divergence"; + +/// One direct, edge-local alias owned by the declaring source. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct SourceParentInput { + pub source: String, + pub alias: String, +} + +impl SourceParentInput { + pub fn new(source: impl Into, alias: impl Into) -> Self { + Self { + source: source.into(), + alias: alias.into(), + } + } +} + +/// A unique logical source node. Canonical node digests and physical routes +/// belong to verification/generation and deliberately do not enter this type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceNodeInput { + pub source: String, + pub direct_parents: Vec, +} + +impl SourceNodeInput { + pub fn new(source: impl Into, direct_parents: Vec) -> Self { + Self { + source: source.into(), + direct_parents, + } + } +} + +/// One already-parsed version-2 override declaration. The loader turns the +/// manifest's globally qualified target and local canonical operands into +/// stable keys before crossing this boundary. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct GraphOverrideDeclaration { + pub owner_source: String, + pub target: ArtifactKey, + pub replacement: ArtifactKey, + pub rationale: ArtifactKey, +} + +impl GraphOverrideDeclaration { + pub fn new( + owner_source: impl Into, + target: ArtifactKey, + replacement: ArtifactKey, + rationale: ArtifactKey, + ) -> Self { + Self { + owner_source: owner_source.into(), + target, + replacement, + rationale, + } + } +} + +/// The filesystem-independent handoff from `VerifiedFederation`. +pub struct GraphCompositionInput { + pub root_source: String, + pub nodes: Vec, + pub items: Vec, + pub overrides: Vec, +} + +impl GraphCompositionInput { + pub fn new( + root_source: impl Into, + nodes: Vec, + items: Vec, + overrides: Vec, + ) -> Self { + Self { + root_source: root_source.into(), + nodes, + items, + overrides, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum GraphFindingReason { + MissingRoot, + DuplicateSource, + UnknownParent, + DuplicateParent, + DuplicateAlias, + InvalidAlias, + Cycle, + UnreachableSource, + UnknownItemSource, + IdentitySourceMismatch, + DuplicateArtifactKey, + UnknownOverrideOwner, + DuplicateOverrideTarget, + TargetNotInherited, + ReplacementNotLocal, + ReplacementNotFound, + RationaleNotLocal, + RationaleNotFound, + RationaleNotDecision, + RationaleNotLive, + TypeMismatch, + SameManifestChain, + OverrideCycle, + DivergentTerminal, +} + +impl GraphFindingReason { + pub const fn as_str(self) -> &'static str { + match self { + Self::MissingRoot => "missing-root", + Self::DuplicateSource => "duplicate-source", + Self::UnknownParent => "unknown-parent", + Self::DuplicateParent => "duplicate-parent", + Self::DuplicateAlias => "duplicate-alias", + Self::InvalidAlias => "invalid-alias", + Self::Cycle => "cycle", + Self::UnreachableSource => "unreachable-source", + Self::UnknownItemSource => "unknown-item-source", + Self::IdentitySourceMismatch => "identity-source-mismatch", + Self::DuplicateArtifactKey => "duplicate-artifact-key", + Self::UnknownOverrideOwner => "unknown-override-owner", + Self::DuplicateOverrideTarget => "duplicate-override-target", + Self::TargetNotInherited => "target-not-inherited", + Self::ReplacementNotLocal => "replacement-not-local", + Self::ReplacementNotFound => "replacement-not-found", + Self::RationaleNotLocal => "rationale-not-local", + Self::RationaleNotFound => "rationale-not-found", + Self::RationaleNotDecision => "rationale-not-decision", + Self::RationaleNotLive => "rationale-not-live", + Self::TypeMismatch => "type-mismatch", + Self::SameManifestChain => "same-manifest-chain", + Self::OverrideCycle => "override-cycle", + Self::DivergentTerminal => "divergent-terminal", + } + } +} + +/// A deterministic graph-composition blocker. Verification findings carry +/// routes; this semantic layer identifies the owning source and stable keys. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GraphCompositionFinding { + pub code: &'static str, + pub reason: GraphFindingReason, + pub owner_source: Option, + pub artifacts: Vec, + pub message: String, +} + +/// Closure visibility and edge-local alias scope. Both tables use bytewise +/// `BTree*` ordering and never contain physical locators. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceVisibility { + visible: BTreeMap>, + aliases: BTreeMap<(String, String), String>, +} + +impl SourceVisibility { + pub fn is_visible(&self, context: &str, source: &str) -> bool { + self.visible + .get(context) + .is_some_and(|sources| sources.contains(source)) + } + + pub fn visible_from(&self, context: &str) -> Option<&BTreeSet> { + self.visible.get(context) + } + + pub fn alias_target(&self, context: &str, alias: &str) -> Option<&str> { + self.aliases + .get(&(context.to_string(), alias.to_string())) + .map(String::as_str) + } + + pub fn aliases(&self) -> &BTreeMap<(String, String), String> { + &self.aliases + } +} + +/// One validated mapping with the accepted total owner order embedded. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct CompiledOverride { + pub owner_rank: usize, + pub owner_source: String, + pub target: ArtifactKey, + pub replacement: ArtifactKey, + pub rationale: ArtifactKey, +} + +impl CompiledOverride { + fn from_declaration(owner_rank: usize, declaration: &GraphOverrideDeclaration) -> Self { + Self { + owner_rank, + owner_source: declaration.owner_source.clone(), + target: declaration.target.clone(), + replacement: declaration.replacement.clone(), + rationale: declaration.rationale.clone(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum GraphOverrideState { + Overridden, + Replacement, + Lineage, +} + +impl GraphOverrideState { + pub const fn as_str(self) -> &'static str { + match self { + Self::Overridden => "overridden", + Self::Replacement => "replacement", + Self::Lineage => "lineage", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GraphOverrideProvenance { + pub state: GraphOverrideState, + pub owner_source: String, + pub target: ArtifactKey, + pub replacement: ArtifactKey, + pub rationale: ArtifactKey, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GraphResolution { + /// Every source-owned record matching the authored token, sorted by key. + pub historical_candidates: Vec, + /// Qualified lookup selects history; unqualified lookup selects the one + /// effective terminal after explicit redirects. + pub selected: ArtifactKey, + /// The source-contextual terminal even when `selected` is historical. + pub effective_terminal: ArtifactKey, + pub qualified: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GraphLookupError { + UnknownContext, + NotFound, + InvalidQualifiedReference, + QualifiedCanonicalRequired, + Ambiguous { + historical_candidates: Vec, + effective_candidates: Vec, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GraphRelationshipIssue { + TargetNotFound, + TargetAmbiguous, + SelfReference, +} + +/// One catalog relationship endpoint. `historical_candidates` records the +/// immutable authored meaning; `effective_terminal` is a separate live value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GraphRelationship { + pub source: ArtifactKey, + pub relationship: String, + pub authored_token: String, + pub historical_candidates: Vec, + pub effective_terminal: Option, + pub issue: Option, + pub external: bool, +} + +type OverrideRoute = Vec; + +#[derive(Debug, Clone, Default)] +struct OriginRoutes { + /// A historical key may arrive through several graph branches. Routes to + /// an equal terminal remain distinct for provenance but do not diverge. + by_terminal: BTreeMap>, +} + +#[derive(Debug, Clone, Default)] +struct EffectiveProjection { + origins: BTreeMap, +} + +impl EffectiveProjection { + fn insert_identity(&mut self, key: ArtifactKey) { + self.origins + .entry(key.clone()) + .or_default() + .by_terminal + .entry(key) + .or_default() + .insert(Vec::new()); + } + + fn merge(&mut self, other: &Self) { + for (origin, routes) in &other.origins { + let destination = self.origins.entry(origin.clone()).or_default(); + for (terminal, paths) in &routes.by_terminal { + destination + .by_terminal + .entry(terminal.clone()) + .or_default() + .extend(paths.iter().cloned()); + } + } + } + + fn terminals(&self) -> BTreeSet { + self.origins + .values() + .flat_map(|routes| routes.by_terminal.keys().cloned()) + .collect() + } + + fn apply(&mut self, replacements: &BTreeMap) { + for routes in self.origins.values_mut() { + let previous = std::mem::take(&mut routes.by_terminal); + for (terminal, paths) in previous { + if let Some(mapping) = replacements.get(&terminal) { + let redirected = routes + .by_terminal + .entry(mapping.replacement.clone()) + .or_default(); + for mut path in paths { + path.push(mapping.clone()); + redirected.insert(path); + } + } else { + routes + .by_terminal + .entry(terminal) + .or_default() + .extend(paths); + } + } + } + } + + fn terminal_for(&self, key: &ArtifactKey) -> Option<&ArtifactKey> { + let terminals = &self.origins.get(key)?.by_terminal; + (terminals.len() == 1) + .then(|| terminals.keys().next()) + .flatten() + } +} + +/// The unique catalog plus source-contextual and root-effective projections. +pub struct GraphComposition { + root_source: String, + items: Vec, + item_by_key: BTreeMap, + local_by_source: BTreeMap>, + root_local: Vec, + root_effective: Vec, + visibility: SourceVisibility, + projections: BTreeMap, + identifiers: BTreeMap>>, + canonical: BTreeMap<(String, String), ArtifactKey>, + topological_sources: Vec, + ordered_overrides: Vec, + terminal_redirects: BTreeMap, + provenance: BTreeMap>, +} + +impl GraphComposition { + pub fn compose(input: GraphCompositionInput) -> Result> { + let topology = PreparedTopology::prepare(&input.root_source, input.nodes)?; + + let PreparedItems { + items, + item_by_key, + local_by_source, + identifiers, + canonical, + } = PreparedItems::prepare(&input.root_source, &topology.nodes, input.items)?; + + let declarations = + prepare_override_declarations(input.overrides, &topology, &item_by_key, &items)?; + + let mut projections: BTreeMap = BTreeMap::new(); + let mut ordered_overrides = Vec::new(); + let mut findings = Vec::new(); + + for source in &topology.order { + let node = &topology.nodes[source]; + let mut projection = EffectiveProjection::default(); + for parent in &node.direct_parents { + if let Some(parent_projection) = projections.get(&parent.source) { + projection.merge(parent_projection); + } + } + + let live_inherited = projection.terminals(); + let owner_declarations = declarations.get(source).cloned().unwrap_or_default(); + let mut replacements = BTreeMap::new(); + for declaration in owner_declarations { + if !live_inherited.contains(&declaration.target) { + findings.push(override_finding( + GraphFindingReason::TargetNotInherited, + &declaration, + vec![declaration.target.clone()], + )); + continue; + } + let mapping = + CompiledOverride::from_declaration(topology.rank[source], &declaration); + replacements.insert(mapping.target.clone(), mapping.clone()); + ordered_overrides.push(mapping); + } + + projection.apply(&replacements); + if let Some(local) = local_by_source.get(source) { + for index in local { + projection.insert_identity(items[*index].key.clone()); + } + } + + for (origin, routes) in &projection.origins { + if routes.by_terminal.len() <= 1 { + continue; + } + let mut artifacts = vec![origin.clone()]; + artifacts.extend(routes.by_terminal.keys().cloned()); + artifacts.sort(); + artifacts.dedup(); + findings.push(GraphCompositionFinding { + code: FINDING_OVERRIDE_DIVERGENCE, + reason: GraphFindingReason::DivergentTerminal, + owner_source: Some(source.clone()), + message: format!( + "source '{source}' leaves historical artifact '{}::{}' with {} effective terminals", + origin.source, + origin.canonical_id, + routes.by_terminal.len() + ), + artifacts, + }); + } + projections.insert(source.clone(), projection); + } + + ordered_overrides.sort(); + if override_graph_has_cycle(&ordered_overrides) { + findings.push(GraphCompositionFinding { + code: FINDING_INVALID_OVERRIDE, + reason: GraphFindingReason::OverrideCycle, + owner_source: None, + artifacts: ordered_overrides + .iter() + .flat_map(|mapping| [mapping.target.clone(), mapping.replacement.clone()]) + .collect::>() + .into_iter() + .collect(), + message: "the compiled override graph contains a cycle".to_string(), + }); + } + if !findings.is_empty() { + findings.sort_by(finding_order); + findings.dedup(); + return Err(findings); + } + + let root_projection = &projections[&input.root_source]; + let terminal_redirects: BTreeMap = root_projection + .origins + .iter() + .filter_map(|(origin, routes)| { + let terminal = routes.by_terminal.keys().next()?; + (origin != terminal).then(|| (origin.clone(), terminal.clone())) + }) + .collect(); + let effective_keys: BTreeSet = root_projection + .origins + .values() + .flat_map(|routes| routes.by_terminal.keys().cloned()) + .collect(); + let root_effective = items + .iter() + .enumerate() + .filter_map(|(index, item)| effective_keys.contains(&item.key).then_some(index)) + .collect(); + let root_local = local_by_source + .get(&input.root_source) + .cloned() + .unwrap_or_default(); + let provenance = build_provenance(root_projection, &ordered_overrides); + + Ok(Self { + root_source: input.root_source, + items, + item_by_key, + local_by_source, + root_local, + root_effective, + visibility: topology.visibility, + projections, + identifiers, + canonical, + topological_sources: topology.order, + ordered_overrides, + terminal_redirects, + provenance, + }) + } + + pub fn root_source(&self) -> &str { + &self.root_source + } + + pub fn catalog(&self) -> impl ExactSizeIterator { + self.items.iter() + } + + pub fn effective(&self) -> impl ExactSizeIterator { + self.root_effective.iter().map(|index| &self.items[*index]) + } + + pub fn root_local(&self) -> impl ExactSizeIterator { + self.root_local.iter().map(|index| &self.items[*index]) + } + + /// Existing-shape ranking adapter for command/cache/MCP consumers. The + /// rows and inbound counts come only from the root-effective projection; + /// no caller reconstructs a graph overlay. + pub fn effective_index(&self) -> Vec { + let mut inbound: BTreeMap = BTreeMap::new(); + for relationship in self.effective_relationships() { + if relationship.issue.is_none() { + if let Some(target) = relationship.effective_terminal { + *inbound.entry(target).or_default() += 1; + } + } + } + self.effective() + .map(|item| entry_from_item(item, inbound.get(&item.key).copied().unwrap_or(0))) + .collect() + } + + /// Existing-shape exact-identity adapter. Effective unqualified aliases + /// point at terminals; stable source-qualified and root-direct aliases + /// continue to select source-owned catalog history. + 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(); + } + entry + .aliases + .push(format!("{}::{}", item.key.source, item.key.canonical_id)); + if let Some(alias) = + self.visibility + .aliases() + .iter() + .find_map(|((owner, alias), target)| { + (owner == &self.root_source && target == &item.key.source) + .then_some(alias.as_str()) + }) + { + entry + .aliases + .push(format!("{alias}::{}", item.key.canonical_id)); + } + entry.aliases.sort_by(|left, right| { + py_casefold(left) + .cmp(&py_casefold(right)) + .then_with(|| left.cmp(right)) + }); + entry + .aliases + .dedup_by(|left, right| py_casefold(left) == py_casefold(right)); + entry + }) + .collect(); + let entry_by_key: BTreeMap = entries + .iter() + .enumerate() + .filter_map(|(index, entry)| entry.key.clone().map(|key| (key, index))) + .collect(); + for (historical, terminal) in &self.terminal_redirects { + let Some(index) = entry_by_key.get(terminal).copied() else { + continue; + }; + if !entries[index] + .aliases + .iter() + .any(|alias| py_casefold(alias) == py_casefold(&historical.canonical_id)) + { + entries[index].aliases.push(historical.canonical_id.clone()); + } + } + entries + } + + pub fn local_to(&self, source: &str) -> impl Iterator { + self.local_by_source + .get(source) + .into_iter() + .flatten() + .map(|index| &self.items[*index]) + } + + /// The effective view rooted at an immutable authoring source. This is + /// the structural/relationship-validation projection for that node; root + /// serving should use [`Self::effective`] instead. + pub fn effective_from(&self, source: &str) -> Option> { + let projection = self.projections.get(source)?; + let terminals: BTreeSet<&ArtifactKey> = projection + .origins + .values() + .flat_map(|routes| routes.by_terminal.keys()) + .collect(); + Some( + self.items + .iter() + .filter(|item| terminals.contains(&item.key)) + .collect(), + ) + } + + pub fn terminal_from(&self, source: &str, key: &ArtifactKey) -> Option<&ArtifactKey> { + self.projections.get(source)?.terminal_for(key) + } + + pub fn item(&self, key: &ArtifactKey) -> Option<&CorpusItem> { + self.item_by_key.get(key).map(|index| &self.items[*index]) + } + + pub fn visibility(&self) -> &SourceVisibility { + &self.visibility + } + + pub fn topological_sources(&self) -> &[String] { + &self.topological_sources + } + + pub fn ordered_overrides(&self) -> &[CompiledOverride] { + &self.ordered_overrides + } + + pub fn terminal_redirects(&self) -> &BTreeMap { + &self.terminal_redirects + } + + pub fn provenance_for(&self, key: &ArtifactKey) -> Option<&[GraphOverrideProvenance]> { + self.item_by_key + .contains_key(key) + .then(|| self.provenance.get(key).map(Vec::as_slice).unwrap_or(&[])) + } + + /// Public root lookup. Qualified tokens select source-owned history; + /// unqualified tokens select the one root-effective terminal. + pub fn resolve_public(&self, reference: &str) -> Result { + self.resolve_from(&self.root_source, reference) + } + + /// Existing exact-lookup response adapter for public root consumers. + pub fn resolve_identity(&self, reference: &str) -> ResolutionResult { + match self.resolve_public(reference) { + Ok(resolution) => self + .item(&resolution.selected) + .map(|item| ResolutionResult { + artifact_id: reference.to_string(), + outcome: OUTCOME_RESOLVED, + artifact: Some(resolved_from_entry(&identity_entry_from_item(item))), + duplicate_paths: Vec::new(), + }) + .unwrap_or_else(|| not_found_resolution(reference)), + Err(GraphLookupError::Ambiguous { + historical_candidates, + .. + }) => { + let mut paths: Vec = historical_candidates + .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(_) => not_found_resolution(reference), + } + } + + /// Resolve using the immutable authoring source's visibility and aliases. + pub fn resolve_from( + &self, + context: &str, + reference: &str, + ) -> Result { + let analyzed = self.analyze_reference(context, reference)?; + let selected = if analyzed.qualified { + analyzed.historical_candidates[0].clone() + } else { + analyzed.effective_terminal.clone() + }; + Ok(GraphResolution { + historical_candidates: analyzed.historical_candidates, + selected, + effective_terminal: analyzed.effective_terminal, + qualified: analyzed.qualified, + }) + } + + pub fn catalog_relationships(&self) -> Vec { + self.items + .iter() + .flat_map(|item| self.relationships_for(item, false)) + .collect() + } + + pub fn effective_relationships(&self) -> Vec { + self.effective() + .flat_map(|item| self.relationships_for(item, true)) + .collect() + } + + /// Existing live relationship shape. The richer catalog history remains + /// available only through [`Self::catalog_relationships`] because the v1 + /// `Relationship` type cannot represent several historical candidates and + /// a separate terminal without losing information. + pub fn relationships(&self) -> Vec { + self.effective_relationships() + .into_iter() + .map(|relationship| self.compatible_relationship(relationship)) + .collect() + } + + fn compatible_relationship(&self, relationship: GraphRelationship) -> Relationship { + let source_item = self.item(&relationship.source); + let target_item = relationship + .effective_terminal + .as_ref() + .and_then(|key| self.item(key)); + Relationship { + source_artifact: source_item.map(|item| item.artifact_path.clone()), + source_path: source_item + .map(|item| item.path.clone()) + .unwrap_or_default(), + relationship: relationship.relationship, + target: relationship.authored_token, + resolved_artifact: target_item.map(|item| item.artifact_path.clone()), + resolved_path: target_item.map(|item| item.path.clone()), + issue: relationship.issue.map(|issue| match issue { + GraphRelationshipIssue::TargetNotFound => ISSUE_TARGET_NOT_FOUND.to_string(), + GraphRelationshipIssue::TargetAmbiguous => ISSUE_TARGET_AMBIGUOUS.to_string(), + GraphRelationshipIssue::SelfReference => ISSUE_SELF_REFERENCE.to_string(), + }), + } + } + + fn relationships_for(&self, item: &CorpusItem, root_effective: bool) -> Vec { + let Some(spec) = item.spec else { + return Vec::new(); + }; + let mut output = Vec::new(); + for (relationship, references) in extract_relationships_full(&item.artifact, spec) { + let external = edge_spec(&relationship).is_some_and(|edge| edge.external); + for reference in references { + if external { + output.push(GraphRelationship { + source: item.key.clone(), + relationship: relationship.clone(), + authored_token: reference, + historical_candidates: Vec::new(), + effective_terminal: None, + issue: None, + external: true, + }); + continue; + } + let analyzed = self.analyze_reference(&item.key.source, &reference); + let mut endpoint = match analyzed { + Ok(analyzed) => GraphRelationship { + source: item.key.clone(), + relationship: relationship.clone(), + authored_token: reference, + historical_candidates: analyzed.historical_candidates, + effective_terminal: Some(analyzed.effective_terminal), + issue: None, + external: false, + }, + Err(GraphLookupError::Ambiguous { + historical_candidates, + .. + }) => GraphRelationship { + source: item.key.clone(), + relationship: relationship.clone(), + authored_token: reference, + historical_candidates, + effective_terminal: None, + issue: Some(GraphRelationshipIssue::TargetAmbiguous), + external: false, + }, + Err(_) => GraphRelationship { + source: item.key.clone(), + relationship: relationship.clone(), + authored_token: reference, + historical_candidates: Vec::new(), + effective_terminal: None, + issue: Some(GraphRelationshipIssue::TargetNotFound), + external: false, + }, + }; + if root_effective { + if let Some(terminal) = endpoint.effective_terminal.as_ref() { + endpoint.effective_terminal = self + .projections + .get(&self.root_source) + .and_then(|projection| projection.terminal_for(terminal)) + .cloned(); + } + } + if endpoint.effective_terminal.as_ref() == Some(&item.key) { + endpoint.effective_terminal = None; + endpoint.issue = Some(GraphRelationshipIssue::SelfReference); + } + output.push(endpoint); + } + } + output + } + + fn analyze_reference( + &self, + context: &str, + reference: &str, + ) -> Result { + let Some(projection) = self.projections.get(context) else { + return Err(GraphLookupError::UnknownContext); + }; + if reference.contains("::") { + return self.analyze_qualified(context, reference, projection); + } + + let folded = py_casefold(reference); + let visible = self + .visibility + .visible_from(context) + .ok_or(GraphLookupError::UnknownContext)?; + let mut historical_candidates: Vec = visible + .iter() + .flat_map(|source| { + self.identifiers + .get(source) + .and_then(|identifiers| identifiers.get(&folded)) + .into_iter() + .flatten() + .cloned() + }) + .collect(); + historical_candidates.sort(); + historical_candidates.dedup(); + if historical_candidates.is_empty() { + return Err(GraphLookupError::NotFound); + } + let effective_candidates: BTreeSet = historical_candidates + .iter() + .filter_map(|candidate| projection.terminal_for(candidate).cloned()) + .collect(); + if effective_candidates.len() != 1 { + return Err(GraphLookupError::Ambiguous { + historical_candidates, + effective_candidates: effective_candidates.into_iter().collect(), + }); + } + Ok(AnalyzedReference { + historical_candidates, + effective_terminal: effective_candidates.into_iter().next().unwrap(), + qualified: false, + }) + } + + fn analyze_qualified( + &self, + context: &str, + reference: &str, + projection: &EffectiveProjection, + ) -> Result { + let Some((qualifier, canonical_id)) = reference.split_once("::") else { + return Err(GraphLookupError::InvalidQualifiedReference); + }; + if qualifier.is_empty() || canonical_id.is_empty() || canonical_id.contains("::") { + return Err(GraphLookupError::InvalidQualifiedReference); + } + let target_source = if qualifier.contains('/') { + if !self.visibility.is_visible(context, qualifier) { + return Err(GraphLookupError::NotFound); + } + qualifier + } else { + self.visibility + .alias_target(context, qualifier) + .ok_or(GraphLookupError::NotFound)? + }; + let folded = py_casefold(canonical_id); + let Some(key) = self + .canonical + .get(&(target_source.to_string(), folded.clone())) + else { + let alias_exists = self + .identifiers + .get(target_source) + .and_then(|identifiers| identifiers.get(&folded)) + .is_some(); + return Err(if alias_exists { + GraphLookupError::QualifiedCanonicalRequired + } else { + GraphLookupError::NotFound + }); + }; + let effective_terminal = projection + .terminal_for(key) + .cloned() + .ok_or(GraphLookupError::NotFound)?; + Ok(AnalyzedReference { + historical_candidates: vec![key.clone()], + effective_terminal, + qualified: true, + }) + } +} + +struct AnalyzedReference { + historical_candidates: Vec, + effective_terminal: ArtifactKey, + qualified: bool, +} + +struct PreparedTopology { + nodes: BTreeMap, + order: Vec, + rank: BTreeMap, + visibility: SourceVisibility, +} + +impl PreparedTopology { + fn prepare( + root_source: &str, + nodes: Vec, + ) -> Result> { + let mut findings = Vec::new(); + let mut by_source = BTreeMap::new(); + for mut node in nodes { + node.direct_parents.sort(); + if by_source.contains_key(&node.source) { + findings.push(graph_finding( + GraphFindingReason::DuplicateSource, + Some(node.source.clone()), + format!( + "logical source '{}' is declared more than once", + node.source + ), + )); + } else { + by_source.insert(node.source.clone(), node); + } + } + if !by_source.contains_key(root_source) { + findings.push(graph_finding( + GraphFindingReason::MissingRoot, + Some(root_source.to_string()), + format!("root source '{root_source}' is absent from the logical graph"), + )); + } + + for node in by_source.values() { + let mut sources = BTreeSet::new(); + let mut aliases = BTreeSet::new(); + for parent in &node.direct_parents { + if !valid_source_alias(&parent.alias) { + findings.push(graph_finding( + GraphFindingReason::InvalidAlias, + Some(node.source.clone()), + format!( + "source '{}' declares invalid edge alias '{}'", + node.source, parent.alias + ), + )); + } + if !by_source.contains_key(&parent.source) { + findings.push(graph_finding( + GraphFindingReason::UnknownParent, + Some(node.source.clone()), + format!( + "source '{}' declares unknown parent '{}'", + node.source, parent.source + ), + )); + } + if !sources.insert(parent.source.clone()) { + findings.push(graph_finding( + GraphFindingReason::DuplicateParent, + Some(node.source.clone()), + format!( + "source '{}' declares parent '{}' more than once", + node.source, parent.source + ), + )); + } + if !aliases.insert(parent.alias.clone()) { + findings.push(graph_finding( + GraphFindingReason::DuplicateAlias, + Some(node.source.clone()), + format!( + "source '{}' declares alias '{}' more than once", + node.source, parent.alias + ), + )); + } + } + } + if !findings.is_empty() { + findings.sort_by(finding_order); + findings.dedup(); + return Err(findings); + } + + let mut reachable = BTreeSet::new(); + let mut pending = vec![root_source.to_string()]; + while let Some(source) = pending.pop() { + if !reachable.insert(source.clone()) { + continue; + } + pending.extend( + by_source[&source] + .direct_parents + .iter() + .map(|parent| parent.source.clone()), + ); + } + for source in by_source.keys() { + if !reachable.contains(source) { + findings.push(graph_finding( + GraphFindingReason::UnreachableSource, + Some(source.clone()), + format!("source '{source}' is not reachable from root '{root_source}'"), + )); + } + } + + let mut indegree: BTreeMap = by_source + .iter() + .map(|(source, node)| (source.clone(), node.direct_parents.len())) + .collect(); + let mut children: BTreeMap> = BTreeMap::new(); + for node in by_source.values() { + for parent in &node.direct_parents { + children + .entry(parent.source.clone()) + .or_default() + .insert(node.source.clone()); + } + } + let mut ready: BTreeSet = indegree + .iter() + .filter_map(|(source, count)| (*count == 0).then_some(source.clone())) + .collect(); + let mut order = Vec::with_capacity(by_source.len()); + while let Some(source) = ready.pop_first() { + order.push(source.clone()); + for child in children.get(&source).into_iter().flatten() { + let remaining = indegree.get_mut(child).unwrap(); + *remaining -= 1; + if *remaining == 0 { + ready.insert(child.clone()); + } + } + } + if order.len() != by_source.len() { + let cyclic: Vec = indegree + .into_iter() + .filter_map(|(source, count)| (count != 0).then_some(source)) + .collect(); + findings.push(GraphCompositionFinding { + code: FINDING_INVALID_GRAPH, + reason: GraphFindingReason::Cycle, + owner_source: cyclic.first().cloned(), + artifacts: Vec::new(), + message: format!( + "source graph contains a cycle through {}", + cyclic.join(", ") + ), + }); + } + if !findings.is_empty() { + findings.sort_by(finding_order); + findings.dedup(); + return Err(findings); + } + + let rank = order + .iter() + .enumerate() + .map(|(rank, source)| (source.clone(), rank)) + .collect(); + let mut visible: BTreeMap> = BTreeMap::new(); + let mut aliases = BTreeMap::new(); + for source in &order { + let mut sources = BTreeSet::from([source.clone()]); + for parent in &by_source[source].direct_parents { + sources.extend(visible[&parent.source].iter().cloned()); + aliases.insert( + (source.clone(), parent.alias.clone()), + parent.source.clone(), + ); + } + visible.insert(source.clone(), sources); + } + Ok(Self { + nodes: by_source, + order, + rank, + visibility: SourceVisibility { visible, aliases }, + }) + } +} + +struct PreparedItems { + items: Vec, + item_by_key: BTreeMap, + local_by_source: BTreeMap>, + identifiers: BTreeMap>>, + canonical: BTreeMap<(String, String), ArtifactKey>, +} + +impl PreparedItems { + fn prepare( + root_source: &str, + nodes: &BTreeMap, + mut items: Vec, + ) -> Result> { + items.sort_by(|left, right| { + left.artifact_path + .cmp(&right.artifact_path) + .then_with(|| left.key.cmp(&right.key)) + }); + let mut findings = Vec::new(); + let mut item_by_key = BTreeMap::new(); + let mut local_by_source: BTreeMap> = BTreeMap::new(); + let mut identifiers: BTreeMap>> = BTreeMap::new(); + let mut canonical = BTreeMap::new(); + + for (index, item) in items.iter().enumerate() { + if !nodes.contains_key(&item.key.source) { + findings.push(item_finding( + GraphFindingReason::UnknownItemSource, + item, + format!("artifact belongs to unknown source '{}'", item.key.source), + )); + continue; + } + if item.key.source != item.origin.source || item.key.source != item.artifact_path.source + { + findings.push(item_finding( + GraphFindingReason::IdentitySourceMismatch, + item, + "artifact key, path, and origin sources differ".to_string(), + )); + continue; + } + let expected_layer = if item.key.source == root_source { + Layer::Local + } else { + Layer::Inherited + }; + if item.origin.layer != expected_layer { + findings.push(item_finding( + GraphFindingReason::IdentitySourceMismatch, + item, + format!( + "source '{}' has layer '{}' but graph root semantics require '{}'", + item.key.source, + item.origin.layer.as_str(), + expected_layer.as_str() + ), + )); + continue; + } + if item_by_key.insert(item.key.clone(), index).is_some() { + findings.push(item_finding( + GraphFindingReason::DuplicateArtifactKey, + item, + format!( + "artifact key '{}::{}' occurs more than once", + item.key.source, item.key.canonical_id + ), + )); + continue; + } + local_by_source + .entry(item.key.source.clone()) + .or_default() + .push(index); + let canonical_key = (item.key.source.clone(), py_casefold(&item.key.canonical_id)); + if let Some(existing) = canonical.insert(canonical_key, item.key.clone()) { + findings.push(GraphCompositionFinding { + code: FINDING_INVALID_GRAPH, + reason: GraphFindingReason::DuplicateArtifactKey, + owner_source: Some(item.key.source.clone()), + artifacts: vec![existing, item.key.clone()], + message: format!( + "source '{}' contains duplicate canonical id '{}'", + item.key.source, item.key.canonical_id + ), + }); + continue; + } + for identifier in artifact_identifiers(&item.artifact, item.spec, &item.path) { + identifiers + .entry(item.key.source.clone()) + .or_default() + .entry(py_casefold(&identifier)) + .or_default() + .push(item.key.clone()); + } + } + for source_identifiers in identifiers.values_mut() { + for keys in source_identifiers.values_mut() { + keys.sort(); + keys.dedup(); + } + } + if !findings.is_empty() { + findings.sort_by(finding_order); + findings.dedup(); + return Err(findings); + } + Ok(Self { + items, + item_by_key, + local_by_source, + identifiers, + canonical, + }) + } +} + +fn prepare_override_declarations( + mut declarations: Vec, + topology: &PreparedTopology, + item_by_key: &BTreeMap, + items: &[CorpusItem], +) -> Result>, Vec> { + declarations.sort(); + let mut findings = Vec::new(); + let mut by_owner: BTreeMap> = BTreeMap::new(); + let mut targets = BTreeSet::new(); + for declaration in declarations { + let owner = declaration.owner_source.clone(); + if !topology.nodes.contains_key(&owner) { + findings.push(override_finding( + GraphFindingReason::UnknownOverrideOwner, + &declaration, + Vec::new(), + )); + continue; + } + if !targets.insert((owner.clone(), declaration.target.clone())) { + findings.push(override_finding( + GraphFindingReason::DuplicateOverrideTarget, + &declaration, + vec![declaration.target.clone()], + )); + continue; + } + if declaration.replacement.source != owner { + findings.push(override_finding( + GraphFindingReason::ReplacementNotLocal, + &declaration, + vec![declaration.replacement.clone()], + )); + continue; + } + if declaration.rationale.source != owner { + findings.push(override_finding( + GraphFindingReason::RationaleNotLocal, + &declaration, + vec![declaration.rationale.clone()], + )); + continue; + } + let Some(replacement_index) = item_by_key.get(&declaration.replacement).copied() else { + findings.push(override_finding( + GraphFindingReason::ReplacementNotFound, + &declaration, + vec![declaration.replacement.clone()], + )); + continue; + }; + let Some(rationale_index) = item_by_key.get(&declaration.rationale).copied() else { + findings.push(override_finding( + GraphFindingReason::RationaleNotFound, + &declaration, + vec![declaration.rationale.clone()], + )); + continue; + }; + let Some(target_index) = item_by_key.get(&declaration.target).copied() else { + findings.push(override_finding( + GraphFindingReason::TargetNotInherited, + &declaration, + vec![declaration.target.clone()], + )); + continue; + }; + let replacement = &items[replacement_index]; + let rationale = &items[rationale_index]; + let target = &items[target_index]; + if rationale.spec.map(|spec| spec.name.as_str()) != Some("decision") { + findings.push(override_finding( + GraphFindingReason::RationaleNotDecision, + &declaration, + vec![declaration.rationale.clone()], + )); + continue; + } + if !is_live_decision(&rationale.artifact) { + findings.push(override_finding( + GraphFindingReason::RationaleNotLive, + &declaration, + vec![declaration.rationale.clone()], + )); + continue; + } + let target_type = target.spec.map(|spec| spec.name.as_str()); + let replacement_type = replacement.spec.map(|spec| spec.name.as_str()); + if target_type.is_none() || target_type != replacement_type { + findings.push(override_finding( + GraphFindingReason::TypeMismatch, + &declaration, + vec![declaration.target.clone(), declaration.replacement.clone()], + )); + continue; + } + by_owner.entry(owner).or_default().push(declaration); + } + + for (owner, owner_declarations) in &by_owner { + let targets: BTreeSet<&ArtifactKey> = owner_declarations + .iter() + .map(|declaration| &declaration.target) + .collect(); + for declaration in owner_declarations { + if targets.contains(&declaration.replacement) { + findings.push(override_finding( + GraphFindingReason::SameManifestChain, + declaration, + vec![declaration.target.clone(), declaration.replacement.clone()], + )); + } + } + if owner_declarations + .iter() + .any(|declaration| declaration.target.source == *owner) + { + // A target local to its declaring source is also caught by the + // bottom-up inherited-terminal check. Record it here so malformed + // input cannot become order-dependent. + for declaration in owner_declarations + .iter() + .filter(|declaration| declaration.target.source == *owner) + { + findings.push(override_finding( + GraphFindingReason::TargetNotInherited, + declaration, + vec![declaration.target.clone()], + )); + } + } + } + + if !findings.is_empty() { + findings.sort_by(finding_order); + findings.dedup(); + return Err(findings); + } + Ok(by_owner) +} + +fn build_provenance( + root: &EffectiveProjection, + ordered: &[CompiledOverride], +) -> BTreeMap> { + let mut states: BTreeMap> = + BTreeMap::new(); + for (origin, routes) in &root.origins { + for paths in routes.by_terminal.values() { + for path in paths { + let mut carrying = BTreeSet::from([origin.clone()]); + carrying.extend(path.iter().map(|mapping| mapping.replacement.clone())); + for artifact in carrying { + let artifact_states = states.entry(artifact.clone()).or_default(); + for mapping in path { + let state = if artifact == mapping.target { + GraphOverrideState::Overridden + } else if artifact == mapping.replacement { + GraphOverrideState::Replacement + } else { + GraphOverrideState::Lineage + }; + artifact_states.entry(mapping.clone()).or_insert(state); + } + } + } + } + } + states + .into_iter() + .map(|(artifact, mappings)| { + let entries = ordered + .iter() + .filter_map(|mapping| { + mappings + .get(mapping) + .copied() + .map(|state| GraphOverrideProvenance { + state, + owner_source: mapping.owner_source.clone(), + target: mapping.target.clone(), + replacement: mapping.replacement.clone(), + rationale: mapping.rationale.clone(), + }) + }) + .collect(); + (artifact, entries) + }) + .collect() +} + +fn override_graph_has_cycle(mappings: &[CompiledOverride]) -> bool { + let outgoing: BTreeMap<&ArtifactKey, &ArtifactKey> = mappings + .iter() + .map(|mapping| (&mapping.target, &mapping.replacement)) + .collect(); + for start in outgoing.keys() { + let mut seen = BTreeSet::new(); + let mut current = *start; + while let Some(next) = outgoing.get(current) { + if !seen.insert(current) { + return true; + } + current = next; + } + } + 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 not_found_resolution(reference: &str) -> ResolutionResult { + ResolutionResult { + artifact_id: reference.to_string(), + outcome: OUTCOME_NOT_FOUND, + artifact: None, + duplicate_paths: Vec::new(), + } +} + +fn graph_finding( + reason: GraphFindingReason, + owner_source: Option, + message: String, +) -> GraphCompositionFinding { + GraphCompositionFinding { + code: FINDING_INVALID_GRAPH, + reason, + owner_source, + artifacts: Vec::new(), + message, + } +} + +fn item_finding( + reason: GraphFindingReason, + item: &CorpusItem, + message: String, +) -> GraphCompositionFinding { + GraphCompositionFinding { + code: FINDING_INVALID_GRAPH, + reason, + owner_source: Some(item.key.source.clone()), + artifacts: vec![item.key.clone()], + message, + } +} + +fn override_finding( + reason: GraphFindingReason, + declaration: &GraphOverrideDeclaration, + mut artifacts: Vec, +) -> GraphCompositionFinding { + artifacts.sort(); + artifacts.dedup(); + GraphCompositionFinding { + code: FINDING_INVALID_OVERRIDE, + reason, + owner_source: Some(declaration.owner_source.clone()), + artifacts, + message: format!( + "override '{}::{}' -> '{}::{}' in source '{}' is invalid: {}", + declaration.target.source, + declaration.target.canonical_id, + declaration.replacement.source, + declaration.replacement.canonical_id, + declaration.owner_source, + reason.as_str() + ), + } +} + +fn finding_order( + left: &GraphCompositionFinding, + right: &GraphCompositionFinding, +) -> std::cmp::Ordering { + left.code + .cmp(right.code) + .then_with(|| left.owner_source.cmp(&right.owner_source)) + .then_with(|| left.artifacts.cmp(&right.artifacts)) + .then_with(|| left.reason.cmp(&right.reason)) + .then_with(|| left.message.cmp(&right.message)) +} diff --git a/rust/rac-engine/src/index_format.rs b/rust/rac-engine/src/index_format.rs index 92071f3b..dcc3842a 100644 --- a/rust/rac-engine/src/index_format.rs +++ b/rust/rac-engine/src/index_format.rs @@ -197,6 +197,15 @@ impl<'a> Reader<'a> { } Ok(out) } + + /// Require that a closed-format payload has no unparsed suffix. + pub fn finish(self) -> Result<(), IndexFormatError> { + if self.pos == self.view.len() { + Ok(()) + } else { + err("segment contains trailing payload bytes") + } + } } // --------------------------------------------------------------------------- diff --git a/rust/rac-engine/src/index_store.rs b/rust/rac-engine/src/index_store.rs index de00d3b5..58aa803d 100644 --- a/rust/rac-engine/src/index_store.rs +++ b/rust/rac-engine/src/index_store.rs @@ -16,6 +16,7 @@ use serde_json::Value; use crate::corpus::{ArtifactKey, ArtifactOrigin, ArtifactPath, CorpusLayer, Layer}; use crate::derived::{CanonicalRedirect, DerivedIndex, SourceAwareArtifact}; +use crate::federation_generation::{GenerationMapping, GenerationRedirect, GRAPH_CONTRACT}; use crate::index_format::{ encode_segment, segment_payload, write_indexed, IndexFormatError, IndexedSegment, Reader, Writer, @@ -33,6 +34,10 @@ pub const STORE_DIRNAME: &str = "store"; /// 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"; +/// Graph-federation state is never decoded from the single-parent v2 tree. +/// The segment schema is extended at the graph integration boundary, while +/// this distinct namespace already guarantees old segments are cache misses. +pub const GRAPH_STORE_LAYOUT_VERSION: &str = "v3"; const SEG_HEADER: &str = "header.seg"; const SEG_ENTRIES: &str = "entries.seg"; @@ -51,6 +56,7 @@ 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 SEG_GRAPH: &str = "graph.seg"; const ALL_SEGMENTS: [&str; 17] = [ SEG_HEADER, @@ -110,11 +116,44 @@ pub fn corpus_content_hash(directory: &str, recursive: bool) -> String { // --------------------------------------------------------------------------- pub fn store_root(cache_dir: &Path) -> PathBuf { - cache_dir.join(STORE_DIRNAME).join(STORE_LAYOUT_VERSION) + store_root_for_layout(cache_dir, STORE_LAYOUT_VERSION) } pub fn store_dir(cache_dir: &Path, corpus_hash: &str) -> PathBuf { - store_root(cache_dir).join(corpus_hash) + store_dir_for_layout(cache_dir, STORE_LAYOUT_VERSION, corpus_hash) +} + +pub fn graph_store_root(cache_dir: &Path) -> PathBuf { + store_root_for_layout(cache_dir, GRAPH_STORE_LAYOUT_VERSION) +} + +pub fn graph_store_dir(cache_dir: &Path, generation: &str) -> Option { + Some(store_dir_for_layout( + cache_dir, + GRAPH_STORE_LAYOUT_VERSION, + graph_store_directory_key(generation)?, + )) +} + +fn graph_store_directory_key(generation: &str) -> Option<&str> { + valid_versioned_digest(generation, "sha256-v3:") +} + +fn valid_versioned_digest<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { + value.strip_prefix(prefix).filter(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +fn store_root_for_layout(cache_dir: &Path, layout: &str) -> PathBuf { + cache_dir.join(STORE_DIRNAME).join(layout) +} + +fn store_dir_for_layout(cache_dir: &Path, layout: &str, key: &str) -> PathBuf { + store_root_for_layout(cache_dir, layout).join(key) } // --------------------------------------------------------------------------- @@ -642,27 +681,131 @@ pub fn write_store( bundle_version: &str, derived: &DerivedIndex, ) -> bool { - let root = store_root(cache_dir); - let final_dir = root.join(corpus_hash); + write_store_in_layout( + cache_dir, + STORE_LAYOUT_VERSION, + corpus_hash, + corpus_hash, + bundle_version, + derived, + None, + ) +} + +/// Stable graph-only state persisted beside the derived read model. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GraphStoreMetadata { + pub generation: String, + pub layers: Vec, + pub mappings: Vec, + pub terminal_redirects: Vec, +} + +impl GraphStoreMetadata { + pub fn from_composition( + generation: impl Into, + layers: Vec, + composition: &crate::graph_composition::GraphComposition, + ) -> Self { + Self { + generation: generation.into(), + layers, + mappings: composition + .ordered_overrides() + .iter() + .map(|mapping| GenerationMapping { + owner_rank: mapping.owner_rank, + owner_source: mapping.owner_source.clone(), + target: mapping.target.clone(), + replacement: mapping.replacement.clone(), + rationale: mapping.rationale.clone(), + }) + .collect(), + terminal_redirects: composition + .terminal_redirects() + .iter() + .map(|(target, terminal)| GenerationRedirect { + target: target.clone(), + terminal: terminal.clone(), + }) + .collect(), + } + } +} + +/// Write one graph-federation generation under the isolated `store/v3` +/// namespace. `generation` is the full canonical `sha256-v3:` text. +pub fn write_graph_store( + cache_dir: &Path, + generation: &str, + bundle_version: &str, + derived: &DerivedIndex, + metadata: &GraphStoreMetadata, +) -> bool { + let Some(directory_key) = graph_store_directory_key(generation) else { + return false; + }; + if metadata.generation != generation || !graph_metadata_matches_derived(metadata, derived) { + return false; + } + write_store_in_layout( + cache_dir, + GRAPH_STORE_LAYOUT_VERSION, + directory_key, + generation, + bundle_version, + derived, + Some(metadata), + ) +} + +fn write_store_in_layout( + cache_dir: &Path, + layout: &str, + directory_key: &str, + corpus_hash: &str, + bundle_version: &str, + derived: &DerivedIndex, + graph_metadata: Option<&GraphStoreMetadata>, +) -> bool { + let root = store_root_for_layout(cache_dir, layout); + let final_dir = root.join(directory_key); if final_dir.is_dir() { // Content addressing: a same-hash store is byte-equivalent within one // format. Probe readability with the full open; replace when bad. - if MmapIndexReader::open(&final_dir, corpus_hash, bundle_version).is_ok() { + let opened = MmapIndexReader::open(&final_dir, corpus_hash, bundle_version).ok(); + let model_valid = opened.is_some() + && graph_metadata + .is_none_or(|expected| graph_metadata_matches_mapped(expected, opened.as_ref().unwrap())); + let graph_valid = graph_metadata.is_none_or(|expected| { + encode_graph_metadata(expected) + .ok() + .zip(fs::read(final_dir.join(SEG_GRAPH)).ok()) + .is_some_and(|(expected_bytes, actual_bytes)| expected_bytes == actual_bytes) + }); + if model_valid && graph_valid { return true; } remove_tree(&final_dir); } let encode_started = crate::timing::start(); - let Ok(segments) = encode_segments(corpus_hash, bundle_version, derived) else { + let Ok(mut segments) = encode_segments(corpus_hash, bundle_version, derived) else { crate::timing::emit_since("store.encode", encode_started, &[("success", 0)]); return false; }; + if let Some(metadata) = graph_metadata { + let Ok(payload) = encode_graph_metadata(metadata) else { + crate::timing::emit_since("store.encode", encode_started, &[("success", 0)]); + return false; + }; + segments.push((SEG_GRAPH, payload)); + } crate::timing::emit_since( "store.encode", encode_started, &[("success", 1), ("segments", segments.len() as u64)], ); - let tmp = root.join(format!(".{corpus_hash}.tmp-{}", temp_suffix())); + let tmp = root.join(format!(".{directory_key}.tmp-{}", temp_suffix())); let timing = crate::timing::enabled(); let mut write_duration = std::time::Duration::ZERO; let mut sync_duration = std::time::Duration::ZERO; @@ -715,6 +858,13 @@ pub fn remove_store(cache_dir: &Path, corpus_hash: &str) { remove_tree(&store_dir(cache_dir, corpus_hash)); } +/// Best-effort removal of one graph-generation store. +pub fn remove_graph_store(cache_dir: &Path, generation: &str) { + if let Some(directory) = graph_store_dir(cache_dir, generation) { + remove_tree(&directory); + } +} + // --------------------------------------------------------------------------- // Reader — mmap the segments, validate on open, point-access the rows. // --------------------------------------------------------------------------- @@ -1244,6 +1394,343 @@ pub fn open_store( MmapIndexReader::open(&directory, corpus_hash, bundle_version).ok() } +/// Open one graph generation from `store/v3`, or return a cache miss. V1 and +/// V2 directories are never considered by this path. +pub fn open_graph_store( + cache_dir: &Path, + generation: &str, + bundle_version: &str, + expected: &GraphStoreMetadata, +) -> Option { + if expected.generation != generation { + return None; + } + let directory = graph_store_dir(cache_dir, generation)?; + if !directory.is_dir() { + return None; + } + let model = MmapIndexReader::open(&directory, generation, bundle_version).ok()?; + let metadata = decode_graph_metadata(&fs::read(directory.join(SEG_GRAPH)).ok()?).ok()?; + if metadata != canonical_graph_metadata(expected) + || !graph_metadata_matches_mapped(&metadata, &model) + { + return None; + } + Some(GraphMmapIndexReader { model, metadata }) +} + +/// One validated graph store. The derived segments and graph-only mapping +/// state are opened atomically from the same versioned directory. +pub struct GraphMmapIndexReader { + pub model: MmapIndexReader, + pub metadata: GraphStoreMetadata, +} + +const MAX_GRAPH_LAYERS: usize = 257; +const MAX_GRAPH_MAPPINGS: usize = 4_096; +const MAX_GRAPH_REDIRECTS: usize = 4_096; + +fn canonical_graph_metadata(metadata: &GraphStoreMetadata) -> GraphStoreMetadata { + let mut canonical = metadata.clone(); + canonical.layers.sort(); + canonical.mappings.sort_by(graph_mapping_order); + canonical + .terminal_redirects + .sort_by(|left, right| left.target.cmp(&right.target)); + canonical +} + +fn graph_mapping_order( + left: &GenerationMapping, + right: &GenerationMapping, +) -> std::cmp::Ordering { + ( + left.owner_rank, + &left.owner_source, + &left.target, + &left.replacement, + &left.rationale, + ) + .cmp(&( + right.owner_rank, + &right.owner_source, + &right.target, + &right.replacement, + &right.rationale, + )) +} + +fn validate_graph_metadata_shape(metadata: &GraphStoreMetadata) -> Result<(), IndexFormatError> { + if graph_store_directory_key(&metadata.generation).is_none() { + return Err(IndexFormatError("invalid graph generation".into())); + } + if metadata.layers.is_empty() || metadata.layers.len() > MAX_GRAPH_LAYERS { + return Err(IndexFormatError("invalid graph layer count".into())); + } + if metadata.mappings.len() > MAX_GRAPH_MAPPINGS { + return Err(IndexFormatError("graph mapping limit exceeded".into())); + } + if metadata.terminal_redirects.len() > MAX_GRAPH_REDIRECTS { + return Err(IndexFormatError("graph redirect limit exceeded".into())); + } + + let mut sources = std::collections::BTreeSet::new(); + let mut local_count = 0usize; + for layer in &metadata.layers { + if !crate::scaffold::valid_corpus_source(&layer.source) || !sources.insert(&layer.source) { + return Err(IndexFormatError("invalid or duplicate graph source".into())); + } + match layer.layer { + Layer::Local => { + local_count += 1; + if layer.pin.is_some() || layer.alias.is_some() { + return Err(IndexFormatError( + "root graph layer must omit pin and alias".into(), + )); + } + } + Layer::Inherited => { + if layer + .pin + .as_deref() + .and_then(|pin| valid_versioned_digest(pin, "sha256-v2:")) + .is_none() + || layer.alias.is_some() + { + return Err(IndexFormatError( + "inherited graph layer requires a canonical v2 pin and no global alias" + .into(), + )); + } + } + } + } + if local_count != 1 { + return Err(IndexFormatError( + "graph metadata must contain exactly one root layer".into(), + )); + } + + let valid_key = |key: &ArtifactKey| { + sources.contains(&key.source) + && !key.canonical_id.is_empty() + && key.canonical_id.len() <= 4096 + && !key.canonical_id.contains("::") + }; + let mut mapping_targets = std::collections::BTreeSet::new(); + for mapping in &metadata.mappings { + if !sources.contains(&mapping.owner_source) + || !valid_key(&mapping.target) + || !valid_key(&mapping.replacement) + || !valid_key(&mapping.rationale) + || mapping.replacement.source != mapping.owner_source + || mapping.rationale.source != mapping.owner_source + || !mapping_targets.insert(&mapping.target) + { + return Err(IndexFormatError("invalid graph mapping row".into())); + } + } + let mut redirect_targets = std::collections::BTreeSet::new(); + for redirect in &metadata.terminal_redirects { + if !valid_key(&redirect.target) + || !valid_key(&redirect.terminal) + || redirect.target == redirect.terminal + || !redirect_targets.insert(&redirect.target) + { + return Err(IndexFormatError("invalid graph redirect row".into())); + } + } + Ok(()) +} + +fn graph_metadata_matches_derived(metadata: &GraphStoreMetadata, derived: &DerivedIndex) -> bool { + if validate_graph_metadata_shape(metadata).is_err() { + return false; + } + let mut layers = derived.layers.clone(); + layers.sort(); + layers.dedup(); + if layers != canonical_graph_metadata(metadata).layers { + return false; + } + let identity_keys: std::collections::BTreeSet = derived + .resolution + .entries + .iter() + .filter_map(|entry| entry.key.clone()) + .collect(); + graph_rows_match_model(metadata, &identity_keys, &derived.resolution.canonical_redirects) +} + +fn graph_metadata_matches_mapped( + metadata: &GraphStoreMetadata, + model: &MmapIndexReader, +) -> bool { + if validate_graph_metadata_shape(metadata).is_err() { + return false; + } + let Ok(mut layers) = model.layers() else { + return false; + }; + layers.sort(); + layers.dedup(); + if layers != canonical_graph_metadata(metadata).layers { + return false; + } + let Ok(identity_count) = model.identity_count() else { + return false; + }; + let mut identity_keys = std::collections::BTreeSet::new(); + for docid in 0..identity_count { + let Ok(entry) = model.identity_entry(docid) else { + return false; + }; + let Some(key) = entry.key else { + return false; + }; + identity_keys.insert(key); + } + let Ok(redirects) = model.canonical_redirects() else { + return false; + }; + graph_rows_match_model(metadata, &identity_keys, &redirects) +} + +fn graph_rows_match_model( + metadata: &GraphStoreMetadata, + identity_keys: &std::collections::BTreeSet, + redirects: &[CanonicalRedirect], +) -> bool { + let metadata_mappings: std::collections::BTreeSet<_> = metadata + .mappings + .iter() + .map(|mapping| { + ( + mapping.target.clone(), + mapping.replacement.clone(), + mapping.rationale.clone(), + ) + }) + .collect(); + let model_mappings: std::collections::BTreeSet<_> = redirects + .iter() + .map(|mapping| { + ( + mapping.parent.clone(), + mapping.replacement.clone(), + mapping.rationale.clone(), + ) + }) + .collect(); + metadata_mappings == model_mappings + && metadata.mappings.iter().all(|mapping| { + identity_keys.contains(&mapping.target) + && identity_keys.contains(&mapping.replacement) + && identity_keys.contains(&mapping.rationale) + }) + && metadata.terminal_redirects.iter().all(|redirect| { + identity_keys.contains(&redirect.target) && identity_keys.contains(&redirect.terminal) + }) +} + +fn encode_graph_metadata(metadata: &GraphStoreMetadata) -> Result, IndexFormatError> { + validate_graph_metadata_shape(metadata)?; + let metadata = canonical_graph_metadata(metadata); + let mut writer = Writer::new(); + writer.text(std::str::from_utf8(GRAPH_CONTRACT).expect("ASCII graph contract"))?; + writer.text(&metadata.generation)?; + + writer.u32(metadata.layers.len() as u64)?; + for layer in &metadata.layers { + write_layer(&mut writer, layer)?; + } + + writer.u32(metadata.mappings.len() as u64)?; + for mapping in &metadata.mappings { + writer.u32(mapping.owner_rank as u64)?; + writer.text(&mapping.owner_source)?; + write_required_artifact_key(&mut writer, &mapping.target)?; + write_required_artifact_key(&mut writer, &mapping.replacement)?; + write_required_artifact_key(&mut writer, &mapping.rationale)?; + } + + writer.u32(metadata.terminal_redirects.len() as u64)?; + for redirect in &metadata.terminal_redirects { + write_required_artifact_key(&mut writer, &redirect.target)?; + write_required_artifact_key(&mut writer, &redirect.terminal)?; + } + Ok(encode_segment(&writer.payload())) +} + +fn decode_graph_metadata(bytes: &[u8]) -> Result { + let payload = segment_payload(bytes)?; + let mut reader = Reader::new(payload); + if reader.text()?.as_bytes() != GRAPH_CONTRACT { + return Err(IndexFormatError("graph contract mismatch".into())); + } + let generation = reader.text()?; + let layer_count = reader.u32()?; + if layer_count as usize > MAX_GRAPH_LAYERS { + return Err(IndexFormatError("invalid graph layer count".into())); + } + let mut layers = Vec::with_capacity(layer_count.min(1 << 20) as usize); + for _ in 0..layer_count { + layers.push(read_layer(&mut reader)?); + } + let mapping_count = reader.u32()?; + if mapping_count as usize > MAX_GRAPH_MAPPINGS { + return Err(IndexFormatError("graph mapping limit exceeded".into())); + } + let mut mappings = Vec::with_capacity(mapping_count.min(1 << 20) as usize); + for _ in 0..mapping_count { + mappings.push(GenerationMapping { + owner_rank: reader.u32()? as usize, + owner_source: reader.text()?, + target: read_required_artifact_key(&mut reader)?, + replacement: read_required_artifact_key(&mut reader)?, + rationale: read_required_artifact_key(&mut reader)?, + }); + } + let redirect_count = reader.u32()?; + if redirect_count as usize > MAX_GRAPH_REDIRECTS { + return Err(IndexFormatError("graph redirect limit exceeded".into())); + } + let mut terminal_redirects = Vec::with_capacity(redirect_count.min(1 << 20) as usize); + for _ in 0..redirect_count { + terminal_redirects.push(GenerationRedirect { + target: read_required_artifact_key(&mut reader)?, + terminal: read_required_artifact_key(&mut reader)?, + }); + } + reader.finish()?; + let metadata = GraphStoreMetadata { + generation, + layers, + mappings, + terminal_redirects, + }; + validate_graph_metadata_shape(&metadata)?; + if encode_graph_metadata(&metadata)? != bytes { + return Err(IndexFormatError( + "graph metadata is not in canonical order".into(), + )); + } + Ok(metadata) +} + +fn write_required_artifact_key( + writer: &mut Writer, + key: &ArtifactKey, +) -> Result<(), IndexFormatError> { + writer.text(&key.source)?; + writer.text(&key.canonical_id)?; + Ok(()) +} + +fn read_required_artifact_key(reader: &mut Reader<'_>) -> Result { + Ok(ArtifactKey::new(reader.text()?, reader.text()?)) +} + // --------------------------------------------------------------------------- // Per-file validation-result store (`.vseg`, ADR-106) — codec only here; // the incremental-validate seam consumes it (INDEX-PLAN B4). @@ -1561,3 +2048,226 @@ fn atomic_write(root: &Path, key: &str, target: &Path, payload: &[u8]) -> bool { } true } + +#[cfg(test)] +mod graph_layout_tests { + use super::*; + + fn graph_metadata_fixture() -> GraphStoreMetadata { + GraphStoreMetadata { + generation: format!("sha256-v3:{}", "b".repeat(64)), + layers: vec![ + CorpusLayer { + source: "acme/standards".into(), + layer: Layer::Inherited, + pin: Some(format!("sha256-v2:{}", "a".repeat(64))), + alias: None, + }, + CorpusLayer::local("acme/app"), + ], + mappings: vec![GenerationMapping { + owner_rank: 1, + owner_source: "acme/app".into(), + target: ArtifactKey::new("acme/standards", "STD-0123456789AB"), + replacement: ArtifactKey::new("acme/app", "APP-0123456789AB"), + rationale: ArtifactKey::new("acme/app", "APP-ABCDEFGHJKMN"), + }], + terminal_redirects: vec![GenerationRedirect { + target: ArtifactKey::new("acme/standards", "STD-0123456789AB"), + terminal: ArtifactKey::new("acme/app", "APP-0123456789AB"), + }], + } + } + + fn identity_entry(key: &ArtifactKey, layer: &CorpusLayer) -> IndexEntry { + IndexEntry { + key: Some(key.clone()), + artifact_path: Some(ArtifactPath::new( + key.source.clone(), + format!("{}.md", key.canonical_id), + )), + origin: Some(layer.origin()), + id: key.canonical_id.clone(), + artifact_type: "decision".into(), + title: None, + path: format!("{}.md", key.canonical_id), + aliases: vec![key.canonical_id.clone()], + search_sections: Vec::new(), + inbound_count: 0, + tags: Vec::new(), + } + } + + fn graph_derived_fixture(metadata: &GraphStoreMetadata) -> DerivedIndex { + let canonical = canonical_graph_metadata(metadata); + let layer_by_source: std::collections::BTreeMap<_, _> = canonical + .layers + .iter() + .map(|layer| (layer.source.clone(), layer)) + .collect(); + let mut keys = std::collections::BTreeSet::new(); + for mapping in &canonical.mappings { + keys.extend([ + mapping.target.clone(), + mapping.replacement.clone(), + mapping.rationale.clone(), + ]); + } + for redirect in &canonical.terminal_redirects { + keys.extend([redirect.target.clone(), redirect.terminal.clone()]); + } + let entries = keys + .iter() + .map(|key| identity_entry(key, layer_by_source[&key.source])) + .collect(); + DerivedIndex { + layers: canonical.layers, + source_artifacts: Vec::new(), + resolution: Box::new(crate::derived::ResolutionProjection { + entries, + canonical_redirects: canonical + .mappings + .iter() + .map(|mapping| CanonicalRedirect { + parent: mapping.target.clone(), + replacement: mapping.replacement.clone(), + rationale: mapping.rationale.clone(), + }) + .collect(), + }), + 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(), + } + } + + #[test] + fn graph_store_isolated_from_v2_with_portable_directory_key() { + let cache = Path::new("cache"); + let digest = "a".repeat(64); + let generation = format!("sha256-v3:{digest}"); + assert_eq!(store_root(cache), cache.join("store/v2")); + assert_eq!(graph_store_root(cache), cache.join("store/v3")); + assert_eq!( + graph_store_dir(cache, &generation), + Some(cache.join("store/v3").join(digest)) + ); + assert_ne!( + graph_store_dir(cache, &generation), + Some(store_dir(cache, &generation)) + ); + } + + #[test] + fn malformed_generation_cannot_escape_store_root() { + let cache = Path::new("cache"); + assert!(graph_store_dir(cache, "sha256-v3:../../outside").is_none()); + assert!(graph_store_dir(cache, &format!("sha256-v3:{}", "A".repeat(64))).is_none()); + } + + #[test] + fn graph_metadata_round_trips_in_canonical_order() { + let metadata = graph_metadata_fixture(); + let encoded = encode_graph_metadata(&metadata).expect("encode graph metadata"); + let decoded = decode_graph_metadata(&encoded).expect("decode graph metadata"); + let mut expected = metadata; + expected.layers.sort(); + assert_eq!(decoded, expected); + + let mut trailing = segment_payload(&encoded).unwrap().to_vec(); + trailing.push(0); + assert!(decode_graph_metadata(&encode_segment(&trailing)).is_err()); + } + + #[test] + fn graph_metadata_rejects_noncanonical_and_incomplete_rows() { + let mut metadata = GraphStoreMetadata { + generation: format!("sha256-v3:{}", "b".repeat(64)), + layers: vec![CorpusLayer::local("acme/app")], + mappings: Vec::new(), + terminal_redirects: Vec::new(), + }; + assert!(encode_graph_metadata(&metadata).is_ok()); + + metadata.generation = "not-a-generation".into(); + assert!(encode_graph_metadata(&metadata).is_err()); + metadata.generation = format!("sha256-v3:{}", "b".repeat(64)); + metadata.layers.push(CorpusLayer::local("acme/app")); + assert!(encode_graph_metadata(&metadata).is_err()); + } + + #[test] + fn graph_store_rejects_corrupt_or_request_mismatched_metadata() { + let cache = std::env::temp_dir().join(format!( + "asdecided-graph-store-{}-{}", + std::process::id(), + temp_suffix() + )); + let metadata = graph_metadata_fixture(); + let derived = graph_derived_fixture(&metadata); + assert!(write_graph_store( + &cache, + &metadata.generation, + crate::derived::SCHEMA_VERSION, + &derived, + &metadata, + )); + assert!(open_store( + &cache, + &metadata.generation, + crate::derived::SCHEMA_VERSION + ) + .is_none()); + assert!(open_graph_store( + &cache, + &metadata.generation, + crate::derived::SCHEMA_VERSION, + &metadata, + ) + .is_some()); + + let mut request_mismatch = metadata.clone(); + request_mismatch.mappings.clear(); + request_mismatch.terminal_redirects.clear(); + assert!(open_graph_store( + &cache, + &metadata.generation, + crate::derived::SCHEMA_VERSION, + &request_mismatch, + ) + .is_none()); + + let directory = graph_store_dir(&cache, &metadata.generation).unwrap(); + let corrupt = encode_graph_metadata(&request_mismatch).unwrap(); + std::fs::write(directory.join(SEG_GRAPH), corrupt).unwrap(); + assert!(open_graph_store( + &cache, + &metadata.generation, + crate::derived::SCHEMA_VERSION, + &metadata, + ) + .is_none()); + + let mut wrong_derived = graph_derived_fixture(&metadata); + wrong_derived.resolution.canonical_redirects.clear(); + assert!(!write_graph_store( + &cache, + &metadata.generation, + crate::derived::SCHEMA_VERSION, + &wrong_derived, + &metadata, + )); + assert!(!write_graph_store( + &cache, + "not-a-generation", + crate::derived::SCHEMA_VERSION, + &derived, + &metadata, + )); + let _ = std::fs::remove_dir_all(cache); + } +} diff --git a/rust/rac-engine/src/lib.rs b/rust/rac-engine/src/lib.rs index b9eaacee..96ce478b 100644 --- a/rust/rac-engine/src/lib.rs +++ b/rust/rac-engine/src/lib.rs @@ -50,6 +50,7 @@ pub mod classify; pub mod identity; pub mod corpus; pub mod composition; +pub mod graph_composition; pub mod validate; pub mod relationships; pub mod diff; @@ -80,6 +81,7 @@ pub mod doctor; pub mod mdhtml; pub mod export; pub mod federation; +pub mod federation_generation; pub mod federated_corpus; pub mod portal; pub mod agent_rules; diff --git a/rust/rac-engine/tests/federation_graph_loader.rs b/rust/rac-engine/tests/federation_graph_loader.rs new file mode 100644 index 00000000..df032d9d --- /dev/null +++ b/rust/rac-engine/tests/federation_graph_loader.rs @@ -0,0 +1,288 @@ +use rac_engine::federation::{ + calculate_parent_digest_v2, digest_snapshot_v2, load_graph_manifest, load_manifest, + verify_federation, ParentCorpusErrorCode, SnapshotFile, +}; +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-v2-{name}-{}-{n}", + std::process::id() + )); + fs::create_dir_all(&path).unwrap(); + path +} + +fn node(root: &Path, source: &str, body: &str) { + fs::create_dir_all(root.join(".decided")).unwrap(); + fs::create_dir_all(root.join("decisions")).unwrap(); + fs::write( + root.join(".decided/config.yaml"), + format!("repository_key: TST\ncorpus:\n source: {source}\n"), + ) + .unwrap(); + fs::write(root.join("decisions/policy.md"), body).unwrap(); +} + +fn v2_manifest(root: &Path, parents: &[(&str, &str, &str, &str)]) { + let mut manifest = String::from("# Corpus\n\n## inherits\n\n```yaml\nversion: 2\nparents:\n"); + for (alias, source, parent_root, digest) in parents { + manifest.push_str(&format!( + " - alias: {alias}\n source: {source}\n root: {parent_root}\n corpus: decisions\n digest: {digest}\n" + )); + } + manifest.push_str("```\n\n## overrides\n\n```yaml\nversion: 2\nitems: []\n```\n"); + fs::write(root.join(".decided/corpus.md"), manifest).unwrap(); +} + +fn diamond(root: &Path, second_shared_body: &str) { + node(root, "acme/root", "root\n"); + let branch_a = root.join("decisions/vendor/a"); + let branch_b = root.join("decisions/vendor/b"); + let shared_a = branch_a.join("vendor/shared"); + let shared_b = branch_b.join("vendor/shared"); + node(&branch_a, "acme/a", "a\n"); + node(&branch_b, "acme/b", "b\n"); + node(&shared_a, "acme/shared", "shared\n"); + node(&shared_b, "acme/shared", second_shared_body); + let shared_a_pin = calculate_parent_digest_v2(&shared_a, "decisions") + .unwrap() + .digest; + let shared_b_pin = calculate_parent_digest_v2(&shared_b, "decisions") + .unwrap() + .digest; + v2_manifest( + &branch_a, + &[("shared", "acme/shared", "vendor/shared", &shared_a_pin)], + ); + v2_manifest( + &branch_b, + &[("shared", "acme/shared", "vendor/shared", &shared_b_pin)], + ); + let a_pin = calculate_parent_digest_v2(&branch_a, "decisions") + .unwrap() + .digest; + let b_pin = calculate_parent_digest_v2(&branch_b, "decisions") + .unwrap() + .digest; + v2_manifest( + root, + &[ + ("a", "acme/a", "decisions/vendor/a", &a_pin), + ("b", "acme/b", "decisions/vendor/b", &b_pin), + ], + ); +} + +#[test] +fn digest_v2_has_a_fixed_manifest_presence_vector() { + let files = vec![SnapshotFile { + relative_path: "policy.md".to_string(), + absolute_path: PathBuf::from("ignored"), + bytes: b"policy\r\n".to_vec(), + }]; + assert_eq!( + digest_snapshot_v2( + "acme/standards", + b"repository_key: STD\ncorpus:\n source: acme/standards\n", + None, + &files, + ), + "sha256-v2:a98e4e89445427bc5a0fdeaffa9bc479895675786ceb987c128db43aad0fa9c1" + ); + assert_ne!( + digest_snapshot_v2( + "acme/standards", + b"repository_key: STD\ncorpus:\n source: acme/standards\n", + Some(b""), + &files, + ), + digest_snapshot_v2( + "acme/standards", + b"repository_key: STD\ncorpus:\n source: acme/standards\n", + None, + &files, + ) + ); +} + +#[test] +fn verifies_every_diamond_route_then_deduplicates_the_logical_source() { + let root = scratch("diamond"); + diamond(&root, "shared\n"); + let closure = verify_federation(&root, "decisions").unwrap().unwrap(); + assert_eq!( + closure + .nodes + .iter() + .map(|node| node.source.as_str()) + .collect::>(), + ["acme/a", "acme/b", "acme/shared"] + ); + assert_eq!(closure.edges.len(), 4); + assert_eq!(closure.materialisation_roots.len(), 4); + assert_eq!(closure.node("acme/a").unwrap().manifest_version, Some(2)); + assert!(closure.node("acme/a").unwrap().overrides.is_some()); + assert_eq!( + closure + .root_files + .iter() + .map(|file| file.relative_path.as_str()) + .collect::>(), + ["policy.md"] + ); + assert!(closure.contains_materialised_path(&root.join("decisions/vendor/a/new.md"))); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn a_tampered_second_diamond_route_fails_instead_of_trusting_the_first_copy() { + let root = scratch("diamond-tamper"); + diamond(&root, "shared\n"); + fs::write( + root.join("decisions/vendor/b/vendor/shared/decisions/policy.md"), + "tampered\n", + ) + .unwrap(); + assert_eq!( + verify_federation(&root, "decisions").unwrap_err().code, + ParentCorpusErrorCode::DigestMismatch + ); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn same_source_with_distinct_verified_v2_digests_is_a_divergent_pin() { + let root = scratch("divergent"); + diamond(&root, "different\n"); + assert_eq!( + verify_federation(&root, "decisions").unwrap_err().code, + ParentCorpusErrorCode::DivergentPin + ); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn active_root_source_recurrence_is_a_cycle_after_pin_verification() { + let root = scratch("cycle"); + node(&root, "acme/root", "root\n"); + let branch = root.join("vendor/branch"); + let recurrence = branch.join("vendor/recurrence"); + node(&branch, "acme/branch", "branch\n"); + node(&recurrence, "acme/root", "nested root\n"); + let recurrence_pin = calculate_parent_digest_v2(&recurrence, "decisions") + .unwrap() + .digest; + v2_manifest( + &branch, + &[( + "root-again", + "acme/root", + "vendor/recurrence", + &recurrence_pin, + )], + ); + let branch_pin = calculate_parent_digest_v2(&branch, "decisions") + .unwrap() + .digest; + v2_manifest( + &root, + &[("branch", "acme/branch", "vendor/branch", &branch_pin)], + ); + assert_eq!( + verify_federation(&root, "decisions").unwrap_err().code, + ParentCorpusErrorCode::Cycle + ); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn v2_parser_rejects_duplicate_sources_aliases_and_non_posix_paths() { + let root = scratch("strict-manifest"); + node(&root, "acme/root", "root\n"); + let digest = "sha256-v2:0000000000000000000000000000000000000000000000000000000000000000"; + v2_manifest( + &root, + &[ + ("one", "acme/shared", "vendor/one", digest), + ("two", "acme/shared", "vendor/two", digest), + ], + ); + assert_eq!( + load_graph_manifest(&root).unwrap_err().code, + ParentCorpusErrorCode::DuplicateParent + ); + v2_manifest(&root, &[("one", "acme/shared", "vendor\\one", digest)]); + assert_eq!( + load_graph_manifest(&root).unwrap_err().code, + ParentCorpusErrorCode::PathEscape + ); + fs::write( + root.join(".decided/corpus.md"), + format!( + "# Corpus\n\n## inherits\n\n```yaml\nversion: 2\nparents:\n - alias: one\n alias: two\n source: acme/shared\n root: vendor/one\n corpus: decisions\n digest: {digest}\n```\n" + ), + ) + .unwrap(); + assert_eq!( + load_graph_manifest(&root).unwrap_err().code, + ParentCorpusErrorCode::MalformedManifest + ); + fs::write( + root.join(".decided/corpus.md"), + format!( + "# Corpus\n\n## inherits\n\n```yaml\nversion: 2\nparents:\n - &parent\n alias: one\n source: acme/shared\n root: vendor/one\n corpus: decisions\n digest: {digest}\n```\n" + ), + ) + .unwrap(); + assert_eq!( + load_graph_manifest(&root).unwrap_err().code, + ParentCorpusErrorCode::MalformedManifest + ); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn version_one_stays_on_the_existing_loader_path() { + let root = scratch("v1"); + node(&root, "acme/root", "root\n"); + fs::write( + root.join(".decided/corpus.md"), + "# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: standards\nsource: acme/standards\nroot: vendor/standards\ncorpus: decisions\ndigest: sha256:0000000000000000000000000000000000000000000000000000000000000000\n```\n", + ) + .unwrap(); + assert!(load_manifest(&root).unwrap().is_some()); + assert!(verify_federation(&root, "decisions").unwrap().is_none()); + fs::remove_dir_all(root).unwrap(); +} + +#[cfg(unix)] +#[test] +fn inherited_hard_links_fail_closed() { + let root = scratch("hard-link"); + node(&root, "acme/root", "root\n"); + let parent = root.join("vendor/parent"); + node(&parent, "acme/parent", "parent\n"); + fs::hard_link( + parent.join("decisions/policy.md"), + parent.join("decisions/alias.md"), + ) + .unwrap(); + let pin = digest_snapshot_v2( + "acme/parent", + &fs::read(parent.join(".decided/config.yaml")).unwrap(), + None, + &[], + ); + v2_manifest(&root, &[("parent", "acme/parent", "vendor/parent", &pin)]); + assert_eq!( + verify_federation(&root, "decisions").unwrap_err().code, + ParentCorpusErrorCode::UnsupportedFilesystem + ); + fs::remove_dir_all(root).unwrap(); +} diff --git a/rust/rac-engine/tests/graph_composition.rs b/rust/rac-engine/tests/graph_composition.rs new file mode 100644 index 00000000..2dc0fe4e --- /dev/null +++ b/rust/rac-engine/tests/graph_composition.rs @@ -0,0 +1,624 @@ +use rac_engine::corpus::{ + ArtifactKey, CorpusLayer, Layer, PhysicalArtifactLocator, PhysicalCorpusLocator, +}; +use rac_engine::graph_composition::{ + GraphComposition, GraphCompositionInput, GraphFindingReason, GraphLookupError, + GraphOverrideDeclaration, GraphOverrideState, GraphRelationshipIssue, SourceNodeInput, + SourceParentInput, FINDING_OVERRIDE_DIVERGENCE, +}; +use rac_engine::parse::parse_text; +use rac_engine::relationships::CorpusItem; +use rac_engine::resolve::{OUTCOME_DUPLICATE, OUTCOME_RESOLVED}; +use rac_engine::spec::spec_for; + +const ROOT: &str = "acme/app"; +const SHARED: &str = "acme/shared"; +const LEFT: &str = "acme/left"; +const RIGHT: &str = "acme/right"; + +fn parent(source: &str, alias: &str) -> SourceParentInput { + SourceParentInput::new(source, alias) +} + +fn node(source: &str, parents: Vec) -> SourceNodeInput { + SourceNodeInput::new(source, parents) +} + +fn item( + source: &str, + relative_path: &str, + id: &str, + artifact_type: &str, + status: &str, + relationships: &str, +) -> CorpusItem { + let body = match artifact_type { + "decision" => { + "## Context\n\nGraph fixture.\n\n## Decision\n\nKeep composition deterministic.\n\n## Consequences\n\nEvery exception remains attributable.\n" + } + "requirement" => { + "## Problem\n\nGraph composition needs one resolver.\n\n## Requirements\n\n- [REQ-001] Resolution MUST remain deterministic.\n" + } + 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\n{body}\n{relationships}" + ); + let origin = if source == ROOT { + CorpusLayer::local(source).origin() + } else { + CorpusLayer::inherited(source, "runtime-only", format!("sha256-v2:{source}")).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 key(source: &str, id: &str) -> ArtifactKey { + ArtifactKey::new(source, id) +} + +fn mapping( + owner: &str, + target: (&str, &str), + replacement: &str, + rationale: &str, +) -> GraphOverrideDeclaration { + GraphOverrideDeclaration::new( + owner, + key(target.0, target.1), + key(owner, replacement), + key(owner, rationale), + ) +} + +fn diamond_nodes() -> Vec { + vec![ + node(ROOT, vec![parent(LEFT, "left"), parent(RIGHT, "right")]), + node(LEFT, vec![parent(SHARED, "base")]), + node(RIGHT, vec![parent(SHARED, "base")]), + node(SHARED, Vec::new()), + ] +} + +#[test] +fn source_context_keeps_aliases_local_and_global_qualification_stable() { + const LEFT_BASE: &str = "acme/left-base"; + const RIGHT_BASE: &str = "acme/right-base"; + let graph = GraphComposition::compose(GraphCompositionInput::new( + ROOT, + vec![ + node(ROOT, vec![parent(LEFT, "left"), parent(RIGHT, "right")]), + node(LEFT, vec![parent(LEFT_BASE, "base")]), + node(RIGHT, vec![parent(RIGHT_BASE, "base")]), + node(LEFT_BASE, Vec::new()), + node(RIGHT_BASE, Vec::new()), + ], + vec![ + item( + LEFT_BASE, + "left.md", + "LEFT-BASE", + "requirement", + "Accepted", + "", + ), + item( + RIGHT_BASE, + "right.md", + "RIGHT-BASE", + "requirement", + "Accepted", + "", + ), + ], + Vec::new(), + )) + .unwrap(); + + assert_eq!( + graph + .resolve_from(LEFT, "base::LEFT-BASE") + .unwrap() + .selected, + key(LEFT_BASE, "LEFT-BASE") + ); + assert_eq!( + graph + .resolve_from(RIGHT, "base::RIGHT-BASE") + .unwrap() + .selected, + key(RIGHT_BASE, "RIGHT-BASE") + ); + assert_eq!( + graph.resolve_from(LEFT, "base::RIGHT-BASE"), + Err(GraphLookupError::NotFound) + ); + assert_eq!( + graph + .resolve_public("acme/right-base::RIGHT-BASE") + .unwrap() + .selected, + key(RIGHT_BASE, "RIGHT-BASE") + ); + assert_eq!( + graph.resolve_public("base::LEFT-BASE"), + Err(GraphLookupError::NotFound), + "an inherited alias must not leak to the root" + ); +} + +#[test] +fn equal_ids_are_legal_and_bare_lookup_is_deterministically_ambiguous() { + let graph = GraphComposition::compose(GraphCompositionInput::new( + ROOT, + diamond_nodes(), + vec![ + item(SHARED, "same.md", "SAME", "requirement", "Accepted", ""), + item(LEFT, "same.md", "SAME", "requirement", "Accepted", ""), + item(RIGHT, "same.md", "SAME", "requirement", "Accepted", ""), + ], + Vec::new(), + )) + .unwrap(); + + assert_eq!(graph.catalog().len(), 3); + assert_eq!( + graph.resolve_public("SAME"), + Err(GraphLookupError::Ambiguous { + historical_candidates: vec![key(LEFT, "SAME"), key(RIGHT, "SAME"), key(SHARED, "SAME"),], + effective_candidates: vec![key(LEFT, "SAME"), key(RIGHT, "SAME"), key(SHARED, "SAME"),], + }) + ); + assert_eq!(graph.resolve_identity("SAME").outcome, OUTCOME_DUPLICATE); + let qualified = graph.resolve_public("acme/shared::SAME").unwrap(); + assert!(qualified.qualified); + assert_eq!(qualified.selected, key(SHARED, "SAME")); +} + +#[test] +fn explicit_multi_candidate_convergence_makes_bare_lookup_unique() { + let mut items = vec![ + item(SHARED, "same.md", "SAME", "requirement", "Accepted", ""), + item(LEFT, "same.md", "SAME", "requirement", "Accepted", ""), + item(RIGHT, "same.md", "SAME", "requirement", "Accepted", ""), + item( + ROOT, + "replacement.md", + "ROOT-SAME", + "requirement", + "Accepted", + "", + ), + item(ROOT, "rationale.md", "ROOT-ADR", "decision", "Accepted", ""), + ]; + items.push(item( + ROOT, + "relationship.md", + "ROOT-REL", + "requirement", + "Accepted", + "## Related Requirements\n\n- SAME\n", + )); + let graph = GraphComposition::compose(GraphCompositionInput::new( + ROOT, + diamond_nodes(), + items, + vec![ + mapping(ROOT, (LEFT, "SAME"), "ROOT-SAME", "ROOT-ADR"), + mapping(ROOT, (RIGHT, "SAME"), "ROOT-SAME", "ROOT-ADR"), + mapping(ROOT, (SHARED, "SAME"), "ROOT-SAME", "ROOT-ADR"), + ], + )) + .unwrap(); + + let resolved = graph.resolve_public("SAME").unwrap(); + assert_eq!(resolved.selected, key(ROOT, "ROOT-SAME")); + assert_eq!(resolved.historical_candidates.len(), 3); + assert_eq!(graph.terminal_redirects().len(), 3); + + let relationship = graph + .effective_relationships() + .into_iter() + .find(|relationship| relationship.source == key(ROOT, "ROOT-REL")) + .unwrap(); + assert_eq!(relationship.historical_candidates.len(), 3); + assert_eq!( + relationship.effective_terminal, + Some(key(ROOT, "ROOT-SAME")) + ); + assert_eq!(relationship.issue, None); + + assert_eq!(graph.resolve_identity("SAME").outcome, OUTCOME_RESOLVED); + let identity = graph.identity_index(); + let replacement = identity + .iter() + .find(|entry| entry.key.as_ref() == Some(&key(ROOT, "ROOT-SAME"))) + .unwrap(); + assert!(replacement.aliases.iter().any(|alias| alias == "SAME")); +} + +#[test] +fn incomplete_diamond_override_is_a_blocking_divergence() { + let result = GraphComposition::compose(GraphCompositionInput::new( + ROOT, + diamond_nodes(), + vec![ + item(SHARED, "policy.md", "POLICY", "requirement", "Accepted", ""), + item( + LEFT, + "replacement.md", + "LEFT-POLICY", + "requirement", + "Accepted", + "", + ), + item(LEFT, "rationale.md", "LEFT-ADR", "decision", "Accepted", ""), + ], + vec![mapping(LEFT, (SHARED, "POLICY"), "LEFT-POLICY", "LEFT-ADR")], + )); + let findings = match result { + Ok(_) => panic!("an unreconciled branch fork must fail"), + Err(findings) => findings, + }; + assert!(findings.iter().any(|finding| { + finding.code == FINDING_OVERRIDE_DIVERGENCE + && finding.reason == GraphFindingReason::DivergentTerminal + && finding.owner_source.as_deref() == Some(ROOT) + && finding.artifacts == vec![key(LEFT, "LEFT-POLICY"), key(SHARED, "POLICY")] + })); +} + +#[test] +fn reconciled_chain_has_bottom_up_terminals_and_total_provenance() { + let graph = GraphComposition::compose(GraphCompositionInput::new( + ROOT, + diamond_nodes(), + vec![ + item(SHARED, "policy.md", "POLICY", "requirement", "Accepted", ""), + item( + LEFT, + "replacement.md", + "LEFT-POLICY", + "requirement", + "Accepted", + "", + ), + item(LEFT, "rationale.md", "LEFT-ADR", "decision", "Accepted", ""), + item( + ROOT, + "replacement.md", + "ROOT-POLICY", + "requirement", + "Accepted", + "", + ), + item(ROOT, "rationale.md", "ROOT-ADR", "decision", "Accepted", ""), + ], + vec![ + mapping(LEFT, (SHARED, "POLICY"), "LEFT-POLICY", "LEFT-ADR"), + mapping(ROOT, (LEFT, "LEFT-POLICY"), "ROOT-POLICY", "ROOT-ADR"), + mapping(ROOT, (SHARED, "POLICY"), "ROOT-POLICY", "ROOT-ADR"), + ], + )) + .unwrap(); + + assert_eq!( + graph.resolve_public("POLICY").unwrap().selected, + key(ROOT, "ROOT-POLICY") + ); + let history = graph.resolve_public("acme/shared::POLICY").unwrap(); + assert_eq!(history.selected, key(SHARED, "POLICY")); + assert_eq!(history.effective_terminal, key(ROOT, "ROOT-POLICY")); + assert_eq!( + graph + .effective() + .map(|item| item.key.clone()) + .collect::>(), + vec![ + key(ROOT, "ROOT-ADR"), + key(ROOT, "ROOT-POLICY"), + key(LEFT, "LEFT-ADR"), + ] + ); + + let owners = graph + .ordered_overrides() + .iter() + .map(|mapping| mapping.owner_source.as_str()) + .collect::>(); + assert_eq!(owners, vec![LEFT, ROOT, ROOT]); + let provenance = graph.provenance_for(&key(ROOT, "ROOT-POLICY")).unwrap(); + assert_eq!(provenance.len(), 3); + assert_eq!(provenance[0].owner_source, LEFT); + assert_eq!(provenance[0].state, GraphOverrideState::Lineage); + assert!(provenance[1..] + .iter() + .all(|entry| entry.state == GraphOverrideState::Replacement)); + + let intermediate = graph.provenance_for(&key(LEFT, "LEFT-POLICY")).unwrap(); + assert_eq!( + intermediate + .iter() + .map(|entry| entry.state) + .collect::>(), + vec![ + GraphOverrideState::Replacement, + GraphOverrideState::Overridden + ] + ); +} + +#[test] +fn parent_authored_relationship_keeps_history_and_root_redirects_only_live_endpoint() { + let graph = GraphComposition::compose(GraphCompositionInput::new( + ROOT, + diamond_nodes(), + vec![ + item(SHARED, "policy.md", "POLICY", "requirement", "Accepted", ""), + item( + SHARED, + "consumer.md", + "CONSUMER", + "requirement", + "Accepted", + "## Related Requirements\n\n- POLICY\n", + ), + item( + ROOT, + "replacement.md", + "ROOT-POLICY", + "requirement", + "Accepted", + "", + ), + item(ROOT, "rationale.md", "ROOT-ADR", "decision", "Accepted", ""), + ], + vec![mapping(ROOT, (SHARED, "POLICY"), "ROOT-POLICY", "ROOT-ADR")], + )) + .unwrap(); + + let catalog = graph + .catalog_relationships() + .into_iter() + .find(|relationship| relationship.source == key(SHARED, "CONSUMER")) + .unwrap(); + assert_eq!(catalog.authored_token, "POLICY"); + assert_eq!(catalog.historical_candidates, vec![key(SHARED, "POLICY")]); + assert_eq!(catalog.effective_terminal, Some(key(SHARED, "POLICY"))); + + let effective = graph + .effective_relationships() + .into_iter() + .find(|relationship| relationship.source == key(SHARED, "CONSUMER")) + .unwrap(); + assert_eq!( + effective.historical_candidates, + catalog.historical_candidates + ); + assert_eq!(effective.effective_terminal, Some(key(ROOT, "ROOT-POLICY"))); + assert_eq!(effective.issue, None); + + let compatible = graph + .relationships() + .into_iter() + .find(|relationship| { + relationship + .source_artifact + .as_ref() + .is_some_and(|path| path.source == SHARED && path.relative_path == "consumer.md") + }) + .unwrap(); + assert_eq!( + compatible.resolved_artifact.unwrap().source, + ROOT, + "existing consumers receive the root-effective endpoint" + ); + let replacement = graph + .effective_index() + .into_iter() + .find(|entry| entry.key.as_ref() == Some(&key(ROOT, "ROOT-POLICY"))) + .unwrap(); + assert_eq!(replacement.inbound_count, 1); +} + +#[test] +fn topology_and_input_order_do_not_change_semantic_ordering() { + let items = vec![ + item(SHARED, "policy.md", "POLICY", "requirement", "Accepted", ""), + item( + ROOT, + "replacement.md", + "ROOT-POLICY", + "requirement", + "Accepted", + "", + ), + item(ROOT, "rationale.md", "ROOT-ADR", "decision", "Accepted", ""), + ]; + let first = GraphComposition::compose(GraphCompositionInput::new( + ROOT, + diamond_nodes(), + items.clone(), + vec![mapping(ROOT, (SHARED, "POLICY"), "ROOT-POLICY", "ROOT-ADR")], + )) + .unwrap(); + let mut nodes = diamond_nodes(); + nodes.reverse(); + nodes + .iter_mut() + .for_each(|node| node.direct_parents.reverse()); + let second = GraphComposition::compose(GraphCompositionInput::new( + ROOT, + nodes, + items.into_iter().rev().collect(), + vec![mapping(ROOT, (SHARED, "POLICY"), "ROOT-POLICY", "ROOT-ADR")], + )) + .unwrap(); + + assert_eq!(first.topological_sources(), second.topological_sources()); + assert_eq!(first.ordered_overrides(), second.ordered_overrides()); + assert_eq!(first.terminal_redirects(), second.terminal_redirects()); + assert_eq!( + first + .effective() + .map(|item| item.key.clone()) + .collect::>(), + second + .effective() + .map(|item| item.key.clone()) + .collect::>() + ); +} + +#[test] +fn graph_cycle_fails_before_any_projection_is_published() { + let result = GraphComposition::compose(GraphCompositionInput::new( + ROOT, + vec![ + node(ROOT, vec![parent(LEFT, "left")]), + node(LEFT, vec![parent(ROOT, "root")]), + ], + Vec::new(), + Vec::new(), + )); + let findings = match result { + Ok(_) => panic!("cyclic graph must fail"), + Err(findings) => findings, + }; + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].reason, GraphFindingReason::Cycle); +} + +#[test] +fn edge_alias_validation_is_rechecked_at_the_semantic_boundary() { + let result = GraphComposition::compose(GraphCompositionInput::new( + ROOT, + vec![ + node(ROOT, vec![parent(SHARED, "Bad/Alias")]), + node(SHARED, Vec::new()), + ], + Vec::new(), + Vec::new(), + )); + let findings = match result { + Ok(_) => panic!("invalid alias must fail"), + Err(findings) => findings, + }; + assert!(findings + .iter() + .any(|finding| finding.reason == GraphFindingReason::InvalidAlias)); +} + +#[test] +fn qualified_legacy_alias_is_rejected_even_when_bare_alias_resolves() { + let text = "---\nschema_version: 1\nid: REQ-KWJ4VMKVSS66\ntype: requirement\n---\n# Qualified fixture\n\n## ID\n\nlegacy-name\n\n## Status\n\nAccepted\n\n## Problem\n\nAliases are not canonical.\n\n## Requirements\n\n- [REQ-001] Qualified references MUST be canonical.\n"; + let origin = CorpusLayer::inherited(SHARED, "runtime-only", "sha256-v2:fixture").origin(); + let display = "/runtime/acme/shared/qualified.md"; + let artifact = CorpusItem::new( + display.to_string(), + "qualified.md".to_string(), + parse_text(text, display), + spec_for("requirement"), + origin, + PhysicalArtifactLocator::new( + PhysicalCorpusLocator::new("/runtime/acme/shared", "/runtime/acme/shared/decisions"), + display, + ), + ); + let graph = GraphComposition::compose(GraphCompositionInput::new( + ROOT, + vec![ + node(ROOT, vec![parent(SHARED, "shared")]), + node(SHARED, Vec::new()), + ], + vec![artifact], + Vec::new(), + )) + .unwrap(); + + assert_eq!( + graph.resolve_public("legacy-name").unwrap().selected, + key(SHARED, "REQ-KWJ4VMKVSS66") + ); + assert_eq!( + graph.resolve_public("acme/shared::legacy-name"), + Err(GraphLookupError::QualifiedCanonicalRequired) + ); + assert_eq!( + graph.resolve_public("shared::legacy-name"), + Err(GraphLookupError::QualifiedCanonicalRequired) + ); +} + +#[test] +fn self_relationship_remains_an_explicit_issue() { + let graph = GraphComposition::compose(GraphCompositionInput::new( + ROOT, + vec![node(ROOT, Vec::new())], + vec![item( + ROOT, + "self.md", + "SELF", + "requirement", + "Accepted", + "## Related Requirements\n\n- SELF\n", + )], + Vec::new(), + )) + .unwrap(); + let relationship = graph.effective_relationships().pop().unwrap(); + assert_eq!( + relationship.issue, + Some(GraphRelationshipIssue::SelfReference) + ); + assert_eq!(relationship.effective_terminal, None); +} + +#[test] +fn root_is_the_only_writable_projection() { + let graph = GraphComposition::compose(GraphCompositionInput::new( + ROOT, + vec![ + node(ROOT, vec![parent(SHARED, "shared")]), + node(SHARED, Vec::new()), + ], + vec![ + item(ROOT, "root.md", "ROOT-REQ", "requirement", "Accepted", ""), + item( + SHARED, + "shared.md", + "SHARED-REQ", + "requirement", + "Accepted", + "", + ), + ], + Vec::new(), + )) + .unwrap(); + assert_eq!(graph.catalog().len(), 2); + assert_eq!(graph.effective().len(), 2); + assert_eq!(graph.root_local().len(), 1); + assert_eq!( + graph.root_local().next().unwrap().origin.layer, + Layer::Local + ); + assert_eq!(graph.effective_from(SHARED).unwrap().len(), 1); + assert_eq!( + graph.resolve_identity("SHARED-REQ").outcome, + OUTCOME_RESOLVED + ); +}