Request a frame whenever frontend-local horizontal scrolling changes the
GPU origin, so the waiting event loop presents the new viewport without
unrelated input.
Discard panel wheel residuals on accepted mapped and legacy identity
replacements, while preserving them across a geometry-only re-grid of the
same panel. Add production-path witnesses for the redraw and both frame
families, plus the geometry negative control.
Refresh the Stage 1b framing as revision 22 with the review findings and
their discriminators.
Step 3 previously proved that each completed fractional bank emitted one
panel event and moved the expected axis, but its positive assertions only
required the viewport origin to be greater than zero. A receiver applying
two steps therefore passed a witness whose contract says exactly one.
Assert the geometry declaration is accepted, require a completely empty
Step for each sub-threshold turn, require no frontend-local effect and one
correctly directed event for each completion, and pin both resulting
viewport origins to the frontend's one-notch constants.
Mutation checks: doubling the vertical receiver step fails at (6, 0),
doubling the horizontal receiver step fails at (3, 6), and adding a local
document scroll beside the panel event fails on the completion transcript.
Focused witness green outside the socket-restricted sandbox. Package-wide
all-target clippy with -D warnings, cargo fmt --check, and git diff --check
are clean.
The framing says it in as many words: "Not 'a PanelPointer was emitted'
--- the observable effect on the panel's viewport." The row I wrote
filtered and counted panel-pointer events, which is the blind spot the
framing exists to close rather than the defect it guards against. It
would have passed if the vertical axis emitted a horizontal gesture, if
the receiver dropped what arrived, or if some other event accompanied a
sub-threshold delta.
Both halves now run in one row. The PRODUCER is this frontend's
apply_wheel, reached through dispatch_window_event. The RECEIVER is a
real pmacs::editor::EditorState with a live panel window, driven through
classify_panel_pointer + apply_panel_pointer --- the pair the daemon
itself calls --- and the assertion is the panel window's (view_top,
view_left). Per axis: a sub-threshold delta puts nothing on the wire and
moves the viewport by nothing; the delta that completes the notch moves
it by exactly one step, on that axis and not the other.
That needs the editor crate, so pmacs-gpu gains a DEV-dependency on
pmacs --- test-only, never in the shipped graph --- and pmacs gains three
#[doc(hidden)] test-support methods beside the ones already there:
install_panel_view_for_test (which daemon.rs's own semantic_panel_view
now delegates to, so there is one fixture rather than two),
seed_window_buffer_for_test, and window_view_origin_for_test.
Three things the row failed on before passing, each now a named setup
fact rather than a silent dependency. The panel buffer starts empty and
scroll_window clamps to line_count - 1, so an unseeded panel cannot
scroll at all. Seeding it is not enough either: TextView caches the line
partition it was built with, so the window has to be handed a rebuilt
view. And the receiver re-derives the panel grid from an accepted
geometry declaration --- without one every coordinate is outside a grid
that does not exist and the gesture is Refused before it can do
anything.
The mutation that matters is the one no emission count could see:
dropping PKind::ScrollLeft/ScrollRight from the daemon's panel arm ---
the receiver half, the axis whose arm did not exist before B2 --- fires
this row. So do rounding instead of banking, and collapsing the two
axes into one accumulator.
Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu 322;
--lib 2009; --lib --features crdt 2202; git diff --check.
Three residuals from review, all of the same family --- a claim that
looked covered and was not.
B1's disposal was implemented for panels only. Terminal(BufferId) is a
residual owner too, and exit_terminal_mode dropped four terminal-only
caches without dropping the bank, so leaving a terminal and re-entering
the SAME one inherited it: the key is the buffer id and it has not
changed. clear_terminals() now runs at that teardown, with a row that
enters, banks 0.6, leaves, re-enters the same buffer, and finds nothing
waiting. The type's own doc still said disposal was unimplemented and
owed; it now says where both halves live.
R5 and the panel-disposal row stopped after the successor's first 0.6
produced nothing. Unlike R4, neither completed the successor's own
0.6 + 0.6 --- so both passed just as well against an accumulator that
banks nothing at all, which is exactly the state they exist to rule
out. Both now carry the completion leg and assert the exact expected
effect. Demonstrated rather than asserted: with accumulation broken
outright (bank = delta instead of bank += delta) those two rows now
fail, and before this change they did not.
The Absent arm resets EIGHT fields before the new clear, not nine. Both
places that said nine are corrected.
Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu 322;
--lib 2009; git diff --check.
Identity keying answers panel A versus panel B on its own: the bank is
keyed by BufferId, so a different buffer starts from zero for free. It
cannot answer close-and-reopen of the SAME persistent buffer. The
successor carries the same id, so nothing about the key distinguishes
it from the panel the user was actually scrolling, and a notch begun in
a panel that no longer exists completes in its replacement.
The Absent arm already resets nine pieces of panel state one line at a
time --- frame, plan, drag, hover, pointer_held, last cell, gesture
cell, generation. The wheel residual was missing from that list, the
same shape of omission as the horizontal origin missing from the TUI's
four replacement resets.
The row closes the panel and reopens it at panel_epoch 2 on the same
buffer, which is precisely the case buffer_id cannot see, and asserts
the reopened panel's first sub-tick reaches nothing. Dropping
clear_panels() from the Absent arm fires it and nothing else.
This closes the last of B1's owed evidence.
Gates: fmt; clippy --workspace --all-targets -D warnings (which caught
a needless mut); pmacs-gpu 321; --lib 2009; git diff --check.
The two clears beside code_scroll_left have had a unit row asserting
they are distinct operations, with that row saying plainly it was not
R4/R5's witness. These are: a sub-tick banked over the predecessor, the
buffer replaced through the production BufferSnapshot receiver, and a
sub-tick over the successor reaching nothing.
Left standing, a bank completes in the successor a notch the user began
in its predecessor --- a jump with nothing on screen to explain it.
R4 covers the document's bank and, with it, chrome's: chrome's owner IS
the document's, so one reset serves both. It also completes the
successor's own 0.6 + 0.6 afterwards, so it cannot pass by having broken
accumulation outright rather than by having reset it.
R5 covers the minimap's, which B6 made independent. One clear omitted at
a time, as the framing requires: dropping clear_document fires R4 alone
and dropping clear_minimap fires R5 alone, so neither field hides behind
the other's reset.
Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu 320;
git diff --check.
Revision 20 sharpened this witness because its earlier form was
satisfiable with the mechanism it protects entirely broken: a whole tick
passes straight through #243's vertical receiver even if B1's
accumulator discards every sub-tick it is given. The row now feeds
fractional input end to end --- a first 0.6 produces no gesture, a
second completes exactly one --- and does it per axis, because a single
accumulator fed by both axes passes any one-axis row.
The ordering is the point of the middle leg: 0.6 vertical, then 0.6
horizontal. Under one shared accumulator the horizontal delta would
finish the vertical one and fire a gesture there. Under per-(surface,
axis) banks it reaches nothing, which is what the row asserts before
completing either axis.
Observed on the wire, filtered to PanelPointer / PanelPointerMapped,
because a panel gesture's whole effect is the event it sends.
Two setup facts the harness forced, both asserted rather than assumed.
It negotiates the MAPPED family, which refuses a frame carrying no
mapping generation, so the row drops to the legacy wire like every other
panel row here. And it has already made its one surface declaration, so
the re-declaration uses the Metrics trigger; a second Surface
declaration is suppressed by design and returns None. The row asserts
the frame was accepted before touching it, and asserts the probe is a
panel CELL --- panel chrome banks nothing at all and would satisfy every
"no gesture" assertion for entirely the wrong reason.
Both documented mutations fire: rounding the notch instead of banking it
fires the sub-threshold legs, and collapsing the two axes into one
accumulator fires the cross-axis leg.
Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu 318;
git diff --check.
Pin L7b to the incremental reshape branch with a sentinel that a full
reshape clears, rather than treating unchanged line count as proof that
the eligible fast path succeeded.
Make GPU L3 assert both the accepted CursorByte and the exact pixel
origin produced by following column five, so an arbitrary partial move
cannot satisfy the row.
Remove the deleted authority field's orphaned documentation from the
cursor-icon field and stop describing the GPU wrap path as clearing a
latch that no longer exists.
Per the decision: the contract is behavioral, and the two frontends
need not share a representation. manual_left_authority, its
initializer and its four writes are gone, and scroll_by_columns no
longer returns a bool nobody read. GPU authority is structural --- the
follow reaches the origin only through ensure_caret_painted, which
Q#F6's painted-before policy skips exactly when the user has scrolled
the caret off screen, and when the caret is on screen it is inside the
viewport where follow_left is a no-op. There is no follow to outrank.
L7b, GPU, found a live gap before it could witness anything. Q#R1's
keystroke case re-shapes only the affected line through
try_reshape_line and skips the full reshape --- and skipped clause 3's
clamp with it. A one-line delete that shortens the widest line could
therefore leave the viewport past the end of the text, with no later
event to repair it. The clamp now runs on that branch too, and the row
drives apply_loro_text_delta_batches and asserts the line count did not
change, so it cannot pass by taking the full-reshape branch instead.
The combined row is split four ways, each with its own mutation: L2
(height-only preserves --- the policy, not a latch), L7a (widening
clamps to the exact bound), L7b (content shrink through the incremental
path), L3 (a moved CursorByte pulls the viewport back). L3 exists
because removing the flag would otherwise leave the "until the cursor
changes" boundary unwitnessed --- the one thing the flag's name claimed
to govern. Its first version re-announced the caret's current byte,
which the arm treats as inert by design; it now moves it.
L2's mutation necessarily bites L7a --- an unconditional follow snaps
the origin to the caret before that row's widen --- and the doc names
that rather than leaving a predicted bite unexplained.
Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu
317; --lib 2009; git diff --check.
The framing offers L2 --- wheel sideways, then a height-only resize ---
as the row that witnesses manual horizontal authority on the GPU. It
cannot. Measured before anything was added: the origin survives that
resize with `manual_left_authority` never read anywhere in the
frontend. What preserves it is Q#F6's painted-before policy. `resize`
runs `ensure_caret_painted` only when the caret was painted, and a
caret the user has scrolled off screen is not painted --- so the follow
that would snap the origin back never runs. Whenever the caret IS
painted it is inside the viewport, where `follow_left` returns the
origin it was handed. Either way the latch is unreachable.
What the GPU actually lacked was the other half of clause 3. Nothing
brought the origin DOWN when the maximum fell, for the same reason: the
follow that would is skipped in exactly that state. Measured: scroll to
the right bound at 640px, widen to 1600px, and the origin stayed 960px
past the new maximum --- most of the viewport blank with the text off
its left edge.
`clamp_code_scroll_left` at reshape's tail, beside B5's icon hook and
for the same reason: one point every geometry settle already passes
through. Gated on a non-zero origin, because it scans for the widest
line and most windows are never in this state.
L2 keeps both legs and says plainly that the first is required
behavior, not a witness of the latch. L5's GPU leg makes the stronger
statement available here: the wire stays silent, because on this
frontend moving point means telling the daemon.
One process note. The L5 mutation appeared not to fire; it had failed
to compile. A mutation that does not build produces exactly the output
of a test that passes, and only reading past the grep separated them.
Both were re-run after the mutant compiled: dropping the clamp fires
L2's widening leg, an off-by-one fires its exact bound, and a wire
event on the horizontal leg fires L5.
Still owed: the framing's L2 wording, which promises a witness this
frontend cannot provide.
Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu
314; --lib 2009; --lib --features crdt 2202; git diff --check.
Two defects in the horizontal row, both of the same kind: it asserted
the absence of one effect and called that inertness.
It never checked what its probes classified as. The other two B6 rows
pin their targets; this one did not, so a probe that drifted onto panel
chrome --- horizontally inert for an entirely different reason, and
banking nowhere at all --- would have satisfied every assertion without
touching B6. Both targets are now asserted exactly. Verified by moving
the minimap probe off the band: all three rows fail at their setup.
And it discarded both Steps, leaving an unchanged code_scroll_left as
the whole of "inert". A notch that scrolled vertically, or put an event
on the wire, would have passed. The minimap leg now asserts an empty
transcript beside the unchanged origin. Verified by rerouting the
minimap's horizontal leg to scroll_by_lines(1): the assertion fires,
reporting [Scroll { top: 1 }].
The contrast leg keeps the left edge as its discriminator, and now says
why: a horizontal document scroll is local and silent, so its transcript
is empty too. Only code_scroll_left separates the two surfaces, and a
reader who assumed otherwise would draw the wrong conclusion from the
row's shape.
Gates: fmt; clippy --workspace --all-targets -D warnings; pmacs-gpu 312;
git diff --check.
B6's mechanism landed with B1: WheelTarget::Minimap maps to its own
ResidualOwner, and the horizontal leg already skips it. What was owed is
the evidence, and apply_wheel had none --- the accumulator had unit rows,
but nothing drove a wheel through dispatch_window_event to the effect.
Three rows on EffectHarness, which holds a real App and a real daemon
socket, so the classifier, the accumulator and the local step are all
production code:
- one notch over the minimap scrolls the document viewport by one notch
and re-declares it, exactly as over text;
- a part-notch over the minimap does not complete one over the document
--- the surface-switch jump B1 forbids, and the minimap is its sharpest
case because both surfaces move the same viewport, so sharing a bank
would look harmless and produce a jump nothing explains. A third step
gives the document bank the rest of its notch, so the row cannot pass
by measuring nothing;
- a horizontal notch over the minimap is inert while the same notch over
text scrolls sideways.
All three mutations fire, disjointly: an inert arm for the minimap fires
the first, ResidualOwner::Document fires the second, dropping the
horizontal guard fires the third.
The third row is why EffectHarness now takes a document. Its first
version asserted inertness over the default fixture and the setup
assertion caught it twice: four-column lines pin the saturated right
bound to zero, and wrapping --- on by default --- pins the left edge to
zero regardless of target. Either would have reported inertness the row
never tested. It now uses wide lines and turns wrap off through
LineWrapFacts, the message production uses, asserting the wrap landed.
`new()` keeps the 200-line fixture and its M22 rationale verbatim.
Gates: fmt; clippy --workspace --all-targets -D warnings (which caught a
float_cmp in the first draft of the inertness assertion); pmacs-gpu 312;
--lib 1997; --lib --features crdt 2190; git diff --check.
The snapshot geometry row enumerated what dropping
`apply_panel_cursor_icon()` from `reshape`'s tail would fire: "this row,
the menu row, and the line-number row below." Two panel rows have since
joined them, so the list read as exhaustive while being short by two.
Executed the mutation at this tree: it fires the two snapshot rows, the
line-number row, and both panel rows. The doc block now says so, and
says the list is expected to grow --- one hook reached by more routes is
the design, not drift, and a fixed enumeration would go stale at every
new one.
Verified: 12 B5 rows green; fmt, workspace clippy --all-targets
-D warnings, and git diff --check clean.
The B5 cursor icon was re-derived in reshape, but accepted PanelFrame
messages changed the document boundary without reaching reshape. A
stationary pointer therefore kept Text over a new panel, or RowResize
after the panel disappeared.
Route panel inset changes through one helper that synchronizes dimensions
and reshapes. Keep content-only panel frames on the repaint path, so normal
panel output does not rebuild the document. Geometry epoch advancement uses
the same helper because it disowns the retained frame after resize/font
handling has already reshaped against the old presentation.
Add production-path witnesses for Present/Absent, an independent epoch
invalidation row, and a discriminator keeping content-only frames off the
reshape path. All four documented mutations fire.
The previous commit applied the icon in the `BufferSnapshot` arm after
its reshape, and routed menu writes through a `set_menu` helper that
applied it too. Both were wrong in the same way: they patched the two
paths that had been noticed instead of the place the paths converge.
Two consequences, both found in review.
The snapshot's documented mutation did not fire. Replacing
`set_menu(None)` with `self.menu = None` still passed, because the
unconditional post-reshape apply in the same arm restored `Text`. The
two appliers masked each other, so the single-writer property was an
inspection claim wearing a witness's clothes, and the row's stated
mutation named something the row could not see.
And the snapshot is only one geometry transition. The line-number mode
moves `text_left` through its own path; minimap arrival, panel
appearance, window resize and font metrics move the text clip the same
way. A stationary pointer could go from text to gutter, minimap, panel
or outside with the icon still saying `Text`. Ten call sites reshape;
three applied the icon.
So the icon is now re-derived in `reshape`'s tail --- the one point
every geometry settle already passes through --- and the two ad-hoc
geometry appliers are gone, `set_menu` with them. Three production
appliers remain, one per cause: pointer motion, menu ownership with no
geometry, and geometry.
Each is separately witnessed; the matrix is disjoint. Dropping the
`reshape` tail fires the two snapshot rows and the new line-number row;
dropping the `MenuPrompt` applier fires the lifecycle row alone;
dropping the every-motion applier fires the motion row alone.
The new row drives `InstanceMessage::LineNumbers` --- the production
arm, not `apply_panel_cursor_icon` directly --- turning the gutter on
under a stationary pointer and asserting the pixel it swallows stops
being an I-beam. Its setup asserts the gutter actually moved past the
probe, so the row cannot pass by measuring nothing.
Gates at this tree: fmt clean; clippy --workspace --all-targets
-D warnings clean; pmacs-gpu 306 (+1); --lib 1997; --lib --features
crdt 2190; pmacs-protocol 32; git diff --check clean.
`MenuPrompt` was not the only writer. `BufferSnapshot` clears the menu
--- a popup anchored in the prior buffer would hijack input --- and left
the icon alone, so an open-menu arrow survived a buffer replacement over
document text until the pointer moved.
Menu state now has ONE writer, `set_menu`, which re-derives the icon.
A third site added later gets it for free instead of reintroducing the
same defect, and `self.menu = ` appears exactly once in the crate.
The snapshot also needs the icon re-derived AFTER the reshape, for a
different reason: it changes geometry. `text_left` is
`TEXT_LEFT + gutter_width_px` and the gutter is sized from the line
count, so a snapshot moves the text boundary under a stationary pointer
--- a pixel that was gutter becomes text.
BOTH MECHANISMS FIRE ON THE SNAPSHOT PATH, so at first each masked the
other: removing either alone left every row green, and only removing
both fired anything. That is the "one omission at a time" defect R4/R5
is written to avoid, reproduced here. Each now has its own witness:
set_menu's apply removed -> 2 rows (the MenuPrompt legs)
post-reshape apply removed -> 1 row (the new geometry leg)
The geometry row moves the line count between one and four digits so the
gutter widens and narrows under a stationary pointer, and asserts the
icon follows. Its setup steps assert the gutter actually moved, so a
fixture that stopped discriminating fails rather than passing quietly.
The menu case was wrong in a way the motion-path patch only hid.
MENU OWNERSHIP CHANGES WITH NO POINTER MOTION. `MenuPrompt` opens and
closes the menu, and re-deriving the icon only on motion meant opening
while an I-beam showed left it on screen over the menu, and closing left
the arrow over text --- in both cases until the pointer happened to
move. The icon is a function of the state, so it is now re-derived where
the state changes.
That makes the motion-path call REDUNDANT, and it is removed rather than
kept: motion inside an open menu changes no ownership, and a second
writer there is one no row could distinguish from the first. This is the
option review offered, and it is the better half --- the transition is
where the fact lives.
The new row drives `apply_attach_message` --- the production path ---
and asserts `last_cursor_icon`, the value actually written, across both
transitions with the pointer never moving. Dropping the `MenuPrompt`
call fires it. The decision-half row stays separate so a failure says
whether the DECISION or the APPLICATION broke; dropping the
`menu.is_some()` guard fires both.
And the blank-area row documented a mutation that was not the one run.
`hit_test_source_byte` takes `&mut self` and the helper is `&self`, so
the literal substitution does not compile; the executed mutation bounds
`x` by the glyphs' extent, which is the same semantics geometrically.
The row now records what was executed and why the literal one is
unavailable.
Three of B5's claims were asserted nowhere that could fail.
THE EVERY-MOTION FIX WAS UNWITNESSED. Both rows called
`desired_cursor_icon` directly, so reinstating the divider-change gate
left them green --- the gate is on the caller. A new row drives
`apply_cursor_moved` from text into the gutter with `hover_divider`
false throughout and asserts `last_cursor_icon` changes. Reinstating the
gate fires it.
THE GEOMETRIC-VERSUS-BYTE RULING WAS UNWITNESSED. The only positive
point sat over an actual glyph, so a byte hit-test passed. A row now
puts the pointer well past a short line's end, inside the text
rectangle, and requires `Text`. Bounding x by the glyphs' extent ---
byte-hit-test semantics expressed geometrically --- fires it.
THE MENU PATH LEAKED AN I-BEAM. `apply_cursor_moved` returns early while
a menu is open, so an I-beam showing when the menu opened stayed on
screen over the menu indefinitely. The menu now applies the icon on that
path and counts as chrome in `pointer_over_text_content`, with a row;
dropping the guard fires it.
`apply_panel_cursor_icon`'s doc still said it chooses between RowResize
and Default. It chooses among three, and says so, including why calling
it per-motion is cheap.
§2a's CORRECTION 3 said where this had to land: `apply_panel_cursor_icon`
already owns the cursor and writes `Default` in its else branch, so an
I-beam at a separate site would be CLOBBERED by it on the next motion.
B5 extends that owner rather than joining it --- `desired_cursor_icon`
decides RowResize, Text and Default together or not at all, with the
divider outranking the I-beam because a drag handle is never text.
`pointer_over_text_content` is geometric, not a byte hit-test: an I-beam
belongs over the text AREA including the blank past a short line's end,
and a byte test would flicker along a ragged right margin. It excludes
the gutter, the minimap, the panel band and everything outside the
document's text rect, each for its own reason.
The icon now applies on EVERY motion rather than only when divider hover
flips. B5's transitions --- crossing the gutter, crossing the text's
right edge --- do not touch `hover_divider`, so the old gate would have
left the icon stale for exactly the cases B5 is about. The write is
idempotent against `last_cursor_icon`, so per-motion calls cost a
comparison rather than a platform round-trip.
THE FIRST VERSION OF THE ROW COULD NOT SEE ITS OWN MUTATION. With line
numbers off, `gutter_width_px` is 0 and `text_left == TEXT_LEFT`, so
"extend the I-beam over the gutter" changed nothing and the row passed a
broken build --- 0 rows fired. The fixture now turns line numbers on and
ASSERTS a gutter exists before relying on one. Both mutations fire:
I-beam over the gutter -> the coverage row
I-beam outranks divider -> the coverage row
no-pointer guesses a spot -> the no-pointer row
Deleting `middle_click_paste_source` left a broken intra-doc link on
`apply_middle_press` and a stale name in the end-to-end row's comment.
Both now name `paste_source_for`, which is what the code calls.
And `an_unused_button_produces_no_effect_of_any_kind`'s opening sentence
was duplicated on one line --- the tail of the same insertion that split
it in the first place. Repaired.
WHY NEITHER WAS CAUGHT, which is the part worth keeping: NOTHING RUNS
`cargo doc`. It is absent from `scripts/gate` and from every ci.yml job,
so broken intra-doc links are ungated across this repository. `git diff
--check` cannot see them because they are syntactically valid, and
clippy does not read doc links. Running it by hand here confirms my link
now resolves --- and surfaces one PRE-EXISTING unresolved link,
`MathNode` at pmacs-gpu/src/math_layout.rs:314, in a file this lane has
never touched.
I have not added a doc step to the gate: that is shared infrastructure
and its own lane, alongside the clippy default-features gap this lane
already recorded. The finding is carried to 1b's ledger block rather
than left in a commit message.
MY "IT RUNS ON THE NON-LINUX CI LEGS" NOTE WAS FALSE. `cargo test -p
pmacs-gpu` appears exactly once in ci.yml, in the Ubuntu-only
`gpu-render` job; the macOS matrix tests the workspace default member
only. So the `unwrap_or(PasteSource::Clipboard)` mutant was green in
every environment that actually executes --- and I wrote that note one
commit after writing about honesty, which is the part worth recording.
The platform is now a PARAMETER rather than a `cfg!` read inside the
decision: `paste_source_for(is_linux)`, with an injectable override on
`App` for tests. The off-Linux branch runs on this host, and a new row
asserts the gesture is completely inert there. The mutant fires it
locally, not hypothetically.
AND "WHOLE TRANSCRIPT" WAS STILL OVERSTATED. Both arms filtered for
`Paste`, so any other outbound event passed, and the release assertion
had the same hole. All three assertions are exact `Step` equality now:
one PRIMARY paste and no local effect on Linux, completely empty off
Linux, completely empty on release. The frontend id is read from the
transcript rather than assumed, so the row pins payload and shape
without pinning an id the handshake owns.
Three mutations, each firing locally:
unwrap_or(Clipboard) -> the off-Linux row
dispatch arm no-op -> the end-to-end row
source = Clipboard -> both
I have not touched ci.yml. Adding a macOS `pmacs-gpu` leg is a change to
shared infrastructure and belongs in its own lane; making the contract
testable where the tests already run was the fix available here.
One slip of mine, fixed in the same change: the first version of this
commit left `middle_click_paste_source` dead --- `apply_middle_press`
calls `paste_source_for` directly now --- and I committed with clippy
failing because I ran the gates after `git commit` rather than before.
The helper is gone, the seam row names both platforms explicitly, and
the gates ran first this time.
The inertness stopped at a seam. `middle_click_paste_source()
.unwrap_or(PasteSource::Clipboard)` at the call site restores the
rejected fallback and passes every row: the helper still returns `None`,
and Linux still receives PRIMARY. A contract asserted only in the
function that decides it is not asserted on the path that acts on it.
The end-to-end row drops its `cfg(target_os = "linux")` and asserts the
complete transcript on both platforms: one PRIMARY paste on Linux, and
off Linux NO paste of any selection and no local effect either.
One honest limit is recorded on the row rather than left implied. On a
Linux host that `unwrap_or` never engages --- the source is already
`Some(Primary)` --- so no row on this machine can fire that mutant, and
a green local run says nothing about it. The `else` branch is what
catches it, and it runs on the non-Linux CI legs. Forcing the source to
`None` everywhere fires two rows locally, which is the closest
demonstration available here.
Also repairs the neighbouring test's documentation, which my insertion
had split: `an_unused_button_produces_no_effect_of_any_kind` was left
with "row that calls it claimed-and-dropped" while its opening two lines
had been absorbed into the B4 row's comment. Both are contiguous blocks
again.
Two process notes, because both recurred:
- This is the THIRD insertion in this lane to damage an adjacent test's
docs or attributes. The cause is anchoring a splice on a `fn` or doc
line without checking what precedes it; from here I anchor above the
doc block and read the neighbour back after inserting.
- The previous commit's message claimed the `cfg` removal it did not
contain: an edit script died partway, wrote nothing, and I committed
on the strength of a later partial edit. Amended rather than left
standing, and the file is now verified per claim rather than per
script exit.
TWO MUTATIONS LEFT BOTH B4 ROWS GREEN. Changing the source to
`Clipboard`, or replacing the dispatch arm with a no-op, was invisible:
one row asserted `middle_click_paste_source` in isolation and the other
asserted `route_pointer` in isolation, and nothing asserted the effect
the gesture produces. Two seams tested separately are not a path tested
once.
A third row drives a middle press through `dispatch_window_event` and
asserts EXACTLY ONE outbound `Paste` carrying the PRIMARY payload, and
that the release sends none. PRIMARY and CLIPBOARD are stubbed with
DISTINGUISHABLE contents, which is the point --- identical stubs would
pass with the wrong selection read.
That needed a seam: `State::set_test_selection`, consulted by
`read_os_selection` before the OS clipboard. A test-only field in
production code is a cost, and it is the smallest one that makes B4's
actual contract --- WHICH selection --- assertable without a real
clipboard. Both mutations now fire: source-to-Clipboard fires two rows,
the no-op dispatch fires the end-to-end row.
AND THE OFF-LINUX FALLBACK WAS UNFRAMED BEHAVIOUR I INVENTED. B4 rules
"PRIMARY on Linux" and rules nothing else. The gesture was inert on
every other platform; my previous commit made it paste the CLIPBOARD
there, and the row adopted that choice permanently. `middle_click_paste_source`
now returns `Option`, `None` off Linux, and the gesture stays inert. A
fallback needs framing and re-approval, not a default chosen while
implementing.
`PointerRoute::UnusedButton`'s own doc named this row: "Stage 1b's B4
gives the middle button a meaning (PRIMARY-selection paste on Linux) and
lands here." B4 splits that variant, as §2a said it would.
A middle PRESS is now `PointerRoute::MiddlePress` and reads the PRIMARY
selection, shipping it as the same `Paste` wire operation Ctrl-V uses.
Its RELEASE stays unused, like the right button's --- the paste happens
once, on the press.
PRIMARY and CLIPBOARD are different selections with different contents:
the clipboard holds what was last explicitly copied, PRIMARY holds what
is currently selected. Reading the wrong one still produces a paste,
just not the one the platform convention promises, so the row asserts
the SOURCE rather than that a paste happened.
`middle_click_paste_source` is the seam that makes that assertable
without an OS clipboard; `read_os_selection` takes the source and uses
arboard's `GetExtLinux` for PRIMARY.
Two rows, three mutations, each firing:
source = Clipboard -> the source row
middle press unrouted -> the routing row
release also pastes -> the routing row
Three existing rows encoded the old behaviour --- that a middle press is
semantics-free. They are updated to keep testing what they SAY rather
than being weakened to accommodate B4: the routing row now covers
Back/Forward/Other plus the middle RELEASE, and the two effect/order
rows switch to `Back`, a button that still has no semantics. Widening
them to accept the new meaning would have left no row asserting that
semantics-free buttons stay inert.
A THIRD copy of the rule lived in the projection that decides where the
GPU actually renders a later tab: manual `stop - column % stop`
arithmetic, and a per-character advance calling `UnicodeWidthChar::width`
directly. So the previous commit's mutation broke the minimap while
leaving the rendering path untouched --- the shared bound could still
drift from the columns the GPU draws at.
Both now delegate to `pmacs_protocol::columns::advance_char`: the tab
width is DERIVED from the shared advance rather than recomputed, and the
per-character step is the shared one.
The projection's stream semantics stay local, because they are real and
distinct: the column runs ACROSS chunks, so adornment text shifts a
later tab, and a newline restarts it. That is why the wrapper still
exists rather than being replaced outright.
Evidence, not assertion: mutating the tab stop in
`pmacs_protocol::columns` now breaks BOTH
`tab_projection_uses_shared_stops_and_unicode_columns` and
`minimap_columns_match_code_tab_and_unicode_widths`. Six adornment rows,
including `caret_projection_accounts_for_inline_adornments`, still pass,
so the stream behaviour survived the delegation.
`TAB_STOP_COLUMNS` and `UnicodeWidthChar` are now unused imports in
pmacs-gpu and are dropped --- which is itself the check that no copy of
the rule remains in this crate.
The previous commit CLAIMED the widest-line rule was shared. It was not.
The daemon called `src/display_width.rs`; the GPU folded through its own
private `advance_display_col`, a second copy of the same tab-stop and
Unicode-width arithmetic. The two agreed for ordinary input, so nothing
failed --- which is precisely why the claim was worth checking and why
asserting structural protection that does not exist is the defect, not
the duplication itself.
`pmacs_protocol::columns` now owns the rule, for the same reason
`scroll::follow_left` lives there: the protocol crate is the one place
both frontends already depend on. `advance_char`, `line_columns` and
`widest_line_columns` live there with their own rows; `display_width`
and the GPU both delegate.
The sharing is now demonstrated rather than described. Mutating the tab
stop inside `pmacs_protocol::columns` breaks the GPU's
`minimap_columns_match_code_tab_and_unicode_widths` --- a row that used
to run entirely through the private copy and could not have noticed.
Also restores `r4_p1_a_chrome_press_neither_arms_nor_moves_point`'s
opening line, "P1 --- a press on the band's MODE LINE begins nothing",
which my insertion had left attached to the B2 test. The attribute came
back last round; the first paragraph did not.
`PKind::ScrollLeft | PKind::ScrollRight` were CLAIMED AND DROPPED in the
panel replay, with a comment assigning the axis to Stage 1b. That is the
"frontend emits, receiver discards" shape the panel-replay lane was
opened to fix, inherited for the horizontal axis. This closes it.
`scroll_window_columns` moves the side window's `view_left` by B7's
bound, stated exactly: `0 ..= widest - viewport`, saturating at zero, so
the final display column stays visible --- clamping at the widest line's
full width would let the origin pass every glyph and blank the viewport.
Wrap pins the origin to zero, matching `horizontal_follow`. It returns
whether the origin actually moved, which is lifetime clause 2's
"effective move".
The widest-line rule is SHARED. `display_width::widest_line_columns`
lives beside the module's other column helpers and both frontends use
it, for the same reason `scroll::follow_left` is shared: two frontends
that compute the right bound differently disagree about where the
document ends.
B2's row asserts the EFFECT --- `view_left` before and after --- not an
emission, and it carries the discriminating setup the bound requires: a
panel whose content fits has a maximum origin of zero, so the move is
absorbed by the clamp and a dropped event reads identical to correct
behaviour. The fixture gets a line wider than the viewport. Mutation:
restore the claimed-and-dropped arm, and the row fires.
Two mistakes of mine in this commit's history, both caught before it:
- I reverted a mutation with `git checkout -- src/editor.rs` on a file
holding UNCOMMITTED work, and destroyed the whole B2 implementation.
Re-applied, and the mutation check redone against a file snapshot ---
the discipline I had used earlier in the CRDT lane and dropped here.
- Inserting the new test above an existing one STOLE ITS `#[test]` and
its doc comment, so `r4_p1_a_chrome_press_neither_arms_nor_moves_point`
silently stopped being a test. Clippy's "never used" caught it. Both
are restored, and the suite count confirms 1994 tests rather than
1993.
Three implementation blockers and one evidence-labelling defect, all
from review.
THE WIRE TARGETS MULTIPLIED ONE NOTCH TWICE. `apply_wheel` banked in
LINES (notch x 3), then emitted one event per banked unit, and the
receiver applied its own SCROLL_LINES = 3 to each. So LineDelta(0, 1)
moved a panel or terminal NINE lines while the document moved three ---
and it broke the "exactly one viewport effect" witness this slice owes
before it was written. The accumulator now banks in NOTCHES, the unit
that survives the wire, and the three-line/column step is applied
exactly once at the point of effect. Pixel deltas divide by a notch's
pixel height rather than a line's.
THE MINIMAP SCROLLED THE DOCUMENT SIDEWAYS. §2a rules the minimap's
horizontal axis inert; the shared local arm was passing its banked x to
`scroll_by_columns`. It keeps its own vertical bank (B6) and no longer
moves the document horizontally.
B3's UPPER BOUND SAW ONLY THE VISIBLE SLICE. `widest_display_columns`
scanned `self.buffer.lines`, which `rebuild_code_slice` populates from
the visible window plus overscan, so every off-screen line was excluded:
horizontal scrolling clamped prematurely and the bound moved as the view
scrolled vertically. It now reads `current_text` --- the whole document
--- through a display-column rule shared with the minimap rather than a
third copy. Cost is O(document) on the wheel path, which is a real risk
against this project's wall-clock budgets and is recorded on the
function rather than pre-optimised: a cache needs an invalidation key,
and the wrong key is a worse defect than a measurable scan.
AND THE R-NAMES WERE WRONG. The rows I called R4 and R5 test
document/chrome sharing and minimap independence; the framing's R4 and
R5 are the two BUFFER-REPLACEMENT resets. The row I called R1 is basic
accumulation; the real R1 is cross-axis. Renamed, and R1's body now
asserts what R1 says --- a sub-tick horizontal followed by a sub-tick
vertical over the same surface reaches no tick on either axis.
The resets themselves are now implemented, on the buffer-replacement
path beside `code_scroll_left`, as two separate clears so that omitting
one is individually visible. Their witnesses --- an actual replacement
through the harness --- are still owed and are labelled as such.
The producer 1b owes. `apply_wheel` used to round to whole lines and
return on zero BEFORE consulting the pointer, so every sub-tick delta
bound for the panel or the terminal was discarded by a decision taken
upstream of routing. §2a CORRECTION 5 measured that ordering; this
inverts it.
The pipeline is now: classify the target, bank the fractional delta
against THAT target's accumulator, route only the whole ticks that fall
out. `WheelTarget` exists because `PointerSurface` cannot name what B1
needs --- it resolves panel geometry only and collapses the document,
the terminal, the minimap and the chrome into one `Elsewhere`, three of
which B1 and B6 must keep apart.
Residual owners follow §2a's enumeration exactly: per panel, per
terminal, the minimap's own, the document's --- and chrome shares the
document's deliberately, so a gesture that strays onto the gutter does
not lose its banked motion. Panel divider and background bank NOWHERE
and clear the panel banks: a residual they could share with a cell would
let motion over an inert strip complete a tick the moment the pointer
entered a live one, which is the surface-switch jump B1 exists to
forbid.
Nine rows, including R1-R5's identity discriminators and §2a's required
crossing witness. `trunc` rather than `round`, so a half-tick that was
never delivered is not spent.
Two things this commit does not do, both recorded rather than stubbed:
- IDENTITY'S SECOND HALF --- disposal --- is owed. A residual keyed to a
surface that goes away must go with it, and this frontend does not
currently track "that buffer is gone". A helper nothing calls would
read as a contract met, so the method is absent and the gap is
documented on the type.
- `scroll_by_columns` and the manual-authority latch land here as B3/B7
and Q#S1-11's foundation, but their witnesses (L1-L8) do not. They
come with the horizontal wire path.
One behaviour regression caught by an existing row and fixed: a wheel
before the first cursor motion has no pointer position, and the first
draft dropped it. It targets the document, as it did before 1b.
Merged rather than rebased, by decision: the lane's 12 commits include
10 framing revisions that all touch the same 800-1000 line doc regions,
so a rebase meant twelve rounds of large-block conflict resolution ---
the operation that produced a committed diff3 marker on the last lane.
One pass instead, with all 12 commits preserved.
Resolutions:
- src/daemon.rs --- took main's structure whole, both inbound arms with
the latch gated on the dispatcher's answer, and threaded replay's
`mods` through both call sites. `mods` is newly BOUND in the mapped
arm, which SS5b left in `..`; the mapped family carries the same
modifiers, so leaving it would have given a v25 session the inverted
Shift behaviour that parent 48 R-a fixed for v24.
- pmacs-gpu/src/main.rs --- additive throughout: both new struct fields
(`gesture_last_content_cell`, `last_pointer_generation`), both resets
at each site, and both test blocks.
- src/editor.rs --- auto-merged; the merged dispatcher keeps SS5b's
`#[must_use]`, its four rejection paths and its `-> bool`, plus
replay's `&mut self`, `mods`, chrome/mode-line handling and terminal
gesture application.
- docs/active-work.md --- the active replay lane above main's corrected
#239/#240/#242 headers.
- docs/bottom-panel-framing.md --- 5a then 5b. The paragraph arguing
the v25->v26 bump should be "recorded as required rather than made"
is marked superseded: SS5b made it and merged as #242.
Workspace compiles clean, all targets, no warnings.
THE MERGE SURFACES A SEMANTIC COLLISION THE FRAMING MUST RULE ON, and
it is not resolved here. The two branches give the dispatcher's bool
different meanings: for SS5b `true` means the gesture was ACCEPTED, and
it drives the accepted-gesture latch; for replay `true` means the event
was CONSUMED HERE, including chrome swallows. So a press on the band's
mode line now returns true and ARMS the latch --- a gesture that never
began in content, which is the defect class SS5b's review round four
found and fixed. Recorded, not patched, because which rows own the
answer is a framing question and the next revision owes it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Closes the rest of this slice's G9-G15 obligations. One production
change, one design correction found by mutation, and the rest witnesses.
G9b is the production change. `panel_motion_is_new` compared the cell
alone, so the FIRST motion after the mapping moved was eaten --- the
pointer has not travelled, but the cell now denotes different text, and
that is exactly the motion the daemon needs to re-anchor the gesture.
Now keyed by `(generation, cell)`. Deliberately read at the motion site
rather than reset from the frame path: resetting on every accepted
repaint would re-arm within one generation and bring pixel-rate traffic
straight back.
G9a, G9c, G10, G10a, G10c are witnesses over behaviour that was already
correct: a generation change ships even when the visible cells are
byte-identical; an identical frame at a higher generation still moves
authority; invalid frames, zero generations and lower generations are
each refused with frame AND generation retained; `Absent` does not erase
the high-water mark.
G15 is the TUI structural control, and its fixture IS the control: the
session has no `SemanticRenderState` at all, so every local click, drag
and wheel effect below is reached without a producer in existence. That
is a stronger claim than asserting a value was not consulted. Both wheel
ticks must land, for the same reason the mapped family needs its
exemption.
The design correction: G10a's first version asserted the zero refusal
with a generation already HELD. Mutating the zero check away left it
green --- zero is also *lower* than the held value, so the nondecreasing
clause refused the frame and the row proved nothing about zero. Zero is
only isolable before any authority exists, which is also the case the
framing names: a sender that never initialised the field. Split into its
own row with that setup, and the row says why.
Mutations, each biting only its named row:
- dedupe compares the cell alone -> G9b
- the frame path re-arms the dedupe on every accepted frame -> G9b
- daemon dedupes across the generation change -> G9a
- return early on frame equality before applying the generation -> G9c
- apply the generation before validating -> G10
- accept generation zero -> G10a (after the split; before it, this
mutation SURVIVED)
- `Absent` erases the high-water mark -> G10c
- a lower generation is accepted -> G10c
- panel input requires a token the TUI cannot have -> G15
Deferred, per SS5b's split table and unchanged here: G11b (exhaustion
cancellation), G12a/G12b (both two-tick wheel EFFECTS), G6c/G7c.
Verified: `cargo fmt --check`; `cargo clippy --workspace --all-targets
-- -D warnings`; `cargo test --lib` (1959); `cargo test -p pmacs-gpu
--bins` (280); `bottom_panel_stage1_acceptance` (47),
`bottom_panel_stage2b_daemon_acceptance` (39),
`bottom_panel_stage2b_gpu_acceptance` (2); `git diff --check`. Clippy
caught two findings in the new test code after the suites were already
green, which is why it runs as its own step.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Three rows that share a shape: each is a gate this slice owns whose
downstream EFFECT belongs to the rebased replay lane.
G10b --- ordering, and a carve-out. `panel_mapping_is_current` now takes
the event kind. Zero is refused FIRST, then coordinate-free wheels skip
the freshness comparison. The order is the row: run the carve-out first
and a sender emitting zeroed wheels faces no check at all, which is an
inbound opt-out through the exempt path. The exemption exists because a
tick changes `view_top` and so advances the key --- the next tick already
queued behind it echoes the previous generation, and without the
carve-out the panel scrolls once per frame and appears dead. It returns
before the read, so a wheel does not advance the key either; advancing
would make a wheel invalidate the press after it.
The framing's carve-out-to-the-carve-out, re-imposing the check for
CHILD-REPORTED terminal wheels where SGR carries row and column, is
replay's. Whether a wheel is forwarded is decided by the reporting mode,
and no panel pointer coordinate is consumed on this base at all.
G11a --- exhaustion fails CLOSED. `saturating_add` froze the key at the
ceiling while the mapping kept moving underneath it: the stale-gesture
hole the key exists to close, with the check still appearing to pass.
Now `checked_add`, and overflow publishes `Absent`, clears input
authority, and latches for the session.
G13a/G13b --- `PanelPointerMapped` fell through `coalesce_kind` to
`None`, so pixel-rate mapped motion was lossless and filled the bounded
outbox. Two tags of its own; tail-replacement takes the whole event, so
coordinate and generation advance together and a collapsed run can never
pair a new coordinate with a stale one. Press, release and every wheel
kind stay lossless.
Mutation results, including two that changed the design:
- exemption before the nonzero check -> G10b(zero) alone
- no wheel exemption -> G10b(exemption) alone
- saturating instead of checked add -> G11a alone
- no exhaustion latch -> G11a, but only AFTER the row was extended.
The first version of G11a did not bite: the latch had no proven
job, because the overflow path already returns before storing the
ceiling snapshot, so the next read re-takes the changed arm anyway.
Measured, the two are ALTERNATIVES --- either alone keeps the band
down; only removing both resurrects it. The latch is kept as the
primary because it has a job the ordering does not: `peek` now
honours it, so the peek and the authoritative read agree that an
exhausted session has no key rather than reporting the ceiling.
The source comment says this, rather than the "second half" claim
it made before the measurement.
- mapped variants untagged / one tag for all kinds / sharing the
legacy tags -> the mapped coalescing row alone, three times
Witness-shape note: the two G10b rows call the predicate directly, and
say why. A wheel has no dispatcher-visible effect on this base --- a
document panel focuses on `Down` only --- so asserting focus for a wheel
would prove nothing. Each row carries a press leg, which does have an
effect, to show the predicate is wired into the production arm.
Verified: `cargo fmt --check`; `cargo clippy --workspace --all-targets
-- -D warnings`; `cargo test --lib` (1959); `cargo test -p pmacs-gpu
--bins` (275); both `bottom_panel_stage2b_*` suites (39); `git diff
--check`. `composition_overhead_under_ten_percent` red once during this
work and green in isolation --- a second occurrence of a signature the
lane ledger already carries, now recorded there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The G6/G7/G8 matrix, plus the probe correction and the corrections
review found in my first attempt at these rows. This is the coherent
checkpoint: full eleven-stage `--protocol` gate, green.
**THREE OF THESE ROWS PASSED WITHOUT PROVING THEIR CLAIM**, and each
failed differently:
G8b/G8d had no atomicity. G8b installed a LEGACY frame and then
switched the session to Mapped, so `mapping_generation` was `None`
throughout --- asserting it stayed `None` after the refusal asserted
nothing. Each direction now uses an independent state, accepts a
CORRECT-FAMILY baseline so there is real authority to preserve, and
primes both pointer latches. Two new mutations pin it: clearing
authority before refusing fails G8b, discarding the retained frame
fails G8d. The family-gate mutation touched neither.
G8e covered one direction. An authority check that only holds one way
is one a peer walks around by choosing which identity to forge, so a
mapped session now also fails to borrow a legacy identity --- and
BOTH claimed identities have real registered sessions, or a
payload-keyed lookup fails for want of a session rather than for want
of authority. That was why G8e's own named mutation did not bite on
the first attempt.
G6b measured ambient state. It pre-focused the panel and then asserted
against `active_window_id()`, which tracks `active_frontend` too ---
satisfiable by a frontend switch that never routed anything. Every
routing and refusal row asserts `views[fid].active` now, with the
document precondition stated rather than assumed.
**And the probe measured the payload rather than the band, twice over.**
Its identity tuple was `(panel_epoch, geometry_epoch, size)`, which
ordinary content, focus, cursor and generation updates all leave
unchanged --- so accepted frames went uncounted, including the
identical-frame/higher-generation case this slice requires, and a
fixture waiting for two frames would wait forever. It snapshots the
complete accepted authority now, `(presented frame, mapping_generation)`,
and keeps the raw payload kind ONLY to tell a real `Absent` from a
refusal: inferring absence from `presented() == None` turned a rejection
into "the daemon says there is no band", a different fact entirely.
Nine rows, ten mutations, each biting its own:
G6a legacy outbound G7a mapped outbound, live generation
G6b legacy inbound routing G7b mapped inbound routing
G8a bare from v25 refused G8c mapped from v24 refused
G8b legacy at v25 refused, atomically
G8d mapped at v24 refused, atomically
G8e both forgery directions
plus: an Unsupported session accepts NEITHER family
G6c/G7c remain replay-lane effects.
**The gate earned its keep**: it caught a real regression I would have
shipped. `one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client`
counter-offers `PROTOCOL_VERSION`, now 25, so it is a MAPPED session
whose helper drained for legacy `Present` and timed out. Third suite
whose helpers assumed one family --- daemon acceptance, the GPU probe,
now GPU acceptance --- each written when only one family existed and
each quietly deciding what "a panel arrived" means.
Four `--protocol` runs were needed. Three failed on unrelated
signatures: the composition budget twice, in different steps, and
`setsid_escapee_is_not_reaped_and_teardown_reclaims_readers` once, a
new signature. All are recorded in the lane ledger rather than
`ci-red-signatures.md`, which ends at U9 here while the unmerged replay
branch already holds a U10.
Gates: all eleven green under `env -u TMPDIR` with `--protocol`,
log 20260815T185708Z, verified by exit status.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
**`Unsupported` WAS ACCEPTING LEGACY FRAMES.** I gated the legacy arm
on `!= Mapped`, and `Unsupported` is neither --- so a session below
`PANEL_MIN_VERSION` accepted a band it never negotiated. The
`carries_panel()` check I had in mind guards
`next_geometry_declaration`, a different seam entirely. Both present
arms gate on their POSITIVE family now, which is the shape that cannot
grow this hole again when a fourth family appears.
**AND THE PROBE MEASURED THE PAYLOAD, NOT THE BAND.** It recorded panel
facts before `apply_attach_message` ruled on the message, so once one
valid frame had landed, a REJECTED frame --- wrong family, invalid,
stale generation --- still supplied the expected text while the
retained old frame supplied the rendering. The probe would report the
band showing something it does not show, which is a false positive in
the one place that exists to tell us the band is real. (The false
NEGATIVE, observing only the legacy family, was the previous commit.)
Facts now come from `state.panel.presented()` after the apply, and
`panel_frames` counts only when the retained frame's identity actually
moved: a duplicate or a refusal leaves the band exactly as it was, and
counting either would say the daemon is painting when it is not.
**Third rustdoc split in this slice**, same mechanism each time ---
`screen_size`, `peer_may_send_panel_events`, now
`send_panel_pointer`. I insert a function at what reads as a gap
between declarations, when the lines above it are the NEXT function's
documentation. The check is to look UP from the insertion point, not
just down, and I will apply it rather than keep reporting the same
correction.
Verified by exit status: clippy 0, `-p pmacs-gpu` 0 (271 passed).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The frontend half of bilateral gating. The nine discriminating rows
(G6a/b, G7a/b, G8a-e) and the full protocol gate are the checkpoint
after this.
**ONE ENUM, DERIVED ONCE.** `PanelFamily::{Unsupported, Legacy,
Mapped}` replaces the `panel_wire` bool, classified from
`session_protocol_version` at all four existing sites --- initial
attach and each reconnect --- and read by BOTH payload acceptance and
pointer production. Deriving those two independently is how a frontend
ends up accepting one family while producing the other: it would speak
v25 inbound and v24 outbound, and neither side could tell.
Acceptance refuses both wrong-family cases, atomically: a mapped
session rejects legacy `Present` rather than painting a band whose
cells it cannot safely invert (G8b), and a legacy session rejects
`PresentMapped` (G8d). The mapped arm also refuses generation zero and
any generation BELOW the one held --- nondecreasing, so a frame delayed
across a hide cannot roll authority backward --- while a duplicate
frame at a HIGHER generation is still accepted, because the daemon has
re-keyed the mapping and echoing the stale value would have every
gesture refused.
Production goes through one family-aware sender used by both send
sites, so they cannot drift. A mapped session with no retained
generation sends NOTHING rather than the legacy variant: falling back
is the frontend half of the bypass, and the daemon refuses it anyway.
**And the live probe observed only the legacy family.** Left alone,
mapped production could have worked end to end while the probe reported
no panel --- a false negative in the one place that exists to tell us
the band is real. It matches both now.
One thing NOT done here, deliberately: the gesture-latch reset on an
identity change is R-d, owned by `panel-pointer-replay`. I had copied
it into the mapped arm before noticing `gesture_last_content_cell` does
not exist on this branch --- it is replay-lane state. Two branches
resetting the same latch would conflict at the rebase and neither would
own the contract, so this arm installs the frame and its generation and
nothing more.
Verified by exit status: clippy 0, `-p pmacs-gpu` 0 (271 passed).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
SS5b's first implementation commit: the two appended variants, the
version constants, and the pins that hold them in place. No gating, no
key, no replay --- those are the next commits, and the variants are
REFUSED everywhere until their gate lands.
**APPENDED AT THE TRUE END, confirmed by the discriminants.**
`PanelPointer` is 15, `TextInput` 16, `PanelPointerMapped` **17**;
`Present` 0, `Absent` 1, `PresentMapped` **2**. "Beside `Present`" would
have been adjacent insertion, which shifts every discriminant below and
silently re-interprets an older peer's bytes. `mapping_generation` is a
`u64`, last within each variant, documented invalid at zero --- the
value a default-constructed sender produces, so accepting it would let
a peer opt out of the check by sending nothing.
**THE COMPILER NAMED EVERY SEAM.** Four non-exhaustive matches:
`semantic_render`'s declaration accessor now sees through both
families, and the three routing sites REFUSE the mapped variant rather
than unwrapping it to legacy meaning. Refusal is the correct default at
an intermediate commit, not a placeholder --- until the frontend can
prove it negotiated v25 it IS a `<= v24` peer for gating purposes, and
painting first would ship a window in which the band is hit-tested with
no mapping identity at all.
**Five mutations, each biting its own rows:**
insert `PanelPointerMapped` before `TextInput`
-> the TextInput pin and the mapped pin. `PanelPointer`'s v23 pin
correctly SURVIVES: its discriminant did not move, which is the
"only the pin whose discriminant moved fails" behaviour G0a
specifies
insert `PresentMapped` before `Absent`
-> the Absent pin and the mapped-frame pin
swap `geometry_epoch` / `panel_epoch`
-> the exact-bytes assertion, while the round-trip stays green.
That is the blind spot G0b exists for, and it is why every
adjacent same-typed field carries a distinct value
bump the wire version without extending the supported set
-> both new tripwires and 1a's v6 ladder
move `ADVERTISED_PROTOCOL_VERSION` to 25
-> the baseline pin
**Version fallout, enumerated rather than discovered one gate at a
time.** Four acceptance-suite tripwires (`bottom_panel_stage2b_gpu`,
`discovery_stage2` x2, `vterm_stage3`, `statusline_segments`) each say
"a wire bump must be a conscious edit here" and each worked. Rather
than fix them one run at a time I grepped the tree for version
assertions and updated all four in one pass.
Review folded five further corrections, two of which fix reasoning of
mine that was wrong:
- I claimed reversing `frame` and `mapping_generation` "fails to
compile" because they are different types. **False for NAMED
variant fields** --- the initializer uses names, so reordering the
declarations compiles and shifts postcard's positional bytes
silently. The pin is the only thing catching that.
- Ladder loops now track `PROTOCOL_VERSION` while TRIPWIRES stay
literal. I had flattened both to `25`. A tripwire is literal so a
bump is a conscious edit; a ladder must move, or the next bump
silently stops testing the top rung. G14b is unaffected ---
`PANEL_MAPPING_MIN_VERSION` stays literal, because there the
arithmetic is exactly the hazard.
- `assert!(24 < MIN)` was a compile-time tautology holding for every
value above 24. Replaced with the literal equality plus
`assert_ne!` against `TEXT_INPUT_MIN_VERSION`: the mapped family
must not share v24's gate, or it is admitted on sessions that
negotiated only `TextInput`.
- Statusline support loop reaches `PROTOCOL_VERSION`; public protocol
history records v25.
**CI-red observations are in the LANE LEDGER, not the registry**, and
that is deliberate: `ci-red-signatures.md` here ends at U9 while the
unmerged replay branch already added a U10, so a row from this branch
would duplicate an id or invent one blind --- which this file's own
history records going wrong, two branches' entries merging "without a
conflict, producing duplicate ids across four sites". R7 twice and the
composition budget once, fragments verified, owed to the registry by
whichever branch merges second.
Gates: all eleven green under `env -u TMPDIR` with `--protocol`,
log 20260815T103555Z. Four runs were needed; three were lost to those
two signatures, not to this diff.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
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
First implementation commit for parent acceptance 48. The daemon-side
replay and every producer-side rule land here; the daemon-side witness
matrices (A1-A5, B1-B6, and Q#BP-R2's document->terminal row) are the
next commit and are NOT claimed yet.
**MODIFIERS NOW CROSS THE SEAM (R-a).** The daemon destructured `mods`
into `..` and the dispatcher had no parameter for them, which inverted
two behaviours rather than degrading them: `apply_terminal_gesture`
gates child reporting on `!shift`, so Shift is the user's "select
locally instead of talking to the child" override, and the document
path reads Shift to extend the selection.
**THE REPLAY IS THE SHARED PATHS, NOT NEW ONES.** A terminal panel goes
through `apply_terminal_gesture` --- "the one terminal pointer path,
shared by both frontend kinds" --- with the side window's
`TerminalViewKey` and a viewport of `rows - 1`, never the full grid: the
frame would make the mode line a child cell and put every clamp a row
out. A document panel scrolls through the window-scoped `scroll_window`
and replays selection through new window-TARGETED writers.
Those writers exist because the selection API is active-window scoped.
`Drag` and `Up` do not activate, and another frontend can interleave
between a `Down` and its tail, so a replay reading `active_window_mut()`
would act on whatever happened to be active then. `panel_cell_byte`
converts against the SIDE window's own `view_top` and fold map without
`activate_and_position`'s `set_active_window_id`. The one place the
ambient helper is used is the double-click word selection, two
statements after the `Down` activated that window synchronously, and it
says so.
**Q#BP-R2 IS ORDERED, NOT MERELY PLACED.** A terminal panel's chrome
wheel is consumed before `focus_window`, before `active_frontend`,
before any controller claim and before the shared path --- `activates`
is `!Move` for a terminal, so a check any lower would leave the wheel
changing FOCUS while scrolling nothing.
Producer half, all target-blind because `PanelFrame` carries no
target-kind field:
- a press on the band's MODE LINE neither sends nor arms. Arming
would let a drag into content emit a `Drag` with no accepted
`Down`, which no receiver-side rule can undo.
- `gesture_last_content_cell`, a TERMINATION FALLBACK distinct from
the dedupe baseline. `last_pointer_cell` is cleared on press
precisely so the first drag after a press reaches the daemon
(asserted at `main.rs:19841`); storing the press cell there would
suppress it. The new field is written on arm and on each accepted
content motion, cleared on release and on either identity change,
and `panel_motion_is_new` never consults it.
- a crossing `Drag` is normalized and then deduped; `Up` is always
sent, always at a content coordinate.
- the gesture latch now dies on a change of EITHER identity --- panel
or geometry --- and survives a same-identity repaint.
Six mutations, each biting its own row:
M-P1 arm on a chrome press -> the producer arming row
M-P2 release reads the dedupe field -> the chrome and no-motion rows
M-D1 no reset on panel epoch -> the identity row
M-D2 no reset on geometry epoch -> the identity row
M-D3 reset clears `pointer_held` only -> the identity row
M-D4 reset on every frame -> the identity row's negative leg
M-P2 caught a defect in my own witness before it caught the code: the
no-intervening-motion row called `panel_motion_is_new` BEFORE asserting
the release, which populated the very field the mutation reads, so a
conflated implementation passed. The probe now runs after the
assertion, and the row is named for a scenario it actually performs.
One existing test moved with the contract rather than against it:
`a_held_button_makes_panel_motion_a_drag_and_a_release_lands_outside`
poked `panel_motion_is_new` and expected the release to follow it. It
now drives both fields as the production motion path does; its
assertion, and the dedupe guarantee it protects, are unchanged.
Gates: all nine green under `env -u TMPDIR`, log 20260814T151901Z.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
**A1 is an exhaustive loop over all 35 function keys, not spot checks.**
The defect it closes was `_ => return None` swallowing F13 upward, and a
test covering F1-F12 would have passed against exactly that. Each row
also asserts `should_forward_key`, because translating without
forwarding leaves a key mapped and inert --- which reads as a daemon
keymap gap rather than a frontend one.
**A2 asserts both halves**: `BackTab`, and `Shift` still set. A
`BackTab` that lost its modifier is indistinguishable from one the user
did not shift. **A3** likewise pairs the mapping with forwarding.
**A4 establishes idle rather than asserting it.** A fresh `State` starts
with `dispatch_idle` false --- the daemon has not spoken yet --- so the
first version of the row asserted the precondition and failed. Had it
been written the other way round it would have tested the INTERCEPTING
case under an idle name, which is the state where Escape never quit
anyway: the row would have passed while proving nothing about the
behaviour A4 changes. It now sets idle, confirms nothing intercepts, and
asserts both halves: the Escape reaches the daemon AND no exit occurs.
**The frozen-byte pin sits on `PanelPointer`, not on `TextInput`, and
the placement is the point.** `TextInput` is appended, so its own
round-trip is byte-identical whether or not a variant was inserted
beneath it; only the PREVIOUS final variant's bytes move. Every v6-v23
daemon decodes the variants below `PanelPointer` on every session, so an
insertion anywhere earlier is a silent wire break for all of them.
MY FIRST MUTATION OF THAT PIN WAS WRONG AND THE PIN WAS RIGHT. I
inserted the wedge variant before `TextInput` --- which is to say AFTER
`PanelPointer`, exactly where an append belongs --- and the pin passed,
correctly, because nothing shifted. Re-run with the wedge BEFORE
`PanelPointer`, it fails with the discriminant visibly moving 15 -> 16.
Worth recording because a mutation that targets the wrong side of the
boundary reports the pin as vacuous when it is sound.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The first of the three remaining discriminators: **multi-scalar text
reaches the wire as `TextInput` while the daemon is INTERCEPTING** ---
the state a modal prompt or a focused terminal puts the session in, and
the state under which A7 and A8 were unreachable before review round 1.
**Getting there required narrowing the 1-pre exception, which is the
substantive part.** `apply_keyboard` took a `&KeyEvent`; `KeyEvent`
carries a `pub(crate)` field and cannot be constructed outside winit, so
the body was undrivable and only the pure classifier could be tested ---
which is precisely why the defect survived: the classifier was correct
throughout and the CALL SITE was wrong. It now takes the two fields it
actually reads, `&Key` and `Option<&str>`, both ordinary constructible
values.
The exception does not disappear, it shrinks: the router arm still
cannot be handed a `WindowEvent::KeyboardInput`, so what remains
unwitnessed is one pattern arm containing a match and a call. That is
recorded on `apply_keyboard` itself, where the next reader meets it.
**M-1a-3 reinstates the original defect** --- the selection moved back
below the intercept return --- **and fails the new row alone**, 23 of 24
still green. That is the shape the review asked for: a witness that
fails for the reason the defect existed.
Its complement is included so the pair cannot be satisfied by sending
`TextInput` for everything: a SINGLE scalar while intercepting still
travels as `Key`, which is §5 rule 4 preserving mode keymaps and typed
provenance.
The harness gains `feed_keyboard`, and the local-effect diffing it
shares with `feed` is extracted rather than copied --- two entry points
observing different effect sets by accident is the kind of divergence
that makes a transcript lie.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
**A7 AND A8 WERE UNREACHABLE FROM THE REAL PRODUCER.** The intercept
branch sends a truncated `Key` and returns, and TextInput classification
sat below it --- but a modal prompt or a focused terminal is exactly
what makes `daemon_intercepts_keys` true, so the two contracts about
prompts and terminals were reachable only when neither was present. The
selection moves ABOVE the intercept return, where the producer sends the
same `TextInput` in every state and the daemon applies the modal
precedence, which is where it belongs: the frontend cannot see which
shadow is up.
Ordering against the branches below is safe by construction rather than
by luck --- `text_input_payload` returns `None` whenever a command
modifier is held, so Ctrl-V and command chords can never be shadowed.
**A pure `text_input_payload` test cannot catch this**, which is the
lesson worth keeping: the classifier was right the whole time and the
call site was wrong. The witness has to drive `intercept = true` and a
terminal.
**SINGLE-SCALAR PROVENANCE WAS PROMISED IN A COMMENT AND NOT
IMPLEMENTED.** §5 rules that a single-scalar commit is indistinguishable
from a keypress; the code only broke the chain for multi-scalar and
called a generic insert, so `this_command` went stale and no
`TypedEditRecord` was produced. Auto-pairing (Q#AP9) and every other
typed-edit consumer would have silently stopped recognizing GUI input
--- surfacing as "auto-pair stopped working in the GUI", far from its
cause. Now runs the real machinery: `rotate_command("buffer.self-insert")`
-> `typed_edit_arm(ch)` -> the one edit -> `typed_edit_finish` ->
`typed_edit_set_armed` -> `buffer.after-edit` -> clear, which is the
tail `dispatch_key` already runs.
**THE PRODUCER GATE WAS ONLY HALF THE WIRE CONTRACT.** The daemon
accepted `TextInput` from every installed session, so a peer negotiated
at v6-v23 --- compiled from this same crate, and postcard will happily
write the discriminant --- could mutate a buffer through a variant its
own session never declared. Now gated on the AUTHENTICATED session's
negotiated version.
**A4's structural half is implemented, not just its behaviour.**
`apply_keyboard` returns `()`, so `LifecycleRoute::Exit` is the sole
`EventOutcome::Exit` producer and the obsolete keyboard-exit channel is
gone rather than merely unused. The type survives, as ruled: one
producer is not one variant.
Also: `dispatch_text_input`'s rustdoc claimed a boolean return that its
signature does not have.
VERSION FALLOUT, SORTED RATHER THAN RENUMBERED.
Six deliberate tripwires took the conscious edit they exist to force
(protocol.rs, bottom-panel, discovery x2, statusline, and the vterm one
that was missing from my inventory). Two carried the version in their
NAME, so the name moved with the number rather than being left to lie.
Two ceiling assertions --- `!is_supported_protocol_version(24)` ---
now probe `PROTOCOL_VERSION + 1`, so they keep meaning "the set ends at
the current wire" instead of needing a hand-edit every bump.
`m4_6_handshake_accepts_v6_peer` was GENUINELY DEFECTIVE and is the one
real find: its name and the M4.6 contract say **v6 is the floor**, but
its body asserted `is_supported_protocol_version(PROTOCOL_VERSION)` ---
"the current wire accepts itself", a different and far weaker claim that
would have kept passing after v6 was dropped from the supported set,
which is the only regression it exists to catch. Anchored on literal 6.
The M10 pair needed no edit: they already use `PROTOCOL_VERSION`, and
they failed in the first sweep only because it predated the
`SUPPORTED_PROTOCOL_VERSIONS` fix.
`ADVERTISED_PROTOCOL_VERSION == 20` did not fire, as it must not.
Full `--workspace --no-fail-fast` sweep clean under an isolated TMPDIR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The mechanism, without its witnesses yet; tests follow in the next
commits.
**A1-A3 were mapping gaps, and forwarding was half the fix.**
`translate_key` gained F1-F35, Shift+Tab -> `BackTab`, and
`ContextMenu` -> `Menu`. All three already existed in the protocol
`Key` enum and the TUI already sent them, so this closes a divergence
rather than inventing a convention. **`should_forward_key` had to learn
them too** --- translated but unforwarded, they would have mapped
correctly and still done nothing, which reads as a daemon keymap gap
rather than a frontend one. They forward with ANY modifier, like motion
keys: they are command keys that never insert text, so the
chord-withholding rule has nothing to protect them from.
F-keys are an exhaustive match, not arithmetic off `F1`: winit's
`NamedKey` is `#[non_exhaustive]` and its ordering is not a contract, so
an offset would corrupt silently the day a variant is inserted.
**A4 --- every Escape now reaches the daemon and none exits.** The
`intercept || completion_open` test went with the quit branch: it never
decided what to SEND (both arms sent the same `Escape`), only whether to
send at all, and with one behaviour left there is nothing to choose.
Both flags remain live for the OS-paste, round-trip and
completion-accept paths.
**The v24 wire variant is APPENDED and the reason is postcard.** It
encodes a variant by positional index, so widening any variant above
would re-interpret every older peer's bytes. `TextInput` carries an
untrusted `frontend_id` like its neighbours --- the daemon uses the
authenticated source --- plus the text.
**It is not `Paste`, and the difference is behavioural.** A terminal
receives it as RAW UTF-8, never bracketed (A8): a shell that sees
`ESC[200~` treats input as pasted and changes how it handles newlines
and completion. The clipboard slot is untouched, because nothing was
copied. And the document path is ONE edit (A6) --- one undo unit, one
`buffer.after-edit`, one eligible CRDT op --- which is the entire reason
the variant exists, since a two-scalar grapheme sent as two keypresses
is two undo units that a remote edit can interleave.
**A5's precedence is a pure function** (`text_input_payload`) so the
eight rules are testable without a window. A keypress stays `Key` unless
a rule moves it, and only printable MULTI-scalar moves; the version gate
WITHHOLDS rather than degrades, so a `< 24` daemon keeps exactly the
behaviour it has, truncation included.
**A7's ordering falls out of routing through the existing shadow
handlers** one scalar at a time, rather than reaching into prompt state:
history, completion and acceptance stay in one place.
THE 1-PRE EFFECT HARNESS CAUGHT A REAL DEFECT IN THIS COMMIT. Bumping
`PROTOCOL_VERSION` to 24 while leaving `SUPPORTED_PROTOCOL_VERSIONS` at
`..=23` made the handshake reject its own version. All NINE effect rows
failed while the thirteen routing rows passed --- the M21 signature,
meaning `EffectHarness::new` could not attach at all. A pure-routing
harness would have stayed green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Two review findings, one of them a real defect.
**THE SENTINEL READ COULD HANG FOREVER.** `read_until_sentinel` blocked
with no bound, so a writer or encoder that regressed after `enqueue`
would WEDGE THE GATE rather than redden it --- and a hang is the worst
failure shape there is, because it looks like slowness until the job is
killed. A 30 s `READ_CEILING` is armed on the daemon socket.
The distinction is kept explicit in the code, because collapsing it is
how this fix would undo the design it protects: **the sentinel remains
the success condition and the ceiling is only an error ceiling.**
Arrival is still decided by the sentinel, so the harness never infers
"nothing was sent" from a duration --- the core-count assumption behind
PR #235's CI red is not reintroduced. The ceiling sits far above any
plausible drain, so reaching it means broken, never busy.
M24 proves it fires rather than trusting it: drop the sentinel enqueue
entirely and the row fails in under a second with a diagnostic naming
both candidate causes and the partial transcript, instead of hanging.
**THE STAGE 1a CONSEQUENCE WAS WRONG IN FOUR PLACES.** Every record
claimed A4 would leave `EventOutcome` with one variant, so the type
should go with the Escape branch. It will not, and it should not.
`LifecycleRoute::Exit` --- a native window close --- returns
`EventOutcome::Exit` too. A4 removes the KEYBOARD producer only, leaving
one `Exit` producer.
And **one producer is not one variant**: the type survives because
`dispatch_window_event` must still distinguish `Continue` from `Exit` on
every event it handles --- nearly all must not exit, and the close must.
What A4 actually changes is `apply_keyboard`'s signature. Corrected in
the `EventOutcome` doc, the Escape-branch comment, the framing and the
ledger; the framing's superseded paragraph is deleted rather than
patched, since it also carried the stale "two `event_loop.exit()`
call sites" count. **There is exactly one executable
`event_loop.exit()`**, in `window_event`.
Also: the sentinel-tag comment claimed four modifier bits and used
three. It now says three, wrapping every eight steps, and why that
suffices --- each sentinel is read before the next is issued, so a tag
only has to differ from its immediate predecessor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Review round 1's blocker: P2 requires outbound events plus local
effects, and the harness recorded `Route` classifications only. The
wheel is the proof it was not enough --- a wheel route carries a delta,
and whether that becomes a viewport update, a panel event, a terminal
event or nothing at all is `State`'s to decide. A route names the
family; only running the body names the effect.
**`App::dispatch_window_event` is what makes P2 reachable, and it is the
substantive change here.** Left inside `window_event`, the dispatch
would force a harness to re-implement it --- and a harness that
re-implements the thing it tests witnesses its own copy. `window_event`
is now four lines: call dispatch, exit if it asks. **P3 narrows from a
33-line match to a single `if`.**
`EffectHarness` drives production code end to end:
* a REAL `AttachClient` over a `socketpair`, through the real
handshake, outbox, writer thread and encoder, so what is recorded is
the wire and not a mock's idea of it (`connect_stream_for_test` in
attach.rs exists only because the constructor is private to that
module; it adds no behaviour);
* a REAL windowless `State`, so the bodies take their real branches;
* `dispatch_window_event` itself.
Local effects have no wire trace, so each is read where it lands: exit
from the returned `EventOutcome`, redraw from a test-only
`State::render_calls`, resize from the surface config, the modifier
mutation from `App::modifiers`, and the scroll from `scroll_top`.
**Steps are delimited by a sentinel key, not a sleep.** "This step sent
nothing" is otherwise undecidable without waiting, and a fixed-duration
wait against a writer thread is the core-count assumption PR #235's CI
red was made of. The sentinel is not coalesceable (only viewport and
drag kinds are), so it can neither replace nor be replaced by a recorded
event. It does sit between steps, so cross-step coalescing that
production would perform is absent here --- stated in the harness doc,
since it makes the transcript per-step rather than as-coalesced.
**Never skips.** Per the ruling, a missing wgpu adapter is an assertion
failure and not a skip: this project has twice recorded a suite that
returned `ok` without running. Mutation M21 makes `new_headless` return
`None` and all NINE effect rows fail loudly while the thirteen pure
routing rows, which need no GPU, stay green --- the two tiers behaving
exactly as intended.
TWO ROWS WERE WRONG AND THE MUTATIONS FOUND THEM, WHICH IS THE POINT:
* the wheel row asserted `.all(|e| matches!(e, Viewport))` over the
transcript --- VACUOUSLY TRUE ON AN EMPTY ONE, so an outbound-blind
harness passed it. Now asserts non-empty first.
* with that fixed it still failed, for a second reason: the fixture
was two lines and could not scroll, and a headless `State` has no
attached buffer, so `scroll_by_lines` returned `None` and withheld
every send. Both are absences the harness manufactured itself ---
the same shape as the panel wire, below.
The panel wire is the third of those. `resumed` sets the frontend id and
the session version on the state before any geometry flush; the harness
did not, so `flush_panel_geometry` silently withheld the declaration and
the resize row failed against an absence of its own making. The harness
now mirrors that wiring and drains the attach-time declaration, so each
row's transcript holds only what its own event produced.
Evidence --- 22 rows (13 routing, 9 effect), 6 further mutations:
M18 exit effect discarded -> the close row
M19 redraw effect discarded -> the redraw row
M20 apply_resize stops declaring -> the resize row
M21 no wgpu adapter -> all NINE effect rows, loudly
M22 harness blind to OUTBOUND -> resize + wheel
M23 harness blind to LOCAL -> six rows
M22 and M23 together are P2's contract made executable: blind the
harness to either half and rows fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
None changes a decision; all four were claims a reader would check and
find false.
**The durable diagnosis command did not run the pair it claimed.**
`m4_24_` is a PREFIX matching **18** tests, so the recorded invocation
would report roughly 16/2 contaminated and 18/0 clean --- not the 0/2
and 2/2 beside it. A reader following it would see a mostly-green run
and conclude the hazard was mis-diagnosed. Replaced with **four literal
`--exact` invocations, one test each**, every one of them executed
before being written down: `running 1 test`, `171 filtered out`,
contaminated `0 passed; 1 failed` panicking at `:5668:5` and `:6615:5`,
clean `1 passed; 0 failed` with no panic. The block now also says to
read the `running N tests` line, pointing at the libtest-filter bullet
two entries below --- which is the trap that produced this defect in the
first place.
**"The diff touches only `pmacs-gpu/src/main.rs`" -> "the whole
EXECUTABLE diff".** The branch changes six files, five under `docs/`.
The structural argument was always about linkage, not file count, but as
written it was simply false and the first `git diff --name-only` would
say so. Fixed in both the ledger and the handoff.
**"a headless test can drive every family"** contradicted the keyboard
exception three paragraphs below it. Now says every family whose event
winit lets a test construct --- all of them except keyboard --- and
points at `route_keyboard` for how far that reaches.
**`[KeyboardRoute::Press]` names a type that does not exist.** It was
renamed to `KeyAction` when the payload moved onto `Route::Keyboard`,
and this doc link was left behind pointing at nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Three documents, one finding each.
**`docs/gui-stage1-input-framing.md` -> revision 10.** Revision 9 is the
approved design and revision 10 changes none of it. It records ONE scope
correction that could not be seen from the design: P1 has a second
structural exception, for the keyboard family alone, and it is winit's
rather than this seam's. `KeyEvent` carries a `pub(crate)
platform_specific` field, so no `WindowEvent::KeyboardInput` can be
constructed outside winit. Bounded three ways rather than accepted
whole --- it does not reach the pointer families (`DeviceId::dummy()`
exists for exactly this, checked BEFORE writing the exception down), the
family's only decision is factored into `route_key_action` and witnessed
directly, and what stays uncovered is one pattern arm with no logic.
Also records that P3 is now MEASURED: deleting the whole delegation
leaves all 256 `pmacs-gpu` tests green, not merely the 13 new rows.
**`docs/active-work.md`** --- the lane moves to IMPLEMENTED with the
four commits, the shape, the verbatim-move method, and the gate result.
**`docs/agent-handoff.md`** --- the stray-marker hazard gains what this
run earned: `scripts/gate` DOES NOT ISOLATE `TMPDIR`. It isolates the
target directory and five ambient roots, so `tempfile::tempdir()` still
lands under whatever `/tmp` happens to contain, and the hazard therefore
reproduces INSIDE a gate run --- which is how it surfaced here, on a
lane that touches only `pmacs-gpu/src/main.rs`. The bullet now carries
the discriminating command pair (`TMPDIR=/tmp` 0/2 versus a marker-free
root 2/2) rather than only the narrative, because a rerun establishes
nothing about this and the pair establishes everything. Isolating
`TMPDIR` is assigned to the gate lane, not to whichever feature PR trips
over it next.
One code change rides along: `EventOutcome`'s doc comment said
`event_loop.exit()` is called in "exactly one place", which is true of
the function and false of the call sites --- there are two, both inside
`window_event`. Stated precisely now, since the whole point of the
sentence is that a reader can check it by grep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The last four arms --- `CursorMoved`, `MouseInput` twice, `MouseWheel`
--- move to `apply_cursor_moved` / `apply_left_button` /
`apply_right_press` / `apply_wheel`. `window_event` is now 33 lines
against 655: one `route_event` call and one arm per route. The seam is
complete.
THE BUTTON DISCRIMINATION WAS THE FIND. It lived in the shape and order
of two overlapping `MouseInput` arms --- left in either state, right in
the pressed state only --- with everything else falling through a
wildcard several hundred lines below. The asymmetry is real and
deliberate (a context menu opens on the press; its release means
nothing), but it was an artefact of arm order rather than a stated
decision. `PointerRoute` names all four cases and both witnesses and
mutations now bear on them.
`UnusedButton` follows the keyboard family's `Release`: a middle /
back / forward / other button, and a right-button release, are CLAIMED
BY THE POINTER FAMILY AND DROPPED rather than left unrouted. Same
behaviour as the wildcard they used to reach, and Stage 1b's B4
(middle-click PRIMARY paste on Linux) lands on exactly this route.
The wheel delta is carried RAW. Converting it to lines needs the code
line height, which is `State`'s to know, so the router must not try ---
and the witness drives both `LineDelta` and `PixelDelta` to pin that.
All four bodies verified as the original arm bodies rustfmt-normalised,
by re-running rustfmt on the pre-move text at the new indent level and
diffing. `apply_cursor_moved` additionally renames `position.x`/`.y` to
`x`/`y`, 6 and 9 occurrences, counted.
Evidence --- 13 rows, 6 further mutations:
M12 right button claimed in both states -> the right-button row
M13 left button claimed only on press -> the left-button row
M14 CursorMoved axes swapped -> the cursor row (+ transcript)
M15 unused button falls through -> unused + right rows (+ transcript)
M16 harness records outbound only -> the transcript row ALONE
M17 wheel delta zeroed -> the wheel row ALONE
P3 RE-DEMONSTRATED AGAINST THE FINAL SHAPE, AND AGAINST THE WHOLE
SUITE. Replacing `window_event`'s entire body with `let _ =
(event_loop, event);` --- a GUI that responds to no input at all ---
leaves ALL 256 `pmacs-gpu` tests green, not merely the 13 routing rows.
That is the accepted structural exception measured rather than
asserted: no headless test anywhere in this crate observes the
delegation, because `ActiveEventLoop` cannot exist outside a live event
loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The 194-line arm moves to `App::apply_keyboard`, verified byte-identical
against `HEAD~1` modulo exactly two named conversions: the press guard
becomes the router's decision, and `event_loop.exit()` becomes a
returned `EventOutcome::Exit`.
THE OUTCOME RETURN IS THE LOAD-BEARING PART. The keyboard arm was the
second caller of `event_loop.exit()` --- an idle Escape is a local quit
--- so a body that owned the exit would have needed an
`&ActiveEventLoop`, and `ActiveEventLoop` is exactly what cannot exist
in a test. Returning the decision keeps `event_loop.exit()` in one
place, `window_event`, and leaves every body reachable in principle.
Both call sites are now inside `window_event` and nowhere else, which
is checkable by grep. Stage 1a's A4 deletes the Escape branch, at which
point `EventOutcome` has one variant and should go; the branch carries
a comment saying so.
A SECOND ACCEPTED STRUCTURAL EXCEPTION, ALONGSIDE P3, and it is winit's
rather than this seam's: `KeyEvent` carries a `pub(crate)
platform_specific` field, so NO `WindowEvent::KeyboardInput` CAN BE
CONSTRUCTED OUTSIDE WINIT and no headless test can feed one. Checked in
winit-0.30.13/src/event.rs, not assumed.
The response is to shrink what the exception covers rather than to
accept it whole. The family's only real decision --- press acted on,
release claimed and discarded --- is factored into
`route_key_action(ElementState) -> KeyAction`, which takes a
constructible argument and is tested directly. What stays unwitnessed
is one pattern arm containing a match and a call, with no logic in it.
The exception does NOT extend to the pointer families: winit provides
`DeviceId::dummy()` for exactly this purpose ("useful for unit
testing") and `CursorMoved`/`MouseInput`/`MouseWheel` are constructible.
Checked before writing the exception down, so its scope is measured.
`Release` is a route and not a `None`. The family CLAIMS a key-up and
drops it, which is a different fact from no family claiming the event;
collapsing them would hide the drop the moment a slice wants key-up
semantics. `window_event` merges the two arms because both are today
nothing to do, and says so.
Evidence --- 8 rows, 3 further mutations, each failing exactly one row:
M9 a release treated as a press -> the key-action row
M10 a press treated as a release -> the key-action row
M11 harness records outbound only -> the transcript row
`route_one` deliberately calls `route_event` and not the harness, so
the transcript row stays P2's sole owner and M11 stays surgical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The second of the two arms with no outbound traffic. `RedrawRequested`
joins the lifecycle family and its body moves to `App::apply_redraw`.
This is the arm P2 was written for. With `CloseRequested` it makes the
pair a harness built on protocol traffic could not see at all: neither
one sends the daemon a byte, so "did this arm get handled?" has no
answer in a transcript of daemon traffic. The transcript row now drives
five events of which two are silent.
The lifecycle family's criterion is stated properly here rather than
left as the accident of which three arms happened to be smallest:
events about the WINDOW ITSELF --- closing, resizing, repainting ---
as against a gesture aimed into the document. `ModifiersChanged` is the
one exception and is documented as one, since it is a bare state
mutation with no gesture of its own and no body to extract.
Evidence --- 7 rows, 2 further mutations:
M7 `RedrawRequested` -> no family -> the redraw row (+ transcript)
M8 harness records outbound only -> the transcript row ALONE
M8 is M4 re-run now that a second silent arm exists: the mutation
discards both `Exit` and `Redraw` and keeps only the resize, and still
fails exactly one row, because the per-variant rows assert `feed`'s
return value and the transcript row alone owns P2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
GUI Stage 1-pre. `App::window_event` decided and performed everything
in one 655-line match, and nothing below it could be witnessed without
a display: `ActiveEventLoop` is non-constructible outside a live event
loop, and the arms that matter reach a GPU surface or a socket.
This is the seam, given its shape on the three smallest arms before the
194-line `KeyboardInput` one.
Deciding is `route_event(&WindowEvent) -> Route`, a free function over
the event alone, composing one decision function per family ---
`route_lifecycle` is the first. Performing stays on `App`: the `Resized`
body moves verbatim to `App::apply_resize`.
A route names its LOCAL EFFECT, not merely the family that claims it,
and the harness records routes rather than outbound protocol traffic.
That is deliberate and load-bearing: `CloseRequested` exits and sends
the daemon nothing, so a transcript of daemon traffic alone cannot tell
a handled arm from a dropped one. `RedrawRequested` is the second such
arm and lands next.
The zero-extent clamp moves into the router with the decision. wgpu
rejects a zero-extent surface configuration and a minimize delivers
0x0, so `.max(1)` is a rule rather than defensive padding --- and
deciding it in a pure function is what makes it witnessable with no
surface at all.
No behaviour change. The router is not yet reached for the families
still inline in `window_event`; each subsequent commit moves one, and
when the last goes the match collapses to the router call.
Evidence --- 6 rows, 5 mutations, each failing its own row and no other
beyond a stated dependency:
M1 `CloseRequested` -> no family -> the exit row (+ transcript)
M2 `Resized` -> `Exit` -> both resize rows (+ transcript)
M3 clamp dropped -> the zero-extent row ALONE
M4 harness records outbound only -> the transcript row ALONE
M5 `ModifiersChanged` drops the state -> the modifiers row (+ transcript)
The transcript row is the only one that fails under M4, because the
per-variant rows assert `feed`'s return value; that row alone owns P2,
which is what makes M4 discriminating rather than a blanket failure.
P3 --- that `window_event` DELEGATES rather than deciding for itself
--- is the framing's accepted structural exception, and it was
demonstrated rather than assumed: deleting the whole delegation, which
would leave the GUI unable to close, resize, or track a modifier, left
all six rows green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai