From 659b7a655a33e71e00b8d86a2674bcbd631681d2 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sat, 12 Sep 2026 19:46:17 -0700 Subject: [PATCH] Reject stale navigation envelopes before shared boundaries --- crates/oa-desktop/src/allocation_input.rs | 1 + crates/oa-desktop/src/app.rs | 36 ++-- crates/oa-desktop/src/app/allocation_drag.rs | 26 ++- .../src/app/allocation_drag_tests.rs | 3 + .../allocation_drag_tests/navigation_union.rs | 203 ++++++++++++++++++ crates/oa-desktop/src/app/diagram_usages.rs | 51 +++-- .../src/app/diagram_usages/tests.rs | 7 +- .../navigation_union_tests/palette_union.rs | 94 ++++++++ crates/oa-desktop/src/app/source_symbols.rs | 18 ++ .../src/behavior/scenario/usage/tests.rs | 8 +- ...lease-navigation-palette-union-20260913.md | 26 +++ 11 files changed, 422 insertions(+), 51 deletions(-) create mode 100644 crates/oa-desktop/src/app/allocation_drag_tests/navigation_union.rs diff --git a/crates/oa-desktop/src/allocation_input.rs b/crates/oa-desktop/src/allocation_input.rs index b5a87308..cd66bff4 100644 --- a/crates/oa-desktop/src/allocation_input.rs +++ b/crates/oa-desktop/src/allocation_input.rs @@ -65,6 +65,7 @@ impl Root<'_> { view_index: Some(self.app.document.view_index()), diagram, selected: self.app.selected, + navigation_focus: None, zoom: self.app.zoom, pan: self.app.pan, context: self.app.route_context().unwrap(), diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 312ced28..9ffdd194 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -1249,6 +1249,21 @@ impl App { ]) } pub fn update(&mut self, message: Message) -> Task { + // These handlers reject old owners/tokens without changing state. Apply + // the same admission before shared camera and allocation boundaries. + if !self.source_input_envelope(&message) + || matches!(&message, Message::DiagramUsage(event) if !self.diagram_usage_envelope(event)) + || matches!(&message, Message::WorkDrag(event) if !self.allocation_event_envelope(event)) + { + return Task::none(); + } + if let Message::WorkDrag(allocation_drag::Event::Press(press)) = &message + && !self.allocation_press_owns_current_view(press) + { + // Keep the existing stale-press explanation, without treating the + // rejected press as a new navigation or allocation boundary. + return self.allocation_event(allocation_drag::Event::Press(press.clone())); + } // A queued toolbar from a previous input owner cannot retire a newer // navigation destination or allocation capability before dispatch. if let Message::BehaviorControl(intent) = &message @@ -1381,29 +1396,16 @@ impl App { ) } fn update_inner(&mut self, message: Message) -> Task { + if !self.source_input_envelope(&message) { + return Task::none(); + } let message = match message { - Message::SourceFindClose(context, instance) => { - if context != self.source_symbol_context() - || instance != self.source_find_instance - || !self.source_navigation.open - || !self.source_navigation_available() - { - return Task::none(); - } + Message::SourceFindClose(..) => { // Only the checked envelope may enter the legacy Close handler. Message::SourceNavigation(crate::source_navigation::Event::Close) } - Message::SourceNavigation(crate::source_navigation::Event::Close) => { - return Task::none(); - } message => message, }; - if let Message::SourceSymbols(context, _) = &message - && (context.session != self.source_workspace.session() - || context.path != self.source_workspace.active) - { - return Task::none(); - } // Internal results and geometry do not constitute a new user intent. // Process query completion even behind a modal to release its worker slot; // its handler independently refuses navigation outside the live editor. diff --git a/crates/oa-desktop/src/app/allocation_drag.rs b/crates/oa-desktop/src/app/allocation_drag.rs index e4191c11..e95991b6 100644 --- a/crates/oa-desktop/src/app/allocation_drag.rs +++ b/crates/oa-desktop/src/app/allocation_drag.rs @@ -437,7 +437,29 @@ impl App { self.refresh_draft(); true } + pub(super) fn allocation_press_owns_current_view(&self, press: &Press) -> bool { + self.allocation_drag.active.is_none() + && self.allocation_stamp_valid(press.stamp) + && press.arrange == self.allocation_drag.arrange_next + } + pub(super) fn allocation_event_envelope(&self, event: &Event) -> bool { + match event { + Event::Cancel(token) | Event::Preview(_, token, _) | Event::Release(_, token, _) => { + self.allocation_drag + .active + .as_ref() + .is_some_and(|active| active.press.token == *token) + } + Event::Refused(epoch, _) => *epoch == self.allocation_drag.epoch, + // Invalid Press packets deliberately report why the view or draft + // changed. Leave their existing validation and feedback intact. + _ => true, + } + } pub(super) fn allocation_event(&mut self, event: Event) -> Task { + if !self.allocation_event_envelope(&event) { + return Task::none(); + } match event { Event::ArrangeNext => { self.allocation_drag.retire(); @@ -475,9 +497,7 @@ impl App { if self.allocation_drag.active.is_some() { return Task::none(); } - if !self.allocation_stamp_valid(press.stamp) - || press.arrange != self.allocation_drag.arrange_next - { + if !self.allocation_press_owns_current_view(&press) { self.status = if self.allocation_drag_clean() { "The view changed. Start this drag again.".into() } else { diff --git a/crates/oa-desktop/src/app/allocation_drag_tests.rs b/crates/oa-desktop/src/app/allocation_drag_tests.rs index 594c0207..65e58860 100644 --- a/crates/oa-desktop/src/app/allocation_drag_tests.rs +++ b/crates/oa-desktop/src/app/allocation_drag_tests.rs @@ -8,6 +8,8 @@ use iced::{ #[path = "allocation_drag_full_tree_tests.rs"] mod full_tree; +mod navigation_union; + struct Fixture { app: App, a: Id, @@ -61,6 +63,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> { view_index: Some(app.document.view_index()), diagram: app.active_diagram.unwrap(), selected: app.selected, + navigation_focus: None, zoom: app.zoom, pan: app.pan, context: app.route_context().unwrap(), diff --git a/crates/oa-desktop/src/app/allocation_drag_tests/navigation_union.rs b/crates/oa-desktop/src/app/allocation_drag_tests/navigation_union.rs new file mode 100644 index 00000000..3b406656 --- /dev/null +++ b/crates/oa-desktop/src/app/allocation_drag_tests/navigation_union.rs @@ -0,0 +1,203 @@ +//! Captured real canvas packets against a later navigation or live gesture. +use super::*; +use crate::diagram_usage::{self as usage, Target}; + +fn navigate_work(f: &mut Fixture) -> usage::Usage { + let message = f.app.diagram_usage_message(f.work); + let _ = f.app.update(message); + let panel = f.app.diagram_usages.as_ref().unwrap(); + let token = panel.token; + let context = panel.context; + let process = f.app.behavior.topology.usage_snapshot().into_query_state(); + let completion = usage::Completion { + token, + context, + result: Ok(usage::collect( + f.app.project(), + f.work, + &process, + &crate::behavior::scenario::State::default(), + )), + }; + let _ = f.app.update(Message::DiagramUsagesReady(completion)); + let found = f + .app + .diagram_usages + .as_ref() + .unwrap() + .usages + .iter() + .find(|usage| usage.target == Target::Placement(f.node)) + .unwrap() + .clone(); + let _ = f.app.update(Message::DiagramUsage(usage::Event::Choose( + token, + found.clone(), + ))); + assert!(f.app.pending_diagram_reveal.is_some()); + assert!(f.app.diagram_focus(found.surface).is_some()); + found +} + +fn assert_stale_noop(app: &mut App, message: Message) { + let before = app.document.snapshot(); + let selection = (app.selected, app.selected_node, app.active_diagram); + let camera = (app.zoom, app.pan, app.canvas_epoch, app.camera_epoch); + let pending = app.pending_diagram_reveal.clone(); + let focus = app + .diagram_navigation_focus + .as_ref() + .map(|focus| (focus.context, focus.usage.clone(), focus.selected)); + let allocation = (app.allocation_drag.epoch, app.allocation_drag.arrange_next); + let active = app + .allocation_drag + .active + .as_ref() + .map(|active| (active.press.token, active.cache.len())); + let interaction = app.source_symbols.interaction; + let status = app.status.clone(); + let _ = app.update(message); + assert_eq!( + (app.selected, app.selected_node, app.active_diagram), + selection + ); + assert_eq!( + (app.zoom, app.pan, app.canvas_epoch, app.camera_epoch), + camera + ); + assert_eq!(app.pending_diagram_reveal, pending); + assert_eq!( + app.diagram_navigation_focus.as_ref().map(|focus| ( + focus.context, + focus.usage.clone(), + focus.selected + )), + focus + ); + assert_eq!( + (app.allocation_drag.epoch, app.allocation_drag.arrange_next), + allocation + ); + assert_eq!( + app.allocation_drag + .active + .as_ref() + .map(|active| (active.press.token, active.cache.len())), + active + ); + assert_eq!(app.source_symbols.interaction, interaction); + assert_eq!(app.status, status); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.source_workspace.bundle(), before.bundle); +} + +fn stale_packets(press: &Press) -> Vec { + vec![ + Message::WorkDrag(Event::Cancel(press.token)), + Message::WorkDrag(Event::Preview(press.stamp, press.token, Some(press.start))), + Message::WorkDrag(Event::Release(press.stamp, press.token, Some(press.start))), + Message::WorkDrag(Event::Refused(press.stamp.epoch, "old refusal".into())), + ] +} + +#[test] +fn stale_gesture_packets_preserve_navigation_and_a_new_live_cancel_still_works() { + let mut f = fixture(Layer::Oa); + let before = f.app.document.snapshot(); + let old = press(&f); + let _ = f.app.update(Message::WorkDrag(Event::Press(old.clone()))); + assert_eq!( + f.app.allocation_drag.active.as_ref().unwrap().press.token, + old.token + ); + let found = navigate_work(&mut f); + assert!(f.app.allocation_drag.active.is_none()); + assert_ne!(f.app.allocation_drag.epoch, old.stamp.epoch); + for message in stale_packets(&old) { + assert_stale_noop(&mut f.app, message); + } + // A rejected press still explains the invalid view, but cannot consume the + // new destination's pending first measurement. + let pending = f.app.pending_diagram_reveal.clone(); + let epoch = f.app.allocation_drag.epoch; + let _ = f.app.update(Message::WorkDrag(Event::Press(old.clone()))); + assert_eq!(f.app.pending_diagram_reveal, pending); + assert_eq!(f.app.allocation_drag.epoch, epoch); + assert!(f.app.diagram_focus(found.surface).is_some()); + assert_eq!(f.app.status, "The view changed. Start this drag again."); + let (context, _, epoch) = f.app.pending_diagram_reveal.clone().unwrap(); + let _ = f.app.update(Message::DiagramRevealViewport( + context, + epoch, + bounds().size(), + )); + assert!(f.app.pending_diagram_reveal.is_none()); + assert!(f.app.diagram_focus(found.surface).is_some()); + + // Restore the fixture's camera through current App actions, then capture a + // fresh packet through the actual DiagramCanvas rather than seeding active. + let factor = 1. / f.app.zoom; + let _ = f.app.update(Message::Zoom(factor)); + let delta = -f.app.pan; + let _ = f.app.update(Message::Pan(delta)); + let current = press(&f); + let _ = f + .app + .update(Message::WorkDrag(Event::Press(current.clone()))); + assert_eq!( + f.app.allocation_drag.active.as_ref().unwrap().press.token, + current.token + ); + assert!( + f.app.diagram_navigation_focus.is_none(), + "live press retires navigation" + ); + for message in stale_packets(&old) { + assert_stale_noop(&mut f.app, message); + } + let epoch = f.app.allocation_drag.epoch; + let _ = f + .app + .update(Message::WorkDrag(Event::Cancel(current.token))); + assert!(f.app.allocation_drag.active.is_none()); + assert!(f.app.allocation_drag.epoch > epoch); + assert_eq!(f.app.status, "Work drag canceled"); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert_eq!(f.app.source_workspace.bundle(), before.bundle); +} + +#[test] +fn old_chooser_envelopes_preserve_a_current_canvas_gesture() { + let mut previous = fixture(Layer::Oa); + let old_open = previous.app.diagram_usage_message(previous.work); + let _ = previous.app.update(old_open.clone()); + let old_token = previous.app.diagram_usages.as_ref().unwrap().token; + let mut f = fixture(Layer::Oa); + let current = press(&f); + let _ = f + .app + .update(Message::WorkDrag(Event::Press(current.clone()))); + assert_stale_noop(&mut f.app, old_open); + assert_stale_noop( + &mut f.app, + Message::DiagramUsage(usage::Event::Close(old_token)), + ); + assert_stale_noop( + &mut f.app, + Message::DiagramUsage(usage::Event::Search(old_token, "old".into())), + ); + assert_eq!( + f.app.allocation_drag.active.as_ref().unwrap().press.token, + current.token + ); + let open = f.app.diagram_usage_message(f.work); + let _ = f.app.update(open); + assert!( + f.app.diagram_usages.is_some(), + "current chooser remains live" + ); + assert!( + f.app.allocation_drag.active.is_none(), + "current chooser retires gesture" + ); +} diff --git a/crates/oa-desktop/src/app/diagram_usages.rs b/crates/oa-desktop/src/app/diagram_usages.rs index fb17862f..5072a8c9 100644 --- a/crates/oa-desktop/src/app/diagram_usages.rs +++ b/crates/oa-desktop/src/app/diagram_usages.rs @@ -144,14 +144,31 @@ impl App { } } - pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task { - if let Event::Open(context) = event { - if context.document != self.canvas_document - || context.revision != self.document.revision() - || !self.project().contains(context.subject) - { - return Task::none(); + pub(super) fn diagram_usage_envelope(&self, event: &Event) -> bool { + match event { + Event::Open(context) => { + context.document == self.canvas_document + && context.revision == self.document.revision() + && self.project().contains(context.subject) } + Event::Close(token) + | Event::Search(token, _) + | Event::Page(token, _) + | Event::Move(token, _) + | Event::Activate(token) + | Event::Destination(token, _) + | Event::Choose(token, _) => self + .diagram_usages + .as_ref() + .is_some_and(|panel| panel.token == *token), + } + } + + pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task { + if !self.diagram_usage_envelope(&event) { + return Task::none(); + } + if let Event::Open(context) = event { if !self.usage_navigation_available() { self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into(); return Task::none(); @@ -171,22 +188,10 @@ impl App { iced::widget::operation::focus(filter_id), ]); } - let token = match &event { - Event::Open(_) => unreachable!(), - Event::Close(t) - | Event::Search(t, _) - | Event::Page(t, _) - | Event::Move(t, _) - | Event::Activate(t) - | Event::Destination(t, _) - | Event::Choose(t, _) => *t, - }; - let Some(panel) = &self.diagram_usages else { - return Task::none(); - }; - if panel.token != token { - return Task::none(); - } + let panel = self + .diagram_usages + .as_ref() + .expect("admitted chooser token"); if matches!(event, Event::Close(_)) { self.diagram_usages = None; if self.page == Page::Text { diff --git a/crates/oa-desktop/src/app/diagram_usages/tests.rs b/crates/oa-desktop/src/app/diagram_usages/tests.rs index 5f107d7b..95b0089d 100644 --- a/crates/oa-desktop/src/app/diagram_usages/tests.rs +++ b/crates/oa-desktop/src/app/diagram_usages/tests.rs @@ -1029,10 +1029,9 @@ fn repeated_preview_arrows_select_the_relation_and_highlight_the_exact_caller_li let camera = app.behavior.topology.camera_boundary(); if call == calls[1] && let Some((previous_call, previous)) = &previous_camera + && *previous_call != call { - if *previous_call != call { - assert!(&camera != previous); - } + assert!(&camera != previous); } if let Some(epoch) = previous_epoch { assert_ne!(app.canvas_epoch, epoch); @@ -1375,7 +1374,7 @@ fn whole_surface_camera_fits_huge_extent_while_target_camera_remains_readable() }; let viewport = iced::Size::new(800., 500.); let (zoom, pan) = usage::camera_for(&Target::Whole, bounds, viewport); - assert!(zoom < 0.05 && zoom >= 0.0001); + assert!((0.0001..0.05).contains(&zoom)); assert!(bounds.width * zoom <= viewport.width - 64. + 0.01); assert!(bounds.height * zoom <= viewport.height - 64. + 0.01); assert!((bounds.center_x() * zoom + pan.x - viewport.width / 2.).abs() < 0.01); diff --git a/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs b/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs index 8aee5cf8..7362e330 100644 --- a/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs +++ b/crates/oa-desktop/src/app/source_rename/navigation_union_tests/palette_union.rs @@ -1,6 +1,100 @@ //! Interactions absent from the independently published navigation branch. use super::*; +fn assert_stale_navigation_message(message: Message) { + let mut app = navigation_fixture(); + let found = navigate(&mut app); + let destination = navigation(&app); + let allocation_epoch = app.allocation_drag.epoch; + let accepted = app.document.snapshot(); + let status = app.status.clone(); + send(&mut app, message); + assert_eq!(navigation(&app), destination); + assert_eq!(app.allocation_drag.epoch, allocation_epoch); + assert_eq!(app.status, status); + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.source_workspace.bundle(), accepted.bundle); + first_measurement(&mut app, &found); +} + +#[test] +fn captured_old_source_query_and_find_close_preserve_the_new_destination() { + let mut previous = navigation_fixture(); + let query = previous.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition)); + send( + &mut previous, + Message::SourceNavigation(nav::Event::Open(false)), + ); + assert!(previous.source_navigation.open); + let close = previous.source_find_close_message(); + assert!(previous.source_input_envelope(&close)); + assert_stale_navigation_message(query); + assert_stale_navigation_message(close); +} + +#[test] +fn current_source_query_and_checked_find_close_remain_live() { + let mut app = navigation_fixture(); + let before = app.document.snapshot(); + send(&mut app, Message::SourceNavigation(nav::Event::Open(false))); + assert!(app.source_navigation.open); + let close = app.source_find_close_message(); + send(&mut app, close.clone()); + assert!(!app.source_navigation.open); + let epoch = app.allocation_drag.epoch; + send(&mut app, close); + assert_eq!(app.allocation_drag.epoch, epoch, "retired close is inert"); + let message = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition)); + send(&mut app, message); + assert!(app.source_symbols.in_flight.is_some()); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.source_workspace.bundle(), before.bundle); +} + +#[test] +fn captured_old_chooser_context_and_every_token_variant_are_inert() { + let mut previous = navigation_fixture(); + let open = previous.diagram_usage_message(model_id(2)); + send(&mut previous, open.clone()); + let (token, found) = finish_lookup(&mut previous); + assert_stale_navigation_message(open); + for event in [ + usage::Event::Close(token), + usage::Event::Search(token, "old filter".into()), + usage::Event::Page(token, 1), + usage::Event::Move(token, 1), + usage::Event::Activate(token), + usage::Event::Destination(token, Some(found.surface)), + usage::Event::Choose(token, found), + ] { + assert_stale_navigation_message(Message::DiagramUsage(event)); + } +} + +#[test] +fn current_chooser_open_and_close_keep_their_boundary_and_worker_semantics() { + let mut app = navigation_fixture(); + navigate(&mut app); + let epoch = app.allocation_drag.epoch; + let message = app.diagram_usage_message(model_id(2)); + send(&mut app, message); + assert!(app.diagram_usages.is_some()); + assert!(app.pending_diagram_reveal.is_none()); + assert!(app.diagram_navigation_focus.is_none()); + assert!(app.allocation_drag.epoch > epoch); + let completion = diagram_completion(&app); + let token = completion.token; + send(&mut app, Message::DiagramUsage(usage::Event::Close(token))); + assert!(app.diagram_usages.is_none()); + assert_eq!(app.diagram_lookup_in_flight, Some(token)); + send(&mut app, Message::DiagramUsagesReady(completion)); + assert!(app.diagram_lookup_in_flight.is_none()); + assert!( + app.diagram_usages.is_none(), + "completion does not reopen a closed chooser" + ); +} + #[test] fn allocation_keyboard_notification_preserves_navigation_and_first_measurement() { let mut app = navigation_fixture(); diff --git a/crates/oa-desktop/src/app/source_symbols.rs b/crates/oa-desktop/src/app/source_symbols.rs index 03aaade7..66a239ef 100644 --- a/crates/oa-desktop/src/app/source_symbols.rs +++ b/crates/oa-desktop/src/app/source_symbols.rs @@ -31,6 +31,24 @@ fn admission_notice(error: query::AdmissionError) -> String { } impl App { + pub(super) fn source_input_envelope(&self, message: &Message) -> bool { + match message { + Message::SourceSymbols(context, _) => { + context.session == self.source_workspace.session() + && context.path == self.source_workspace.active + } + Message::SourceFindClose(context, instance) => { + context == &self.source_symbol_context() + && *instance == self.source_find_instance + && self.source_navigation.open + && self.source_navigation_available() + } + // The checked Find-close envelope is mapped inside update_inner. + Message::SourceNavigation(nav::Event::Close) => false, + _ => true, + } + } + pub fn source_symbol_context(&self) -> nav::SymbolContext { nav::SymbolContext { session: self.source_workspace.session(), diff --git a/crates/oa-desktop/src/behavior/scenario/usage/tests.rs b/crates/oa-desktop/src/behavior/scenario/usage/tests.rs index fdead29d..145289ea 100644 --- a/crates/oa-desktop/src/behavior/scenario/usage/tests.rs +++ b/crates/oa-desktop/src/behavior/scenario/usage/tests.rs @@ -10,10 +10,10 @@ fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) precondition: String::new(), postcondition: String::new(), }; - if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() { - if !steps.contains(&id) { - steps.push(id); - } + if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() + && !steps.contains(&id) + { + steps.push(id); } id } diff --git a/harness/release-navigation-palette-union-20260913.md b/harness/release-navigation-palette-union-20260913.md index 6bd6f242..239044d4 100644 --- a/harness/release-navigation-palette-union-20260913.md +++ b/harness/release-navigation-palette-union-20260913.md @@ -56,3 +56,29 @@ Only source inspection, bounded formatting and whitespace checks are performed h No `cargo`, `rustc`, test binary or GUI was run. Compile/lint correctness, new test outcomes, timing and actual native usability remain open. The integration owner should run focused new filters first, retain existing grouped/full/strict gates and build the matching executable. The parent's e44 results do not validate this union. Native acceptance retains the peer's six-file `examples/source-project` oracle: rename the second `@assess_report` reference to `assess_report_union`, exactly six alias-token changes in two files, no disk change before Save, immediate editor selection/typing and text Undo, navigation without model history, complete model Undo/Redo, then Save/reopen equality. Also exercise actual overflowing preview/Details return, modal exclusivity, source syntax selection, canvas focus/Delete after navigation, exact port/state-row and repeated-placement targets, nested activation shoulders/lifecycle boundaries, and passive event timing. Use deterministic race tests for held operations; screenshots alone do not establish ordering. Linux remains deferred. + +## Independent-review correction after 4e51, source only + +The independent review of `4e51d68973da52cf057fab4983b9f112400b802c` identified an actual dispatch-order defect: old source, chooser and allocation envelopes reached shared reveal/allocation boundaries before their handlers rejected them. The follow-up shares those existing admission checks with the outer App boundary. SourceSymbols retains session/path checks; Find-close retains context/instance/open/availability checks and maps to the legacy Close only inside the checked handler. DiagramUsage retains exact Open document/revision/subject checks and all seven panel-token checks. Cancel, Preview and Release retain the active gesture token check; Refused retains its epoch check. Worker completion handlers and their exact occupied-slot release remain unchanged. + +Rejected Press packets still run their existing feedback handler, before shared boundaries, so an old stamp can explain the changed view without consuming a newer destination. Current gestures, current chooser actions and current Find-close remain active. Current-token chooser model-refresh recovery is retained; this change does not require a matching panel revision before its existing refresh handler can run. No selection rewrite or broader event classification is introduced. + +The exact test-only peer correction `a49b0ec7ac4538a372139175386a881645c1a59e` was inspected and applied: two collapsed conditions and one range containment expression in `app/diagram_usages/tests.rs` and `behavior/scenario/usage/tests.rs`. A source sweep also supplies the newly required `navigation_focus: None` in the root allocation test canvas and the production dirty-point-only canvas in `allocation_input.rs`. The latter is used only for the dirty-work hit check, not painted. + +Four further tests under `app::source_rename::tests::navigation_union_tests::palette_union::`, all unrun: + +- `captured_old_source_query_and_find_close_preserve_the_new_destination` +- `current_source_query_and_checked_find_close_remain_live` +- `captured_old_chooser_context_and_every_token_variant_are_inert` +- `current_chooser_open_and_close_keep_their_boundary_and_worker_semantics` + +They capture actual prior owners/tokens and check complete navigation, allocation epoch, source interaction, status and accepted snapshot retention; each rejected navigation case then completes the valid pending viewport. Positive controls exercise current source query/Find-close and current chooser boundaries, including close while its worker slot remains occupied and exact completion release. + +Two further tests under `app::allocation_drag::tests::navigation_union::`, all unrun: + +- `stale_gesture_packets_preserve_navigation_and_a_new_live_cancel_still_works` +- `old_chooser_envelopes_preserve_a_current_canvas_gesture` + +They capture Press through the actual DiagramCanvas event path, navigate through the actual chooser, preserve camera/selection/focus/pending measurement and the newer active gesture across stale packets, retain invalid-Press feedback, and exercise current Cancel and current chooser retirement. They do not seed private active gesture state. The resulting `palette_union::` filter contains seven tests, its enclosing `navigation_union_tests::` contains thirteen, the separate removal union still contains two, and the new allocation union contains two. The four existing preview-scroll tests are retained. + +Only bounded rustfmt and whitespace/source checks are performed for this follow-up; the ignored formatting receipt is `.local/navigation-import/stale-envelope-format.json`. All new tests, compilation, strict lint and native acceptance remain unrun here. Root readonly, typed activation/lifecycle and paint implementations are untouched by this follow-up. The integration owner must execute the combined source gates; neither source review nor peer-only passes establishes union acceptance.