docs(framing): the panel cell-mapping generation (v25) --- SS5b revision 14

Own branch, own slice, protocol-bearing, runs alone. Framing only; no
implementation. Blocks `panel-pointer-replay`, which blocks GUI arc 1b.

Answers review of revision 13. Every item below reverses or completes
something 13 got wrong.

**GATING IS REFUSAL, NOT FALLBACK.** Revision 13 said a bare
`PanelPointer` from a new peer would be "handled under the old
semantics". That is a BYPASS: it leaves the exact hole this slice
exists to close, reachable by omitting a field. A >= v25 session
sending the legacy event is REFUSED before mutation, and a >= v25
frontend REJECTS a legacy `Present` rather than painting a band it
cannot safely hit-test. Only negotiated <= v24 keeps legacy semantics;
`Absent` stays common to both families.

**ONE AUTHORITATIVE PER-FRONTEND KEY**, used by projection AND inbound
validation, advanced after any mapping mutation and BEFORE the next
inbound pointer is handled --- whether or not anything has rendered.
Comparing against the last EMITTED frame recreates the hole, because a
mutation not yet painted has still changed the inverse mapping.

**STALE TAILS TERMINATE; THEY DO NOT VANISH.** A blanket drop breaks
liveness: a refused `Up` leaves an empty document selection armed with
a stale anchor, and leaves a reporting terminal child HOLDING A BUTTON
FOREVER. Cancellation is now a ruled outcome --- producer latch reset,
daemon selection and click-chain cleanup, and the child's release
delivered at the last coordinate known good. A cancelled gesture is
explicitly not a replayed one: the release is for liveness, and no
selection or scroll effect is applied from the stale event. Stale
BEGINNINGS may still simply drop.

**THE DOMAIN WAS INCOMPLETE.** `view_left` is added, because 1b makes
horizontal scrolling real. "Cursor movement is stable" is now
CONDITIONAL: a cursor move that triggers vertical or horizontal follow
changes `view_top` or `view_left` and therefore does change the
mapping. Terminal panels are ruled explicitly --- their coordinates are
decided by the SCREEN, so output and scrollback movement change the
generation while their buffer revision does not.

**SS5b HAD NO ACCEPTANCE MATRIX.** G1-G11 now cover the foreign edit
before render, every changing and stable domain entry ROW BY ROW, a
selection repaint that must preserve the generation and let a drag
continue, mid-gesture cancellation, v24 and v25 positive controls with
both wrong-family refusals, identical cells across a generation change
still emitting, atomic retention of frame and generation on an invalid
frame, and fail-closed exhaustion. The per-entry enumeration is
deliberate: one aggregate row cannot show WHICH input moved the key,
and a key ignoring `view_left` passes every vertical-only row.

**MAPPED MOTION KEEPS ITS COALESCING TAGS.** A new variant falling
through to the lossless default would put pixel-rate `Move`/`Drag` on a
bounded queue.

**PINS ACCUMULATE.** Revision 13 said the pin "moves", which would
delete coverage of the shape it protects. `PanelPointer` is retained;
exact `TextInput` bytes are added as the previous-final
`FrontendEvent`; the complete nested `PanelFrame(Absent)` bytes are
added as the previous-final `PanelFramePayload`. Recorded honestly: I
could find NO exact-bytes pin for `PanelPointer` anywhere in the tree,
though `pmacs-protocol/src/message.rs:524` says one is in the tests.
Either my search missed it or the doc overclaims; this slice resolves
it either way, since it must add exact pins regardless.

**AND THIS SLICE OWNS THE VERSION CORRECTION.**
`docs/gui-stage1-input-framing.md` now says 1e's `OpenTarget` is
**v26**, with the reason stated at the top. An expected rebase conflict
on `gui-stage1b-pointer-scroll` is not grounds for leaving the
canonical document false --- which is what I argued last round, and it
was wrong. `ADVERTISED_PROTOCOL_VERSION` stays pinned at 20.

