Commit Graph

117 Commits

Author SHA1 Message Date
Levi Neuwirth e50f38ae20
test(gui-1b): step 3 witnesses the panel viewport, not the emitted event
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.
2026-09-02 13:47:18 +02:00
Levi Neuwirth a2d5b2657b
fix(gui-1b): clause 5 for the TUI's three buffer-replacement paths
The latch commit implemented clause 5's wrap half and left its
replacement half undone. Three paths replace a window's buffer ---
switch_active_buffer_for, install_buffer_in_window, and the daemon's
align_primary_document_window --- and none cleared view_left or the
latch. Two of them already reset cursor, selection and view_top one
line at a time; the horizontal origin was simply missing from the list.
A successor inherited both, rendering sideways with nothing about that
buffer to explain it. The GPU carries this reset for exactly that
symptom.

One `Window::forget_manual_horizontal_origin` rather than three copies,
so a fourth path gets the rule by calling it, with each call site
removable on its own --- which is what lets each leg have its own row.
L8b, L8c and L8d, one per site; dropping any one call fires only its
own. L8d lives in daemon.rs because the function is private there, arms
the latch through a real wheel gesture rather than by writing fields,
and is deliberately NOT crdt-gated like its neighbour, so it runs in
the default --lib leg too.

L7a asserted only that the origin came DOWN, which any arbitrary
reduction satisfies. It now asserts `widest − viewport` exactly, with
the fixture's widest named as a constant so the row and the fixture
cannot drift apart. Mutation-checked with an off-by-one clamp, which
the old assertion could not see and the new one fires on.

And L4's rationale was false as written. It said the caret stays inside
the viewport after the vertical wheel; with short filler lines the
caret clamps to their end, LEFT of the origin, so the origin
discriminated too and the row's stated reason for using the latch
instead did not hold. The filler lines are now 120 columns wide and the
row asserts the caret is still inside afterwards, through
`pos_to_display` --- the same rule production uses to decide whether the
wheel can carry point at all.

One repair of my own making: the helper landed between `#[must_use]`
and `layout_ctx`, stealing the attribute and leaving that function
wearing the tail of my doc comment. Third time this file's neighbours
have been damaged by an insertion. The method now sits after
`layout_ctx`, whose attribute and body were read back intact.

Gates: fmt; clippy --workspace --all-targets -D warnings (which caught
the stolen attribute); --lib 2007; --lib --features crdt 2200;
pmacs-gpu 312; git diff --check.
2026-09-01 21:59:58 +02:00
Levi Neuwirth 9e54cd2c5b
refactor: put the display-column rule where both frontends share it
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.
2026-09-01 10:34:00 +02:00
Levi Neuwirth d3d720ba8a
feat(gui-1b): B2's horizontal leg --- the panel origin actually moves
`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.
2026-09-01 10:07:23 +02:00
Levi Neuwirth b8c51b75ed
docs(panel): drop the obsolete "Q5 is owed" block
The block said Q5 was unwitnessed, acceptance-suite shaped, and owed. It
sat immediately above the row that witnesses and closes Q5 through the
extracted seam, so the source carried two rulings and the stale one
first.

Written when the gap was real and left behind when it was filled. Q5's
own doc still records that it was owed through tasks 18 and 19, which is
history rather than a standing claim.

Swept for siblings: the remaining "owed" mentions all describe a release
the gesture record owes a child, and the two "unwitnessed" mentions both
say a PAST round was wrong to claim it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-28 10:39:28 +02:00
Levi Neuwirth 6142acc203
test(panel): Q5 names the successor frame it orders against
`!messages.is_empty()` could be satisfied by any unrelated semantic
message, so the row asserted an ordering against a frame it never
confirmed was there. It now requires the unwritten messages to contain
InstanceMessage::PanelFrame(PanelFramePayload::Absent) --- the successor
frame whose own transition raised the release.

The assertion bites: suppressing that payload while keeping the
cancellation fails the row, where the emptiness check would not have
noticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-21 14:26:32 +02:00
Levi Neuwirth 85946bf9b6
test(panel): Q5 --- the projection-seam drain, witnessed
Closes the gap recorded through tasks 18 and 19. The third drain sits
inside the daemon's per-frontend frame loop, which no unit row could
enter, so it was written down as owed rather than assumed covered by its
neighbours.

The seam is now `project_semantic_frame`, extracted from that loop. It
returns its messages UNWRITTEN, and that is what makes the ordering
assertable: a caller holding them has by construction not sent the
successor frame, so a release already delivered at that moment provably
precedes it rather than merely arriving alongside it.

The row arms a gesture on a reporting terminal, takes the panel away so
publish_absent_panel cancels from inside projection, calls the seam, and
asserts the child already has the release while the successor frame is
still in the caller's hands.

It bites its own drain and no other: removing the drain from the seam
fails Q5 while Q1-Q4 stay green on the effect and detach drains.

Grid sessions no longer reach the drain at all --- they hold no panel and
no gesture --- which is tighter than the previous per-fid call.

Also folds in the reported prose typo, and records the gate's
precondition: a foreign C++/java build has been at load 114+ through
this work, and the three wall-clock rows that redded under it were green
in isolation every time. Running the gate into that would manufacture
another U6/U9/U10 rotating-red incident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-21 14:06:01 +02:00
Levi Neuwirth 70999e22ac
test(panel): Absent joins the matrix, and G5m proves its coincidence
Answers review of c37c066.

ABSENT WAS MISSING. The approved G5b table has five transitions, not
four, so the matrix was 16 quadrants where it should be 20. Added, and
it earns its place twice: as the fifth row, and as a CONTROL on SS5b's
own cancellation --- removing that cancel now fails the Absent quadrants
while the four this lane added still pass.

The quadrant count is asserted in the row. A loop that quietly stops
covering a combination passes exactly as loudly as one that covers them
all, which is the same read-success-from-absence shape as a test filter
that matches nothing.

G5m ASSUMED its composites. Both scenarios asserted one release without
ever checking the mapping moved, so either would pass as a
single-cause transition wearing a composite's name. It now peeks the
generation before and asserts it advanced after --- peeked, because
reading through the authoritative accessor would advance the key and
manufacture the very second cause the row is meant to observe. The
assertion bites: a same-size geometry change does NOT move the mapping,
and substituting one fails the fixture.

The doubled P12 rustdoc line is back and removed again. I wrote the note
about this exact seam after the first occurrence and then spliced
through it a second time. The crate-wide sweep is now a real check
rather than a grep I improvised: every `///` line containing a second
`///` that is not a URL.

Machine note: a foreign C++ build was running at load 114 during the
final verification, and three wall-clock rows redded under it ---
m6_2_pty_streaming_respects_byte_ceiling,
composition_overhead_under_ten_percent and
full_buffer_summary_flatten_scales_on_large_grammar_file --- each green
in isolation. The gate still wants a quiet machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-21 12:15:56 +02:00
Levi Neuwirth c37c066786
test(panel): the authority-loss matrix, asserted as EFFECTS across every quadrant
Answers review of e2b080f. The transition detection was sound; the rows
proving it were not.

G5b(a)-(c) and G5m stopped at has_pending_release() or a cancellation
count. Both pass while delivery or recorded-domain routing is broken,
and the mutation that parks a release and never delivers it proves it:
it now fails all three rows and previously failed none. Every quadrant
drains explicitly and asserts the effect --- exact release bytes for a
reporting terminal, the cleared empty selection for a document, and an
empty slot afterwards.

The matrix is now the table the framing asks for: four transitions x two
families x two targets, sixteen quadrants. All four earlier rows used
the legacy terminal fixture, so the mapped legs and every document leg
were simply absent.

G5m takes both composites the framing names --- changed-size geometry,
which moves the epoch AND the mapping generation, and a buffer
replacement that also moves the mapping --- rather than the one
wrong-shaped combination it had.

One quadrant asserts less and says so: for window replacement on a
document the window the gesture belonged to is gone, so the completion
has nothing left to clear and the ending is the whole effect. Written
into the row, because a silently absent assertion is how a quadrant
stops testing anything.

Two fixture facts the failures taught: the document legs press at row 0
because foreign_edit replaces buffer contents without refreshing the
window's cached line index, so only the first display row resolves to a
byte and a press that anchors nothing does not arm; and cargo check
--lib does not compile #[cfg(test)] code, which is why a missing test
helper passed a check and failed the build.

Corrects two records. The ledger had the mutation labels reversed ---
dropping the BUFFER comparison misses the buffer transition, not the
window one. And semantic_render still told readers these transitions
were left armed on this branch, which was true of SS5b and false here
since the matrix landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-21 11:43:27 +02:00
Levi Neuwirth e2b080f6ae
feat(panel): the authority-loss matrix --- four transitions gain their effect
Task 19. SS5b wired Absent and left the other four transitions armed,
inert while nothing consumed the latch and defects the moment
cancellation gained an effect.

Three are visible in the producer, where the declaration is built: the
side WINDOW replaced, its BUFFER replaced, and the GEOMETRY epoch moved.
The last needed a retained geometry_epoch on PanelPresentation, because
nothing else the producer holds moves with a same-size geometry change
--- not the panel epoch, not the identity, and on a legacy peer not a
mapping key either --- so the transition was invisible and a live gesture
survived a grid it no longer belonged to. DETACH cancels in the
dispatcher, before any teardown, because it is the transition with no
later opportunity of any kind.

The release follows the RECORDED domain, so a buffer replacement pays
the child the gesture was pressed on rather than whatever occupies the
panel now.

G5b(a)-(d), G5m and G5j, each reading the child's byte stream or the
document's selection rather than the latch. The mutations discriminate
exactly: dropping the window half of the identity check fails only the
buffer leg, dropping the buffer half fails only the window leg, dropping
the geometry check fails only that leg, and dropping detach's cancel
fails only detach.

