//! Pure planners for direct editing of persisted state-machine diagrams. //! //! The application owns tool state and draft guards. It must supply its current //! document/diagram/revision/epoch context, execute a returned command once, and //! apply the selection metadata only after the document transaction succeeds. pub use crate::route_edit::Context; use oa_core::{projection, *}; use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Endpoint { Source, Target, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tool { Select, Vertex(Kind), Region, Transition, Reconnect { relation: Id, endpoint: Endpoint }, } #[derive(Debug, Clone)] pub enum Event { Choose(Tool), CreateView(Id), Select(Context, Id), Pan(Context, iced::Vector), Zoom(Context, f32), Viewport(Context, iced::Size), Fit(Context), EditTransition(Context, Id), ContextMenu { context: Context, object: Id, placement: Option, anchor: iced::Point, }, CreateAt { context: Context, owner: Id, kind: Kind, position: Position, }, Place { context: Context, element: Id, position: Position, }, Move { context: Context, node: Id, position: Position, }, Connect { context: Context, source: Id, target: Id, }, Reconnect { context: Context, relation: Id, endpoint: Endpoint, vertex: Id, }, } impl Event { pub fn context(&self) -> Option { match self { Self::Choose(_) | Self::CreateView(_) => None, Self::Select(c, _) | Self::Pan(c, _) | Self::Zoom(c, _) | Self::Viewport(c, _) | Self::Fit(c) | Self::EditTransition(c, _) | Self::ContextMenu { context: c, .. } | Self::CreateAt { context: c, .. } | Self::Place { context: c, .. } | Self::Move { context: c, .. } | Self::Connect { context: c, .. } | Self::Reconnect { context: c, .. } => Some(*c), } } } #[derive(Debug, Clone)] pub struct Plan { pub command: Command, pub diagram: Id, /// Semantic identity, including after a placement move. pub selected: Id, pub placement: Option, } fn error(message: impl Into) -> ModelError { ModelError(message.into()) } fn machine(project: &Project, diagram: Id) -> Result { let view = project .diagrams .get(&diagram) .ok_or_else(|| error("This state diagram no longer exists."))?; let machine = view .projection .state_machine() .ok_or_else(|| error("Choose a dedicated state-machine diagram."))?; if !project .elements .get(&machine) .is_some_and(|e| e.kind() == Kind::StateMachine && e.layer() == view.layer) { return Err(error( "The diagram's state-machine context is no longer valid.", )); } Ok(machine) } /// Returns the diagram's machine only when the captured input is still current. /// The caller supplies the actual current context; project UUID is not the /// application's document identity (reopening the same project creates a new one). pub fn validate_context( project: &Project, captured: Context, current: Context, ) -> Result { if captured != current { return Err(error( "The state diagram changed. Start this gesture again.", )); } machine(project, captured.diagram) } pub fn eligible_owner(project: &Project, diagram: Id, owner: Id, kind: Kind) -> bool { let Ok(machine) = machine(project, diagram) else { return false; }; let Some(parent) = project.elements.get(&owner) else { return false; }; projection::state_machine_contains(project, machine, owner) && if kind == Kind::Region { parent.kind() == Kind::StateMachine || parent.kind().is_stable_state() } else { kind.is_state() && parent.kind() == Kind::Region } && validation::can_parent(project, kind, parent.layer(), Some(owner)) } /// Semantic objects without a placement, including missing Regions and Machine. /// Folded placements are retained and therefore are not offered as missing. pub fn unplaced(project: &Project, diagram: Id) -> Vec { let Ok(machine) = machine(project, diagram) else { return vec![]; }; let placed: BTreeSet<_> = project.diagrams[&diagram] .nodes .values() .map(|n| n.element) .collect(); let mut elements: Vec<_> = project .elements .values() .filter(|e| { !placed.contains(&e.id) && projection::state_machine_contains(project, machine, e.id) }) .collect(); elements.sort_by_key(|e| (&e.name, &e.alias, e.id)); elements.into_iter().map(|e| e.id).collect() } fn visible_vertex(project: &Project, diagram: Id, vertex: Id) -> bool { machine(project, diagram).is_ok_and(|machine| { projection::state_machine_contains(project, machine, vertex) && project.elements[&vertex].kind().is_state() && projection::project_view(project, diagram) .nodes .iter() .any(|n| n.element == vertex) }) } /// The direct transition tool creates External transitions. EntryPoint routing /// requires Local semantics and remains available in the typed transition form. pub fn can_start(project: &Project, diagram: Id, vertex: Id) -> bool { visible_vertex(project, diagram, vertex) && !matches!( project.elements[&vertex].kind(), Kind::Final | Kind::Terminate | Kind::EntryPoint ) } pub fn can_connect(project: &Project, diagram: Id, source: Id, target: Id) -> bool { can_start(project, diagram, source) && visible_vertex(project, diagram, target) && validation::can_connect(project, RelationKind::StateTransition, source, target) } fn reconnect_command( project: &Project, diagram: Id, relation: Id, endpoint: Endpoint, vertex: Id, ) -> Result { let machine = machine(project, diagram)?; let original = project .relations .get(&relation) .filter(|r| { r.kind() == RelationKind::StateTransition && projection::state_machine_contains(project, machine, r.source()) && projection::state_machine_contains(project, machine, r.target()) }) .ok_or_else(|| error("Choose a transition in this state machine."))?; if !visible_vertex(project, diagram, vertex) { return Err(error( "Choose a visible state-machine vertex in this diagram.", )); } let mut replacement = original.clone(); let RelationBody::StateTransition { source, target, .. } = &mut replacement.body else { unreachable!("the original relation was checked above") }; match endpoint { Endpoint::Source => *source = vertex, Endpoint::Target => *target = vertex, } Ok(Command::EditRelation(relation, replacement)) } pub fn can_reconnect( project: &Project, diagram: Id, relation: Id, endpoint: Endpoint, vertex: Id, ) -> bool { reconnect_command(project, diagram, relation, endpoint, vertex) .and_then(|command| prepare_command(project, &command)) .is_ok() } fn checked(project: &Project, plan: Plan) -> Result { prepare_command(project, &plan.command)?; Ok(plan) } fn finite(position: Position) -> Result<(), ModelError> { if !position.x.is_finite() || !position.y.is_finite() { return Err(error("Choose a finite position in the state diagram.")); } Ok(()) } fn initial_size(project: &Project, element: Id) -> Size { let kind = project.elements[&element].kind(); projection::state_symbol_size(kind).unwrap_or(Size { width: if matches!(kind, Kind::StateMachine | Kind::Region) { 336. } else { 224. }, height: (projection::state_header_height(project, element) + 40.).max(94.), }) } /// One explicit operation initializes all existing contents. Shared Layout /// reserves headers/internal rows and uses the same state-symbol geometry. pub fn create_view(project: &Project, machine: Id) -> Result { let owner = project .elements .get(&machine) .filter(|e| e.kind() == Kind::StateMachine) .ok_or_else(|| error("Choose an existing state machine."))?; let diagram = Id::new_v4(); let name = format!("{} · states", owner.name); let mut working = project.clone(); working.diagrams.insert( diagram, Diagram { id: diagram, alias: project.fresh_alias(&name, "state_view", diagram), name, description: String::new(), layer: owner.layer(), projection: ProjectionKind::StateMachine { machine }, nodes: BTreeMap::new(), routes: vec![], }, ); let members: Vec<_> = project .elements .keys() .copied() .filter(|id| projection::state_machine_contains(project, machine, *id)) .collect(); for element in members { let size = initial_size(&working, element); let node = working.place(diagram, element, Position { x: 60., y: 60. }); working .diagrams .get_mut(&diagram) .unwrap() .nodes .get_mut(&node) .unwrap() .size = size; } checked( project, Plan { command: Command::Batch(vec![ Command::CreateDiagram(working.diagrams.remove(&diagram).unwrap()), Command::Layout(diagram), ]), diagram, selected: machine, placement: None, }, ) } /// Place the semantic path without changing ownership, other placements, or /// other views. Ancestors wrap the requested position using their shared header /// heights. Existing ancestors unfold so the accepted child is actually visible. fn place_path( working: &mut Project, diagram: Id, element: Id, position: Position, commands: &mut Vec, ) -> Result { finite(position)?; let machine = machine(working, diagram)?; if !projection::state_machine_contains(working, machine, element) { return Err(error( "Place a region or vertex from this diagram's state machine.", )); } let mut path = vec![(element, position)]; let mut current = element; let mut position = position; while current != machine { let parent = working.elements[¤t] .parent .ok_or_else(|| error("The state-machine ownership path is incomplete."))?; position = Position { x: position.x - 24., y: position.y - projection::state_header_height(working, parent), }; path.push((parent, position)); current = parent; } let mut placement = None; for (element, position) in path.into_iter().rev() { let existing = working.diagrams[&diagram] .nodes .values() .find(|n| n.element == element) .cloned(); let id = if let Some(mut node) = existing { if node.collapsed { node.collapsed = false; working .diagrams .get_mut(&diagram) .unwrap() .nodes .insert(node.id, node.clone()); commands.push(Command::EditNode { diagram, original: node.id, node: node.clone(), }); } node.id } else { let size = initial_size(working, element); let id = working.place(diagram, element, position); let node = working .diagrams .get_mut(&diagram) .unwrap() .nodes .get_mut(&id) .unwrap(); node.size = size; commands.push(Command::CreateNode { diagram, node: node.clone(), }); id }; placement = Some(id); } Ok(placement.expect("a membership path includes its requested element")) } pub fn plan(project: &Project, current: Context, event: &Event) -> Result { let captured = event .context() .ok_or_else(|| error("This request does not edit diagram content."))?; validate_context(project, captured, current)?; let diagram = captured.diagram; let (command, selected, placement) = match *event { Event::CreateAt { owner, kind, position, .. } => { if !eligible_owner(project, diagram, owner, kind) { return Err(error( "Create a vertex inside an explicit owning Region, or a Region inside its Machine or stable State/Mode.", )); } finite(position)?; let element = project .make_element( kind, project.diagrams[&diagram].layer, kind.label(), Some(owner), ) .ok_or_else(|| error("This owner cannot contain the selected kind."))?; let selected = element.id; let mut working = project.clone(); working.elements.insert(selected, element.clone()); let mut commands = vec![Command::CreateElement(element)]; let placement = place_path(&mut working, diagram, selected, position, &mut commands)?; (Command::Batch(commands), selected, Some(placement)) } Event::Place { element, position, .. } => { let mut working = project.clone(); let mut commands = vec![]; let placement = place_path(&mut working, diagram, element, position, &mut commands)?; (Command::Batch(commands), element, Some(placement)) } Event::Move { node, position, .. } => { finite(position)?; let placement = project.diagrams[&diagram] .nodes .get(&node) .ok_or_else(|| error("The placement no longer belongs to this state diagram."))?; ( Command::MoveNode { diagram, node, position, with_descendants: true, }, placement.element, Some(node), ) } Event::Connect { source, target, .. } => { if !visible_vertex(project, diagram, source) || !visible_vertex(project, diagram, target) { return Err(error("Choose two visible vertices in this state diagram.")); } let id = Id::new_v4(); let name = format!("{} → {}", project.named(source), project.named(target)); let relation = Relation { id, alias: project.fresh_alias(&name, "transition", id), name, description: String::new(), body: RelationBody::new(RelationKind::StateTransition, source, target), }; (Command::CreateRelation(relation), id, None) } Event::Reconnect { relation, endpoint, vertex, .. } => ( reconnect_command(project, diagram, relation, endpoint, vertex)?, relation, None, ), _ => { return Err(error( "Handle this navigation or tool request without a model command.", )); } }; checked( project, Plan { command, diagram, selected, placement, }, ) } #[cfg(test)] mod tests { use super::*; use crate::document::Document; struct Fixture { project: Project, machine: Id, region: Id, initial: Id, composite: Id, nested: Id, leaf: Id, target: Id, other_machine: Id, function: Id, } fn add(p: &mut Project, kind: Kind, name: &str, parent: Option) -> Id { p.add_element(kind, Layer::Sa, name, parent).unwrap() } fn fixture() -> Fixture { let mut project = Project::empty(); let machine = add(&mut project, Kind::StateMachine, "Operating modes", None); let region = add(&mut project, Kind::Region, "Main", Some(machine)); let initial = add(&mut project, Kind::Initial, "Start", Some(region)); let composite = add(&mut project, Kind::State, "Operating", Some(region)); let nested = add(&mut project, Kind::Region, "Work", Some(composite)); let leaf = add(&mut project, Kind::State, "Working", Some(nested)); let target = add(&mut project, Kind::State, "Standby", Some(region)); let other_machine = add(&mut project, Kind::StateMachine, "Other behavior", None); let function = add(&mut project, Kind::Function, "Report mode", None); Fixture { project, machine, region, initial, composite, nested, leaf, target, other_machine, function, } } fn context(diagram: Id) -> Context { Context { document: Id::new_v4(), diagram, revision: 7, epoch: 3, } } fn node(project: &Project, diagram: Id, element: Id) -> Id { project.diagrams[&diagram] .nodes .values() .find(|n| n.element == element) .unwrap() .id } fn create(f: &mut Fixture) -> Id { let plan = create_view(&f.project, f.machine).unwrap(); f.project = prepare_command(&f.project, &plan.command).unwrap().project; plan.diagram } #[test] fn view_initializes_only_its_machine_in_one_source_and_model_history_entry() { let f = fixture(); let before = f.project.clone(); let plan = create_view(&f.project, f.machine).unwrap(); assert_eq!(f.project, before, "Planning never mutates the input"); let mut document = Document::from_project(f.project, Default::default()).unwrap(); let source = document.bundle().clone(); document.execute(plan.command).unwrap(); assert_eq!(document.revision(), 1); let view = projection::project_view(document.project(), plan.diagram); let members: BTreeSet<_> = view.nodes.iter().map(|n| n.element).collect(); assert_eq!( members, BTreeSet::from([ f.machine, f.region, f.initial, f.composite, f.nested, f.leaf, f.target ]) ); assert!( view.ports.is_empty(), "State vertices do not become semantic ports" ); assert!(!members.contains(&f.other_machine)); assert!(!members.contains(&f.function)); assert_eq!(document.project().elements, before.elements); let accepted = document.bundle().clone(); assert_eq!( source_project::compile_bundle(&accepted, Default::default()) .unwrap() .decoded .project, *document.project() ); assert!(document.undo()); assert_eq!(document.bundle(), &source); assert_eq!(document.project(), &before); assert!(document.redo()); assert_eq!(document.bundle(), &accepted); } #[test] fn an_empty_machine_has_an_explicit_visible_owner_for_first_region_creation() { let mut project = Project::empty(); let machine = add(&mut project, Kind::StateMachine, "New behavior", None); let view = create_view(&project, machine).unwrap(); let project = prepare_command(&project, &view.command).unwrap().project; assert_eq!( projection::project_view(&project, view.diagram).nodes[0].element, machine ); let c = context(view.diagram); let plan = plan( &project, c, &Event::CreateAt { context: c, owner: machine, kind: Kind::Region, position: Position { x: 100., y: 160. }, }, ) .unwrap(); let accepted = prepare_command(&project, &plan.command).unwrap().project; assert_eq!(accepted.elements[&plan.selected].parent, Some(machine)); assert_eq!(accepted.elements[&plan.selected].kind(), Kind::Region); } #[test] fn creation_places_missing_ancestry_and_unfolds_existing_owners_atomically() { let mut f = fixture(); let diagram = create(&mut f); let composite_node = node(&f.project, diagram, f.composite); f.project .diagrams .get_mut(&diagram) .unwrap() .nodes .retain(|_, n| n.element == f.machine || n.element == f.composite); f.project .diagrams .get_mut(&diagram) .unwrap() .nodes .get_mut(&composite_node) .unwrap() .collapsed = true; let old_position = f.project.diagrams[&diagram].nodes[&composite_node].position; let before = f.project.clone(); let c = context(diagram); let at = Position { x: 430., y: 510. }; let plan = plan( &f.project, c, &Event::CreateAt { context: c, owner: f.nested, kind: Kind::Mode, position: at, }, ) .unwrap(); let mut document = Document::from_project(f.project, Default::default()).unwrap(); let source = document.bundle().clone(); document.execute(plan.command).unwrap(); assert_eq!(document.revision(), 1); let p = document.project(); assert_eq!(p.elements[&plan.selected].parent, Some(f.nested)); assert_eq!( p.diagrams[&diagram].nodes[&plan.placement.unwrap()].position, at ); assert_eq!( p.diagrams[&diagram].nodes[&composite_node].position, old_position ); assert!(!p.diagrams[&diagram].nodes[&composite_node].collapsed); for id in [f.machine, f.region, f.composite, f.nested, plan.selected] { assert!( projection::project_view(p, diagram) .nodes .iter() .any(|n| n.element == id) ); } assert_eq!(p.elements.len(), before.elements.len() + 1); assert!( !p.diagrams[&diagram] .nodes .values() .any(|n| n.element == f.leaf) ); assert!(document.undo()); assert_eq!(document.bundle(), &source); assert_eq!(document.project(), &before); } #[test] fn placing_existing_content_and_moving_a_folded_group_preserve_other_views_and_semantics() { let mut f = fixture(); let diagram = create(&mut f); let other = create(&mut f); let leaf_node = node(&f.project, diagram, f.leaf); f.project .diagrams .get_mut(&diagram) .unwrap() .nodes .remove(&leaf_node); assert!(unplaced(&f.project, diagram).contains(&f.leaf)); let c = context(diagram); let placement = plan( &f.project, c, &Event::Place { context: c, element: f.leaf, position: Position { x: 280., y: 460. }, }, ) .unwrap(); let semantics = f.project.elements.clone(); f.project = prepare_command(&f.project, &placement.command) .unwrap() .project; assert_eq!(f.project.elements, semantics); assert!(unplaced(&f.project, diagram).is_empty()); let edge = f .project .relate(RelationKind::StateTransition, "Retry", f.leaf, f.leaf); let pin = Position { x: 440., y: 540. }; f.project .diagrams .get_mut(&diagram) .unwrap() .routes .push(EdgeRoute { edge, waypoints: vec![pin], label: None, }); let composite = node(&f.project, diagram, f.composite); f.project .diagrams .get_mut(&diagram) .unwrap() .nodes .get_mut(&composite) .unwrap() .collapsed = true; let before = f.project.clone(); let old = before.diagrams[&diagram].nodes[&composite].position; let plan = plan( &f.project, c, &Event::Move { context: c, node: composite, position: Position { x: old.x + 40., y: old.y + 70., }, }, ) .unwrap(); assert_eq!(plan.selected, f.composite); assert_eq!(plan.placement, Some(composite)); let mut session = Session::try_new(f.project).unwrap(); session.execute(plan.command).unwrap(); let after = session.project(); for element in [f.composite, f.nested, f.leaf] { let id = node(&before, diagram, element); let old = before.diagrams[&diagram].nodes[&id].position; assert_eq!( after.diagrams[&diagram].nodes[&id].position, Position { x: old.x + 40., y: old.y + 70. } ); } assert_eq!(after.diagrams[&other], before.diagrams[&other]); assert_eq!(after.elements, before.elements); assert_eq!( after.diagrams[&diagram].routes[0].waypoints, vec![Position { x: pin.x + 40., y: pin.y + 70. }] ); session.undo(); assert_eq!(session.project(), &before); } #[test] fn every_context_component_and_invalid_ownership_are_checked_before_planning() { let mut f = fixture(); let diagram = create(&mut f); let c = context(diagram); let event = Event::CreateAt { context: c, owner: f.region, kind: Kind::State, position: Position { x: 200., y: 300. }, }; for current in [ Context { document: Id::new_v4(), ..c }, Context { diagram: Id::new_v4(), ..c }, Context { revision: c.revision + 1, ..c }, Context { epoch: c.epoch + 1, ..c }, ] { assert!(plan(&f.project, current, &event).is_err()); } let before = f.project.clone(); for (owner, kind) in [ (f.machine, Kind::State), (f.region, Kind::Region), (f.other_machine, Kind::Region), (f.region, Kind::Port), (f.function, Kind::Region), ] { assert!(!eligible_owner(&f.project, diagram, owner, kind)); assert!( plan( &f.project, c, &Event::CreateAt { context: c, owner, kind, position: Position { x: 20., y: 30. } } ) .is_err() ); } assert!( plan( &f.project, c, &Event::CreateAt { context: c, owner: f.region, kind: Kind::Initial, position: Position { x: 20., y: 30. } } ) .is_err(), "A second Initial cannot be partially created or placed" ); assert!( plan( &f.project, c, &Event::Move { context: c, node: node(&f.project, diagram, f.target), position: Position { x: f32::NAN, y: 0. } } ) .is_err() ); assert_eq!(f.project, before); f.project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview; assert!(validate_context(&f.project, c, c).is_err()); } #[test] fn connect_preserves_self_loops_and_rejects_hidden_or_incompatible_vertices() { let mut f = fixture(); let diagram = create(&mut f); let c = context(diagram); assert!(can_connect(&f.project, diagram, f.target, f.target)); let self_loop = plan( &f.project, c, &Event::Connect { context: c, source: f.target, target: f.target, }, ) .unwrap(); let after = prepare_command(&f.project, &self_loop.command) .unwrap() .project; assert!( matches!(after.relations[&self_loop.selected].body, RelationBody::StateTransition { source, target, transition_kind: TransitionKind::External, .. } if source == f.target && target == f.target) ); assert!( projection::project_view(&after, diagram) .edges .iter() .any(|edge| edge.id == self_loop.selected) ); assert!(!can_connect(&f.project, diagram, f.target, f.initial)); assert!( plan( &f.project, c, &Event::Connect { context: c, source: f.target, target: f.initial } ) .is_err() ); let composite = node(&f.project, diagram, f.composite); f.project .diagrams .get_mut(&diagram) .unwrap() .nodes .get_mut(&composite) .unwrap() .collapsed = true; assert!(!can_start(&f.project, diagram, f.leaf)); assert!( plan( &f.project, c, &Event::Connect { context: c, source: f.leaf, target: f.target } ) .is_err() ); } #[test] fn reconnect_preserves_the_complete_record_and_replaces_cardinality_in_place() { let mut f = fixture(); let diagram = create(&mut f); let c = context(diagram); let trigger = add(&mut f.project, Kind::Event, "Mode requested", None); let edge = f.project.relate( RelationKind::StateTransition, "Named transition", f.composite, f.target, ); let r = f.project.relations.get_mut(&edge).unwrap(); r.description = "Reviewed behavior rationale".into(); if let RelationBody::StateTransition { triggers, trigger: notes, guard, effects, effect, .. } = &mut r.body { triggers.push(trigger); *notes = "request notes".into(); *guard = "ready".into(); effects.push(f.function); *effect = "retain this effect prose".into(); } let artifact = Id::new_v4(); f.project.artifact_links.insert( artifact, ArtifactLink { id: artifact, alias: "transition_review".into(), name: "Review".into(), description: String::new(), owner: edge, relation: ArtifactRelation::References, uri: "https://example.com/review".into(), revision: Some("2".into()), fragment: None, }, ); let original = f.project.relations[&edge].clone(); let reconnected = plan( &f.project, c, &Event::Reconnect { context: c, relation: edge, endpoint: Endpoint::Target, vertex: f.composite, }, ) .unwrap(); let after = prepare_command(&f.project, &reconnected.command) .unwrap() .project; let mut expected = original.clone(); if let RelationBody::StateTransition { target, .. } = &mut expected.body { *target = f.composite; } assert_eq!(after.relations[&edge], expected); assert_eq!(after.artifact_links, f.project.artifact_links); assert_eq!(f.project.relations[&edge], original); let initial_edge = f.project.relate( RelationKind::StateTransition, "Initial choice", f.initial, f.composite, ); assert!(can_reconnect( &f.project, diagram, initial_edge, Endpoint::Target, f.target )); let replacement = plan( &f.project, c, &Event::Reconnect { context: c, relation: initial_edge, endpoint: Endpoint::Target, vertex: f.target, }, ) .unwrap(); let after = prepare_command(&f.project, &replacement.command) .unwrap() .project; assert_eq!(after.relations[&initial_edge].target(), f.target); assert_eq!(after.relations.len(), f.project.relations.len()); assert!(!can_reconnect( &f.project, diagram, initial_edge, Endpoint::Target, f.initial )); let internal = f.project.relate( RelationKind::StateTransition, "Internal work", f.target, f.target, ); if let RelationBody::StateTransition { transition_kind, .. } = &mut f.project.relations.get_mut(&internal).unwrap().body { *transition_kind = TransitionKind::Internal; } assert!( !can_reconnect(&f.project, diagram, internal, Endpoint::Target, f.composite), "Reconnect does not convert Internal to External or rewrite its other endpoint" ); } }