use crate::*; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; mod communication; pub(crate) use communication::review_endpoint_fact; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum TransitionDirection { OaToSa, SaToOa, } impl TransitionDirection { pub fn source_layer(self) -> Layer { match self { Self::OaToSa => Layer::Oa, Self::SaToOa => Layer::Sa, } } pub fn target_layer(self) -> Layer { match self { Self::OaToSa => Layer::Sa, Self::SaToOa => Layer::Oa, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DependencyScope { SelectedOnly, Required, Subtree, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum TransitionField { Name, Description, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TransitionMapping { pub source: Id, pub target: Option, pub target_kind: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum FieldDecision { TakeSource, KeepTarget, Value(String), } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct FieldResolution { pub source: Id, pub field: TransitionField, pub decision: FieldDecision, } /// Technical endpoint choices are scoped to one OA exchange. Participant /// correspondence remains in `mappings`, never in these port identities. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum CommunicationPortChoice { Reuse { port: Id, }, Create { id: Id, alias: String, name: String, description: String, kind: ComponentPortKind, orientation: ComponentPortOrientation, }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CommunicationEndpointDecision { pub source: Id, pub source_port: CommunicationPortChoice, pub target_port: CommunicationPortChoice, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct OperationOmission { pub source: Id, pub rationale: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TransitionRequest { pub direction: TransitionDirection, pub sources: Vec, pub scope: DependencyScope, pub mappings: Vec, pub fields: Vec, pub resolutions: Vec, pub destination_diagram: Option, #[serde(default)] pub communication_endpoints: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub operation_omissions: Vec, } impl TransitionRequest { pub fn new(direction: TransitionDirection, sources: Vec) -> Self { Self { direction, sources, scope: DependencyScope::SelectedOnly, mappings: vec![], fields: vec![TransitionField::Name, TransitionField::Description], resolutions: vec![], destination_diagram: None, communication_endpoints: vec![], operation_omissions: vec![], } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum TransitionAction { Create, Update, Reuse, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct FieldChange { pub field: TransitionField, pub before: String, pub after: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TransitionChange { pub source: Id, pub target: Id, pub action: TransitionAction, pub fields: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TransitionConflict { pub source: Option, pub target: Option, pub field: Option, pub message: String, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TransitionPlan { pub base_fingerprint: String, pub request: TransitionRequest, pub changes: Vec, pub conflicts: Vec, pub warnings: Vec, pub commands: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Drift { Unreviewed, Unchanged, OaChanged, SaChanged, BothChanged, } pub fn trace_drift(p: &Project, t: &Trace) -> Drift { let Some(base) = &t.baseline else { return Drift::Unreviewed; }; let (Some(oa), Some(sa)) = (p.properties(t.oa), p.properties(t.sa)) else { return Drift::Unreviewed; }; match (oa != base.oa, sa != base.sa) { (false, false) => Drift::Unchanged, (true, false) => Drift::OaChanged, (false, true) => Drift::SaChanged, (true, true) => Drift::BothChanged, } } /// Deterministic change token, not a security hash or persisted revision. pub fn project_fingerprint(p: &Project) -> String { let bytes = serde_json::to_vec(p).expect("Project serializes"); let mut hash = 0xcbf29ce484222325u64; for byte in bytes { hash ^= byte as u64; hash = hash.wrapping_mul(0x100000001b3); } format!("{hash:016x}") } fn conflict( plan: &mut TransitionPlan, source: Option, target: Option, field: Option, message: impl Into, ) { plan.conflicts.push(TransitionConflict { source, target, field, message: message.into(), }); } fn inferred_kind(kind: Kind, direction: TransitionDirection) -> Option { Some(match (direction, kind) { (TransitionDirection::OaToSa, Kind::Activity) => Kind::Function, (TransitionDirection::SaToOa, Kind::Function) => Kind::Activity, (TransitionDirection::OaToSa, Kind::Capability) => Kind::SystemCapability, (TransitionDirection::SaToOa, Kind::SystemCapability) => Kind::Capability, (TransitionDirection::OaToSa, Kind::OperationalMission) => Kind::Mission, (TransitionDirection::SaToOa, Kind::Mission) => Kind::OperationalMission, (TransitionDirection::OaToSa, Kind::Process) => Kind::FunctionalChain, (TransitionDirection::SaToOa, Kind::FunctionalChain) => Kind::Process, (_, k) if k.is_state() || matches!( k, Kind::ExchangeItem | Kind::ExchangeCategory | Kind::DataType | Kind::DataField | Kind::Interface | Kind::Port | Kind::Scenario | Kind::Lifeline | Kind::Occurrence | Kind::StateMachine | Kind::Region | Kind::Event | Kind::Constraint ) => { k } _ => return None, }) } fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option { if layer == Layer::Oa && crate::ports::port_contract(e).is_some_and(|contract| *contract != PortContract::Legacy) { return None; } if let Some(shared) = e.shared() && (kind == e.kind() || (kind.is_state() && e.kind().is_state())) { let mut shared = shared.clone(); if let SharedBody::State { kind: state_kind, .. } = &mut shared { *state_kind = kind.state_kind()?; } if let SharedBody::Scenario { viewpoint, .. } = &mut shared { *viewpoint = match (*viewpoint, layer) { (ScenarioViewpoint::OperationalActivity, Layer::Sa) => { ScenarioViewpoint::Functional } (ScenarioViewpoint::Functional, Layer::Oa) => { ScenarioViewpoint::OperationalActivity } (ScenarioViewpoint::ParticipantInteraction, Layer::Sa) => { ScenarioViewpoint::BoundaryInterface } (ScenarioViewpoint::BoundaryInterface, Layer::Oa) => { ScenarioViewpoint::ParticipantInteraction } (viewpoint, _) => viewpoint, }; } return Some(ElementBody::shared_in(layer, shared)); } let mut body = ElementBody::new(kind, layer)?; if let (Some(old), Some(new)) = (e.steps(), body.steps_mut()) { *new = old.clone(); } Some(body) } fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option { let (source, target) = r.body.endpoints(); Some(match (&r.body, direction) { (RelationBody::Interaction { items, .. }, TransitionDirection::OaToSa) => { RelationBody::FunctionalExchange { source, target, source_port: Id::nil(), target_port: Id::nil(), items: items.clone(), } } (RelationBody::FunctionalExchange { items, .. }, TransitionDirection::SaToOa) => { RelationBody::Interaction { source, target, items: items.clone(), } } ( RelationBody::CommunicationMean { oriented, items, .. }, TransitionDirection::OaToSa, ) => RelationBody::BoundaryExchange { source, target, oriented: *oriented, items: items.clone(), }, ( RelationBody::BoundaryExchange { oriented, items, .. }, TransitionDirection::SaToOa, ) => RelationBody::CommunicationMean { source, target, oriented: *oriented, items: items.clone(), }, ( RelationBody::Allocation { .. } | RelationBody::InterfaceItemAllocation { .. } | RelationBody::DataAssociation { .. } | RelationBody::ExchangeAllocation { .. } | RelationBody::CapabilityInvolvement { .. } | RelationBody::CapabilityInclude { .. } | RelationBody::CapabilityExtend { .. } | RelationBody::CapabilityGeneralization { .. } | RelationBody::MissionInvolvement { .. } | RelationBody::MissionCapability { .. } | RelationBody::SequenceLink { .. } | RelationBody::ContextualLink { .. } | RelationBody::ChainInvolvement { .. } | RelationBody::BoundaryExchange { .. } | RelationBody::InterfaceProvision { .. } | RelationBody::InterfaceRequirement { .. } | RelationBody::StateTransition { .. } | RelationBody::Availability { .. } | RelationBody::StateAction { .. }, _, ) => r.body.clone(), _ => return None, }) } fn trace_kind(p: &Project, oa: Id, sa: Id) -> TraceKind { match (p.elements.get(&oa), p.elements.get(&sa)) { (Some(a), Some(b)) => match (a.kind(), b.kind()) { (Kind::Activity, Kind::Function) => TraceKind::Refinement, (Kind::Entity | Kind::Actor, Kind::System | Kind::SystemActor) => { TraceKind::Participant } (Kind::Capability, Kind::SystemCapability) => TraceKind::Capability, (Kind::OperationalMission, Kind::Mission) => TraceKind::Mission, (Kind::Process, Kind::FunctionalChain) => TraceKind::Sequence, (Kind::Occurrence, Kind::Occurrence) if a.parent .and_then(|id| p.elements.get(&id)) .is_some_and(|owner| owner.kind() == Kind::Process) && b.parent .and_then(|id| p.elements.get(&id)) .is_some_and(|owner| owner.kind() == Kind::FunctionalChain) => { TraceKind::Sequence } ( Kind::ExchangeItem | Kind::DataType | Kind::DataField | Kind::Interface | Kind::Port, _, ) => TraceKind::Item, (Kind::Scenario | Kind::Lifeline | Kind::Occurrence, _) => TraceKind::Scenario, (kind, other) if kind == other && (kind.is_state() || matches!(kind, Kind::StateMachine | Kind::Region | Kind::Event)) => { TraceKind::State } _ => TraceKind::Manual, }, _ => { if matches!((p.relations.get(&oa),p.relations.get(&sa)),(Some(a),Some(b))if exchange_counterparts(a.kind(), b.kind())) { TraceKind::Exchange } else { TraceKind::Manual } } } } fn value(props: &TracedProperties, field: TransitionField) -> &String { match field { TransitionField::Name => &props.name, TransitionField::Description => &props.description, } } /// SA port implementation details have no fabricated OA counterpart. Propagate /// omissions through dependent records, except the FE whose ports are removed /// by its explicit Interaction mapping. fn sa_port_omissions(p: &Project) -> BTreeSet { let mut omitted: BTreeSet<_> = p .elements .values() .filter(|e| { e.layer() == Layer::Sa && crate::ports::port_contract(e).is_some_and(|c| *c != PortContract::Legacy) }) .map(|e| e.id) .collect(); loop { let before = omitted.len(); for r in p.relations.values().filter(|r| { !matches!( r.kind(), RelationKind::FunctionalExchange | RelationKind::BoundaryExchange ) }) { if crate::references::relation_references(r) .iter() .any(|id| omitted.contains(id)) { omitted.insert(r.id); } } for e in p.elements.values().filter(|e| e.layer() == Layer::Sa) { if crate::references::element_references(e) .iter() .any(|id| omitted.contains(id)) { omitted.insert(e.id); } } if before == omitted.len() { return omitted; } } } fn set(props: &mut TracedProperties, field: TransitionField, value: String) { match field { TransitionField::Name => props.name = value, TransitionField::Description => props.description = value, } } pub fn plan_transition( project: &Project, request: &TransitionRequest, ) -> Result { validate_candidate(project)?; let mut plan = TransitionPlan { base_fingerprint: project_fingerprint(project), request: request.clone(), changes: vec![], conflicts: vec![], warnings: vec![], commands: vec![], }; let mut selected: BTreeSet<_> = request.sources.iter().copied().collect(); let mut omitted = if request.direction == TransitionDirection::SaToOa { sa_port_omissions(project) } else { BTreeSet::new() }; // An explicit existing Legacy counterpart can be compared and reviewed // without inventing an OA typed port. Only this port is retained: dependent // SA implementation records and new counterpart requests stay omitted. for mapping in &request.mappings { if project .elements .get(&mapping.source) .is_some_and(|e| e.layer() == Layer::Sa && crate::ports::port_contract(e).is_some()) && mapping .target .and_then(|target| project.elements.get(&target)) .is_some_and(|e| { e.layer() == Layer::Oa && crate::ports::port_contract(e) == Some(&PortContract::Legacy) }) { omitted.remove(&mapping.source); } } let mut reported_omissions = BTreeSet::new(); if selected.len() != request.sources.len() { conflict( &mut plan, None, None, None, "Source selection contains duplicate identities.", ); } if selected.is_empty() { conflict( &mut plan, None, None, None, "Select at least one source object.", ); } if request.scope != DependencyScope::SelectedOnly { loop { let before = selected.len(); let existing: Vec<_> = selected.iter().copied().collect(); for id in existing { if omitted.contains(&id) { continue; } if let Some(e) = project.elements.get(&id) { let mut dependencies = e.clone(); if request.direction == TransitionDirection::SaToOa && let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation, .. }, .. }) = dependencies.shared_mut() { *operation = None; } selected.extend(crate::references::element_references(&dependencies)); // Allocations are owned by interfaces. Independent data // associations travel only when both endpoint types are // already required; they do not expand the dictionary. if matches!(e.kind(), Kind::Interface | Kind::DataType) { let contracts: Vec<_> = project .relations .values() .filter_map(|r| { ((r.kind() == RelationKind::InterfaceItemAllocation && r.source() == id) || (r.kind() == RelationKind::DataAssociation && selected.contains(&r.source()) && selected.contains(&r.target()))) .then_some(r.id) }) .collect(); selected.extend(contracts); } // Contextual links are owned by the process/chain shared by // their endpoint involvements, independently of list order. if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { selected.extend(project.relations.values().filter_map(|relation| { (relation.contextual_owner() == Some(id) || (relation.kind() == RelationKind::SequenceLink && project .elements .get(&relation.source()) .and_then(|e| e.parent) == Some(id))) .then_some(relation.id) })); } if e.kind() == Kind::StateMachine && request.scope == DependencyScope::Subtree { selected.extend(project.relations.values().filter_map(|relation| { (matches!( relation.kind(), RelationKind::StateTransition | RelationKind::StateAction ) && validation::ancestor_of_kind( project, relation.source(), Kind::StateMachine, ) == Some(id)) .then_some(relation.id) })); } if request.scope == DependencyScope::Subtree || matches!(e.kind(), Kind::ExchangeItem | Kind::DataType) { selected.extend(project.children(id).map(|e| e.id)); } } else if let Some(r) = project.relations.get(&id) { if request.direction == TransitionDirection::SaToOa && r.kind() == RelationKind::BoundaryExchange { selected.extend( [r.source(), r.target()].into_iter().filter_map(|port| { project.elements.get(&port).and_then(|e| e.parent) }), ); selected.extend(r.body.items()); } else if request.direction == TransitionDirection::SaToOa && let RelationBody::FunctionalExchange { source, target, source_port, target_port, items, } = &r.body { // An Interaction needs the work/items, never either SA // endpoint port, including an unresolved legacy one. selected.extend([*source, *target]); selected.extend(items); for port in [*source_port, *target_port] { if reported_omissions.insert(port) { plan.warnings.push(format!("SA-only omission: functional endpoint {} ({port}) is not copied into the portless OA interaction; the original is preserved.", project.named(port))); } } } else { selected.extend(crate::references::relation_references(r)); } } } if selected.len() == before { break; } } } selected.retain(|id| { if omitted.contains(id) { if reported_omissions.insert(*id) { plan.warnings.push(format!("SA-only omission: {} ({id}) has no OA port counterpart; the original is preserved.", project.named(*id))); } false } else { true } }); let mut operation_omissions = BTreeSet::new(); for decision in &request.operation_omissions { if request.direction != TransitionDirection::SaToOa || !selected.contains(&decision.source) || !matches!( project .elements .get(&decision.source) .and_then(Element::shared), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation: Some(_), message_kind: MessageKind::Call | MessageKind::Asynchronous, .. }, .. }) ) || decision.rationale.trim().is_empty() || !validation::valid_text(&decision.rationale) || !operation_omissions.insert(decision.source) { conflict( &mut plan, Some(decision.source), None, None, "Operation omission needs one selected SA invocation, SA→OA direction, and a nonempty rationale; irrelevant or duplicate decisions reject.", ); } else { plan.warnings.push(format!( "Reviewed operation omission: {} ({}): {}. Source contract is preserved.", project.named(decision.source), decision.source, decision.rationale )); } } if request.direction == TransitionDirection::SaToOa { for source in &selected { if matches!( project.elements.get(source).and_then(Element::shared), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation: Some(_), .. }, .. }) ) && !operation_omissions.contains(source) { conflict( &mut plan, Some(*source), None, None, "This technical operation has no OA operation slot. Review an explicit per-invocation omission with rationale; Reply derives the Call's decision.", ); } } } let mut choices = BTreeMap::new(); for mapping in &request.mappings { if choices.insert(mapping.source, mapping).is_some() { conflict( &mut plan, Some(mapping.source), None, None, "Choose one counterpart per source in this plan; run separately for another existing trace.", ); } } let mut targets = BTreeMap::new(); let mut creating = BTreeSet::new(); let mut kinds = BTreeMap::new(); for source in &selected { if project.layer_of(*source) != Some(request.direction.source_layer()) { conflict( &mut plan, Some(*source), None, None, "Source is not a semantic object in the selected source layer.", ); continue; } let explicit = choices.get(source).copied(); let existing: Vec<_> = project .traces .values() .filter_map(|t| match request.direction { TransitionDirection::OaToSa if t.oa == *source => Some(t.sa), TransitionDirection::SaToOa if t.sa == *source => Some(t.oa), _ => None, }) .collect::>() .into_iter() .collect(); let target = if let Some(mapping) = explicit { mapping.target } else if existing.len() == 1 { Some(existing[0]) } else if existing.len() > 1 { conflict( &mut plan, Some(*source), None, None, "Several traced counterparts exist; select the target explicitly.", ); continue; } else { None }; if let Some(target) = target { if project.layer_of(target) != Some(request.direction.target_layer()) { conflict( &mut plan, Some(*source), Some(target), None, "Selected target must exist in the opposite layer.", ); continue; } if project.elements.contains_key(source) != project.elements.contains_key(&target) { conflict( &mut plan, Some(*source), Some(target), None, "Elements map to elements and relationships map to relationships.", ); continue; } if !compatible_existing(project, *source, target, request.direction) { conflict( &mut plan, Some(*source), Some(target), None, "These kinds have no automatic transition mapping; use a manual trace.", ); continue; } targets.insert(*source, target); continue; } if let Some(e) = project.elements.get(source) { let kind = explicit .and_then(|m| m.target_kind) .or_else(|| inferred_kind(e.kind(), request.direction)); let Some(kind) = kind else { conflict( &mut plan, Some(*source), None, None, "Choose a counterpart kind explicitly; participant/system boundary decisions are never inferred.", ); continue; }; if !kind.supports(request.direction.target_layer()) || !compatible_kinds(e.kind(), kind, request.direction) { conflict( &mut plan, Some(*source), None, None, "Counterpart kind is not valid for this automatic transition.", ); continue; } kinds.insert(*source, kind); } else if let Some(r) = project.relations.get(source) && mapped_relation(r, request.direction).is_none() { conflict( &mut plan, Some(*source), None, None, "This relation has no automatic cross-layer mapping; use an explicit manual trace.", ); continue; } targets.insert(*source, Id::new_v4()); creating.insert(*source); } // Explicit mapping choices may provide dependencies without selecting updates. for mapping in &request.mappings { if let Some(target) = mapping.target && project.layer_of(mapping.source) == Some(request.direction.source_layer()) && project.layer_of(target) == Some(request.direction.target_layer()) { targets.entry(mapping.source).or_insert(target); } } let mut reused = BTreeMap::new(); for source in &selected { if let Some(target) = targets.get(source) && let Some(previous) = reused.insert(*target, *source) { let different = match (project.properties(previous), project.properties(*source)) { (Some(a), Some(b)) => request .fields .iter() .any(|field| value(&a, *field) != value(&b, *field)), _ => true, }; if different { conflict( &mut plan, Some(*source), Some(*target), None, "Several selected sources propose different values for one target. Reconcile that target explicitly in separate previews.", ); } } } let mut working = project.clone(); let communication_pairs = communication::prepare_ports(project, &mut working, &targets, &mut plan); let ordered_creations: Vec<_> = selected .iter() .filter(|id| creating.contains(id) && project.elements.contains_key(id)) .chain( selected .iter() .filter(|id| creating.contains(id) && project.relations.contains_key(id)), ) .copied() .collect(); for source in &ordered_creations { let Some(target) = targets.get(source).copied() else { continue; }; if let Some(e) = project.elements.get(source) { let Some(body) = mapped_body(e, kinds[source], request.direction.target_layer()) else { conflict( &mut plan, Some(*source), Some(target), None, "Unsupported typed body conversion.", ); continue; }; let mut created = e.clone(); created.id = target; created.body = body; if request.direction == TransitionDirection::SaToOa && operation_omissions.contains(source) && let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation, .. }, .. }) = created.shared_mut() { *operation = None; } created.alias = working.fresh_alias( &format!( "{}_{}", request.direction.target_layer().to_string().to_lowercase(), e.alias ), "element", target, ); crate::references::visit_element_refs(&mut created, |id| { if let Some(mapped) = targets.get(id) { *id = *mapped; } }); for dependency in crate::references::element_references(&created) { if !targets.values().any(|id| *id == dependency) && working.layer_of(dependency) != Some(request.direction.target_layer()) { conflict( &mut plan, Some(*source), Some(target), None, format!( "Dependency {dependency} needs an explicit counterpart or a broader dependency scope." ), ); } } if !request.fields.contains(&TransitionField::Description) { created.description.clear(); } working.elements.insert(target, created.clone()); plan.commands.push(Command::CreateElement(created)); } else if let Some(r) = project.relations.get(source) { let Some(body) = mapped_relation(r, request.direction) else { continue; }; let mut created = r.clone(); created.id = target; created.body = body; // SA technical ports project through their reviewed owners; never // place a port→participant pair in the global correspondence map. if request.direction == TransitionDirection::SaToOa && r.kind() == RelationKind::BoundaryExchange { let owners = [r.source(), r.target()] .map(|port| project.elements.get(&port).and_then(|e| e.parent)); if let [Some(a), Some(b)] = owners { created.body.set_endpoints(a, b); } else { conflict( &mut plan, Some(*source), Some(target), None, "Boundary endpoints need participant owners before communication transition.", ); } } created.alias = working.fresh_alias( &format!( "{}_{}", request.direction.target_layer().to_string().to_lowercase(), r.alias ), "relation", target, ); crate::references::visit_relation_refs(&mut created, |id| { if let Some(mapped) = targets.get(id) { *id = *mapped; } }); if request.direction == TransitionDirection::OaToSa && r.kind() == RelationKind::CommunicationMean { if let Some((a, b)) = communication_pairs.get(source) { created.body.set_endpoints(*a, *b); } else { conflict( &mut plan, Some(*source), Some(target), None, "Choose an explicit SA component-port pair for this communication means; participant mappings cannot stand in for ports.", ); } } for dependency in crate::references::relation_references(&created) { if dependency.is_nil() && request.direction == TransitionDirection::OaToSa && r.kind() == RelationKind::Interaction { continue; } if !targets.values().any(|id| *id == dependency) && working.layer_of(dependency) != Some(request.direction.target_layer()) { conflict( &mut plan, Some(*source), Some(target), None, format!( "Relationship dependency {dependency} needs a counterpart mapping." ), ); } } if !request.fields.contains(&TransitionField::Description) { created.description.clear(); } if request.direction == TransitionDirection::OaToSa && let RelationBody::FunctionalExchange { source, target: destination, items, .. } = &created.body { let request = FunctionalExchangeRequest { id: created.id, alias: created.alias.clone(), name: created.name.clone(), description: created.description.clone(), source: *source, target: *destination, source_port: PortChoice::Auto, target_port: PortChoice::Auto, items: items.clone(), }; match crate::ports::functional_exchange_commands(&working, &request) { Ok(commands) => { for command in &commands { match command { Command::CreateElement(e) => { working.elements.insert(e.id, e.clone()); } Command::CreateRelation(r) => { working.relations.insert(r.id, r.clone()); } _ => unreachable!("functional creation only creates objects"), } } plan.commands.extend(commands); } Err(err) => conflict( &mut plan, Some(*source), Some(target), None, format!("Functional endpoint creation: {err}"), ), } } else { working.relations.insert(target, created.clone()); plan.commands.push(Command::CreateRelation(created)); } } } for source in &selected { let Some(target) = targets.get(source).copied() else { continue; }; let (Some(source_props), Some(mut target_props)) = (project.properties(*source), working.properties(target)) else { continue; }; let (oa, sa) = match request.direction { TransitionDirection::OaToSa => (*source, target), TransitionDirection::SaToOa => (target, *source), }; let existing_trace = project.traces.values().find(|t| t.oa == oa && t.sa == sa); let mut changes = vec![]; let mut reviewed_fields = vec![]; for field in &request.fields { let before = value(&target_props, *field).clone(); let incoming = value(&source_props, *field).clone(); if before == incoming { reviewed_fields.push(*field); continue; } let decision = request .resolutions .iter() .find(|r| r.source == *source && r.field == *field) .map(|r| &r.decision); let changed = existing_trace .and_then(|t| t.baseline.as_ref()) .map(|base| { let (origin, destination) = match request.direction { TransitionDirection::OaToSa => (&base.oa, &base.sa), TransitionDirection::SaToOa => (&base.sa, &base.oa), }; ( value(origin, *field) != &incoming, value(destination, *field) != &before, ) }); let after = match decision { Some(FieldDecision::TakeSource) => incoming, Some(FieldDecision::KeepTarget) => before.clone(), Some(FieldDecision::Value(v)) => v.clone(), None if creating.contains(source) => incoming, // A reviewed pair may deliberately have different values. No // source edit means no propagation, including when the target // has a local edit that can later propagate in reverse. None if matches!(changed, Some((false, _))) => continue, None if matches!(changed, Some((true, false))) => incoming, None => { conflict( &mut plan, Some(*source), Some(target), Some(*field), "Both sides changed or no reviewed baseline exists. Choose take source, keep target, or a merged value.", ); continue; } }; reviewed_fields.push(*field); if after != before { set(&mut target_props, *field, after.clone()); changes.push(FieldChange { field: *field, before, after, }); } } if !changes.is_empty() { if let Some(e) = working.elements.get_mut(&target) { e.name = target_props.name.clone(); e.description = target_props.description.clone(); plan.commands.push(Command::EditElement(target, e.clone())); } else if let Some(r) = working.relations.get_mut(&target) { r.name = target_props.name.clone(); r.description = target_props.description.clone(); plan.commands.push(Command::EditRelation(target, r.clone())); } } plan.changes.push(TransitionChange { source: *source, target, action: if creating.contains(source) { TransitionAction::Create } else if changes.is_empty() { TransitionAction::Reuse } else { TransitionAction::Update }, fields: changes, }); let current_oa = working.properties(oa).unwrap(); let current_sa = working.properties(sa).unwrap(); let mut baseline = existing_trace .and_then(|t| t.baseline.clone()) .unwrap_or(TraceBaseline { oa: current_oa.clone(), sa: current_sa.clone(), semantic: None, }); for field in &reviewed_fields { set(&mut baseline.oa, *field, value(¤t_oa, *field).clone()); set(&mut baseline.sa, *field, value(¤t_sa, *field).clone()); } if let Some(old) = existing_trace { let mut trace = old.clone(); trace.baseline = Some(baseline); if &trace != old { working.traces.insert(trace.id, trace.clone()); plan.commands.push(Command::EditTrace(trace.id, trace)); } } else { let id = Id::new_v4(); let name = format!("{} ↔ {}", working.named(oa), working.named(sa)); let alias = working.fresh_alias( &format!( "trace_{}_{}", working .elements .get(&oa) .map(|e| e.alias.as_str()) .unwrap_or("exchange"), working .elements .get(&sa) .map(|e| e.alias.as_str()) .unwrap_or("exchange") ), "trace", id, ); let trace = Trace { id, alias, name, description: String::new(), oa, sa, kind: trace_kind(&working, oa, sa), baseline: Some(baseline), }; working.traces.insert(id, trace.clone()); plan.commands.push(Command::CreateTrace(trace)); } if let Some(diagram) = request.destination_diagram && working.elements.contains_key(&target) { match working.diagrams.get(&diagram) { Some(d) if d.layer == request.direction.target_layer() || d.projection == ProjectionKind::Traceability => { if !d.nodes.values().any(|n| n.element == target) { let index = d.nodes.len(); let id = Id::new_v4(); let alias = working.fresh_alias( &format!("view_{}", working.named(target)), "node", id, ); let node = NodePlacement { id, alias, element: target, position: Position { x: 60. + (index % 4) as f32 * 270., y: 60. + (index / 4) as f32 * 160., }, size: Size { width: 224., height: 94., }, collapsed: false, port_anchors: Vec::new(), }; working .diagrams .get_mut(&diagram) .unwrap() .nodes .insert(id, node.clone()); plan.commands.push(Command::CreateNode { diagram, node }); } } _ => conflict( &mut plan, Some(*source), Some(target), None, "Destination diagram is missing or belongs to the wrong layer.", ), } } } // Final source-order-independent batch execution also applies occurrence ownership rules. match execute_batch(project, &plan.commands) { Ok(candidate) => { for issue in validation::validate(&candidate) { if issue.severity == validation::Severity::Warning { plan.warnings.push(issue.message); } } } Err(err) => conflict( &mut plan, None, None, None, format!("Candidate validation: {err}"), ), } Ok(plan) } pub fn apply_transition(project: &Project, plan: &TransitionPlan) -> Result { prepare_transition(project, plan).map(|prepared| prepared.project) } /// Prepare a reviewed transition once, retaining identity correspondence for /// adapters that update accepted graphical and authored source state together. pub fn prepare_transition( project: &Project, plan: &TransitionPlan, ) -> Result { if project_fingerprint(project) != plan.base_fingerprint { return Err(ModelError( "Transition preview is stale; create a new preview.".into(), )); } if !plan.conflicts.is_empty() { return Err(ModelError( "Resolve transition conflicts before applying.".into(), )); } prepare_command(project, &Command::Batch(plan.commands.clone())) } /// Supported explicit element counterparts, in stable kind-catalog order. pub fn counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec { let mut kinds: Vec<_> = Kind::ALL .into_iter() .filter(|target| { target.supports(direction.target_layer()) && compatible_kinds(source, *target, direction) }) .collect(); // Offer the semantic counterpart first; alternative engineering mappings // remain explicit choices and existing traces remain reusable. let preferred = inferred_kind(source, direction); kinds.sort_by_key(|kind| Some(*kind) != preferred); kinds } fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection) -> bool { // State kind is a reviewed structural facet of one state family. An existing // counterpart remains selectable after either side changes its vertex kind. if source.is_state() && target.is_state() { return true; } if matches!( (direction, source, target), ( TransitionDirection::OaToSa, Kind::Entity | Kind::Actor, Kind::System | Kind::SystemActor ) | ( TransitionDirection::SaToOa, Kind::System | Kind::SystemActor, Kind::Entity | Kind::Actor ) | (TransitionDirection::OaToSa, Kind::Capability, Kind::Mission) | (TransitionDirection::SaToOa, Kind::Mission, Kind::Capability) ) { return true; } inferred_kind(source, direction) == Some(target) } fn compatible_existing( p: &Project, source: Id, target: Id, direction: TransitionDirection, ) -> bool { match (p.elements.get(&source), p.elements.get(&target)) { (Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction), _ => match (p.relations.get(&source), p.relations.get(&target)) { (Some(a), Some(b)) => { (a.kind() == RelationKind::BoundaryExchange && b.kind() == RelationKind::BoundaryExchange) || mapped_relation(a, direction).is_some_and(|body| body.kind() == b.kind()) } _ => false, }, } } pub(crate) fn exchange_counterparts(oa: RelationKind, sa: RelationKind) -> bool { matches!( (oa, sa), (RelationKind::Interaction, RelationKind::FunctionalExchange) | ( RelationKind::CommunicationMean, RelationKind::BoundaryExchange ) ) }