G5m survives all four, and correctly --- two coincident causes take the
same latch, so one release, and removing either cause still leaves one.

G5j's two legs differ and the row proves it: an empty selection is
cleared without moving point, while a real dragged region survives
anchor-and-cursor exact. Clearing every selection fails the second leg,
which is the mutation that matters --- ending a gesture is not a reason
to discard what the user selected.

Machine note: a foreign java build was running at 213% CPU during this
work, and the wall-clock budget row composition_overhead_under_ten_percent
redded twice under it, green in isolation both times. Functional rows
are unaffected; the gate should wait for a quiet machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-21 11:06:44 +02:00
Levi Neuwirth 70d531cb7d
docs(panel): drop a doubled P12 rustdoc line
The Q6 rewrite spliced its replacement text ending with P12's heading
while the slice it replaced began with the same line, concatenating both
copies onto one line. Neither cargo fmt nor clippy reflows doc comments,
so nothing downstream noticed.

Swept the rest of the crate for the same shape; the only other hit is a
`file:///` URI inside a legitimate doc line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-21 10:46:43 +02:00
Levi Neuwirth 4e9420a69a
fix(panel): a live gesture is paid BEFORE its replacement press lands
Answers review of ab8ddae. The entry drain could not close this: it
looks for an OWED release, and a gesture that is still LIVE owes nothing
yet. Arming was what cancelled it, and arming runs after the replacement
press has already reached the target --- so a second press with the first
never released put `old press, new press, old release` on the wire. Two
presses outstanding, then a release arriving for the wrong one.

The Down arm now ends the live gesture and drains it before applying the
replacement, so the child sees `old press, old release, new press`.

The invariant moved to where it is relied on. arm_accepted_gesture now
asserts that neither a live gesture nor an owed release remains, at the
point of ARMING rather than inside cancellation --- arming is what the
ordering protects, and checking during cancellation cannot see the case
where nothing has been cancelled yet. The defensive cancel stays for
release builds, because parking late is recoverable and overwriting is
not.

Q6 was rewritten, because the old one never sent a second press while
the first was live and so could not observe any of this; its final
assertion also ran after a further cancellation. It now expects the
exact bytes `release(1,2), press(2,4)` in that order.

Both layers are witnessed separately. Reverting the ordering trips the
new debug assertion at the point of arming; reverting it AND compiling
that assertion out --- which is what a release build does --- fails the
byte-order assertion instead, with the child receiving only the new
press.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-21 10:40:54 +02:00
Levi Neuwirth ab8ddaecec
feat(panel): the pending-release slot, and the order it drains in
Task 18. Cancellation now PARKS the record instead of returning it into
a context that drops it: two of the three cancellation sites --- the
mapping-generation advance and publish_absent_panel --- run inside frame
production, where no target effect can happen, so the gesture ended with
the child still holding its button.

A SLOT, not a queue. The latch holds at most one gesture per frontend,
so at most one release can be owed, and the bound is structural rather
than a cap someone chose. Overwriting a still-parked release is a
debug_assert rather than a silent loss --- a backstop, since the ordering
is what actually prevents it.

Three drains, each against a specific way the release would otherwise
arrive too late or not at all: before any subsequent panel-pointer
effect, before detach teardown (the next statement in that arm drops the
state holding the record), and at the projection seam between
render_frame returning and its messages being written.

Q1, Q2, Q3, Q4 and Q6, each biting its named mutation with the run
counts checked. The mutations discriminate: dropping the park fails all
three delivery rows; removing the effect-drain spares Q4, which has its
own; removing the detach drain hits Q4 alone. Q3 asserts ORDER rather
than arrival, and the mutation that keeps the drain but moves it after
the press effect fails exactly that assertion, with the child's stream
coming back press-then-release.

Q5 IS OWED, AND RECORDED AS SUCH. The projection-seam drain needs a row
that drives the real per-frontend frame loop; these rows call
render_frame directly and never enter it. The seam still matters --- a
cancellation with no following panel event and no detach would let the
successor frame overtake the release its own new mapping required --- so
it is written down as unwitnessed rather than treated as covered by its
neighbours.

Two fixture facts worth keeping: the Q rows cancel through Absence
rather than a mapping advance, because a terminal panel's key tracks its
screen and anchor, not its buffer, so a foreign edit does not move it;
and the helper re-shows AND re-declares the panel, because a panel left
Absent fails the inbound ladder and the row would then be observing the
ladder rather than the slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-20 23:28:15 +02:00
Levi Neuwirth ba48ae6153
test(panel): P2's focus assertion was blocked by assertion order, not by the type
Answers review of 557ea6d. Capturing the outcome and asserting it LAST
makes the focus assertion reachable: removing the buffer check accepts
the press, an accepted press activates the panel before it replays, and
the row now fails on focus --- WindowId(3) against WindowId(2).

I had recorded this as a limit of the type boundary, claiming no
mutation could reach the effect assertions because the daemon applies
only on Accepted and the disposition gives Refused no target. That was
wrong. The obstacle was that the row asserted the refusal BEFORE
dispatch and aborted there. Ordering, not architecture.

The classification is still checked, at the end, so the row cannot go
vacuous if it ever stops testing a refusal.

Controller and byte assertions stay documented as defence in depth, and
now for an accurate reason: the mutation that reaches them routes
through a document buffer, which touches neither.

Also replaces failure text that still described an out-of-range anchor,
which this fixture stopped using when its refusal lever became a foreign
buffer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-20 23:12:06 +02:00
Levi Neuwirth 557ea6dfac
test(panel): P2 asserts real focus, and the ledger stops overstating
Answers review of 9028e1b.

P2 recorded `active_frontend`, which is which FRONTEND is current, not
which window has focus. Focus is `views[&fid].active` and `focus_window`
moves it, so the row would have watched a panel steal focus without
noticing. It now records the focused window, and asserts up front that
the panel does not already hold it --- otherwise "focus did not move to
the panel" asserts nothing.

P2's refusal lever also changed, because the old one could not exercise
what the row claims. An out-of-grid cell with the row bound removed
becomes `on_chrome`, so the press classifies Consumed and still reaches
no target: the row bit on its own precondition while focus was never
touched. A foreign buffer at an in-content cell is the refusal whose
mis-gating actually yields Accepted.

And the row now says what is falsifiable about it. Removing the buffer
check makes the press Accepted and P2 fails --- but on the precondition,
which fires first, so the focus, controller and byte assertions cannot
fail under that mutation and no other mutation reaches them: the daemon
applies only on Accepted, and the disposition enum gives Refused no
target to apply. They are defence in depth against a future refactor,
labelled as such rather than presented as coverage.

The ledger claimed every row reads a target effect and never the latch
alone. That was false. P9 and P10 read the LATCH, and correctly so ---
the defect they fence is a record existing for a gesture that never
began, so the record is the artifact, and an effect assertion would not
distinguish their mutations. The line now separates effect rows from
arming-gate rows and names P2's third case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-20 23:06:30 +02:00
Levi Neuwirth 9028e1b172
fix(panel): the projection clamp, a reachable P10, and a disposition that cannot lie
Answers review of 2ea39aa. Two of my claims were wrong and the code
disproved them.

WIDE PANELS HAD DEAD POINTER INPUT. A panel deliberately does not
inherit the terminal's per-axis PTY caps (Bet B5'), and the renderer
clamps through terminal_projection_size so a band wider than
MAX_TERMINAL_COLS paints correctly. Pointer routing passed the RAW panel
width, and view_status_for_size refuses anything over the cap --- so on
exactly those panels every click inside the visible terminal resolved to
None while the band looked perfectly normal. Routing and the recorded
viewport now go through the same clamp, with P12 as a POSITIVE control
at MAX_TERMINAL_COLS + 128.

P10 IS REACHABLE, and my note saying otherwise was wrong. I claimed
anchor_at resolves every in-grid cell of a live view, "measured, not
assumed". I had measured ROWS and generalised to cells. anchor_at
refuses coord.col >= row.cells.len(), and the fixture's band is 80
columns over a 20-column child, so columns 20..79 are painted padding
inside accepted content. The row exists now and the begin_selection gate
is witnessed rather than excused.

THE DISPOSITION IS AN ENUM. As {outcome, Option<target>} the invalid
pair --- refused, yet carrying a target --- stayed representable inside
editor.rs, so my "the type makes it impossible" was also wrong. Refused
now holds no target at all. ResolvedPanelTarget is public as a type and
opaque as a value: every field stays private, so the daemon hands the
disposition back rather than reading a derivation out of it.

P2 also now asserts what it observes: the classification is Refused, and
focus and terminal-controller ownership are preserved. A misclassified
press focuses the panel and claims the controller BEFORE its
out-of-range anchor fails, so byte and latch assertions alone stayed
green through exactly that bug.

MY MUTATION HARNESS WAS READING SUCCESS FROM SILENCE. `cargo test --lib
"r4_p\|g5k_"` is a literal substring filter, not a regex: it matched
nothing and ran ZERO tests, and I read the absence of failures as
"the mutation did not bite". One whole round proved nothing. The harness
now prints the run count and says so loudly when it is zero. Re-run
properly, all three fixes bite their named rows.

Also updates the ledger's witness list, which omitted P2, P9, P11, the
recorded viewport and the exact-byte strengthening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-20 22:54:57 +02:00
Levi Neuwirth 2ea39aaa57
fix(panel): the record is self-contained --- viewport, anchoring, exact bytes
Answers review of 48057d7. The G5k routing fix held; the record still
leaned on ambient state in three places.

TerminalLocal now records the accepted content VIEWPORT. Replay fetched
the current panel_grid_size and returned when it was None --- which is
exactly what a hidden or absent panel produces, so a cancellation could
not finish the drag it was cancelling, and a size-changing cancellation
would have finished against the successor's geometry.

