SS5a is the acceptance-48 ground truth and its rulings are settled:
Q#BP-R1 (a single click selects a listview row only), Q#BP-R2 (a
terminal-chrome wheel is consumed daemon-side, before activation), the
R-a..R-d replay edges, and the witness matrices A1-A5, B1-B6, D1-D4.
Also records what the eight rounds cost, because the pattern repeated:
every round found a witness or a rule that would have PASSED against a
broken implementation. A `#[cfg(test)]` fixture cited as a production
handler. Activation ordering mistaken for a safety proof. A mode-line
rule that was per-row when the tree's is per-kind, and document-only at
that. A producer rule the frontend had no information to obey. A
consume check that would have left focus stolen. And twice, an
assertion added without a state in which it could fail.
No round found a design disagreement. The rulings from 8 and 9 survived
intact; what changed each time was WHERE a rule lived or WHAT A WITNESS
COULD SEE.
Implementation begins.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Answers review of revision 11. Still framing only; no implementation.
**"FOCUS UNCHANGED" IS VACUOUS IF THE PANEL IS ALREADY FOCUSED.** With
the terminal side window active, the below-activation mutation calls
`focus_window` on the window that is ALREADY active --- nothing
changes, and the row stays green against the exact bug it exists to
catch. I added the assertion in revision 11 and did not give it a state
in which it could fail.
Leg 2 now starts passive, and says so as assertions rather than setup:
the primary document window is active, the terminal side window is
distinct from it and passive, and the terminal controller identity is
captured as a baseline. Asserted rather than arranged, because each is
the kind of detail a later edit changes silently, and each failure mode
is a witness that passes while proving nothing.
**AND THE TWO ASSERTIONS ARE NOT INTERCHANGEABLE**, which revision 11's
prose blurred by naming them together as though either would do:
consume check below activation
-> `focus_window` runs, the panel takes focus, and NOTHING
scrolls, so every movement assertion still passes
-> caught by FOCUS, and focus alone
terminal branch calls `apply_terminal_gesture`
-> `claims_control` is `!matches!(kind, Move)` (`:3555`), so a
wheel CLAIMS THE PANEL'S CONTROLLER at `:3571`, before any
local handling at `:3575`; the chrome coordinate then fails
the reporting bounds check and drops into the local branch,
which may also move scrollback
-> caught by CONTROLLER IDENTITY, plus scrollback when it moves
Activation alone claims nothing --- `focus_window` and
`claim_terminal_controller` are separate acts --- so a row asserting
only controller identity misses the ordering bug entirely, and one
asserting only focus misses a replay that quietly claims the terminal.
Gates: all nine green under `env -u TMPDIR`, log 20260814T144513Z.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Answers review of revision 10. Still framing only; no implementation.
**"CONSUME BEFORE `apply_terminal_gesture`" WAS NOT EARLY ENOUGH.**
`activates` is `!matches!(kind, Move)` for a terminal panel
(`src/editor.rs:2695`), which INCLUDES the wheel, and focus plus
`active_frontend` are written at `:2699` --- ahead of any replay
decision. A consume check below that block leaves the wheel CHANGING
FOCUS while scrolling nothing and claiming no controller: exactly the
half-state AC48's activate-then-claim rule exists to prevent. The panel
steals focus and does not move.
A terminal-chrome wheel is therefore not a terminal gesture at all, and
the dispatcher must treat it that way. The order is now stated:
1. authenticate and validate (the existing ladder)
2. resolve the side window and its buffer kind
3. terminal + chrome wheel -> CONSUME IMMEDIATELY, before
`focus_window`, `active_frontend`, any controller claim, any
command-chain mutation, and the shared terminal path
4. otherwise, the existing activation and replay rules
Step 3 is implementable where it belongs: `is_terminal` is resolved
from `buffer_id` at `:2687`, before the `core` borrow and before the
activation block, so the kind is already in hand.
**The witness gains focus and controller assertions, and they are
load-bearing.** The document->terminal replacement row now asserts the
focused window and terminal controller identity are unchanged, on top
of no child bytes, no scrollback and no document movement. Two
mutations bite different halves:
- terminal branch calls `apply_terminal_gesture` -> the chrome
coordinate fails the reporting bounds check, falls into the local
branch, and the row catches the accidental local scrollback
- the consume check sits BELOW activation -> nothing scrolls, so
every movement assertion still passes and ONLY the focus and
controller assertions catch it
That second mutation is the reason the new assertions exist. Without
them the row stays green against an implementation that steals focus on
every chrome wheel over a terminal, because the half-state is invisible
to any assertion about movement --- nothing moves either way.
Gates: all nine green under `env -u TMPDIR`, log 20260814T143558Z.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Answers review of revision 9. Still framing only; no implementation.
Q#BP-R2's outcome is unchanged; its ENFORCEMENT POINT moves.
**THE GPU CANNOT KNOW WHETHER A PANEL HOLDS A TERMINAL.** `PanelFrame`
carries `buffer_id`, both epochs, `size`, `cells`, `cursor` and
`focused` --- and no target-kind discriminator
(`pmacs-protocol/src/panel.rs:73`). The panel is an OPAQUE CELL
PROJECTION to the frontend. `state.terminal`
(`pmacs-gpu/src/main.rs:1964`) cannot answer either: it describes the
primary full-window terminal, not the side-window.
So revision 9's producer rule --- "terminal chrome wheel: do not send"
--- was unimplementable without a new wire field, and this lane is
explicitly non-protocol-bearing. I wrote a rule the frontend has no
information to obey.
The correction is structural, not cosmetic. **Every producer rule is
now target-blind**, and the table is split by SEAM SIDE rather than by
target:
producer claims the chrome wheel and sends `PanelPointer` with its
valid frame coordinate, for EVERY panel
daemon after validation and resolving the side window ---
document panel -> `scroll_window`;
terminal panel -> CONSUME, producing no child bytes, no
local scrollback, and no document fallthrough
The other producer rules collapse safely because both targets want the
same behaviour there: a chrome press is reserved by documents and
dropped by terminals --- both drop --- and a crossing `Up` must arrive
for either, to terminate a selection or deliver a release. The wheel
was the sole divergence, which is exactly why it was the rule that
could not survive on the producer side.
The chrome coordinate does cross the wire now, and that is fine: it is
a valid frame cell, coord validation accepts it, `scroll_window` is a
window-level move that never reads it, and the terminal branch never
forwards it to a child. Nothing is fabricated and nothing reaches an
application --- which was the whole objection to clamping.
**Witness: ONE frontend across a document->terminal replacement.**
Document chrome wheel scrolls the panel; after replacement the terminal
chrome wheel changes nothing. Doing it in one frontend across a
replacement is what makes it a control rather than two unrelated
observations --- geometry, pointer position and producer path are
identical, and only the target differs. Mutation: let the terminal
branch call `apply_terminal_gesture`, and the chrome coordinate fails
its reporting bounds check, falls into the local branch, and the row
catches the accidental local scrollback that revision 8's clamp would
have shipped on purpose.
Gates: all nine green under `env -u TMPDIR`, log 20260814T142336Z.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Answers review of revision 8. Still framing only; no implementation.
Both items reverse a decision revision 8 made.
**Q#BP-R2 IS OVERRULED: a terminal-chrome wheel is CONSUMED, not
clamped.** I argued clamping on consistency grounds and missed that SGR
wheel input is COORDINATE-BEARING: `encode_mouse` takes a `coord` and
writes `coord.col + 1` / `coord.row + 1` into the emitted sequence
(`src/terminal/input.rs:102`, `:146`) --- which A4, added one revision
earlier, pins exactly. Clamping therefore fabricates a hit on the
terminal's final content row, and an application that routes wheel
input by position acts on a cell the user never pointed at. The
consistency I was buying is cosmetic; the price is a synthetic
coordinate handed to a program as real.
The asymmetry with `Up` is the part worth keeping in the record: `Up`
must be normalized because an unterminated gesture HANGS --- the daemon
holds a button down forever --- while a wheel tick is self-contained
and dropping one strands nothing. Liveness is what earns
normalization, and the wheel has no liveness obligation. Consumed, and
never fallen through to the document; TUI parity restored.
**R-c2 WOULD HAVE BROKEN A TESTED GUARANTEE.** Revision 8 said to
retain the `Down` cell in `last_pointer_cell`. That field is cleared on
press deliberately, and a live test says why: "the first drag after a
press must reach the daemon even at the cell the press landed on"
(`pmacs-gpu/src/main.rs:19841`). Storing `Down` there makes the press's
own cell the dedupe baseline and suppresses exactly that `Drag`.
Ruled: a separate `gesture_last_content_cell`. The two fields have
different jobs and conflating them was the error ---
`last_pointer_cell` answers "is this motion worth sending?", the new
one answers "where did this gesture last legitimately point?" ---
different lifetimes, different resets. Written on arm and on each
accepted content motion; reset on release and on BOTH identity changes;
never consulted by `panel_motion_is_new`. A second mutation guards the
separation itself: point the dedupe at the new field and the existing
first-`Drag`-after-press assertion must fail.
The alternative --- ruling `Down` the dedupe baseline and retiring the
guarantee --- is recorded as considered and rejected. It would need a
mutation showing the first same-cell `Drag` is redundant, and it is not
obviously so, since that event is what establishes the daemon's drag
state.
**And the crossing table follows the choice.** Revision 8 promised a
content-originated `Drag` over chrome would be sent. It should not be:
once normalized, its coordinate is usually the one already reported and
the ordinary dedupe suppresses it, correctly --- promising delivery
would oblige the producer to defeat its own dedupe for a daemon state
that is identical either way. `Up` is the load-bearing crossing event
and the only one promised unconditionally.
Gates: all nine green under `env -u TMPDIR`, log 20260814T123502Z.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Answers review of revision 7. Still framing only; no implementation.
**R-c NEEDED TARGET x GESTURE-ORIGIN, NOT KIND ALONE.** The per-kind
rule I took from the TUI is DOCUMENT-ONLY: for a terminal window the
TUI rejects every kind on the mode line before any per-kind match
(`src/editor.rs:3273`) and hands `dispatch_terminal_mouse` a
content-sized viewport (`:3272`). Terminals never see a chrome
coordinate.
And passing one is not merely untidy, it is wrong twice:
`apply_terminal_gesture`'s reporting branch is bounds-checked on
`coord.row < screen_size.rows` (`:3560`), and a chrome row equals
`screen_size.rows`, so the check FAILS and the gesture falls into the
LOCAL branch. A reporting child therefore receives a `Down` and then no
`Up`, and a chrome wheel silently becomes local scrollback instead of
behaving like a content wheel. The table is now split by target, and a
content-originated gesture terminates at its LAST VALID CONTENT
COORDINATE.
**Q#BP-R2 is ruled: a chrome wheel over a terminal panel CLAMPS.** This
diverges from the TUI, which drops it, and is flagged as the one
product call inside R-c. The panel's mode line is the band's own
chrome, one row, and a document panel already scrolls on a chrome
wheel; dropping only for terminals would make the same pixel behave
differently depending on which buffer happens to be shown. Overrule if
TUI parity is preferred --- nothing else in R-c depends on it.
**R-c2 --- THE PRODUCER NEVER REMEMBERS THE `Down` CELL.**
`set_panel_pointer_held` clears `last_pointer_cell`
(`pmacs-gpu/src/main.rs:7250`) and only motion refills it, while
`panel_release_cell` falls back to exactly that field. Today the
fallback is rarely reached because chrome IS a `PanelCell`; the moment
R-c stops that, a `Down` in content followed immediately by a release
over chrome --- no intervening motion --- has nothing to fall back to.
Retain the `Down` cell at arm time. For a reporting terminal the row
asserts the exact child release BYTES, because a latch that clears
while the child never hears the release is the failure being guarded.
**A1 WAS A NEGATIVE ROW STANDING ALONE.** It asserts bytes that must
not appear, so disabling child reporting entirely satisfies it --- as
does never reaching `apply_terminal_gesture`. The whole edge matrix
could have stayed green against a replay that never reports anything.
A3-A5 add the positive half on the SAME terminal: exact SGR sequences
for `Down`/`Drag`/`Up`, exact wheel bytes when reporting, and the
non-reporting wheel's local scrollback effect.
**Four witness seams tightened.** B2 was checking the wrong thing:
`active_region()` returns `None` "if no region is set OR IT IS EMPTY"
(`src/editor_core.rs:4684`), so it answers None both before and after
the `Up` and cannot see the residue --- which is an active-but-EMPTY
selection whose stale anchor would capture the next shift-motion. It
reads the owning window's raw `selection` now. B4 pins the exact
selected word rather than that click state "reads a multi-click". B6
runs its visit sentinel through single AND double click, since the
ruling forbids both. D4 uses a CHANGED frame with unchanged epochs ---
the focus repaint the `Down` itself causes --- because a byte-identical
duplicate returns early ("A duplicate does no work", `:6918`) and would
never reach the reset code it is meant to constrain.
Gates: all nine green under `env -u TMPDIR`, log 20260814T122442Z.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Answers review of revision 6. Still framing only; no implementation.
Three of the five are corrections to rules 6 introduced.
**R-c's "the last row is inert" WAS WRONG.** The TUI precedent I cited
is per KIND, not per row: `inner_rows` guards `Down(Left)` (:3303),
`Drag(Left)` (:3331) and `Down(Right)` (:3348), and deliberately does
NOT guard `Up(Left)` (:3339) or the wheel (:3358/:3362). A blanket
inert rule breaks two things at once --- a wheel over the mode line
stops scrolling, and a gesture begun in content and released over the
mode line NEVER TERMINATES.
And a receiver-only rule cannot work at all here, because the producer
arms first: `PanelCell` spans the whole frame, so a mode-line `Down`
runs `set_panel_pointer_held(true)` locally
(`pmacs-gpu/src/main.rs:2878`) before the daemon sees anything.
Dragging into content then emits an orphan `Drag` the daemon cannot
tell from a real one. R-c now carries a per-kind producer/receiver
table, both crossings as witnesses --- mode-line->content must emit no
Drag, content->mode-line must terminate --- and rules the wheel
explicitly.
**R-d COVERED PANEL IDENTITY AND MISSED GEOMETRY IDENTITY.** The two
epochs move independently by design: `panel_epoch` is stable across
ordinary frames while `geometry_epoch` moves on any new declaration
"including a font or scale change that leaves `CellSize` identical"
(`pmacs-protocol/src/panel.rs:61`). `next_geometry_declaration`
(`pmacs-gpu/src/main.rs:6847`) advances it and clears neither pointer
field, so a held gesture resumes UNDER A NEW GRID carrying epochs that
are current and valid --- acceptance 49 rejects stale epochs and this
one is not stale.
R-d also constrained only one of the two latch fields. Clearing
`pointer_held` alone kills the orphan drag but leaves
`last_pointer_cell`, and `panel_motion_is_new` (`:7238`) then
suppresses the successor's first same-cell `Move` as a duplicate. Four
mutations now, D1-D4, including the NEGATIVE one: an ordinary
same-identity refresh must not cancel a live gesture, or D1/D2 are
satisfiable by resetting on every frame and selection stops working.
**R-a WITNESSED ONLY THE TERMINAL.** `dispatch_pointer`'s `Down` arm
reads `extending = mods.contains(SHIFT)` and either keeps the anchor or
collapses the selection (`src/editor.rs:3673`), so Shift-click in a
document or listview panel is selection extension. Threading modifiers
into `apply_terminal_gesture` alone would have passed the proposed row
while leaving document panels broken --- a witness certifying half a
fix. Two rows now, A1 terminal and A2 document, with separate
mutations: a single "drop mods at the boundary" bites both and proves
the boundary matters, not that each CONSUMER is wired.
**R-b'S ROWS WERE SATISFIABLE BY DOING NOTHING.** "Only A's panel
changes" passes for an implementation that drops the tail entirely. B1
now pins A's anchor at the Down cell's byte and A's cursor at the Drag
cell's byte; B2 proves `Up` collapses an empty click. B4 and B5 carry
Q#BP16's existing multi-click and Context statements, which replay is
where they first become executable. B6 is a listview visit sentinel:
the row is selected and `on_visit` does NOT run, so Q#BP-R1's ruling is
falsifiable rather than merely asserted.
Gates: all nine green under `env -u TMPDIR`, log 20260814T120856Z.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Answers review of revision 5. Still framing only; no implementation.
**Q#BP-R1 IS RULED: a single click SELECTS a listview row only.**
RET/SPC remain the activation path (`listview.lua:610`); no
click-to-visit, no double-click-to-visit. That is acceptance 48's own
wording, and it keeps document navigation from arriving as an
incidental consequence of wiring replay.
**REVISION 5 CONCLUDED THAT ACTIVATION ORDERING MADE REPLAY SAFE. IT
IS NECESSARY BUT NOT SUFFICIENT.** The ordering holds only for gestures
that activate, in a session nothing interleaves with, on a presentation
that does not change mid-gesture, with modifiers intact and the mode
line excluded. Each of those four provisos is an edge:
**R-a --- modifiers are dropped before the shared path.** `PanelPointer`
carries `mods`; the daemon destructures them into `..`
(`src/daemon.rs:2425`) and the dispatcher has no modifier parameter.
This breaks PRECEDENCE, not just fidelity: `apply_terminal_gesture`
gates child reporting on `!shift && ... && modes.mouse_sgr`
(`src/editor.rs:3534`), so Shift is the user's "select locally instead
of talking to the child" override. Zeroed, a Shift-drag over a
reporting terminal panel sends SGR. Row: Shift-drag selects locally and
the child receives NO bytes. Mutation: drop `mods` at the boundary.
**R-b --- `Drag` and `Up` do not activate**, and another frontend's
input can interleave between a Down and its Drag, so a replay reading
ambient active-window state acts on whatever is active then. The
framing now NAMES the mechanism: an explicit side-window cell->byte
adapter, and selection through a window-TARGETED path.
`activate_and_position` (`src/editor.rs:3795`) is both the precedent
and the trap --- its conversion is properly window-scoped, but it calls
`set_active_window_id`. Rows: panel A Down -> frontend B input -> A
Drag/Up changes only A; an orphan Drag/Up on a passive panel leaves the
document mirror byte-identical.
**R-c --- `panel_grid_size` is the FRAME, not the terminal viewport.**
The panel's last row is its mode line: projection derives content as
`rows - 1` (`src/editor.rs:2499`) while `panel_hit_test` reports across
the whole frame (`pmacs-gpu/src/main.rs:7184`), so a `PanelPointer` can
name the mode-line row. Passing the full grid to
`apply_terminal_gesture` would make the mode line a child terminal
cell. Terminal viewport is `rows - 1`; document replay follows the
TUI's existing rule, "Mode-line click: reserved"
(`src/editor.rs:3304`). Rows must distinguish content from chrome or an
off-by-one passes.
**R-d --- replacement leaves the frontend's gesture latch armed.**
`Absent` clears `pointer_held` and `last_pointer_cell`
(`pmacs-gpu/src/main.rs:6909`); `Present`->`Present` does not
(`:6913`). Press on A, A replaced by B, and the latch emits a Drag or
release for B with no B press --- and acceptance 49 CANNOT reject it,
because the event carries B's current epochs. 49 is a staleness gate
and this is not a stale event; it is a well-formed event from a gesture
whose presentation no longer exists. The fix has a precedent in the
same file: the DIVIDER drag latch already carries both epochs and
self-invalidates (`:7288`). The pointer latch never got it.
Three of the four are places where the tree already contains the right
answer and the panel path does not use it, which is worth saying
plainly: this is wiring that has to be done carefully, not mechanism
that has to be invented.
Gates: all nine green under `env -u TMPDIR`, log 20260814T115257Z.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Ground truth before code, and the lane entry in the same commit, per
the standing correction from #171 and #215 --- the one the 1b lane
missed. Base `githubsucks/main` @ `72da24a`. No implementation yet.
**AC48 IS NOT UNIMPLEMENTED. IT IS HALF IMPLEMENTED**, and nothing had
written the halves down separately, which is how "PanelPointer drives
listview rows, selection, terminal reporting and focus" could sit next
to a dispatcher that does none of the first three.
Landed already: click-to-focus and the terminal activation rule
(`src/editor.rs:2701`); the focused-only auto-scroll clamp with a
passive panel's `view_top` preserved (`:2569`, which has cited parent
48 all along); and the coalescing rules --- `Move`/`Drag` tails
coalesce while press/release/context/wheel stay lossless and ordered
(`pmacs-gpu/src/attach.rs:374`).
Missing: listview row selection, panel selection, terminal mouse
reporting, wheel replay.
**AND THE REPLAY IS MOSTLY WIRING.** `apply_terminal_gesture`
(`src/editor.rs:3525`) is already "the one terminal pointer path,
shared by both frontend kinds" and already drives child mouse
reporting, selection and scrollback; a panel terminal needs the SAME
CALL, with `side_window_for` + `TerminalViewKey` + the
`panel_grid_size` the dispatcher already fetches. `scroll_window`
(`:3845`) is window-scoped including its cursor carry, and documents
that a wheel "names the pane under the pointer and does NOT activate
it". This is why a wheel-only bridge would be the wrong shape: the
shared path takes every kind at once, and the wheel-only version would
have to grow a second one later.
**The scoping hazard, and why no ruling is needed.** `set_cursor_byte`,
`begin_selection` and `clear_selection` are ACTIVE-WINDOW scoped
(`src/editor_core.rs:1216`, `:4691`), so replaying naively from the
panel path would move the DOCUMENT's point --- exactly what AC48's
"without disturbing the document mirror" forbids. The existing
activation rule already resolves it: activation runs before replay in
the same dispatch, and the gestures that need the active-window API are
precisely the ones that activate. The single gesture that does not --- a
document panel's wheel --- needs only the window-scoped
`scroll_window`.
**One genuine question, opened not answered: Q#BP-R1.** Does a listview
row VISIT on click, or only select? `listview.lua` binds visiting to
RET/SPC (`:610`) and there is no pointer precedent anywhere in the
file. Single-click-visits matches a file tree; click-selects,
double-click-visits matches an editor list; AC48 says "row selection"
and nothing more. This lane implements SELECTION and does not invent
activation, because the difference is whether a click navigates the
user's document.
Gates: all nine green under `env -u TMPDIR`, log 20260814T113155Z.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Records only. No behaviour change; the focused suite stays 27/27 and
the full gate stays green.
**THE WITHDRAWN CAUSAL CLAIM WAS ASSIGNED TO THE WRONG ROUND.** The
framing credited it to round 4. It was round 3 --- whose subject line
says so plainly, "canonical ancestry, guard witnesses, and a withdrawn
claim". Round 4 was the four discriminators: it added the
canonical-traversal WITNESS (round 3 had fixed the traversal and
verified it by hand, which is the evidence that does not survive the
next edit), moved the guard rows onto the exact boundary, and covered
both managed areas on cleanup.
Rounds 1 and 2 were re-checked against their commits in the same pass,
and round 1 was also wrong: it carried ONE reserve finding, not two.
The nested-reserve ruling is round 2's. Each round is now described
from what its commit actually did.
**AND THE GATE CLAIMED A TMPFS QUOTA THAT WAS NEVER ESTABLISHED.**
`scripts/gate` said a build here "hit tmpfs quota mid-compile". Nothing
measured that, and it contradicts what was measured later: `/tmp` was
at 24G of a 30G tmpfs with 6G free --- NOT out of space. What actually
happened is worth keeping precisely because it is subtler than a quota:
24G of tmpfs is 24G of RAM, free memory fell to ~4G of 61G, process
spawning became unreliable, and eleven rows failed with EMPTY output.
That reads like a code defect. The comment records the measurement and
the symptom now, and drops the quota story.
The independent reason for a disk-backed TMPDIR is unchanged and is
still the measured one: tmpfs fixtures compete for memory.
Gates: all nine green under `env -u TMPDIR`, log 20260813T190456Z.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
CI went red on three Test jobs with the helper's own panic. That is the
loud failure working as designed --- and the design was wrong.
`${#x}` counting characters is a property of the SHELL first and the
locale second. `bash` counts characters under a UTF-8 locale; `dash`
counts bytes under EVERY locale, having no multibyte handling at all.
`/bin/sh` is `bash` on Arch and `dash` on the Ubuntu runners, so
probing `/bin/sh` and naming a locale established nothing there: no
pair could qualify, and the row could not run.
The helper now probes `(shell, locale)` pairs and the row invokes the
gate THROUGH the qualifying shell. `/bin/sh` is tried first so the real
interpreter is used wherever it qualifies. This is not a contrivance to
get green: `#!/bin/sh` resolves to `bash` on Arch and on macOS, which
is precisely where a `${#VAR}` guard would miscount, and it is the
configuration the guard exists for.
Renumbered, because `M-G-8` was taken. Round 3 assigned it to the
canonical-traversal mutation and the ledger never recorded it, so the
locale exercise reusing the ID was a collision. Canonical `M-G-8` is
restored to the ledger; the locale legs are `M-G-9a-c`. Nine total.
9a mutant gate, probed pair -> row fails, boundary row still passes.
Re-run with /bin/sh EXCLUDED, covering the dash/CI fallback
path -> still fails.
9b SAME mutant gate, pair forced byte-counting -> row passes.
The defect reproduced rather than argued.
9c no pair qualifies -> panic naming shells and locales tried
Record corrections review asked for:
- framing said three rounds and revisions 6a-6c; history is rounds 1-4
plus this follow-up, and each round is now named for what it fixed
- framing SS2a claimed `${#var}` counts characters under UTF-8 with no
qualifier --- the same error as the helper's. It now states the shell
dependence and why the guard measures bytes explicitly.
- the helper's prose said every candidate comes from `locale -a` while
the code also tried two hardcoded spellings; the doc comment now
describes what the code does
Gates: all nine green under `env -u TMPDIR`, log 20260813T183646Z.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Review found the byte-versus-character witness asserting something
adjacent to its contract. It set `LC_ALL=C.UTF-8` and assumed the
locale took effect. Locale names beyond `C` and `POSIX` are
implementation-defined, so where that one is absent the shell falls
back to byte semantics --- and then the character-counting mutant
counts bytes too, agrees with the fix, and the row passes while
proving nothing. M-G-6 was killable here and unkillable elsewhere,
which is the same as not having it.
The locale is now chosen by BEHAVIOUR. Candidates come from `locale -a`
so the set reflects what is installed, and each is probed through the
same `/bin/sh` the gate runs under, asking `${#x}` on a two-byte
character and requiring `1`. No qualifying locale is a loud panic
naming what was tried, never a skip: a skip would be indistinguishable
from a pass, which is the failure mode this replaces.
M-G-8 proves the fix in three legs, because the hazard lives in the
environment rather than the code:
8a mutant gate, probed locale -> the row fails, and the
exact-boundary row still passes
8b SAME mutant gate, locale forced to `C` -> the row passes.
The defect reproduced rather than argued.
8c no candidate can qualify -> panic naming the candidates
Also marks framing revision 6 approved and records M-G-8 in the ledger.
Gates: all nine green under `env -u TMPDIR`, log 20260813T182020Z.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Every item here is the same failure in a different place: a property
fixed or claimed, and a suite that would stay green without it.
**CANONICAL ANCESTRY WAS UNWITNESSED.** Round 3 fixed word-splitting
and lexical traversal and verified both BY HAND, which is exactly the
evidence that does not survive the next edit --- reverting to the
`for _anc in $(... dirname ...)` loop left all 26 rows green. There is
now a row driving a **space-containing root** (an unquoted expansion
tears it into fragments and the real ancestor is never tested, so the
guard passes on the path it must reject) and a **symlinked root** (a
marker visible only after `pwd -P`, which is what `detect_project`
sees). `M-G-8` restores the old loop and fails it. The traversal is
encoded in framing §2a rather than left as an implementation detail.
**THE SOCKET GUARD MISSED ITS OWN BOUNDARY.** The rows generated ~51-
and ~71-byte paths against a 55-byte cutoff, so they constrained the
guard's EXISTENCE and not its VALUE: raising the budget from 103 to 118
would have kept both green. They now hit **exactly 55 accepted and 56
rejected**, assert the measured byte lengths, and check that the
refusal reports precisely one byte over.
**REJECTION-CLEANUP CHECKED ONE AREA OF TWO.** Only `<root>/tmp` was
inspected, so leaking AMBIENT alone would have passed --- and AMBIENT
is created before the guard can measure anything, which is the whole
reason the trap moved earlier. Both areas are inspected now, the
ambient one under the derived per-worktree target whose hashed name the
test does not compute.
**THE MULTIBYTE ROW DEPENDED ON THE INHERITED LOCALE.** Under
`LC_ALL=C`, `${#var}` already counts bytes, so the character-counting
mutant passed and the row's verdict was a property of the environment
rather than of the code. It sets `LC_ALL=C.UTF-8` explicitly;
`M-G-6` now fails even when the harness itself runs under `LC_ALL=C`.
Stale test prose corrected, including one claim that was the very
inference the ancestor check exists to refute: **placement under a
managed root does not make a path marker-free**. Also, the tmp parent
is SHARED between worktrees and untouched by `--prune` (unlike the
ambient root, which is per-worktree), and the module header named
framing revision 4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
**THE ANCESTOR WALK WAS WRONG TWICE OVER.** `for _anc in $(...)`
word-splits on IFS, so a gate root containing a SPACE was torn into
fragments and the real ancestor never tested --- the check passed on
exactly the path it should reject. And `dirname` walks LEXICAL
ancestry while `detect_project` canonicalizes, so a symlinked root hid
a marker the editor plainly sees. The walk resolves with `pwd -P` first
and iterates a quoted `while`; both shapes are verified by hand
(space-containing root refused, symlinked root refused at its real
path).
**THE 103-BYTE GUARD HAD NO WITNESS AT ALL** --- every other row runs
with a short root, so the guard is silent and a broken one looked
identical. Three rows now aim at it deliberately: boundary rejection
and acceptance, a MULTIBYTE root (each `é` is one character and two
bytes, so it is rejected only if the guard measures bytes), and
**rejection must reap both created areas**, which is the leak the early
trap exists to prevent.
**The `Cargo.toml`-DIRECTORY case was claimed and not covered**, and
the consequence is exactly as review predicted: reverting only the
language-marker arm to `[ -e ]` stayed green. The marker-type row now
drives all three shapes, and `M-G-5` --- that precise revert --- fails
it.
**Prose brought level with the implementation.** The framing, the
handoff and the ledger all said 108; the supported floor is **103
usable bytes**, Darwin's 104-byte array minus its NUL. The ledger also
still said `<pid>`, the superseded 21/30 reserve, and `M-G-1`.
**And the ruling said nested gates "do not pay" the reserve, which is
false and would have licensed exempting them.** They pay it in full;
the short layout merely gives them the headroom to satisfy an unchanged
production guard. Reworded, because the wrong version is the one a
future reader would act on.
**THE btrfs CAUSAL CLAIM IS WITHDRAWN.** The draft argued that a
one-second deadline plus a slower filesystem was a plausible new
mechanism for the fourth `managed_retry` occurrence. It does not
survive inspection: the deadline bounds the connection RETRY loop, not
the socketpair handshake that returned `BrokenPipe`, and the filesystem
work happens before it is armed --- the tempdir is created and never
bound. The environmental change is still recorded, as a CHANGE rather
than a mechanism, so a later occurrence can compare like with like.
Recording a mechanism the code does not support is worse than
recording none: the next occurrence gets measured against a story
instead of the evidence. TMPDIR stays disk-backed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
**108 IS LINUX'S NUMBER, NOT THE FLOOR.** Darwin's `sun_path` is 104
(xnu `bsd/sys/un.h`) and pmacs supports macOS --- CI runs a
`macos-latest` leg --- so a Linux-derived limit passes on the machine
that writes it and bind-fails on the other. **The usable PATH length is
one less than the array**, because the stored value is NUL-terminated:
103 on Darwin, 107 on Linux. The script takes **103**, and the
diagnostic says which platform's floor it is quoting.
**THE NESTED CASE IS NOW RULED, NOT ACCOMMODATED BY LOOSENING THE
GUARD.** The reserve exists for fixtures that bind sockets under
TMPDIR; this script's own behaviour suite runs nested gates whose plans
are synthetic and bind nothing, so charging them the fixture reserve
rejects a configuration that cannot suffer the failure it guards
against. Exempting nested runs was rejected --- it makes the guard
untestable in the very configuration the tests exercise, and "this run
is nested" is not reliably knowable. **The suite roots its gates at a
short base instead**, so a nested TMPDIR is ~24 bytes rather than ~71
and clears the real reserve. Recorded in revision 6 with the rejected
alternative, and with the obligation that a future row which DOES bind
a socket must move off that base and take the reserve with it.
**MIRRORING THE MARKER NAMES WAS NOT ENOUGH; THE TYPES ARE PART OF THE
CONTRACT.** `match_marker` requires `.git` to be a DIRECTORY and the
seven language markers to be FILES, so `[ -e ]` rejected ancestors
project detection walks straight past. The case is not exotic: **a git
WORKTREE has a `.git` FILE**, so every worktree in this repository
would have tripped the guard. It tests `[ -d ]` for `.git` and `[ -f ]`
for the rest, with a witness covering all three shapes --- `.git` file
accepted, `.git` directory refused, `Cargo.toml` directory accepted.
That witness keys on WHICH marker the gate named rather than on whether
a refusal happened, because the ancestors of any base a test can create
are outside its control; "no refusal" is not a claim it can make
anywhere, while "the refusal did not name MY file" is. `M-G-4` reverts
the guard to existence-only and the row fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
**The gate's own run reproduced a REGISTERED signature**, and it is
recorded as a fourth occurrence rather than waved through: same
selector, same `gpu`-step flavor, all three required fragments verified
against the durable log. Three isolated re-runs were green, which this
file's rule says establishes intermittence only.
**This lane is code-neutral for `pmacs-gpu` but NOT
environment-neutral**, and that distinction is the entry's point.
Occurrence 3 excluded "the added GPU test is the mechanism"; this
occurrence adds nothing to that binary at all, which corroborates the
exclusion independently. But the lane moves `TMPDIR` off `/tmp`, taking
every `tempfile::tempdir()` in the run from **tmpfs to btrfs** --- and
the failing test runs a handshake against a **one-second deadline**. A
slower filesystem under a timing-bounded test is a plausible mechanism
that did not exist in occurrences 1-3. Booking this as "the usual
flake" when the observing lane changed the conditions the flake is
sensitive to is exactly the reasoning this registry exists to prevent.
Also: the suite's own roots move to a short base. Rooting them under
the ambient `TMPDIR` put a NESTED gate's TMPDIR near 70 bytes, which
legitimately tripped its own SUN_LEN guard --- the suite failing on a
configuration it created rather than on the behaviour under test. And
the marker row's `.then(..).unwrap_or_else(..)` chain is gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
**THE PROPAGATION WITNESS DID NOT OBSERVE INHERITANCE.** The runner's
`eval` expanded `$TMPDIR` in the PARENT before `sh -c` ever started, so
the child received an already-substituted literal --- and an unexported
`TMPDIR=` would have passed the row unchanged. Single-quoted inside
`sh -c` now, so the CHILD expands it. **M-G-1b keeps the assignment and
removes only `export`: the row fails.** That is the mutation the
previous version could not catch, and the reason to prefer it over
M-G-1's blunter deletion.
**THE RESERVE WAS NOT THE MAXIMUM.**
`/.tmpXXXXXX/directory-target.sock` is 33 bytes
(`tests/gpu_invocation_acceptance.rs`), so paths of 76-78 passed the
30-byte guard and still blew the 108-byte limit during the CRDT sweep.
Reserve is 48 now --- the measured maximum plus ~45% headroom. And the
length is counted in BYTES: `${#var}` counts CHARACTERS under a UTF-8
locale while `sun_path` is byte-limited, so a multibyte path measured
short and passed a check it should fail.
**A MANAGED ROOT IS NOT INHERENTLY MARKER-FREE**, and assuming it was
rebuilt the original defect one directory up: a `.git` in `$HOME`, a
marker above `$HOME/build`, or a contaminated
`PMACS_GATE_TARGET_ROOT`. Placement under a directory the gate owns is
NECESSARY, NOT SUFFICIENT, and the old test proved only placement. The
gate now walks the ancestors and refuses, naming the marker it found.
`PMACS_GATE_ALLOW_ANCESTOR_MARKER` is the documented test-only escape,
beside `PMACS_GATE_TARGET_ROOT` in kind and risk: the behaviour tests
run under a tempdir whose ancestors they do not control, on a machine
whose `/tmp` carries this very marker, and their plans are synthetic so
no markerless fixture exists to re-root. **The check is witnessed by a
row that deliberately does not set it**, and M-G-3 (check removed)
fails that row.
**The guard leaked what it exists to manage.** It created both
temporary areas and exited before the trap was armed, so every
rejection left an AMBIENT and a TMPDIR behind. The trap is installed
first now; verified by rejecting a run and finding neither.
**`tmp/$$` with `mkdir -p` was not fresh.** PIDs are reused, so after a
SIGKILL it silently ADOPTS a leftover directory and the run inherits
another run's fixtures. `mktemp -d` fails rather than reuses.
**Prose corrected to match.** The handoff described
`<target>/gate-tmp/<stamp>-<pid>`; the implementation uses
`<gate-root>/tmp/<mktemp>`. Comments called the shared parent
per-worktree and pruned --- it is neither: `--prune` only considers
directories carrying an ownership marker, so the parent is skipped and
each run removes its own leaf.
**AND THE LANE CLAIMED A FRAMING EXCEPTION THAT DOES NOT EXIST.**
`AGENTS.md` says framing -> approval -> branch -> implement,
unconditionally; "the fix was already recorded as standing" is not an
exemption it grants. `docs/gate-script-framing.md` is amended as
**revision 6, AWAITING APPROVAL** --- a widening of §2's existing
isolation responsibility rather than a new feature, which is why it
amends that document instead of opening another. **This PR must not
merge before that revision is approved.**
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Discharges the standing fix recorded in `docs/agent-handoff.md` §1 and
assigned to this lane. Every gate invocation now gets a fresh,
disk-backed `TMPDIR` at `<gate-root>/tmp/<pid>`, exported once so every
stage and every process they spawn inherits it, reaped by the same exit
trap as the ambient root. **A gate run no longer needs a `TMPDIR=`
override.**
**A CHILD OF `/tmp` WOULD NOT HAVE WORKED**, which is why the obvious
cheaper fix was not taken. The hazard is an ANCESTOR marker: project
detection walks upward, so a fresh subdirectory of `/tmp` inherits
`/tmp`'s ancestors and the same stray `.git`. The directory had to move
somewhere the gate already owns.
**`SUN_LEN` shaped the layout, and the fix's own gate run is what found
it.** A Unix socket path cannot exceed 108 bytes, and the suites bind
sockets INSIDE `TMPDIR`. The first placement --- `$TARGET/gate-tmp/$STAMP-$$`
--- produced a 114-byte socket path and failed SIX daemon and attach
tests with "path must be shorter than SUN_LEN". It hangs off the gate
root (36 bytes) rather than the per-worktree target (60) now, with a
short name: 47 bytes, leaving 61 for fixtures. Running the real gate
rather than only the witnesses is what caught this.
**A startup guard turns that failure class into a named one.** Six
socket failures deep in a suite name a LIMIT, not a CAUSE; the guard
fails immediately with the path, its length, and what to shorten.
**Its reserve is measured, not round, and the first value was wrong in
the more embarrassing direction.** The longest suffix a fixture appends
is `/.tmpXXXXXXX/test.sock`, 21 bytes, so 30 leaves ~40% headroom. An
earlier "generous" 45 FIRED ON THE GATE'S OWN BEHAVIOUR TESTS: they run
the gate inside the gate, so their root sits under the outer run's
TMPDIR and the nested path reaches 71 bytes. A guard that rejects a
legitimate configuration is worse than the failure it prevents, because
it fires on every run instead of a rare one. Verified both directions:
still catches an 87-byte root, silent on the real one and on the nested
tests.
**Two witnesses, each mutation-checked.** `M-G-1` removes the export ->
the propagation row alone; `M-G-2` stops the reaping -> the cleanup row
alone. Propagation is observed in a SPAWNED CHILD --- the self-test's
first step reports its own `$TMPDIR` into its log --- because asserting
the variable inside the script would only prove the script can set a
variable. The cleanup row runs against the self-test, which FAILS on
purpose, so it also pins that the trap fires on the failure path, which
is the path a leak would actually happen on.
One witness of mine needed correcting twice, both times because it
asserted something adjacent to the contract: first `!starts_with("/tmp/")`,
which tested where the FIXTURE put its root and failed on correct code;
then `contains("/gate-tmp/")`, stale after the directory was shortened.
It now asserts the exact parent, `<root>/tmp`.
**Proved against the live hazard:** `/tmp/.git` is still present on this
machine, and `m4_24_bare_string_glob_stays_relative` --- one of the two
tests it reddened --- passes with no override.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The comment claimed every v6-v23 daemon decodes the variants before
`PanelPointer` on every session. False on both counts: `PanelPointer`
arrived at v21 and is gated, so a wedge before it shifts its
discriminant and breaks v21-v23 PANEL traffic --- not v6-v20, and not
every session.
The pin itself is unchanged and still fires (15 -> 16). What was wrong
was the justification, and an inflated one is not harmless: it sends the
next reader hunting a larger blast radius than exists, and it teaches
the wrong rule about which populations an insertion actually reaches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Sixteen gates green with --protocol, under an isolated TMPDIR.
The crdt-gated wire suite is verified to have RUN --- 2 tests in the
crdt sweep, 0 in the default one --- rather than assumed, because a
suite that compiles to nothing reports ok and that is the shape this
project has been caught by before.
The mutation inventory names which row each one fails, including
M-1a-6: a mutation that targeted the wrong side of the append boundary
and so reported a sound pin as vacuous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
**A1 is an exhaustive loop over all 35 function keys, not spot checks.**
The defect it closes was `_ => return None` swallowing F13 upward, and a
test covering F1-F12 would have passed against exactly that. Each row
also asserts `should_forward_key`, because translating without
forwarding leaves a key mapped and inert --- which reads as a daemon
keymap gap rather than a frontend one.
**A2 asserts both halves**: `BackTab`, and `Shift` still set. A
`BackTab` that lost its modifier is indistinguishable from one the user
did not shift. **A3** likewise pairs the mapping with forwarding.
**A4 establishes idle rather than asserting it.** A fresh `State` starts
with `dispatch_idle` false --- the daemon has not spoken yet --- so the
first version of the row asserted the precondition and failed. Had it
been written the other way round it would have tested the INTERCEPTING
case under an idle name, which is the state where Escape never quit
anyway: the row would have passed while proving nothing about the
behaviour A4 changes. It now sets idle, confirms nothing intercepts, and
asserts both halves: the Escape reaches the daemon AND no exit occurs.
**The frozen-byte pin sits on `PanelPointer`, not on `TextInput`, and
the placement is the point.** `TextInput` is appended, so its own
round-trip is byte-identical whether or not a variant was inserted
beneath it; only the PREVIOUS final variant's bytes move. Every v6-v23
daemon decodes the variants below `PanelPointer` on every session, so an
insertion anywhere earlier is a silent wire break for all of them.
MY FIRST MUTATION OF THAT PIN WAS WRONG AND THE PIN WAS RIGHT. I
inserted the wedge variant before `TextInput` --- which is to say AFTER
`PanelPointer`, exactly where an append belongs --- and the pin passed,
correctly, because nothing shifted. Re-run with the wedge BEFORE
`PanelPointer`, it fails with the discriminant visibly moving 15 -> 16.
Worth recording because a mutation that targets the wrong side of the
boundary reports the pin as vacuous when it is sound.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
**A partial write could have failed correct code.** The contrast broke
out of its poll as soon as `ESC[200~` appeared and then required
`pasted` and `ESC[201~` in the same breath --- but PTY delivery and the
child's writes split anywhere, so the closer may simply not have
arrived. A flake that only ever fires on a loaded machine, and one that
would have been read as an A8 regression.
It now polls for the COMPLETE `ESC[200~pastedESC[201~`, which makes a
partial write indistinguishable from "not yet" --- which is what it is.
Same rule the vterm suite already follows when it waits for `row19`
rather than for a prefix of it.
**The typed-text read above it is sound and is now documented as such**,
because the two loops look alike and are not. Its gate is a LOWER BOUND
ON LENGTH: a split delivery keeps waiting rather than being mistaken for
a wrong answer, and the exact-equality assertion afterwards can still
fail for the real reason. A wait-for-exact-content loop there would have
been tautological. Stating the difference beside the two loops is worth
more than making them superficially uniform.
M-1a-4 re-run after the change: still fails the row, so the fix removed
a race without removing the discrimination.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The last two of the three discriminators. Each is proven by a mutation
that reinstates the defect it exists to catch.
**A8, delivered rather than routed.** The producer row proved a
terminal-focused session reaches the `TextInput` branch; this one
observes the CHILD PROCESS. A real PTY runs `printf '\033[?2004h'; exec
cat > FILE`, so the terminal turns bracketed paste ON and then records
exactly what arrives on its stdin.
**The enabled mode is the entire precondition**, and the row waits for
the child's own mode-set to be parsed before typing rather than assuming
it: with bracketed paste OFF, "no markers" is true of every code path
including a paste, so the assertion would pass against the behaviour it
forbids. The contrast closes it from the other side — through the SAME
terminal in the SAME mode, a paste IS bracketed. One path marked and the
other not, both observed at the PTY.
`M-1a-4` routes typed text through `encode_paste` and the row fails with
the forbidden bytes in hand:
`"\u{1b}[200~héllo\u{301}\u{1b}[201~"`.
**The negotiated-v23 refusal** gets its own suite, because it needs a
live daemon. A refusal is the hardest claim to witness honestly —
"nothing happened" is also what a dead daemon, a desynchronized stream
or a broken test look like — so the row pairs it with a POSITIVE CONTROL
on the same session: after the refused `TextInput`, an ordinary `Key`
that must take effect. Events from one session are processed in order,
so the control's edit arriving with no preceding `REFUSED` edit means
the gate fired rather than that the daemon was asleep.
Its complement runs the same traffic on a v24 session and requires the
edit to land, so the pair cannot be satisfied by `TextInput` being
broken outright.
`M-1a-5` disables the inbound gate and the v23 row fails with `REFUSED`
visible inside the CRDT op — a v23 peer editing a buffer through a
variant its session never declared, which is precisely the hole review
round 1 identified.
One setup lesson, recorded because it cost a red: the A8 row first
failed with an empty file, and the cause was that the frontend's view
was never pointed at the terminal buffer, so `active_terminal_key`
returned `None` and the DOCUMENT path ran. It now asserts that
precondition through public state before typing — a row that quietly
tests the document path and reports a terminal result is worse than one
that fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The first of the three remaining discriminators: **multi-scalar text
reaches the wire as `TextInput` while the daemon is INTERCEPTING** ---
the state a modal prompt or a focused terminal puts the session in, and
the state under which A7 and A8 were unreachable before review round 1.
**Getting there required narrowing the 1-pre exception, which is the
substantive part.** `apply_keyboard` took a `&KeyEvent`; `KeyEvent`
carries a `pub(crate)` field and cannot be constructed outside winit, so
the body was undrivable and only the pure classifier could be tested ---
which is precisely why the defect survived: the classifier was correct
throughout and the CALL SITE was wrong. It now takes the two fields it
actually reads, `&Key` and `Option<&str>`, both ordinary constructible
values.
The exception does not disappear, it shrinks: the router arm still
cannot be handed a `WindowEvent::KeyboardInput`, so what remains
unwitnessed is one pattern arm containing a match and a call. That is
recorded on `apply_keyboard` itself, where the next reader meets it.
**M-1a-3 reinstates the original defect** --- the selection moved back
below the intercept return --- **and fails the new row alone**, 23 of 24
still green. That is the shape the review asked for: a witness that
fails for the reason the defect existed.
Its complement is included so the pair cannot be satisfied by sending
`TextInput` for everything: a SINGLE scalar while intercepting still
travels as `Key`, which is §5 rule 4 preserving mode keymaps and typed
provenance.
The harness gains `feed_keyboard`, and the local-effect diffing it
shares with `feed` is extracted rather than copied --- two entry points
observing different effect sets by accident is the kind of divergence
that makes a transcript lie.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
**"The first frontend→instance extension needing a gate in both
directions" was false**, and checking it took one grep: the v19 terminal
family and the v21 panel family each have an inbound variant AND an
outbound one, each gated. "First inbound-only extension" does not hold
either --- v7's `TripleDown` and v11's `Context` are inbound-only
`PointerKind` variants.
So the superlative is gone rather than weakened. The rustdoc now states
the PROPERTY and its consequence: the gate is producer-side and
receiver-side, and for an inbound variant the receiving half is the
load-bearing one, because withholding would otherwise be the peer's job
and a client built from this same crate can encode the discriminant
whatever it negotiated. The v19/v21 precedent is named so the shape does
not read as novel; what is unusual is only that this extension has no
outbound counterpart, so the receiver check is the whole of the daemon's
half.
**`multi_scalar_text_input_creates_no_typed_provenance` proved half of
what its name claimed.** It started from a fresh editor, where the
command chain is ALREADY empty, so asserting emptiness afterwards passed
whether or not `break_command_chain` ran --- the assertion could not
fail for the reason it existed.
Split in two. The record half keeps its name and its claim; the chain
half is a new row that PRIMES the chain first and then asserts it
cleared. Priming goes through 1a's own single-scalar path, because
`pmacs.command.invoke('buffer.self-insert')` cannot prime it: rotation
belongs to the dispatcher and invoking the command directly deliberately
never rotates --- the first attempt at this row failed on exactly that
precondition, which is a better outcome than passing on a chain that was
never live.
**M-1a-2 deletes `break_command_chain` from the multi-scalar branch and
fails the new row alone**; the other six stay green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
**The P1 was real and my previous fix only did half of it.** Arming and
completing a `TypedEditRecord` are separate steps: `typed_edit_complete`
is called from `insert_char` / `insert_char_over_region` and NOWHERE
else, so routing the single-scalar branch through the generic
`insert_text_input` left the arm holding `None`. `this_command` rotated
correctly and `buffer.after-edit` saw no record --- auto-pairing stayed
broken while the command side looked right, which is the failure mode
that hides longest.
The single-scalar branch now goes through `insert_char_over_region(ch)`,
which handles the no-region case itself by delegating to `insert_char`.
`insert_text_input` is documented as the MULTI-scalar path only, with
the trap named at the definition rather than left for the next caller
to rediscover.
**The witness consumes the record rather than inspecting
`this_command`**, per the review. `single_scalar_text_input_produces_a_consumable_typed_edit_record`
takes it through the same `pmacs.pair._last_record` seam `pair.lua`
uses, and `single_scalar_text_input_auto_pairs_like_a_keypress` states
the same fact in the terms a user would notice: typing `(` must produce
`()`. **Mutation M-1a-1 reverts the fix and both rows fail**; the four
others stay green, so they are discriminating rather than duplicated.
**A ceiling tripwire proved less than it claimed.** The discovery
acceptance looped `6..=23` and then rejected `PROTOCOL_VERSION + 1`, so
a supported set that ENDED at 23 would have passed while
`PROTOCOL_VERSION` was 24 --- the accepted half said nothing about the
version the constant names. It runs to `PROTOCOL_VERSION` now.
**The public protocol history stopped at v23 while both constants
already included 24.** The rustdoc above `PROTOCOL_VERSION` and
`SUPPORTED_PROTOCOL_VERSIONS` now carries the v24 bump, and states the
thing that makes it unlike its predecessors: **it is the first
FRONTEND->INSTANCE extension needing a gate in BOTH directions**,
because the producer withholding is not enough when a peer compiled from
this same crate can encode the variant whatever it negotiated.
Three typed-edit doc sites said the arm is set by "the dispatch fallback
only" or named two producers; there are three now, and the single-scalar
`TextInput` path is one. `typed_edit_arm`'s own doc gains the warning
that arming is only half.
Also 1a's suite: A6 (one commit, one edit, one undo unit), A7 (a prompt
accumulates scalars in order), and A9's boundary row --- a payload
exactly at the cap lands intact, the complement of the rejection that is
enforced where a test can reach it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
**A7 AND A8 WERE UNREACHABLE FROM THE REAL PRODUCER.** The intercept
branch sends a truncated `Key` and returns, and TextInput classification
sat below it --- but a modal prompt or a focused terminal is exactly
what makes `daemon_intercepts_keys` true, so the two contracts about
prompts and terminals were reachable only when neither was present. The
selection moves ABOVE the intercept return, where the producer sends the
same `TextInput` in every state and the daemon applies the modal
precedence, which is where it belongs: the frontend cannot see which
shadow is up.
Ordering against the branches below is safe by construction rather than
by luck --- `text_input_payload` returns `None` whenever a command
modifier is held, so Ctrl-V and command chords can never be shadowed.
**A pure `text_input_payload` test cannot catch this**, which is the
lesson worth keeping: the classifier was right the whole time and the
call site was wrong. The witness has to drive `intercept = true` and a
terminal.
**SINGLE-SCALAR PROVENANCE WAS PROMISED IN A COMMENT AND NOT
IMPLEMENTED.** §5 rules that a single-scalar commit is indistinguishable
from a keypress; the code only broke the chain for multi-scalar and
called a generic insert, so `this_command` went stale and no
`TypedEditRecord` was produced. Auto-pairing (Q#AP9) and every other
typed-edit consumer would have silently stopped recognizing GUI input
--- surfacing as "auto-pair stopped working in the GUI", far from its
cause. Now runs the real machinery: `rotate_command("buffer.self-insert")`
-> `typed_edit_arm(ch)` -> the one edit -> `typed_edit_finish` ->
`typed_edit_set_armed` -> `buffer.after-edit` -> clear, which is the
tail `dispatch_key` already runs.
**THE PRODUCER GATE WAS ONLY HALF THE WIRE CONTRACT.** The daemon
accepted `TextInput` from every installed session, so a peer negotiated
at v6-v23 --- compiled from this same crate, and postcard will happily
write the discriminant --- could mutate a buffer through a variant its
own session never declared. Now gated on the AUTHENTICATED session's
negotiated version.
**A4's structural half is implemented, not just its behaviour.**
`apply_keyboard` returns `()`, so `LifecycleRoute::Exit` is the sole
`EventOutcome::Exit` producer and the obsolete keyboard-exit channel is
gone rather than merely unused. The type survives, as ruled: one
producer is not one variant.
Also: `dispatch_text_input`'s rustdoc claimed a boolean return that its
signature does not have.
VERSION FALLOUT, SORTED RATHER THAN RENUMBERED.
Six deliberate tripwires took the conscious edit they exist to force
(protocol.rs, bottom-panel, discovery x2, statusline, and the vterm one
that was missing from my inventory). Two carried the version in their
NAME, so the name moved with the number rather than being left to lie.
Two ceiling assertions --- `!is_supported_protocol_version(24)` ---
now probe `PROTOCOL_VERSION + 1`, so they keep meaning "the set ends at
the current wire" instead of needing a hand-edit every bump.
`m4_6_handshake_accepts_v6_peer` was GENUINELY DEFECTIVE and is the one
real find: its name and the M4.6 contract say **v6 is the floor**, but
its body asserted `is_supported_protocol_version(PROTOCOL_VERSION)` ---
"the current wire accepts itself", a different and far weaker claim that
would have kept passing after v6 was dropped from the supported set,
which is the only regression it exists to catch. Anchored on literal 6.
The M10 pair needed no edit: they already use `PROTOCOL_VERSION`, and
they failed in the first sweep only because it predated the
`SUPPORTED_PROTOCOL_VERSIONS` fix.
`ADVERTISED_PROTOCOL_VERSION == 20` did not fire, as it must not.
Full `--workspace --no-fail-fast` sweep clean under an isolated TMPDIR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The mechanism, without its witnesses yet; tests follow in the next
commits.
**A1-A3 were mapping gaps, and forwarding was half the fix.**
`translate_key` gained F1-F35, Shift+Tab -> `BackTab`, and
`ContextMenu` -> `Menu`. All three already existed in the protocol
`Key` enum and the TUI already sent them, so this closes a divergence
rather than inventing a convention. **`should_forward_key` had to learn
them too** --- translated but unforwarded, they would have mapped
correctly and still done nothing, which reads as a daemon keymap gap
rather than a frontend one. They forward with ANY modifier, like motion
keys: they are command keys that never insert text, so the
chord-withholding rule has nothing to protect them from.
F-keys are an exhaustive match, not arithmetic off `F1`: winit's
`NamedKey` is `#[non_exhaustive]` and its ordering is not a contract, so
an offset would corrupt silently the day a variant is inserted.
**A4 --- every Escape now reaches the daemon and none exits.** The
`intercept || completion_open` test went with the quit branch: it never
decided what to SEND (both arms sent the same `Escape`), only whether to
send at all, and with one behaviour left there is nothing to choose.
Both flags remain live for the OS-paste, round-trip and
completion-accept paths.
**The v24 wire variant is APPENDED and the reason is postcard.** It
encodes a variant by positional index, so widening any variant above
would re-interpret every older peer's bytes. `TextInput` carries an
untrusted `frontend_id` like its neighbours --- the daemon uses the
authenticated source --- plus the text.
**It is not `Paste`, and the difference is behavioural.** A terminal
receives it as RAW UTF-8, never bracketed (A8): a shell that sees
`ESC[200~` treats input as pasted and changes how it handles newlines
and completion. The clipboard slot is untouched, because nothing was
copied. And the document path is ONE edit (A6) --- one undo unit, one
`buffer.after-edit`, one eligible CRDT op --- which is the entire reason
the variant exists, since a two-scalar grapheme sent as two keypresses
is two undo units that a remote edit can interleave.
**A5's precedence is a pure function** (`text_input_payload`) so the
eight rules are testable without a window. A keypress stays `Key` unless
a rule moves it, and only printable MULTI-scalar moves; the version gate
WITHHOLDS rather than degrades, so a `< 24` daemon keeps exactly the
behaviour it has, truncation included.
**A7's ordering falls out of routing through the existing shadow
handlers** one scalar at a time, rather than reaching into prompt state:
history, completion and acceptance stay in one place.
THE 1-PRE EFFECT HARNESS CAUGHT A REAL DEFECT IN THIS COMMIT. Bumping
`PROTOCOL_VERSION` to 24 while leaving `SUPPORTED_PROTOCOL_VERSIONS` at
`..=23` made the handshake reject its own version. All NINE effect rows
failed while the thirteen routing rows passed --- the M21 signature,
meaning `EffectHarness::new` could not attach at all. A pure-routing
harness would have stayed green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Docs only, and the first commit on the 1a branch. **No ruling changes.**
**1a needs no framing of its own** ---
`docs/gui-stage1-input-framing.md` already governs every Stage 1 slice,
and A1-A9, the eight Q#S1-9 precedence rules, §8's wire contract and
§11's gates are ruled there. What 1a does need is §2 to be TRUE, and it
was measured at `a994f37`, before 1-pre moved almost every GPU-side
coordinate in it. A framing whose ground truth points at the wrong lines
is how an implementation ends up arguing with the tree.
Re-measured at `4f77491`, and split by kind rather than renumbered
wholesale, because the two are not the same claim:
STILL TRUE `FrontendEvent`'s sixteen variants, `PROTOCOL_VERSION =
23`, IME's zero occurrences, the TUI wheel arms (1-pre
touched only `pmacs-gpu`), the handshake preceding the
window, and `Outbox::enqueue`'s closed-return.
MOVED `window_event` `:2734`/655 lines -> `:4450`/FOUR lines;
`translate_key` `:10975` -> `:12053`; "eight arms, rest
fall to `_`" -> three family decision functions over nine
variants. **1a edits `apply_keyboard` and
`translate_key`, not `window_event`.**
**TWO CLAIMS WERE WRONG AT BOTH ANCHORS, so they are corrections rather
than drift.**
**"`KeyEvent.text` is never read" is false, and was false at `a994f37`
too** (`:2800` there, `main.rs:3251` now): the AltGr rule reads it via
`is_layout_text(key.text.as_deref(), pmods)`. The true claim is
narrower and is the one that matters --- **`text` is never read as the
text a keypress INSERTS**, only as a discriminator separating AltGr from
a command chord.
That is load-bearing for A5 rather than cosmetic. §5's rule 2 already
exempts "printable Ctrl+Alt recognized by the existing AltGr rule", so
the precedence table depends on code the section claimed did not exist.
**1a widens `text` from discriminator to payload, and that is the actual
change of kind** --- which "text is never read" hid, along with the one
place the new payload must leave undisturbed.
**A4's exit site moved without its behaviour changing.** 1-pre routed
the idle-Escape quit through `EventOutcome`, so **A4 edits a branch and
a return type in `apply_keyboard` (`main.rs:3219`) and does not touch
`window_event`**, which holds the crate's only executable
`event_loop.exit()` (`main.rs:4452`). And **`EventOutcome` survives
A4**: a native close still returns `Exit`.
The lane records the v24 serialization constraint and the bump's blast
radius up front --- eight version-sensitive failures across six suites
on the last bump, of which CI showed one, because cargo stops at the
first failing target --- together with the rule for sorting them and the
pin that must never fire, `ADVERTISED_PROTOCOL_VERSION == 20`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The lane recorded this PR while it was under review, because an open PR
without a lane is the #171 defect: it drifted 153 commits while
invisible in the ledger. Review is done, so the lane goes.
**It had to go in a commit rather than "at merge".** GitHub merges the
tree as it stands, so leaving it would have landed the words *PR #238
OPEN* on `main` --- a lane advertising an open PR that closed in the
same commit, which is the exact staleness class this absorption exists
to clear.
Rule 4 takes it **outright** rather than rewriting it: a lane is
rewritten when its ARC survives its PR, and this one describes a docs
pass with nothing left behind it. Every reference to #238 and to the
`docs-absorb-237` branch lived inside the lane, so the deletion is
self-contained and leaves no dangling pointer.
What the lane recorded that outlives it is already elsewhere: the
recovery rehearsal and its floor checks are in "Repository authority",
and the GUI arc's state is in the Stage 1 lane and the handoff anchor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
**The absorption was incomplete, and the two holes were Stage 0's.**
`docs/agent-handoff.md` §6 still called Stage 0 "READY FOR PR (not yet
opened)" and the ledger's Stage 0 lane still handed out branch-recovery
instructions and called itself pre-PR --- for work that merged as #236.
A merged lane that still says "checkout the branch" sends a reader to a
tip that no longer moves. Both rewritten; the recovery lines are removed
rather than updated, because there is nothing left to recover.
**A LANE CANNOT REMOVE ITSELF AT MERGE, and saying so was wrong in a way
that would have shipped.** GitHub merges the tree as it stands, so
"removed at merge" would have landed the words *PR #238 OPEN* on `main`
--- a lane advertising an open PR that closed in the same commit. It
stays while the PR is under review, because an open PR without a lane is
the #171 defect, and **a final pre-merge commit on this branch takes it
out**. That obligation is now written into the lane itself.
**Q#GA5 was recorded backwards.** The P2 row said P2 was "gated by the
GUI arc reaching Stage 4b". The ruling is the reverse: **P2 is blocked
by nothing and may start now**; when the arc reaches Stage 4b, P2 must
already have started, and **no later GUI stage begins** until it has an
approved framing and an opened lane. The gate is on STARTING P2, not on
finishing it, and it binds the GUI arc rather than P2. Corrected in both
the P2 row and the GUI row, which had inherited the same inversion.
**`9a26ac8` was the previous last-EXERCISED anchor, not the previous
declared FLOOR** --- that was `e67ad07`, and the two had drifted apart.
Checking the exercised anchor in the floor's place verifies the wrong
claim, so the check is re-run and re-stated against `e67ad07` and
`d038f71`; `9a26ac8` is retained only under its own correct label.
**1-pre did not ADD the idle-Escape local quit.** The docs said "no
behaviour change" and then called the Escape quit a "1-pre change" and
"the one thing 1-pre added" --- which contradict each other and the
diff. It is **pre-existing behaviour that 1-pre preserved and isolated**
behind an `EventOutcome` return; **1a removes it**. Fixed in both files.
**§1a called the board current at `d038f71` while most of it dates from
2026-08-01**, and P5 still described #217 as a held PR although it had
merged. The board is now labelled **MIXED-ANCHOR and explicitly not
current as a whole**, with the drift named as evidence and a pointer to
`git log --first-parent`; the P5 row is corrected. Refreshing the rest
is a separate pass, deliberately not folded into a GUI absorption.
**The exact-head accounting was wrong --- one commit and 34 lines, when
it was two and 38 --- and the fix is to stop counting.** A count goes
stale on the very next commit, including the one carrying the fix, which
is how it broke in the first place. Both the lane and the PR body now
state the standing property instead: every commit after the gated tree
is markdown-only, checkable with `git diff --name-only 502308d..HEAD`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This file's standing rule is 'when a PR is opened, give it a lane', and
the correction behind that rule --- #171 drifted 153 commits while
invisible here --- applies to a docs PR as much as a code one. Written
with the branch's own commit rather than after the fact, which is also
what keeps a lane from re-conflicting on every other merge.
Records the eight-stage gate result, states the docs-only scope as
something verified with `git diff --name-only` rather than intended,
and notes that the lane is REMOVED at merge: it describes a docs pass,
not an arc, so rule 4 takes it outright instead of rewriting it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Docs only. No `src/`, no `pmacs-gpu/`, no 1a implementation.
**The recovery procedure was RE-RUN, not SHA-swapped, and that is what
licenses the rest of this commit.** Every documented step, from an empty
directory, on git 2.55.0: clone the canonical URL (which names it
`origin`, and `git remote get-url githubsucks` errors with `No such
remote`, exactly as the section anticipates); add the alias; fetch;
`git log -1 --oneline githubsucks/main` -> `d038f71`; confirm BOTH the
old floor `9a26ac8` and the new `d038f71` are ancestors, so advancing
the floor is valid rather than merely plausible; recover with the
three-argument `git worktree add <path> -b <local> githubsucks/<branch>`
form and confirm the upstream is actually set. The documented TRAP was
reproduced too: the two-argument form succeeds and leaves
`## HEAD (no branch)`, so that correction still holds on 2.55.0.
**This discharges a debt rather than inheriting one.** The previous
absorption advanced the base to `e67ad07` WITHOUT re-running the
procedure and recorded that as an outstanding obligation. It is settled
here, by the run above.
Canonical base, recovery floor, the `git log` threshold and the
last-exercised record all move to `d038f71` together --- a floor that
accepts an older commit than the base it declares passes on a tree the
rest of the file does not describe.
**The GUI lane is REWRITTEN, not removed.** Rule 4 removes a lane when
its ARC is done; the arc is Stage 1 as a whole and **five slices
remain**. The lane now records 1-pre as merged, names 1a as next and
NOT STARTED, and keeps the fact that 1a (v24 `TextInput`) and 1e (v25
`OpenTarget`) are the two protocol-bearing slices and are serialized.
Open-PR and branch-recovery wording for 1-pre is gone --- a lane that
still says "recover with `git checkout`" for a merged branch is how a
reader ends up on a stale tip.
The handoff anchor absorbs both merges, and §1 gains the arc bullet the
anchor promises. Its durable facts are the ones that will bind future
slices rather than the diff: a route carries the DECISION and only the
body gives the EFFECT; a harness that re-implements what it tests
witnesses its own copy; winit's `KeyEvent` is non-constructible outside
winit and this will bind every GUI input slice; the sentinel is the
success condition and the timeout only an error ceiling; four test
defects that were all one shape --- witnessing an absence the harness
had manufactured; and `cargo metadata`, not intuition, decides which CI
job runs a member's unit tests.
**One judgement beyond the stated scope, flagged rather than slipped
in.** §1a's board had no GUI-arc row at all, which would have
contradicted the anchor two sections above it. A row is added at the
position `COHERENCE.md` §20 Q#GA5 gives it --- after Priority 1, running
alongside P2-P5, interacting only at the P2 start gate --- and the P2
row now names that gate. The section's own dateline is corrected in the
same breath: the table was written 2026-08-01 and claimed zero open PRs,
so it now says which row is current and which are as-of that date.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
M24 makes the total 24 mutations, not 23. Stated as M1-M24 with the
split named --- twenty-three fail their own rows, and M6 is the P3
exception check, which must STAY GREEN. Writing it as a bare total was
what let the count drift twice: an exception check and a failing
mutation are both 'mutations' in the ledger's usual sense, so the
sentence has to say which is which.
'Gate passes on the final tree' contradicted the very next sentence,
which records that prose and doc comments changed after the run. It now
says final EXECUTABLE tree and explains why the distinction is drawn ---
'the gate was green' and 'the gate was green on exactly this tree' are
different claims, and only the weaker one is true here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Two review findings, one of them a real defect.
**THE SENTINEL READ COULD HANG FOREVER.** `read_until_sentinel` blocked
with no bound, so a writer or encoder that regressed after `enqueue`
would WEDGE THE GATE rather than redden it --- and a hang is the worst
failure shape there is, because it looks like slowness until the job is
killed. A 30 s `READ_CEILING` is armed on the daemon socket.
The distinction is kept explicit in the code, because collapsing it is
how this fix would undo the design it protects: **the sentinel remains
the success condition and the ceiling is only an error ceiling.**
Arrival is still decided by the sentinel, so the harness never infers
"nothing was sent" from a duration --- the core-count assumption behind
PR #235's CI red is not reintroduced. The ceiling sits far above any
plausible drain, so reaching it means broken, never busy.
M24 proves it fires rather than trusting it: drop the sentinel enqueue
entirely and the row fails in under a second with a diagnostic naming
both candidate causes and the partial transcript, instead of hanging.
**THE STAGE 1a CONSEQUENCE WAS WRONG IN FOUR PLACES.** Every record
claimed A4 would leave `EventOutcome` with one variant, so the type
should go with the Escape branch. It will not, and it should not.
`LifecycleRoute::Exit` --- a native window close --- returns
`EventOutcome::Exit` too. A4 removes the KEYBOARD producer only, leaving
one `Exit` producer.
And **one producer is not one variant**: the type survives because
`dispatch_window_event` must still distinguish `Continue` from `Exit` on
every event it handles --- nearly all must not exit, and the close must.
What A4 actually changes is `apply_keyboard`'s signature. Corrected in
the `EventOutcome` doc, the Escape-branch comment, the framing and the
ledger; the framing's superseded paragraph is deleted rather than
patched, since it also carried the stale "two `event_loop.exit()`
call sites" count. **There is exactly one executable
`event_loop.exit()`**, in `window_event`.
Also: the sentinel-tag comment claimed four modifier bits and used
three. It now says three, wrapping every eight steps, and why that
suffices --- each sentinel is read before the next is issued, so a tag
only has to differ from its immediate predecessor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Checked with `cargo metadata`, not assumed: `workspace_default_members`
is the root `pmacs` package alone, so the `test` matrix and
`crdt-test` --- both bare `cargo test --all-targets` --- never compile
pmacs-gpu's unit tests. Only `gpu-render` runs them, and it installs
lavapipe, proves the adapter with vulkaninfo, and sets
PMACS_REQUIRE_GPU=1.
That matters because the harness's adapter assert is UNCONDITIONAL
rather than env-gated: it cannot be turned into a quiet ok anywhere, and
a future job that runs -p pmacs-gpu without an adapter fails loudly
instead of silently covering nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Revision 10 argued a route-classification transcript satisfied P2
because a route 'names its local effect'. The wheel falsifies it: a
wheel route carries a delta, and whether that becomes a viewport
update, a panel event, a terminal event or nothing at all depends on
State. The route names the family; only running the body names the
effect. Retracted rather than quietly amended, since it was an argument
this document made and review overturned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Review round 1's blocker: P2 requires outbound events plus local
effects, and the harness recorded `Route` classifications only. The
wheel is the proof it was not enough --- a wheel route carries a delta,
and whether that becomes a viewport update, a panel event, a terminal
event or nothing at all is `State`'s to decide. A route names the
family; only running the body names the effect.
**`App::dispatch_window_event` is what makes P2 reachable, and it is the
substantive change here.** Left inside `window_event`, the dispatch
would force a harness to re-implement it --- and a harness that
re-implements the thing it tests witnesses its own copy. `window_event`
is now four lines: call dispatch, exit if it asks. **P3 narrows from a
33-line match to a single `if`.**
`EffectHarness` drives production code end to end:
* a REAL `AttachClient` over a `socketpair`, through the real
handshake, outbox, writer thread and encoder, so what is recorded is
the wire and not a mock's idea of it (`connect_stream_for_test` in
attach.rs exists only because the constructor is private to that
module; it adds no behaviour);
* a REAL windowless `State`, so the bodies take their real branches;
* `dispatch_window_event` itself.
Local effects have no wire trace, so each is read where it lands: exit
from the returned `EventOutcome`, redraw from a test-only
`State::render_calls`, resize from the surface config, the modifier
mutation from `App::modifiers`, and the scroll from `scroll_top`.
**Steps are delimited by a sentinel key, not a sleep.** "This step sent
nothing" is otherwise undecidable without waiting, and a fixed-duration
wait against a writer thread is the core-count assumption PR #235's CI
red was made of. The sentinel is not coalesceable (only viewport and
drag kinds are), so it can neither replace nor be replaced by a recorded
event. It does sit between steps, so cross-step coalescing that
production would perform is absent here --- stated in the harness doc,
since it makes the transcript per-step rather than as-coalesced.
**Never skips.** Per the ruling, a missing wgpu adapter is an assertion
failure and not a skip: this project has twice recorded a suite that
returned `ok` without running. Mutation M21 makes `new_headless` return
`None` and all NINE effect rows fail loudly while the thirteen pure
routing rows, which need no GPU, stay green --- the two tiers behaving
exactly as intended.
TWO ROWS WERE WRONG AND THE MUTATIONS FOUND THEM, WHICH IS THE POINT:
* the wheel row asserted `.all(|e| matches!(e, Viewport))` over the
transcript --- VACUOUSLY TRUE ON AN EMPTY ONE, so an outbound-blind
harness passed it. Now asserts non-empty first.
* with that fixed it still failed, for a second reason: the fixture
was two lines and could not scroll, and a headless `State` has no
attached buffer, so `scroll_by_lines` returned `None` and withheld
every send. Both are absences the harness manufactured itself ---
the same shape as the panel wire, below.
The panel wire is the third of those. `resumed` sets the frontend id and
the session version on the state before any geometry flush; the harness
did not, so `flush_panel_geometry` silently withheld the declaration and
the resize row failed against an absence of its own making. The harness
now mirrors that wiring and drains the attach-time declaration, so each
row's transcript holds only what its own event produced.
Evidence --- 22 rows (13 routing, 9 effect), 6 further mutations:
M18 exit effect discarded -> the close row
M19 redraw effect discarded -> the redraw row
M20 apply_resize stops declaring -> the resize row
M21 no wgpu adapter -> all NINE effect rows, loudly
M22 harness blind to OUTBOUND -> resize + wheel
M23 harness blind to LOCAL -> six rows
M22 and M23 together are P2's contract made executable: blind the
harness to either half and rows fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
None changes a decision; all four were claims a reader would check and
find false.
**The durable diagnosis command did not run the pair it claimed.**
`m4_24_` is a PREFIX matching **18** tests, so the recorded invocation
would report roughly 16/2 contaminated and 18/0 clean --- not the 0/2
and 2/2 beside it. A reader following it would see a mostly-green run
and conclude the hazard was mis-diagnosed. Replaced with **four literal
`--exact` invocations, one test each**, every one of them executed
before being written down: `running 1 test`, `171 filtered out`,
contaminated `0 passed; 1 failed` panicking at `:5668:5` and `:6615:5`,
clean `1 passed; 0 failed` with no panic. The block now also says to
read the `running N tests` line, pointing at the libtest-filter bullet
two entries below --- which is the trap that produced this defect in the
first place.
**"The diff touches only `pmacs-gpu/src/main.rs`" -> "the whole
EXECUTABLE diff".** The branch changes six files, five under `docs/`.
The structural argument was always about linkage, not file count, but as
written it was simply false and the first `git diff --name-only` would
say so. Fixed in both the ledger and the handoff.
**"a headless test can drive every family"** contradicted the keyboard
exception three paragraphs below it. Now says every family whose event
winit lets a test construct --- all of them except keyboard --- and
points at `route_keyboard` for how far that reaches.
**`[KeyboardRoute::Press]` names a type that does not exist.** It was
renamed to `KeyAction` when the payload moved onto `Route::Keyboard`,
and this doc link was left behind pointing at nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
An open PR gets its number in the ledger at the moment it opens, per the
standing correction from #171 and #215 --- #171 drifted 153 commits while
invisible here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The `/tmp/.git` diagnosis, finished properly.
**Compared on SIGNATURE, not test name**, which is what the ledger's own
CI-triage rule requires. The contaminated leg panics at
`m4_acceptance.rs:5668:5` and `:6615:5` with `.received = ""` ---
byte-identical to the gate red's own signature --- and the clean leg has
ZERO panics. Same binary, same commit, one variable.
**All nine gates pass under an isolated `TMPDIR`**: m4 168/0/3 by the
mandated command, and the 117-target `--workspace --no-fail-fast` sweep
with zero failures anywhere, `m4_acceptance` running all 171 inside it.
**The marker is left in place.** It is foreign, isolating is sufficient,
and deleting it is not this lane's call. Recorded with the constraint
that matters: the isolated `TMPDIR` must be outside `/tmp` AND outside
every git worktree, because a child of `/tmp` is not isolated ---
`/tmp/.git` is still its ancestor.
**One hazard this cost a round, worth the bullet it gets.** A libtest
filter that matches nothing prints `0 passed; 0 failed; N filtered out`
and exits ZERO. This shell is zsh, which does not word-split unquoted
parameter expansions, so `NAMES="a b"; cargo test -- $NAMES` passes one
argument, matches no test, and reports green. The contaminated leg
"passed" that way and briefly looked like the hypothesis collapsing.
The fix is to read the `running N tests` line, which is the only place
a filter's actual reach is stated --- the same family as the
`feature-census` trap already recorded two bullets above, and the same
lesson as "a probe must fail loudly".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Three documents, one finding each.
**`docs/gui-stage1-input-framing.md` -> revision 10.** Revision 9 is the
approved design and revision 10 changes none of it. It records ONE scope
correction that could not be seen from the design: P1 has a second
structural exception, for the keyboard family alone, and it is winit's
rather than this seam's. `KeyEvent` carries a `pub(crate)
platform_specific` field, so no `WindowEvent::KeyboardInput` can be
constructed outside winit. Bounded three ways rather than accepted
whole --- it does not reach the pointer families (`DeviceId::dummy()`
exists for exactly this, checked BEFORE writing the exception down), the
family's only decision is factored into `route_key_action` and witnessed
directly, and what stays uncovered is one pattern arm with no logic.
Also records that P3 is now MEASURED: deleting the whole delegation
leaves all 256 `pmacs-gpu` tests green, not merely the 13 new rows.
**`docs/active-work.md`** --- the lane moves to IMPLEMENTED with the
four commits, the shape, the verbatim-move method, and the gate result.
**`docs/agent-handoff.md`** --- the stray-marker hazard gains what this
run earned: `scripts/gate` DOES NOT ISOLATE `TMPDIR`. It isolates the
target directory and five ambient roots, so `tempfile::tempdir()` still
lands under whatever `/tmp` happens to contain, and the hazard therefore
reproduces INSIDE a gate run --- which is how it surfaced here, on a
lane that touches only `pmacs-gpu/src/main.rs`. The bullet now carries
the discriminating command pair (`TMPDIR=/tmp` 0/2 versus a marker-free
root 2/2) rather than only the narrative, because a rerun establishes
nothing about this and the pair establishes everything. Isolating
`TMPDIR` is assigned to the gate lane, not to whichever feature PR trips
over it next.
One code change rides along: `EventOutcome`'s doc comment said
`event_loop.exit()` is called in "exactly one place", which is true of
the function and false of the call sites --- there are two, both inside
`window_event`. Stated precisely now, since the whole point of the
sentence is that a reader can check it by grep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
The last four arms --- `CursorMoved`, `MouseInput` twice, `MouseWheel`
--- move to `apply_cursor_moved` / `apply_left_button` /
`apply_right_press` / `apply_wheel`. `window_event` is now 33 lines
against 655: one `route_event` call and one arm per route. The seam is
complete.
THE BUTTON DISCRIMINATION WAS THE FIND. It lived in the shape and order
of two overlapping `MouseInput` arms --- left in either state, right in
the pressed state only --- with everything else falling through a
wildcard several hundred lines below. The asymmetry is real and
deliberate (a context menu opens on the press; its release means
nothing), but it was an artefact of arm order rather than a stated
decision. `PointerRoute` names all four cases and both witnesses and
mutations now bear on them.
`UnusedButton` follows the keyboard family's `Release`: a middle /
back / forward / other button, and a right-button release, are CLAIMED
BY THE POINTER FAMILY AND DROPPED rather than left unrouted. Same
behaviour as the wildcard they used to reach, and Stage 1b's B4
(middle-click PRIMARY paste on Linux) lands on exactly this route.
The wheel delta is carried RAW. Converting it to lines needs the code
line height, which is `State`'s to know, so the router must not try ---
and the witness drives both `LineDelta` and `PixelDelta` to pin that.
All four bodies verified as the original arm bodies rustfmt-normalised,
by re-running rustfmt on the pre-move text at the new indent level and
diffing. `apply_cursor_moved` additionally renames `position.x`/`.y` to
`x`/`y`, 6 and 9 occurrences, counted.
Evidence --- 13 rows, 6 further mutations:
M12 right button claimed in both states -> the right-button row
M13 left button claimed only on press -> the left-button row
M14 CursorMoved axes swapped -> the cursor row (+ transcript)
M15 unused button falls through -> unused + right rows (+ transcript)
M16 harness records outbound only -> the transcript row ALONE
M17 wheel delta zeroed -> the wheel row ALONE
P3 RE-DEMONSTRATED AGAINST THE FINAL SHAPE, AND AGAINST THE WHOLE
SUITE. Replacing `window_event`'s entire body with `let _ =
(event_loop, event);` --- a GUI that responds to no input at all ---
leaves ALL 256 `pmacs-gpu` tests green, not merely the 13 routing rows.
That is the accepted structural exception measured rather than
asserted: no headless test anywhere in this crate observes the
delegation, because `ActiveEventLoop` cannot exist outside a live event
loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai