fix(panel): D2 was broken on the real sequence, and Q#BP-R3 is ruled

Two of review's findings: the framing blocker, and a live defect in the
commit before this one. The remaining six are queued.

**I REPORTED M-D2 AS BITING AND IT DID NOT.** `presented()` filters on
`frame.geometry_epoch == self.panel.geometry_epoch`, and a geometry
change advances that field FIRST --- so by the time the matching frame
arrives, `presented()` already answers `None` and my
`is_some_and` predicate skipped the reset entirely. The shipped D2 did
nothing on the production sequence.

The witness could not see it because it invented a higher-epoch frame
without driving `next_geometry_declaration`, leaving
`self.panel.geometry_epoch` untouched so `presented()` still matched.
A test that skips the step which breaks the code cannot fail on it.

The predicate now compares against the RETAINED frame
(`self.panel.frame`), which survives the epoch advance, and the witness
drives `GeometryTrigger::Metrics` for real --- asserting along the way
that `presented()` IS `None` in that window, so the trap is pinned
rather than merely avoided. Restoring the `presented()` predicate now
fails the row.

**M-D3 WAS ALSO UNCONSTRAINED**, for a smaller reason: arming clears
`last_pointer_cell`, and the leg only armed, so the field was already
`None` before the replacement and deleting its reset changed nothing.
The arm helper now seeds the baseline with one accepted motion --- what
a real gesture would have produced --- and after replacement the row
requires `panel_motion_is_new` at that same cell to return true.
Deleting only that line now fails.

**Q#BP-R3 IS RULED: current-state hit semantics, narrowly, with the
token named as follow-up.** `PanelPointer` carries epochs and a cell
but nothing identifying the frame CONTENT the user saw, and
`panel_epoch` is stable across ordinary frames by design. So a document
wheel moves `view_top` daemon-side, and a click sent before the new
frame lands is inverted through the NEW `view_top` --- selecting a row
the user never saw, with every validation passing.

Closing it properly needs a per-frame token on `PanelFrame` echoed by
`PanelPointer`: a WIRE CHANGE, and this lane is non-protocol-bearing
with 1b blocked behind it. A daemon-only mitigation was considered and
does not work --- inverting against the last EMITTED frame still cannot
tell which frame the user SAW, and the failing window is identical.

So the lane accepts current-state semantics and says so: the window is
narrow and self-inflicted (the same frontend must move the view and
then click within one round trip), the magnitude is bounded by
`SCROLL_LINES`, and the TUI is structurally unaffected. The token is
recorded as a named follow-up for the next protocol-bearing slice, so
it is inherited rather than rediscovered. Overrule stated explicitly:
the trade is a narrow same-frontend mis-hit now, against serializing
this lane and 1b behind a v25 wire change.

Gates: all nine green under `env -u TMPDIR`, log 20260814T154611Z.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-14 17:50:54 +02:00
parent 4bd3a76618
commit 3dff79a0ec
No known key found for this signature in database
2 changed files with 112 additions and 12 deletions

View File

@ -2031,6 +2031,68 @@ follows acceptance 48's wording — it names row *selection* — and keeps
document navigation from becoming an incidental consequence of wiring document navigation from becoming an incidental consequence of wiring
replay. replay.
#### Q#BP-R3 — a panel cell has no frame-content provenance **RULED: current-state hit semantics, narrowly, with the token named as follow-up**
**The hole.** `PanelPointer` carries `geometry_epoch`, `panel_epoch`,
`buffer_id` and a `coord` — **and nothing identifying the frame CONTENT
the user was looking at** (`pmacs-protocol/src/message.rs:500`).
`panel_epoch` is deliberately *"stable across ordinary frames of one
continuously present window/buffer"* (`panel.rs:61`), so an ordinary
repaint changes no epoch at all.
**The race that follows is real.** A document-panel wheel moves
`view_top` **daemon-side**. The daemon repaints and emits a new frame.
Before that frame reaches the GPU, the user clicks. Every validation
passes — same buffer, same epochs — and the daemon inverts the cell
through its **current** `view_top`, selecting a row the user never saw.
Off by up to `SCROLL_LINES` (3). **No existing gate can reject it**,
because nothing about the event is stale by any test the ladder
applies.
**Why the epochs cannot be stretched to cover it.** Moving
`panel_epoch` on content change would invalidate a gesture on every
repaint, which breaks drags outright — the field is stable *by design*,
and that design is what makes selection possible.
**Why a fix is not free.** Closing it properly needs a **per-frame
token** on `PanelFrame`, echoed by `PanelPointer` — **a wire change**,
which makes it a protocol-bearing slice. This lane is explicitly
non-protocol-bearing, and GUI arc 1b is blocked behind it.
**A daemon-only mitigation was considered and does not work.** Having
the daemon invert against the `view_top` it used for its **last emitted
frame** sounds like it removes the wire dependency, but it does not:
the daemon still cannot know **which** emitted frame the user saw, and
the failing window — frames emitted after the one on screen — is
exactly the same one. Without a token echoed back, the information does
not exist on the receiving side.
**Ruling: this lane accepts CURRENT-STATE hit semantics**, and says so
rather than leaving it undiscovered:
- A panel cell is resolved against the daemon's state **at the moment
the event is processed**, not against the frame the frontend painted.
- **The window is narrow and self-inflicted**: it requires the same
frontend to change the panel's view and then click inside one
round-trip. It cannot arise from another frontend's activity, because
a foreign edit that moves `view_top` is not a thing panels do.
- **The magnitude is bounded** by whatever moved the view — one wheel
step, `SCROLL_LINES` rows.
- **The TUI is unaffected.** It has no round trip; the hazard is
structural to a remote frontend inverting cells against mutable
daemon state.
**Named follow-up, not a shrug: `PanelFrame` gains a content token and
`PanelPointer` echoes it, in the next protocol-bearing slice.** The
daemon then drops a gesture whose token no longer matches — the same
shape as the epoch ladder, one level finer. It is recorded here so the
next wire slice inherits it rather than rediscovering the race.
**Overrule this if you would rather block the lane on a protocol
slice.** The trade is explicit: a narrow, bounded, same-frontend
mis-hit now, against serializing this lane and 1b behind a v25 wire
change.
### The four replay edges — none of which "activation ordering" covers ### The four replay edges — none of which "activation ordering" covers
§5a's first draft concluded that the activation rule made the replay §5a's first draft concluded that the activation rule made the replay