Gates: all ELEVEN green under `env -u TMPDIR`, with `--protocol`
(`build-crdt`, `sweep-crdt`), log 20260814T162843Z.

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

View File

@ -270,6 +270,65 @@ hazard in a shape that looks committed. **A documented error message
that never appears is worse than no documentation**, because the reader
waits for a signal that is not coming.
## Panel cell-mapping generation (v25) — ACTIVE, framing only
**Written with the branch's FIRST commit**, per the standing correction
from #171 and #215.
- **Branch `panel-mapping-generation`**, base `githubsucks/main` @
**`72da24a`**, worktree
`/home/jeans/Repos/personal/pmacs-mapping-gen`.
**`githubsucks/panel-mapping-generation` is the authoritative tip.**
Recover with `git fetch githubsucks && git checkout
panel-mapping-generation`.
- **No PR yet. Checkpoint: framing revision 14 (§5b) AWAITING
APPROVAL; NO IMPLEMENTATION.**
- **PROTOCOL-BEARING — v25, and it runs alone.**
`ADVERTISED_PROTOCOL_VERSION` stays pinned at **20**.
- **Why it exists.** A `PanelPointer` names a cell and nothing on the
wire says which inverse mapping the frontend saw, so the daemon
inverts against whatever is current. `buffer_id` catches
replacement, `panel_epoch` close/reopen, `geometry_epoch` a
declaration race — **nothing catches "the text under that cell
changed"**. Q#BP-R3 first accepted this as narrow; that was
**overruled**, because a **foreign** edit moves the mapping with
`view_top` untouched, the error is unbounded, and the window lasts
until the frontend **presents** the new frame.
- **A generation, not a per-frame token.** A token moving with the
frame would cancel a drag on the next repaint — the mistake
`panel_epoch` is stable to avoid. The key moves with the inverse
mapping (`view_top`, **`view_left`**, grid size, folds, wrap/gutter
geometry, buffer content, terminal output/scrollback) and holds
across focus, styling, selection-only repaints and cursor motion
**that the follow rules absorb**.
- **One authoritative per-frontend key**, used by projection AND
inbound validation, advanced after any mapping mutation and
**before** the next inbound pointer — comparing against the last
emitted frame recreates the hole.
- **Gating is REFUSAL, not fallback.** A ≥ v25 session sending bare
`PanelPointer` is refused; a ≥ v25 frontend rejects legacy
`Present`. Only ≤ v24 sessions keep legacy semantics; `Absent` is
common.
- **Stale tails TERMINATE, they do not vanish.** A dropped `Up` leaves
an empty selection armed and a reporting child holding a button
forever, so cancellation is a ruled outcome: producer latch reset,
daemon selection/click-chain cleanup, and the child's release
delivered.
- **Pins ACCUMULATE**: keep `PanelPointer`, add exact `TextInput`
bytes (previous-final `FrontendEvent`) and the complete nested
`PanelFrame(Absent)` bytes (previous-final `PanelFramePayload`).
**No exact-bytes pin for `PanelPointer` could be found in the tree**
despite `pmacs-protocol/src/message.rs:524` claiming one; this slice
resolves that either way.
- **Owns the version correction.** `docs/gui-stage1-input-framing.md`
moves 1e's `OpenTarget` to **v26** here — an expected rebase
conflict on `gui-stage1b-pointer-scroll` is not grounds for leaving
the canonical document false.
- **Chain: this slice → `panel-pointer-replay` (rebases onto it) →
GUI arc 1b.**
- **Gates:** the four `bottom_panel_*` suites, the GUI 1a wire suite,
`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`, and **`--protocol`**.
## GPU launcher / probe SIGINT teardown — MERGED as #241 (`f8033bc`)
- **MERGED 2026-08-20** at approved head `5089715`, merge commit
@ -722,6 +781,7 @@ from #171 and #215.
bite, direct-test diagnosis, unaffected foreground success, mutation,
an otherwise unchanged gate, a distinct `error` outcome, and a
non-Linux-unix statement.
||||||| parent of 8a4711e (docs(framing): the panel cell-mapping generation (v25) --- SS5b revision 14)
## `scripts/gate` TMPDIR isolation — PR #240 OPEN