A press that anchors NOTHING no longer arms. The document path returned
Some(Document) unconditionally even when panel_cell_byte found no byte,
and the terminal path returned Local while discarding begin_selection's
answer. Both now report what actually began.

The child rows assert EXACT BYTES rather than a count: a wrong event or
encoding passed the old length checks. The literals are written out
rather than built with the encoder's own formula, which would only
assert that the encoder agrees with itself. G5k(b) pins the ruling that
the SGR framing comes from the record while the modifier bits still
report live state, so its release carries code 4 rather than 0.

New rows: P2's effect half (a refused press reaches no target), P9 (a
document press that anchors nothing does not arm), P11 (a recorded local
completion still runs with the panel HIDDEN, which is what the recorded
viewport is for).

THREE ROWS IN THIS ROUND WERE VACUOUS BEFORE THE MUTATIONS CAUGHT THEM,
and the fixtures now assert their way past each cause. The panel grid in
this fixture is FOUR rows, so content is rows 0..=2: my first P9 and P10
cells were out of grid and refused before reaching the path they claimed
to test, and P9's earlier cell clamped to byte 0 instead of failing to
anchor. Both rows now assert the disposition is Accepted before
asserting anything about the effect.

P10 IS DELIBERATELY ABSENT AND RECORDED AS UNWITNESSED. The
begin_selection gate has no reachable false branch through the daemon:
classify has already established the buffer is the side window's live
terminal, and anchor_at resolves every in-grid cell of a live view ---
measured on the fixture, not assumed. The gate is kept as insurance and
the gap is written where the row would have been, rather than covered by
something that would pass whether or not the gate existed.

P2's effect half is likewise not falsifiable by any mutation I could
construct, because a Refused disposition carries no resolution, so no
path can apply it. That is a stronger guarantee than a test, and it is
stated rather than dressed up as coverage.

Also corrects the last false ledger tense.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-20 22:22:09 +02:00
Levi Neuwirth 48057d7667
fix(panel): G5k --- the gesture records its domain, and every tail obeys it
Answers review of 39b6fa7. The three-state disposition was sound; the
record and effect half downstream of it were not.

apply_panel_pointer returned a bare bool, so the record carried neither
the resolved target nor the reporting contract, and the daemon drove
accepted Drag/Up back through the mode-sensitive adapter. That adapter
re-reads Shift, the scrollback position and the child's mouse modes on
every event --- which is G5k's named mutation verbatim. A press reported
to the child followed by a release re-evaluated after the child turned
reporting off leaves that child holding a button down; the reverse
transition sends a child an Up for a Down it never saw. The recorded
completion had the same defect and additionally re-derived the current
side window, returning when it had changed --- precisely the transitions
task 19 must terminate, so the completion they need was the one thing
that refused to run.

The press now resolves a PanelGestureDomain --- Document{window},
TerminalChild{window, buffer, modes} or TerminalLocal{window, buffer}
--- and the record carries it. Tails and completions route through
replay_panel_gesture_in_domain, which gates on nothing: not Shift, not
the scroll position, not the child's current modes, not the panel's
current identity. apply_terminal_gesture reports which way it routed so
the domain is measured where the branch is taken. Arming now requires an
effect: a press the target refused records nothing.

G5k(a)-(d) plus P3's reporting leg, P4 and P5. Every row reads a TARGET
EFFECT --- the child's byte stream in order, the terminal drag state, or
the document selection --- never the latch. Each bites its own mutation,
and G5k's four legs all fail under the framing's own named mutation
applied verbatim.

Two seams exist because nothing else exposes what the child received: an
opt-in child-input tap, off by default, and a drag-state read.

Also corrects the recovery ledger, which still said implementation was
paused and the bool collision unfixed.

Records for the ci-red registry rather than hiding it: during this work
composition_overhead_under_ten_percent and pty_mode_child_sees_a_tty
redded together in one --lib run at load 21 and each passed in isolation
immediately after --- U9's signature, and neither path is touched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-20 21:40:02 +02:00
Levi Neuwirth 39b6fa7dba
feat(panel): Q#BP-R4 --- the disposition, and the lifecycle table it enables
Replaces the panel dispatcher's `bool` with a three-state
PanelPointerOutcome decided BEFORE any target effect, and moves the
gesture lifecycle into one table in the daemon.

The old shape could not express the rule it needed. It validated,
classified and mutated in one pass, so an Up or Drag with no accepted
Down had already reached the child or the selection by the time the
daemon consulted the latch. classify_panel_pointer now returns the
disposition and carries the resolution it was decided from;
apply_panel_pointer acts on that same resolution, so the editor stays
the only authority and the daemon never re-derives chrome, target kind
or content bounds.

The table: a chrome press begins nothing; a left tail with no live
record is inert; an Accepted release performs the ordinary in-content
completion and takes the record; a Consumed release did not reach
content, so it terminates from the record at the gesture's last valid
content cell. Never both --- that is P5.

apply_terminal_gesture now returns whether the gesture REACHED THE
CHILD, so the latch is armed from the effect result rather than from a
prediction about the modes. complete_panel_gesture routes both terminal
domains back through that same shared path, which is what keeps "what a
release does" from having a second implementation.

Four witnesses, each reading a TARGET EFFECT and not the latch, and
each biting its own mutation: P1 chrome press (classify chrome as
Accepted), P3 chrome release on a terminal (drop the recorded
completion), P7 orphan release (remove the Up live-gate), P8 orphan
drag (remove the Drag live-gate).

Two of those rows were vacuous when first written and are recorded here
because the mutations are what caught them. P8 dragged over an EMPTY
panel buffer, so panel_cell_byte returned None and point could not move
whether the gate was there or not. P3 was written against a document
panel --- but R-c lets document chrome Up fall through to content, so it
classifies Accepted and never reaches the Consumed path it claimed to
test; it now uses a terminal panel, on the legacy arm, because reading
the live mapping generation ADVANCES the key and SS5b wired a key
advance to cancel the live gesture, so the mapped fixture destroyed the
gesture it was trying to complete.

Adds view_is_dragging_for_test, the observable that separates a
delivered completion from a latch that merely emptied.

Also re-homes a doc paragraph that described peer_uses_mapped_panel_family
while sitting above update_accepted_gesture; deleting the latter's doc
with the function made the misplacement visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-20 21:16:56 +02:00
Levi Neuwirth cf78385b5e
fix(daemon): bind `mods` in the mapped arm --- and withdraw b758c2e's build claim
The merge commit b758c2e DOES NOT COMPILE, and its message says
"Workspace compiles clean, all targets, no warnings". That claim is
WITHDRAWN. It was true of the tree I checked and false of the tree I
committed.

What happened: I staged the conflict resolution, ran cargo check, hit
`cannot find value mods in this scope` at the mapped arm, edited
src/daemon.rs to bind it, re-ran cargo check clean --- and then
committed without re-staging that file. The verification and the commit
were of different trees. This is the same defect class as gating one
step of an edit-then-commit chain and leaving the next ungated: a
commit that does not depend on the edit it claims.

The fix itself is unchanged from what was verified. SS5b left `mods` in
`..` on PanelPointerMapped; the mapped family carries the same
modifiers, so leaving it there gives a v25 session the inverted Shift
behaviour that parent 48 R-a fixed for v24.

Verified at THIS commit, after staging: cargo check --workspace
--all-targets, no errors and no warnings.

Not amended away. b758c2e stays in history with its false claim
standing and this withdrawal attached, because erasing a bad record is
worse than carrying a corrected one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-20 20:05:51 +02:00
Levi Neuwirth b758c2e76f
Merge githubsucks/main into panel-pointer-replay
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
2026-08-20 19:51:50 +02:00
Levi Neuwirth 026bb4955f
fix(daemon): the accepted-gesture latch must follow the dispatch
Both inbound panel-pointer arms discarded the bool from
dispatch_semantic_panel_pointer and called update_accepted_gesture
unconditionally. The ladder authenticates the SENDER; only the
dispatcher re-derives the TARGET, so an event can clear every rung and
still be refused --- for an out-of-grid coordinate, an absent side
window, or a buffer that is no longer the one in the side window.

A rejected Down therefore ARMED the latch, so a later authority loss
counted a cancellation for a gesture that never began, and once replay
attaches effects it would deliver a release to a child that was never
pressed. A rejected Up CONSUMED a real armed gesture, so the authority
loss that should have ended it found nothing armed and that child holds
the button down for good. A rejected Down on top of a live gesture was
worse again: arm_accepted_gesture ends what it overwrites, so it also
counted a spurious cancellation.

Both arms now gate on the return. The dispatcher is #[must_use], so the
class cannot recur silently --- clippy runs with -D warnings, making a
future discarded answer a build failure rather than a review finding.

Four rows, g5_substrate_a_refused_{press_never_arms,
release_never_consumes}_on_the_{legacy,mapped}_arm. Each drives the
refusal from a coordinate one past the last grid row and ends in a
positive control differing only in that coordinate; without the control
a row would pass just as well if an unrelated rung had dropped the
event. Mapped rows read the generation through the validator's own
accessor so a mapping-rung refusal cannot masquerade as a dispatcher
refusal. Three mutations, each biting its named rows: ungating the
legacy arm fails exactly the two legacy rows, ungating the mapped arm
exactly the two mapped rows, and relaxing the dispatcher's >= to > fails
all four.

Also removes a committed diff3 ancestor marker this file carried since
8c9afde --- the only one in the branch's 32 commits, and invisible to a
clean-worktree `git diff --check`.