View File

@ -7085,7 +7085,14 @@ impl State {
// Only on a CHANGE of identity. A panel repaints constantly // Only on a CHANGE of identity. A panel repaints constantly
// during a drag, and resetting on every frame would make // during a drag, and resetting on every frame would make
// selection impossible. // selection impossible.
let identity_changed = self.panel.presented().is_some_and(|current| { //
// Compared against the RETAINED frame, never `presented()`:
// that accessor filters on `geometry_epoch == self.panel
// .geometry_epoch`, and a geometry change advances the field
// FIRST, so by the time the matching frame arrives
// `presented()` is already `None` and an `is_some_and`
// predicate skips the reset — exactly the case D2 covers.
let identity_changed = self.panel.frame.as_ref().is_some_and(|current| {
current.panel_epoch != frame.panel_epoch current.panel_epoch != frame.panel_epoch
|| current.geometry_epoch != frame.geometry_epoch || current.geometry_epoch != frame.geometry_epoch
}); });
@ -20105,9 +20112,20 @@ mod tests {
}; };
let frame = present_panel(&mut state, 4); let frame = present_panel(&mut state, 4);
let live = pmacs_protocol::CellCoord::new(1, 1);
let arm = |state: &mut State| { let arm = |state: &mut State| {
state.set_panel_pointer_held(true); state.set_panel_pointer_held(true);
state.panel.gesture_last_content_cell = Some(pmacs_protocol::CellCoord::new(1, 1)); state.panel.gesture_last_content_cell = Some(live);
// SEED THE DEDUPE BASELINE TOO. Arming clears it, so a leg that
// only arms leaves `last_pointer_cell` already `None` and its
// reset is unconstrained — deleting that line stays green. One
// accepted motion is what a real gesture would have produced by
// the time a replacement arrives.
assert!(
state.panel_motion_is_new(live),
"the first motion after a press is never a duplicate"
);
assert_eq!(state.panel.last_pointer_cell, Some(live));
}; };
// D4, the NEGATIVE leg, first: a CHANGED frame at the SAME identity // D4, the NEGATIVE leg, first: a CHANGED frame at the SAME identity
@ -20137,23 +20155,43 @@ mod tests {
"a replacement panel never saw the press" "a replacement panel never saw the press"
); );
assert_eq!(state.panel.gesture_last_content_cell, None); assert_eq!(state.panel.gesture_last_content_cell, None);
assert_eq!( assert_eq!(state.panel.last_pointer_cell, None);
state.panel.last_pointer_cell, None, assert!(
"and the dedupe baseline goes too, or the successor's first \ state.panel_motion_is_new(live),
same-cell motion is silently suppressed as a duplicate" "the dedupe baseline goes too: the successor's first motion at \
the predecessor's cell must reach the daemon, not be suppressed \
as a duplicate of a gesture that belonged to another panel"
); );
// D2 — geometry identity, with panel identity UNCHANGED. This is the // D2 — geometry identity, with panel identity UNCHANGED. This is the
// font/scale case: the gesture would otherwise resume under a new // font/scale case: the gesture would otherwise resume under a new
// grid carrying epochs that are current and perfectly valid. // grid carrying epochs that are current and perfectly valid.
let base = state //
.panel // **Driven through the real declaration path.** Inventing a
.presented() // higher-epoch frame is not the production sequence: a font or
.cloned() // scale change advances `self.panel.geometry_epoch` FIRST, and only
.expect("a frame is present"); // then does the matching frame arrive. That ordering is what broke
// the first implementation — `presented()` filters on the epoch, so
// it answers `None` in exactly this window — and a witness that
// skips the declaration cannot see it.
let base = state.panel.frame.clone().expect("a frame is retained");
arm(&mut state); arm(&mut state);
let (next_epoch, total) = state
.next_geometry_declaration(GeometryTrigger::Metrics)
.expect("a metrics change re-declares geometry");
assert_ne!(next_epoch, base.geometry_epoch, "the declaration advanced");
assert!(
state.panel.presented().is_none(),
"and the retained frame no longer answers `presented()` — the \
window in which a `presented()`-based reset silently skips"
);
let mut regeometried = base.clone(); let mut regeometried = base.clone();
regeometried.geometry_epoch = base.geometry_epoch + 1; regeometried.geometry_epoch = next_epoch;
regeometried.size = CellSize::new(base.size.rows, total.cols.max(1));
regeometried.cells = vec![
pmacs_protocol::Cell::default();
(regeometried.size.rows * regeometried.size.cols) as usize
];
assert_eq!( assert_eq!(
regeometried.panel_epoch, base.panel_epoch, regeometried.panel_epoch, base.panel_epoch,
"the point of this leg is that PANEL identity holds" "the point of this leg is that PANEL identity holds"