View File

@ -1770,6 +1770,196 @@ cannot preserve the old panel-focused attach leak.
preserving the Stage 1 unknown-value rollback assertions; the Stage 3 PR
then runs the full gate suite.
## 5b. The cell-mapping generation — a protocol slice (Q#BP-R3)
**Status: revision 14 — AWAITING APPROVAL. Nothing implemented.** This
slice **blocks** panel-pointer replay (`panel-pointer-replay`), which
blocks GUI arc 1b. It is protocol-bearing and runs alone.
### What it fixes
A `PanelPointer` names a **cell**; the daemon must invert that to a
**byte**. Nothing on the wire says which inverse mapping the frontend
was looking at, so the daemon inverts against whatever is current — and
the mapping can move for reasons the clicking frontend neither caused
nor can observe.
This is the one hole in the ladder: `buffer_id` catches replacement,
`panel_epoch` catches close/reopen, `geometry_epoch` catches a
declaration race, and **nothing catches "the text under that cell
changed"**.
**Revision 12 accepted this as narrow. It is not.** A **foreign** edit
moves the mapping with `view_top` untouched; ticks, paging, folds,
edits and reloads accumulate without bound; and the window lasts until
the frontend **presents** the replacement frame, which a backed-up
frontend widens arbitrarily.
### The generation, and why not a token
**A per-frame token is the wrong object.** Panels repaint constantly,
and a token that moved with the frame would invalidate a live drag on
the next repaint — the mistake `panel_epoch` is stable to avoid.
**`mapping_generation` identifies the INVERSE MAPPING.**
| changes it | leaves it alone |
|---|---|
| `view_top` | focus gained or lost |
| **`view_left`** — 1b makes horizontal scrolling real | styling, theme, face changes |
| panel grid size | selection-only changes |
| fold state | a re-emitted identical frame |
| wrap mode, gutter geometry | **cursor motion that moves nothing else** |
| **buffer content — any edit, from any source** | |
| **terminal output or scrollback movement** (terminal panels) | **terminal buffer revision** (see below) |
**"Cursor movement is stable" is CONDITIONAL, and revision 13 stated it
flatly.** A cursor move that triggers vertical or horizontal follow
changes `view_top` or `view_left`, and therefore **does** change the
generation. The stable case is a cursor move the follow rules absorb.
**Terminal panels are ruled explicitly.** What a coordinate denotes
there is decided by the terminal's **screen**, so output and scrollback
movement change the generation. Their **buffer revision** does not — a
terminal buffer's revision counter tracks something else, and keying on
it would both miss real changes and fire on non-changes.
**The stability half is load-bearing, not an optimisation.** A drag
provokes selection repaints on every motion; a generation that moved
with them would cancel the drag after one step.
### Ownership and refresh timing
**One authoritative per-frontend mapping key**, owned by the daemon,
**used by both projection and inbound validation**. Not two derivations
that agree by inspection.
- It **advances after any mapping mutation and BEFORE the next inbound
pointer is handled**, whether or not a frame has been rendered or
emitted since.
- **Comparing against the last EMITTED frame recreates the hole.** A
mutation that has not yet been painted still changes the inverse
mapping, and a gesture arriving in that gap must be refused.
- Projection stamps the frame with the same key it validates against,
so "what the frontend was shown" and "what the daemon checks" cannot
drift.
### Wire shape — appended variants, never widened
Postcard encodes enums **positionally**, so a shipped variant's field
list is frozen. Both messages gain **new variants**:
- `PanelFramePayload::PresentMapped { .. }` beside `Present`.
- `FrontendEvent::PanelPointerMapped { .. }` beside `PanelPointer`.
`Absent` is unchanged and **common to both families** — hiding a band
carries no mapping.
### Bilateral gating — REFUSAL, not fallback
Revision 13 said an unmapped event from a new peer is "handled under
the old semantics". **That is a bypass**: it leaves the exact hole the
slice exists to close, reachable by omitting a field.
| negotiated | daemon sends | daemon accepts | frontend sends | frontend accepts |
|---|---|---|---|---|
| **≤ v24** | `Present` | `PanelPointer` | `PanelPointer` | `Present` |
| **≥ v25** | `PresentMapped` | `PanelPointerMapped` **only** | `PanelPointerMapped` | `PresentMapped` **only** |
- A **≥ v25 session sending bare `PanelPointer` is REFUSED**, dropped
before any mutation, exactly as an out-of-epoch event is.
- A **≥ v25 frontend receiving legacy `Present` REJECTS it** rather
than painting a band it cannot safely hit-test.
- Only a negotiated **≤ v24** session retains legacy semantics.
- `Absent` is accepted from either family.
### Enforcement, and the liveness it must not break
On `PanelPointerMapped`, the daemon compares the echoed generation with
the authoritative key and refuses the gesture before any mutation when
they differ.
**But a blanket drop breaks liveness, and revision 13's did.** A
refused **tail** is not the same as a refused **beginning**:
- **Stale BEGINNINGS may simply drop.** A `Down` that never took effect
leaves nothing behind.
- **Stale TAILS must TERMINATE the gesture, not vanish.** A dropped
`Up` leaves an empty document selection armed with a stale anchor,
and leaves a **reporting terminal child holding a button forever**.
**Cancellation is therefore ruled as a first-class outcome:**
1. **Producer:** the frontend resets its gesture latch —
`pointer_held`, `last_pointer_cell`, `gesture_last_content_cell` —
on the same signal, so no further `Drag` is manufactured.
2. **Daemon, document:** the panel's selection is cleared if empty, the
click chain is cleared, and no cursor move is applied.
3. **Daemon, terminal:** the child receives its **release** — a
reporting child must not be left holding a button because the
mapping moved — and the controller claim is settled.
**A cancelled gesture is not a replayed one**: the release is delivered
for liveness, at the last coordinate known to be valid, and no
selection or scroll effect is applied from the stale event.
### Motion must stay coalesced
`PanelPointerMapped` carrying `Move` or `Drag` **takes the same
tail-coalescing tags as `PanelPointer`** (`pmacs-gpu/src/attach.rs:374`
onward). A new variant that fell through to the lossless default would
put pixel-rate motion on a bounded queue — the failure the coalescing
tags exist to prevent. `Down`/`Up`/wheel/context stay lossless and
ordered, as today.
### Pins ACCUMULATE
**They are not moved.** Revision 13 said the pin "moves to the previous
final variant", which would delete coverage of the shape it was
protecting.
| pin | covers |
|---|---|
| existing `FrontendEvent::PanelPointer` | retained, unchanged |
| **new**: exact `FrontendEvent::TextInput` bytes | the previous-final `FrontendEvent`, appended by 1a and never pinned |
| **new**: complete nested bytes of `InstanceMessage::PanelFrame(PanelFramePayload::Absent)` | the previous-final `PanelFramePayload` variant |
**A note on what exists today.** The panel protocol suite round-trips
these shapes and asserts `Absent` differs from an empty `Present`
(`tests/bottom_panel_stage2b_protocol_acceptance.rs:196`), but I could
find **no exact-bytes pin** for `PanelPointer`, despite
`pmacs-protocol/src/message.rs:524` stating one is in the tests. Either
it is somewhere my search missed, or the doc overclaims — **this slice
resolves it either way**, since it must add exact-byte pins regardless.
### Acceptance and mutation matrix
| # | row | mutation |
|---|---|---|
| G1 | a **foreign** edit before the next render → the old generation is refused | never advance on content change → G1 passes a stale hit |
| G2 | every **changing** entry of the domain table moves the generation, one row each | omit that entry from the key |
| G3 | every **stable** entry leaves it unchanged, one row each | include that entry → drags cancel on repaint |
| G4 | a **selection repaint** preserves the generation and an in-flight drag **continues** | as G3 |
| G5 | a mid-gesture mapping change **cancels**: latch reset, empty selection cleared, click chain cleared, **child receives its release** | drop the stale tail silently → the child holds the button and the selection stays armed |
| G6 | **v24 positive control** — a legacy session drives a panel gesture end to end | gate ≤ v24 off → old peers lose the panel |
| G7 | **v25 positive control** — a mapped session drives one end to end | — |
| G8 | **wrong-family refusals, both directions** — bare `PanelPointer` from a v25 session is refused; legacy `Present` at a v25 frontend is rejected | accept either → the bypass returns |
| G9 | **identical cells, changed generation, still emitted** — motion dedupe is per cell, and a mapping change makes the same cell a different byte | dedupe across a generation change → the second gesture is suppressed |
| G10 | an **invalid** mapped frame retains the previous frame **and** its generation, atomically | update one without the other → a valid generation names a frame never shown |
| G11 | **generation exhaustion fails CLOSED** — refuse gestures rather than accept unchecked ones | fail open → the hole returns at the boundary |
G2 and G3 are enumerated per entry deliberately: one row asserting "the
generation changed" cannot show **which** input moved it, and a key
that ignores `view_left` passes every row that only scrolls
vertically.
### Consequence for the GUI arc
This slice takes **v25**, so GUI arc **1e's `OpenTarget` moves to
v26** — corrected in `docs/gui-stage1-input-framing.md` **by this
slice**, because a canonical document that says v25 is false the moment
this lands. `ADVERTISED_PROTOCOL_VERSION` stays pinned at **20**.
## 6. Deferred (named)
Left / right / top side windows; multiple slots per side; **rehoming a leaf