Also withdraws the claim that the local CRDT sweep could not go green.
The full 16-stage gate is green, sweep-crdt included. The two m4_24
failures came from running that sweep outside scripts/gate, where it
inherited TMPDIR=/tmp; /tmp/.git exists on this machine and project
detection walks upward, so both base-resolution rows resolved against
the wrong root. That is the exact hazard #240's isolation exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-20 17:55:21 +02:00
Levi Neuwirth bf4ffe0995
fix(panel): a second press ends the gesture it replaces
Found by reading the arming path back after the G5 checkpoint, not by a
failing row. `arm_accepted_gesture` overwrote an already-armed latch, so
a dropped `Up` --- one lost to an outbox that closed under a stall ---
was followed by the next press silently discarding the first gesture's
record, without counting it as a cancellation.

Inert on this base, where records are only counted. Once
`panel-pointer-replay` attaches a child release to each record, the
discarded one leaves a button held down with nothing left to release
it, and the arming code that decides this is this slice's.

Mutation: restore the plain overwrite -> the new row alone.

Also records three CI-red observations from the slice-completion gate,
which is the first entry on this lane with a MEASURED confound instead
of the standing uncontrolled one. Three wall-clock-deadline rows red in
one run --- criterion_1 by 0.12%, a PTY lifecycle race, and a 5s child
-exit deadline --- all green in isolation, the last in 0.15s against
its 5s deadline. `uptime` during the run went 14.02 -> 28.35, from an
unrelated turso test suite on the same machine with one binary at 693%
CPU. Not a controlled experiment, but the same evidence U9's synthetic
-load control was meant to produce, and it points at load.

Two process traps are recorded with them, because both were made here.
The Bash tool caps a command at ten minutes and SIGTERMs it, which the
gate reports as `FAILED (exit 143)` on whatever stage was running and
which reads exactly like a real failure. And `pkill -f <pattern>` kills
the invoking shell when the pattern appears in its own command line, so
the intended target survives while the operator believes it died --- and
here `pkill -f "cargo test"` would have destroyed an unrelated
project's build. Identify by PID.

Verified: `cargo fmt --check`; `cargo clippy --workspace --all-targets
-- -D warnings`; the four §5b G5 rows. The full protocol gate follows on
a quieter machine; the run described above is not evidence for this tree
and is recorded as an observation only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-20 17:55:21 +02:00
Levi Neuwirth a8a996c446
feat(panel): the wheel exemption, exhaustion, and mapped coalescing
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
2026-08-20 17:55:21 +02:00
Levi Neuwirth 8c5ca10698
feat(panel): G5a's cancellation trigger, and the latch it needs
G5a is the one G5 row this slice owns: the mapping key advancing must
raise cancellation at the advance, not reactively when a later event is
refused. Reactive cancellation loses a race --- if the successor frame
reaches the frontend before the physical `Up`, the producer clears its
latch and the cancelling event never arrives.

Cancelling requires something to cancel, so the accepted-gesture latch
lands with it: `AcceptedPanelGesture` (button, coord, buffer, and
whether the press reached a child) in a per-frontend slot on
`SemanticRenderState`, armed from the daemon's accepted inbound arms
after every gate has passed.

Writing that code decides three things the framing's deferred rows later
assert about, so they are pinned here under SUBSTRATE names rather than
under G5c/G5d/G5g/G5p. Those IDs stay on `panel-pointer-replay` per
SS5b's split table --- each asserts something about a synthetic release
or a real drag continuation that does not exist on this base, and
claiming an ID in two branches is the merge hazard `active-work.md`
already records surviving a clean merge once.

Two consequences are recorded rather than fixed:

  - Cancellations are COUNTED, not queued. The record queue is what
    replay drains to deliver each release; landing it here would grow
    one entry per cancelled drag with nothing ever draining it. A
    saturating count is bounded and still separates a consume from a
    cancellation.
  - The other G5b transitions --- panel epoch, buffer replacement,
    same-size geometry, detach --- leave the latch armed on this base.
    Each strands a live gesture whose release can never be accepted.
    That is inert while nothing consumes the latch, and becomes a defect
    exactly when replay supplies effects, in the branch that owns the
    row. `Absent` is wired anyway, because `publish_absent_panel`
    clears input authority two lines later; leaving it out would be an
    inconsistency inside one function rather than a clean deferral.

Five mutations, each biting only its named row:

  - drop the advance trigger (reactive cancellation) -> G5a alone
  - arm on every accepted pointer event -> the arming substrate alone
  - an ordinary `Up` no longer consumes -> the arming substrate alone
  - a consume counts as a cancellation -> the arming substrate alone
  - one global latch via a shared slot -> the ownership substrate alone

Also repairs a fourth rustdoc split on this branch. Inserting
`AcceptedPanelGesture` at what read as a blank gap adopted
`SemanticRenderState`'s doc comment AND its
`#[allow(clippy::struct_excessive_bools)]`, silently un-suppressing a
lint on the struct that needed it. Same mechanism all four times; the
ledger now records the check as "look UP from the insertion point".

Verified: `cargo fmt --check`; `cargo clippy --workspace --all-targets
-- -D warnings`; `cargo test --lib` (1956 passed); the two
`bottom_panel_stage2b_*` acceptance suites (39 passed); `git diff
--check`. The full eleven-stage `--protocol` gate is reserved for slice
completion per the standing procedure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-20 17:55:21 +02:00
Levi Neuwirth a242038e8a
test(panel): the nine family-gate rows, and three that proved nothing
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
2026-08-20 17:55:21 +02:00
Levi Neuwirth 65eef564bf
fix(panel): one negotiated version per row, a literal boundary, and a reattached doc
Three review corrections ahead of the witness rows.

**THE LEGACY CONTROLS NEGOTIATED TWO DIFFERENT VERSIONS AT ONCE.** I
moved their `SessionRegistry` to v24 and left their retained
`SemanticRenderState` on `PROTOCOL_VERSION`, so the producer shipped
`PresentMapped` to a session the daemon believed was v24. The rows
passed, which is the problem: a control that negotiates two versions
proves nothing about either. Five producers across the four rows now
take `LEGACY_PANEL_VERSION`, the same constant the registry does.

**The boundary is a LITERAL 24, not `PANEL_MAPPING_MIN_VERSION - 1`.**
G6/G14 make the family boundary absolute; arithmetic against a moving
constant would drag these rows forward on the next bump and they would
silently begin testing v25 as "legacy".

**And I split a rustdoc from its function again.** Inserting
`peer_uses_mapped_panel_family` above `peer_may_send_panel_events` left
the latter's doc block stranded, so my function inherited two
incompatible descriptions and the documented one had none. Same mistake
as `screen_size` two commits ago --- inserting an item at what looks
like a blank line between declarations, when the line above is the next
function's documentation. Each block sits directly above its own
function now.

Verified by EXIT STATUS, not by reading filtered output: `cargo test
--lib` 0 (1945 passed), clippy 0, focused suite 0 (37 passed). The
`composition_overhead_under_ten_percent` flake appeared once mid-run
and passed isolated; it is the known wall-clock budget signature, not
this diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-20 17:55:21 +02:00
Levi Neuwirth 0fff640d15
feat(panel): inbound family gating and the mapping check
The receiving half of bilateral gating, plus the constructor fix review
found. The eight G6/G7/G8 witness rows and the GPU side are next; the
full eleven-stage `--protocol` gate runs once they discriminate.

**THE FAMILY IS DECIDED FIRST, FROM THE AUTHENTICATED SESSION.**
`peer_uses_mapped_panel_family` reads `session_state(source)`, never the
payload's `frontend_id` --- that field is untrusted on every inbound
variant, and looking negotiation up by it would let a peer claim
another session's family. The order is stated at both arms: family,
then the existing epoch ladder, then the mapping generation, then
dispatch. It cannot depend on the variant's contents, because it
decides which variant is admissible at all.

Both wrong-family cases are REFUSALS, not fallbacks. A `>= v25` session
sending the bare `PanelPointer` is dropped rather than handled under
legacy semantics --- handling it would leave the mapping hole reachable
by choosing a discriminant, which is the entire bypass. A `<= v24`
session sending `PanelPointerMapped` is dropped too, even though a peer
compiled from this crate can encode the discriminant: negotiation is a
gate, not a sender convention.

`panel_mapping_is_current` is the ladder's finest rung. `buffer_id`
catches an A->B replacement, `panel_epoch` a close/reopen,
`geometry_epoch` a declaration race, and this catches the text under
the cell changing --- a foreign edit, a fold, a reload, none of which
moves an epoch. Zero is refused outright, and the check reads through
the same accessor projection stamps with, so the two cannot drift.

**And `new()` contradicted its own contract.** It documents a
current-build peer and enables every other current capability, but I
initialised `peer_knows_mapped_panel` to `false` --- so an implicitly
current peer was sent the LEGACY family. Set true, with the doc
extended to say the assumption covers later capabilities too.

**Four daemon rows broke, and that is G8a firing.** They drive
`FrontendEvent::PanelPointer` through sessions negotiated at
`PROTOCOL_VERSION`, which is now 25 --- so the bare variant is refused,
correctly. They negotiate `LEGACY_PANEL_VERSION` now, which both fixes
them and makes them explicit legacy positive controls rather than rows
that happened to pass.

Note for the eventual rebase: this branch is based on main, where
`dispatch_semantic_panel_pointer` still takes four arguments. Threading
`mods` is R-a, owned by `panel-pointer-replay`; the mapped arm
destructures `mods` into `..` here and will pass it through when the
lanes meet.

Verified: focused suite 37/37, `cargo test --lib` 1945 green, clippy
clean.

