From 7750ecd7510ae39e4170f37e4aa7e101cf392f66 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Thu, 24 Sep 2026 21:24:43 -0700 Subject: [PATCH] Plan whole saved-view model deletion from exact visible groups --- crates/oa-desktop/src/diagram.rs | 2 + .../oa-desktop/src/diagram/group_removal.rs | 152 +++ .../group_removal/conditional_tests.rs | 302 ++++++ .../src/diagram/group_removal/tests.rs | 896 ++++++++++++++++++ docs/language.rst | 22 + .../saved-group-removal-policy-20260925.md | 116 +++ 6 files changed, 1490 insertions(+) create mode 100644 crates/oa-desktop/src/diagram/group_removal.rs create mode 100644 crates/oa-desktop/src/diagram/group_removal/conditional_tests.rs create mode 100644 crates/oa-desktop/src/diagram/group_removal/tests.rs create mode 100644 harness/saved-group-removal-policy-20260925.md diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index 464c085b..6f023afe 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -43,6 +43,8 @@ pub use port_labels::PortLabels; #[path = "diagram/focus.rs"] pub(crate) mod focus; pub(crate) mod group_gesture; +#[cfg(test)] +pub(crate) mod group_removal; mod multiselect; #[cfg(test)] mod multiselect_tests; diff --git a/crates/oa-desktop/src/diagram/group_removal.rs b/crates/oa-desktop/src/diagram/group_removal.rs new file mode 100644 index 00000000..f6bcc43d --- /dev/null +++ b/crates/oa-desktop/src/diagram/group_removal.rs @@ -0,0 +1,152 @@ +//! Pure, whole-selection Delete from model planning for saved architecture and +//! State views. UI lifetime, drafts and confirmation remain the caller's concern. +use crate::diagram_usage::Target; +use oa_core::{ + Command, Id, Kind, Project, execute_batch, projection, references, validate_candidate, +}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug)] +pub(crate) struct Plan { + pub(crate) command: Command, + /// Semantic identities resolved from every admitted exact target. + pub(crate) selected: BTreeSet, + /// Actual identity and surviving-record differences after the complete batch. + pub(crate) removed: BTreeSet, + pub(crate) changed: BTreeSet, +} + +pub(crate) fn prepare(project: &Project, diagram: Id, targets: &[Target]) -> Result { + let saved = project + .diagrams + .get(&diagram) + .ok_or("The saved view no longer exists.")?; + if !saved.projection.is_architecture() && saved.projection.state_machine().is_none() { + return Err("Delete from model supports saved Architecture and State views here.".into()); + } + if targets.is_empty() { + return Err("Select at least one visible model object to delete.".into()); + } + validate_candidate(project).map_err(|error| error.to_string())?; + let view = projection::project_diagram(project, saved); + let mut selected = BTreeSet::new(); + // Resolve every original typed identity BEFORE removing any cascade overlap. + // A stale/hidden/wrong-kind target cannot become admissible because another + // selection member would happen to delete its semantic identity. + for (index, target) in targets.iter().enumerate() { + if targets[..index].contains(target) { + return Err("Every selected exact target must be unique.".into()); + } + let id = match target { + Target::Placement(id) => saved.nodes.get(id) + .filter(|node| view.nodes.iter().any(|visible| visible.id == *id && visible.element == node.element)) + .map(|node| node.element) + .ok_or("A selected placement is no longer visible in this saved view.")?, + Target::Port { port, placement } => { + let owner = saved.nodes.get(placement) + .ok_or("A selected port's exact owner placement no longer exists in this view.")?; + if !view.ports.iter().any(|visible| visible.element == *port && visible.owner == *placement) + || !project.elements.get(port).is_some_and(|element| element.kind() == Kind::Port && element.parent == Some(owner.element)) + { + return Err("A selected port is no longer visible on its exact owner placement.".into()); + } + *port + } + Target::Edge(id) => { + if !view.edges.iter().any(|edge| edge.id == *id) + || !(project.relations.contains_key(id) || project.traces.contains_key(id)) + { + return Err("Every selected edge must be a visible stored relation or trace; inferred edges are read-only.".into()); + } + *id + } + Target::StateRow { placement, relation } => { + if !crate::behavior::state_canvas::visible_row_target(project, diagram, &view, *placement, *relation) { + return Err("A selected State row is no longer visible on its exact placement.".into()); + } + *relation + } + _ => return Err("The complete selection must contain only visible placements, ports, stored edges or State rows; grouping and read-only targets cannot be deleted here.".into()), + }; + selected.insert(id); + } + + // Prefer cascades covering the most remaining selected subjects. Selecting a + // parent together with its child/port/incident relation must not issue Delete + // for an identity already removed by that parent. Stable ID order breaks ties. + // Core defers conditional exchange-allocation cleanup until batch completion; + // admission and the final result are therefore never singleton-validated. + let impacts: BTreeMap<_, _> = selected + .iter() + .map(|&id| (id, references::deletion_impact(project, id))) + .collect(); + let mut pending = selected.clone(); + let mut commands = Vec::new(); + while !pending.is_empty() { + let id = *pending + .iter() + .max_by_key(|&&id| { + ( + impacts[&id].intersection(&pending).count(), + std::cmp::Reverse(id), + ) + }) + .unwrap(); + commands.push(Command::Delete(id)); + pending.retain(|remaining| !impacts[&id].contains(remaining)); + } + let command = Command::Batch(commands); + let candidate = execute_batch(project, std::slice::from_ref(&command)) + .map_err(|error| error.to_string())?; + let original: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect(); + let retained: BTreeSet<_> = candidate + .identities() + .into_iter() + .map(|(id, _)| id) + .collect(); + if !retained.is_subset(&original) { + return Err("Removal unexpectedly created model identities; no change was applied.".into()); + } + let removed: BTreeSet<_> = original.difference(&retained).copied().collect(); + if !selected.is_subset(&removed) { + return Err("The complete selected set could not be removed.".into()); + } + let mut changed = BTreeSet::new(); + changed_records(&project.elements, &candidate.elements, &mut changed); + changed_records(&project.relations, &candidate.relations, &mut changed); + changed_records(&project.diagrams, &candidate.diagrams, &mut changed); + changed_records(&project.traces, &candidate.traces, &mut changed); + changed_records( + &project.artifact_links, + &candidate.artifact_links, + &mut changed, + ); + for (&id, diagram) in &project.diagrams { + if let Some(after) = candidate.diagrams.get(&id) { + changed_records(&diagram.nodes, &after.nodes, &mut changed); + } + } + Ok(Plan { + command, + selected, + removed, + changed, + }) +} + +fn changed_records( + before: &BTreeMap, + after: &BTreeMap, + changed: &mut BTreeSet, +) { + changed.extend( + before + .iter() + .filter_map(|(&id, record)| after.get(&id).filter(|next| *next != record).map(|_| id)), + ); +} + +#[cfg(test)] +mod conditional_tests; +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/diagram/group_removal/conditional_tests.rs b/crates/oa-desktop/src/diagram/group_removal/conditional_tests.rs new file mode 100644 index 00000000..7df7c78f --- /dev/null +++ b/crates/oa-desktop/src/diagram/group_removal/conditional_tests.rs @@ -0,0 +1,302 @@ +//! Independent conditional-cascade checks for overlapping selected endpoint allocations. +use super::prepare; +use crate::diagram_usage::Target; +use oa_core::*; +use std::collections::BTreeSet; + +struct Fixture { + project: Project, + diagram: Id, + selected_allocations: [Id; 2], + exchange_allocation: Id, + functional_exchange: Id, +} + +fn relation(project: &mut Project, number: u128, alias: &str, body: RelationBody) -> Id { + let id = Id::from_u128(number); + assert!( + project + .relations + .insert( + id, + Relation { + id, + alias: alias.into(), + name: alias.into(), + description: String::new(), + body, + } + ) + .is_none() + ); + id +} + +fn fixture(reverse_ids: bool, invalid_dual_mapping: bool) -> Fixture { + let mut project = Project::empty(); + let performer = project + .add_element(Kind::System, Layer::Sa, "Send performer", None) + .unwrap(); + let receiver = if invalid_dual_mapping { + performer + } else { + project + .add_element(Kind::SystemActor, Layer::Sa, "Receive performer", None) + .unwrap() + }; + let source = project + .add_element(Kind::Function, Layer::Sa, "Send", None) + .unwrap(); + let target = project + .add_element(Kind::Function, Layer::Sa, "Receive", None) + .unwrap(); + let retained = project.relate(RelationKind::Allocation, "Performs send", performer, source); + project.relate( + RelationKind::Allocation, + "Performs receive", + receiver, + target, + ); + let functional_exchange = project.relate( + RelationKind::FunctionalExchange, + "Information", + source, + target, + ); + let (output, input) = match project.relations[&functional_exchange].body { + RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } => (source_port, target_port), + _ => unreachable!(), + }; + let mut component_ports = vec![]; + for (owner, name) in [ + (performer, "First component port"), + (receiver, "Second component port"), + ] { + let id = Id::new_v4(); + let port = make_port( + &project, + id, + owner, + name, + PortContract::Component { + kind: ComponentPortKind::Flow, + orientation: ComponentPortOrientation::InOut, + }, + ) + .unwrap(); + project.elements.insert(id, port); + component_ports.push(id); + } + let [a, b] = [component_ports[0], component_ports[1]]; + let boundary = relation( + &mut project, + 300, + "boundary", + RelationBody::BoundaryExchange { + source: a, + target: b, + oriented: false, + items: vec![], + }, + ); + // Either endpoint allocation is required. Their overlapping conditional + // cascade must remove the exchange allocation once, after one whole batch. + let numbers = if reverse_ids { [200, 100] } else { [100, 200] }; + let first = relation( + &mut project, + numbers[0], + "first_output", + RelationBody::PortAllocation { + source: a, + target: output, + }, + ); + let second = relation( + &mut project, + numbers[1], + "second_input", + RelationBody::PortAllocation { + source: b, + target: input, + }, + ); + if invalid_dual_mapping { + // Two simultaneous direct/reverse mappings would need the same actual + // performer at both ends, violating the different-boundary-owners rule. + relation( + &mut project, + 400, + "first_input", + RelationBody::PortAllocation { + source: a, + target: input, + }, + ); + relation( + &mut project, + 500, + "second_output", + RelationBody::PortAllocation { + source: b, + target: output, + }, + ); + } + let exchange_allocation = relation( + &mut project, + 150, + "realizes_exchange", + RelationBody::ExchangeAllocation { + source: boundary, + target: functional_exchange, + }, + ); + let diagram = project.ensure_diagram(Layer::Sa); + project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview; + for (element, x) in [ + (performer, 40.), + (receiver, 1360.), + (source, 480.), + (target, 920.), + ] { + project.place(diagram, element, Position { x, y: 200. }); + } + project.diagrams.get_mut(&diagram).unwrap().routes = [first, retained] + .into_iter() + .map(|edge| EdgeRoute { + edge, + source_attachment: None, + target_attachment: None, + waypoints: vec![Position { x: 360., y: 350. }], + label: None, + }) + .collect(); + Fixture { + project, + diagram, + selected_allocations: [first, second], + exchange_allocation, + functional_exchange, + } +} + +#[test] +fn overlapping_conditional_allocation_loss_is_reviewed_from_final_candidate() { + for reverse_ids in [false, true] { + let f = fixture(reverse_ids, false); + validate_candidate(&f.project) + .expect("Separate actual performers and required endpoint mappings are valid"); + for selected in f.selected_allocations { + assert!( + references::deletion_impact(&f.project, selected).contains(&f.exchange_allocation), + "Both selected allocations overlap on the same conditional dependent" + ); + let singleton = execute_batch(&f.project, &[Command::Delete(selected)]).unwrap(); + assert!(!singleton.relations.contains_key(&f.exchange_allocation)); + } + let targets = f.selected_allocations.map(Target::Edge); + let plan = prepare(&f.project, f.diagram, &targets).unwrap(); + let reversed = prepare( + &f.project, + f.diagram, + &[targets[1].clone(), targets[0].clone()], + ) + .unwrap(); + assert_eq!( + serde_json::to_value(&plan.command).unwrap(), + serde_json::to_value(&reversed.command).unwrap(), + "Click order must not change normalization" + ); + let removed = BTreeSet::from([ + f.selected_allocations[0], + f.selected_allocations[1], + f.exchange_allocation, + ]); + assert_eq!(plan.selected, BTreeSet::from(f.selected_allocations)); + assert_eq!(plan.removed, removed); + assert_eq!(plan.changed, BTreeSet::from([f.diagram])); + // Literal record oracle, independent of deletion_impact / planner output. + let mut expected = f.project.clone(); + for id in removed { + expected.relations.remove(&id); + } + expected + .diagrams + .get_mut(&f.diagram) + .unwrap() + .routes + .remove(0); + let actual = execute_batch(&f.project, &[plan.command]).unwrap(); + assert_eq!( + actual, expected, + "Only two port allocations, their conditional exchange allocation and the deleted edge's authored route change" + ); + } +} + +#[test] +fn direct_exchange_cascade_and_conditional_support_loss_share_one_batch() { + for reverse_ids in [false, true] { + let f = fixture(reverse_ids, false); + validate_candidate(&f.project) + .expect("Separate actual performers and required endpoint mappings are valid"); + let targets = [ + Target::Edge(f.selected_allocations[0]), + Target::Edge(f.functional_exchange), + Target::Edge(f.selected_allocations[1]), + ]; + let plan = prepare(&f.project, f.diagram, &targets).unwrap(); + let removed = BTreeSet::from([ + f.selected_allocations[0], + f.selected_allocations[1], + f.functional_exchange, + f.exchange_allocation, + ]); + assert_eq!( + plan.selected, + BTreeSet::from([ + f.selected_allocations[0], + f.selected_allocations[1], + f.functional_exchange + ]) + ); + assert_eq!(plan.removed, removed); + assert_eq!(plan.changed, BTreeSet::from([f.diagram])); + let mut expected = f.project.clone(); + for id in removed { + expected.relations.remove(&id); + } + expected + .diagrams + .get_mut(&f.diagram) + .unwrap() + .routes + .remove(0); + assert_eq!( + execute_batch(&f.project, &[plan.command]).unwrap(), + expected + ); + } +} + +#[test] +fn alternative_pairing_counterexample_is_not_a_valid_saved_model() { + let f = fixture(false, true); + assert!(validation::validate(&f.project).iter().any( + |issue| issue.code == "boundary_owner" && issue.severity == validation::Severity::Error + )); + let before = f.project.clone(); + assert!( + prepare( + &f.project, + f.diagram, + &f.selected_allocations.map(Target::Edge) + ) + .is_err() + ); + assert_eq!(f.project, before); +} diff --git a/crates/oa-desktop/src/diagram/group_removal/tests.rs b/crates/oa-desktop/src/diagram/group_removal/tests.rs new file mode 100644 index 00000000..d1f8a689 --- /dev/null +++ b/crates/oa-desktop/src/diagram/group_removal/tests.rs @@ -0,0 +1,896 @@ +use super::*; +use oa_core::*; + +fn pos(x: f32, y: f32) -> Position { + Position { x, y } +} + +fn diagram(project: &mut Project, layer: Layer, projection: ProjectionKind) -> Id { + let id = Id::new_v4(); + project.diagrams.insert( + id, + Diagram { + id, + alias: format!("view_{}", id.simple()), + name: "Saved view".into(), + description: String::new(), + layer, + projection, + view_options: Default::default(), + nodes: BTreeMap::new(), + routes: vec![], + layout: None, + }, + ); + id +} + +fn artifact(project: &mut Project, owner: Id) -> Id { + let id = Id::new_v4(); + project.artifact_links.insert( + id, + ArtifactLink { + id, + alias: format!("evidence_{}", id.simple()), + name: "Evidence".into(), + description: String::new(), + owner, + relation: ArtifactRelation::References, + uri: "docs/evidence.md".into(), + revision: Some("r1".into()), + fragment: None, + }, + ); + id +} + +fn route(edge: Id) -> EdgeRoute { + EdgeRoute { + edge, + source_attachment: None, + target_attachment: None, + waypoints: vec![pos(450., 150.)], + label: None, + } +} + +/// Every success compares the whole resulting Project with a separately authored +/// expected value; the planner's own impact calculation is never the oracle. +fn check( + project: &Project, + view: Id, + targets: &[Target], + expected: &Project, + selected: &[Id], + removed: &[Id], + changed: &[Id], +) -> Plan { + validate_candidate(project).unwrap(); + validate_candidate(expected).unwrap(); + let original = project.clone(); + let plan = prepare(project, view, targets).unwrap(); + assert_eq!(project, &original, "planning must be pure"); + assert_eq!(plan.selected, selected.iter().copied().collect()); + assert_eq!(plan.removed, removed.iter().copied().collect()); + assert_eq!(plan.changed, changed.iter().copied().collect()); + assert_eq!( + execute_batch(project, std::slice::from_ref(&plan.command)).unwrap(), + *expected + ); + assert!( + matches!(&plan.command, Command::Batch(commands) if commands.iter().all(|command| matches!(command, Command::Delete(_)))) + ); + plan +} + +#[test] +fn architecture_parent_child_and_incident_edge_collapse_to_one_delete_across_views() { + for layer in [Layer::Oa, Layer::Sa] { + let mut project = Project::empty(); + let kind = if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + let parent = project.add_element(kind, layer, "Parent", None).unwrap(); + let child = project + .add_element(kind, layer, "Child", Some(parent)) + .unwrap(); + let survivor = project.add_element(kind, layer, "Survivor", None).unwrap(); + let edge_kind = if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }; + let edge = project.relate(edge_kind, "Incident", child, survivor); + let ports = match project.relations[&edge].body { + RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } => Some((source_port, target_port)), + _ => None, + }; + let evidence = artifact(&mut project, child); + let view = diagram(&mut project, layer, ProjectionKind::Overview); + let parent_node = project.place(view, parent, pos(0., 0.)); + let child_node = project.place(view, child, pos(30., 80.)); + project.place(view, survivor, pos(800., 100.)); + project + .diagrams + .get_mut(&view) + .unwrap() + .routes + .push(route(edge)); + let other_view = diagram(&mut project, layer, ProjectionKind::Overview); + let other_child = project.place(other_view, child, pos(100., 100.)); + project.place(other_view, survivor, pos(800., 100.)); + project + .diagrams + .get_mut(&other_view) + .unwrap() + .view_options + .hidden_edges + .push(edge); + let mut expected = project.clone(); + expected.elements.remove(&parent); + expected.elements.remove(&child); + expected.relations.remove(&edge); + expected.artifact_links.remove(&evidence); + let mut removed = vec![ + parent, + child, + edge, + evidence, + parent_node, + child_node, + other_child, + ]; + if let Some((source_port, _)) = ports { + expected.elements.remove(&source_port); + removed.push(source_port); + } + let current = expected.diagrams.get_mut(&view).unwrap(); + current.nodes.remove(&parent_node); + current.nodes.remove(&child_node); + current.routes.clear(); + let other = expected.diagrams.get_mut(&other_view).unwrap(); + other.nodes.remove(&other_child); + other.view_options.hidden_edges.clear(); + for targets in [ + vec![ + Target::Placement(parent_node), + Target::Placement(child_node), + Target::Edge(edge), + ], + vec![ + Target::Edge(edge), + Target::Placement(child_node), + Target::Placement(parent_node), + ], + ] { + let plan = check( + &project, + view, + &targets, + &expected, + &[parent, child, edge], + &removed, + &[view, other_view], + ); + assert!( + matches!(plan.command, Command::Batch(commands) if matches!(commands.as_slice(), [Command::Delete(id)] if *id == parent)) + ); + } + } +} + +#[test] +fn port_owner_port_and_exchange_admission_preserve_other_endpoint_and_nested_anchor_diff() { + let mut project = Project::empty(); + let a = project + .add_element(Kind::Function, Layer::Sa, "Send", None) + .unwrap(); + let b = project + .add_element(Kind::Function, Layer::Sa, "Receive", None) + .unwrap(); + let edge = project.relate(RelationKind::FunctionalExchange, "Payload", a, b); + let RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } = project.relations[&edge].body + else { + unreachable!() + }; + let view = diagram(&mut project, Layer::Sa, ProjectionKind::FunctionalDataflow); + let na = project.place(view, a, pos(0., 0.)); + let nb = project.place(view, b, pos(700., 0.)); + project + .diagrams + .get_mut(&view) + .unwrap() + .nodes + .get_mut(&na) + .unwrap() + .port_anchors + .push(PortAnchor { + port: source_port, + side: PortAnchorSide::Top, + offset: 0.3, + }); + project + .diagrams + .get_mut(&view) + .unwrap() + .routes + .push(route(edge)); + + let mut port_expected = project.clone(); + port_expected.elements.remove(&source_port); + port_expected.relations.remove(&edge); + let d = port_expected.diagrams.get_mut(&view).unwrap(); + d.routes.clear(); + d.nodes.get_mut(&na).unwrap().port_anchors.clear(); + check( + &project, + view, + &[ + Target::Port { + port: source_port, + placement: na, + }, + Target::Edge(edge), + ], + &port_expected, + &[source_port, edge], + &[source_port, edge], + &[view, na], + ); + + let mut owner_expected = port_expected; + owner_expected.elements.remove(&a); + owner_expected + .diagrams + .get_mut(&view) + .unwrap() + .nodes + .remove(&na); + let plan = check( + &project, + view, + &[ + Target::Port { + port: source_port, + placement: na, + }, + Target::Placement(na), + Target::Edge(edge), + ], + &owner_expected, + &[a, source_port, edge], + &[a, source_port, edge, na], + &[view], + ); + assert!( + owner_expected.elements.contains_key(&b) + && owner_expected.elements.contains_key(&target_port) + ); + assert!(owner_expected.diagrams[&view].nodes.contains_key(&nb)); + assert!(matches!(plan.command, Command::Batch(commands) if commands.len() == 1)); + // A wrong owner placement is invalid even though the selected owner covers it. + assert!( + prepare( + &project, + view, + &[ + Target::Placement(na), + Target::Port { + port: source_port, + placement: nb + } + ] + ) + .is_err() + ); +} + +#[test] +fn state_rows_delete_relations_not_referenced_work_and_overlap_with_state_cascade() { + for layer in [Layer::Oa, Layer::Sa] { + let mut project = Project::empty(); + let machine = project + .add_element(Kind::StateMachine, layer, "Modes", None) + .unwrap(); + let region = project + .add_element(Kind::Region, layer, "Main", Some(machine)) + .unwrap(); + let state = project + .add_element(Kind::State, layer, "Ready", Some(region)) + .unwrap(); + let other = project + .add_element(Kind::State, layer, "Other", Some(region)) + .unwrap(); + let work = project + .add_element( + if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }, + layer, + "Work", + None, + ) + .unwrap(); + let action = project.relate(RelationKind::StateAction, "Entry", state, work); + let internal = project.relate(RelationKind::StateTransition, "Internal", state, state); + if let RelationBody::StateTransition { + transition_kind, + effects, + .. + } = &mut project.relations.get_mut(&internal).unwrap().body + { + *transition_kind = TransitionKind::Internal; + *effects = vec![work]; + } + let external = project.relate(RelationKind::StateTransition, "External", state, other); + let view = diagram( + &mut project, + layer, + ProjectionKind::StateMachine { machine }, + ); + let state_node = project.place(view, state, pos(100., 100.)); + let other_node = project.place(view, other, pos(700., 100.)); + let rows = [ + Target::StateRow { + placement: state_node, + relation: action, + }, + Target::StateRow { + placement: state_node, + relation: internal, + }, + ]; + let mut expected = project.clone(); + expected.relations.remove(&action); + expected.relations.remove(&internal); + check( + &project, + view, + &rows, + &expected, + &[action, internal], + &[action, internal], + &[], + ); + expected.relations.remove(&external); + expected.elements.remove(&state); + expected + .diagrams + .get_mut(&view) + .unwrap() + .nodes + .remove(&state_node); + let mut targets = rows.to_vec(); + targets.extend([Target::Placement(state_node), Target::Edge(external)]); + check( + &project, + view, + &targets, + &expected, + &[action, internal, state, external], + &[action, internal, state, external, state_node], + &[view], + ); + assert_eq!(expected.elements[&work], project.elements[&work]); + assert!( + prepare( + &project, + view, + &[ + Target::Placement(state_node), + Target::StateRow { + placement: other_node, + relation: action + } + ] + ) + .is_err() + ); + assert!(prepare(&project, view, &[Target::Edge(internal)]).is_err()); + assert!( + prepare( + &project, + view, + &[Target::StateRow { + placement: state_node, + relation: external + }] + ) + .is_err() + ); + let region_node = project.place(view, region, pos(0., 0.)); + project + .diagrams + .get_mut(&view) + .unwrap() + .nodes + .get_mut(®ion_node) + .unwrap() + .collapsed = true; + assert!( + prepare( + &project, + view, + &[ + Target::Placement(region_node), + Target::StateRow { + placement: state_node, + relation: action + } + ] + ) + .is_err(), + "hidden State row cannot borrow its selected ancestor's permission" + ); + } +} + +#[test] +fn deleting_machine_cascades_saved_diagram_and_its_embedded_placements() { + let mut project = Project::empty(); + let machine = project + .add_element(Kind::StateMachine, Layer::Sa, "Modes", None) + .unwrap(); + let region = project + .add_element(Kind::Region, Layer::Sa, "Main", Some(machine)) + .unwrap(); + let state = project + .add_element(Kind::State, Layer::Sa, "Ready", Some(region)) + .unwrap(); + let view = diagram( + &mut project, + Layer::Sa, + ProjectionKind::StateMachine { machine }, + ); + let machine_node = project.place(view, machine, pos(0., 0.)); + let region_node = project.place(view, region, pos(30., 90.)); + let state_node = project.place(view, state, pos(60., 180.)); + let evidence = artifact(&mut project, view); + let architecture = diagram(&mut project, Layer::Sa, ProjectionKind::Overview); + let architecture_node = project.place(architecture, machine, pos(0., 0.)); + let mut expected = project.clone(); + for id in [machine, region, state] { + expected.elements.remove(&id); + } + expected.diagrams.remove(&view); + expected.artifact_links.remove(&evidence); + expected + .diagrams + .get_mut(&architecture) + .unwrap() + .nodes + .remove(&architecture_node); + check( + &project, + view, + &[ + Target::Placement(machine_node), + Target::Placement(state_node), + ], + &expected, + &[machine, state], + &[ + machine, + region, + state, + view, + machine_node, + region_node, + state_node, + evidence, + architecture_node, + ], + &[architecture], + ); +} + +#[test] +fn trace_edge_and_endpoint_deletion_preserve_counterpart_unless_explicitly_selected() { + let mut project = Project::empty(); + let oa = project + .add_element(Kind::Activity, Layer::Oa, "Operational", None) + .unwrap(); + let sa = project + .add_element(Kind::Function, Layer::Sa, "System", None) + .unwrap(); + let trace = Id::new_v4(); + project.traces.insert( + trace, + Trace { + id: trace, + alias: "refinement".into(), + name: "Refinement".into(), + description: String::new(), + oa, + sa, + kind: TraceKind::Refinement, + baseline: None, + }, + ); + let evidence = artifact(&mut project, trace); + let view = diagram(&mut project, Layer::Oa, ProjectionKind::Traceability); + let noa = project.place(view, oa, pos(0., 0.)); + let nsa = project.place(view, sa, pos(700., 0.)); + project + .diagrams + .get_mut(&view) + .unwrap() + .routes + .push(route(trace)); + let mut expected = project.clone(); + expected.traces.remove(&trace); + expected.artifact_links.remove(&evidence); + expected.diagrams.get_mut(&view).unwrap().routes.clear(); + check( + &project, + view, + &[Target::Edge(trace)], + &expected, + &[trace], + &[trace, evidence], + &[view], + ); + expected.elements.remove(&oa); + expected.diagrams.get_mut(&view).unwrap().nodes.remove(&noa); + check( + &project, + view, + &[Target::Placement(noa), Target::Edge(trace)], + &expected, + &[oa, trace], + &[oa, noa, trace, evidence], + &[view], + ); + assert_eq!(expected.elements[&sa], project.elements[&sa]); + expected.elements.remove(&sa); + expected.diagrams.get_mut(&view).unwrap().nodes.remove(&nsa); + check( + &project, + view, + &[ + Target::Placement(noa), + Target::Placement(nsa), + Target::Edge(trace), + ], + &expected, + &[oa, sa, trace], + &[oa, noa, sa, nsa, trace, evidence], + &[view], + ); +} + +#[test] +fn surviving_semantic_records_report_reference_pruning_as_changed() { + let mut project = Project::empty(); + let work = project + .add_element(Kind::Function, Layer::Sa, "Work", None) + .unwrap(); + let machine = project + .add_element(Kind::StateMachine, Layer::Sa, "Modes", None) + .unwrap(); + let region = project + .add_element(Kind::Region, Layer::Sa, "Main", Some(machine)) + .unwrap(); + let a = project + .add_element(Kind::State, Layer::Sa, "A", Some(region)) + .unwrap(); + let b = project + .add_element(Kind::State, Layer::Sa, "B", Some(region)) + .unwrap(); + let transition = project.relate(RelationKind::StateTransition, "Advance", a, b); + if let RelationBody::StateTransition { effects, .. } = + &mut project.relations.get_mut(&transition).unwrap().body + { + *effects = vec![work]; + } + let chain = project + .add_element(Kind::FunctionalChain, Layer::Sa, "Chain", None) + .unwrap(); + let occurrence = project + .add_element(Kind::Occurrence, Layer::Sa, "Action", Some(chain)) + .unwrap(); + if let Some(SharedBody::Occurrence { body, .. }) = + project.elements.get_mut(&occurrence).unwrap().shared_mut() + { + *body = OccurrenceBody::Action { + action: Some(work), + lifeline: None, + }; + } + project + .elements + .get_mut(&chain) + .unwrap() + .steps_mut() + .unwrap() + .push(occurrence); + let view = diagram(&mut project, Layer::Sa, ProjectionKind::Overview); + let placement = project.place(view, work, pos(100., 100.)); + let mut expected = project.clone(); + expected.elements.remove(&work); + expected + .diagrams + .get_mut(&view) + .unwrap() + .nodes + .remove(&placement); + if let RelationBody::StateTransition { effects, .. } = + &mut expected.relations.get_mut(&transition).unwrap().body + { + effects.clear(); + } + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Action { action, .. }, + .. + }) = expected.elements.get_mut(&occurrence).unwrap().shared_mut() + { + *action = None; + } + check( + &project, + view, + &[Target::Placement(placement)], + &expected, + &[work], + &[work, placement], + &[view, occurrence, transition], + ); +} + +#[test] +fn valid_union_is_not_rejected_by_an_invalid_intermediate_singleton() { + let mut project = Project::empty(); + let sender = project + .add_element(Kind::SystemActor, Layer::Sa, "Sender", None) + .unwrap(); + let receiver = project + .add_element(Kind::System, Layer::Sa, "Receiver", None) + .unwrap(); + let mut ports = vec![]; + for owner in [sender, receiver] { + let id = Id::new_v4(); + let port = make_port( + &project, + id, + owner, + "Service port", + PortContract::Component { + kind: ComponentPortKind::Standard, + orientation: ComponentPortOrientation::Unset, + }, + ) + .unwrap(); + project.elements.insert(id, port); + ports.push(id); + } + // Ensure the planner's deterministic tie break orders this invalid-alone + // deletion first; a singleton transaction implementation would fail here. + let protocol = Id::from_u128(1); + let mut protocol_element = project + .make_element(Kind::Interface, Layer::Sa, "Payload protocol", None) + .unwrap(); + protocol_element.id = protocol; + project.elements.insert(protocol, protocol_element); + let empty_protocol = project + .add_element(Kind::Interface, Layer::Sa, "Other protocol", None) + .unwrap(); + let item = project + .add_element(Kind::ExchangeItem, Layer::Sa, "Payload", None) + .unwrap(); + let allocation = project.relate( + RelationKind::InterfaceItemAllocation, + "Declares payload", + protocol, + item, + ); + let role = project.relate( + RelationKind::InterfaceRequirement, + "Requires payload", + ports[0], + protocol, + ); + project.relate( + RelationKind::InterfaceRequirement, + "Requires other", + ports[0], + empty_protocol, + ); + let boundary = project.relate( + RelationKind::BoundaryExchange, + "Channel", + ports[0], + ports[1], + ); + if let RelationBody::BoundaryExchange { items, .. } = + &mut project.relations.get_mut(&boundary).unwrap().body + { + *items = vec![item]; + } + let view = diagram(&mut project, Layer::Sa, ProjectionKind::Overview); + project.place(view, sender, pos(0., 0.)); + project.place(view, receiver, pos(800., 0.)); + let protocol_node = project.place(view, protocol, pos(400., 300.)); + project + .diagrams + .get_mut(&view) + .unwrap() + .routes + .push(route(boundary)); + validate_candidate(&project).unwrap(); + // The remaining interface declaration becomes an empty constraint on the + // live channel. This is an invalid intermediate state, but deleting the + // selected channel in the SAME batch makes the complete intent valid. + assert!(execute_batch(&project, &[Command::Delete(protocol)]).is_err()); + assert!(prepare(&project, view, &[Target::Placement(protocol_node)]).is_err()); + let mut expected = project.clone(); + expected.elements.remove(&protocol); + for id in [allocation, role, boundary] { + expected.relations.remove(&id); + } + let d = expected.diagrams.get_mut(&view).unwrap(); + d.nodes.remove(&protocol_node); + d.routes.clear(); + for targets in [ + vec![Target::Placement(protocol_node), Target::Edge(boundary)], + vec![Target::Edge(boundary), Target::Placement(protocol_node)], + ] { + check( + &project, + view, + &targets, + &expected, + &[protocol, boundary], + &[protocol, protocol_node, allocation, role, boundary], + &[view], + ); + } +} + +#[test] +fn every_original_target_must_be_visible_local_supported_and_unique_even_when_covered() { + let mut project = Project::empty(); + let a = project + .add_element(Kind::Activity, Layer::Oa, "A", None) + .unwrap(); + let child = project + .add_element(Kind::Activity, Layer::Oa, "Child", Some(a)) + .unwrap(); + let b = project + .add_element(Kind::Activity, Layer::Oa, "B", None) + .unwrap(); + let edge = project.relate(RelationKind::Interaction, "Flow", child, b); + let view = diagram(&mut project, Layer::Oa, ProjectionKind::Overview); + let na = project.place(view, a, pos(0., 0.)); + let nc = project.place(view, child, pos(30., 90.)); + let nb = project.place(view, b, pos(700., 0.)); + project + .diagrams + .get_mut(&view) + .unwrap() + .nodes + .get_mut(&na) + .unwrap() + .collapsed = true; + project + .diagrams + .get_mut(&view) + .unwrap() + .view_options + .hidden_edges + .push(edge); + let other = diagram(&mut project, Layer::Oa, ProjectionKind::Overview); + let foreign = project.place(other, child, pos(0., 0.)); + validate_candidate(&project).unwrap(); + let original = project.clone(); + for inadmissible in [ + Target::Whole, + Target::Placement(nc), + Target::Placement(foreign), + Target::Placement(Id::new_v4()), + Target::Edge(edge), + Target::Edge(Id::new_v4()), + Target::Edge(a), + Target::Placement(a), + Target::Port { + port: child, + placement: na, + }, + Target::StateRow { + placement: na, + relation: edge, + }, + Target::AllocationGrouping { + relation: edge, + work_placement: nc, + performer_placement: nb, + }, + Target::ProcessLink(edge), + Target::ScenarioObject(child), + Target::ProcessOccurrence(OccurrenceEndpoint { + references: vec![], + occurrence: child, + }), + Target::ProcessPreviewLink { + relation: edge, + source: OccurrenceEndpoint { + references: vec![], + occurrence: a, + }, + target: OccurrenceEndpoint { + references: vec![], + occurrence: b, + }, + }, + ] { + assert!( + prepare( + &project, + view, + &[Target::Placement(na), inadmissible.clone()] + ) + .is_err(), + "accepted {inadmissible:?}" + ); + assert_eq!(project, original); + } + assert!(prepare(&project, view, &[]).is_err()); + assert!(prepare(&project, Id::new_v4(), &[Target::Placement(na)]).is_err()); + assert!( + prepare( + &project, + view, + &[Target::Placement(na), Target::Placement(na)] + ) + .is_err() + ); +} + +#[test] +fn scenario_surface_and_invalid_duplicate_placements_are_rejected_before_planning() { + let mut project = Project::empty(); + let activity = project + .add_element(Kind::Activity, Layer::Oa, "Work", None) + .unwrap(); + let view = diagram(&mut project, Layer::Oa, ProjectionKind::Overview); + let node = project.place(view, activity, pos(0., 0.)); + let scenario = project + .add_element(Kind::Scenario, Layer::Oa, "Scenario", None) + .unwrap(); + let scenario_view = diagram( + &mut project, + Layer::Oa, + ProjectionKind::Scenario { scenario }, + ); + assert!(prepare(&project, scenario_view, &[Target::Placement(node)]).is_err()); + let duplicate = Id::new_v4(); + let mut placement = project.diagrams[&view].nodes[&node].clone(); + placement.id = duplicate; + placement.alias = "duplicate_placement".into(); + project + .diagrams + .get_mut(&view) + .unwrap() + .nodes + .insert(duplicate, placement); + assert!( + prepare( + &project, + view, + &[Target::Placement(node), Target::Placement(duplicate)] + ) + .unwrap_err() + .contains("duplicate_placement") + ); +} diff --git a/docs/language.rst b/docs/language.rst index 8636ba5c..a0490da3 100644 --- a/docs/language.rst +++ b/docs/language.rst @@ -2279,6 +2279,28 @@ transition row, uses confirmed relation deletion for either key. States and referenced Activity/Function definitions remain. Hidden or collapsed rows are not keyboard deletion targets. +For a group in a saved architecture or state view, plain Delete retains its +placement-only Hide meaning; a mixture containing ports, edges or state rows +MUST refuse Hide rather than silently delete model objects. Explicit group +model deletion, including focused Shift+Delete, resolves each exact visible +placement to its semantic element, each port to that port, each stored edge to +its relation or trace, and each state row to its maintained relation. Navigation +groupings and inferred edges are not semantic deletion subjects. Every original +typed member MUST be admitted before redundant semantic subjects or cascade +commands are reduced. A selected ancestor does not make a hidden or invalid +member admissible. Existing project validity constraints, including placement +uniqueness within a diagram, remain in force. + +Saved-view aggregate review distinguishes selected visual targets, unique +semantic subjects, actual deleted records and changed surviving records. Model +deletion affects all placements of deleted elements in all views. A trace does +not cause deletion of its counterpart endpoints; an explicitly selected +counterpart remains an independent deletion subject. Removing a state action +row deletes its maintained relation, not the referenced Activity or Function. +The combined candidate and source update MUST form one atomic transaction; +neither partial primary-only deletion nor intermediate singleton history is +permitted. + Process and scenario projections have no independent occurrence placements. Delete and Shift+Delete request confirmed removal of the selected occurrence, participant or maintained link. A link's endpoints and outbound reusable diff --git a/harness/saved-group-removal-policy-20260925.md b/harness/saved-group-removal-policy-20260925.md new file mode 100644 index 00000000..cce7442a --- /dev/null +++ b/harness/saved-group-removal-policy-20260925.md @@ -0,0 +1,116 @@ +# Saved Architecture and State aggregate model removal + +Development policy under [issue 29](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/29), +derived from the [multiple-selection contract](multiselect-contract-20260923.md) +and `docs/language.rst` section 8.1. Implementation and verification must be +identified separately; this document does not establish App/native acceptance. + +## User operation and identity + +Keep **Hide selected placements** and plain Delete placement-only. An explicit +**Delete selected from model…** action, also available through focused +Shift+Delete, requests one semantic operation for the complete current group. +Adding a port, relation or State row must never turn plain Delete into model +deletion. Unsupported Hide mixtures explain the refusal without changing any +member. Existing singleton behavior remains governed by section 8.1. + +Every original typed target must be unique, structurally current and actually +visible in the captured saved projection before deduplication or cascade +normalization. A hidden, stale or unsupported member refuses the whole request, +even if a selected ancestor would otherwise delete it. Pointer focus, current +tool, revision, exact selection generation/primary and pending drafts remain +App admission requirements, beyond this pure model planner. + +| Selected visual target | Semantic deletion subject | Required identity evidence | +| --- | --- | --- | +| Placement | Its referenced model element | Exact placement belongs to captured diagram and current projected node | +| Port | The semantic port itself | Exact visible port and its owner placement, not a promoted owner node | +| Stored edge | Its relation or trace record | Exact visible edge with corresponding stored identity; inferred edges are not deletable objects | +| State action/internal-transition row | Its StateAction or StateTransition relation | Exact visible row and state placement, not its referenced activity/function | + +Whole-diagram targets, navigation-only Allocation groupings, Process/Scenario +targets and unsupported synthetic identities refuse this saved-view operation. +Saved Scenario presentations retain their dedicated local-occurrence adapter. +Admit each visual identity first, then delete each semantic subject once. +Current model validation forbids two placements of the same element in one +diagram (`duplicate_placement`); semantic deduplication must not admit such an +invalid project. The same element can appear in different diagrams, all of +which participate in deletion impact. Selecting both a state and one of its +rows is valid if both are visible. + +## Candidate and impact + +Validate the accepted input project, resolve the complete subject set, then +normalize only redundant Delete commands covered by existing core cascades. +Ancestry alone is insufficient: selected owners, ports, incident relations, +traces and action rows can overlap through dependencies. Deterministic +normalization must not depend on click order or primary selection. Do not run +separate validated singleton transactions; an intermediate candidate may be +invalid even though the full group deletion is valid. + +Execute one final Batch candidate using the existing core deletion semantics. +The final candidate is authoritative for actual removed identities and changed +surviving records. Assert every selected semantic subject was removed and no +identity was created. Include elements, relations, traces, artifact links, +diagrams and embedded placements in impact accounting. Do not estimate union +impact merely by summing individual deletion counts; conditional allocation +dependents and global view/reference pruning require the combined result. + +The foundation chooses original core deletion-impact coverage to suppress +redundant selected commands, with deterministic subject ordering. Coverage is +only command normalization, never the final impact report. Compared with +mutating a scratch project after every root, this keeps admission and coverage +bound to the same accepted input and avoids treating immediate scratch cleanup +as identical to the engine's deferred allocation cleanup. The final Batch and +complete selected-subject removal check remain mandatory. Tests cover two +endpoint allocations whose deferred cascades overlap, together with a direct +exchange deletion. An attempted alternate-support counterexample was invalid: +the current unique allocation, single performer and distinct boundary-owner +rules exclude simultaneous direct/reverse mappings. Preserve those rules and +test refusal of the invalid fixture; do not claim a union-only cascade case. + +Show the number of visual targets separately from unique semantic subjects, +then actual removed/updated records with searchable names, aliases and kinds. +All placements of deleted elements across views are affected. Referenced work +survives removal of a State action relation; trace endpoint counterparts +survive deletion of a trace. A counterpart may itself be deleted when it was +explicitly selected, but a trace must not cause implicit counterpart deletion. +Do not invent replacement links, activities or state transitions. + +Deleting an exchange can remove Process occurrences and alter implicit order +or a reused definition's interpretation. Changed owner records must be shown; +unchanged stored caller records do not prove unchanged caller behavior. Before +App acceptance, compare derived effects on reused callers using the Process +review approach, or explicitly resolve that review requirement. + +The App must confirm the captured candidate through one Document transaction, +which includes source synchronization. A failed source update, cancelled review +or stale token must not partially change model, source, selection, camera or +history. Success removes transient deleted selection, chooses a surviving +inspection context and supports one exact model/source Undo and Redo. The +review footer remains reachable while impact content scrolls or is filtered. + +## Verification obligations + +- OA and SA; exact placement-to-subject mapping and other-view occurrences; + invalid duplicate placements; nested semantic ownership; + selected ancestors with selected descendants and incident connections. +- Exact ports with owner/edge mixtures, allocation dependency interactions, + State rows and transitions preserving referenced work, and trace deletion + preserving endpoints/counterparts. +- Wrong placement/port/row association, foreign/hidden/synthetic members, + duplicates and cross-family inputs refuse before normalization. Selected + ancestor coverage must not make an invalid descendant admissible. +- Independently construct the complete expected Project; inspect final Batch + effects and exact source/history rather than comparing two calls to the same + planner. Preserve unrelated views, authored metadata and surviving records. +- App stale/ABA/private-gesture/draft/tool/modal admission and failure recovery; + actual Windows build plus native group review, Cancel, confirmation, Save, + Undo/Redo/reopen. Bounded planner checks do not satisfy these later gates. + +Rejected UI approach: overloaded plain Delete changes destructiveness when a +row or edge joins a placement group. Explicit model deletion preserves a stable +meaning and still supports a clear mixed semantic selection. Rejected planner +approach: only removing selected ancestor duplicates misses non-ownership +cascade dependencies; repeated validated singleton deletes can reject a valid +union or create partial history.