diff --git a/crates/oa-core/src/ports.rs b/crates/oa-core/src/ports.rs index 463c9af4..7d066784 100644 --- a/crates/oa-core/src/ports.rs +++ b/crates/oa-core/src/ports.rs @@ -251,6 +251,20 @@ pub(crate) fn functional_exchange_commands( ) -> Result, ModelError> { vacant(p, request.id)?; let mut working = p.clone(); + let mut relation = Relation { + id: request.id, + alias: request.alias.clone(), + name: request.name.clone(), + description: request.description.clone(), + body: RelationBody::new( + RelationKind::FunctionalExchange, + request.source, + request.target, + ), + }; + // Reserve the authored alias as well as identity before naming generated + // endpoints. This draft exists only in the planner's private candidate. + working.relations.insert(relation.id, relation.clone()); let mut commands = Vec::new(); let label = if request.name.trim().is_empty() { "Exchange" @@ -289,19 +303,14 @@ pub(crate) fn functional_exchange_commands( &request.items, &mut commands, )?; - commands.push(Command::CreateRelation(Relation { - id: request.id, - alias: request.alias.clone(), - name: request.name.clone(), - description: request.description.clone(), - body: RelationBody::FunctionalExchange { - source: request.source, - target: request.target, - source_port, - target_port, - items: request.items.clone(), - }, - })); + relation.body = RelationBody::FunctionalExchange { + source: request.source, + target: request.target, + source_port, + target_port, + items: request.items.clone(), + }; + commands.push(Command::CreateRelation(relation)); Ok(commands) } fn add_allocation( @@ -392,6 +401,14 @@ pub fn plan_component_exchange( target_port, } => { vacant(&working, *id)?; + let mut relation = Relation { + id: *id, + alias: alias.clone(), + name: name.clone(), + description: description.clone(), + body: RelationBody::new(RelationKind::BoundaryExchange, Id::nil(), Id::nil()), + }; + working.relations.insert(*id, relation.clone()); let contract = |outgoing| PortContract::Component { kind: *port_kind, orientation: if *port_kind == ComponentPortKind::Standard { @@ -424,17 +441,11 @@ pub fn plan_component_exchange( contract(false), )?; let b = select_port(&mut working, *target_port, input_port, items, &mut commands)?; - let relation = Relation { - id: *id, - alias: alias.clone(), - name: name.clone(), - description: description.clone(), - body: RelationBody::BoundaryExchange { - source: a, - target: b, - oriented: *oriented, - items: items.clone(), - }, + relation.body = RelationBody::BoundaryExchange { + source: a, + target: b, + oriented: *oriented, + items: items.clone(), }; working.relations.insert(*id, relation.clone()); commands.push(Command::CreateRelation(relation)); @@ -646,6 +657,66 @@ pub fn plan_function_port_items( finish(p, vec![Command::EditElement(port, e)]) } +/// Whether removing these allocation facts breaks a typed exchange mapping. +/// Legacy or malformed endpoints are left to their own validation rules; this +/// helper only determines the conditional dependency on PortAllocation facts. +pub(crate) fn missing_exchange_port_allocations( + p: &Project, + relation: &Relation, + removed: &BTreeSet, +) -> bool { + let Some(Relation { + body: + RelationBody::BoundaryExchange { + source: a, + target: b, + oriented, + .. + }, + .. + }) = p.relations.get(&relation.source()) + else { + return false; + }; + let Some(Relation { + body: + RelationBody::FunctionalExchange { + source_port, + target_port, + .. + }, + .. + }) = p.relations.get(&relation.target()) + else { + return false; + }; + if ![*a, *b].iter().all(|id| { + p.elements + .get(id) + .is_some_and(|e| matches!(port_contract(e), Some(PortContract::Component { .. }))) + }) || !p + .elements + .get(source_port) + .is_some_and(|e| matches!(port_contract(e), Some(PortContract::FunctionOutput { .. }))) + || !p + .elements + .get(target_port) + .is_some_and(|e| matches!(port_contract(e), Some(PortContract::FunctionInput { .. }))) + { + return false; + } + let allocated = |component, function| { + p.relations.values().any(|allocation| { + allocation.kind() == RelationKind::PortAllocation + && !removed.contains(&allocation.id) + && allocation.source() == component + && allocation.target() == function + }) + }; + !((allocated(*a, *source_port) && allocated(*b, *target_port)) + || (!*oriented && allocated(*b, *source_port) && allocated(*a, *target_port))) +} + pub(crate) fn validate_port(p: &Project, e: &Element, out: &mut Vec) { let Some(SharedBody::Port { contract, legacy }) = e.shared() else { return; diff --git a/crates/oa-core/src/references.rs b/crates/oa-core/src/references.rs index 16b7f230..20f740f2 100644 --- a/crates/oa-core/src/references.rs +++ b/crates/oa-core/src/references.rs @@ -203,6 +203,9 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) { } /// IDs removed by a cascade. Cross-layer traced counterparts are never included. pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet { + deletion_impact_inner(p, id, false) +} +fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool) -> BTreeSet { let mut removed = BTreeSet::from([id]); loop { let before = removed.len(); @@ -254,12 +257,15 @@ pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet { else { continue; }; - if p.relations.values().any(|allocation| { - allocation.kind() == RelationKind::PortAllocation - && removed.contains(&allocation.id) - && [boundary.source(), boundary.target()].contains(&allocation.source()) - && [*source_port, *target_port].contains(&allocation.target()) - }) { + if !defer_exchange_allocations + && p.relations.values().any(|allocation| { + allocation.kind() == RelationKind::PortAllocation + && removed.contains(&allocation.id) + && [boundary.source(), boundary.target()].contains(&allocation.source()) + && [*source_port, *target_port].contains(&allocation.target()) + }) + && crate::ports::missing_exchange_port_allocations(p, r, &removed) + { removed.insert(r.id); } } @@ -288,6 +294,40 @@ pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet { } pub fn delete_cascade(p: &mut Project, id: Id) { let removed = deletion_impact(p, id); + remove_objects(p, removed); +} +/// Preserve conditional exchange-allocation dependents until the transaction +/// has had the opportunity to supply explicit replacement allocation facts. +pub(crate) fn delete_cascade_deferred(p: &mut Project, id: Id, pending: &mut BTreeSet) { + let complete = deletion_impact(p, id); + let removed = deletion_impact_inner(p, id, true); + // An explicitly removed relation cannot pass its conditional cascade on + // to a new relation that later reuses the same identity in this batch. + pending.retain(|id| !removed.contains(id)); + pending.extend( + complete + .difference(&removed) + .filter(|id| { + p.relations + .get(id) + .is_some_and(|r| r.kind() == RelationKind::ExchangeAllocation) + }) + .copied(), + ); + remove_objects(p, removed); +} +pub(crate) fn finish_deletions(p: &mut Project, pending: &BTreeSet) { + let none = BTreeSet::new(); + for id in pending { + if p.relations.get(id).is_some_and(|r| { + r.kind() == RelationKind::ExchangeAllocation + && crate::ports::missing_exchange_port_allocations(p, r, &none) + }) { + delete_cascade(p, *id); + } + } +} +fn remove_objects(p: &mut Project, removed: BTreeSet) { let removed_names: std::collections::BTreeMap<_, _> = removed .iter() .map(|id| (*id, p.named(*id).to_string())) diff --git a/crates/oa-core/src/session.rs b/crates/oa-core/src/session.rs index 8dadaee9..4ff59dbb 100644 --- a/crates/oa-core/src/session.rs +++ b/crates/oa-core/src/session.rs @@ -1,5 +1,6 @@ use crate::*; use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; use thiserror::Error; #[derive(Debug, Clone, Error, Serialize, Deserialize)] @@ -135,7 +136,9 @@ impl Session { } pub fn execute(&mut self, command: Command) -> Result, ModelError> { let mut candidate = self.project.clone(); - let selected = apply_command(&mut candidate, &command, 0)?; + let mut pending_deletions = BTreeSet::new(); + let selected = apply_command(&mut candidate, &command, 0, &mut pending_deletions)?; + crate::references::finish_deletions(&mut candidate, &pending_deletions); validate_candidate(&candidate)?; if candidate != self.project { self.past @@ -161,9 +164,11 @@ pub fn validate_candidate(p: &Project) -> Result<(), ModelError> { } pub fn execute_batch(project: &Project, commands: &[Command]) -> Result { let mut candidate = project.clone(); + let mut pending_deletions = BTreeSet::new(); for command in commands { - apply_command(&mut candidate, command, 0)?; + apply_command(&mut candidate, command, 0, &mut pending_deletions)?; } + crate::references::finish_deletions(&mut candidate, &pending_deletions); validate_candidate(&candidate)?; Ok(candidate) } @@ -224,7 +229,12 @@ fn attach(p: &mut Project, id: Id) { } } } -fn apply_command(p: &mut Project, c: &Command, depth: usize) -> Result, ModelError> { +fn apply_command( + p: &mut Project, + c: &Command, + depth: usize, + pending_deletions: &mut BTreeSet, +) -> Result, ModelError> { if depth > 64 { return Err(ModelError( "Command batch nesting exceeds 64 levels.".into(), @@ -233,12 +243,13 @@ fn apply_command(p: &mut Project, c: &Command, depth: usize) -> Result { *p = value.clone(); + pending_deletions.clear(); None } Command::Batch(commands) => { let mut selected = None; for command in commands { - let id = apply_command(p, command, depth + 1)?; + let id = apply_command(p, command, depth + 1, pending_deletions)?; if id.is_some() { selected = id; } @@ -318,6 +329,9 @@ fn apply_command(p: &mut Project, c: &Command, depth: usize) -> Result { @@ -438,6 +452,9 @@ fn apply_command(p: &mut Project, c: &Command, depth: usize) -> Result { reidentify(p, *old, *new)?; + if pending_deletions.remove(old) { + pending_deletions.insert(*new); + } Some(*new) } Command::Rename(id, value) => { @@ -469,7 +486,7 @@ fn apply_command(p: &mut Project, c: &Command, depth: usize) -> Result Result { @@ -519,7 +536,7 @@ fn apply_command(p: &mut Project, c: &Command, depth: usize) -> Result { diff --git a/crates/oa-core/tests/release_ports.rs b/crates/oa-core/tests/release_ports.rs index 30b3a6e9..3b952d82 100644 --- a/crates/oa-core/tests/release_ports.rs +++ b/crates/oa-core/tests/release_ports.rs @@ -137,6 +137,245 @@ fn ambiguous_reuse_creates_a_fresh_port_and_collisions_never_overwrite() { assert_eq!(p.elements[&other], occupied); } +#[test] +fn generated_ports_respect_authored_exchange_aliases() { + let mut p = Project::empty(); + let a = add(&mut p, Kind::Function, Layer::Sa, "Send"); + let b = add(&mut p, Kind::Function, Layer::Sa, "Receive"); + for alias in ["message_output", "message_input"] { + let mut req = request(&p, a, b, vec![]); + req.alias = alias.into(); + req.source_port = PortChoice::Create; + req.target_port = PortChoice::Create; + let commands = plan_functional_exchange(&p, &req).unwrap(); + assert_eq!( + serde_json::to_value(&commands).unwrap(), + serde_json::to_value(plan_functional_exchange(&p, &req).unwrap()).unwrap() + ); + let result = execute_batch(&p, &commands).unwrap(); + assert_eq!(result.relations[&req.id].alias, alias); + let (output, input) = endpoints(&result, req.id); + assert_eq!(output, Id::new_v5(&req.id, b"function-source-port")); + assert_eq!(input, Id::new_v5(&req.id, b"function-target-port")); + assert_ne!(result.elements[&output].alias, alias); + assert_ne!(result.elements[&input].alias, alias); + assert!(!p.contains(req.id)); + req.alias = p.elements[&a].alias.clone(); + assert!(plan_functional_exchange(&p, &req).is_err()); + } + + let (mut p, a, b, _, fe) = fixture(); + allocate(&mut p, a, b); + for alias in ["boundary_source_port", "boundary_target_port"] { + let mut req = component_request(&p, fe, ComponentPortKind::Flow); + let id = boundary_id(&req); + if let ComponentExchangeChoice::Create { + alias: authored, + source_port, + target_port, + .. + } = &mut req.boundary + { + *authored = alias.into(); + *source_port = PortChoice::Create; + *target_port = PortChoice::Create; + } + let commands = plan_component_exchange(&p, &req).unwrap(); + assert_eq!( + serde_json::to_value(&commands).unwrap(), + serde_json::to_value(plan_component_exchange(&p, &req).unwrap()).unwrap() + ); + let result = execute_batch(&p, &commands).unwrap(); + assert_eq!(result.relations[&id].alias, alias); + let (source, target) = result.relations[&id].body.endpoints(); + assert_ne!(result.elements[&source].alias, alias); + assert_ne!(result.elements[&target].alias, alias); + assert!(!p.contains(id)); + if let ComponentExchangeChoice::Create { alias, .. } = &mut req.boundary { + *alias = p.elements[&a].alias.clone(); + } + assert!(plan_component_exchange(&p, &req).is_err()); + } +} + +fn allocated_fixture() -> (Project, Id, Id, Id) { + let (mut p, a, b, _, fe) = fixture(); + allocate(&mut p, a, b); + let req = component_request(&p, fe, ComponentPortKind::Flow); + p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap(); + let allocation = p + .relations + .values() + .find(|r| r.kind() == RelationKind::PortAllocation) + .unwrap() + .id; + let exchange_allocation = p + .relations + .values() + .find(|r| r.kind() == RelationKind::ExchangeAllocation) + .unwrap() + .id; + p.relations + .get_mut(&exchange_allocation) + .unwrap() + .description = "Retain this authored mapping rationale".into(); + let artifact = Id::new_v4(); + p.artifact_links.insert( + artifact, + ArtifactLink { + id: artifact, + alias: "mapping_evidence".into(), + name: "Allocation evidence".into(), + description: "Keep the original evidence".into(), + owner: exchange_allocation, + relation: ArtifactRelation::References, + uri: "docs/port-rationale.md".into(), + revision: Some("r1".into()), + fragment: None, + }, + ); + validate_candidate(&p).unwrap(); + (p, allocation, exchange_allocation, artifact) +} + +#[test] +fn replacement_port_allocation_retains_exchange_mapping_and_evidence_in_either_order() { + let (p, old, exchange_allocation, artifact) = allocated_fixture(); + let mut replacement = p.relations[&old].clone(); + replacement.id = Id::new_v4(); + replacement.alias = "replacement_mapping".into(); + for delete_first in [false, true] { + let mut commands = vec![ + Command::CreateRelation(replacement.clone()), + Command::Delete(old), + ]; + if delete_first { + commands.reverse(); + } + let mut expected = p.clone(); + expected.relations.remove(&old); + expected + .relations + .insert(replacement.id, replacement.clone()); + let mut session = Session::new(p.clone()); + session.execute(Command::Batch(commands)).unwrap(); + assert_eq!(session.project(), &expected); + assert_eq!( + session.project().relations[&exchange_allocation], + p.relations[&exchange_allocation] + ); + assert_eq!( + session.project().artifact_links[&artifact], + p.artifact_links[&artifact] + ); + assert_eq!(session.revision(), 1); + session.undo(); + assert_eq!(session.project(), &p); + assert!(!session.can_undo()); + } +} + +#[test] +fn deferred_deletion_preserves_preview_explicit_deletion_and_reidentification() { + let (p, old, exchange_allocation, artifact) = allocated_fixture(); + let impact = references::deletion_impact(&p, old); + assert!(impact.contains(&exchange_allocation)); + assert!(impact.contains(&artifact)); + let result = execute_batch(&p, &[Command::Delete(old)]).unwrap(); + assert!(impact.iter().all(|id| !result.contains(*id))); + let mut expected = p.clone(); + references::delete_cascade(&mut expected, old); + assert_eq!(result, expected); + + let mut replacement = p.relations[&old].clone(); + replacement.id = Id::new_v4(); + replacement.alias = "replacement_mapping".into(); + let explicit = execute_batch( + &p, + &[ + Command::Delete(old), + Command::Delete(exchange_allocation), + Command::CreateRelation(replacement.clone()), + ], + ) + .unwrap(); + assert!(!explicit.contains(exchange_allocation)); + assert!(!explicit.contains(artifact)); + assert_eq!(explicit.relations[&replacement.id], replacement); + + let renamed = Id::new_v4(); + let mut edited = p.relations[&exchange_allocation].clone(); + edited.id = renamed; + for command in [ + Command::Reidentify(exchange_allocation, renamed), + Command::EditRelation(exchange_allocation, edited), + ] { + let result = execute_batch(&p, &[Command::Delete(old), command.clone()]).unwrap(); + assert_eq!(result, expected); + let preserved = execute_batch( + &p, + &[ + Command::Delete(old), + command, + Command::CreateRelation(replacement.clone()), + ], + ) + .unwrap(); + assert_eq!( + preserved.relations[&renamed].description, + p.relations[&exchange_allocation].description + ); + assert_eq!(preserved.artifact_links[&artifact].owner, renamed); + } + + let recreated = execute_batch( + &p, + &[ + Command::Delete(old), + Command::Delete(exchange_allocation), + Command::CreateRelation(replacement), + Command::CreateRelation(p.relations[&exchange_allocation].clone()), + ], + ) + .unwrap(); + assert_eq!( + recreated.relations[&exchange_allocation], + p.relations[&exchange_allocation] + ); + assert!(!recreated.contains(artifact)); +} + +#[test] +fn invalid_replacement_rolls_back_without_repairing_explicit_replacement_projects() { + let (p, old, exchange_allocation, _) = allocated_fixture(); + let mut replacement = p.relations[&old].clone(); + replacement.id = Id::new_v4(); + replacement.alias = "invalid_replacement_mapping".into(); + // The reverse allocation is an invalid role, even if deletion would remove + // its dependent exchange allocation before the final candidate check. + replacement.body = RelationBody::PortAllocation { + source: replacement.target(), + target: replacement.source(), + }; + let mut invalid_project = p.clone(); + invalid_project.relations.remove(&old); + for commands in [ + vec![Command::Delete(old), Command::CreateRelation(replacement)], + vec![Command::Delete(old), Command::Replace(invalid_project)], + vec![ + Command::Delete(old), + Command::Delete(exchange_allocation), + Command::CreateRelation(p.relations[&exchange_allocation].clone()), + ], + ] { + let mut session = Session::new(p.clone()); + assert!(session.execute(Command::Batch(commands)).is_err()); + assert_eq!(session.project(), &p); + assert_eq!(session.revision(), 0); + assert!(!session.can_undo()); + } +} + #[test] fn component_generation_requires_real_allocations_and_reuses_existing_facts() { let (mut p, a, b, _, fe) = fixture();