View File

@ -11,6 +11,13 @@ review overturned; revision 11 retracts it and P2 is implemented as
written** (§6). **Q#S1-8, Q#S1-9 and Q#S1-10 are RULED.** **1-pre is
IMPLEMENTED**; 1a onward may begin from this document.
**v26, not v25 — corrected by the panel mapping-generation slice.**
That slice (`docs/bottom-panel-framing.md` §5b) takes **v25** for
`PanelFramePayload::PresentMapped` / `FrontendEvent::PanelPointerMapped`,
and it lands ahead of 1e because panel-pointer replay blocks 1b.
Protocol slices stay serialized; one was inserted in front.
`ADVERTISED_PROTOCOL_VERSION` remains pinned at **20**.
**Verification base:** §2 is **re-measured at `4f77491`** (2026-08-12),
the tip after 1-pre; it was originally taken at `a994f37`. Sections
other than §2 were written against `a994f37` and their *rulings* are
@ -350,7 +357,7 @@ says nothing about glyphs or hit tests staying at scale 1.
| D5 | Overlay clears on **empty `Preedit`, `Ime::Disabled`, and focus loss** — all three | no overlay | clear on focus loss only → `Disabled` row leaves stale text |
| D6 | Dead-key state owned here; 1a buffers nothing | dead keys dropped | buffer in 1a → D6 by construction |
### 1e — `OpenTarget` (v25)
### 1e — `OpenTarget` (v26)
| # | Contract | Witness (fails today because) | Mutation |
|---|---|---|---|
@ -560,7 +567,7 @@ and flushed to the socket**. **Bound: 250 ms.**
| | **1a — `TextInput`** | **1e — `OpenTarget` + `OpenTargetResult`** |
|---|---|---|
| **floor** | **v24** | **v25**, after v24, serialized |
| **floor** | **v24** | **v26**, after v25's mapping generation, serialized |
| **encoding** | **appended variant**; never widen a field in place — postcard is positional | appended variants |
| **byte pin** | frozen-byte fixture on the **previous final variant** | same |
| **gate** | daemon accepts from `>= 24`; producer withholds below | `>= 25`; producer withholds below |