pmacs/docs/generated-buffer-immutabili...

43 KiB
Raw Blame History

Generated-buffer immutability

PROPOSED — needs explicit user approval before implementation. DO NOT implement, DO NOT merge.

Revision 1 — scouted against canonical githubsucks/main @ ad41cf1, 2026-07-28. Every claim below about pmacs was executed, not read. The reproductions in §0 are transcripts of throwaway probes run in this worktree at ad41cf1 and deleted before the commit; the counts in §1 are whole greps with the arithmetic shown, never | head.

This closes the class-wide half of the invariant Buffer::set_generated_contents opened in terminal copy mode (#178) and that docs/agent-handoff.md §4 and COHERENCE.md §14 both record as unfinished: four writer mechanisms across five buffer families still pair an erroring intercept with bypass_intercept writes over a writable rope, and every one of them is emptied by undo.

Two things the arc turns out NOT to be, both discovered by measurement:

  • It is not "compile is the urgent one". compile.lua and the *search-results* panel rebind all seven undo chords to a no-op (compile.lua:219, builtin/commands/default.lua:855), so reaching them needs M-x. dired.lua and listview.lua rebind nothing, so a bare C-/ empties them. The cheap half is also the exposed half.
  • It is not a Lua-only change. Two of the four mechanisms write incrementally and cannot use the shipped primitive at all, and a buffer the shipped primitive has locked refuses bypass_intercept writes (§2.4, measured) — so partial adoption is impossible and a new Rust primitive is required.

0. The bug, reproduced

Buffer::undo (src/buffer.rs:1301) gates on ensure_writable() (src/buffer.rs:568) and nothing else. ensure_writable reads the Rust read_only field; it never consults the intercept chain. The pmacs.buffer.add_intercept(buf, function() error(name .. " is read-only") end) idiom therefore protects the edit path and leaves the history path wide open, while the owner's own bypass_intercept paint lands on the undo stack for undo to pop.

The user-reachable chain, verified end to end:

M-x buffer.undocmd { name = "buffer.undo" } (builtin/commands/default.lua:179) → ed.undo()EditorCore::undo (src/editor_core.rs:2575) → Buffer::undoensure_writable. The chords C-/ C-_ C-4 C-x u are bound globally (builtin/keymaps/default.lua:126-136) and the menu carries it too (builtin/menus/default.lua:141). No buffer-local rebinding removes the command, which is what compile.lua's own comment already admits ("command/menu undo stays dispatchable", compile.lua:236).

0.1 Measured transcripts

Every line below is probe output from ad41cf1.

listview panel, plain C-/ through dispatch_key — no rebinding exists, so this is the whole distance from a keystroke to an empty panel:

listview BEFORE   = "H\nrow-one\nrow-two"
listview after C-/ = ""

listview panel, ordinary edit — the intercept works, which is exactly why the idiom reads as safe:

listview ORDINARY EDIT = false | intercept rejected the edit:
  builtin/runtime/listview.lua:102: *probe-panel* is read-only

dired listing, one buffer.undo:

dired BEFORE  = "/tmp/.tmpEJlp9i:\n  -rw-r--r--  1 2026-07-28 17:42 alpha.txt\n  -rw-r--r--  1 2026-07-28 17:42 beta.txt"
dired AFTER 1 = ""

*shell-command*, M-x buffer.undo through the real minibuffer (M-x, typed buffer.undo, RET):

shell BEFORE = "$ printf ...\nDirectory: ...\n\none\ntwo\n\n[shell exited with code 0]\n"
shell after M-x buffer.undo
             = "$ printf ...\nDirectory: ...\n\none\ntwo\n\n[output desynced by external edit]\n"

Read that one carefully — it is the single most important measurement in this document. The Q#CM2 revision guard noticed and appended its desync marker. It did not prevent anything: the run's exit status is gone for good, and the buffer is still non-empty. Any acceptance criterion phrased as "the buffer is not empty" passes with the bug live. See §5, Stage 2 criterion 1.

Driven programmatically to the end, the same buffer empties completely:

shell AFTER 1 undo  = "... one\ntwo\n"          (exit marker gone)
shell AFTER 13 undos = ""

*search-results*:

search BEFORE = "Searching for: fn main\n\n"
search AFTER  = ""

0.2 The fix already in the tree, and what it proves

terminal.lua is the adopter and the precedent. render_snapshot (terminal.lua:320-337) calls pmacs.buffer.set_generated_contents, and the comment at :322-336 documents this exact defect in these exact terms. claim_snapshot (:339-396) keeps the erroring intercept and set_round_trip_input, and :351-366 states the layering that this framing must preserve at every adopter:

rope-level read-only protects the daemon copy, round-trip input protects the replica copy — and neither substitutes for the other.

A locked buffer measured at ad41cf1:

bypass write after lock            = false | buffer `*probe*` (id BufferId(4)) is read-only
M-x buffer.undo after lock leaves  = "header\n"

1. The census, with its arithmetic

1.1 bypass_intercept — 21 grep hits, 16 write sites

grep -rn bypass_intercept builtin returns 21 lines. Five of them are prose in comments, not calls:

file:line what it is
compile.lua:265 comment above ensure_slot's intercept
terminal.lua:304 comment in unique_snapshot_name
terminal.lua:324 comment in render_snapshot (the round-2 note)
dired.lua:478 comment above claim_handle
listview.lua:9 module header

21 5 = 16 actual write call sites, and the per-file arithmetic is 9 + 4 + 1 + 2 + 0 = 16:

file writes lines
compile.lua 9 319, 443, 454, 465, 506, 512, 642, 794, 798
builtin/commands/default.lua 4 827, 849, 1005, 1007
dired.lua 1 371
listview.lua 2 60, 61
terminal.lua 0 — (it adopted the primitive)

Two corrections to the counts this lane was briefed with. compile.lua has 9 write sites, not 10 — the tenth hit is the comment at :265. terminal.lua's two hits are both comments; it performs no bypass_intercept write at all, which is the correct state for an adopter and is worth stating because the raw grep count reads as though it still does.

1.2 add_intercept — 17 Lua sites, 6 production

grep -rn --include='*.lua' add_intercept . --exclude-dir=target returns 17 lines: 6 in builtin/, 11 under tests/fixtures/. One of the eleven (tests/fixtures/pmacs-mcp-prompts/init.lua:84) is a doc comment, so the fixture call count is 10; 6 + 10 = 16 calls across 17 lines. The six production sites:

site buffer(s) shape
terminal.lua:367 terminal copy snapshot blanket read-only — adopted
dired.lua:509 every dired buffer blanket read-only
listview.lua:101 every listview panel blanket read-only
compile.lua:266 *compilation*, *shell-command* blanket read-only
builtin/commands/default.lua:869 *search-results* blanket read-only
builtin/packages/repl/init.lua:187 REPL buffers filtering — §2.5

1.3 set_read_only — zero Lua callers, and no Lua binding

grep -rn set_read_only builtin tests returns 5 hits, all Rust test code (tests/folding_acceptance.rs:587, tests/vterm_stage1_acceptance.rs:139,175, tests/terminal_copy_mode_acceptance.rs:582,584). The stronger fact: the Lua binding table registers "add_intercept" and no "set_read_only" / "is_read_only" at all (src/lua_bindings/mod.rs:3409 is the only match in the neighbourhood). Lua cannot set read_only today. That matters for Q#GB7.

1.4 The classification, by writer mechanism

Class A — erroring intercept + bypass_intercept writes over a writable rope. This is the bug.

  1. terminal.lua — copy-mode snapshot. ADOPTED (:336). Fixed.

  2. dired.lua — every dired buffer. One write, in paint (:369-372): handle.buf:replace(0, handle.buf:len(), text, {bypass_intercept=true}). A whole-buffer replace already. Convertible with the shipped primitive.

  3. listview.lua*references*, *outline*, *lsp-help* (the three production listview.open callers, all in lsp.lua:2056, 2102, 2513). One writer, render (:50-62): delete-all then insert-all, which is a whole-buffer replace spelled in two ops. Convertible.

  4. compile.lua*compilation* and *shell-command*, both via ensure_slot (:258). Nine writes across five enclosing functions, and they are genuinely incremental:

    enclosing function line shape
    resync (:309) 319 append desync marker at end
    emit_text (:432) 443 append remainder at end
    emit_text 454 append "\n" at end
    emit_text 465 positional replace (CR overwrite)
    apply_events (:480) 506 targeted delete (erase-to-eol)
    apply_events 512 targeted delete (erase-line)
    emit_text_raw (:639) 642 append marker at end
    start_run (:746) 794 delete-all (run reset)
    start_run 798 insert header (run reset)

    NOT convertible. emit_text is a terminal emulator: it tracks slot.out_pos / slot.line_start / slot.parse_line_start as byte anchors, reads buf:slice(pos, len) between writes, and settles slot.expected_rev = buf:revision() afterwards. A whole-buffer replace destroys every one of those anchors.

  5. builtin/commands/default.lua — the independent *search-results* panel (ensure_search_panel, :857). Four writes across three enclosing functions:

    enclosing function line shape
    search_panel_resync (:821) 827 append desync marker
    search_panel_append (:844) 849 append match batch
    pmacs.project.search (:982) 1005 delete-all (query reset)
    pmacs.project.search 1007 insert header (query reset)

    NOT convertible, for the same reason at smaller scale: the append path carries p.next_row / p.expected_rev bookkeeping.

    Do not read ensure_slot as covering this panel. It serves *compilation* and *shell-command* only; *search-results* has its own intercept, its own round-trip mark, its own resync and its own writes, and compile.lua names it only inside the is_generated_buffer predicate (:216).

Class B — filtering intercept, deliberately partly editable.

  1. builtin/packages/repl/init.lua:187 — see §2.5. Shares the root cause, does not share the remedy. Out of this arc.

Class C — generated, but nothing ever claimed they were protected. Keying the inventory on bypass_intercept misses these entirely, because an unprotected buffer needs no bypass:

  1. *buffer-list*render_list (default.lua:387) writes with plain buf:delete(0, len) / buf:insert(0, body) (:403-404). No intercept, no round-trip mark. Whole-replace shape.
  2. *help*show_help_text (default.lua:1239), same plain delete-all + insert-all (:1245-1246). No intercept.
  3. *workers* — a Rust writer, workers_buffer::render (src/workers_buffer.rs:65), using Buffer::apply_edit (not the skip-intercepts path), delete-all + insert-all, then Buffer::mark_clean() (:95). Its fan-out is a fourth mechanism: queue_generated_buffer_edits + rebuild_generated_buffer_views (src/lua_bindings/mod.rs:7142-7145).

Class C is a different defect — nothing is defeated, because nothing was claimed. It is named here so the inventory is complete and so a future reviewer does not re-derive it; §4 keeps it out of this arc.

1.5 A correction to COHERENCE.md §14 (not edited here)

§14 states that "references, outline, buffer-list, and project-search all use" listview. Measured: pmacs.listview.open has three production callers, all in lsp.lua*references* (:2056), *outline* (:2102), *lsp-help* (:2513). *buffer-list* is hand-rolled in default.lua (render_list, :387) and *search-results* is the independent grep panel. Two of §14's four examples are wrong, and *lsp-help* is missing. COHERENCE.md is not this lane's file to edit; recorded here and in the PR body.


2. Ground truth (measured, not recalled)

2.1 What the shipped primitive is

Buffer::set_generated_contents (src/buffer.rs:545, doc comment :507-544): lift read_only, apply_edit_skip_intercepts a single whole-buffer EditOp::Replace, clear_history() (:559), re-assert read_only, return the Edit. The Lua binding (src/lua_bindings/mod.rs:3079-3095) fans that Edit out via notify_buffer_edit_to_windows (:1573) after dropping the registry borrow, because the fan-out re-enters the core.

clear_history clears whichever history the buffer has: the v0.1 undo/redo stacks, and in CRDT mode CrdtState::clear_undo_history (src/crdt.rs:507), which rebinds a fresh UndoManager to the same doc because loro exposes no clear.

2.2 Why history clearing is load-bearing

The doc comment's reason is retention, not tidiness: read_only guarantees the pushed entries can never be popped, so a periodically refreshed panel accumulates full rope clones nothing will ever release. CRDT mode has the identical retention inside loro's UndoManager.

2.3 Why a bare lock is not the answer

ensure_writable guards the bypass path too (apply_edit_skip_intercepts, src/buffer.rs:1055-1056). Locking a generated buffer without giving its owner a door refuses the refresh the buffer exists for. That is why the pairing is the primitive and why there is deliberately no Lua set_read_only today.

2.4 Partial adoption is impossible — measured

This is the fact that decides the design. Once set_generated_contents has locked a buffer, a subsequent owner write through bypass_intercept is refused:

bypass write after lock = false | buffer `*probe*` (id BufferId(4)) is read-only

So compile cannot convert its run reset (start_run:794,798) to the shipped primitive and keep bypass_intercept for streaming: the first append after the reset raises. The streaming owner needs a write path that itself carries authority. Reads are unaffected — buf:slice, buf:len and buf:revision all work on a locked buffer, which is what makes an op-level solution viable at all.

2.5 The REPL: same root cause, different remedy — measured

builtin/packages/repl/init.lua:187 installs function(op) return repl._intercept(h, op) end — a filtering policy (repl._intercept, :686-726): reject edits wholly inside the history/prompt region, truncate edits that straddle the boundary, pass edits in the input region. Its own writes use a _self_write flag (with_self_write, :111-116) rather than bypass_intercept, and it has real teardown (remove_intercept, :325-327).

Does it share the bug? Yes — measured — and I am not asserting the comfortable answer:

repl BEFORE                 = "line one\nline two\n> "
repl ordinary edit at pos 0 = false | REPL: history/prompt region is read-only
                                      (insert at 0; input region begins at 20)
repl AFTER 1 buffer.undo    = "line one\nline two\n"
repl bookkeeping after undo = _history_end=18 / _prompt_end=20  (rope is 18 bytes)

Undo deleted the prompt the intercept had just refused to let anyone touch, and left _prompt_end pointing two bytes past the end of the rope. The marks (_history_end_mark, _prompt_end_mark) adjust with the rope, but _blocks[i].start_byte are plain integers maintained by hand in drop_oldest_block (:643-656) and do not.

But the remedy cannot be rope-level read_only: the input region must accept ordinary user edits, which is the whole point of a REPL. The REPL needs either an undo that consults the intercept chain, or mark-anchored blocks. Both are different work. Q#GB8: out of this arc, named deferral, with its measurement recorded above so the next lane does not have to rediscover it.

2.6 A pre-existing defect in the shipped primitive — measured

notify_buffer_edit (src/editor_core.rs:1814) updates each window's TextView and overlays. It does not clamp win.cursor or win.view_top. Only rebuild_views_for (:1843) does, and its doc comment says so explicitly (:1841-1842). set_generated_contents's binding calls the former.

cursor before = 29, len = 30
cursor after set_generated_contents(G, 'x\n') = 29, len = 2
row0 after shrink = "x"          (paint did not crash)
cursor after C-p  = 29           (motion did not recover it)

A shrinking generated write leaves the window cursor 27 bytes past the end of the buffer, indefinitely. This ships today in terminal copy mode — refresh a snapshot to a shorter one with the point low in the buffer and this is the state — and every adopter inherits it. Q#GB6.

2.7 What buffer.after-edit does and does not do

buf:insert / buf:delete / buf:replace do not fire buffer.after-edit; the dispatcher and daemon do (src/editor.rs:1436,1984,2128,2163, src/daemon.rs:2976). compile.lua:714 already relies on this ("hook edits don't re-fire the hook"). Consequence for §3: a generated write does not run arbitrary Lua, so the fan-out is not a re-entrancy hazard — but a scoped primitive's callback body still is, because it is arbitrary owner Lua.


3. The primitive decision (Q#GB1)

The question this arc exists to answer: what write primitive do compile.lua and the search panel need?

3.1 Recommendation

Buffer::apply_generated_edit(op: EditOp) -> Result<Edit, BufferError> — one authorized op at a time — exposed to Lua as a new option key on the mutators that already exist:

buf:insert(pos, text,   { generated = true })
buf:delete(start, end_, { generated = true })
buf:replace(s, e, text, { generated = true })

Semantics, per call, entirely inside one with_registry_mut: lift read_onlyapply_edit_skip_intercepts(op)clear_history() → re-assert read_only → return the Edit. The binding then fans it out through the notify_buffer_edit_to_windows call it already makes (src/lua_bindings/mod.rs:1291, :1302, :1322), after the borrow has dropped.

Buffer::set_generated_contents(bytes) is reimplemented as apply_generated_edit(Replace { range: 0..len, bytes }). It keeps its name, its signature, its doc comment and its tests — it becomes the whole-buffer spelling of one primitive rather than a second primitive.

One sentence for why it wins: it is the only candidate in which the buffer is never observably unlocked, because the lift and the re-assert happen inside a single registry borrow with no Lua in between — so there is no flag to clear on an error path, no yield to defend against, and nothing for a reviewer to audit site by site.

3.2 Why the alternatives lose

A. append_generated_contents — provably insufficient. compile.lua does a positional replace at emit_text:465 (the CR overwrite that makes progress bars work) and two targeted deletes at apply_events:506,512 (erase-to-eol, erase-line). Append cannot express any of the three. Dead on the census.

B. Scoped with_generated_writes(buf, fn) — the pattern this project has already been burned by. It is the cheapest on history (one clear_history per scope instead of one per op) and that is its only real advantage. Against it:

  • The unlocked interval is the callback's whole duration, and the callback is arbitrary owner Lua. Drawn loosely around start_run (compile.lua:746), the scope spans pmacs.window.switch_buffer(buf) and pcall(pmacs.process.spawn, spec) — the buffer would be writable across a process spawn. Drawn tightly, compile needs four separate scopes (start_run's reset, resync, feed_bytes, finish_run), each needing its own audit for what the body reaches.
  • Correctness reduces to "a flag cleared on every exit." That is the exact shape docs/agent-handoff.md §5 and #155 record as a repeat offender, and the REPL already had to defend its own version of it: with_self_write (repl/init.lua:111-116) wraps in pcall specifically because "a single failed write would leave the bypass on for every subsequent user edit". Adding a second instance of a pattern the tree already documents as fragile is a poor trade for one saved clear_history per batch.
  • Yield is an error here, which helps but does not rescue it. A Lua callback that yields across the Rust boundary raises attempt to yield across C-call boundary (observed in this worktree while probing pmacs.dired.open), so a yielding body surfaces as Err — but the relock must still run on that path, which is the same obligation.
  • It is strictly harder to review: a per-site scope audit versus a mechanical option-key change at 16 call sites.

C. Standalone generated_edit(buf, op). Identical semantics to the recommendation, worse ergonomics: it re-implements the three-op argument parsing that buf:insert/delete/replace already own, and turns adoption from an option-key change into a rewrite of 16 call sites. Recommended only if the user objects to { generated = true } sitting beside { bypass_intercept = true } in the same options table.

D. Make Buffer::undo/redo consult the intercept chain. This would fix all six families at once, including the REPL, and it deserves an explicit rejection rather than silence. Against it: (i) there is no EditOp to hand the chain — v0.1 undo is a whole-rope swap (src/buffer.rs:1327-1328) and CRDT undo is materialize-and-replace (undo_crdt_mode, :1365), so the chain would have to be given a synthetic op it was never designed to see; (ii) it changes behaviour for every intercept in the tree, including the transforming ones (auto-pair, lean-input, the REPL's truncation) which have no business rewriting an undo; (iii) an erroring intercept becomes a new Lua-raise failure path out of EditorCore::undo, which today cannot fail that way. It may still be the right answer for the REPL specifically — recorded in Q#GB8's deferral, not adopted here.

3.3 The four questions the recommendation must answer

How many Edits are fanned out, and when? One per generated op, immediately, by the binding that already does it. compile.lua's emit_text fast path emits one insert for a whole output batch, so a typical feed_bytes produces one to three ops; a CR-heavy progress bar produces more. This is exactly today's fan-out count — the conversion changes authority, not cardinality.

Per-op or per-scope history clearing? Per op, and it is cheap by construction: because read_only is re-asserted immediately, at most one v0.1 undo entry can exist when the clear runs and the redo stack is always empty, so the v0.1 clear is O(1). In CRDT mode the clear rebinds a fresh UndoManager (CrdtState::clear_undo_history), and create_undo_manager (src/crdt.rs:154) is UndoManager::new(doc) plus set_max_undo_steps — a subscription registration, not a document copy, so it is O(1) in document size too. Measurement obligation, not a claim: Stage 2 must show a streaming compile run does not regress against the existing compile-mode timings in both configurations. If it does, the escape hatch is to suppress recording rather than clear it — recorded as a named deferral rather than designed speculatively.

CRDT-mode behaviour? Identical to set_generated_contents today. The Edit carries crdt_op when the buffer is CRDT-backed, and notify_buffer_edit_to_windows queues it via queue_daemon_origin_crdt_op (src/lua_bindings/mod.rs:1582) so replica mirrors import the owner's write. History clearing goes to loro's UndoManager. Nothing new.

How do the returned edits reach the fan-out without a live registry borrow? By construction, unchanged since #178: run_bypass_edit (src/lua_bindings/mod.rs:1445) closes its with_registry_mut before returning, and the mutator bindings call notify_buffer_edit_to_windows afterwards. apply_generated_edit slots into the same place apply_edit_skip_intercepts occupies now.


4. Decisions

Q#GB1 — The streaming primitive. Buffer::apply_generated_edit(op), exposed as { generated = true } on the three Lua mutators. set_generated_contents becomes its whole-buffer wrapper, keeping name, signature and tests. Rationale and rejected alternatives: §3.

Q#GB2 — generated is additive; bypass_intercept stays. Seven call sites outside builtin/ depend on bypass_intercept, including tests/folding_stage2_acceptance.rs:1296-1315, which pins that a bypass edit still triggers the Q#FD19 interactive unfold. Redefining the existing key would silently change that pinned seam. generated = true implies bypass; passing both is legal and generated wins (it is strictly stronger); passing generated on a buffer with no intercept is legal (Class C would use it if it ever adopts).

Q#GB3 — Routing is otherwise identical to bypass. A generated write goes through run_buffer_edit's bypass arm (src/lua_bindings/mod.rs:1368), including unfold_before_interactive_lua_edit. The unfold guard already requires InteractiveCommandOrigin::current() to be Some, which is false for the process.after-tick pump and true for M-x compile; folding a *compilation* buffer is possible, so changing this would be a silent behaviour change to a pinned seam for no reason this lane owns.

Q#GB4 — History cleared per op, with a measurement obligation. §3.3. Deferred optimization: suppress recording instead of clearing.

Q#GB5 — The lock-at-creation gap, and who closes it. A { generated = true } write locks the buffer after its first call, so between pmacs.buffer.create(name) and the owner's first generated write the rope is writable. dired.lua (claim_handlepaint), listview.lua (ensure_panelrender) and the search panel (ensure_search_panel → the header write in pmacs.project.search) all write synchronously in the same call, so the window is not observable. compile.lua's ensure_slot (:258-282) does not — it creates *compilation* and returns, leaving it empty and writable until start_run. Recommendation: ensure_slot ends with pmacs.buffer.set_generated_contents(slot.buf, ""), using the shipped primitive; no third surface is needed. Note the pre-existing hazard this inherits and does not create: ensure_slot is buffer_named(name) or create, so it can adopt a foreign buffer, which start_run:794's delete-all already clobbers today.

Q#GB6 — Clamp the window cursor on a generated write. §2.6 measures a shipped defect: a shrinking generated write leaves win.cursor past the end of the rope, and neither paint nor C-p recovers it. Recommendation: clamp win.cursor and win.view_top in EditorCore::notify_buffer_edit when the buffer shrank — a clamp, not a call to rebuild_views_for, because a rebuild is O(buffer length) and would run per streaming op. Recommended for Stage 1, because Stage 1's adopters refresh shrinking panels constantly and because it fixes terminal copy mode retroactively. Alternative if the user prefers a narrower Stage 1: its own lane, in which case Stage 1 must say so out loud rather than inherit it silently.

Q#GB7 — wdired needs an unlock, and Lua cannot express one. Dired Stage 3 (docs/dired-framing.md §5) makes a dired buffer editable by removing the read-only intercept and swapping the major mode. Once dired's rope is read_only, removing the intercept is no longer sufficient — and §1.3 measured that no Lua binding can clear read_only. Recommendation: name it, do not build it. A binding whose only consumer does not exist yet cannot be pinned against a real caller, and "asserting that a value was stored is not asserting that anything reads it" is a lesson this repo has already paid for. Stage 1 records it as a hard prerequisite on dired Stage 3's framing.

If the user rules the other way, the two shapes are: expose pmacs.buffer.set_read_only(buf, on) — which contradicts docs/agent-handoff.md §4's "there is deliberately no Lua set_read_only", and needs an explicit ruling rather than a quiet addition, though the objection behind that invariant ("it also refuses the owner's refresh") is answered once { generated = true } ships — or a one-way pmacs.buffer.unlock_generated(buf), strictly weaker because it can never lock anything.

Q#GB8 — The REPL is out of this arc. §2.5. Same root cause, different remedy, its own lane. Its measured exposure is recorded above so the next scout starts from evidence.

Q#GB9 — Class C is out of this arc. *buffer-list*, *help* and *workers* are generated but were never claimed to be protected, so nothing about them is defeated. Making them immutable is a product decision about COHERENCE.md §14's list and output-channel primitives, not a bug fix, and it should not ride a bug-fix arc. *workers* additionally writes from Rust with its own fan-out pair and already mark_cleans, so it is not a like-for-like conversion.

Q#GB10 — Mark generated buffers clean. set_generated_contents leaves is_modified = true (measured: modified=true after one write), so every adopter shows * in the mode line (src/editor.rs:3704) and in *buffer-list* (default.lua:395). workers_buffer::render calls Buffer::mark_clean() (:95) for exactly this reason. Recommendation: apply_generated_edit marks clean. This changes shipped set_generated_contents behaviour and therefore the terminal snapshot, so it belongs in Stage 2 alongside the reimplementation, not smuggled into Stage 1. Verified non-blocking: the flag drives only the mode-line indicator and the buffer-list column — grep finds no quit-time or kill-time prompt reading it.

Q#GB11 — Staging. §5.

Q#GB12 — The revision guard becomes near-dead, and three tests break. After Stage 2, an external edit to *compilation* or *search-results* is refused at the rope, so the Q#CM2 desync machinery (check_rev compile.lua:332, resync :309, search_panel_check_rev default.lua:832) can essentially no longer fire. Recommendation: keep it — it is cheap, and a future Rust-side writer could still mutate the buffer — but say so, and do not delete its tests. Three compile acceptance tests inject intruder edits through bypass_intercept (tests/compile_mode_acceptance.rs:1040, :1106, :1305) and will be refused after conversion; they must lift read_only Rust-side first, exactly as tests/terminal_copy_mode_acceptance.rs:582-584 already does. That is a concrete, verified integration cost of Stage 2, not a surprise to discover during implementation.


5. Staging

The proposed cut is endorsed, with two amendments. The argument for it is not the obvious one.

Stage 1 — generated-buffer-immutability-stage1

dired.lua and listview.lua adopt pmacs.buffer.set_generated_contents. No new primitive.

  • listview.lua:50-62render's delete-all + insert-all becomes one set_generated_contents(buf, body).
  • dired.lua:369-372paint's whole-buffer replace becomes one set_generated_contents(handle.buf, text).
  • Both keep their erroring intercept (named error, per the layering at terminal.lua:351-366) and both keep set_round_trip_input.
  • Plus Q#GB6's cursor clamp, if approved.

Why this cut, and why Stage 1 is not merely "the cheap half": it is the worse-exposure half. compile.lua:219 and builtin/commands/default.lua:855 rebind all seven undo chords to compile.undo-noop; dired.lua and listview.lua rebind nothinggrep -n 'C-/\|C-_\|C-x u\|undo' builtin/runtime/dired.lua builtin/runtime/listview.lua returns zero binding lines. Measured, a bare C-/ empties a listview panel and a dired listing. Stage 1 closes the only two families reachable without M-x.

Why the cut is safe under every candidate primitive: a whole-buffer replace is expressible in all of AC, and under the recommendation set_generated_contents keeps its name and signature as apply_generated_edit's wrapper. Stage 1 is therefore not rework under any Q#GB1 outcome — which is the decisive argument for cutting here rather than shipping one large PR.

The honest objection, and the answer. A reviewer could call Stage 1 churn: two call sites converted to a primitive Stage 2 then rewrites. Stage 2 rewrites the primitive's implementation, not its callers; the diff at dired.lua:371 and listview.lua:60-61 is written once.

Stage 2 — generated-buffer-immutability-stage2

Buffer::apply_generated_edit + the { generated = true } option + set_generated_contents reimplemented over it + Q#GB10's mark_clean + conversion of all 13 remaining write sites (compile.lua 9, builtin/commands/default.lua 4) + Q#GB5's ensure_slot lock + the three compile_mode_acceptance intruder tests updated per Q#GB12.

All the new Rust and all the review risk in one PR, which is the point of the cut.

Amendments to the briefed cut:

  1. Q#GB7 (wdired) is recorded in Stage 1, not built in it. Stage 1 makes dired's rope read-only, which creates an obligation for dired Stage 3 that does not exist today. Recording it is the deliverable; building an unlock binding with no caller is not.
  2. Q#GB10 (mark_clean) lands in Stage 2, not Stage 1, because it edits set_generated_contents itself and therefore changes the already-shipped terminal snapshot. Stage 1 stays a pure-Lua change (plus Q#GB6, if approved).

Where the REPL lands: neither stage. Q#GB8.


6. Acceptance, with the pre-image each criterion must fail against

M-x buffer.undo is the user-reachable trigger and needs no keymap. A criterion that only exercises the intercept, or only the chords, proves nothing — that is precisely what compile.lua's idiom already achieves and what this bug already defeats.

Stage 1

  1. C-/ cannot empty a listview panel. Driven by dispatch_key, not by a Lua call. Bite: on ad41cf1 this measured "H\nrow-one\nrow-two""".
  2. M-x buffer.undo cannot empty a listview panel, driven through the real minibuffer (M-x, type buffer.undo, RET) — not pmacs.command.invoke, which is the programmatic path. Bite: same empty result on the pre-image; and a chord-only fix passes 1 and fails this.
  3. C-/ and M-x buffer.undo cannot empty a dired listing. Bite: measured — one undo takes the listing to "".
  4. The owner's own refresh still works after the lockg on a listview panel and on a dired buffer re-renders new content. Bite: this is the criterion that falsifies the obvious wrong fix, not the pre-image: a naive set_read_only(true) at creation passes 13 and fails here, which is the failure mode src/buffer.rs:521-524 exists to prevent. Assert the new content appears, not that the call did not raise.
  5. An ordinary edit is still refused with the intercept's named message. Bite: deleting the intercept while keeping the rope lock passes 14 and fails this; the layering at terminal.lua:351-366 requires the named error to survive.
  6. set_round_trip_input is still set on both. Pinned ungated, via dispatch_idle_for reporting false while the panel is focused — the shape tests/terminal_copy_mode_acceptance.rs criterion 16 uses, which needs no CRDT. Bite: delete the set_round_trip_input call and 15 all still pass; only this fails. A daemon-side refusal does nothing for a replica's own mirror.
  7. A refresh reaches the window, not just the rope — pinned by painting a shrinking render (many rows → one) and asserting row 1 is empty, for each adopter. Bite: not redundant with #178's criterion 16d, because it catches a partial conversion — an adopter that keeps one bypass_intercept write beside the primitive — which 16d cannot see.
  8. Cursor clamp (only if Q#GB6 is approved). After a shrinking refresh, pmacs.editor.cursor() <= buf:len(), and C-p moves. Bite: measured on ad41cf1 — cursor 29, len 2, C-p leaves it at 29. This pin fails on main today, including for terminal copy mode, which is the evidence it is a real fix and not bookkeeping.
  9. Structural, riding alongside and never instead of 18: no bypass_intercept write remains in dired.lua or listview.lua. A structural comparison of two authorities does not catch a misrouted consumer; keep the consumer-level assertions.

Stage 2

  1. M-x buffer.undo cannot destroy *compilation* / *shell-command* / *search-results* content — and the criterion must assert the exit marker survives, not that the buffer is non-empty. Bite, and this is the whole point: on ad41cf1 the measured result of M-x buffer.undo on *shell-command* is [shell exited with code 0] replaced by [output desynced by external edit]. The buffer is still non-empty, so a "not empty" assertion passes with the bug live. The revision guard marks the corruption; it does not prevent it.
  2. A streaming run's incremental writes still land, including CR overwrite semantics (a progress-bar fixture) and erase-to-eol. Assert the produced content, not the absence of an error. Bite: the pre-image is the tempting half-conversion — reset via set_generated_contents, stream via bypass_intercept — which raises is read-only at the first append (measured, §2.4).
  3. The buffer is locked BETWEEN batches, not only after the run. Attempt an ordinary edit mid-run and require the refusal. Bite: a scope-shaped implementation that unlocks for a whole run passes 1 and 2 and fails this. A state predicate, not a geometric readout.
  4. History does not accumulate across a long run: buf:undo() returns false and the contents are unchanged after N batches.
  5. ensure_slot leaves *compilation* locked before any run (Q#GB5). Bite: create the slot without running anything, then attempt an ordinary edit; without the explicit lock it lands.
  6. mark_clean (Q#GB10): pmacs.describe.buffer(b).modified is false after a generated write. Bite: fails against ad41cf1, where it measures true.
  7. Both configurations — default and --features crdt — for criteria 14. CRDT must not be the only home of any of them; CI never enables the feature, and 264 tests are already dark for that reason.
  8. Structural, alongside: zero bypass_intercept writes remain in compile.lua and in default.lua's search panel (comments excepted; §1.1's arithmetic is the reference).
  9. The three intruder tests still assert what they were written to assert after being converted to a Rust-side read_only lift (Q#GB12), rather than being deleted or weakened.

7. Bets

  • That a per-op clear_history is not a throughput problem. Argued from create_undo_manager being UndoManager::new(doc) (O(1) in document size) and from at most one v0.1 entry existing per clear. Measured in Stage 2, not asserted here.
  • That converting compile's nine sites does not disturb its byte anchors. The conversion changes authority, not op shape, position or count — emit_text's slot.out_pos arithmetic is untouched. The bet is that nothing else in the module reads read_only indirectly; criteria 2 and 3 are what test it.
  • That { generated = true } sitting beside { bypass_intercept = true } is clearer than replacing it. Q#GB2.

8. Deferred (named)

  • The REPL's undo exposure (Q#GB8), with the §2.5 measurement.
  • Class C: *buffer-list*, *help*, *workers* (Q#GB9).
  • wdired's unlock (Q#GB7) — a hard prerequisite recorded onto dired Stage 3's framing.
  • Suppress-rather-than-clear history recording, if Stage 2's measurement says the per-op clear costs anything.
  • COHERENCE.md §14's listview consumer list is wrong (§1.5). Not edited here; carried in the PR body.

9. Coherence impact (COHERENCE.md §20)

Section served: §14 Coherent Workbench Primitives, and specifically its Output channel bullet, which is where this caveat is already recorded ("four writer mechanisms have not yet adopted it and remain emptiable"). This arc discharges that entry for Class A and replaces its "a streaming variant of the primitive that does not exist yet" with one that does. §14's list-primitive bullet is touched too: listview is called "the strongest coherence asset in the UI layer", and it is currently emptiable by one keystroke.

  • Priority 5 (finish the workbench convergence) is the priority this serves. It is a correctness debt inside an existing primitive rather than a new primitive, so it is wiring, not model.
  • §6 interaction islands — none added. No new keymap scope, no new dispatch shadow, no new precedence rung. The count stays at six. This arc deliberately does not add undo-chord rebindings anywhere; the measured point of the bug is that rebinding chords was never the fix.
  • §11 configuration registry — no new settings, no adoption change.
  • §2 golden journey — step 6 (compile) and the dired/browse steps are touched only in the sense that their buffers stop being destructible. No journey step opens or closes.
  • Background-work attribution — unchanged. compile.lua's process pump and the grep stream keep their existing ownership.
  • Protocol — no change. Nothing new crosses the wire; the fan-out reuses queue_daemon_origin_crdt_op.
  • §14 correction owed: the listview consumer list (§1.5) and, on merge, the handoff §4 table, whose four-row inventory is by bypass_intercept and therefore misses Class C.

10. Verification plan

Full gate suite per CLAUDE.md for each PR separately:

cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings   # own step
cargo test --lib
cargo test --lib --features crdt
cargo test --test <the touched acceptance suites>
cargo test --test m4_acceptance -- --skip basedpyright
PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu
git diff --check

Plus, per stage:

  • Stage 1cargo test --test dired_acceptance and --test listview_acceptance, plus --test terminal_copy_mode_acceptance if Q#GB6 lands, since the cursor clamp changes the shipped snapshot path.
  • Stage 2cargo test --test compile_mode_acceptance and --test compile_mode_crdt_acceptance, plus --test terminal_copy_mode_acceptance (the set_generated_contents reimplementation and mark_clean both reach it). The search panel has no suite of its owngrep -rln 'search-results' tests/ returns only compile_mode_acceptance.rs and m4_acceptance.rs, so Stage 2's search-panel criteria need a new home rather than an existing one to extend.
  • Do not gate any new test on #[cfg(feature = "crdt")] unless it genuinely needs CRDT. CI never enables the feature.
  • Judge the touched suites by elapsed time as well as verdict where they reach for a sibling binary (docs/agent-handoff.md §5).
  • Commit before gating: cargo fmt after a commit splits the worktree from the branch and git diff --check will not catch it.