# Auto-pairing — framing (Arc 2, editing table stakes) Typing `(` should give `()` with the cursor between; typing `)` when the next char is already `)` should step over it instead of doubling it. Language-aware pair sets, conservative insertion predicate. Last Arc 2 item; after it merges, Arc 2 closes and the compile-mode vs themes decision discussion is due. Roadmap: `docs/roadmap-2026-07.md` Arc 2 ("auto-pairing"). Revision 2: R1 findings — pair chars now route through dispatch (peer-bound undo falsified the pure-reaction undo story), pair.lua loads before lsp.lua (the sighelp path flushes didChange synchronously mid-hook), rejected/transformed intercept outcomes separated (the effective edit has already landed), the type-over claim is path-qualified with a region guard, and the CRDT acceptance gains a second replica plus undo cases for both routing models. Revision 3: R2 findings — the dispatch route no longer claims global chronological undo (an older source-peer edit still wins the TUI's local undo arbitration), source self-inserts gain an exact ephemeral typed-edit record so a relocated opener can never be inferred from unrelated text, the context-switch/LSP limit is stated rather than hidden by the cursor guard, non-typed acceptance now drives callbacks that actually fire, and redo is exercised rather than merely named. Revision 4: PR #110 round 1 — the typed-edit record pins the edited buffer's revision after the completing edit and dies at dispatch end if the command edited again (a redefined self-insert that replaces the typed char can no longer leave a stale-but-clean record); pair-set relevance is established before any provenance report (transformed non-pair characters stay silent); pair entries parse as exactly two codepoints (malformed entries are skipped entirely, never partially honored); the record-capture seam is an opt-in test facility, off in production; and the source-context-change *report* is scoped as best-effort under the active-buffer edit-epoch limit — an equal-revision context switch skips the fan-out and fails closed silently. Revision 5: PR #110 round 2 — set entries validate full UTF-8 well-formedness (Table 3-7: continuation bytes, overlong encodings, surrogates, beyond-U+10FFFF all disqualify; lead-byte length alone had accepted `"(\xC2x"`), and the predicate treats malformed buffer bytes as word-like (no pairing before junk), never as EOL; relevance and reporting resolve against the SOURCE buffer's language via the new `pmacs.lsp.buffer_language(buf)` / `buf:path()` (a context-switching command no longer attributes them to the destination buffer); and non-table set containers degrade language→default→empty instead of throwing from the callback. Revision 6: PR #110 round 3 — coverage pins only, no code changes: the predicate's raw-byte posture is pinned from the buffer side (`(` typed before a lone `0xFF` inserts no closer; a regression to nil-on-malformed would read junk as end-of-buffer), and the top-level non-table `pmacs.pair.sets` container is pinned alongside the per-entry cases (pairs nothing, clean `*errors*`). ## Ground truth (as of `7e127ab`) - **Dispatch is keymap-first for printables** — `Char('(')` resolves through the keymap stack before the self-insert fallback (`src/editor.rs:713-717`, fallback `:748-761`); printable keys are bindable (the buffer list binds `n`/`p` buffer-locally, `builtin/commands/default.lua:407-423`). - **But a key binding cannot carry pairing.** The GPU applies plain printables optimistically **even mid-line** — classifier `pmacs-gpu/src/main.rs:1525-1533`, eligibility `:2054-2079` — so a `(`-binding would never run for GPU typing. The TUI applies printables optimistically **at end of line** (the F19 paint constraint: mid-line inserts round-trip, EOL appends do not — `src/optimistic.rs:269-271`, contract `:168-200`). And a binding that inserted the pair atomically would break classification (below) even where it did run. - **The typed-char substrate exists and has a working consumer.** The daemon classifies single-codepoint optimistic inserts as `buffer.self-insert` (exact byte decode, `src/daemon.rs:1985-2002`, `:2085-2087`), explicitly "for typed-char consumers (signature help; …)". `buffer.after-edit` fires on BOTH paths — dispatch (`src/editor.rs:772-776`) and the optimistic CRDT arm (`src/daemon.rs:2156-2169`) — with core borrows released, so a hook may edit the buffer. Signature-help auto-trigger (`builtin/runtime/lsp.lua:761-791`) is the in-tree template: `this_command() == "buffer.self-insert"` plus `char_before`; a paste can never trigger it. - **An atomic 2-byte `"()"` insert breaks that classification** (`is_single_codepoint_insert` decodes the actual bytes, `src/daemon.rs:2079-2084` names signature help as the reason), and **intercepts cannot rewrite `(` into `()`** (M6.4: kind and payload immutable, `src/lua_bindings/mod.rs:1061-1073`, `:1657-1705`). Any pairing design must keep the opener a genuine single-codepoint self-insert. - **Hook mechanics**: callbacks run in registration order (`src/hook.rs:240`, snapshot `:255-259`; fan-out `:291-297`); `buffer.after-edit` is **all-must-succeed** (`builtin/hooks/default.lua:41-45`) — one callback's error or return value cannot suppress the others. Hook-created edits do **not** re-fire `buffer.after-edit` (fired once per dispatch cycle / per optimistic op). - **The LSP hook flushes synchronously mid-fan-out.** The after-edit callback in lsp.lua marks the buffer dirty, and — when the typed char is a signature trigger — calls `signature_help_quiet`, which calls `flush_did_change_for(rec)` **synchronously** (`builtin/runtime/lsp.lua:739-743`: "The server must see the character we just typed") before requesting. `flush_did_change` reads `buffer_text` at flush time and clears the pending entry (`:268-279`). Consequence: a closer inserted by a hook registered **after** lsp.lua's would miss that flush AND stay unsynchronized (no re-fire) until the next edit. Coalescing does not save this path; registration order does. - **Undo is peer-bound, per frontend.** Loro's `UndoManager` binds to one peer at construction; each frontend's `BufferMirror` undoes only its own peer's ops (`src/buffer_mirror.rs:541-555`), and the daemon's `buffer.undo` covers only daemon-peer edits (`src/buffer.rs:1360-1380`). The TUI's undo key tries the mirror first and **round-trips to the daemon when the mirror has nothing to undo** (`src/optimistic.rs:238-247`). Consequence: a pair whose opener is a source-peer optimistic op and whose closer is a daemon-peer Lua edit is **not undoable coherently by either frontend** — the TUI removes the opener first (leaving `)`), the daemon removes the closer then unrelated daemon edits. Edits that route through dispatch are all daemon-peer. - **The TUI's mirror-empty fallback is not chronological arbitration.** If the mirror has *any* older source-peer edit, its optimistic undo succeeds and the key never reaches the daemon (`src/optimistic.rs:230-247`). Thus `a` (optimistic) then `()` (daemon-routed) followed by the TUI's single-key undo removes `a`, not the closer. `C-x u`, which always dispatches, removes the daemon closer instead. Routing the pair's two edits to one peer makes their daemon-local order coherent; it does not merge that order with older source-peer history. Global chronological undo is existing cross-peer substrate work, not something pairing can truthfully claim to solve. - **Mutator effects land before the caller sees them.** The effective edit is applied and only then reported as the returned triple (`src/lua_bindings/mod.rs:1184`, `:1246-1259`) — a transformed (relocated/expanded) edit has already happened; it can be *repaired around*, never *withheld*. Lua mutators move no cursors and reconcile no window state (PR #109 ground truth); the context-guarded right-gravity repair in `builtin/runtime/indent.lua:60-64`, `:100-121` is the established pattern. - **The producing self-insert has the same intercept problem, and `after-edit` currently carries no payload.** `insert_char` advances the cursor from the requested position after `apply_active_edit` (`src/editor_core.rs:1738-1749`), even if an insert intercept moved the effective insertion. `this_command` proves only the input class; `char_before(cursor)` does not prove which character was typed or where it landed. Pairing therefore needs exact ephemeral source-edit provenance, not the signature-help heuristic alone. - **A hook-inserted closer lands correctly by construction**: insert at the cursor, cursor stays before it (mutators move no cursors); the daemon's `CursorByte` re-grounds both frontends; the GPU applies incoming ops and rebases unconfirmed edits without moving `own_cursor` (`pmacs-gpu/src/main.rs:2463-2564`). - **Broadcast ordering quirk on the optimistic path**: the after-edit hook fires (`src/daemon.rs:2167`) **before** the source opener op is queued for broadcast (`:2178`), so a hook-queued DaemonKey closer reaches **non-source replicas before the opener it depends on**. Loro is expected to buffer and converge; nothing pins that today. - **Region-active typing**: CUA type-over consumes the region on the dispatch path. The GPU round-trips when a selection decoration exists; the TUI's optimistic gate consults no selection state (PR #109 named deferral) — a nonempty TUI selection ending at EOL can optimistically insert without consuming the region, and the daemon arm deliberately preserves nonempty anchors. - **A callback may switch context, and later callbacks observe the switch.** The hook runner snapshots callbacks, not editor context; lsp.lua reads `pmacs.window.buffer()` when its callback runs. If a pair reaction's intercept switches from A to B, a local cursor guard can avoid touching B, but it cannot make the later LSP callback see A. Origin-pinned hook fan-out is named substrate work below. - **Direct Lua mutation and plain `pmacs.command.invoke` do not fire `buffer.after-edit`.** A non-typed regression that merely calls either API is vacuous; the test must explicitly run the hook or use a production path such as paste which fires it. - **No pair knowledge exists anywhere**; `pmacs.comment.strings` (`builtin/runtime/comment.lua:26-42`) is the per-language table precedent. No node-at-byte API on `pmacs.parse` (manual descent only; async/stale trees) — syntax-aware inhibit is not v1-viable. - Rust uses `'` for lifetimes — pairing `'` per-language is a correctness matter, not taste. ## Decisions ### Q#AP1 — Carrier: after-edit reaction, with pair chars routed through dispatch Two coupled decisions, each grounded in the constraints above: **The reaction carrier** (unchanged from R1): on `this_command() == "buffer.self-insert"`, the pairing hook reads the exact ephemeral typed-edit record from Q#AP9 and reacts with a second edit. This keeps the opener a genuine single-codepoint self-insert — the classification signature help depends on — without guessing its identity or position from surrounding buffer text. **The routing change** (new, R1 finding): the built-in pair charset `( ) [ ] { } " ' `` ` is **removed from both optimistic classifiers** — the GPU's `optimistic_insert_text` and the TUI's `classify_key` — so those chars always round-trip through dispatch. Without this, the opener is a source-peer op and the closer a daemon-peer op, and peer-bound undo makes the pair uncleanly undoable on every frontend (ground truth). With it, both edits are adjacent daemon-peer undo units. A daemon-routed undo removes the closer and then the opener; the TUI's single-key optimistic undo does the same **only when its older source-peer stack is empty**. This is pair-local coherence, not global chronological arbitration (Q#AP5). The exclusion also restores CUA type-over for pair chars on the TUI (a round-tripped `(` consumes the region the optimistic path would have skipped) and removes transient pair/skip paint from both frontends. Costs, named: one daemon round-trip per built-in-charset keystroke — on the TUI only EOL appends change (mid-line already round-trips); on the GPU all nine chars do. `'` and `` ` `` round-trip even in languages whose sets don't pair them — uniform routing beats per-language classifier state the frontends don't have. **User-extended pair chars beyond the built-in nine still arrive optimistically**: they pair correctly (the reaction fires either way) but their undo is cross-peer-degraded — documented limitation, pinned in acceptance, full fix deferred with the pre-existing mixed-history problem (cross-peer chronological undo arbitration). `builtin/runtime/pair.lua`; a `pmacs.pair.*` namespace. ### Q#AP2 — Pair sets: per-language table, conservative default `pmacs.pair.sets` — the `pmacs.comment.strings` shape: language → array of pair strings, plus a `default` entry used when the language is unknown or has no entry (pairing is useful in scratch buffers). An entry is EXACTLY two **well-formed** UTF-8 codepoints — opener then closer, multibyte allowed (`"«»"`); malformed entries are skipped entirely, never partially honored (R4: a `"()x"` typo must not turn `(` into `()x`; R5: well-formedness per Unicode Table 3-7, so truncated sequences, overlong encodings, surrogates, and beyond-U+10FFFF closers like `"(\xC2x"` also disqualify). A non-table container anywhere — a typo like `pmacs.pair.sets.default = "()"` — degrades language→default→empty rather than throwing from the after-edit callback (R5). The set (and the language behind it) always resolves against the buffer the typed-edit record names, via `pmacs.lsp.buffer_language(buf)` (R5): - `default = { "()", "[]", "{}", '""' }` — no `'` (prose apostrophes), no backtick. - `python`, `lua`, `javascript`/`typescript` (+react), `sh`/`bash` add `''`; javascript/typescript/markdown add `` `` `` pairs. - `rust`, `c`, `cpp`, `go`, `zig` = default (lifetimes, char literals). Users opt in from init.lua: `pmacs.pair.sets.rust = …`. ### Q#AP3 — Insert-pair semantics (open char typed) React when ALL hold: - `this_command() == "buffer.self-insert"` **and** Q#AP9 returns a live typed-edit record for this callback (pastes, manual hook runs, and programmatic inserts have no record and never pair); - **relevance first (R4)**: the record's exact typed codepoint is in the active pair set at all (opener or closer). Characters outside the set exit silently BEFORE any gate below can report — a transformed ordinary `a` is not auto-pairing's business; - the record's buffer/window match the current context, its source edit is clean (effective triple equals the requested insert or replace), and the current cursor equals its recorded post-edit cursor. A relocated/expanded or context-switching source self-insert stands as the intercept produced it and gets no pair reaction. A non-clean triple reports *"auto-pair skipped: source self-insert transformed"*; a context/cursor mismatch reports *"auto-pair skipped: source context changed"*. This is the fail-closed answer to R2 finding 2. The context-change *report* is best-effort (R4): it requires the after-edit fan-out to run, and the dispatcher's active-buffer revision compare (the named buffer-aware edit-epoch deferral) skips the fan-out when a context-switching command lands on a buffer whose revision coincidentally equals the origin's — the record dies un-armed and pairing fails closed silently; - **no active region survives the edit** (`ed.region() == nil`) — on the dispatch path type-over has already consumed and cleared it; a surviving nonempty region means the edit arrived through the TUI's selection-blind optimistic gate (custom chars only), where reacting would pile a closer onto an unconsumed region; - the record's exact typed codepoint is an opener in the SOURCE buffer's pair set (language via `pmacs.lsp.buffer_language(buf)` on the record's buffer, resolved **at callback time**, nil-guarded — pair.lua loads before lsp.lua, Q#AP7; R5: the active buffer is the wrong buffer whenever a context-switching command ran). `char_before` is not input provenance; - **conservative predicate**: the char at the cursor is EOL, whitespace, or a closing bracket from the pair set — `foo|bar` + `(` gives `(bar`, never `()bar`. Malformed bytes at the cursor are word-like (no pairing before junk), not EOL-like (R5); - for symmetric pairs (quotes), the skip check (Q#AP4) runs first. Reaction: one pcall'd `buf:insert(cursor, closer)`. Outcomes, separated (R1 finding): - **Rejected** (intercept threw): nothing landed; the opener stands alone; status *"auto-pair closer rejected by buffer intercept"*. - **Transformed** (effective triple deviates): the edit has already landed wherever the intercept put it — the positional result stands. Report, then **context-guarded cursor repair**: with the window+buffer snapshot taken before the mutator, right-gravity- translate the pre-edit cursor through the effective edit and `goto_byte` (clamps); skip all repair if the intercept switched window or buffer. (The clean path needs no cursor motion at all — the asymmetry is deliberate: repair only on deviation, because the clean at-cursor insert must leave the cursor *before* the closer, which translation would not.) ### Q#AP4 — Skip-over-close, reactive When Q#AP9's exact typed char is a closer in the pair set AND the char at the recorded post-insert cursor equals it: one pcall'd `buf:delete(cursor, cursor+len)`. Net text and cursor are exactly Emacs's skip: `(|)` + `)` → `()|`; nested closers skip likewise; `"` at `"|"` exits the string. With pair chars on the dispatch route there is no transient duplicate to paint — the frontends never locally applied the typed closer. Same outcome separation as Q#AP3: rejected delete → the duplicate stays (`())`), status, no retry; transformed delete → already landed, report, context-guarded translate-and-clamp repair. ### Q#AP5 — Undo grain Built-in pairs are two adjacent daemon-peer edits. On the GPU, and on the TUI when the mirror has no older source-peer unit (or when the user invokes the always-dispatched `C-x u`), daemon undo removes the closer and then the opener. Still two steps, not one; a skip's daemon undo restores the swallowed duplicate. This does **not** make undo globally chronological. With optimistic `a` already in the TUI mirror, then daemon-routed `()`, the TUI's single-key optimistic undo removes `a` first because the mirror has a local unit and never falls back. The GPU can undo the two daemon pair units but cannot subsequently reach the older source-peer `a` through daemon undo. Custom optimistic pair chars additionally split the pair itself across peers. All three behaviors are pinned explicitly; the general fix is a cross-peer chronological undo arbiter, deferred as existing collaboration substrate rather than charged to pair.lua. ### Q#AP6 — Type-over composes on the dispatch path; wrap is deferred For built-in pair chars (always dispatch-routed), typing an opener over an active region type-overs the region, then the reaction runs under the Q#AP3 predicate — Emacs-with-delete-selection semantics, guaranteed for a clean, context-preserving source edit. A transformed source edit fails closed under Q#AP9. For custom optimistic chars the TUI selection gap persists; the Q#AP3 region guard suppresses the reaction there rather than compounding the gap. **Wrapping** the region in the pair is deferred (needs a command carrier plus the TUI selection-gate fix). ### Q#AP7 — Load order: pair.lua BEFORE lsp.lua Registration order is execution order (ground truth), and lsp.lua's after-edit callback synchronously flushes didChange on the signature trigger path — the closer must already be in the buffer when that callback runs, or the server receives `(`-only text and the closer stays unsynchronized until the next edit (hook edits don't re-fire). So pair.lua's loader entry in `src/editor.rs` goes **before** lsp.lua's (`:288`), with a comment naming this ordering contract; all `pmacs.lsp.*` lookups inside the callback are lazy and nil-guarded (lsp.lua defines them later in the load sequence). Acceptance asserts the ordering by its observable: the **first** didChange after an ordinary `(` carries `()`; when the closer is position-transformed without switching context, that first didChange carries the complete effective post-reaction text rather than an opener-only intermediate. Scope: if the reaction intercept itself switches active window or buffer, later callbacks observe that new context. The pair callback's cursor guard cannot repair hook-wide context, so the first didChange guarantee does not extend to that legal-but-pathological case; A may remain pending while lsp.lua observes B. This is recorded under Deferred as origin-pinned after-edit fan-out and is no longer hidden behind a cursor-only acceptance claim. ### Q#AP8 — Interactions, verified - **Signature help**: the opener still classifies as self-insert on both routes; the closer insert moves no cursor, so `char_before` still reads `(`; the synchronous flush ships `()` (Q#AP7). Pinned with the `sighelp` fake-LSP mode. Same context-switch exception as Q#AP7. - **Completion popup**: `(` never belonged to the popup's key set; `completion_popup_validate` runs after the hook fan-out (`src/editor.rs:778-783`) and judges the post-pair buffer when context is preserved. The Q#AP7 context-switch exception applies to completion as well. - **Kill ring / boundaries**: the closer is a plain Lua mutator — stamps nothing, rotates nothing. - **No recursion**: hook edits don't re-fire after-edit; the autosave-driven manual `pmacs.hook.run("buffer.after-edit")` (`builtin/runtime/autosave.lua:233`) is inert because Q#AP9 exposes no typed-edit record outside the Rust-owned hook boundary, even if `this_command` is stale. - **Auto-indent**: RET inside `{|}` yields `{\n␣␣|}`; the electric closer-on-own-line split stays deferred with language-aware indent. ### Q#AP9 — Exact typed-edit provenance, ephemeral, one-shot, fail-closed `this_command` remains the coarse input-origin signal used by existing consumers, but pairing additionally requires a new `pmacs.editor.take_typed_edit()` record. The record is per frontend, is armed only while Rust is running the one `buffer.after-edit` fan-out for that input, and can be consumed exactly once: ```text { buffer, window_id, codepoint, requested_start, requested_end, effective_start, effective_end, inserted_len, post_cursor, clean } ``` The dispatch path arms the record from the self-insert codepoint and the requested Insert/Replace, then completes it from the effective `Edit` returned by `insert_char_over_region`. The optimistic CRDT arm already has both the decoded single codepoint and effective `Edit`, so it builds the same record before firing the hook. A small EditorCore outcome/report refactor is required because `apply_active_edit` currently discards the effective range on its way back to `insert_char`; payload immutability means the codepoint itself remains authoritative. The record additionally pins the edited buffer's revision immediately after the completing edit — a producer-side postcondition, not consumer surface (R4). At dispatch end, before arming, the revision is re-read: if the command edited again after the self-insert (a redefined `buffer.self-insert` that replaces or removes the typed character while leaving the cursor in place), the record no longer describes the buffer and dies un-armed. Note the switch-context case is reachable only through such a redefined command: a context-switching *intercept* cannot exist on the dispatch self-insert path (the core borrow is held across it; the borrow-released three-phase discipline belongs to the Lua-mutator path the reaction uses). The pair callback takes the record; later callbacks and a nested manual re-run of `buffer.after-edit` see nil. The dispatcher/daemon also clears any untaken slot immediately after the hook returns, including error paths and the no-revision-change path. Plain `pmacs.hook.run`, paste, programmatic mutation, and a stale `this_command == "buffer.self-insert"` therefore see nil. Pairing requires `clean == true`, matching buffer/window, and `cursor == post_cursor`; otherwise it reports (pair-set characters only, R4) and does nothing. This is deliberately narrower than teaching every command to expose its edit: one producer class, one consumer contract, and no persistent history. Record retention is zero in production: the opt-in `pmacs.pair._capture_records` test facility is the only way a consumed record outlives its fan-out (R4). ## Bets 1. **Nine round-tripped chars are imperceptible.** The TUI already round-trips every mid-line char; the GPU pays one local-socket hop on pair chars. The concrete return is real dispatch type-over, no optimistic duplicate paint during skip, and adjacent daemon-local pair units — not a false promise of global chronological undo. 2. **The conservative predicate kills the hate-mail cases** — no pairing before words, no apostrophe pairing in the default set. 3. **Uniform routing beats language-aware classifiers** — `'` round-trips in Rust for nothing, and nobody notices. 4. **`default`-set pairing in language-less buffers is wanted**, not surprising. ## Deferred (named) - Wrap-region on opener with active selection (command carrier + the TUI selection-gate fix). - Pair-aware backspace (delete both of a fresh empty pair). - RET inside a pair → closer on its own line (with language-aware / electric indent). - In-string/in-comment inhibit — needs a node-at-byte `pmacs.parse` binding and freshness guarantees. - Undo amalgamation (pair = one undo step); cross-peer undo grouping and chronological arbitration (would un-degrade custom optimistic pair chars and mixed source/daemon history generally). - Origin-pinned `buffer.after-edit` context. Today a legal intercept that switches window/buffer changes what all later callbacks see; pair.lua can guard its own cursor repair but cannot keep LSP and completion on the producing context without a hook-wide substrate. - Balance-aware quote handling (odd/even counting). - A per-buffer toggle (config-registry-blocked). ## Acceptance `tests/auto_pair_acceptance.rs` (dispatch-driven): - `(` at EOL → `()`, cursor between; mid-line before whitespace and before `)`; before a word char → no pair. - Skip: `(|)` + `)` → `()`, cursor after; nested; `"` at `"|"` exits. - Quotes pair under the predicate. - Per-language: `'` pairs in `.py`, not in `.rs`; scratch pairs the default set. - Set-entry parsing (R4): a malformed `"()x"` (and an overlong multibyte `"«»x"`) pairs nothing; a valid multibyte `"«»"` pairs and skips at byte-correct cursors. Ill-formed UTF-8 closers (R5) — truncated `"(\xC2x"`, overlong `"(\xC0\xAF"`, surrogate `"(\xED\xA0\x80"`, beyond-U+10FFFF `"(\xF5\x80\x80\x80"` — all pair nothing. Non-table containers (R5/R6): a string `default` and a non-table `pmacs.pair.sets` itself pair nothing without erroring; a junk language entry falls back to the default set. Malformed BUFFER bytes (R6): `(` typed immediately before a lone `0xFF` stays unpaired — junk is word-like, not EOL-like. - Source-buffer relevance (R5): `'` typed in Rust with a context-switching command landing in Python stays silent; the inverse Python→Rust route still reports "source context changed". - Non-typed provenance, with the callback actually exercised: production `FrontendEvent::Paste("(")` after a prior self-insert leaves a lone pasted opener; `buf:insert("(")` followed by explicit `pmacs.hook.run("buffer.after-edit")` also leaves it lone even when `this_command` was deliberately left as `buffer.self-insert`; `pmacs.command.invoke`d self-insert plus the same explicit hook run also has no record and no reaction. - Type-over: region + `(` (dispatch route) → region consumed, then the predicate decides; selection cleared. - Daemon undo grain pinned in the non-replica harness: `(` then undo → `(` alone; undo → empty; redo → `(`; redo → `()`; skip undo restores the duplicate. - Intercepts: rejected closer → opener stands, status; **relocated closer** → landed at the intercept's position, reported, cursor translated not teleported; rejected skip-delete → duplicate stays; **expanded/relocated skip-delete** → landed, reported, translate-and-clamp. The **source self-insert** gets separate cases: relocated opener and expanded/relocated type-over produce exactly the intercept's positional result, Q#AP9 reports/skips, and no unrelated closer is inserted; a source context switch (via a redefined `buffer.self-insert` — the only legal producer, see Q#AP9) likewise fails closed, in BOTH revision shapes (R4): skewed revisions report "source context changed"; equal revisions skip the fan-out entirely and fail closed silently. A relocated **non-pair** character draws no auto-pair report at all (R4), and a redefined self-insert that edits again after the insert (replacing the typed char, cursor unmoved) kills the record — no `[)` (R4). - Context-switching **reaction** intercept → pair cursor repair skipped, new context's text/cursor untouched by pair.lua. A probe callback registered after pair.lua observes the switched context, explicitly pinning (not concealing) the origin-context deferral; do not combine this case with the Q#AP7 first-didChange guarantee. - Signature help: fake-LSP `sighelp` mode — auto-trigger still fires with pairing active, and the **first didChange after `(` contains `()`** when the reaction preserves context (the Q#AP7 ordering observable). A preserving-context relocated closer instead asserts that the first didChange contains the complete effective text. - Hook fan-out: one fire per keystroke; the closer edit does not re-fire. - Typed-edit lifecycle: `take_typed_edit()` yields the exact codepoint/effective triple once during both dispatch and optimistic self-insert hooks; a second take (including a nested manual after-edit run) is nil. It is also nil before/after the fan-out, for paste, for standalone manual hook runs, and after a rejecting edit. Two frontends cannot see or consume each other's slot. Exact-record observation goes through the opt-in `_capture_records` facility; with it off (production), no consumed record is retained anywhere (R4). Classifier flips (in-crate): GPU `optimistic_insert_text` returns `None` for the nine pair chars (test updated alongside Enter's); TUI `classify_key`/orchestrator equivalents round-trip them (test updated). Exercise both `Modifiers::NONE` and the `SHIFT` shapes real keyboards use for `(){}"` so the test cannot pass only for synthetic unshifted punctuation. CRDT (`--features crdt`), **two replicas** (source + observer): - Dispatch route: round-tripped `(` → both replicas converge to `()`; daemon cursor between; `)` skip converges; **undo/redo**: two daemon undos restore empty on both replicas, then two daemon redos restore `(` and `()` in order on both. - Mixed history, dispatch route: source optimistically inserts `a`, then round-tripped `(` produces `a()`. On the TUI routing model the single-key optimistic undo removes `a` first (leaving `()`). From a fresh identical state, an always-dispatched `C-x u` removes the closer first (leaving `a(`). On the GPU routing model two daemon undos remove the pair but a further daemon undo cannot reach source-peer `a`. These are assertions of the named substrate limit, not frontend-equivalence claims. - Optimistic route (custom pair char added to the set): source ships the opener op; the hook's DaemonKey closer is queued **before** the opener's broadcast — the observer receives the causally dependent closer first and must still converge (pairing and skip both). **Undo**: pinned degraded behavior — the source mirror's undo removes the opener, leaving the closer.