diff --git a/docs/active-work.md b/docs/active-work.md index 5ae7f6a..18c8c31 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -250,7 +250,47 @@ 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. -## The GUI arc — Stage 0 branch OPEN, absorption COMPLETE, ready for PR +## GUI arc Stage 1 — 1-pre branch OPEN, framing APPROVED, no PR yet + +**Written at the branch's first commit**, with the framing, as the arc's +§5 requires of every PR in it. + +- **Branch `gui-stage1-pre`**, base `githubsucks/main` @ `f8ad3e7` (the + Stage 0 merge, #236). **`githubsucks/gui-stage1-pre` is the + authoritative tip** — the ref, not a SHA. Recover with + `git fetch githubsucks && git checkout gui-stage1-pre`. +- **Framing `docs/gui-stage1-input-framing.md`, revision 9, APPROVED** + after **eight rejected revisions**. It is Stage 1's framing for **all** + slices and governs the later branches; only Stage 0 was framed by the + arc document itself. +- **Scope of THIS branch: 1-pre only — the input seam. No behaviour + change.** `App::window_event` is **655 lines** (`main.rs:2734`) and + nothing below it can be witnessed without a display, which is why the + seam precedes every other slice. +- **The evidence contract, because "no behaviour change" is not one:** a + **headless routing harness** records, per event family, the routing + decision, the **outbound protocol events, and the LOCAL effects** — + exit, redraw, resize, state mutation. Production `window_event` + becomes a **thin call-through**. Mutation evidence: bypassing an arm, + or misrouting one family to another, must fail. +- **P3 is an ACCEPTED STRUCTURAL EXCEPTION.** A headless test cannot + construct `ActiveEventLoop` or invoke the real callback, so the + `window_event → router` delegation is a **code-review invariant, not a + tested one**. Recorded rather than papered over: mutation evidence + covers every router arm and **not** the delegation. +- **Slice order (each its own branch and PR):** `1-pre` → `1a`\* → `1b` + → `1c` → `1d` → `1e`\*. **`1a` and `1e` are protocol-bearing (v24 + `TextInput`, v25 `OpenTarget`/`OpenTargetResult`) and are + SERIALIZED.** 1c is **not** protocol-bearing under Q#S1-8. +- **Q#S1-7 obligation carried by THIS PR:** Meta/Super policy moves to + Stage 2, so the arc framing's §2.5 and the standing backlog are + amended here. Stage 1 keeps the deliberate OS reservation and adds no + island. +- **Gates:** `./scripts/gate --acceptance gpu_invocation_acceptance` + plus touched input suites, and `PMACS_REQUIRE_GPU=1 cargo test -p + pmacs-gpu`. **No `--protocol`** — 1-pre changes no wire. + +## The GUI arc — Stage 0 MERGED as #236 (`f8ad3e7`) **Written at the branch's first commit**, with the framing, which is what this arc's own §5 requires of every PR in it. The standing diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md new file mode 100644 index 0000000..ddc3e38 --- /dev/null +++ b/docs/gui-stage1-input-framing.md @@ -0,0 +1,436 @@ +# GUI arc, Stage 1 — input foundation (framing) + +**Status: revision 9 — APPROVED.** Revisions 1–8 rejected. +**Q#S1-8, Q#S1-9 and Q#S1-10 are RULED.** Implementation may begin +from this document. + +**Verification base:** checked in the `gui-arc-stage0` worktree at +`a994f37`, whose tree for these files is what `f8ad3e7` merged. + +## 1. What this stage closes + +Journey **step 5**. **Not step 12** (Stage 4b, P2-gated). Five of nine +§3.1 blockers die here. + +## 2. Ground truth at `a994f37` + +`App::window_event` (`main.rs:2734`) is **655 lines**; **eight** +`WindowEvent` arms handled, the rest fall to `_`. + +**Two text producers.** `translate_key(logical: &Key, …)` +(`main.rs:10975`) reads the **logical key** and truncates via +`chars().next()`; **`KeyEvent.text` is never read.** +`WindowEvent::Ime::Commit(String)` is a **separate event, ignored +entirely** (`set_ime_allowed`/`WindowEvent::Ime`: zero occurrences). +**Today's two failures are therefore: multi-scalar keyboard input is +truncated to its first scalar, and an IME commit produces nothing.** + +**`FrontendEvent`: sixteen variants, none carrying an open path or +command invocation.** `PROTOCOL_VERSION = 23`. + +**The handshake precedes any window** — `EventLoop` built, client +constructed, handshake done **before `run_app`** (`main.rs:696`). + +**TUI wheel arms**: `EditorState::dispatch_mouse` (`src/editor.rs:3052`), +`ScrollUp`/`ScrollDown` at **`:3203`**. + +**1c is producer-side only for Focus/Detach** — those variants exist on +the wire. **Title, Bell and `Goodbye` are GPU consumer work.** + +**`Outbox::enqueue` returns `false` once closed** (`attach.rs:414`) and +**coalesces by kind**. + +## 3. PR topology + +`1-pre` → `1a`\* → `1b` → `1c` → `1d` → `1e`\* (\* `--protocol`) + +**1c is NOT protocol-bearing** under Q#S1-8's ruling. Protocol slices +are serialized. + +## 4. Q#S1-8 — RULED: (A), preserve pre-window readiness + +`AttachRequest.initial_size` for a semantic session is a **named, +provisional `SEMANTIC_BOOTSTRAP_GRID` of 24×80**. It is **not measured +geometry** and **must never become semantic frame or panel authority**. +**`FrontendCellGeometry`, sent after window creation, is the sole real +frame declaration.** + +This **codifies current daemon behaviour**, so **1c stays +non-protocol-bearing** — unless implementation changes that behaviour, +which would be a wire-contract change even with no bytes moved. + +## 5. Q#S1-9 — RULED: `TextInput` precedence + +**A `KeyboardInput` stays `Key` unless a rule below moves it.** + +1. **Named keys and control text remain `Key`**, regardless of + `KeyEvent.text` — so `Enter`'s `"\r"` never becomes text. +2. **Ctrl/Alt chords remain `Key`**, except **printable Ctrl+Alt + recognized by the existing AltGr rule**. +3. **Meta/Super-only text stays reserved to the OS.** +4. **Plain printable SINGLE-scalar remains `Key`** — preserving mode + keymaps and today's typed provenance. +5. **Printable MULTI-scalar becomes one `TextInput`.** +6. **Every non-empty `Ime::Commit` becomes one `TextInput`**, even + single-scalar. +7. **`Key::Dead` is 1d-owned; 1a buffers nothing.** +8. **`Shift` is already reflected in resolved text** and is **not** + carried on `TextInput`. + +**Provenance and chain.** A **single-scalar** `TextInput` rotates to +`buffer.self-insert` and creates **today's one-codepoint typed +provenance**. A **multi-scalar** `TextInput` **breaks the command chain +and creates no typed provenance**. Both are **one edit, one undo unit, +one hook, one eligible CRDT op**. + +**Modal precedence is preserved:** terminals take **raw UTF-8**; +search and minibuffer **consume** text; menu and query-replace **retain +their shadow behaviour**; **only the ordinary document path performs the +atomic edit**. + +**Payload cap: 64 KiB UTF-8, oversize REJECTED, never truncated.** + +## 6. Evidence + +**The promise, corrected.** Revision 4 claimed every mutation fails only +its own clause. That is **false and cannot be made true**: clauses have +real dependencies — D1 gates every 1d row, D3's failure surfaces at A6, +and **E0 and E2–E6 all presuppose E1** (no transport, no receiver). The promise is therefore +**dependency-aware, and split by clause kind** — revision 5 still said +"every clause fails today", which C6 falsifies by design: + +- **CHANGE clauses** have a witness that **fails today** and a mutation + that fails **at least** its own clause. +- **PRESERVATION clauses** (marked **[P]**) **pass today**; their + witness pins behaviour that must not regress, and their mutation is + the change that would break it. +- Where a mutation necessarily breaks dependents, **the dependency is + named**. + +P3 remains an accepted structural exception: not headlessly testable. + +### 1-pre + +| # | Contract | Witness (fails today because) | Mutation | +|---|---|---|---| +| P1 | Every handled family routes through an extracted function | no extracted functions exist | misroute one family → that family's row | +| P2 | Harness records outbound events **and local effects** (exit, redraw, resize, state mutation) | no harness | record outbound only → exit/redraw rows | +| P3 | `window_event` is a thin call-through | — | **structural/code-review invariant; not testable headlessly** (no `ActiveEventLoop`) | + +### 1a — `TextInput` (v24) + +| # | Contract | Witness (fails today because) | Mutation | +|---|---|---|---| +| A1 | `F1`–`F35` → `F(1..=35)` | `_ => return None` | map `F13+` → `None` → F13–F35 rows | +| A2 | Shift+Tab → `BackTab` with `Shift` set | produces `Tab` | drop `Shift` → A2 only | +| A3 | `ContextMenu` → `Menu` | produces nothing | map to `Char('\0')` → A3 only | +| A4 | Idle Escape reaches the daemon, never exits | exits (`main.rs:2771`) | restore the quit branch → A4 only | +| A5 | Precedence per §5 (1–8) | multi-scalar truncated; IME ignored | move rule 1 (control text → text) → the `Enter`-in-dired row | +| A6 | One commit = one edit, undo unit, hook, eligible CRDT op | commit truncated to one scalar | one edit per scalar → undo-unit row (**and D3 surfaces here**) | +| A7 | Prompts consume scalars **in order** | multi-scalar never arrives | reverse order → A7's prompt transcript | +| A8 | Terminals get **raw UTF-8, never bracketed paste** | multi-scalar never arrives | route via `Paste` → terminal row shows bracket markers | +| A9 | **64 KiB cap; oversize rejected, not truncated** | no cap exists | truncate instead → the oversize row observes silent loss | + +### 1b — pointer and scroll + +| # | Contract | Witness | Mutation | +|---|---|---|---| +| B1 | Residual per **axis and surface** | deltas discarded | share one accumulator → surface-switch jump | +| B2 | Wheel-right raises leftmost column; wheel-down raises top line | `x` discarded | invert a sign → that axis's row | +| B3 | Clamps at content bounds; never a negative origin | no horizontal scroll to clamp | remove clamp → **at-bounds row: origin goes negative and the view blanks** | +| B4 | Middle-click paste uses **PRIMARY on Linux** | no middle-click path | use `CLIPBOARD` → B4 only | +| B5 | I-beam over text content only | no I-beam | extend over the gutter → B5 only | +| B6 | Wheel over the minimap scrolls the **document viewport** with **its own residual accumulator**; click/drag remains scrub | **a FULL tick already scrolls today** — minimap pixels are `Elsewhere` (`main.rs:2061`) and the wheel falls through to `scroll_by_lines` (`main.rs:3373`). What fails is **fractional accumulation**, and **residual ownership distinct from the document's**: sub-tick minimap deltas are discarded, and a **surface-switch fractional witness** (part-tick over the minimap, then over the document) must not carry residue across | share the document's accumulator → the surface-switch fractional row jumps | +| B7 | **TUI horizontal**: **three columns per wheel tick**, sign per B2; **left origin clamps at 0 and at (widest display-line width − text viewport width), SATURATING AT ZERO**; **wrap pins the origin to 0** | events arrive at `:3203` and are dropped | step of one → the three-column row; clamp at the widest line's **full width** → **the right-bound row blanks the viewport**; drop the wrap guard → the wrap row scrolls a wrapped buffer sideways | + +**B7's right bound corrected.** Clamping at the widest line's full width +lets the origin pass every glyph and leave the viewport **entirely +blank**. The bound is *width − viewport*, saturating at zero for buffers +narrower than the viewport, and **the right-bound witness asserts the +final display column is still visible**. + +**Why B6 changed — and revision 5's reason was wrong.** Scrubbing on +wheel is not *impossible*: the wheel handler already reads the cached +`state.pointer_pos` for surface routing (`main.rs:3337`), so an absolute +target is available. It is the **wrong semantics**: a wheel is a +**relative** gesture, and mapping relative ticks onto an absolute +position would make one notch jump to wherever the pointer happens to +rest. Click and drag remain scrub because those *are* absolute. + +### 1c — session and window signals + +| # | Contract | Witness | Mutation | +|---|---|---|---| +| C1 | Title `" — pmacs"`, `"pmacs"` when unnamed | title is static | drop the name → C1 only | +| C2 | **Visible bell: 120 ms, WHOLE CLIENT AREA visibly changes**; repeats **neither queue nor extend** the first deadline | `Bell` unconsumed | let repeats extend → the repeat row's flash outlasts 120 ms; flash a sub-region → the headless render witness cannot see it | +| C3 | `Goodbye` names the daemon's reason, else an explicitly **locally-classified** transport/EOF reason; never blank | live-loop reason discarded | blank the fallback → EOF row | +| C4 | `FocusLost` precedes `Detach`; `FocusGained` only after attach completes | `Focused` unhandled | swap → C4 only | +| C5a | **Local DPI correctness**: at scale 1→2 with **unchanged logical size**, logical wrapping, row count and hit testing are **stable** while **physical pixels double** — glyphs, clips, caret, hit tests and overlays all rescale | `scale: 1.0` hardcoded (`main.rs:8950`) | rescale glyphs only → **caret, clip and hit-test rows fail while text still looks right**, which is the bug this splits out | +| C5b | **Geometry declaration**: the epoch advances and `FrontendCellGeometry` is emitted | no `ScaleFactorChanged` arm | suppress the emit → C5b only, **C5a still passing** | +| C6 **[P]** | `initial_size` = `SEMANTIC_BOOTSTRAP_GRID` (24×80), **never** frame or panel authority | **passes today** — this codifies current daemon behaviour; the witness pins that a semantic frame/panel is sized from `FrontendCellGeometry` alone | derive a frame or panel extent from `initial_size` → the panel-authority row | +| C7 | Close contract — §7 | see §7 | see §7 | + +**C5 was one row and hid half the defect** — suppressing the wire emit +says nothing about glyphs or hit tests staying at scale 1. + +### 1d — IME + +| # | Contract | Witness | Mutation | +|---|---|---|---| +| D1 | `set_ime_allowed(true)` | zero occurrences — **no composition arrives at all** | omit → **every 1d row (stated dependency, not a matrix defect)** | +| D2 | Preedit overlay with caret and selection; **indices are BYTE OFFSETS** into the preedit string | no overlay | treat as char indices → multibyte row | +| D3 | `Ime::Commit` emits A5's `TextInput` | commit ignored | emit per-scalar `Key`s → **A6's undo row (named dependency)** | +| D4 | **`set_ime_cursor_area` updated** after caret motion, scroll, resize, font/DPI change, and every preedit change | never called → candidates misplaced | update on caret only → scroll and resize rows | +| 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) + +| # | Contract | Witness (fails today because) | Mutation | +|---|---|---|---| +| E0 | **SUCCESS**: a **successfully handled valid target** is **resolved, installed, hooked and dispatched through the existing file/directory pipeline**, and the originating frontend receives **exactly one terminal result** — `Opened { request_id, buffer_id }` when a commit lands, **or `Handled` when an extension legitimately claims it** | no receiver — a drop does nothing | dispatch without firing the open hooks → the hook row; settle before the deferred commit lands → the async-directory row (Q#S1-10) | +| E1 | Versioned `OpenTarget { request_id: u64, cwd, path }` carrying `InitialTarget`'s raw shape. **`request_id` is unique among a frontend's OUTSTANDING requests; a duplicate is REJECTED at the protocol boundary** and must never replace or settle the original completion | no variant exists | **omit `request_id`** → the two-concurrent-drops row misattributes; **accept a duplicate id** → the reuse row settles the first drop's completion with the second drop's outcome | +| E2 | Source is **authenticated** | no receiver | accept an unauthenticated sender → E2's forged-source row | +| E3 | Primary document `ViewDestination` captured **immediately on receipt**, before any await | no receiver | capture after the open resolves → **frontend-switch row: the file lands in whichever frontend is ambient** (the #231 defect) | +| E4 | **No window identity trusted from the wire** | no receiver | accept a window id → E4's forged-window row | +| E5 | Failures **before terminal disposition** — including completion-aware `Deferred` work — are **visible to the ORIGINATING frontend** as bounded `Failed { request_id, message }`. After `Handled`, responsibility and any later failure belong to the claiming extension, and no second result is sent — see §8 | no receiver | swallow the error → the permission and embedded-NUL rows | +| E6 | `InitialTarget` limits enforced: **32 KiB per raw path**, **non-empty path**, **absolute non-empty cwd**, **embedded NUL rejected** | no receiver | drop the NUL check → the embedded-NUL row | + +**The failure taxonomy was wrong in revision 5.** **A missing path is +NOT a failure**: the resolver deliberately creates an **empty +path-backed buffer** on `NotFound` (`src/editor_core.rs:1177`), which is +how "open a file that does not exist yet" works. **Directories are valid +targets too.** Genuine failures are permission denial, validation +rejection (E6), and open errors that are not `NotFound`. Revision 5 +listed "missing-path" and "not-a-directory" rows that would have pinned +the opposite of the intended behaviour. + +### Q#S1-10 — RULED: the result is TERMINAL + +Directory opening completes **asynchronously**, so `OpenTargetResult` +either waits for the captured-destination commit or merely acknowledges +dispatch. **Ruling: terminal.** The result is sent **when the request +reaches a terminal disposition** — for `Opened`, after the commit +resolves; for `Handled`, at the moment responsibility transfers; for +`Failed`, at the failure, **which for several paths has no commit at +all**. Asynchronous failure is reported through the same result — an acknowledgement-plus-later-channel design would need a +second source-scoped mechanism to carry exactly the failures that matter +most. + +``` +OpenTargetResult::Opened { request_id: u64, buffer_id: BufferId } +OpenTargetResult::Handled { request_id: u64 } // claimed; no buffer attributable +OpenTargetResult::Failed { request_id: u64, message: String } // 4 KiB cap +``` + +**Terminal completion must be TOTAL over the existing pipeline, and +"after the commit resolves" is not** — three legitimate paths reach +neither a commit nor a result: + +- **Claimed**: a `path.open-directory` listener returns `proceed = + false` and the dispatch **returns without committing** + (`src/editor.rs:1375`). +- **Disabled**: the `directory_handler` slot is clear, which is a + supported configuration — the dispatch emits a **status message and no + commit** (`src/editor.rs:1389`). +- **Asynchronous fallback**: the default handler calls `open_async` and + **returns immediately** (`builtin/runtime/dired.lua:750`), so the + commit lands long after the dispatch unwinds. + +**Mechanism: a request-scoped ONE-SHOT COMPLETION with an EXPLICIT +STATE MACHINE**, carried through the directory pipeline, settled +**exactly once**, and **discarded on source detach** — a frontend that +left cannot be told anything. + +``` +Pending ──(defer before scheduling)──▶ Deferred ──▶ Settled + │ │ + └──────────(commit / failure)────────┴──▶ Settled + │ + └──(source detach, from Pending or Deferred)──▶ Cancelled +``` + +**`open_async` transitions to `Deferred` BEFORE handing the completion +to scheduled work.** The ownership transfer is explicit and does not +depend on whether today's scheduler starts a coroutine synchronously or +a future scheduler defers its first step. Without the transition the +dispatch unwinds while the completion is still `Pending`, the +end-of-turn fallback fires, and the request is settled *before* the +commit it was waiting for. + +**The end-of-turn fallback acts ONLY on `Pending`.** A `Deferred` +completion is owned by the scheduled work. **Any later settlement is +exactly-once**: a second attempt against `Settled` or `Cancelled` is a +no-op, never a second message. + +*Mutation:* omit the `Deferred` transition, or delay it until after the +end-of-turn fallback → the async-directory row receives a premature +`Handled`/`Failed`; exactly-once settlement then suppresses the later +`Opened` attempt when the commit lands. + +`open_async` carrying the completion to the commit is what makes the +asynchronous path terminal rather than silent. + +**Total disposition, so no path can fall through:** + +| path | settles as | +|---|---| +| commit lands | `Opened { buffer_id }` | +| listener **claimed** and did not settle | `Handled` | +| **replacement handler** ran and did not settle | `Handled` | +| handler slot **disabled** | `Failed` naming that directory opening is disabled | +| **synchronous error** (listener raised, validation, permission) | `Failed` with the reason | +| pipeline unwinds **still `Pending`** at end of dispatch turn | `Handled` if a listener claimed or a replacement handler ran; **otherwise `Failed`** | +| **source detached** before settlement | discarded — nothing is sent, and the completion is dropped rather than leaked | + +**`Handled` exists because `Opened` cannot be honest there.** A claim +means a user listener took responsibility and **no buffer is +attributable**; reporting `Opened` would require inventing a +`buffer_id`, and reporting `Failed` would mislabel a supported +extension point as an error. `Handled` is the terminal responsibility +transfer: any later extension-owned failure uses the extension's own +reporting surface and cannot emit a second `OpenTargetResult`. + +*Witness:* one case per **live-source** row, each asserting **exactly +one** result. **The detach row asserts the opposite and must not be +read as "one result":** zero messages sent, **no completion retained**, +and a later settlement attempt **ignored** rather than delivered. +*Mutation:* drop the end-of-turn fallback → the claimed and disabled +rows hang with no result at all, which is the defect this ruling +closes. + +`message` uses the **existing 4 KiB error cap**. **Both affected enums +get an independent frozen-byte pin on their own preceding final +variant** — `FrontendEvent` for `OpenTarget`, `InstanceMessage` for +`OpenTargetResult` — because an appended variant's own round-trip cannot +detect a discriminant shift in either. + +## 7. The close contract + +`send_event` only enqueues (`attach.rs:1145`); the writer takes a batch +and releases the lock before blocking writes (`:671`); **`enqueue` +returns `false` once closed** (`:414`). + +**So revision 4's order was literally unexecutable** — closing first +would have rejected the `Detach` it then tried to enqueue. + +**Contract — state inspection plus any append/transition is ONE atomic +critical section under a single lock hold, dispatched on the outbox +STATE. The lock is released before notifying the writer, waiting on an +acknowledgement, returning, or shutting down.** Holding it while waiting +would prevent the writer from taking the suffix or transitioning to +`Drained`. The four states are tabulated below; this paragraph is their +normative statement, and revision 6's "append `Detach` / +already-closed → fallback" wording is superseded: + +- **Open** → under the lock, append the **complete suffix** (`FocusLost` + when currently focused, then `Detach`), transition to `Sealed`, and + capture its acknowledgement handle; release the lock, wake the writer, + then wait. +- **Sealed** → under the lock, capture the **existing acknowledgement**; + release the lock, then join it. Do not re-append, do not seal again, + do not shut down. +- **Drained** → release the lock, then return immediately. +- **Failed** → release the lock, then take the socket-shutdown fallback. + +**An ordinary post-seal `send_event` REJECTS and does nothing else** — +it must not invoke shutdown, because a healthy drain is in flight. +**`Detach` is exempt from coalescing.** **Wake the writer after +append-and-seal**, or +a sealed outbox with a pending `Detach` waits on a condvar nobody +signals and the 250 ms bound expires on a daemon that was reading fine. + +**The exactly-full case, which revision 5 left undefined.** +`OUTBOX_MAX` is **8192**, and a queue at exactly that length is a +**valid OPEN state**: the *next* ordinary `enqueue` both **sets +`closed`** and **rejects the event** (`attach.rs:427`). So terminal +close must not go through the ordinary path. **Ruling: the terminal operation appends the whole required SUFFIX +atomically** — **`FocusLost` when currently focused, then `Detach`** — +reserving **at most `OUTBOX_MAX + 2`**. + +**One slot was not enough, and C4 is why.** C4 requires `FocusLost` +before `Detach`; at exactly `OUTBOX_MAX` an *ordinary* `FocusLost` +enqueue is **rejected and sets `closed`**, so the terminal append would +then find a closed outbox and fall back — losing both events. Revision 6 +reserved one slot and so contradicted a contract two sections above it. + +**Exact-cap witness:** fill to exactly `OUTBOX_MAX` **while focused**, +then close. The transcript ends **`… FocusLost, Detach`**, the writer is +woken, and acknowledgement precedes exit. *Mutation:* reserve one slot → +`FocusLost` is dropped and C4's ordering row fails at exact cap only. + +**Outbox states — clean SEALED is not failed CLOSED.** Revision 6 had +one `closed` flag doing both jobs, so a duplicate close or a post-seal +send would see "closed" and **invoke the socket-shutdown fallback, +aborting the drain it should have joined**. Four states, with distinct +behaviour: + +| state | ordinary enqueue | close called again | +|---|---|---| +| **Open** | ordinary policy: accepted below the cap; a cap-crossing lossless append is rejected and transitions to `Failed` | performs the terminal append-and-seal | +| **Sealed** (suffix appended, drain in flight) | rejected | **waits on the existing acknowledgement** — never re-seals, never falls back | +| **Drained** (acknowledged) | rejected | returns immediately | +| **Failed** (overflow, or transport error) | rejected | **takes the socket-shutdown fallback** | + +*Mutation:* collapse `Sealed` into `Failed` → the duplicate-close witness +aborts a healthy drain and exits before `Detach` is written. + +**Acknowledgement point:** the batch containing `Detach` **fully written +and flushed to the socket**. **Bound: 250 ms.** + +**Two witnesses, mutually exclusive:** + +- **Responsive reader** — all preceding lossless events **and** `Detach` + are written, acknowledgement occurs, **then** exit. *Mutation:* drop + the drain → exit-before-`Detach` is observable. +- **Stalled reader** — the deadline fires, socket shutdown yields EOF + cleanup, the frontend **exits anyway**. *Mutation:* remove the bound + → this test hangs. + +*Seal mutation:* permit a late enqueue → an event appears after +`Detach` in the responsive transcript. + +## 8. Wire contracts + +| | **1a — `TextInput`** | **1e — `OpenTarget` + `OpenTargetResult`** | +|---|---|---| +| **floor** | **v24** | **v25**, after v24, 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 | +| **old peer** | a `< 24` frontend **retains its existing `Key` behaviour and its existing limitations** — it truncates multi-scalar input today and ignores IME, and continues to. **The guarantee is NO REGRESSION, not retroactive correctness** | a `< 25` frontend cannot drop-open; nothing it already had degrades | +| **bounds** | **64 KiB** UTF-8; oversize **rejected** | **32 KiB** per raw path; non-empty path; absolute non-empty cwd; **embedded NUL rejected**; `Failed.message` capped at the **existing 4 KiB** error cap | +| **pins** | frozen bytes on `FrontendEvent`'s previous final variant | **two independent pins** — `FrontendEvent` for `OpenTarget`, `InstanceMessage` for `OpenTargetResult` | + +**E5's delivery mechanism.** `StatusFacts.message` is **global and can +be cleared before the originating frontend observes it**, so it cannot +carry this. **1e adds a source-scoped `OpenTargetResult`, correlated by +a frontend request ID**, so a failure reaches the frontend that dropped +the file and no other. + +## 9. Coherence impact (§20) + +- **Journey steps**: **5**; **3** (1e); **6(e)** on the GPU column. +- **Islands**: Escape ceasing to be a local quit **removes** one; + Q#S1-7 adds none → the census **falls by one**. +- **Config registry**: none. The bell's 120 ms is a constant. +- **Background work**: **1e adds no new worker**, but it **attributes + the existing asynchronous directory operation to an originating + frontend and request** until terminal settlement — the one-shot + completion is that attribution — **and drops it on source detach**. + That is §9's ownership question appearing in miniature, and it is + answered here for this one operation rather than in general. + +## 10. Rulings + +**Q#S1-1** native close detaches, `editor.quit` shuts down the daemon +and its attachments, Escape only cancels/round-trips · **Q#S1-5** A/`1e` +· **Q#S1-6** B/`TextInput` · **Q#S1-7** Meta/Super → Stage 2, arc §2.5 +and the backlog amended by 1-pre's first PR · **Q#S1-8** (A), +`SEMANTIC_BOOTSTRAP_GRID` · **Q#S1-9** precedence per §5 · **Q#S1-10** terminal `OpenTargetResult`. + +## 11. Gates + +`./scripts/gate --acceptance gpu_invocation_acceptance` plus touched +input suites, and `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`. +**`--protocol` for 1a and 1e only.**