**Amended.** The first version of this message claimed the lib suite
green when it was not: I piped `cargo test` through `tail`, so the
pipeline exited 0 and my `&&` chain committed on a failure I had not
read. The four rows above are what it was reporting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-20 17:55:21 +02:00
Levi Neuwirth 758b985c35
feat(protocol): the mapped panel family --- v25 wire shapes and their pins
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
2026-08-20 17:55:21 +02:00
Levi Neuwirth 4bd3a76618
feat(panel): replay PanelPointer --- the wiring, the seam, and the producer half
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
2026-08-14 17:24:04 +02:00
Levi Neuwirth 266bc6e4f3
fix(gpu,daemon): 1a review round 1 --- three P1s, and the version fallout
**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
2026-08-12 17:05:55 +02:00
Levi Neuwirth 211241a437
feat(gpu,protocol): 1a --- A1-A5 and the v24 TextInput variant
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
2026-08-12 16:35:21 +02:00
Levi Neuwirth a1b931fa0e
Merge main into destination-capture, and correct the U4 row it turns on
Merged rather than rebased. Eighteen commits replayed against a ledger
that three other lanes had rewritten meant eighteen conflict
resolutions in `docs/active-work.md`, each one a chance to lose a lane
entry; merging resolves it once, against the state that actually ships,
and leaves the reviewed commits' SHAs intact. Only one file conflicted.

`docs/ci-red-signatures.md` auto-merged **without a conflict** — the
same silent path that produced duplicate U4/U5 ids when #232 rebased.
Verified by hand afterwards: ids U1-U8 are disjoint. They are out of
numeric order (U6/U7 sit ahead of U4/U5) and are left that way rather
than moved, since the note at the U6 row explains the history and
relocating sixty lines inside a merge commit hides real changes.

Three leftover conflict markers were sitting in `docs/active-work.md`
on `main`, committed by an earlier lane's resolution. `git diff --check`
flags them — but only for a working-tree diff, which is why the gate's
`diff-check` step never saw them and they survived several merges.
Removed here.

The U4 row is corrected on evidence this lane produced:

- **Flavour was wrong as a matching key.** The row was filed from
  #229's `lua54` red and put the flavour in the key; #231 reddened the
  identical selector with the identical three fragments twice on
  `luajit`. Matching as filed would have missed both.
- **A fourth sighting was a deliberate bite, not an occurrence** — the
  defect reintroduced on purpose during the test's own development. It
  is recorded for what it proves instead: the genuine defect and these
  CI reds are signature-indistinguishable, same message class and same
  full-timeout duration.
- **The control experiment is written down with its own bounds** — five
  green base observations against 0/2, 4.8% under an equal-rate model,
  and the two facts that bound it: attempt 5 reddened a different
  selector, and the branch side was never resampled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 20:05:07 +02:00
Levi Neuwirth 9fee5618ee
feat(window): a destination any async continuation can capture
Journey Stage 1a built `pmacs.window.commit_to` for the continuation
boundary --- "the listing settles a tick or more later, and by then the
ambient frontend, selected window, and active buffer may all name
something else" --- but nothing outside the `path.open-directory`
dispatch could mint a destination to hand it. Every other async Lua
continuation therefore resolved its target from ambient state a tick
after the request, which is PR #227's P1a finding: run `git.status` in
frontend A, let B become active, and A's panel opens in B.

This is the prerequisite lane #227 blocks on
(`docs/destination-capture-framing.md`, revision 5). No adopter here:
git's adoption is #227's work, since a prerequisite that converts its
own first consumer cannot be reviewed separately from it.

Three parts.

