diff --git a/BUILD.bazel b/BUILD.bazel index 903c92e24..984b2eb39 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -181,6 +181,7 @@ test_suite( "//crates/graphforge-ir:golden", "//crates/graphforge-ontology:integration", "//crates/graphforge-ontology:composition_inventory", + "//crates/graphforge-ontology:bridge_sets", "//crates/graphforge-ontology:inventory_crud", "//crates/graphforge-rel:expression_lowering_matrix", "//crates/graphforge-rel:logical_plan_golden", @@ -242,6 +243,7 @@ test_suite( "//crates/graphforge-ir:golden", "//crates/graphforge-ontology:integration", "//crates/graphforge-ontology:composition_inventory", + "//crates/graphforge-ontology:bridge_sets", "//crates/graphforge-ontology:inventory_crud", "//crates/graphforge-rel:expression_lowering_matrix", "//crates/graphforge-rel:logical_plan_golden", diff --git a/crates/graphforge-ontology/BUILD.bazel b/crates/graphforge-ontology/BUILD.bazel index 1bc8dcbd4..ca84a665a 100644 --- a/crates/graphforge-ontology/BUILD.bazel +++ b/crates/graphforge-ontology/BUILD.bazel @@ -62,6 +62,15 @@ gf_rust_integration_test( ], ) +gf_rust_integration_test( + name = "bridge_sets", + srcs = ["tests/bridge_sets.rs"], + crate = ":graphforge_ontology", + deps = [ + "//crates/graphforge-core:graphforge_core", + ], +) + gf_rust_integration_test( name = "inventory_crud", srcs = ["tests/inventory_crud.rs"], @@ -78,6 +87,7 @@ test_suite( ":graphforge_ontology_test", ":integration", ":composition_inventory", + ":bridge_sets", ":inventory_crud", ], ) diff --git a/crates/graphforge-ontology/src/bridge/mod.rs b/crates/graphforge-ontology/src/bridge/mod.rs new file mode 100644 index 000000000..f13f9af9f --- /dev/null +++ b/crates/graphforge-ontology/src/bridge/mod.rs @@ -0,0 +1,21 @@ +//! Provenance-bearing bridge-set lifecycle (#838). +//! +//! Bridge sets connect exact qualified symbols across ontology modules without +//! mutating either source module. Suggested/inferred mappings stay +//! non-authoritative until explicitly adopted. Equal human names never create a +//! bridge automatically. + +mod store; +mod types; +mod validate; + +pub use store::{ + BridgeDeletePreview, BridgeExportFormat, BridgeImportFormatHint, BridgeInspect, + BridgeInventory, BridgeListEntry, BridgeMutationReceipt, BridgeSelector, BridgeSnapshot, + BridgeUpdatePreview, ModuleSymbolTable, +}; +pub use types::{ + BridgeAssertion, BridgeDocument, BridgeLifecycleStatus, BridgePredicate, BridgeProvenance, + MappingConfidence, MappingMethod, SharedSurfaceHint, +}; +pub use validate::validate_bridge_document; diff --git a/crates/graphforge-ontology/src/bridge/store.rs b/crates/graphforge-ontology/src/bridge/store.rs new file mode 100644 index 000000000..c0c50a780 --- /dev/null +++ b/crates/graphforge-ontology/src/bridge/store.rs @@ -0,0 +1,942 @@ +//! Bridge-set authority store and lifecycle operations. + +use std::collections::{HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +use crate::composition::{ + ActivationMode, BridgeSetId, CompositionDiagnostic, CompositionError, DiagnosticCode, + DiagnosticLimit, OntologyModuleId, SymbolKind, bridge_document_digest, +}; + +use super::types::{BridgeDocument, BridgeLifecycleStatus}; +use super::validate::validate_bridge_document; + +/// Known module symbols used to validate bridge endpoints. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModuleSymbolTable { + /// Exact module identity. + pub id: OntologyModuleId, + /// Entity local IDs. + pub entities: HashSet, + /// Relation local IDs. + pub relations: HashSet, + /// Property local IDs. + pub properties: HashSet, +} + +impl ModuleSymbolTable { + /// Whether the table contains a symbol of the given kind. + #[must_use] + pub fn contains(&self, kind: SymbolKind, local_id: &str) -> bool { + match kind { + SymbolKind::Entity => self.entities.contains(local_id), + SymbolKind::Relation => self.relations.contains(local_id), + SymbolKind::Property => self.properties.contains(local_id), + SymbolKind::Constraint | SymbolKind::Migration => false, + } + } +} + +/// How callers select a bridge for read/export/delete. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BridgeSelector { + /// Exact bridge identity. + Exact(BridgeSetId), + /// Bridge ID only — succeeds only when exactly one non-removed match exists. + BridgeId(String), +} + +/// Export encoding for a bridge document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BridgeExportFormat { + /// Canonical JSON. + Json, + /// YAML document. + Yaml, +} + +/// Import format hint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BridgeImportFormatHint { + /// Parse as JSON. + Json, + /// Parse as YAML. + Yaml, + /// Detect from leading non-whitespace (`{` → JSON, else YAML). + Auto, +} + +/// List row returned in identity order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgeListEntry { + /// Exact bridge identity. + pub id: BridgeSetId, + /// Lifecycle status. + pub status: BridgeLifecycleStatus, + /// Effective enforcement. + pub enforcement: ActivationMode, + /// Exact bridge dependencies. + pub dependencies: Vec, + /// Canonical digest. + pub digest: String, +} + +/// Detailed inspect receipt. +#[derive(Debug, Clone, PartialEq)] +pub struct BridgeInspect { + /// List metadata. + pub entry: BridgeListEntry, + /// Authored bridge document. + pub doc: BridgeDocument, + /// Current bridge inventory generation. + pub generation: u64, +} + +/// Non-mutating update impact preview. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgeUpdatePreview { + /// Source generation. + pub source_generation: u64, + /// Bridge that would be superseded. + pub prior: BridgeSetId, + /// Replacement identity. + pub next: BridgeSetId, + /// Dependants that reference `prior`. + pub affected_dependants: Vec, + /// Whether the replacement document validates. + pub document_valid: bool, +} + +/// Non-mutating delete impact preview. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgeDeletePreview { + /// Source generation. + pub source_generation: u64, + /// Bridge under consideration. + pub target: BridgeSetId, + /// Adopted bridges that list `target` as a dependency. + pub dependent_bridges: Vec, + /// Activation subjects referencing the target. + pub activation_refs: Vec, + /// True when delete would succeed without remediation. + pub safe: bool, +} + +/// Mutation receipt published with a successful authority change. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BridgeMutationReceipt { + /// Caller operation identity (idempotency key). + pub operation_id: String, + /// Generation before the mutation. + pub prior_generation: u64, + /// Generation after the mutation. + pub new_generation: u64, + /// Exact bridge affected (when applicable). + pub affected_bridge: Option, + /// Digest of the affected bridge document. + pub digest: Option, + /// True when this call replayed a prior successful operation. + pub idempotent_replay: bool, +} + +/// Durable snapshot for reopen / persistence tests. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BridgeSnapshot { + /// Schema version for this snapshot encoding. + pub schema_version: u32, + /// Authority generation. + pub generation: u64, + /// Default enforcement for bridges without overrides. + pub profile_default: ActivationMode, + /// Activation subjects that reference bridges (opaque subject strings). + pub activation_subjects: Vec, + /// Known module symbol tables (reopen authority context). + pub modules: Vec, + /// Adopted bridges. + pub adopted: Vec, + /// Completed operation receipts for idempotency. + pub receipts: Vec, +} + +/// Serializable module symbol table. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SnapshotModuleSymbols { + /// Exact module identity. + pub id: OntologyModuleId, + /// Entity local IDs. + pub entities: Vec, + /// Relation local IDs. + pub relations: Vec, + /// Property local IDs. + pub properties: Vec, +} + +/// One adopted bridge in a snapshot. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SnapshotBridge { + /// Exact identity. + pub id: BridgeSetId, + /// Dependencies. + pub dependencies: Vec, + /// Document. + pub doc: BridgeDocument, +} + +#[derive(Debug, Clone, PartialEq)] +struct BridgeRecord { + id: BridgeSetId, + status: BridgeLifecycleStatus, + dependencies: Vec, + doc: BridgeDocument, +} + +/// Durable bridge inventory with session staging. +#[derive(Debug, Clone)] +pub struct BridgeInventory { + generation: u64, + profile_default: ActivationMode, + modules: HashMap, + adopted: HashMap, + staging: HashMap, + /// Opaque activation subjects that reference bridge display_refs. + activation_subjects: HashSet, + receipts: HashMap, + diag_limit: DiagnosticLimit, +} + +impl Default for BridgeInventory { + fn default() -> Self { + Self::new(ActivationMode::Exploratory, DiagnosticLimit::default()) + } +} + +impl BridgeInventory { + /// Create an empty bridge inventory at generation 0. + #[must_use] + pub fn new(profile_default: ActivationMode, diag_limit: DiagnosticLimit) -> Self { + Self { + generation: 0, + profile_default, + modules: HashMap::new(), + adopted: HashMap::new(), + staging: HashMap::new(), + activation_subjects: HashSet::new(), + receipts: HashMap::new(), + diag_limit, + } + } + + /// Current authority generation. + #[must_use] + pub fn generation(&self) -> u64 { + self.generation + } + + /// Register or replace a known module symbol table (session/authority context). + /// + /// Does not mutate bridge authority generation. Equal local names across + /// modules never create bridges. + pub fn register_module(&mut self, table: ModuleSymbolTable) { + self.modules.insert(table.id.display_ref(), table); + } + + /// Record that an activation profile references a bridge (blocks delete). + pub fn note_activation(&mut self, bridge: &BridgeSetId) { + self.activation_subjects.insert(bridge.display_ref()); + } + + /// Validate a document without mutating authority or staging. + pub fn validate_document(&self, doc: &BridgeDocument) -> Result<(), CompositionError> { + let modules = self.module_tables(); + validate_bridge_document(doc, &modules, self.diag_limit) + } + + fn require_authoritative(&self, doc: &BridgeDocument) -> Result<(), CompositionError> { + if doc + .assertions + .iter() + .any(|assertion| !assertion.provenance.method.is_authoritative()) + { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::LifecycleInvalidTransition, + "suggested/inferred mappings remain non-authoritative until rewritten as authored", + vec![doc.bridge_id.clone()], + self.diag_limit, + ))); + } + Ok(()) + } + + /// Create/register a validated authored bridge into session staging. + pub fn create_register( + &mut self, + doc: BridgeDocument, + operation_id: impl Into, + ) -> Result { + let _operation_id = operation_id.into(); + self.validate_document(&doc)?; + // Suggested/inferred-only documents may stage as candidates but create_register + // requires authored assertions for the validated path. + self.require_authoritative(&doc)?; + let id = self.identity_for(&doc)?; + let key = id.display_ref(); + if self.adopted.contains_key(&key) { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InventoryDuplicate, + "bridge identity already adopted", + vec![id.display_ref()], + self.diag_limit, + ))); + } + self.staging.insert( + key, + BridgeRecord { + id: id.clone(), + status: BridgeLifecycleStatus::Validated, + dependencies: doc.dependencies.clone(), + doc, + }, + ); + Ok(id) + } + + /// Import YAML/JSON as a non-authoritative staged candidate. + pub fn import_text( + &mut self, + text: &str, + format_hint: BridgeImportFormatHint, + operation_id: impl Into, + ) -> Result { + let _operation_id = operation_id.into(); + let doc = parse_document(text, format_hint, self.diag_limit)?; + // Import stages even when suggested; full validation still applies to structure. + self.validate_document(&doc)?; + let id = self.identity_for(&doc)?; + let key = id.display_ref(); + self.staging.insert( + key, + BridgeRecord { + id: id.clone(), + status: BridgeLifecycleStatus::Candidate, + dependencies: doc.dependencies.clone(), + doc, + }, + ); + Ok(id) + } + + /// Explicitly adopt a staged bridge into durable authority. + pub fn adopt( + &mut self, + selector: &BridgeSelector, + source_generation: u64, + operation_id: impl Into, + ) -> Result { + let operation_id = operation_id.into(); + if let Some(prior) = self.receipts.get(&operation_id) { + return Ok(replay(prior)); + } + self.require_generation(source_generation)?; + let staged = self.take_staged(selector)?; + if staged.status != BridgeLifecycleStatus::Validated + && staged.status != BridgeLifecycleStatus::Candidate + { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::LifecycleInvalidTransition, + format!("cannot adopt bridge in status {}", staged.status.as_str()), + vec![staged.id.display_ref()], + self.diag_limit, + ))); + } + self.validate_document(&staged.doc)?; + // Adoption requires authored mappings (suggested stay non-authoritative). + self.require_authoritative(&staged.doc)?; + let key = staged.id.display_ref(); + if self.adopted.contains_key(&key) { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InventoryDuplicate, + "bridge already adopted", + vec![staged.id.display_ref()], + self.diag_limit, + ))); + } + self.require_bridge_dependencies(&staged.dependencies)?; + let mut record = staged; + record.status = BridgeLifecycleStatus::Adopted; + let prior = self.generation; + let digest = record.id.canonical_digest.clone(); + let id = record.id.clone(); + self.adopted.insert(key, record); + self.publish(prior, Some(id), Some(digest), operation_id) + } + + /// List adopted bridges in deterministic identity order. + #[must_use] + pub fn list(&self) -> Vec { + let mut entries: Vec = self + .adopted + .values() + .filter(|r| r.status == BridgeLifecycleStatus::Adopted) + .map(|r| self.to_list_entry(r)) + .collect(); + entries.sort_by_key(|e| e.id.sort_key()); + entries + } + + /// Get/inspect one exact adopted bridge. + pub fn inspect(&self, selector: &BridgeSelector) -> Result { + let record = self.resolve_adopted(selector)?; + Ok(BridgeInspect { + entry: self.to_list_entry(record), + doc: record.doc.clone(), + generation: self.generation, + }) + } + + /// Preview replacing an adopted bridge with a new document version. + pub fn preview_update( + &self, + selector: &BridgeSelector, + next_doc: &BridgeDocument, + ) -> Result { + let prior = self.resolve_adopted(selector)?; + let document_valid = self.validate_document(next_doc).is_ok(); + let digest = bridge_document_digest(next_doc).unwrap_or_default(); + let next = BridgeSetId { + bridge_id: next_doc.bridge_id.clone(), + authored_version: next_doc.authored_version.clone(), + canonical_digest: digest, + }; + Ok(BridgeUpdatePreview { + source_generation: self.generation, + prior: prior.id.clone(), + next, + affected_dependants: self.dependants_of(&prior.id), + document_valid, + }) + } + + /// Atomically replace one adopted bridge version. + pub fn update( + &mut self, + selector: &BridgeSelector, + next_doc: BridgeDocument, + source_generation: u64, + operation_id: impl Into, + ) -> Result { + let operation_id = operation_id.into(); + if let Some(prior) = self.receipts.get(&operation_id) { + return Ok(replay(prior)); + } + self.require_generation(source_generation)?; + let preview = self.preview_update(selector, &next_doc)?; + if !preview.document_valid { + self.validate_document(&next_doc)?; + } + self.require_authoritative(&next_doc)?; + if preview.next.bridge_id != preview.prior.bridge_id { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::CollisionMetadata, + "update replacement must retain the same bridge_id", + vec![preview.prior.display_ref(), preview.next.display_ref()], + self.diag_limit, + ))); + } + self.require_bridge_dependencies(&next_doc.dependencies)?; + let prior_key = preview.prior.display_ref(); + let Some(mut prior_record) = self.adopted.remove(&prior_key) else { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InventoryNotFound, + "bridge disappeared during update", + vec![preview.prior.display_ref()], + self.diag_limit, + ))); + }; + prior_record.status = BridgeLifecycleStatus::Superseded; + + let next_record = BridgeRecord { + id: preview.next.clone(), + status: BridgeLifecycleStatus::Adopted, + dependencies: next_doc.dependencies.clone(), + doc: next_doc, + }; + for dep_key in self.adopted.keys().cloned().collect::>() { + if let Some(dep) = self.adopted.get_mut(&dep_key) { + for edge in &mut dep.dependencies { + if edge == &preview.prior { + *edge = preview.next.clone(); + } + } + } + } + // Rewrite activation subjects that pointed at the prior identity. + if self + .activation_subjects + .remove(&preview.prior.display_ref()) + { + self.activation_subjects.insert(preview.next.display_ref()); + } + self.adopted + .insert(preview.next.display_ref(), next_record.clone()); + let prior_gen = self.generation; + self.publish( + prior_gen, + Some(next_record.id.clone()), + Some(next_record.id.canonical_digest.clone()), + operation_id, + ) + } + + /// Preview deletion impact. + pub fn preview_delete( + &self, + selector: &BridgeSelector, + ) -> Result { + let target = self.resolve_adopted(selector)?; + let dependent_bridges = self.dependants_of(&target.id); + let activation_refs: Vec = self + .activation_subjects + .iter() + .filter(|s| *s == &target.id.display_ref()) + .cloned() + .collect(); + let safe = dependent_bridges.is_empty() && activation_refs.is_empty(); + Ok(BridgeDeletePreview { + source_generation: self.generation, + target: target.id.clone(), + dependent_bridges, + activation_refs, + safe, + }) + } + + /// Atomically remove an adopted bridge when safe. + pub fn delete( + &mut self, + selector: &BridgeSelector, + source_generation: u64, + operation_id: impl Into, + ) -> Result { + let operation_id = operation_id.into(); + if let Some(prior) = self.receipts.get(&operation_id) { + return Ok(replay(prior)); + } + self.require_generation(source_generation)?; + let preview = self.preview_delete(selector)?; + if !preview.safe { + let mut subjects = vec![preview.target.display_ref()]; + subjects.extend( + preview + .dependent_bridges + .iter() + .map(BridgeSetId::display_ref), + ); + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::DependencyInUse, + "bridge is referenced by dependants or activation; remove those first", + subjects, + self.diag_limit, + ))); + } + let key = preview.target.display_ref(); + let Some(mut record) = self.adopted.remove(&key) else { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InventoryNotFound, + "bridge not found for delete", + vec![preview.target.display_ref()], + self.diag_limit, + ))); + }; + record.status = BridgeLifecycleStatus::Removed; + let digest = record.id.canonical_digest.clone(); + let id = record.id.clone(); + let prior = self.generation; + self.activation_subjects.retain(|s| s != &id.display_ref()); + self.publish(prior, Some(id), Some(digest), operation_id) + } + + /// Deterministically export one adopted bridge as YAML or JSON. + pub fn export_bridge( + &self, + selector: &BridgeSelector, + format: BridgeExportFormat, + ) -> Result { + let record = self.resolve_adopted(selector)?; + match format { + BridgeExportFormat::Json => { + let value = serde_json::to_value(&record.doc).map_err(|e| { + CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InterchangeIntegrity, + format!("json encode failed: {e}"), + vec![record.id.display_ref()], + self.diag_limit, + )) + })?; + serde_json::to_string_pretty(&value).map_err(|e| { + CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InterchangeIntegrity, + format!("json pretty failed: {e}"), + vec![record.id.display_ref()], + self.diag_limit, + )) + }) + } + BridgeExportFormat::Yaml => serde_yaml::to_string(&record.doc).map_err(|e| { + CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InterchangeIntegrity, + format!("yaml encode failed: {e}"), + vec![record.id.display_ref()], + self.diag_limit, + )) + }), + } + } + + /// Adopted bridge identities in deterministic order (for composition closure). + #[must_use] + pub fn adopted_ids(&self) -> Vec { + let mut ids: Vec = self + .adopted + .values() + .filter(|r| r.status == BridgeLifecycleStatus::Adopted) + .map(|r| r.id.clone()) + .collect(); + ids.sort_by_key(BridgeSetId::sort_key); + ids + } + + /// Serialize durable authority (staging excluded). + #[must_use] + pub fn snapshot(&self) -> BridgeSnapshot { + let mut adopted: Vec = self + .adopted + .values() + .filter(|r| r.status == BridgeLifecycleStatus::Adopted) + .map(|r| SnapshotBridge { + id: r.id.clone(), + dependencies: r.dependencies.clone(), + doc: r.doc.clone(), + }) + .collect(); + adopted.sort_by_key(|b| b.id.sort_key()); + let mut modules: Vec = self + .modules + .values() + .map(|m| { + let mut entities: Vec<_> = m.entities.iter().cloned().collect(); + let mut relations: Vec<_> = m.relations.iter().cloned().collect(); + let mut properties: Vec<_> = m.properties.iter().cloned().collect(); + entities.sort(); + relations.sort(); + properties.sort(); + SnapshotModuleSymbols { + id: m.id.clone(), + entities, + relations, + properties, + } + }) + .collect(); + modules.sort_by_key(|m| m.id.sort_key()); + let mut activation_subjects: Vec<_> = self.activation_subjects.iter().cloned().collect(); + activation_subjects.sort(); + let mut receipts: Vec<_> = self.receipts.values().cloned().collect(); + receipts.sort_by(|a, b| a.operation_id.cmp(&b.operation_id)); + BridgeSnapshot { + schema_version: 1, + generation: self.generation, + profile_default: self.profile_default, + activation_subjects, + modules, + adopted, + receipts, + } + } + + /// Reopen durable authority from a snapshot (staging starts empty). + pub fn reopen(snapshot: BridgeSnapshot) -> Result { + if snapshot.schema_version != 1 { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InterchangeIntegrity, + format!( + "unsupported bridge snapshot version {}", + snapshot.schema_version + ), + Vec::new(), + DiagnosticLimit::default(), + ))); + } + let mut inv = Self::new(snapshot.profile_default, DiagnosticLimit::default()); + inv.generation = snapshot.generation; + inv.activation_subjects = snapshot.activation_subjects.into_iter().collect(); + for module in snapshot.modules { + inv.register_module(ModuleSymbolTable { + id: module.id, + entities: module.entities.into_iter().collect(), + relations: module.relations.into_iter().collect(), + properties: module.properties.into_iter().collect(), + }); + } + for bridge in snapshot.adopted { + inv.validate_document(&bridge.doc)?; + inv.require_authoritative(&bridge.doc)?; + let computed_id = inv.identity_for(&bridge.doc)?; + if bridge.id != computed_id || bridge.dependencies != bridge.doc.dependencies { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InterchangeIntegrity, + "snapshot bridge identity or dependency projection does not match its document", + vec![bridge.id.display_ref(), computed_id.display_ref()], + inv.diag_limit, + ))); + } + inv.adopted.insert( + bridge.id.display_ref(), + BridgeRecord { + id: bridge.id, + status: BridgeLifecycleStatus::Adopted, + dependencies: bridge.dependencies, + doc: bridge.doc, + }, + ); + } + for receipt in snapshot.receipts { + inv.receipts.insert(receipt.operation_id.clone(), receipt); + } + // Re-validate the adopted dependency closure after every identity is loaded. + for record in inv.adopted.values() { + inv.require_bridge_dependencies(&record.dependencies)?; + } + Ok(inv) + } + + fn identity_for(&self, doc: &BridgeDocument) -> Result { + let digest = bridge_document_digest(doc).map_err(|e| { + CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InterchangeIntegrity, + format!("failed to digest bridge document: {e}"), + vec![doc.bridge_id.clone()], + self.diag_limit, + )) + })?; + Ok(BridgeSetId { + bridge_id: doc.bridge_id.clone(), + authored_version: doc.authored_version.clone(), + canonical_digest: digest, + }) + } + + fn module_tables(&self) -> Vec { + self.modules.values().cloned().collect() + } + + fn publish( + &mut self, + prior_generation: u64, + affected: Option, + digest: Option, + operation_id: String, + ) -> Result { + self.generation = prior_generation.checked_add(1).ok_or_else(|| { + CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::ResourceDiagnostics, + "generation counter overflow", + Vec::new(), + self.diag_limit, + )) + })?; + let receipt = BridgeMutationReceipt { + operation_id: operation_id.clone(), + prior_generation, + new_generation: self.generation, + affected_bridge: affected, + digest, + idempotent_replay: false, + }; + self.receipts.insert(operation_id, receipt.clone()); + Ok(receipt) + } + + fn dependants_of(&self, target: &BridgeSetId) -> Vec { + let mut deps: Vec = self + .adopted + .values() + .filter(|r| r.status == BridgeLifecycleStatus::Adopted) + .filter(|r| r.dependencies.iter().any(|d| d == target)) + .map(|r| r.id.clone()) + .collect(); + deps.sort_by_key(BridgeSetId::sort_key); + deps + } + + fn require_bridge_dependencies( + &self, + dependencies: &[BridgeSetId], + ) -> Result<(), CompositionError> { + for dep in dependencies { + if !self + .adopted + .get(&dep.display_ref()) + .is_some_and(|r| r.status == BridgeLifecycleStatus::Adopted) + { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::DependencyMissing, + "required bridge dependency is not adopted", + vec![dep.display_ref()], + self.diag_limit, + ))); + } + } + Ok(()) + } + + fn require_generation(&self, source: u64) -> Result<(), CompositionError> { + if source != self.generation { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InventoryGenerationConflict, + format!( + "stale source generation {source}; current is {}", + self.generation + ), + vec![source.to_string(), self.generation.to_string()], + self.diag_limit, + ))); + } + Ok(()) + } + + fn to_list_entry(&self, record: &BridgeRecord) -> BridgeListEntry { + BridgeListEntry { + id: record.id.clone(), + status: record.status, + enforcement: record.doc.enforcement.unwrap_or(self.profile_default), + dependencies: record.dependencies.clone(), + digest: record.id.canonical_digest.clone(), + } + } + + fn resolve_adopted<'a>( + &'a self, + selector: &BridgeSelector, + ) -> Result<&'a BridgeRecord, CompositionError> { + match selector { + BridgeSelector::Exact(id) => self + .adopted + .get(&id.display_ref()) + .filter(|r| r.status == BridgeLifecycleStatus::Adopted) + .ok_or_else(|| { + CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InventoryNotFound, + "bridge not found in durable inventory", + vec![id.display_ref()], + self.diag_limit, + )) + }), + BridgeSelector::BridgeId(bridge_id) => { + let matches: Vec<_> = self + .adopted + .values() + .filter(|r| { + r.status == BridgeLifecycleStatus::Adopted && r.id.bridge_id == *bridge_id + }) + .collect(); + match matches.as_slice() { + [one] => Ok(*one), + [] => Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InventoryNotFound, + "no adopted bridge matches bridge_id", + vec![bridge_id.clone()], + self.diag_limit, + ))), + many => Err(CompositionError::one(CompositionDiagnostic::new( + DiagnosticCode::ResolutionAmbiguous, + "bridge_id selects more than one adopted bridge", + vec![bridge_id.clone()], + many.iter().map(|r| r.id.display_ref()).collect(), + self.diag_limit, + ))), + } + } + } + } + + fn take_staged(&mut self, selector: &BridgeSelector) -> Result { + let key = match selector { + BridgeSelector::Exact(id) => id.display_ref(), + BridgeSelector::BridgeId(bridge_id) => { + let matches: Vec<_> = self + .staging + .values() + .filter(|r| r.id.bridge_id == *bridge_id) + .map(|r| r.id.display_ref()) + .collect(); + match matches.as_slice() { + [one] => one.clone(), + [] => { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InventoryNotFound, + "no staged bridge matches bridge_id", + vec![bridge_id.clone()], + self.diag_limit, + ))); + } + many => { + return Err(CompositionError::one(CompositionDiagnostic::new( + DiagnosticCode::ResolutionAmbiguous, + "bridge_id selects more than one staged bridge", + vec![bridge_id.clone()], + many.to_vec(), + self.diag_limit, + ))); + } + } + } + }; + self.staging.remove(&key).ok_or_else(|| { + CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InventoryNotFound, + "staged bridge not found", + vec![key], + self.diag_limit, + )) + }) + } +} + +fn replay(prior: &BridgeMutationReceipt) -> BridgeMutationReceipt { + let mut out = prior.clone(); + out.idempotent_replay = true; + out +} + +fn parse_document( + text: &str, + format_hint: BridgeImportFormatHint, + limit: DiagnosticLimit, +) -> Result { + let trimmed = text.trim_start(); + let as_json = match format_hint { + BridgeImportFormatHint::Json => true, + BridgeImportFormatHint::Yaml => false, + BridgeImportFormatHint::Auto => trimmed.starts_with('{'), + }; + if as_json { + serde_json::from_str(text).map_err(|e| { + CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InventoryMalformed, + format!("bridge json parse failed: {e}"), + Vec::new(), + limit, + )) + }) + } else { + serde_yaml::from_str(text).map_err(|e| { + CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InventoryMalformed, + format!("bridge yaml parse failed: {e}"), + Vec::new(), + limit, + )) + }) + } +} diff --git a/crates/graphforge-ontology/src/bridge/types.rs b/crates/graphforge-ontology/src/bridge/types.rs new file mode 100644 index 000000000..23301f897 --- /dev/null +++ b/crates/graphforge-ontology/src/bridge/types.rs @@ -0,0 +1,209 @@ +//! Bridge-set document types and bounded predicate vocabulary. + +use serde::{Deserialize, Serialize}; + +use crate::composition::{ActivationMode, BridgeSetId, OntologyModuleId, QualifiedSymbol}; + +/// Bounded predicate set for bridge assertions (contract + #838 AC). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BridgePredicate { + /// Governed equivalence between same-kind endpoints. + Equivalent, + /// Directional relatedness (source related-to target). + Related, + /// Directional broader mapping (source broader than target). + Broader, + /// Directional narrower mapping (source narrower than target). + Narrower, + /// Governed disjointness (same-kind endpoints must not overlap). + Disjoint, + /// Property or relation mapping (kind-compatible maps_to). + MapsTo, + /// Evidence linkage (typically entity/claim surfaces). + EvidenceFor, +} + +impl BridgePredicate { + /// Stable wire token. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Equivalent => "equivalent", + Self::Related => "related", + Self::Broader => "broader", + Self::Narrower => "narrower", + Self::Disjoint => "disjoint", + Self::MapsTo => "maps_to", + Self::EvidenceFor => "evidence_for", + } + } + + /// Whether the predicate is symmetric (direction still recorded for provenance). + #[must_use] + pub fn is_symmetric(self) -> bool { + matches!(self, Self::Equivalent | Self::Disjoint) + } +} + +/// How a mapping was produced. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MappingMethod { + /// Explicitly authored by a human curator. + Authored, + /// Suggested by tooling; non-authoritative until adopted. + Suggested, + /// Inferred by tooling; non-authoritative until adopted. + Inferred, +} + +impl MappingMethod { + /// Stable wire token. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Authored => "authored", + Self::Suggested => "suggested", + Self::Inferred => "inferred", + } + } + + /// Suggested/inferred mappings cannot become durable authority without adoption. + #[must_use] + pub fn is_authoritative(self) -> bool { + matches!(self, Self::Authored) + } +} + +/// Optional confidence for suggested/inferred mappings. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct MappingConfidence { + /// Closed interval \[0.0, 1.0\]. + pub value: f64, +} + +/// Provenance recorded on every authoritative assertion. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BridgeProvenance { + /// Production method. + pub method: MappingMethod, + /// Optional confidence (required for suggested/inferred when present). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub confidence: Option, + /// Human justification (required for adopted authoritative mappings). + pub justification: String, + /// Evidence references (URIs or opaque evidence IDs); path-free. + #[serde(default)] + pub evidence_refs: Vec, +} + +/// Optional shared semantic surface hint (never mandatory). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SharedSurfaceHint { + /// Provenance vocabulary. + Provenance, + /// Evidence / claim surface. + Evidence, + /// Research action vocabulary. + ResearchActions, + /// Time / place commons. + TimePlace, + /// Common relation vocabulary. + CommonRelations, +} + +impl SharedSurfaceHint { + /// Stable wire token. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Provenance => "provenance", + Self::Evidence => "evidence", + Self::ResearchActions => "research_actions", + Self::TimePlace => "time_place", + Self::CommonRelations => "common_relations", + } + } +} + +/// Lifecycle status for a bridge-set record. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BridgeLifecycleStatus { + /// Staged, not validated. + Candidate, + /// Validated but not durable authority. + Validated, + /// Durable bridge inventory authority. + Adopted, + /// Replaced by a newer exact bridge version. + Superseded, + /// Explicitly removed from authority. + Removed, +} + +impl BridgeLifecycleStatus { + /// Stable wire token. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Candidate => "candidate", + Self::Validated => "validated", + Self::Adopted => "adopted", + Self::Superseded => "superseded", + Self::Removed => "removed", + } + } +} + +/// One directed (or symmetric) mapping between exact qualified endpoints. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BridgeAssertion { + /// Source endpoint (exact qualified symbol). + pub source: QualifiedSymbol, + /// Target endpoint (exact qualified symbol). + pub target: QualifiedSymbol, + /// Bounded predicate. + pub predicate: BridgePredicate, + /// Direction flag retained even for symmetric predicates (source→target). + #[serde(default = "default_true")] + pub directional: bool, + /// Provenance / evidence. + pub provenance: BridgeProvenance, + /// Optional validity interval start as opaque NFC string (ISO-8601 recommended). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid_from: Option, + /// Optional validity interval end as opaque NFC string (ISO-8601 recommended). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid_to: Option, +} + +fn default_true() -> bool { + true +} + +/// Canonical bridge-set document (digest domain: `graphforge-ontology-bridge/1`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BridgeDocument { + /// Globally unique NFC-normalized bridge URI. + pub bridge_id: String, + /// Opaque NFC authored version. + pub authored_version: String, + /// Exact source module constraints (must appear in known inventory). + pub source_modules: Vec, + /// Exact target module constraints. + pub target_modules: Vec, + /// Optional bridge-set dependencies on other exact bridge identities. + #[serde(default)] + pub dependencies: Vec, + /// Optional shared surface hints (never required for validity). + #[serde(default)] + pub shared_surfaces: Vec, + /// Mapping assertions. + pub assertions: Vec, + /// Optional enforcement override for this bridge when activated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enforcement: Option, +} diff --git a/crates/graphforge-ontology/src/bridge/validate.rs b/crates/graphforge-ontology/src/bridge/validate.rs new file mode 100644 index 000000000..6b26c87bf --- /dev/null +++ b/crates/graphforge-ontology/src/bridge/validate.rs @@ -0,0 +1,262 @@ +//! Bridge-set document validation (endpoints, kinds, conflicts, provenance). + +use std::collections::{HashMap, HashSet}; + +use unicode_normalization::UnicodeNormalization; + +use crate::composition::{ + CompositionDiagnostic, CompositionError, DiagnosticCode, DiagnosticLimit, OntologyModuleId, + QualifiedSymbol, SymbolKind, bridge_document_digest, +}; + +use super::store::ModuleSymbolTable; +use super::types::{BridgeAssertion, BridgeDocument, BridgePredicate, MappingMethod}; + +/// Validate a bridge document against known module symbol tables. +pub fn validate_bridge_document( + doc: &BridgeDocument, + modules: &[ModuleSymbolTable], + limit: DiagnosticLimit, +) -> Result<(), CompositionError> { + require_nfc(&doc.bridge_id, "bridge_id", limit)?; + require_nfc(&doc.authored_version, "authored_version", limit)?; + if doc.assertions.is_empty() { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InventoryMalformed, + "bridge document must contain at least one assertion", + vec![doc.bridge_id.clone()], + limit, + ))); + } + + let by_id: HashMap = + modules.iter().map(|m| (m.id.display_ref(), m)).collect(); + + for module in doc.source_modules.iter().chain(doc.target_modules.iter()) { + if !by_id.contains_key(&module.display_ref()) { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::DependencyMissing, + "bridge source/target module constraint missing from known inventory", + vec![module.display_ref()], + limit, + ))); + } + } + + let constraint_ok = |module: &OntologyModuleId| { + doc.source_modules.iter().any(|m| m == module) + || doc.target_modules.iter().any(|m| m == module) + }; + + for assertion in &doc.assertions { + validate_assertion(assertion, &by_id, &constraint_ok, limit)?; + } + + detect_contradictions(&doc.assertions, limit)?; + + // Digest must be computable (canonicalization / serde). + let _ = bridge_document_digest(doc).map_err(|e| { + CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InterchangeIntegrity, + format!("failed to digest bridge document: {e}"), + vec![doc.bridge_id.clone()], + limit, + )) + })?; + + Ok(()) +} + +fn validate_assertion( + assertion: &BridgeAssertion, + by_id: &HashMap, + constraint_ok: &dyn Fn(&OntologyModuleId) -> bool, + limit: DiagnosticLimit, +) -> Result<(), CompositionError> { + require_nfc(&assertion.source.local_id, "source.local_id", limit)?; + require_nfc(&assertion.target.local_id, "target.local_id", limit)?; + + if !constraint_ok(&assertion.source.module) || !constraint_ok(&assertion.target.module) { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::BridgeEndpointMissing, + "assertion endpoint module is outside bridge source/target constraints", + vec![assertion.source.display(), assertion.target.display()], + limit, + ))); + } + + resolve_endpoint(&assertion.source, by_id, limit)?; + resolve_endpoint(&assertion.target, by_id, limit)?; + + if !kinds_compatible( + assertion.predicate, + assertion.source.kind, + assertion.target.kind, + ) { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::ResolutionKindMismatch, + format!( + "predicate {} rejects kind pair {} -> {}", + assertion.predicate.as_str(), + assertion.source.kind.as_str(), + assertion.target.kind.as_str() + ), + vec![assertion.source.display(), assertion.target.display()], + limit, + ))); + } + + // Authoritative mappings require justification + provenance method authored. + if assertion.provenance.method == MappingMethod::Authored + && assertion.provenance.justification.trim().is_empty() + { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::BridgeProvenanceMissing, + "authored mapping requires a non-empty justification", + vec![assertion.source.display(), assertion.target.display()], + limit, + ))); + } + + if let Some(confidence) = assertion.provenance.confidence + && !(0.0..=1.0).contains(&confidence.value) + { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::InventoryMalformed, + "mapping confidence must be in [0.0, 1.0]", + vec![assertion.source.display(), assertion.target.display()], + limit, + ))); + } + + Ok(()) +} + +fn resolve_endpoint( + symbol: &QualifiedSymbol, + by_id: &HashMap, + limit: DiagnosticLimit, +) -> Result<(), CompositionError> { + let Some(table) = by_id.get(&symbol.module.display_ref()) else { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::BridgeEndpointMissing, + "bridge endpoint module is absent from the known inventory", + vec![symbol.display()], + limit, + ))); + }; + if !table.contains(symbol.kind, &symbol.local_id) { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::BridgeEndpointMissing, + "bridge endpoint symbol is missing from its module", + vec![symbol.display()], + limit, + ))); + } + Ok(()) +} + +fn kinds_compatible(predicate: BridgePredicate, source: SymbolKind, target: SymbolKind) -> bool { + match predicate { + BridgePredicate::Equivalent + | BridgePredicate::Related + | BridgePredicate::Broader + | BridgePredicate::Narrower + | BridgePredicate::Disjoint => { + source == target + && matches!( + source, + SymbolKind::Entity | SymbolKind::Relation | SymbolKind::Property + ) + } + BridgePredicate::MapsTo => { + source == target && matches!(source, SymbolKind::Property | SymbolKind::Relation) + } + BridgePredicate::EvidenceFor => { + // Typically entity/claim → evidence entity; allow entity→entity. + source == SymbolKind::Entity && target == SymbolKind::Entity + } + } +} + +/// Detect contradictory assertions; return a minimal attributable conflict set. +fn detect_contradictions( + assertions: &[BridgeAssertion], + limit: DiagnosticLimit, +) -> Result<(), CompositionError> { + // Index by unordered endpoint pair + kind for symmetric conflict checks. + let mut by_pair: HashMap<(String, String), Vec<&BridgeAssertion>> = HashMap::new(); + for assertion in assertions { + let a = assertion.source.display(); + let b = assertion.target.display(); + let key = if a <= b { (a, b) } else { (b, a) }; + by_pair.entry(key).or_default().push(assertion); + } + + for ((_a, _b), group) in &by_pair { + let predicates: HashSet = group.iter().map(|a| a.predicate).collect(); + if predicates.contains(&BridgePredicate::Equivalent) + && predicates.contains(&BridgePredicate::Disjoint) + { + let subjects: Vec = group + .iter() + .flat_map(|a| [a.source.display(), a.target.display()]) + .collect(); + return Err(CompositionError::one(CompositionDiagnostic::new( + DiagnosticCode::BridgeContradiction, + "equivalent and disjoint assertions conflict for the same endpoints", + subjects, + group + .iter() + .map(|a| a.predicate.as_str().to_owned()) + .collect(), + limit, + ))); + } + // Broader both ways without a coherent inverse is incoherent. + let mut directed: HashSet<(String, BridgePredicate, String)> = HashSet::new(); + for a in group { + directed.insert((a.source.display(), a.predicate, a.target.display())); + } + for a in group { + if a.predicate == BridgePredicate::Broader { + let reverse = ( + a.target.display(), + BridgePredicate::Broader, + a.source.display(), + ); + let conflicting_narrower = ( + a.source.display(), + BridgePredicate::Narrower, + a.target.display(), + ); + if directed.contains(&reverse) || directed.contains(&conflicting_narrower) { + return Err(CompositionError::one(CompositionDiagnostic::new( + DiagnosticCode::BridgeContradiction, + "broader/narrower assertions form an incoherent pair", + vec![a.source.display(), a.target.display()], + vec![ + BridgePredicate::Broader.as_str().to_owned(), + BridgePredicate::Narrower.as_str().to_owned(), + ], + limit, + ))); + } + } + } + } + Ok(()) +} + +fn require_nfc(value: &str, field: &str, limit: DiagnosticLimit) -> Result<(), CompositionError> { + let nfc: String = value.nfc().collect(); + if nfc != value { + return Err(CompositionError::one(CompositionDiagnostic::with_subjects( + DiagnosticCode::CollisionMetadata, + format!("{field} must be NFC-normalized"), + vec![value.to_owned()], + limit, + ))); + } + Ok(()) +} diff --git a/crates/graphforge-ontology/src/composition/canonical.rs b/crates/graphforge-ontology/src/composition/canonical.rs index 6528d7b06..d0d1ce6e5 100644 --- a/crates/graphforge-ontology/src/composition/canonical.rs +++ b/crates/graphforge-ontology/src/composition/canonical.rs @@ -3,7 +3,7 @@ use serde_json::Value; use sha2::{Digest, Sha256}; -use super::identity::MODULE_DIGEST_DOMAIN; +use super::identity::{BRIDGE_DIGEST_DOMAIN, MODULE_DIGEST_DOMAIN}; /// Serialize a JSON [`Value`] with object keys sorted (RFC 8785 subset used by GraphForge). pub fn canonical_json(value: &Value) -> Result, String> { @@ -64,6 +64,12 @@ pub fn module_document_digest(doc: &impl serde::Serialize) -> Result Result { + let value = serde_json::to_value(doc).map_err(|e| e.to_string())?; + domain_digest(BRIDGE_DIGEST_DOMAIN, &value) +} + pub(crate) fn hex_lower(bytes: &[u8]) -> String { use std::fmt::Write; bytes diff --git a/crates/graphforge-ontology/src/composition/diagnostic.rs b/crates/graphforge-ontology/src/composition/diagnostic.rs index 440f6b384..b3ea9053a 100644 --- a/crates/graphforge-ontology/src/composition/diagnostic.rs +++ b/crates/graphforge-ontology/src/composition/diagnostic.rs @@ -13,6 +13,8 @@ pub enum CompositionPhase { Dependency, /// Qualified symbol collisions across modules. Collision, + /// Bridge-set endpoint / conflict / provenance problems. + Bridge, /// Resource / limit exhaustion. Resource, /// Lifecycle cancellation. @@ -29,6 +31,7 @@ impl CompositionPhase { Self::Inventory => "inventory", Self::Dependency => "dependency", Self::Collision => "collision", + Self::Bridge => "bridge", Self::Resource => "resource", Self::Lifecycle => "lifecycle", Self::Resolution => "resolution", @@ -73,10 +76,16 @@ pub enum DiagnosticCode { CollisionMetadata, /// Mutation preview/source generation does not match current authority. InventoryGenerationConflict, - /// Module cannot be removed because dependants still reference it. + /// Module or bridge cannot be removed because dependants still reference it. DependencyInUse, /// Lifecycle transition is not allowed from the current status. LifecycleInvalidTransition, + /// Bridge assertion endpoint is absent from the known module inventory. + BridgeEndpointMissing, + /// Bridge assertions contradict each other (minimal attributable set). + BridgeContradiction, + /// Authoritative mapping lacks required provenance. + BridgeProvenanceMissing, } impl DiagnosticCode { @@ -86,22 +95,25 @@ impl DiagnosticCode { match self { Self::InventoryDuplicate => "inventory.duplicate", Self::InventoryNotFound | Self::InventoryMalformed => "inventory.not_found", + Self::InventoryGenerationConflict => "inventory.generation_conflict", Self::DependencyMissing => "dependency.missing", Self::DependencyCycle => "dependency.cycle", + Self::DependencyInUse => "dependency.in_use", Self::CollisionQualifiedDuplicate => "collision.qualified_duplicate", + Self::BridgeEndpointMissing => "bridge.endpoint_missing", + Self::BridgeContradiction => "bridge.contradiction", + Self::BridgeProvenanceMissing => "bridge.provenance_missing", Self::ResourceModules => "resource.modules", Self::ResourceBridges => "resource.bridges", Self::ResourceSymbols => "resource.symbols", Self::ResourceDiagnostics => "resource.diagnostics", Self::LifecycleCancelled => "lifecycle.cancelled", + Self::LifecycleInvalidTransition => "lifecycle.invalid_transition", Self::ResolutionAmbiguous => "resolution.ambiguous", Self::ResolutionNotFound => "resolution.not_found", Self::ResolutionKindMismatch => "resolution.kind_mismatch", Self::InterchangeIntegrity => "interchange.integrity", Self::CollisionMetadata => "collision.metadata", - Self::InventoryGenerationConflict => "inventory.generation_conflict", - Self::DependencyInUse => "dependency.in_use", - Self::LifecycleInvalidTransition => "lifecycle.invalid_transition", } } @@ -112,13 +124,16 @@ impl DiagnosticCode { Self::InventoryDuplicate | Self::InventoryNotFound | Self::InventoryMalformed + | Self::InventoryGenerationConflict | Self::InterchangeIntegrity - | Self::CollisionMetadata - | Self::InventoryGenerationConflict => CompositionPhase::Inventory, + | Self::CollisionMetadata => CompositionPhase::Inventory, Self::DependencyMissing | Self::DependencyCycle | Self::DependencyInUse => { CompositionPhase::Dependency } Self::CollisionQualifiedDuplicate => CompositionPhase::Collision, + Self::BridgeEndpointMissing + | Self::BridgeContradiction + | Self::BridgeProvenanceMissing => CompositionPhase::Bridge, Self::ResourceModules | Self::ResourceBridges | Self::ResourceSymbols diff --git a/crates/graphforge-ontology/src/composition/identity.rs b/crates/graphforge-ontology/src/composition/identity.rs index e70b00ef5..a90f51631 100644 --- a/crates/graphforge-ontology/src/composition/identity.rs +++ b/crates/graphforge-ontology/src/composition/identity.rs @@ -8,6 +8,9 @@ pub const MODULE_DIGEST_DOMAIN: &[u8] = b"graphforge-ontology-module/1\0"; /// Domain separation prefix for composition fingerprints (ADR 0023). pub const COMPOSITION_DOMAIN: &[u8] = b"graphforge-ontology-composition/1\0"; +/// Domain separation prefix for bridge-set document digests (#838). +pub const BRIDGE_DIGEST_DOMAIN: &[u8] = b"graphforge-ontology-bridge/1\0"; + /// Hex-encoded SHA-256 digest length. pub const DIGEST_HEX_LEN: usize = 64; @@ -73,6 +76,15 @@ impl BridgeSetId { self.canonical_digest.as_bytes().to_vec(), ) } + + /// Compact display form used in subjects and candidate lists. + #[must_use] + pub fn display_ref(&self) -> String { + format!( + "{}@{}#{}", + self.bridge_id, self.authored_version, self.canonical_digest + ) + } } /// Symbol kind within a module. diff --git a/crates/graphforge-ontology/src/composition/mod.rs b/crates/graphforge-ontology/src/composition/mod.rs index 8f166582d..c720b2ca6 100644 --- a/crates/graphforge-ontology/src/composition/mod.rs +++ b/crates/graphforge-ontology/src/composition/mod.rs @@ -3,7 +3,7 @@ //! Compiles independently identified ontology modules into a closure-ordered //! inventory with qualified-symbol lookups and a contract-approved composition //! fingerprint. Bridge *identities* participate in the fingerprint; full bridge -//! set lifecycle belongs to #838. +//! set lifecycle is owned by the sibling `bridge` module (#838). mod canonical; mod compile; @@ -11,7 +11,7 @@ mod diagnostic; mod identity; mod resolve; -pub use canonical::{canonical_json, module_document_digest}; +pub use canonical::{bridge_document_digest, canonical_json, module_document_digest}; pub use compile::{ AuthoredModule, CompiledComposition, CompiledModule, CompositionLimits, InventoryCompileRequest, compile_inventory, compile_legacy_single_ontology, @@ -20,7 +20,7 @@ pub use diagnostic::{ CompositionDiagnostic, CompositionError, CompositionPhase, DiagnosticCode, DiagnosticLimit, }; pub use identity::{ - ActivationMode, ActivationRecord, ActivationScope, BridgeSetId, DIGEST_HEX_LEN, - MODULE_DIGEST_DOMAIN, OntologyModuleId, QualifiedSymbol, SymbolKind, + ActivationMode, ActivationRecord, ActivationScope, BRIDGE_DIGEST_DOMAIN, BridgeSetId, + DIGEST_HEX_LEN, MODULE_DIGEST_DOMAIN, OntologyModuleId, QualifiedSymbol, SymbolKind, }; pub use resolve::{ResolutionOutcome, ResolveRequest}; diff --git a/crates/graphforge-ontology/src/lib.rs b/crates/graphforge-ontology/src/lib.rs index 325b58d65..8fb174e4b 100644 --- a/crates/graphforge-ontology/src/lib.rs +++ b/crates/graphforge-ontology/src/lib.rs @@ -5,8 +5,10 @@ //! - phase-10 ontology load/compile/persist/migrate ✓ //! - M9 #836 — deterministic inventory composition (`composition` module) ✓ //! - M9 #837 — ergonomic inventory CRUD / import-export (`inventory` module) ✓ +//! - M9 #838 — provenance-bearing bridge sets (`bridge` module) ✓ #![forbid(unsafe_code)] +pub mod bridge; pub mod compiler; pub mod composition; pub mod error; @@ -21,12 +23,19 @@ pub mod schemas; pub mod spatial; pub mod validator; +pub use bridge::{ + BridgeAssertion, BridgeDeletePreview, BridgeDocument, BridgeExportFormat, + BridgeImportFormatHint, BridgeInspect, BridgeInventory, BridgeLifecycleStatus, BridgeListEntry, + BridgeMutationReceipt, BridgePredicate, BridgeProvenance, BridgeSelector, BridgeSnapshot, + BridgeUpdatePreview, MappingConfidence, MappingMethod, ModuleSymbolTable, SharedSurfaceHint, + validate_bridge_document, +}; pub use compiler::{OntologyCompiler, OntologyRuntime, PropertyOwnerKind}; pub use composition::{ ActivationMode, ActivationRecord, ActivationScope, AuthoredModule, BridgeSetId, CompiledComposition, CompiledModule, CompositionDiagnostic, CompositionError, CompositionLimits, DiagnosticCode, InventoryCompileRequest, OntologyModuleId, QualifiedSymbol, - ResolutionOutcome, ResolveRequest, SymbolKind, compile_inventory, + ResolutionOutcome, ResolveRequest, SymbolKind, bridge_document_digest, compile_inventory, compile_legacy_single_ontology, module_document_digest, }; pub use error::{OntologyError, OntologyValidationError, ValidationErrorKind}; diff --git a/crates/graphforge-ontology/tests/bridge_sets.rs b/crates/graphforge-ontology/tests/bridge_sets.rs new file mode 100644 index 000000000..9c4a96d0b --- /dev/null +++ b/crates/graphforge-ontology/tests/bridge_sets.rs @@ -0,0 +1,562 @@ +//! Direct tests for provenance-bearing bridge-set lifecycle (#838). + +use std::collections::HashSet; + +use graphforge_ontology::{ + ActivationMode, BridgeAssertion, BridgeDocument, BridgeExportFormat, BridgeImportFormatHint, + BridgeInventory, BridgePredicate, BridgeProvenance, BridgeSelector, BridgeSetId, + DiagnosticCode, MappingMethod, ModuleSymbolTable, OntologyModuleId, QualifiedSymbol, + SharedSurfaceHint, SymbolKind, bridge_document_digest, +}; + +fn module_id(ontology_id: &str, version: &str, digest: &str) -> OntologyModuleId { + OntologyModuleId { + ontology_id: ontology_id.to_owned(), + authored_version: version.to_owned(), + canonical_digest: digest.to_owned(), + } +} + +fn table( + id: OntologyModuleId, + entities: &[&str], + relations: &[&str], + properties: &[&str], +) -> ModuleSymbolTable { + ModuleSymbolTable { + id, + entities: entities.iter().map(|s| (*s).to_owned()).collect(), + relations: relations.iter().map(|s| (*s).to_owned()).collect(), + properties: properties.iter().map(|s| (*s).to_owned()).collect(), + } +} + +fn q(module: &OntologyModuleId, kind: SymbolKind, local_id: &str) -> QualifiedSymbol { + QualifiedSymbol { + module: module.clone(), + kind, + local_id: local_id.to_owned(), + } +} + +fn authored_prov(justification: &str) -> BridgeProvenance { + BridgeProvenance { + method: MappingMethod::Authored, + confidence: None, + justification: justification.to_owned(), + evidence_refs: vec!["evidence:review-1".into()], + } +} + +fn research_gene_modules() -> ( + OntologyModuleId, + OntologyModuleId, + ModuleSymbolTable, + ModuleSymbolTable, +) { + let research = module_id( + "https://graphforge.dev/ontology/research", + "1.0.0", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let genealogy = module_id( + "https://graphforge.dev/ontology/genealogy", + "3.0.0", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ); + let research_table = table( + research.clone(), + &["Study", "Person"], + &["FUNDED_BY"], + &["title"], + ); + let genealogy_table = table( + genealogy.clone(), + &["Person", "Claim"], + &["PARENT_OF"], + &["name"], + ); + (research, genealogy, research_table, genealogy_table) +} + +fn base_inventory() -> (BridgeInventory, OntologyModuleId, OntologyModuleId) { + let (research, genealogy, research_table, genealogy_table) = research_gene_modules(); + let mut inv = BridgeInventory::new(ActivationMode::Exploratory, Default::default()); + inv.register_module(research_table); + inv.register_module(genealogy_table); + (inv, research, genealogy) +} + +fn assertion( + source: QualifiedSymbol, + target: QualifiedSymbol, + predicate: BridgePredicate, +) -> BridgeAssertion { + BridgeAssertion { + source, + target, + predicate, + directional: true, + provenance: authored_prov("curated mapping"), + valid_from: None, + valid_to: None, + } +} + +fn doc( + bridge_id: &str, + version: &str, + source: &OntologyModuleId, + target: &OntologyModuleId, + assertions: Vec, +) -> BridgeDocument { + BridgeDocument { + bridge_id: bridge_id.to_owned(), + authored_version: version.to_owned(), + source_modules: vec![source.clone()], + target_modules: vec![target.clone()], + dependencies: vec![], + shared_surfaces: vec![SharedSurfaceHint::Evidence], + assertions, + enforcement: None, + } +} + +#[test] +fn equivalent_entity_mapping_crud_and_idempotent_adopt() { + let (mut inv, research, genealogy) = base_inventory(); + let document = doc( + "https://graphforge.dev/bridge/research-genealogy", + "1.0.0", + &research, + &genealogy, + vec![assertion( + q(&research, SymbolKind::Entity, "Person"), + q(&genealogy, SymbolKind::Entity, "Person"), + BridgePredicate::Equivalent, + )], + ); + let id = inv.create_register(document, "op-create").expect("create"); + let gen0 = inv.generation(); + let receipt = inv + .adopt(&BridgeSelector::Exact(id.clone()), gen0, "op-adopt") + .expect("adopt"); + assert_eq!(receipt.prior_generation, 0); + assert_eq!(receipt.new_generation, 1); + assert!(!receipt.idempotent_replay); + + let replay = inv + .adopt(&BridgeSelector::Exact(id.clone()), gen0, "op-adopt") + .expect("idempotent"); + assert!(replay.idempotent_replay); + assert_eq!(replay.new_generation, receipt.new_generation); + + let listed = inv.list(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, id); + let inspected = inv.inspect(&BridgeSelector::Exact(id)).unwrap(); + assert_eq!( + inspected.doc.assertions[0].predicate, + BridgePredicate::Equivalent + ); +} + +#[test] +fn directional_related_broader_narrower_and_disjoint() { + let (mut inv, research, genealogy) = base_inventory(); + let document = doc( + "https://graphforge.dev/bridge/directional", + "1.0.0", + &research, + &genealogy, + vec![ + assertion( + q(&research, SymbolKind::Entity, "Study"), + q(&genealogy, SymbolKind::Entity, "Person"), + BridgePredicate::Related, + ), + assertion( + q(&research, SymbolKind::Entity, "Study"), + q(&genealogy, SymbolKind::Entity, "Claim"), + BridgePredicate::Broader, + ), + assertion( + q(&genealogy, SymbolKind::Entity, "Claim"), + q(&research, SymbolKind::Entity, "Study"), + BridgePredicate::Narrower, + ), + assertion( + q(&research, SymbolKind::Entity, "Study"), + q(&genealogy, SymbolKind::Entity, "Person"), + BridgePredicate::Disjoint, + ), + ], + ); + // Related + Disjoint on same Study↔Person pair is allowed (different predicates + // that do not form the equivalent∩disjoint contradiction). + // Broader(Study,Claim) + Narrower(Claim,Study) is coherent (inverse pair). + let id = inv.create_register(document, "op").unwrap(); + inv.adopt(&BridgeSelector::Exact(id), inv.generation(), "adopt") + .unwrap(); + assert_eq!(inv.list().len(), 1); +} + +#[test] +fn property_and_relation_maps_to() { + let (mut inv, research, genealogy) = base_inventory(); + let document = doc( + "https://graphforge.dev/bridge/maps", + "1.0.0", + &research, + &genealogy, + vec![ + assertion( + q(&research, SymbolKind::Property, "title"), + q(&genealogy, SymbolKind::Property, "name"), + BridgePredicate::MapsTo, + ), + assertion( + q(&research, SymbolKind::Relation, "FUNDED_BY"), + q(&genealogy, SymbolKind::Relation, "PARENT_OF"), + BridgePredicate::MapsTo, + ), + ], + ); + let id = inv.create_register(document, "op").unwrap(); + inv.adopt(&BridgeSelector::Exact(id), inv.generation(), "adopt") + .unwrap(); +} + +#[test] +fn conflicting_equivalent_and_disjoint_rejected() { + let (inv, research, genealogy) = base_inventory(); + let document = doc( + "https://graphforge.dev/bridge/conflict", + "1.0.0", + &research, + &genealogy, + vec![ + assertion( + q(&research, SymbolKind::Entity, "Person"), + q(&genealogy, SymbolKind::Entity, "Person"), + BridgePredicate::Equivalent, + ), + assertion( + q(&research, SymbolKind::Entity, "Person"), + q(&genealogy, SymbolKind::Entity, "Person"), + BridgePredicate::Disjoint, + ), + ], + ); + let err = inv.validate_document(&document).unwrap_err(); + assert_eq!(err.code(), Some(DiagnosticCode::BridgeContradiction)); +} + +#[test] +fn missing_module_endpoint_rejected() { + let (inv, research, genealogy) = base_inventory(); + let missing = module_id( + "https://graphforge.dev/ontology/missing", + "1.0.0", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + ); + let document = doc( + "https://graphforge.dev/bridge/missing", + "1.0.0", + &research, + &genealogy, + vec![assertion( + q(&research, SymbolKind::Entity, "Person"), + q(&missing, SymbolKind::Entity, "Ghost"), + BridgePredicate::Related, + )], + ); + let err = inv.validate_document(&document).unwrap_err(); + assert_eq!(err.code(), Some(DiagnosticCode::BridgeEndpointMissing)); +} + +#[test] +fn invalid_kind_pair_rejected() { + let (inv, research, genealogy) = base_inventory(); + let document = doc( + "https://graphforge.dev/bridge/kinds", + "1.0.0", + &research, + &genealogy, + vec![assertion( + q(&research, SymbolKind::Entity, "Person"), + q(&genealogy, SymbolKind::Property, "name"), + BridgePredicate::Equivalent, + )], + ); + let err = inv.validate_document(&document).unwrap_err(); + assert_eq!(err.code(), Some(DiagnosticCode::ResolutionKindMismatch)); +} + +#[test] +fn dependency_aware_deletion_and_safe_delete() { + let (mut inv, research, genealogy) = base_inventory(); + let base = doc( + "https://graphforge.dev/bridge/base", + "1.0.0", + &research, + &genealogy, + vec![assertion( + q(&research, SymbolKind::Entity, "Person"), + q(&genealogy, SymbolKind::Entity, "Person"), + BridgePredicate::Equivalent, + )], + ); + let base_id = inv.create_register(base, "c1").unwrap(); + inv.adopt(&BridgeSelector::Exact(base_id.clone()), 0, "a1") + .unwrap(); + + let mut dependent = doc( + "https://graphforge.dev/bridge/dependent", + "1.0.0", + &research, + &genealogy, + vec![assertion( + q(&research, SymbolKind::Entity, "Study"), + q(&genealogy, SymbolKind::Entity, "Claim"), + BridgePredicate::Related, + )], + ); + dependent.dependencies = vec![base_id.clone()]; + let dep_id = inv.create_register(dependent, "c2").unwrap(); + inv.adopt( + &BridgeSelector::Exact(dep_id.clone()), + inv.generation(), + "a2", + ) + .unwrap(); + + let preview = inv + .preview_delete(&BridgeSelector::Exact(base_id.clone())) + .unwrap(); + assert!(!preview.safe); + assert_eq!(preview.dependent_bridges, vec![dep_id.clone()]); + + let err = inv + .delete( + &BridgeSelector::Exact(base_id.clone()), + inv.generation(), + "del-blocked", + ) + .unwrap_err(); + assert_eq!(err.code(), Some(DiagnosticCode::DependencyInUse)); + + inv.delete(&BridgeSelector::Exact(dep_id), inv.generation(), "del-dep") + .unwrap(); + inv.delete( + &BridgeSelector::Exact(base_id), + inv.generation(), + "del-base", + ) + .unwrap(); + assert!(inv.list().is_empty()); +} + +#[test] +fn deterministic_export_and_reopen() { + let (mut inv, research, genealogy) = base_inventory(); + let document = doc( + "https://graphforge.dev/bridge/export", + "1.0.0", + &research, + &genealogy, + vec![assertion( + q(&research, SymbolKind::Entity, "Person"), + q(&genealogy, SymbolKind::Entity, "Person"), + BridgePredicate::Equivalent, + )], + ); + let digest_a = bridge_document_digest(&document).unwrap(); + let id = inv.create_register(document.clone(), "c").unwrap(); + assert_eq!(id.canonical_digest, digest_a); + inv.adopt(&BridgeSelector::Exact(id.clone()), 0, "a") + .unwrap(); + + let json = inv + .export_bridge(&BridgeSelector::Exact(id.clone()), BridgeExportFormat::Json) + .unwrap(); + let yaml = inv + .export_bridge(&BridgeSelector::Exact(id.clone()), BridgeExportFormat::Yaml) + .unwrap(); + let round_json: BridgeDocument = serde_json::from_str(&json).unwrap(); + assert_eq!(bridge_document_digest(&round_json).unwrap(), digest_a); + + // YAML round-trip via lifecycle import (avoid direct serde_yaml crate name; + // Bazel crate_universe exposes it as serde_yaml_ng). + let mut staging = BridgeInventory::new(ActivationMode::Exploratory, Default::default()); + for table in [ + table( + research.clone(), + &["Study", "Person"], + &["FUNDED_BY"], + &["title"], + ), + table( + genealogy.clone(), + &["Person", "Claim"], + &["PARENT_OF"], + &["name"], + ), + ] { + staging.register_module(table); + } + let yaml_id = staging + .import_text(&yaml, BridgeImportFormatHint::Yaml, "yaml-round") + .unwrap(); + assert_eq!(yaml_id.canonical_digest, digest_a); + + let snap = inv.snapshot(); + let reopened = BridgeInventory::reopen(snap).unwrap(); + assert_eq!(reopened.generation(), inv.generation()); + assert_eq!(reopened.list().len(), 1); + assert_eq!(reopened.list()[0].id, id); +} + +#[test] +fn suggested_mappings_remain_non_authoritative() { + let (mut inv, research, genealogy) = base_inventory(); + let mut document = doc( + "https://graphforge.dev/bridge/suggested", + "1.0.0", + &research, + &genealogy, + vec![assertion( + q(&research, SymbolKind::Entity, "Person"), + q(&genealogy, SymbolKind::Entity, "Person"), + BridgePredicate::Equivalent, + )], + ); + document.assertions[0].provenance.method = MappingMethod::Suggested; + document.assertions[0].provenance.justification = "tooling hint".into(); + + let err = inv.create_register(document.clone(), "c").unwrap_err(); + assert_eq!(err.code(), Some(DiagnosticCode::LifecycleInvalidTransition)); + + let text = serde_json::to_string(&document).unwrap(); + let id = inv + .import_text(&text, BridgeImportFormatHint::Json, "imp") + .unwrap(); + let err = inv + .adopt(&BridgeSelector::Exact(id), inv.generation(), "adopt") + .unwrap_err(); + assert_eq!(err.code(), Some(DiagnosticCode::LifecycleInvalidTransition)); +} + +#[test] +fn suggested_mapping_cannot_enter_authority_via_update_or_reopen() { + let (mut inv, research, genealogy) = base_inventory(); + let authored = doc( + "https://graphforge.dev/bridge/authority-boundary", + "1.0.0", + &research, + &genealogy, + vec![assertion( + q(&research, SymbolKind::Entity, "Person"), + q(&genealogy, SymbolKind::Entity, "Person"), + BridgePredicate::Equivalent, + )], + ); + let id = inv.create_register(authored, "create").unwrap(); + inv.adopt(&BridgeSelector::Exact(id.clone()), 0, "adopt") + .unwrap(); + + let mut suggested = inv.inspect(&BridgeSelector::Exact(id.clone())).unwrap().doc; + suggested.authored_version = "2.0.0".into(); + suggested.assertions[0].provenance.method = MappingMethod::Suggested; + let err = inv + .update( + &BridgeSelector::Exact(id), + suggested, + inv.generation(), + "update", + ) + .unwrap_err(); + assert_eq!(err.code(), Some(DiagnosticCode::LifecycleInvalidTransition)); + + let mut snapshot = inv.snapshot(); + snapshot.adopted[0].doc.assertions[0].provenance.method = MappingMethod::Inferred; + let err = BridgeInventory::reopen(snapshot).unwrap_err(); + assert_eq!(err.code(), Some(DiagnosticCode::LifecycleInvalidTransition)); +} + +#[test] +fn reopen_rejects_tampered_identity_projection() { + let (mut inv, research, genealogy) = base_inventory(); + let document = doc( + "https://graphforge.dev/bridge/reopen-integrity", + "1.0.0", + &research, + &genealogy, + vec![assertion( + q(&research, SymbolKind::Entity, "Person"), + q(&genealogy, SymbolKind::Entity, "Person"), + BridgePredicate::Equivalent, + )], + ); + let id = inv.create_register(document, "create").unwrap(); + inv.adopt(&BridgeSelector::Exact(id), 0, "adopt").unwrap(); + + let mut snapshot = inv.snapshot(); + snapshot.adopted[0].id.canonical_digest = "0".repeat(64); + let err = BridgeInventory::reopen(snapshot).unwrap_err(); + assert_eq!(err.code(), Some(DiagnosticCode::InterchangeIntegrity)); +} + +#[test] +fn equal_human_names_never_create_a_bridge() { + let (inv, research, genealogy) = base_inventory(); + // Both modules declare Person; inventory of bridges stays empty. + assert!(inv.list().is_empty()); + assert!(inv.adopted_ids().is_empty()); + // No auto-bridge API exists; same local name is not an equivalence. + let left = q(&research, SymbolKind::Entity, "Person"); + let right = q(&genealogy, SymbolKind::Entity, "Person"); + assert_eq!(left.local_id, right.local_id); + assert_ne!(left.module, right.module); + let _ = HashSet::::new(); +} + +#[test] +fn stale_generation_fails_closed() { + let (mut inv, research, genealogy) = base_inventory(); + let document = doc( + "https://graphforge.dev/bridge/stale", + "1.0.0", + &research, + &genealogy, + vec![assertion( + q(&research, SymbolKind::Entity, "Person"), + q(&genealogy, SymbolKind::Entity, "Person"), + BridgePredicate::Equivalent, + )], + ); + let id = inv.create_register(document, "c").unwrap(); + let err = inv.adopt(&BridgeSelector::Exact(id), 99, "a").unwrap_err(); + assert_eq!( + err.code(), + Some(DiagnosticCode::InventoryGenerationConflict) + ); +} + +#[test] +fn missing_symbol_in_module_rejected() { + let (inv, research, genealogy) = base_inventory(); + let document = doc( + "https://graphforge.dev/bridge/nosymbol", + "1.0.0", + &research, + &genealogy, + vec![assertion( + q(&research, SymbolKind::Entity, "DoesNotExist"), + q(&genealogy, SymbolKind::Entity, "Person"), + BridgePredicate::Related, + )], + ); + let err = inv.validate_document(&document).unwrap_err(); + assert_eq!(err.code(), Some(DiagnosticCode::BridgeEndpointMissing)); +} diff --git a/docs/development/bazel-migration-ledger.md b/docs/development/bazel-migration-ledger.md index 05b539279..89ccda37d 100644 --- a/docs/development/bazel-migration-ledger.md +++ b/docs/development/bazel-migration-ledger.md @@ -145,6 +145,7 @@ Authoritative machine-readable map: `tools/bazel/parity/migration_target_map.jso | `graphforge-ontology` | `graphforge_ontology` | `lib` | `crates/graphforge-ontology/src/lib.rs` | `//crates/graphforge-ontology:graphforge_ontology` | `mapped` | #10; unit tests `//crates/graphforge-ontology:graphforge_ontology_test` | | `graphforge-ontology` | `integration` | `integration-test` | `crates/graphforge-ontology/tests/integration.rs` | `//crates/graphforge-ontology:integration` | `mapped` | #8 | | `graphforge-ontology` | `composition_inventory` | `integration-test` | `crates/graphforge-ontology/tests/composition_inventory.rs` | `//crates/graphforge-ontology:composition_inventory` | `mapped` | #836 | +| `graphforge-ontology` | `bridge_sets` | `integration-test` | `crates/graphforge-ontology/tests/bridge_sets.rs` | `//crates/graphforge-ontology:bridge_sets` | `mapped` | #838 | | `graphforge-ontology` | `inventory_crud` | `integration-test` | `crates/graphforge-ontology/tests/inventory_crud.rs` | `//crates/graphforge-ontology:inventory_crud` | `mapped` | #837 | | `graphforge-plan` | `graphforge_plan` | `lib` | `crates/graphforge-plan/src/lib.rs` | `//crates/graphforge-plan:graphforge_plan` | `mapped` | #10; unit tests `//crates/graphforge-plan:graphforge_plan_test` | | `graphforge-provenance` | `graphforge_provenance` | `lib` | `crates/graphforge-provenance/src/lib.rs` | `//crates/graphforge-provenance:graphforge_provenance` | `mapped` | #10; unit tests `//crates/graphforge-provenance:graphforge_provenance_test` | diff --git a/tools/bazel/parity/migration_target_map.json b/tools/bazel/parity/migration_target_map.json index 4c546d497..4a235a256 100644 --- a/tools/bazel/parity/migration_target_map.json +++ b/tools/bazel/parity/migration_target_map.json @@ -1,7 +1,7 @@ { "schema": "graphforge.bazel-migration-target-map.v1", "issue": 6, - "cargo_target_count": 109, + "cargo_target_count": 110, "targets": [ { "package": "graphforge-api", @@ -933,6 +933,16 @@ "exception_id": null, "notes": "#836" }, + { + "package": "graphforge-ontology", + "target": "bridge_sets", + "class": "integration-test", + "source": "crates/graphforge-ontology/tests/bridge_sets.rs", + "status": "mapped", + "bazel_label": "//crates/graphforge-ontology:bridge_sets", + "exception_id": null, + "notes": "#838" + }, { "package": "graphforge-ontology", "target": "inventory_crud",