**`pmacs.window.capture_destination()`** returns the same
nonconstructible userdata for the current frontend. No arguments, and
that is load-bearing rather than minimal (Q#DC-1): a Lua-supplied
frontend id would reintroduce exactly the fabrication hole the userdata
design closes. Profile-blind for the same kind of reason (Q#DC-4) ---
capture freezes what is true now, and what a commit depends on is
declared later, at the commit.

**`DirectoryDestination` -> `ViewDestination`**, with the Lua userdata
and the capture renamed to match. The captured triple was already
generic; only its name and its capture site were not. The document pair
is now `Option`, set and cleared together, so a frontend with no live
document window still captures rather than returning nothing and
sending the caller back to the ambient state this exists to replace.

**`commit_to(dest, body [, profile])`** (Q#DC-2/Q#DC-5), a closed set of
two. The document profile keeps all four preflight checks. The panel
profile keeps only the first --- the requesting frontend still has a
layout --- because a panel result does not occupy the captured document
window, does not replace its buffer, and does not need it to exist, so
each of the other three would refuse for a reason unrelated to what the
continuation does. Omitting the profile means `"document"`, which is
what makes the preservation promise contractual rather than careful:
every existing two-argument caller keeps all four checks by definition
of the signature.

The profile argument is typed `mlua::Value`, NOT `Option<String>`, so
its error is REACHABLE: with the narrower type mlua rejects a number or
a table during argument conversion, before the closure body runs, and
the message naming the accepted values never appears. That is the same
trap the `dest` argument documents one position to its left. `nil` and
absence are the same answer; anything else is refused by one message
that names both accepted values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth 70e5781420
feat(discovery): M-x rows carry descriptions — protocol v22 -> v23
`Command.description` has always been required and has always been
rendered by `help.list-commands`. It was missing at the one moment it
would change a decision: the M-x row. This carries it there.

COHERENCE.md §5's clause "M-x rows are still bare names", per
docs/discovery-stage2-framing.md revision 3.

## The wire half is additive, and the old variant is FROZEN

postcard is not self-describing: enum variants encode by index and
fields by position. Widening `MinibufferPrompt.candidates` in place
would make every v12–v22 peer MIS-DECODE the bytes rather than ignore
them — and gating the widened form at `>= 23` would not rescue them
either, because with only one variant to gate they would receive no
minibuffer message at all. Compatibility requires the old shape to
still exist AND still be sent.

So `MinibufferPrompt` is retained unchanged for `12..=22`, and
`MinibufferPromptRows { prompt, input, cursor, rows, selected, total }`
is APPENDED as the final variant, carrying `MinibufferRow { label,
detail: Option<String> }`. A new row type, not `CompletionPopupRow`,
whose `kind` is an LSP `CompletionItemKind` code with no honest value
for a command (Q#D2-1).

Exactly one of the two reaches any peer, ever. The producer selects on
the session's negotiated version, so the CLOSE necessarily uses the
same family as the OPEN — a rows session closed by a legacy clear
leaves the dropdown on screen forever. The daemon's write loop gates
both directions again, with the legacy gate written as a RANGE
(`12..MINIBUFFER_ROWS_MIN_VERSION`) rather than a floor, so a v23 peer
cannot receive both and double-render.

`ADVERTISED_PROTOCOL_VERSION` stays 20, untouched.

## The TUI half involves no wire at all

`src/editor.rs` contains zero references to `MinibufferPrompt`:
`paint_minibuffer` reads `core.minibuffer` directly. So it reads
`Command.description` from the registry in-process, which is why this
half is independent of the bump.

Clipping is three ORDERED steps (§3.4), and the guarantee is "never a
PARTIAL name", not "the name always survives" — the prompt and typed
input consume the budget first, so the remainder can be too small even
for the bare name. If the whole name does not fit, the suffix is
omitted entirely; only once it fits is a description attempted; a
description that does not fit whole is dropped, leaving today's
`[name]`. No ellipsis stub, and no prefix of a name is ever emitted.

## Verification

`src/protocol.rs` gains this repo's FIRST literal postcard byte
fixtures: `minibuffer_prompt_v12_wire_bytes_are_frozen`, open and
cleared. A round-trip freezes nothing — it encodes and decodes with
the same types, so a field addition leaves it passing while every
shipped peer breaks. Bite-verified: reordering two fields of
`MinibufferPrompt` leaves `minibuffer_prompt_round_trips_through_postcard`
green and fails the fixture.

`line_wrap_facts_encoding_is_unchanged_by_the_v23_build` pins the
PREVIOUS final variant, per the handoff §4 rule that an appended
variant's own round-trip cannot detect a discriminant shift.

`tests/discovery_stage2_acceptance.rs` runs ONE daemon serving a v22
and a v23 session simultaneously, through the real M-x key path, and
asserts each receives its own variant AND ONLY its own — open and
close alike — by collecting every minibuffer message rather than
filtering for the expected one.

No cross-version cache test, deliberately (§3.2):
`SemanticRenderState::for_peer` bakes the negotiated version in at
attach and is dropped at detach, so a cache cannot span two versions.
A test for an impossible condition passes forever while teaching the
next reader that the hazard is real.

Five version assertions updated, each read before editing:
`src/protocol.rs` (the `PROTOCOL_VERSION` tripwire, renamed; and the
v6-floor ladder's accepted/rejected ranges),
`tests/statusline_segments_acceptance.rs`,
`tests/bottom_panel_stage2b_gpu_acceptance.rs`,
`tests/vterm_stage3_acceptance.rs`. No `ADVERTISED_PROTOCOL_VERSION`
assertion fired.

Gates: `scripts/gate --protocol --acceptance discovery_stage2_acceptance`
— all ten green, including the strengthened two-configuration sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 13:52:32 +02:00
Levi Neuwirth d8cf809b97
feat: ui.line-wrap, resolved once and told to both frontends
The setting exists now, and both frontends honor it. This is the commit
that turns the stage on: the grid renderer, the coordinate mapping, and
the GPU were all built and tested against a mode nothing could set.

ui.line-wrap is buffer-local, an enum of wrap and truncate, defaulting
to wrap. A closed set rather than a string, so an unknown value is
impossible rather than handled --- and so adding "word" later is
additive. It lives in ui., not editing.: editing.* is buffer-editing
behavior, this changes only how text is SHOWN, and the two existing
ui.* settings carry a gpu- prefix to mark frontend-specific ones, so
the absence of a prefix is what says "both frontends".

Resolved exactly once. The render loop reads it per window per frame
and records it on the window; the viewport is built FROM that, and
Window::layout_ctx hands the same answer to all twenty coordinate call
sites. Nothing re-resolves, so two callers cannot disagree about one
buffer. The earlier write-back from viewport to window is removed as
circular now that the resolution precedes the viewport.

Semantic frontends are told over LineWrapFacts, gated at v22 in the
producer and again in the daemon write loop. The dedup key is the
(buffer, wrap) PAIR, and that is the whole design rather than a
micro-optimisation: font size is global, so caching it by value is
right, but wrap mode is buffer-local, so a value-keyed cache stays
silent when the user switches from a truncating buffer to a wrapping
one --- a real mode change with no config event behind it.
a_buffer_switch_re_emits_the_wrap_mode pins that, and it bites: keying
the cache on the mode alone fails it while every other test still
passes, which is exactly how the bug would have shipped.

Two existing tests moved, and both moved for the right reason rather
than because they were stale. The semantic allowlist gains the variant,
and the first-frame count goes 7 to 8 --- the second is really an
assertion that the attach trigger works: a semantic frontend that is
not told on its first frame never learns the setting at all.

Also ships ui.toggle-line-wrap, and its status line says the quiet part
--- turning wrapping off makes text past the right edge unreachable
until horizontal scrolling lands in Stage 4. That belongs where a user
sees it, not only in the framing.

Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1916/0,
crdt 2101/0, pmacs-gpu 224/0, tab_width 2/0, folding 21/0, gui_zoom
15/15, full_grid_resync 1/1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-07 18:02:21 +02:00
Levi Neuwirth 84b1620e7e
feat(release): binaries on tag — Distribution Stage 1
.github/workflows/ had exactly one workflow and it was test-only: no
release job, no artifact upload, no tags-to-binaries path. Installing
pmacs meant `git clone` plus knowing the feature-flag matrix.
COHERENCE.md §17 grades this "missing — zero release machinery exists";
this moves it to Partial and completes journey step 1.

Scope is one stage: binaries when a `v*` tag is pushed, attached to a
GitHub Release. Channels, rollback, update-in-place, signing, RHEL 9 and
Intel macOS are out of scope and named in the framing's §5.

WHAT SHIPS: pmacs and pmacs-gpu, both at 1.1.0, CRDT-enabled, co-located
in one archive, with SHA256SUMS. pmacs-protocol stays at 1.0.0 — it is
the wire crate and versions on its own schedule.

THE VERSION BUMP EXPOSED A REAL DEFECT, and it is the reason this PR
touches src/ at all. `InstanceIdentity::for_running_process` is defined
in pmacs-protocol and expanded `env!("CARGO_PKG_VERSION")` THERE. `env!`
expands in the crate being compiled, so the field documented as "Pmacs
version string" carried the PROTOCOL crate's version. That identity
reaches Lua as `pmacs.instance.identity()` and goes on the wire in
`Hello`, so a 1.1.0 release would have told every attached frontend it
was 1.0.0.

Nothing could have caught it earlier. Three tests assert
`id.pmacs_version == env!("CARGO_PKG_VERSION")` evaluated in the pmacs
crate — the correct assertion — but while both crates read 1.0.0 they
compared the same number reached by two different paths and COULD NOT
FAIL. Deciding to hold pmacs-protocol at 1.0.0 while moving pmacs is
what made them discriminating; all three failed on the bump. The version
is now a parameter so `env!` expands in the caller's crate. A test can
be correct and still prove nothing when the two things it compares are
equal for a reason unrelated to the code under test.

TWO LAYERS OF BINARY EXCLUSION, and layer 2 is load-bearing —
demonstrated, not argued. Cargo auto-discovers src/bin/*.rs, so a
release build can produce five binaries and three must never ship
(pmacs-audit is a contributor tool; pmacs_fake_lsp and pmacs_fake_mcp
are test fixtures). Layer 1 names explicit --bin targets. Layer 2 stages
an explicit asset list, and building this branch produced exactly the
case it guards: after building ONLY --bin pmacs and -p pmacs-gpu,
target/release still held all three forbidden binaries, left by an
earlier `cargo test --release`. Swatinem/rust-cache restores that kind
of directory in CI. An implementation trusting layer 1 and archiving the
directory would have published a fake language server in the first
release.

The three archive assertions are bite-verified: a smuggled
pmacs_fake_lsp, a missing pmacs-gpu, and a cleared executable bit are
each caught, with the honest archive passing.

THE GLIBC FLOOR IS ASSERTED, NOT TRUSTED. Pinning ubuntu-22.04 sets the
floor at 2.35 (Ubuntu 22.04, Debian 12 — NOT RHEL 9 at 2.34, which needs
a container or cross-build and is parked). But a pinned runner proves
nothing about the artifact, and the failure surfaces as a bare
`GLIBC_2.39 not found` on a user's machine with no clue which commit
caused it. The build reads versioned-symbol requirements out of the
binary and fails above the floor, so switching to ubuntu-latest fails in
CI instead of shipping. Bite-verified both directions on a glibc 2.44
host. Both runners are pinned; macos-latest would drift the minimum
supported macOS with no commit to point at.

Preflight runs before any build: the tag must match the root crate
version (stripping a prerelease suffix, so v1.1.0-rc.1 and v1.1.0 both
match 1.1.0), and the tagged commit must be an ancestor of main. Both
catch mistakes that are cheap now and expensive once a public URL
exists. The suite is not re-run — CI already tested the commit — but
nothing otherwise enforced that a tag points at a tested one.

Verified: fmt, diff-check, clippy with and without crdt, --lib 1896,
--lib --features crdt 2081, pmacs-protocol 19, m4 149, required GPU 221,
and the full serialized crdt sweep at 3,715 passed / 0 failed / 30
ignored — identical to the pre-change baseline, so the protocol
signature change broke nothing. Archive staging, contents, executable
bits and both --version outputs were exercised against a real release
build locally.

No release is cut by this PR. Per the framing's §7 the RC is tagged
after merge, from the merge SHA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:40:47 -04:00
Levi Neuwirth 7a9cf5b812
fix(lint): make the crdt targets pass clippy for the first time
`cargo clippy --workspace --all-targets --features crdt -- -D warnings`
has never passed on main. The standing gate list runs clippy without
`crdt`, so these lints have never been enforced, and any CI job that
compiles the crdt targets would be red on arrival. This is framing §7
step 1: nothing else in the lane is testable until it lands.

Eight findings across four files, none behavioral:

  src/daemon.rs                              useless_conversion (u64)
  src/daemon.rs                              missing doc backticks
  src/daemon.rs                              too_many_lines (112/100)
  tests/auto_indent_crdt_acceptance.rs       missing doc backticks
  tests/bottom_panel_stage2b_gpu_acceptance  too_many_lines (104/100)
  tests/vterm_stage3_acceptance.rs           too_many_lines (122/100)
  tests/vterm_stage3_acceptance.rs           too_many_lines (132/100)
  tests/vterm_stage3_acceptance.rs           redundant `continue`

--keep-going is what made this an inventory rather than a lower bound.
docs/active-work.md recorded seven findings at 74301d1 and correctly
warned they were "a lower bound, not an inventory" because clippy
abandons remaining targets once one fails. With --keep-going the set is
complete, and it differs from the ledger's in both directions: the
`unneeded mut` at src/daemon.rs:4965 is gone (fixed incidentally by
later work), a finding in bottom_panel_stage2b_gpu_acceptance.rs is new,
and every src/daemon.rs line number had moved. A stale lint inventory is
worse than none — it invites fixing lines that no longer exist.

The four too_many_lines findings are silenced with a reason rather than
refactored. Refactoring a test body to satisfy a lint that has never run
would be a behavioral change riding a CI-configuration lane, and the
codebase already has ~20 `#[allow(clippy::too_many_lines)]` sites, the
best of them carrying `reason =`. Each reason states why the scenario is
one test: the GPU acceptances exist specifically to prove a real
daemon, a real PTY and real wgpu fit together, which splitting would
hide.

The redundant `continue` needed care. Replacing it with `Err(_) => {}`
traded the lint for `single_match` — the match then destructured one
pattern. Rewritten as an edition-2024 let-chain, which drops both
without changing semantics: an unreadable message still falls through
to the next loop iteration.

Verified: clippy green with and without `crdt` (the second confirming
no regression to the enforced gate), fmt, diff-check, --lib --features
crdt 2081 passed, and the three touched suites green — vterm_stage3 at
9/9 in 4.34s rather than 0.17s, so a37 really ran rather than reporting
ok on a missing binary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:38:36 -04:00
Levi Neuwirth 5fe7eb30b6 fix(bottom-panel): finish the panel port, not one omission at a time
Review round 1: six findings, four sharing one shape — the panel layer was a
partial port of the document/terminal layer, and the tests asserted the
declaration side only, so each omission was invisible. Audited as a port
rather than patched as a list.

GEOMETRY AGREEMENT (P1). Three grids had drifted apart. The declaration
subtracted `TEXT_LEFT` from its width against the parent framing's explicit
contract ("`total.cols` describes the full-width panel grid beginning at
x=0; document `TEXT_LEFT`/gutter padding is unrelated"), while painting and
hit-testing used the document-dependent `mono_advance` and the declaration
used the stable probe. So daemon columns could overflow the surface and a
click could resolve to a different cell than the one painted — and the new
test separated the two advances and then asserted only the declaration, so
it saw none of it.

The fix is structural, not three edits: the advance is cached BEHIND the
declaration (`PanelBand::declared_advance`) and painting and hit-testing read
it. They cannot disagree, because there is one value. The band's rect is now
x = 0 across the full surface width, and the fractional right-edge remainder
is band background that maps to no cell — which is what the framing says and
what `hit_test_cell`'s column bound already enforced.

GESTURES (P1). Only `Move` was sent. Left press never armed, so `Drag(Left)`
was never emitted and panel selection could not work; releases outside the
band were dropped, leaving the daemon holding a button down; right-click and
wheel never consulted the band at all and were applied to the document
underneath.

The root cause is that four handlers each decided for themselves whether the
band owned a pixel, and three did not ask. There is now ONE authority —
`PointerSurface` / `classify_pointer_surface` — and all four route through
it, so a future handler cannot quietly forget the band. `PanelBackground` is
its own arm: the remainder is the band's pixel even though it emits no
`PanelPointer`, so it must not fall through either.

PASSIVE CARET (P1). The producer ships `cursor` for a passive panel too — it
is the window's real point and the daemon does not suppress it — so painting
it unconditionally put a second insertion caret on screen. Gated on
`frame.focused`, the presentation bit Q#BP14b reserves for exactly this.

UNDERLINES (P2). `build_grid` planned them and nobody consumed them. Straight
forms now ride the quad batch and curly rides the squiggle pipeline, the same
split the terminal path makes for the same reason.

VERSION MISMATCH (P2). The daemon reported the advertised baseline as the
server version while its own `PROTOCOL_VERSION` is 21, contradicting the wire
field's own documentation and inverting the upgrade advice. The field doc now
states what each side can know, and the acceptance is re-pinned — it had been
holding the wrong value in place.

Two gaps the audit found beyond the six, same shape:
  * the headless probe never armed the panel wire at all, so no probe could
    ever exercise a band;
  * a disconnect left the band on screen — the frozen, live-looking surface
    the terminal arm already refuses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 22:29:24 -04:00
Levi Neuwirth d03845e826 feat(bottom-panel): activate protocol v21 by frontend counter-offer
Bottom-panel Stage 2B-3, part 1 of 3: the compatibility-preserving v21
activation mechanism and the negotiated `panel_capable` flip.

2B-1 reserved the v21 wire and 2B-2 built the daemon projection behind
it, both dark, because the handshake is server-first: the daemon writes
`Hello` before the frontend has said anything, and a frontend rejects a
`protocol_version` outside its supported range *before* it can send
`AttachRequest`. Advertising 21 there is therefore an incompatible act on
its own, independent of whether one new message is ever exchanged.

So the advertised version does not move. `ADVERTISED_PROTOCOL_VERSION`
becomes a permanent compatibility BASELINE, and the session's real
version is settled one message later, by the frontend:

  1. the daemon advertises the baseline (20, unchanged);
  2. the frontend answers `requested_protocol_version(baseline)` — its
     own `PROTOCOL_VERSION` when the baseline is the current one, and a
     verbatim echo of anything older;
  3. the daemon records `negotiated_session_version(offer)`.

A shipped v20 frontend echoes 20 and gets a v20 session, byte-for-byte
as before — the real-daemon acceptance that emulates its rejection point
still passes untouched. A current frontend offers up and gets v21. The
`Hello` encoding and value are unchanged, which is why the old frontend
never sees a version it must reject.

`peer_declared_panel_support` gains the arm 2B-2 deliberately left off:
a semantic session is panel-capable exactly when it negotiated
`PANEL_MIN_VERSION` or later. The gate is on placement, not only
transport, so a v6-v20 semantic session keeps the Stage 1 fallback.

The GPU client's `server_protocol_version` splits into
`session_protocol_version` (what the session speaks — every wire gate
keys on this) and `baseline_protocol_version` (what `Hello` advertised).
They now differ in the normal case, and that difference IS the
compatibility property, so both headless probe reports emit both keys and
the two ratchets that read them assert both directions: session 21 AND
baseline 20. Asserting only the session version would pass if the
baseline had been bumped too — the exact incompatible change this
mechanism avoids.

Also fixes a pre-existing `unused_mut` in a `crdt`-gated daemon test,
dark to the standard clippy gate because that gate runs without the
feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 17:42:08 -04:00
Levi Neuwirth 3ecb03d949 Close review round 1: five findings, plus one the sweep found
All five review findings reproduced with a failing test before any fix,
and every fix falsified by reverting it.

R1-1 — the wire-area clamp lived only in `panel_grid_size`, so the daemon
shipped an authoritative `Absent` while `panel_hidden` stayed false:
keys kept reaching the invisible window and a panel terminal kept its
controller. Q#BP2b calls hiding a DURABLE state transition and the
exhaustion arm had made it a per-frame effect. Fixed structurally rather
than pointwise: `presentable_panel_grid` is now the one derivation behind
both the renderer and `reconcile_panel_layout_core`, so the two cannot
drift apart again.

R1-2 — closing and reopening the same PERSISTENT buffer inside one
dispatcher burst left the shipped declaration intact while the window it
described was already dead, and same-buffer/same-size made the successor
indistinguishable by every other field. A presentation epoch only
identifies a presentation if something checks that the presentation it
names is still on screen, so `panel_declaration_matches` now takes the
live side window and buffer.

R1-3 — the semantic terminal-layout twin consulted only the full-document
declaration, which a panel terminal deliberately lacks, so the child kept
its opening geometry through the drain. `sync_semantic_panel_terminal_
layout` is the missing case; it resolves through `side_window_for` while
its sibling resolves through `primary_document_window`, so the two are
disjoint by construction and nothing is resized twice per tick.

R2-4 — `NoMessage` means publish nothing, not publish empty. Treating it
like `Invalidated` removed the band's provider text on a transient
buffer-follow mismatch. The band repaints its whole mode line every
frame, so "publish nothing" has to be a retained baseline; it is keyed by
window id so a replaced panel inherits nothing.

R2-5 — non-`Move` activation is Q#BP16's TERMINAL clause, because the
shared adapter claims the controller for wheel steps too. A document
panel keeps scroll-without-focus, matching `dispatch_mouse`.

The sweep for R1-1's and R1-3's shape found one more, and it is the same
bug as R1-1: a panel wider than the terminal subsystem's per-axis cap is
legal on the wire (Bet B5') but its content rect was refused by
`snapshot_for_view`, collapsing the projection to `None` — a per-frame
`Absent` with the durable state still saying visible, reachable with one
`FrontendCellGeometry` declaration. The band is legitimately that wide,
so the child is clamped to the columns a PTY can have and the remainder
paints as band background, exactly as a narrower snapshot already does.

One knowingly per-frame `Absent` remains and is recorded in the code
rather than fixed: presentation-epoch exhaustion, which takes 2^64
shipped presentation changes in one session and cannot be reached by any
frontend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-28 18:52:43 -04:00
Levi Neuwirth a251b89e2b Pin the two epoch races the session check alone cannot see
Q#BP16 step 3 compares an inbound event's geometry epoch against BOTH
the declaration the frontend was looking at and the daemon's latest
accepted one, and mutation testing showed the second half unpinned: no
fixture made the two diverge, so deleting it changed nothing. They
diverge exactly once — between a declaration being accepted and the
next frame answering it — which is the font/scale/resize race the epoch
exists for.

Also pins the attach/resize gate change at the seam it would break: a
semantic frontend's `Resize` must mint no frame geometry even when its
view is panel-capable, while a grid frontend's real frame size still IS
its declaration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-28 17:32:27 -04:00
Levi Neuwirth 817b134ae8 Produce PanelFrame and gate the inbound panel events
Bottom-panel Stage 2B-2, second half: the producer, the presentation
epoch, and the three inbound event gates.

The producer lives beside the terminal pass in `semantic_render.rs` and
follows its shape: compare the complete payload first, validate only a
payload that differs, and store only what was actually shipped. The
presentation epoch is allocated from the side window and its buffer, so
a new side window, a replaced buffer, and every `Absent` -> `Present`
transition each take a fresh identity; `Absent` clears the identity,
which is what makes close/hide/reopen of the SAME persistent buffer
unaddressable by a stale `PanelPointer`. Allocation is checked and
exhaustion fails closed to `Absent` rather than wrapping into a live
identity. The `Absent` baseline is seeded rather than left empty: a
fresh session has no band, so the opening state is a fact the peer
already holds.

The band rides both render paths and does not wait for a declared byte
viewport: it is a separate surface, and gating it on the document
declaration would leave the first panel unpaintable. Its mode line takes
the side window's segments from the SAME provider invocation that serves
the document's wire segments.

Inbound, `peer_may_send_panel_events` checks four facts together — an
installed semantic projection, the negotiated version, the daemon's own
capability bit, and (via the transport source) that the payload's
claimed id is never consulted. `panel_event_epochs_are_current` then
runs Q#BP16 steps 2-4 as one predicate so no caller can check the
geometry epoch and forget the presentation epoch.

Two daemon gates moved from `panel_capable` to `!semantic_render`. Stage
1 could conflate them because panel capability implied grid; now that a
semantic view can be panel-capable, a capability-keyed gate would feed it
the permanent 24x80 attach placeholder that Q#BP15a forbids, and parent
acceptance 40 would fail through the attach line rather than through the
projection. Not a live defect — no production semantic session is
panel-capable yet — but it is the landmine Stage 2B-3 would have stepped
on.

`panel_capable` is unchanged for production negotiation and the
unsolicited `Hello` still advertises v20. Nothing here is reachable by a
user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-28 17:28:43 -04:00
Levi Neuwirth ab7c207904 Keep the v21 panel wire dark for v20 clients
Reserve the additive v21 panel schema without advertising it in the
server-first production handshake. Pin a real shipped-v20 client attach,
make the two aggregate-budget ratchets exactly one byte over, and update
the framing, coherence audit, handoff, and volatile lane record.
2026-07-28 14:08:17 -04:00
Levi Neuwirth d88d60eba6 Merge canonical main into bottom-panel Stage 2B-1
Integrate the Journey Stage 1a merge without rewriting the already
reviewed protocol branch. Record the approved three-way Stage 2B split,
advance the canonical recovery anchor, remove the landed Journey lane,
and put 2B-1 into its full-gating state.
2026-07-27 22:16:11 -04:00
Levi Neuwirth 8e31ca4646 Merge remote-tracking branch 'githubsucks/main' into journey-stage1a-directory-open 2026-07-26 18:33:44 -04:00
Levi Neuwirth 640c5cd0d2 feat(panel): bottom-panel Stage 2B — the v21 protocol layer
Adds the four wire shapes Q#BP9 names, bumps the protocol to v21, and
factors the cell-grid validator so a panel frame shares the terminal's
rules without inheriting its PTY caps.

- `InstanceMessage::PanelFrame(PanelFramePayload)`, appended after
  `InitialTargetResult`; `Absent` is an explicit authoritative state, not
  silence, because the receiver retains its last valid frame.
- `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`,
  appended after `TerminalPointer`. Geometry is valid without a side
  window — gating it on panel presence would deadlock the first open,
  since the daemon needs columns before it can paint a first frame.
- `pmacs-protocol/src/wire_grid.rs` holds the shared rules: checked area,
  visible-cell bound, cell count, cursor bounds, glyph legality,
  wide-continuation topology, the aggregate glyph budget, and the
  attachment rejection. The 512 per-axis caps, metadata, selection spans,
  and the at_bottom/scroll_offset coupling stay terminal-only.
- The attachment rejection is deliberately shared despite its
  terminal-side wording: panels render no attachments either, so sharing
  it fails closed for both.

Both byte pins were falsified by revert: moving `PanelFrame` ahead of
`InitialTargetResult` shifts it 27 -> 28 and fails; moving the three
events ahead of `TerminalPointer` shifts it 12 -> 15 and fails.

The factoring changed no terminal acceptance — all 17 terminal tests pass
unchanged. It did surface a pre-existing coverage gap: those tests pin
the row cap but never the column cap, so widening `max_cols` to u32::MAX
left them green. `a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not`
now covers that direction.

The daemon projection, the epoch state machine, and the GPU band are
later slices of this stage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuhVYUPHXMHG8r2z4tsDPR
2026-07-26 16:52:54 -04:00
Levi Neuwirth 25b07be97b fix(journey): correct Q#JR3, report the post-dispatch buffer, unvacuate two pins
Four review findings, all confirmed against the tree.

Q#JR3 was false. `replace_active_buffer` does not drop the startup
scratch buffer -- its body is one `switch_active_buffer` call, which
reassigns the window's buffer_id and removes nothing. The claim came
from that function's own doc comment, wrong for as long as it has
existed, and rev 5 propagated it into the framing and into new
documentation this branch added. Both comments are corrected here,
because this PR was adding further false references to a claim P4
depends on. Actually removing the stale scratch is buffer-lifetime work
and stays out.

The daemon bootstrap could report the wrong buffer. The directory arm
captured the destination id, ran the resolver chain synchronously, then
returned the captured id -- so a handler that opened something
synchronously through commit_to had already replaced the window's
buffer, and the reply paired one buffer's snapshot with another's
identity. It also returned early, skipping the post-hook revalidation
the framing said stayed active. The arm now re-reads the destination
after dispatch and rehomes through `non_side_target` as the file arm
does. Pinned by a test whose handler claims synchronously.

N11 tested neither RET nor self-insert: it called display_file and
buf:insert directly, so it stayed green with dired's RET binding, its
entry dispatch, and the editor's self-insert path all broken. Both
gestures now go through dispatch_key.

P7 is removed rather than weakened. Q#JR12 has nothing to pin --
`had_file = file.is_some()` and a directory is Some like any other, so
no directory-specific branch exists to break. The old test never armed
restore and hard-coded had_file, so it could not fail against any
implementation.

Also adds the daemon bootstrap pins (N2, N5) and fixes an insertion that
had orphaned a `#[cfg(feature = "crdt")]` from the test it guarded --
which would have made one new test dark and one existing test escape its
gate.

Framing: docs/journey-stage1a-framing.md rev 6.
2026-07-26 16:39:59 -04:00
Levi Neuwirth f09f66ce37 feat(journey): open a directory, on one path
Journey Stage 1a's core: `pmacs .` opens the directory instead of
exiting 1, and local startup stops being a second implementation of
path resolution.

`EditorState::open` now calls `EditorCore::resolve_target_buffer` --
the primitive whose own doc comment says it exists "so two
path-normalization, dedup, and hook transactions cannot drift apart",
and which local startup had never been a caller of.

`resolve_target_buffer` returns a typed `ResolvedTarget` rather than
`(BufferId, HookKind)`, with a `Directory` arm checked ahead of the
load. Without it the load runs and fails: `File::open` succeeds on a
directory and `read_to_end` returns EISDIR, which is not `NotFound`, so
the `[new file]` arm never fired.

A directory creates no buffer. It dispatches a resolver chain: the
short-circuit `path.open-directory` hook, which no builtin subscribes
to, and then `pmacs.path.directory_handler`, which dired defaults. The
split is forced rather than chosen -- hook callbacks only append and
builtins load before init.lua, so a subscribing builtin would always
claim before any user listener could run. A raising listener stops the
chain and suppresses the fallback.

The listing is async and the daemon bootstrap is not, so the whole
post-await commit runs inside a new `pmacs.window.commit_to`: it
validates the destination -- frontend live, window live, buffer
unchanged, window replaceable -- BEFORE invoking its callback, then
scopes the acting frontend for its extent. Validating at display time
would be four dired mutations too late.

That scope is deliberately not `InteractiveCommandOrigin`, which does
not reach the core-ambient APIs and is authenticated user-command
authority a background continuation must not acquire.

The dedication rule is extracted into one `window_accepts_buffer`
shared by exact display, the display probe, and the new preflight, with
`incoming: Option<BufferId>` -- `None` means "the replacement does not
exist yet" and refuses a dedicated window.

`display_file` keeps its directory-is-an-error contract and does not
enter the chain; find-file's accept arm depends on it.

Framing: docs/journey-stage1a-framing.md rev 5 (Q#JR1-JR15).
2026-07-26 16:39:59 -04:00
Levi Neuwirth a53965474d feat(lean4): the Unicode input method (Arc 8 Stage 4b)
Typing `\alpha` in a Lean 4 buffer gives `α`; `\<>` gives `⟨⟩` with the
point between them. The abbreviation table is vendored from
vscode-lean4 and the expander is a typed-edit consumer registered on
the Stage 4a chain at priority 50, ahead of auto-pairing.

The ordering is load-bearing. 64 abbreviation keys contain a character
in the `lean4` pair set, so with pairing first, typing `\[` would
insert `[]` and corrupt the pending key to `\[]` before the second `[`
arrives — `\[[]]` becomes unreachable. The consumer therefore claims
every keystroke that EXTENDS a pending abbreviation, not only one that
completes an expansion; claiming only completions would hand each
intermediate `[` to pairing by a different route.

The vendored table is an ORDERED SEQUENCE, not a map. Upstream breaks
equal-length ties by source declaration order — 101 prefixes depend on
it, and `\f` resolves through `f<` rather than `f>` — which a
`pairs`-iterated Lua table cannot express. `scripts/regen-lean-abbrev`
takes a vscode-lean4 commit, emits the file with its provenance header,
and aborts on a duplicate key, invalid UTF-8, or a round-trip mismatch.

Undo is cross-peer-degraded on CRDT frontends and that is accepted and
named, not papered over (Q#LN21): `\alpha` arrives as six source-peer
optimistic inserts while the expansion is one daemon-peer replace.
`set_round_trip_input` would fix it and also makes `dispatch_idle`
report false, so RET would stop inserting a newline.

Round 9 corrects three approved acceptance criteria that the real table
contradicts, found by simulating the state machine over all 1,855
entries and re-reading upstream at the pinned commit rather than
re-reading the prose. `\to` is not eager — `top`, `to0` and `toa`
extend it. `\zzzz` expands to `ζzzz ` because `ze`, `zeta` and
`zsqrtd` exist; only `$ % , ; @ W` open no key at all. And `\alpha`'s
undo does not restore `\alpha ` because `alpha` IS eager, so the
terminator is a separate edit. Criteria 38, 41 and 42 now state both
paths, and the false halves are asserted too: they read as correct
until the table is consulted.

Three implementation traps worth the record. The generator's own
round-trip check was broken twice and failed closed both times:
`str.splitlines()` splits on U+2028, which 53 symbols contain, and
escaping through `chr(byte)` produced a latin-1-shaped string that the
UTF-8 write re-encoded. The first check compared in-memory strings and
agreed with itself; it now stages the file, re-reads the bytes from
disk, and renames into place only on a match. And the expansion SHRINKS
the buffer, so the point must be placed explicitly — pairing's
no-cursor-motion rule holds only for an insert AT the cursor, and
without this every self-insert after the first expansion is silently
rejected and the editor looks dead.

25 acceptance tests plus one `--lib` test for the optimistic CRDT
producer (45f), which is where the gate list's `--features crdt` run
reaches it; a crdt-gated integration test would be dark in CI and in
the gates both. Fifteen mutations bite, each failing its target. Three
of these tests were vacuous when first written and biting is what
found them: the abandonment test asserted text a surviving record
would also produce, the re-arm test used an example that never reaches
the re-arm branch, and both switch tests ran through
`find_or_open`'s fresh-load path rather than `buffer.after-switch`.

No protocol change (Q#LN14). Also reconciles the handoff and ledger
for Stage 4a (#179) and adds `lean.abbrev` to COHERENCE.md's
config-registry adoption census, now nine settings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B
2026-07-26 16:24:06 -04:00