204 KiB
Dired Stage 2 — marks and operations — framing
Revision 8 — 2026-07-28. Status: PROPOSED — NOT APPROVED. This document has never received a formal framing approval, and it needs one from the user before any implementation branch is cut. Its commits embody six rounds of findings; that is not the same as approval. Revision 7 resolved the cross-lane split with PR #186 — accepted, and not reopened here. Revision 8 answers review round 7: five blocking contract defects and two documentation fixes, all of one family. §0's round-7 section says what each decided.
Ground truth: re-scouted 2026-07-28 against canonical main @
6bee09d (Merge pull request #184 from levineuwirth/bottom-panel-stage2b).
Rev 4 was scouted at c8ec8f3 — which is dired Stage 1's own merge
commit — and main had moved 153 commits since. Rev 5 is that
re-scout: §0's round-4 section states exactly what moved, what it
invalidated, and what survived unchanged.
main then moved again mid-re-scout, to 0442d78 (#174,
fix-m4-sink-races), and this branch is merged up to it. #174 touches
tests/m4_acceptance.rs only, changes no source, and intersects this
document at exactly one point — a §11 deferral, which it confirms
rather than invalidates. Every scouting claim below therefore holds at
0442d78 as well as at 6bee09d; the 6bee09d anchor is kept as the
one the census was actually read against.
Continues docs/dired-framing.md (rev 7, approved; Stage 0 merged as
#162, Stage 1 as #165). That document's §6 and §7 carry the approved
shape of marks and operations; this one re-verifies every claim in them
against the tree, corrects what has drifted or was wrong, and adds what a
Stage 2 implementation needs and the parent did not decide: the
batch-execution contract, the confirmation surface, the staging cut, and
acceptance. Decisions continue the parent's scheme from Q#DR12.
The parent framing's ground truth (§2) was scouted at main @ e745068
and re-verified at 0827dd1. Stage 1 then changed three of the files
Stage 2 depends on most (src/fs.rs, src/async_runtime.rs,
src/lua_bindings/mod.rs), and #178/#179/#181/#182/#183/#184 have since
changed more, so §2 below supersedes the parent's line references for
everything Stage 2 touches. Line numbers throughout are given for
navigation only; every claim is anchored to a symbol, because line
numbers drift and this document has now watched them drift twice.
0. Revision history
Review round 7 (rev 7 → rev 8) — five contract defects, one family
The split with #186 was accepted and none of it is reopened. What
round 7 found instead is five places where this framing asserted a
guarantee it had derived from a mechanism whose implementation it never
read. Findings 1, 2 and 4 are literally that: reply order assumed to
be execution order, a refusal assumed to be inert, forget_uri assumed
to cover every writer. Findings 3 and 5 are the same shape one level
out — a naming rule assumed from how some buffers are named, and a
callback contract assumed from a helper signature that cannot express
it.
Every citation was re-verified here before being acted on. All seven hold. One is understated (F2); nothing in the review was wrong.
F1 — "settle order" is not filesystem mutation order → the ordering guarantee is WITHDRAWN, not engineered
Verified: AsyncRuntime::tick (src/async_runtime.rs:1003) is
while let Ok(env) = self.main.try_recv() — it drains the bus and
records arrival order. Mutations run on separate pool workers, so a
worker can finish, be descheduled before sending, and have a later
mutation's reply arrive first. TickOutcome.resources therefore cannot
carry execution order, and rev 7's justification for a single ordered
vector was false.
The cheaper question first, as directed: does reconciliation need to be order-dependent? Working it through:
- For independent mutations — disjoint paths, disjoint subtrees — reconciliation commutes. Order is irrelevant.
- For interdependent ones it genuinely is not, and no static rule
rescues it. Take rename
dir→newdirracing deletedir/child.txt. If the delete ran first on disk, correct reconciliation is delete-then- rename. If the rename ran first (so the delete was really ofnewdir/child.txt), correct reconciliation is rename-then-delete. A fixed rule such as "deletes before renames" gets the first case right and the second wrong: the kill misses, the rename then rebinds the buffer onto a path whose file is gone, and it survives pointing at nothing. So it is a real execution-order token or nothing — this was worked out rather than assumed, because the review asked which. - But no production path can produce the interdependent case. dired
serializes — one coroutine per batch, awaiting each op before
dispatching the next (§9).
apply_resource_opis synchronous on the main thread and never enters the drain at all. And §2 verified thatpmacs.fs.renameandpmacs.fs.removehave zero production callers besides tests. The hazard requires a third-party package to fire-and-forget two interdependent mutations. - And the substrate already tells it not to.
fs.lua:155-165states the contract: "If a package needs at-most-one-pending semantics for mutations, it should serialize on the package side (await each op before dispatching the next)." §9 already cites this as the reason dired serializes.
Decision: the reconciliation contract is order-independent by
construction, and promises nothing about two mutations in flight
simultaneously. Each settled mutation reconciles on its own; the
framing states explicitly that resources is in bus-arrival order,
which is not execution order, and does not build on the sequence.
Where interdependence exists, the caller owes serialization, and the
primitive's own documentation already says so.
Why the token was rejected. An execution-order token established while holding a mutation lock would serialize every filesystem mutation through one lock — undoing the concurrency the worker pool exists for — to close a hazard with no production reachability and a documented caller-side remedy. It is named as a deferral (§11) with its trigger stated: the first production caller that fire-and-forgets two interdependent mutations. That is a condition someone can check, not a vague "if it becomes a problem".
Consequences carried through: rev 7's one-vector-because-ordered argument is replaced (§5); §6's "via the same ordered sequence" is reworded; a new acceptance pins that two independent concurrent mutations reconcile correctly regardless of arrival order, and §13 states explicitly that there is no item for interdependent ordering, with the reason — a test cannot pin an order the mechanism does not establish. That is the same discipline item 20 already applies to H1's open interval.
F2 — a refused mid-edit kill is already destructive → preflight, and the review understates it by one
Verified in EditorCore::kill_buffer (src/editor_core.rs:4590), in
execution order: the last-buffer and unknown-id guards return early
(safe); then self.round_trip_buffers.remove(&buffer_id); then the
fallback is computed; then side windows showing the victim are
closed via remove_side_window; then every remaining window on the
victim is redirected, resetting text_view, cursor, selection,
overlays, view_top, goal_col; and only then does
BufferRegistry::remove (src/buffer_registry.rs:127) get the chance
to refuse with ConcurrentEdit. So "refused" is not "nothing happened".
Where rev 7 was worse than the review says: rev 7 did not merely
mis-describe the refusal, it wrote "the reconcile must treat a phase-1
failure as 'keep the buffer' and not run phase 2" — which reads as
though skipping phase 2 restores the buffer. It does not. Skipping
phase 2 only avoids firing on_removed for a buffer that still exists;
the window and round-trip damage is already done and nothing in the
described flow undoes it.
Decision: preflight editing_in_progress before phase 1. Not a
transactional kill. Three reasons:
- The preflight is sound, not merely cheap. Phase 1 is entirely
EditorCore, which holds no Lua handle (the layering fact C1 established), so nothing between the check andBufferRegistry::removecan re-enter Lua and start an edit. The window that would make a preflight racy does not exist on this path. - It matches #186. Its Q#RD2 already moves
editing_in_progressout of discovery and into validation, for the same reason — "Today aConcurrentEditrefusal fromBufferRegistry::removearrives after the file is gone." Both lanes then check the same predicate in the same phase. - A transactional kill would need a compensation path that snapshots and restores window layout, side-window collapse and round-trip membership — and compensation can itself fail. A check that makes the failure unreachable is strictly smaller than a rollback that handles it.
Acceptance 53 is split into three separately-asserted properties with individual bites (§13).
F3 — the name-provenance rule is false for relative opens → path-equivalence, stated
Verified: get_or_load_buffer (src/editor_core.rs:917) sets
display_name = path.display().to_string() — the path as given —
and normalizes only the stored file_path via set_buffer_path.
pmacs.buffer.from_file (src/lua_bindings/mod.rs:3112) does the same:
create_from_bytes(path.clone(), …) with the raw string. So opening
foo.rs relatively yields name foo.rs and path /abs/dir/foo.rs.
Rev 7's rule — update the name only when it equals the normalized
old path — leaves that buffer's name stale, and it is not user-renamed.
Decision: the rule is path-equivalence, not string equality. The
name is treated as path-derived — and therefore updated — iff the
stored name, parsed as a path and normalized by
normalize_buffer_path, equals the buffer's stored normalized old
path. In words: the name still denotes this file.
- A full-path name (Stage 1's and dired's shape) qualifies.
- A relative name
foo.rsqualifies, because it normalizes to the same absolute path. - A genuinely custom name —
notes,*scratch*, anything the user chose — does not, and is preserved. That direction is what stops the rule over-correcting, and §13 tests it.
One consequence stated rather than hidden: when the rule fires the
new name is written as the normalized new path, so a buffer opened
by a relative path acquires an absolute name after a rename. Preserving
the relative rendering would need a record of what base the name was
relative to, which no buffer carries. The effect is confined to the
statusline and buffer list. If that is judged wrong, the alternative is
an explicit provenance flag on Buffer — real state, every creation
site audited — and this framing does not think a corner of the
statusline earns that.
F4 — late publishDiagnostics can repopulate the old URI
Verified: the manager absorbs textDocument/publishDiagnostics
unconditionally (src/lsp.rs:2878); it is a server-initiated
notification with no request id, so pending_routes — which is what
forget_uri's purge and awaiter drain operate on — cannot see it. A
notification queued before didClose can therefore arrive after the
store is cleared and recreate the entry under the old URI.
This is the five-owner census in a new lens, and the review is right
that it is the same shape. The re-run is reported in full in §5, and
it changed the design four times over — most sharply: diag_store has
zero correlated writers, so the store forget_uri most needs to
protect is the one its route purge cannot help; and
DiagnosticStore.by_uri is keyed by URI alone, with no server
component, so a (sid, uri) tombstone would not match it.
Answering the question as asked: no, publishDiagnostics is not the
only one. It is the only uncorrelated notification writer — 1 of
1, and handle_notification has no default arm — but
pub fn mark_document_stale (src/lsp.rs:3108) is a second
uncorrelated writer that takes no LspServerId and creates URI keys
across three stores for every server at once. A first pass of this
revision checked only handle_notification and concluded it was the
only one. That was the wrong lens boundary, and it is this round's own
defect class occurring inside the round — recorded rather than
quietly corrected, because "I checked the mechanism" and "I checked the
right mechanism" are different claims.
Decision: a bounded per-server tombstone, checked at absorb.
forget_uri(sid, uri) records (sid, uri) as forgotten; an
uncorrelated absorb whose URI is tombstoned is dropped; the
tombstone is cleared when that URI is next did_opened, and wholesale
at the existing server-teardown sites (start_generation, forget).
Why a tombstone and not the obvious membership gate. The tempting
version is "absorb only if (sid, uri) is currently in documents",
which needs no new state at all. It would be a behaviour regression:
servers legitimately publish diagnostics for files the editor never
opened — a crate-wide push naming a dependency — and a membership gate
drops all of them. The tombstone drops only what this editor
explicitly forgot, which is the narrow claim that is actually true.
Residue stated: a server that legitimately publishes for the old path
after we renamed away from it is also dropped until the tombstone
clears. That is the conservative direction, and it is the same
information-loss trade forget_uri already makes for the correlated
stores.
F5 — confirm { prompt, on_yes } cannot express decline → three outcomes, because the substrate already has three
Verified: the copy contract says a declined overwrite continues,
copying the non-colliding entries, while the helper exposes only
on_yes. There is no negative continuation to hang that on.
And the substrate is richer than the helper: pmacs.minibuffer.read
takes a required on_accept and an optional on_cancel
(src/lua_bindings/mod.rs:13403-13417), and the session carries both
(src/minibuffer.rs:403-405), with cancel() returning the
on-cancel callback (:353). So a prompt has three outcomes, not
two: accept-affirmative, accept-negative, and cancel — and rev 7's
helper collapsed all three into "call on_yes or do nothing".
Decision: pmacs.minibuffer.confirm { prompt, on_yes, on_no, on_cancel }, with decline and cancel distinguishable, because the
copy flow needs exactly that distinction and the substrate already
draws it:
on_yes—y/yes, case-insensitive.on_no— any other accepted text, including an emptyRET. The fail-closed default from Q#DR15 is unchanged; it now has somewhere to go.on_cancel—C-g/Escape. If omitted, cancelling does nothing at all — it does not fall through toon_no.
That last point is the decision worth arguing with, so here is why. For
x and D the two are equivalent: declining and cancelling both mean
"delete nothing". For C they are not — declining means copy the
non-colliding entries (a partial action), cancelling means abandon
the batch. Defaulting cancel to on_no would make C-g perform a
partial copy, which is the one thing a user pressing C-g is trying to
avoid. So cancel is inert unless a caller explicitly opts in.
Acceptance 39 is extended to exercise the negative path and the cancel path separately — the current item cannot, because there was nothing to observe.
P2 fixes
- The Lua surface for
forget_uriis now named. Rev 7 told a Lua subscriber to call a manager-level method with no binding behind it. Lua reaches only explicit bindings; the nearest precedent ispmacs.lsp.forget(src/lua_bindings/mod.rs:10257), a four-line closure that calls through and maps the error withmlua::Error::external. §5 now specifiespmacs.lsp.forget_uri, its error contract, and a pin. - The ledger note is rewritten and its numbers re-measured. It claimed this branch touches only the framing file — false since rev 6, which is when the lane moved onto this PR — and quoted a line count from a previous revision. Counts in §16 and in the ledger are now measured at this revision.
The sweep for the same defect class
Findings 1, 2 and 4 share one shape: a guarantee assumed from a mechanism whose implementation was not read. Swept the rest of the framing for it. Three candidates, honestly reported:
run_all_must_succeed"collects errors and does not abort the fan-out" (§2). This had been carried from a project lesson rather than read. Read now (src/hook.rs:332-348): it is a plainfor cb in callbacksloop pushing intoerrorswith no early return, andproceediserrors.is_empty(). Claim holds.pmacs.window.commit_torefuses anawait(§2), cited to a test by name. Verified:commit_to_refuses_an_await_and_restoresexists attests/journey_acceptance.rs:746. Claim holds.- One genuine new finding, which the review did not raise: the
framing never says which
HookKindthe two new hooks register with.src/hook.rsdefines three —ShortCircuit,AllMustSucceed,Accumulate— and every declared hook picks one inbuiltin/hooks/default.lua(buffer.after-editisall-must-succeed;path.open-directoryandeditor.before-quitareshort-circuit). Rev 7's B1b asserted only that the mechanism exists. That gap matters: registeredshort-circuit, oneresource.renamedsubscriber returning falsey would stop the fan-out and silently prevent every later subscriber from reconciling — the precise trap the typed-edit chain lesson names, arriving through a door this framing left unlabelled. Decision: both hooks areall-must-succeed(Q#DR28), so a failing subscriber is reported and the rest still run, and no subscriber can claim the event.
Nothing else in the framing rests on an unread mechanism. Recorded as a count rather than a shrug: three candidates, two cleared by reading, one real.
Round 6 (rev 6 → rev 7) — cross-lane reconciliation with PR #186
Not a review of this document. PR #186
(docs/resource-op-delete-guard-framing.md, branch
resource-op-delete-guard, based on the same ad41cf1) frames a guard
for apply_resource_op's delete arm that refuses before touching
disk when any affected buffer is modified. Rev 6 said the opposite for
the same event: the file is deleted, the modified buffer survives
orphaned, and §11 named that orphaning as accepted residue. Two lanes,
two answers, same call site.
The settled split, recorded verbatim so the two lanes cannot diverge again:
#186 owns the urgent pre-filesystem refusal for synchronous
apply_resource_op. #171 later owns full post-delete lifecycle reconciliation, including the async race where a buffer becomes modified after dired dispatch. #171's revision 7 adopts the refusal and stops saying LSP intentionally deletes modified files.
Rev 7 adopts it. This was not re-litigated, and the changes below are what adopting it costs this document.
The census of orphaning claims, and its arithmetic
Built by grep over the whole file, then classified by reading each site's
enclosing passage rather than the matched line. The pattern set was
orphan, destroys/destroy/destroying unsaved, unsaved work,
keeps/keep modified, kept_modified, still deletes the file,
no longer destroys, accepted the refactor.
20 matched lines, resolving to 13 distinct passages, classified:
| Class | Count | What it said | Rev 7 |
|---|---|---|---|
| A — ownership | 4 | that this lane closes the LSP-delete data-loss defect | Reassigned to #186. §1, §10, §13 item 23, §16 |
| B — policy | 4 | that an LSP-authored delete intentionally removes the file and orphans the buffer, as accepted residue | Deleted. §6's asymmetric split, §6's residue paragraph, §11's deferral, Q#DR18 |
| C — ground truth | 3 | that the defect exists on main today |
Kept — it is still true — but attributed to #186 as the fixer. §0's round-4 list, §2, §6 |
| D — the async race | 2 | that a buffer modified after dispatch is orphaned | Kept, and narrowed — see below |
Class C is deliberately not deleted: main at ad41cf1 really does
destroy unsaved work on that arm, and a framing that stopped saying so
because another lane is fixing it would be describing a tree that does
not exist yet. What changed is the attribution, not the fact.
What changed in Q#DR18
The decision is rewritten, not withdrawn — reconcile_delete
survives, and the coordinator's instruction is explicit that the shared
seam is not cancelled. Three changes:
- The synchronous path refuses before disk. For
apply_resource_op, a modified buffer in the affected set means the delete fails, changing nothing on disk and nothing in the registry (#186 Q#RD1). Rev 6's "still deletes the file, because the user accepted the refactor" is gone. reconcile_delete's modified arm is no longer reached on that path — not removed from the seam.DeleteReconcile.kept_modifiedstays, because the asynchronous path still reaches it (Class D). Saying "the field is unreachable" would be wrong; saying "the sync caller can no longer produce it" is right.- The walk rule is adopted from #186, not reinvented. #186's Q#RD6
already specifies the shared query — scans every path-bound
buffer rather than first-match, normalizes once, and matches with
component-aware
Path::starts_withso/treedoes not match/tree-sibling. That is character-for-character the rule rev 6 wrote forreconcile_delete, arrived at independently. Whichever lands first owns the query and the other adopts it, which is #186's own Q#RD5 boundary wording.reconcile_renamethen uses the same query — #186's §6 explicitly parks "the rename side of prefix-aware, normalizing lookup" as dired Stage 2's.
What residue survives the refusal, and why
- The async race is real, and rev 7 narrows whose it is. #186's
refusal is synchronous and lives inside
apply_resource_op. Dired never goes throughapply_resource_op— it callspmacs.fs.remove, which dispatches a worker (§2), so the refusal cannot reach it at any strength. Dired's own pre-dispatch check closes the window up to dispatch; between dispatch andremove_blocking'sremove_filethe interval stays open, and a buffer modified in it is orphaned. Correcting rev 6's framing of this: rev 6 called it "the residue rev 3 scoped to the LSP path applies to dired too — one deferral, two paths". After #186 that is backwards. The LSP path has no residue, because it refuses. It is one deferral, one path, and the path is dired's — it always was, and rev 6 obscured it by bundling. - The full post-delete lifecycle stays this lane's, and #186 hands
it over explicitly rather than by omission. Its Q#RD8 parks "the
window/last-buffer defects" and states the trap rev 6's R4 found
independently:
kill_bufferandremove_buffer_and_fireclean disjoint sets, so neither is a superset and "just callkill_buffer" silently regresses four cleanups. Its Q#RD5 then deliberately keeps reconciliation exact-path — leaving descendant buffers orphaned-and-clean after a recursive delete — precisely because widening it "would promote mode (d)'s dangling-window and last-buffer defects from an exact-path defect to a tree-wide one". That is this lane's Q#DR27. #186 narrows on purpose so that #171 can widen safely once the lifecycle is correct; the two decisions fit, and neither is complete alone.
The LSP failure-handling claim — checked, and not inherited
The coordinator flagged that #186 mis-assigns Abort to edits
containing resource operations and warned this document might carry the
same error. It does not. grep over this file for
failureHandling|failure handling|Abort|transactional returns 10
lines, none of which is a claim about the LSP specification — every
one is about hook fan-out or dired's own batch semantics ("a per-entry
failure does not abort the batch"). Rev 6 asserted nothing about
WorkspaceEdit recovery.
The one adjacent claim was §6's "refusing mid-edit leaves a half-applied refactor", offered as the reason the LSP arm should delete anyway. That was a claim about consequence rather than about the protocol — but it was the load-bearing support for Class B, and it goes with it.
Verified against the LSP 3.18 specification so the record is accurate for both lanes:
abort— "Applying the workspace change is simply aborted if one of the changes provided fails. All operations executed before the failing operation stay executed." No mention of resource operations.transactional— "All operations are executed transactionally. That means they either all succeed or no changes at all are applied to the workspace." This does cover resource operations.textOnlyTransactional— "If the workspace edit contains only textual file changes they are executed transactionally. If resource changes (create, rename or delete file) are part of the change the failure handling strategy is abort." This is the only kind that degrades for resource ops, and it is the sentence that gets misattributed toabort.undo— "The client tries to undo the operations already executed. But there is no guarantee that this is succeeding."- The
failureHandlingcapability is described only as "The failure handling strategy of a client if applying the workspace edit fails." The specification states no default for a client that does not advertise it.
So "pmacs advertises none, therefore Abort semantics apply by
omission" does not follow, and neither does "the protocol declines to
promise transactionality for resource-op edits" — transactional
promises exactly that. Recorded here rather than only in a review
comment because this document's §6 will sit next to #186's §1.7 in the
same arc, and the corrected reading should be findable from either.
One supporting citation in #186 that has gone stale
Not a disagreement with the split, and not grounds to revisit it —
#186's Q#RD5 justifies taking the delete side now partly by citing "the
ledger's own assessment of that lane: PR #171 is OPEN, STALE, DO NOT
MERGE AS-IS, 153 commits behind at the last snapshot, under re-scout."
That was accurate when written and is no longer: #171 completed the
re-scout, is integrated to ad41cf1, and is at revision 7. The
conclusion stands on urgency alone — #186 fixes live data loss and
this lane is not implementable until its framing is approved — so
nothing about the split changes. Recorded so #186's next revision can
drop a citation that would otherwise read as current.
Review round 5 (rev 5 → rev 6) — six findings, four P1, none approved
Round 5's theme is one theme, not six: rev 5 changed the slice split and the ownership of a decision, and the prose did not follow. Four of the six findings are that same defect in different places. Every cited line was verified against the tree before being acted on; all six hold, and two of them are understated — see R2 and R4.
- R1 (P1) — rev 5 was superseded by its own ledger entry, and by a
PR body older still. Verified:
docs/active-work.md:507records that Q#DR25 has moved out of this lane — dired'sbypass_intercept-over-a-writable-rope paint turned out to be a class bug, the same idiom appearing in listview,compile.luaand the search/grep panel, with zero Lua callers anywhere settingread_only— and that a separate lane now owns it (branchgenerated-buffer-immutability, worktree../pmacs-generated-immutability, framing in progress). Meanwhile rev 5 still called the adoption a fix "Stage 2 may not skip" (§1) and assigned it to 2b (§10), and the GitHub PR body still described revision 1's two-slice plan againstc8ec8f3. Three sources, three different stories. Rev 6 reconciles all three: §1 and §3.1 now defer to the lane, §10's table drops the row, Q#DR25 is restated as a deferral in §11 and §15, and the PR body is rewritten. Dired's adoption is not cancelled — it is owned elsewhere, and this document now says where rather than deleting the problem. Correcting one thing rev 5 got wrong in the other direction: rev 5's §0.5 claimed Q#DR25 "closes dired's quarter of that gap". That was already arguable, and the class-bug finding settles it — dired was never a quarter of anything; it was one instance of one idiom. - R2 (P1) — the acceptance allocation contradicted the code split,
and the review understates it by one. Verified: items 23–24 test
apply_resource_op's delete arm and fire-and-forgetpmacs.fs.remove— both pure 2a substrate — yet sat under a**2b** — deletion policyheader and inside 2b's1–24table range. Conversely item 33 asserts an open dired buffer follows a directory rename, which needs adired.luasubscriber, while §10 and §16 both define 2a as containing no dired code at all. Rev 6 moves 23–24 into 2a and moves 33 into 2b, and justifies the direction rather than leaving both readings open (§13). The review asked for a choice on 33 and the choice is 2b, because 2a's entire review rationale — §10's "with no dired surface at all" and §16's "its diff contains no dired code" — is what makes it reviewable as substrate rather than as a dired feature, and admitting one dired subscriber to satisfy one acceptance item would spend that. The cost is stated: between 2a and 2b a directory rename leaves dired handles stale, which is exactly the status quo and so is not a regression. The undercount: moving 33 out leaves 2a shippingresource.renamedwith no acceptance on the hook itself, so rev 6 adds item 50 pinning the hook's own contract (fires once per successful rename, with normalized absolute paths), which is what 2b's subscriber attaches to. - R3 (P1) —
TickOutcomecould not carry what §6 asked of it. Verified: the struct as specified heldsettledandrenamesonly, while §6 required deletion to emerge "via the sameTickOutcome". As written_tickcould not learn which settledFsRemoveto reconcile or which path to pass toresource.deleted. Rev 6 replaces the two ad-hoc vectors with one orderedVec<ResourceOp>and makesPendingJobcarry a singleOption<ResourceOp>rather than rev 5'srename_paths(§5). Ordering is the reason it is one vector and not two: a directory rename and a delete beneath it can settle in the same tick, and reconciling them out of order reconciles the wrong path. A single enum field also refuses the impossible both-Somestate, the argumentResolvedTarget's own doc makes atsrc/editor_core.rs:100-102. - R4 (P1) —
reconcile_deletestopped short of the real removal lifecycle, and the substrate is worse than the review says. The review is right that removal is two phases —EditorCore::kill_buffer(src/editor_core.rs:4590) for window and side-window cleanup, andafter_buffer_removed(src/lua_bindings/mod.rs:1602) for keymaps, buffer-local config, folds andon_removedcallbacks. What it does not say is that no existing Rust path composes them, and the oneapply_resource_opuses is the incomplete one. Verified:pmacs.buffer.kill(install_buffer_kill,mod.rs:5476-5491) does compose both, and its doc comment says it is late-bound precisely because it "needs anEditorCorehandle to redirect any windows showing the doomed buffer before removal". Butapply_resource_op's delete arm callsremove_buffer_and_fire(mod.rs:1592) =registry.remove+after_buffer_removed, with no window cleanup — andBufferRegistry::removetouches onlybuffersandorder. So an LSP-authored delete leaves any window displaying that buffer pointing at a removed id, which is a third defect on that arm that rev 5 never named, alongside the missing dirty check and the first-match lookup. §6 now specifies both phases, the failure modes (ConcurrentEditon a mid-edit buffer, and the refusal to kill the last remaining buffer), and acceptance items 51–53. - R5 (P2) — the "every consumer" claim was false for Lean. Verified
the contradiction: §1 listed
lean.lua's progress table among the consumers reconciliation reaches, while §11 correctly said Stage 2 does not write that subscriber. The deferral was right and the summary was wrong, which is the direction rev 6 fixes: Stage 2 supplies the hook; Lean's URI-keyed state stays stale until its owner adopts it. Owner 6 remains in §5's census — it is still evidence thatforget_uricannot be complete — it is just no longer claimed as fixed. - R6 (P2) — pre-three-slice text in the file inventory. Verified
both halves: §7 requires
builtin/runtime/minibuffer.luato joinsrc/editor.rs's explicitinclude_str!load sequence (which is a real edit tosrc/editor.rs— the sequence is ~30 entries atsrc/editor.rs:395-660), contradicting §16's "2b isdired.luaplus one killring binding"; and+andCwere still tagged 2b in §1's table though §10 puts them in 2c.
The sweep the review asked for caught four more instances of the same defect, none of them cited:
| Where | Said | Should say |
|---|---|---|
| §4, the set-based class list | "C (copy, 2b)" |
2c |
| §7, the confirmation surface | "x, D, and (in 2b) a recursive delete and an overwriting copy" |
2c |
§8, d/x |
"reported as such in 2a; 2b's remove_dir_all" |
wrong twice — x's report is a 2b surface, and remove_dir_all is 2c |
| §8, the op sections | "+ (create directory, 2b)" and "C (copy, 2b)" |
2c |
The §8 line is the clearest survivor of the two-slice era: under rev 1's plan "2a" was the mark layer and "2b" was the primitives, so a sentence written then reads one slice off in both halves after the three-way cut. All are corrected. The three historical round sections keep their original labels, where "2b" means the old primitives slice — flagged in the note at the head of round 1 rather than retconned.
Re-scout round 4 (rev 4 → rev 5) — c8ec8f3 → 6bee09d, 153 commits
No reviewer produced these; a re-scout did. Rev 4's design survives, but
one new dependency changes what Stage 2 must build, one changes the
shape of a mechanism rev 4 described, and seven of rev 4's own claims
about pmacs were wrong — which is the failure mode this arc has been
burned by before (a framing that verifies its external facts and gets
its internal ones wrong). Every claim below was read on the tree at
6bee09d, not inferred.
What arrived that Stage 2 must now answer
- N1 (new scope, blocking the mark layer) — #178 landed
Buffer::set_generated_contents, and dired has not adopted it.src/buffer.rs:545is now the authorized write path for a generated buffer: liftread_only, one whole-bufferReplaceskipping intercepts,clear_history, re-assertread_only, and return theEdit.docs/agent-handoff.md§4 namesbuiltin/runtime/dired.lua:371as one of four writer mechanisms that have not adopted it, andCOHERENCE.md§14 records the same. Dired still pairs an erroring intercept (dired.lua:509-511) with abypass_interceptwhole-buffer replace (paint,dired.lua:369-372) over a still-writable rope, soM-x buffer.undoempties a dired listing today — the command exists (builtin/commands/default.lua:179), needs no keybinding, andBuffer::undoreaches the rope throughensure_writable(src/buffer.rs:568-577) without ever consulting the intercept chain. Stage 2 writes that buffer on every mark, every unmark, every toggle, and after every batch, so it multiplies the exposure rather than inheriting it quietly. Rev 5 adopted the primitive as Q#DR25. Round 5 withdrew that — the same idiom turned out to be inlistview.lua,compile.luaand the search/grep panel, so it is a class bug with its own lane (R1). The re-scout finding stands; only its owner changed. See §3.1. - N2 (shape change) — #182 (Journey Stage 1a) demoted dired to a
replaceable slot, and rewrote the function Stage 2's operations run
inside.
resolve_target_buffergained aResolvedTarget::Directoryarm ahead of the load (src/editor_core.rs:964-970);EditorState::openbecame a caller rather than a parallel implementation (src/editor.rs:953-959); and which surface handles a directory is now thepath.open-directoryhook chain with dired as a replaceable fallback slot,pmacs.path.directory_handler(dired.lua:736-738, registered viapmacs.path.set_directory_handler,src/lua_bindings/mod.rs:3679) — deliberately not a hook subscriber, becauseHookRegistry::addonly appends and builtins load beforeinit.lua, so a dired subscription would always claim before any user listener. The consequence for Stage 2 is inopen_directory, which gainedopts.destand acommit()closure run underpmacs.window.commit_to(dired.lua:656-707), and that scope refuses anawaitinside it (Q#JR14b). §9's serialize-and-await batch therefore has a constraint rev 4 could not have known about. - N3 (a ratchet Stage 2 must not break) —
tests/journey_acceptance.rs. 24#[test]s, 0 ignored, 0 feature-gated, 2 gated#[cfg(target_os = "linux")]. Its module doc states the rule verbatim at:12: "This file is a ratchet: stages add rows, none removes them." Seven of its tests assert on dired directly, and its step-3 rows pin that a directory launch produces a*dired:<canon>*buffer. The ratchet is split across two files: #183 put the GPU journey row intests/gpu_invocation_acceptance.rs(public_gpu_directory_target_reaches_dired_and_leaves_the_daemon_usable), not injourney_acceptance.rs. Both are gates now (§14). (Correcting the re-scout brief: #183 did not extendjourney_acceptance.rs—git diff c2d56ff 7fd646d -- tests/journey_acceptance.rsis empty.) - N4 (a fan-out lesson Stage 2's two new hooks inherit) — #179/#181
landed the typed-edit consumer chain. Dired participates in
neither the chain nor
buffer.after-edit(grep ofdired.luafortyped_edit|after-edit: 0), andset_generated_contentsfires no hook — its Lua binding routes only throughnotify_buffer_edit_to_windows(src/lua_bindings/mod.rs:3092), which runs no Lua. So N1 does not drag dired into the chain. Butresource.renamed/resource.deletedare new fan-outs and inherit the chain's three hard-won lessons: a consumer cannot both edit and let a later consumer act meaningfully (the record is a copy taken before any consumer ran);buffer.after-editfan-outs nest; and the thing that counts fan-outs must be unskippable. §5 states which of these bind the new hooks and which do not.
Rev 4 claims that are wrong about pmacs itself
- W1 (load-bearing) — §5 step 2 names one function and cites a
different one's line. Rev 4: "
drain_external_cancelled(:1596) is the existing sweep." These are two distinct functions:LspManager::drain_external_cancelled(src/lsp.rs:1561-1576) is the server-scoped, unconditional drain — settle every awaiter forsidcancelled and drop itspending_externalentries; that is the right precedent.LspManager::drain_cancelled_externals(src/lsp.rs:1596-1645) is a per-tick, per-awaiter cancellation and timeout sweep that removes only awaiters whoseCancellationTokenwas flipped or which outlivedrequest_timeout. An implementer following rev 4's line number reaches the second: a rename flips no token, so the drain half offorget_uriwould be a silent no-op and a coroutine awaiting a request against the old URI hangs forever — precisely the failure step 2 exists to prevent. Corrected in §5 and Q#DR23. - W2 — there is no
fn restart. Rev 4 modelsforget_urion "the server-scoped teardown that already exists (restart,:1316-1331)". The teardown isLspManager::start_generation(src/lsp.rs:1307-1345); its route purge is at:1324, which is the one number rev 4 got right. And there is a second precedent rev 4 never names:LspManager::forget(src/lsp.rs:3015-3042), which does the same three things (pending_routes.retain,drain_external_cancelled,documents.retain) plusstatus_tracker.forgetandproject_servers. Rev 4's genuinely surprising observation survives and now applies twice: neither server-scoped teardown clears the fourteen result stores. - W3 — the route inventory is off, and the gap is the part an
implementer trips on.
ResponseRoute(src/lsp.rs:835-859) has 15 variants, of which 14 carry auri; the fifteenth isWorkspaceSymbol { query }. There are 16 insert sites, all inrequest_*methods spanningsrc/lsp.rs:1683-2132— and 15 of the 16 insert a URI-bearing route. Rev 4's enumeration of fifteen was therefore correct as a list of URI-bearing inserts and wrong as a claim about how many insert sites exist. The arithmetic matters concretely: a purge predicate written as "retain unless the route's uri equals the old one" has to say something about a variant that has nourifield at all, andrequest_workspace_symbol(src/lsp.rs:1877-1891) is that variant. Stated in §5. - W4 — the Lua-side blast radius is ~3× what rev 4 said. F1 put
rec.uriat "~20 sites". The real figure on this tree is 57 lines inbuiltin/runtime/lsp.lua(grep -c "rec\.uri"= 57, one per line). Rev 4's design is unaffected — the point of cachingrec.uriin the attachment record is that one rebind reaches all of them — but the magnitude was understated and the arc has a standing rule against sizing anything from a partial count. - W5 — the path-owner census has grown to SIX. Rev 4's five owners
all still hold (re-verified individually in §5). A sixth arrived
with the Lean 4 stages:
lean.lua'sM.file_progress(builtin/runtime/lean.lua:646, written at:651), a URI-keyed Lua module table populated from the$/lean/fileProgressnotification and, by its own comment, read by Stage 5's goal view. It lives in no Rust store, soforget_uricannot reach it; it is reachable from aresource.renamedLua subscriber, which is an argument for the hook rev 4 already proposed. Two weaker, persisted owners are also now named rather than fixed:saveplace.lua's path-keyed places file andrecentf.lua's MRU list (§11). - W6 — the ledger note names a merged PR. §16 said
docs/active-work.mdanddocs/agent-handoff.mdare held by open PR #169. #169 merged as74301d1. The current holder is PR #185 (docs-landed-state-184). The instruction is unchanged; the number was stale. - W7 — the C1 seam moved, and it has two namesakes.
pmacs._async._tickis atsrc/lua_bindings/mod.rs:7104-7115, insideinstall_async, not:6911-6924. Its closure ismove |lua, ()|, solua.app_data_ref::<SharedCore>()is reachable and C1's decision stands. Worth naming becausemod.rsdefines three_tickbindings in different classes —install_async:7104(pmacs._async._tick),install_process:8469(pmacs.process._tick), andinstall_lsp:10271(pmacs.lsp._tick) — and only the first hasluain scope.
What the re-scout confirmed unchanged
Listed because "still true at 6bee09d" is the load-bearing half of a
re-scout, and because each of these was read again rather than carried
forward: the whole pmacs.fs surface and its five *_blocking
implementations, at identical line numbers (§2); the absence of
mkdir, copy, and any recursive remove (the four create_dir hits in
src/fs.rs are all inside #[cfg(test)] bodies); PendingJob's seven
fields and JobKind's 12 variants; tick's signature
(pub fn tick(&self) -> Vec<JobId>, src/async_runtime.rs:1003) and
the Sleep | FsUnit collapse at :1046; dispatch_fs_rename moving
both paths into the closure (:871); LspManager's fourteen
URI-bearing store families (src/lsp.rs:753-793), exactly the fourteen
rev 4 tabulated, in the same order; documents at :810 and
pending_external at :804 with the "drained-cancelled wherever
pending_routes is purged" contract at :801-803;
DiagnosticView.uri private, set once at construction, its doc still
reading "M5 may add re-rooting if a buffer is renamed"
(src/diag.rs:456-457); View still carrying overlay_identity and
clone_for_split and no rename_resource and no downcast
(src/view.rs:300, :310, :285); the overlay-disposal sweep over
core.windows.values_mut() (moved to src/lua_bindings/mod.rs:2044-2046);
_attach_view taking active_window_mut() and erroring
(src/lua_bindings/diag.rs:211-232); apply_resource_op's rename arm
still doing a raw-path find_by_path first-match rebind
(src/lua_bindings/mod.rs:3306) and its delete arm still killing
through remove_buffer_and_fire with no modified check
(:3339-3341) — so an LSP-authored delete still destroys unsaved work
on main today (still true at ad41cf1; PR #186 is what fixes it,
not this lane — see round 6); apply_workspace_edit still capturing origin as a
string and restoring with find_or_open (builtin/runtime/lsp.lua:1338,
:1352), its own comment still conceding the path "may have just been
renamed or deleted"; resolve_target_buffer's NotFound arm still
materializing a phantom path-backed buffer (src/editor_core.rs:980-989);
Buffer::set_name existing with the doc "Used by save-as and rename
operations" (src/buffer.rs:453) and no pmacs.buffer.set_name
Lua binding; killring.lua's push_entry still a local function
(:93) and copy() still region-required (:184-194); no
builtin/runtime/minibuffer.lua and no y_or_n/yes_or_no/
yes-or-no anywhere in src/ or builtin/; pmacs.fs.rename still
having zero production callers (only tests/fixtures/pmacs-dired/init.lua:1200,1210,
tests/m8_1_acceptance.rs:359, and tests/m8_3_acceptance.rs:182-184's
monkeypatch) — and the same is now separately confirmed for
pmacs.fs.remove (only tests/m8_1_acceptance.rs:438,439,472); and
every one of dired's own Stage 1 seams at unchanged line numbers
(render_entry:334, paint:369-372, entry_at_cursor:381-385,
seat_cursor:405-416, the handle shape :521-528). The stale *errors*
comment rev 4 offered to fix in passing is still there, having moved
from :665 to :718.
Two dired references in rev 4 did drift and are corrected in §2: the
mode-scoped bind helper is at dired.lua:933-935 with the bindings at
:937-946 (was :866-868), and pmacs.dired._layout is at :954-961
(was :887-895).
Review round 1 (rev 1 → rev 2)
The three round sections below are a historical record and keep the line numbers they were written with (
c8ec8f3/c93f9ee). Their findings all still hold — each was re-verified at6bee09d— but for a current reference use §2 and §5, which carry the re-scouted positions. Where a round-section claim was found to be wrong about pmacs, §0's round-4 list says so and the live section is corrected; the round text is left as written rather than retconned.
Every checkable claim in the review was verified against c8ec8f3
before being acted on; all seven held.
- F1 (blocking) — the rename contract reached only one path owner.
Rev 1 rebound
Buffer.file_pathand stopped. Verified:Buffer::set_namedocuments itself as "used by save-as and rename operations" (src/buffer.rs:452) andset_buffer_pathnever calls it, so the statusline and buffer list keep the old filename;rec.uriis cached per attachment inlsp.luaand read at ~20 sites (didChange:418, semantic tokens:776-790, diagnostics:953, signature:1016, definition:1648, references:1757); dired's own buffers are pathless so no buffer-keyed rebind can ever reach them; and the workspace-edit path capturesorigin = active_buffer_path()(lsp.lua:1252) then callsfind_or_open(origin)(:1266), which for a since-renamed path does not fail gracefully —resolve_target_bufferturnsNotFoundinto "an empty path-backed buffer" (editor_core.rs:876-878), i.e. it materializes a phantom buffer at the obsolete path and selects it. Rev 2 replaces the rebind with a shared reconciliation transaction plus aresource.renamedhook (§5). - F2 (blocking) — deletion of visited paths had no policy. Correct, and rev 1 simply did not consider it. §6 is new and decides all four cases; the modified-buffer case blocks rather than orphans.
- F3 (high) — the key table silently changed approved scope. Verified
against the parent's table (
dired-framing.md:963-966): it listsw(copy filename to the kill ring) in Stage 2 and contains noM. Rev 1 droppedwand addedMwithout saying so. Worse, the parent states at:543-546that the fixture "rejects symlink perms edits at intercept time ... that decision carries over unchanged", and rev 1'sMchose to chmod the symlink's target with a warning — a direct contradiction of approved text.wis restored (§8, Q#DR20) andMis now an explicit new decision that refuses symlinks (Q#DR19). - F4 (high) — Q#DR13 contradicted the
Rcontract. It did: "every operation targets the marked set ... exceptx", thenRpoint-only. Q#DR13 is narrowed to name the set-based operations explicitly, withRandwas point-based by construction (§4), plus acceptance forRwith unrelated marks present. - F5 (high) — load-bearing decisions lacked falsifying acceptance. All five additions taken; see §13 items 14, 27, 39, 40, and 41 (renumbered again in rev 4).
- F6 (medium) —
take_settled_renames()was underspecified. Correct: a no-argument drain either needs a second queue or a scan of every settled entry, neither of which rev 1 named. Rev 2 takes the reviewer's preferred shape —tickreturns a structured outcome so settle identity and rename metadata stay in one transaction (§5). - F7 (2b) — overwrite and recursive-delete safety were incomplete.
§8 now defines the
Ccommand flow including per-collision handling in a multi-source batch, pinsremove_dir_all's lstat safety at the primitive, and statesdired.recursive-deletesas boolean, default false.
One thing the review asked for that rev 2 does not do: it does not
widen R to the marked set. Multi-file rename needs a target-directory
concept that does not exist, so R stays point-based and is named as
such rather than being an unstated exception. §11 keeps it deferred.
Review round 3 (rev 3 → rev 4)
Round 3's theme: rev 3 named the right seams but two of them were sized
from a partial inventory, and one promise was still stronger than the
mechanism behind it. All three verified against c93f9ee.
- H1 (blocking) — the modified check still races the syscall.
Correct, and rev 3's "immediately before each syscall" was wrong about
where the boundary is:
pmacs.fs.removedispatches a worker, so the interval between the Lua check andremove_blocking'sremove_file/remove_diris wide open, and acceptance 20 (edit beforey) could never detect it. Rev 4 narrows the promise to a TOCTOU-bounded pre-dispatch check — the same honest framing G6 forced onR— rather than inventing a reservation primitive inside a dired stage. The residue is stated: reconciliation preserves the newly-modified buffer, but the file is gone, so the orphan deferral rev 3 scoped to the LSP path applies to dired too (§6, §11). - H2 (blocking) — the LSP teardown inventory was a third of the real
one. Rev 3 cleared five stores.
LspManager(src/lsp.rs:741-819) holds fourteen URI-bearing store families, plus thedocumentstext map thatdidChangediffs against, andpending_routes— whoseResponseRoutevariants carry the URI at fifteen insert sites (:1684-2133), so an in-flight response repopulates a key after any clear. §5 now carries the full inventory and a purge/drain policy modelled on the server-scoped teardown that already exists (§5). - H3 (blocking) — the diagnostic-view seam was still "either/or".
Verified:
DiagnosticView.uriis private and immutable,Viewhas no downcast, and_attach_view(lua_bindings/diag.rs:211-232) takesactive_window_mut()and errors if the active window is not showing the buffer — so it reaches one window. Rev 4 chooses the seam, and it has direct precedent: aView::rename_resourcedefault-no-op hook alongsideoverlay_identityandclone_for_split(the hook family #113 added for this exact class), swept overcore.windows.values_mut()the way overlay disposal already is (mod.rs:2016-2019). In-place mutation, so overlay order is preserved by construction (§5). - H4 (merge-readiness) — #169's problem, not this PR's; fixed there by
merging
c93f9eeand aligning its recovery threshold with its own canonical anchor. - H5 — four cleanups, all taken: the §4 item-35 → item-40 reference, the §5 acceptance 27 → 30 and 28 → 32 references, and the §10 table's obsolete "rename-only Rust" description.
And the staging call, taken as directed: three PRs, not two (§10, Q#DR17). The reconciliation transaction lands first, on its own — after three rounds the LSP lifecycle and multi-window diagnostic work are substantial enough that reviewing them beside a mark layer would waste the round.
Review round 2 (rev 2 → rev 3)
Round 2's finding: rev 2 widened the rename fix into a resource
transaction, and four consumers of that transaction were named but not
actually reachable by it. All six substantive claims verified against
c8ec8f3.
- G1 (blocking) — acceptance 29 was unimplementable by the proposed
design.
apply_workspace_editcapturesoriginas a string (active_buffer_path()ispmacs.editor.file_path(),lsp.lua:471-473), so neitherreconcile_renamenorresource.renamedcan reach an already-captured Lua local; the phantom survives. Fixed by changing the applier: capture the buffer handle and restore withpmacs.window.switch_buffer, with no path fallback (§5). - G2 (blocking) — the dired subscriber could not rename its own
buffer.
dired.lua:34-38records in its own module doc that there is nopmacs.buffer.set_name, so "updates its handle and renames its buffer" was not implementable. Rev 3 adds the narrow setter, which §5 needs anyway for theBuffer.namehalf of the transaction (§5, Q#DR21). - G3 (blocking) —
rec.uriwas not the last LSP path owner.DiagnosticViewcaptures its URI at construction and the field's own doc says so: "Set once at construction; M5 may add re-rooting if a buffer is renamed" (src/diag.rs:455-457). Five more stores are URI-keyed (pmacs.diag,semantic_tokens,signature,definition,references). §5 now carries a complete teardown/re-attach contract. - G4 (blocking) — Q#DR18 had no shared seam and was racy across the
prompt. Verified:
apply_resource_op's delete arm (mod.rs:3256-3285) kills viafind_by_path— raw path, first match only, no descendants, and no modified check, so an LSP-driven delete destroys unsaved work today. Rev 3 defines one sharedreconcile_deleteseam and revalidates modified state immediately before each syscall (§6). - G5 (high) —
whad no implementation surface and the wrong semantics.push_entryis alocal function(killring.lua:93) and the publiccopy()requires a region, failing with "no region" otherwise (:182-190). Rev 3 addspmacs.killring.pushand — taking the reviewer's point that the parent approved the binding, and Emacs copies marked filenames — makeswset-based, soRis now the only point-based operation (§4, Q#DR20). - G6 (high) —
R's no-clobber was only a preflight.rename_blockingcalls plainstd::fs::rename(fs.rs:492-499), which silently replaces an existing target, so a target appearing between the check and the syscall is overwritten. Rev 3 narrows the claim rather than overstating it, and names a no-replace primitive as deferred (§8). - G7 — four cleanups, all taken:
lsp_multi_rootadded to §14, the §13/§7 cross-reference slips fixed, and the stale "2a's only Rust is the rename rebind" line corrected — it is no longer true after the widened transaction.
Corrections to the parent framing (rev 1, unchanged)
Five corrections, one of them load-bearing.
- C1 (load-bearing). The parent says the rename rebind belongs "in
the main-thread completion drain,
AsyncRuntime::tick(async_runtime.rs:991)". The decision is right and this framing keeps it, but it cannot be implemented there:AsyncRuntime(src/async_runtime.rs:513-549) holds a worker pool, a bus, and job tables — it has no buffer registry, noEditorCore, and no Lua handle. Putting a buffer rebind inside it would invert the layering. The actual seam is one level up:pmacs._async._tick(src/lua_bindings/mod.rs:6911-6924), the production binding that callsrt.tick()and is already alua.create_functionclosure withluain scope — so it can reachSharedCorebylua.app_data_ref::<SharedCore>(), exactly asapply_resource_opdoes (:3251). §5 specifies the split:AsyncRuntimeharvests, the binding rebinds. (Rev 5, W7: the decision stands, verified again at6bee09d. The seam is nowsrc/lua_bindings/mod.rs:7104-7115, insideinstall_async; its closure ismove |lua, ()|, solua.app_data_ref::<SharedCore>()is still reachable. Note thatmod.rsdefines three_tickbindings in different classes —install_async:7104,install_process:8469,install_lsp:10271— and only the async one hasluain scope.) - C2. The parent's line references have drifted.
tickis now:1003(was 991); theSleep | FsUnit → JobResult::Unitarm is:1046-1048(was 1022-1025);apply_resource_op's raw lookup is:3249(was 3248). Stage 1'sReadDirvariants account for the shift. Three references were already exact and still are:find_by_path(buffer_registry.rs:168-174),find_buffer_for_path(editor_core.rs:864-867), and the normalize-on-write inset_buffer_path(:819). - C3. The parent says the fixture's 45 behavioral tests are the
reference for dired behavior. For Stage 2 that is false and worth
knowing: the frozen fixture has no mark-and-operate layer at all.
It defines eight commands —
open-line,parent, three sorts, and threewdired— and binds exactly two keys (RET,Backspace,tests/fixtures/pmacs-dired/init.lua:381-388). Its "marks" arepmacs.buffer.mark_createtext position marks used by wdired (:716-770), not dired mark flags. Stage 2 has no in-repo reference implementation and no existing test coverage to match. Stage 3 does. - C4. The parent's
y_or_nclaim is confirmed stronger than stated: grep acrossbuiltin/andsrc/finds noy_or_n,yes_or_no, oryes-or-noanywhere, and there is nobuiltin/runtime/minibuffer.luaat all —pmacs.minibufferis entirely Rust-provided. §7 decides where the helper lives. - C5. The parent's list of three missing primitives is correct, and
removecovers more than it implies:remove_blocking(src/fs.rs:561-583) already deletes files and empty directories, and correctly unlinks a symlink-to-a-directory rather than following it. Soremove_dir_allis needed only for non-empty directories. This is what makes the staging cut in §10 possible.
0.5. Coherence impact (COHERENCE.md §20, required since #163)
Sections served: §20 Priority 1 (protect the golden product journey) and §14 (coherent workbench primitives). Rev 4 cited neither by number; the re-scout makes both concrete, and Priority 1 is a new claim that only became true when #182 landed.
- Journey steps — and this changed under rev 4. Rev 4 said Stage 2
touches "step 7's file half" only, and that dired "deepens a surface
the user must still already know about". Half of that is now false:
#182 put dired on step 3.
COHERENCE.md§20 Priority 1 records directory-argument handling as done and says it "routespmacs .into #165's dired buffer rather than growing a second directory surface", andtests/journey_acceptance.rspins exactly that (journey_step3_opening_a_directory_lists_it). So dired is no longer only a surface the user must know to ask for — for anyone who runspmacs ., it is the first thing they see, and step 5's row (journey_step5_editing_a_file_reached_through_the_directory) reaches the editable file through it. That raises the stakes on Stage 2's correctness rather than changing its scope, and it is the strongest argument for N1: a step-3 surface thatM-x buffer.undocan empty is a journey regression waiting to be filed. Discoverability of the marks remains §20 Priority 4's job, not this stage's, and this framing still does not claim otherwise. - Workbench convergence (§14, Priority 5).
COHERENCE.md§14 names dired explicitly in the generated-buffer adoption gap — "four writer mechanisms have not yet adopted it and remain emptiable … and dired buffers" — and classifies dired, with listview, as "the cheap half", because both already write whole-buffer replaces and so need none of the streaming variant the three appending buffers require. Rev 5 claimed Q#DR25 closed "dired's quarter" of that gap; rev 6 withdraws both the decision and the claim (R1). Dired was never a quarter of anything — it is one instance of one idiom, and the missing capability (no Luaset_read_only, so no Lua caller can assertread_onlyat all) is shared by all four. Stage 2 therefore makes no progress on §14's generated-buffer gap, and no longer says it does; thegenerated-buffer-immutabilitylane owns it (§3.1). Separately, §14's tree-primitive point is untouched: Stage 2 adds no tree, andi(insert subdirectory) stays deferred (§11) precisely so it can land on a shared primitive rather than inventing one. - Interaction islands: adds none. Every new key is an entry in the
existing mode-scoped
diredkeymap (dired.lua:937-946), and every new command is an ordinarypmacs.command.define, so all of it is reachable fromM-xand describable bydescribe.key. The confirmation prompt uses the existing minibuffer rather than a new modal surface — that is the reason §7 spends a section on it. - Config registry. Stage 2a and 2b add no keys. Stage 2c adds
dired.recursive-deletes(Emacs'sdired-recursive-deletes), throughpmacs.config.definelikedired.kill-when-opening(dired.lua:78-84). - Background-work attribution (§9): this stage makes a real, and
honest, dent in the wrong direction unless it is deliberate.
COHERENCE.md§9 grades the worker model "mechanism without identity" and names the prerequisite precisely:PendingJob(src/async_runtime.rs:367-375) carries no owner, purpose, buffer association, or parent, andJobKindis a closed 12-variant enum. Stage 2 pushes on both:- It must make a pending rename retain its from/to paths (§5). That
is a new
PendingJobfield which is not an owner or a purpose — a one-off. The alternative shape, ajob_id → (from, to)side map onAsyncRuntime, is worse by §9's own diagnosis: §9 singles out the existing parse-job→buffer link for living "in aSyntaxCoordinatorside map, invisible to the workers view". So the field is the coherent choice of the two, and this framing takes it — while recording that a generalpurpose/ownerfield should later subsume it, and that Stage 2 is not the place to design that. - Stage 2b grows
JobKindfrom 12 to 15 variants. That is additive and matches every existing fs op, but the closedness §9 objects to is not something this stage fixes, and the framing does not pretend the growth is progress.
- It must make a pending rename retain its from/to paths (§5). That
is a new
1. What Stage 2 ships
The Emacs dired working loop: select a set of files, then act on it.
| Key | Command | Effect |
|---|---|---|
m |
dired.mark |
Mark entry under cursor *, advance |
u |
dired.unmark |
Clear the mark, advance |
U |
dired.unmark-all |
Clear every mark in this listing |
t |
dired.toggle-marks |
Invert * marks across the listing |
d |
dired.flag-delete |
Flag D, advance |
x |
dired.execute-flags |
Delete every D-flagged entry, after confirming |
D |
dired.do-delete |
Delete the marked set (or entry at point) now, after confirming |
R |
dired.do-rename |
Rename the entry at point (§4) |
w |
dired.copy-filename |
Copy the marked filenames to the kill ring |
M |
dired.do-chmod |
Mode bits on the marked set; refuses symlinks (NEW — Q#DR19) |
+ |
dired.create-directory |
Create a subdirectory (2c) |
C |
dired.do-copy |
Copy the marked set (or entry at point) (2c) |
w is carried forward from the parent's approved table; M is new
scope and needs explicit approval, since the parent listed neither it
nor any chmod surface for Stage 2 (F3).
Two small public surfaces come with it, both because the operations have
nowhere to land otherwise: pmacs.buffer.set_name (Q#DR21) and
pmacs.killring.push (Q#DR22).
Not in Stage 2, and no longer this document's decision (Q#DR25,
withdrawn in rev 6 — R1): dired's listing becoming a genuinely
immutable generated buffer. Rev 5 added it here as a fix "Stage 2 may
not skip". It is a real defect — M-x buffer.undo empties a dired
listing on main today — but it is not dired's defect: the same
erroring-intercept-over-a-writable-rope idiom is in listview.lua,
compile.lua and the search/grep panel, and no Lua caller anywhere
sets read_only. A class bug gets a class fix, so it now belongs to
the generated-buffer immutability lane (branch
generated-buffer-immutability). §3.1 records what that lane inherits
from this one; §11 and §15 carry the deferral. Stage 2 must not
implement it, and Stage 2's acceptance must not pin it.
Plus, invisibly: renaming or deleting a path starts reconciling the
consumers this stage can reach — buffer path and name, the fourteen
URI-keyed LSP store families and the attached diagnostic view, dired's
own pathless handles, and the workspace-edit applier (§5, §6) — and
fires a hook so the ones it cannot reach are able to reconcile
themselves. That distinction is load-bearing and rev 5 blurred it
(R5): lean.lua's URI-keyed progress table is not reconciled by
this stage. Stage 2 supplies resource.renamed; Lean's state stays
stale until Lean's owner subscribes (§11). What Stage 2 does deliver
is a correctness fix to shared substrate: it is the reason R is safe
on a directory at all, and it gives rename and delete one registry
walk instead of the raw first-match lookup each arm does today.
What this lane no longer claims (rev 7, round 6): closing the
LSP-authored delete that destroys unsaved work. That defect is real on
main and is PR #186's, which refuses the delete before disk
rather than reconciling after it. This lane inherits the refusal and
owns what remains once it is in place — the full post-delete lifecycle,
and the async race #186's synchronous guard cannot reach (§6).
Not in Stage 2: wdired (Stage 3), subdirectory insertion (i),
shell commands on marks (!), regexp marking (% m), and
compress/symlink/hardlink ops. §11 names them.
2. Ground truth (re-scouted 2026-07-28, main @ 6bee09d)
Everything below was read or executed on this tree, not inferred. Where a fact was already in rev 4 and survived the 153-commit gap unchanged, it is left as written; where a line number moved it is corrected in place without comment; where a claim changed, the change is called out.
The filesystem surface
pmacs.fs is exactly five ops plus watch
(builtin/runtime/fs.lua): read_dir (:124), stat (:133), rename
(:167), chmod (:177), remove (:187), watch (:267). Rust side:
read_dir_blocking (src/fs.rs:289), stat_blocking (:449),
rename_blocking (:492), chmod_blocking (:521), remove_blocking
(:561). No mkdir, no copy, no recursive remove exists anywhere.
remove_blocking(:561-583) lstats first, thenremove_dirfor a real directory (non-recursive) andremove_fileotherwise, with an explicit comment that a symlink-to-a-directory is not a directory perlstatand so gets unlinked rather than followed. Soremovealready handles files and empty directories, and deleting a symlink never touches its target.- Mutating ops deliberately take no
supersede, andfs.lua:155-165states the reason (a cancelled mutation may still have completed) and the prescribed alternative: "If a package needs at-most-one-pending semantics for mutations, it should serialize on the package side (await each op before dispatching the next)." §9 takes that instruction literally. chmodfollows symlinks (fs.lua:145-153) whileread_dir/statlstat. SoMon a symlink line changes the target's mode and a refresh shows the link's own, unchanged mode. This is documented substrate behavior, not a bug to fix here; §8 decides whatMdoes about it.read_opts(:82-105) rejects unknown keys. Any new op that takes opts must go through it or repeat that discipline.
Rename, and why the buffer rebind is not where you would put it
pmacs.fs.renamehas zero production callers. Repo-wide the only callers are the frozen fixture (init.lua:1200,:1210),tests/m8_1_acceptance.rs:359, andtests/m8_3_acceptance.rs:182-184— which monkeypatches the Lua function. Changing rename's contract therefore breaks nobody, and the monkeypatch matters for test design: a Lua-level replacement bypasses a Rust-level rebind entirely.- The fixture's wdired commit renames in two phases through unique temp
names (
init.lua:1197-1215), awaiting each. A prefix-aware rebind will therefore rebind an affected buffer twice (real→temp→final), landing correctly. Worth knowing before reading a confusing trace. dispatch_fs_rename(async_runtime.rs:871-879) movesfromandtointo the worker closure. Nothing retains them, which is exactly why §5 needs a new field.PendingJob(:367-375) carries{cancel, state, supersede_key, stream_buffer, max_batch, kind, dispatched_at}—kindis there, the paths are not.- Rename settles as an undifferentiated
ReplyKind::FsUnit: theSleep | FsUnitarm maps both toJobResult::Unit(:1046-1048). There is noRenamereply variant, so a drain cannot key on the reply; it must key on the pending job's ownJobKind::FsRename. tick's post-loop block already does exactly the read the harvest needs.:1074-1108iteratesnewly_settled, borrowspending, and readsjob.kind,job.state,job.dispatched_at, andjob.supersede_keyto push aCompletedSlot. The harvest is one more read in a loop that already exists. (Unchanged at6bee09d, line numbers included.)find_by_path(buffer_registry.rs:168-174) is exactPathequality overself.order, first match only. No prefix logic, and two buffers on one path means one of them is invisible to it.apply_resource_op's rename arm (mod.rs:3291-3312) does a synchronousstd::fs::renameon the main thread and thenreg.borrow().find_by_path(&from)with the raw path (:3306), while stored paths are normalized on write (editor_core.rs:889-890) and the normalizing wrapperfind_buffer_for_path(:935-938) exists and is bypassed. This is a second, LSP-facing rename path with the same two defects; §5 fixes both in one change. (Rev 4 cited:3234-3255/:3249/:819/:864-867; the code is unchanged, only its position.)
A verified pre-existing defect Stage 2 must not lean on
A fire-and-forget non-stream job leaks its pending entry forever. The
only two removals from pending are stream eviction of closed streams
(:1225) and take_result (:1262-1282); the Lua Handle is a
bare setmetatable({_id = id}, Handle) (async.lua:55) with no
__gc, so dropping a handle reaps nothing.
Executed on this tree to confirm rather than infer — dispatch a rename, pump until complete, never take the result:
PROBE renamed_on_disk=true pending_len_after_settle=1
PROBE is_complete=true snapshot_active=0 snapshot_completed=1
Two consequences. Good: the settled job is still in pending when the
harvest runs, so §5's design is sound. Bad: this is a real leak, it is
pre-existing and not Stage 2's to fix, and Stage 2 must not make it
routine. §9's serialize-and-await contract means dired's own ops reap
every entry they create; the rebind exists for other callers, who leak
today regardless. Named as a deferral (§11) with the note that
pending_len is the observable.
Marks: the precedent already in the tree
*buffer-list* keys deletion marks by stable id, not line index
(builtin/commands/default.lua:373, set at :507, cleared at :520),
and prunes marks whose target no longer exists on refresh
(:444-452). That is the exact shape §3 adopts, one substitution
(basename for buffer id).
Prompts: the shadowing trap has a precise boundary
Stage 0's finding is that a selected candidate shadows typed text. The
boundary is sharper than the parent framing records:
resolve_accepted_value (src/minibuffer.rs:564-575) short-circuits
on CompletionSource::None at :565-567 and returns the typed text
before the candidate branch is reached.
So: a prompt with no source returns typed text verbatim, always.
Every Stage 2 free-text prompt (a new name, an octal mode, a directory
name) omits source and is immune. A prompt that wants candidates
re-enters the trap deliberately — which is what §7 is about.
There is no builtin/runtime/minibuffer.lua; pmacs.minibuffer is
Rust-only. The nearest existing confirm is autosave.lua:219-224, a
source = function() return {"yes","no"} end prompt tested with
answer ~= "yes".
What Stage 1 left for Stage 2 to build on
dired.lua's handle is {buf, path, entries, errors, sort_mode, prev}
(:521-528) — no mark state; §3 adds it. render_entry (:334-349)
hardcodes BLANK_MARK in column 0. paint (:369-372) is a wholesale
buf:replace with bypass_intercept = true; the read-only intercept
(:509-511) rejects everything else. Both of those are what the
generated-buffer-immutability lane replaces — rev 5 proposed to do
it here and rev 6 withdrew that (§3.1, R1); Stage 2 leaves paint and
the intercept exactly as Stage 1 built them. seat_cursor (:405-416) re-seats by basename and
carries the warning that move_to_line is ambient — every
post-await seat must first check pmacs.window.buffer().
entry_at_cursor (:381-385) maps cursor line n to entries[n],
returning nil on the header and the footer. Keys are mode-scoped through
one helper (bind, :933-935, with the nine bindings at :937-946),
and pmacs.dired._layout (:954-961) exports the column contract.
tests/dired_acceptance.rs carries 25 tests.
One stale comment to fix in passing: :718 still says an uncaught raise
lands "in *errors*", which #161's COHERENCE finding falsified and
which the module doc at :56-72 already corrects. Same file, two
answers. (It was :665 in rev 4; #182 moved it, and it is still
wrong.)
The generated-buffer write invariant, which dired has not adopted (N1)
Buffer::set_generated_contents (src/buffer.rs:545-556) is, per
docs/agent-handoff.md §4, the one authorized write for a generated
buffer. It does four things as a unit: clear read_only, apply one
whole-buffer EditOp::Replace through apply_edit_skip_intercepts,
clear_history(), re-assert read_only — and return the Edit.
The three traps the handoff attaches to it, each verified here:
- An intercept is not read-only.
Buffer::undoreaches the rope viaensure_writable(src/buffer.rs:568-577), which consults only theread_onlyflag and never the intercept chain.M-x buffer.undois a defined command (builtin/commands/default.lua:179) reachable from M-x with no binding at all, so rebindingC-/buffer-locally would not close it. Dired sets noread_only—claim_handleinstalls an intercept andset_round_trip_input, nothing more (dired.lua:506-519) — so a dired listing is emptiable today. - A rope write is only half an edit. The
Editmust be fanned out or a displaying window keeps aTextViewline index describing the previous contents. The Lua binding already discharges this:pmacs.buffer.set_generated_contents(src/lua_bindings/mod.rs:3079-3095) releases the registry borrow and then callsnotify_buffer_edit_to_windows(:3092). So a Lua caller inherits the fan-out for free — this is not somethingdired.luahas to arrange, and the comment at:3088-3091says why the borrow is dropped first. - "Discard history" means whichever history exists.
clear_history(src/buffer.rs:559-566) clears the v0.1undo/redostacks and, under#[cfg(feature = "crdt")], callscrdt.clear_undo_history()— because CRDT mode bypasses the v0.1 stacks entirely. Consequence for §14: whichever lane adopts the primitive, thecrdt-featured run is the only one in which that half is live — a default-feature run exercises the v0.1 stacks alone.
There is deliberately no Lua set_read_only
(src/lua_bindings/mod.rs:3074-3078 states the reason: it would let a
caller lock a buffer with no way to refresh it). Pairing the lock with
the write is the primitive, which is why adoption is a swap of
paint, not an addition to it.
And it does not replace set_round_trip_input. The handoff is
explicit that the protection is layered across two copies: rope-level
read_only refuses the op at the daemon, while round-trip input stops a
semantic frontend applying optimistically to its own mirror, which a
daemon-side refusal arrives too late to prevent. dired.lua:516 stays.
How a directory reaches dired, after #182 (N2)
Rev 4 predates Journey Stage 1a. Three facts a Stage 2 implementer will otherwise get wrong:
resolve_target_bufferresolves a directory before it loads. TheResolvedTarget::Directory { path }arm is first (src/editor_core.rs:964-970), ahead ofget_or_load_buffer, and itspathis normalized — the type's own doc says so (:118-130) and warns that this is not free, because normalization otherwise happens insideset_buffer_path(:889-890), which never runs on this arm. A handler keying state by path gets the canonical form, which is what makes dired'shandle_for_pathdedup agree with it. Stage 2's reconciliation must normalize on the same seam or it will miss dired handles.- Dired is a replaceable fallback slot, not a hook subscriber. The
chain is the
path.open-directoryhook (builtin/hooks/default.lua:65), and dired registers throughpmacs.path.set_directory_handler(dired.lua:736-738; binding atsrc/lua_bindings/mod.rs:3679, readable back aspmacs.path.directory_handler,:3677). The module's own comment states the reason:HookRegistry::addonly appends and builtins load beforeinit.lua, so a dired subscription would always claim first and no user listener could ever win. Setting the slot tonildisables directory opening entirely, andjourney_unclaimed_directory_starts_successfully_with_a_statuspins that this is a status message rather than a failure. Nothing in Stage 2 may assume dired is what opened a directory. open_directorynow commits under a captured destination, and that scope refuses anawait. The mutating half ofopen_directoryis acommit()closure (dired.lua:656-693) run throughpmacs.window.commit_towhenopts.destis present (:703-707); theread_listingawait is deliberately outside it, and the comment at:653-655records why: awaiting inside a commit is refused (Q#JR14b), because a yield would restore the scope while the coroutine is still parked.journey_acceptance.rs'scommit_to_refuses_an_await_and_restorespins it. §9's serialize-and-await batch therefore cannot run inside a commit scope — see §9.
The journey ratchet (N3)
tests/journey_acceptance.rs — 24 #[test]s, 0 #[ignore], 0
feature-gated, 2 under #[cfg(target_os = "linux")]. Its module doc at
:12 is the rule: "This file is a ratchet: stages add rows, none
removes them." Two further disciplines it states, both of which bind
Stage 2's own acceptance:
- Drive the real entry point (
:16-19): "a directory arm with no production caller passes every direct-call test". The suite goes throughEditorState::open/open_directory_target(src/editor.rs:999), neverresolve_target_buffer. - Pump to quiescence, never to a frame count (
:20-22), because every listing is worker-dispatched. Stage 2's batches are more worker-dispatched still.
Seven rows assert on dired directly, including that a failed listing
leaves no *dired: buffer behind, that a declining resolver chain
still falls back to dired, and that q returns to the destination
window's origin buffer. The GPU row is in a different file —
tests/gpu_invocation_acceptance.rs's
public_gpu_directory_target_reaches_dired_and_leaves_the_daemon_usable,
added by #183 through a real daemon child process. Stage 2 must keep
both green and must not remove a row from either (§14).
The typed-edit chain, and why Stage 2 stays outside it (N4)
Dired participates in neither pmacs.typed_edit nor
buffer.after-edit: a grep of dired.lua for
typed_edit|after-edit|after_edit returns 0, and it registers no
pmacs.hook.add at all. Nor does the generated-buffer write drag it in
— set_generated_contents's binding runs notify_buffer_edit_to_windows
and nothing else, and EditorCore::notify_buffer_edit
(src/editor_core.rs:1814-1828) runs no Lua hook. So adopting the
generated-buffer primitive would not put dired writes on the chain
either — recorded for the lane that will do it (§3.1), since it is the
obvious worry about N1 and N4 interacting and the answer is no.
What does inherit from the chain is §5's two new hooks, because they are new fan-outs. The relevant facts:
resource.renamed/resource.deletedwill be declared inbuiltin/hooks/default.lua. The existing edit hook there isall-must-succeed, andrun_all_must_succeed(src/hook.rs:332) iterates every callback and collects errors — it does not abort the fan-out. A reconciliation subscriber may therefore not rely on a raising peer to stop the sequence, and §5's ordered LSP teardown must be internally ordered rather than ordered-by-registration.- A chain consumer cannot both edit and let a later consumer act
meaningfully, because the record handed to consumers is a copy taken
before any ran (
builtin/runtime/typed_edit.lua:132,:145-159). The analogue for §5 is direct:resource.renamedcarries paths, not a rebind list, and a subscriber that re-reads editor state sees whatever earlier subscribers did. §5's LSP and dired subscribers are independent by construction — one touches URI-keyed stores, the other touches handles — and this framing asserts that independence rather than assuming it. buffer.after-editfan-outs nest (builtin/runtime/lean_input.lua:232-241). Stage 2 fires nobuffer.after-edit, so this does not bind directly; it is recorded because the reconciliation runs insidepmacs._async._tick, and a subscriber that edits a buffer there is onepmacs.hook.runaway from re-entering a fan-out it is inside.
3. Marks (Q#DR12, continuing the parent's Q#DR4)
handle.marks is a table keyed by basename, valued by the mark
character: { ["foo.txt"] = "*", ["old/"] = "D" }. Two characters only,
per the parent: * (general, consumed by operations) and D (deletion
flag, consumed by x).
Basename keying is the parent's decision and it is right for the reason it gives — a sort or a revert reorders lines, so a line-indexed set retargets onto a different file. Three consequences the parent does not draw:
- Marks are per-directory-buffer, which falls out for free. The mark table lives on the handle, and there is one handle per directory (Q#DR2). Nothing to decide, but it is why a basename key is sufficient — the directory is implied by the table it is in.
- Marks must be pruned on every re-read, following
*buffer-list*'s:444-452. A vanished name's mark is dropped before it can be counted by the next operation, so the mark set can never name something the listing does not. render_entrybecomes mark-aware, which means it needs the mark for the entry it is rendering. It currently takes onlyentry; it grows a second parameter rather than reaching for the handle, so it stays a pure function of its inputs and the acceptance can call it directly.
t inverts only * marks and leaves D flags alone — Emacs's
behavior, and the alternative silently converts flags into marks.
U clears both.
3.1 The listing's write path — deferred to the generated-buffer lane (Q#DR25, withdrawn in rev 6)
Rev 5 decided here that dired's paint would adopt
pmacs.buffer.set_generated_contents, dropping the erroring intercept.
Round 5 withdrew that decision from this document (R1), and the
reason is worth stating precisely because it changes what the fix is,
not merely who does it.
The defect is real and rev 5 characterised it correctly: dired sets no
rope-level read_only — claim_handle installs an intercept and
set_round_trip_input, nothing more (dired.lua:506-519) — while
Buffer::undo reaches the rope through ensure_writable
(src/buffer.rs:568-577) without consulting the intercept chain, and
M-x buffer.undo (builtin/commands/default.lua:179) is reachable from
M-x with no binding. So a dired listing is emptiable today.
What rev 5 got wrong is the scope. It framed this as dired's
quarter of a four-writer inventory. It is not a quarter of anything: the
same idiom — erroring intercept, bypass_intercept write, writable rope
— is in listview.lua, compile.lua and the search/grep panel, and
no Lua caller anywhere sets read_only, because there is
deliberately no Lua set_read_only to call
(src/lua_bindings/mod.rs:3074-3078 says why). One idiom, one missing
capability, four instances. Fixing dired's instance inside a dired stage
would have produced a fourth bespoke adoption and left the shared
question — what a Lua-owned generated buffer is supposed to do —
unanswered for the fourth time.
It is therefore owned by the generated-buffer-immutability lane,
whose framing is in progress. Stage 2 does not implement it, does not
gate on it, and carries no acceptance for it.
What that lane inherits from this one, recorded here so the re-scout is not repeated:
- The three obligations the primitive carries, all verified at
6bee09d: an intercept is notread_only; the returnedEditmust be fanned out (the Lua binding already does it,src/lua_bindings/mod.rs:3092, releasing the registry borrow first); andclear_history(src/buffer.rs:559-566) must clear whichever history exists, since CRDT mode bypasses the v0.1 stacks and keeps its own in loro'sUndoManager— so any acceptance for this must run under both default andcrdtfeatures or it exercises one half. - Adoption does not replace
set_round_trip_input. The protections cover different copies: rope-levelread_onlyrefuses the op at the daemon, while round-trip input stops a semantic frontend applying optimistically to its own mirror, which a daemon-side refusal arrives too late to prevent.dired.lua:516stays either way. - A trap for that lane's acceptance.
tests/dired_acceptance.rs:969'sdired_buffer_is_read_only_and_round_trips_inputassertsstatus(&s).contains("read-only"), andBufferError::ReadOnlyrenders asbuffer `{name}` (id {id:?}) is read-only(src/buffer.rs:1794). That test passes both before and after the swap, so it is not coverage of the adoption and must not be counted as such. The pin with bite isM-x buffer.undoleaving the listing intact, which fails againstmaintoday.
What Stage 2 owes it: nothing but non-interference. Stage 2 changes
paint's callers (every mark and batch repaints) but not paint
itself, so the two lanes touch dired.lua in different places. If the
immutability lane lands first, Stage 2b rebases onto a paint that
already writes through the primitive and needs no change; if Stage 2b
lands first, the lane adopts a paint with more callers and still needs
no change to them. Neither ordering creates a conflict, which is why
this is a deferral rather than a dependency.
4. Target sets: which operations are set-based (Q#DR13)
Rev 1 stated one rule with one exception and then contradicted it (F4). Operations fall into three classes, and the class is a property of the command, not a special case:
Set-based — D (delete), M (chmod), w (copy filename), and C
(copy, 2c) target:
the marked set, or — if nothing is marked — the entry at point.
Emacs's rule, and it is why none of them needs a separate at-point binding.
Flag-based — x alone. It consumes D flags only, never *
marks, and never falls back to point: with nothing flagged, x does
nothing and says so. A d-then-x sequence and an m-then-D sequence
are different gestures, and collapsing them makes x unpredictable after
a stray m.
Point-based — R (rename) alone. It acts on the entry at point
regardless of what is marked, and leaves marks untouched. This is a
real limitation, not a preference: renaming a set means renaming into a
target directory, which needs a concept Stage 2 does not build (§11).
Naming it here is the point — rev 1 left it as an unstated exception to a
rule that claimed to have only one.
(Rev 2 also put w here. Round 2 was right that this was an unapproved
narrowing: the parent approved the w binding, and Emacs copies the
marked filenames when marks exist. w is set-based in rev 3.)
A basename in a set that has vanished from disk since it was marked is dropped from the batch and reported, not silently skipped and not fatal to the rest — the parent framing's §6 rule, kept. Note this is a distinct event from a revert pruning the mark (§3): a target can vanish between the last revert and the operation, and §13 item 40 pins that the batch reports it rather than silently shrinking.
5. Rename reconciliation (Q#DR14, superseding Q#DR5's rebind)
The parent's decision — fix it at the primitive, in the main-thread
drain, unconditionally on success — is kept. Rev 1's scope was wrong:
it updated Buffer.file_path and nothing else, leaving four other owners
of the same path stale (F1). A rename is not a buffer-field update; it is
a transaction across every consumer that holds the path.
The owners, all verified
Rev 4 listed five. The re-scout confirms all five and finds a sixth (W5); two further owners are persisted rather than in-memory and are named in §11 rather than fixed here.
| # | Owner | Held as | Stale after a rev-1 rebind |
|---|---|---|---|
| 1 | Buffer path | Buffer.file_path |
fixed |
| 2 | Buffer name | Buffer.name |
yes — set_buffer_path never calls set_name, which documents itself as for "save-as and rename operations" (buffer.rs:453). Statusline and buffer list keep the old filename |
| 3 | LSP attachment | rec.uri, cached per buffer in the attachments table (lsp.lua:340, record built at :861-868) |
yes — read at 57 lines in lsp.lua (W4), among them didChange (:418), semantic tokens (:799-813), the diagnostic attach (:1039), signature (:1102-1108), definition (:1922-1937), references (:2031-2047), all firing at the old URI. Buffer-keyed, so one rebind reaches all 57 |
| 4 | dired handles | handle.path in Lua; the buffers are pathless |
yes, and unreachable — no buffer-keyed rebind can ever find them |
| 5 | Workspace-edit origin | origin = active_buffer_path() (lsp.lua:1338) — a string (:471-473) |
yes, and it materializes a phantom — find_or_open(origin) (:1352) on a renamed-away path hits resolve_target_buffer's NotFound arm, which creates an empty path-backed buffer (editor_core.rs:980-989) and selects it. No transaction can fix this one, because the stale value is a captured Lua local, not editor state (G1) |
| 6 | Lean file progress (NEW, W5) | M.file_progress[uri], a URI-keyed Lua module table (lean.lua:646, written :651) |
yes, and forget_uri cannot reach it — it is in no Rust store. Populated from $/lean/fileProgress; its own comment says Stage 5's goal view reads it to tell "no goals" from "not done yet". Reachable only from a resource.renamed Lua subscriber, which is an independent argument for the hook |
Owner 6 is the load-bearing addition, and not because Lean matters
here. It is evidence that the Rust-side forget_uri (below) is
structurally incapable of being complete: any package may key state by
URI in its own module table, and the LSP manager will never know. The
hook is not a convenience for dired — it is the only mechanism that
scales, and owner 6 is the first case that proves it outside dired.
lean.lua's subscriber is not in Stage 2's scope (Lean's arc owns
it); what Stage 2 owes is a hook whose contract makes writing one
possible, which is why the hook carries (old, new) paths.
One transaction, two callers, one notification
EditorCore::reconcile_rename(old, new) -> Vec<RenameRebind>, where
RenameRebind { buffer_id, old_path, new_path }:
- walks the whole registry, not
find_by_path's first match — a directory rename has many affected buffers by construction, and two buffers can visit one path; - matches normalized stored paths against normalized
oldby equality or path-component prefix (/foomust not match/foobar); - sets the new path and sets the name, but only when the buffer's name is path-derived, by the equivalence rule below;
- returns every rebind it performed.
The name-provenance rule (Q#DR30, rewritten in rev 8 — F3). Rev 7
updated the name only when it equalled the normalized old path,
assuming path-backed buffers carry full-path names. They do not.
get_or_load_buffer (src/editor_core.rs:917) sets
display_name = path.display().to_string() — the path as given —
and normalizes only the stored file_path; pmacs.buffer.from_file
(src/lua_bindings/mod.rs:3112) passes the raw string the same way. So
pmacs foo.rs yields name foo.rs, path /abs/dir/foo.rs, and rev 7's
rule would have left that name stale while insisting it was
user-chosen.
The rule is path-equivalence, not string equality:
A buffer's name is path-derived — and therefore updated — iff the stored name, parsed as a path and normalized by
normalize_buffer_path, equals the buffer's stored normalized old path.
In words: the name still denotes this file. A full-path name
qualifies; a relative name qualifies, because it normalizes to the same
absolute path; and a name the user chose — notes, *scratch* — does
not, and survives. Both directions are load-bearing, and §13 tests
both: the relative case is what rev 7 got wrong, and the custom case is
what stops the fix over-correcting into a name-clobberer.
One consequence, stated rather than hidden: when the rule fires the
new name is written as the normalized new path, so a buffer opened
by a relative path acquires an absolute name after a rename. Preserving
the relative rendering would require knowing which base the name was
relative to, which no buffer records. The effect is confined to the
statusline and the buffer list. The alternative — an explicit
provenance flag on Buffer, with every creation site audited — is real
core state for a corner of the statusline, and this framing does not
think that trade is worth it. Named in §11 in case review disagrees.
Both rename paths call it: the async harvest below, and
apply_resource_op's rename arm (mod.rs:3234-3255), whose raw
first-match lookup at :3249 is deleted in favour of it. One function,
two callers — so the two can no longer drift, which is how the trap
survived being "fixed" once already.
Then one hook, fired once per rename:
resource.renamed(old_path, new_path). The mechanism exists —
run_hook_if_defined (mod.rs:1623) fires Rust-side, pmacs.hook.run
(:6005) fires Lua-side — but no rename or delete hook exists today;
the whole set is buffer.{after-edit,after-load,after-save,after-switch, before-save,save,self-insert}, editor.before-quit,
frontend.detached, process.after-tick. Stage 2 adds this one.
The hook carries the paths, not the rebind list, precisely because
dired's buffers are pathless: a path-keyed consumer must be able to
reconcile from (old, new) alone.
The LSP subscriber, in full (G3, corrected H2)
Rev 2 said "recompute rec.uri and didClose/didOpen". Rev 3 added five
stores. Both were sized from a partial inventory: LspManager
(src/lsp.rs:741-830) holds fourteen URI-bearing store families, and
two more things keyed the same way.
The complete inventory, from the struct itself:
| # | Field | Keyed by |
|---|---|---|
| 1 | diag_store |
uri |
| 2 | completion_store |
(server, uri) |
| 3 | hover_store |
(server, uri) |
| 4 | signature_store |
(server, uri) |
| 5 | definition_store |
(server, uri) |
| 6 | locations_store |
(server, uri, kind) — references / declaration / typeDefinition / implementation |
| 7 | symbol_store |
scope-keyed — documentSymbol and workspace symbol |
| 8 | document_highlight_store |
(server, uri) |
| 9 | formatting_store |
(server, uri) |
| 10 | rename_store |
(server, uri) |
| 11 | prepare_rename_store |
(server, uri) |
| 12 | code_action_store |
(server, uri) |
| 13 | inlay_hint_store |
(server, uri) |
| 14 | semantic_token_store |
(server, uri) — plus the retained raw int stream and result_id |
Plus documents: HashMap<(LspServerId, String), String> (:810), the
latest full text per (server, uri) — which is what didChange diffs
against, so a stale entry under the old URI is a correctness problem,
not just a leak. And pending_routes (:797), whose ResponseRoute
variants carry the URI.
The route arithmetic, corrected (W3). ResponseRoute
(src/lsp.rs:835-859) has 15 variants, of which 14 carry a
uri: Completion, Hover, Signature, Definition, Formatting,
Rename, PrepareRename, CodeAction, InlayHint, SemanticTokens,
SemanticTokensDelta, Locations { uri, kind }, DocumentSymbol,
DocumentHighlight. The fifteenth is WorkspaceSymbol { query },
which carries no URI at all — its own comment explains that the
query stands in for the doc URI in the supersede key
(src/lsp.rs:1887-1888). There are 16 insert sites, every one in a
request_* method, spanning src/lsp.rs:1683-2132; 15 of the 16
insert a URI-bearing route, the two SemanticTokens inserts coming
from request_semantic_tokens (:2073) and
request_semantic_tokens_range (:2108), and the sixteenth being
request_workspace_symbol (:1889). Rev 4 said "fifteen insert
sites"; the fifteen was a correct count of the URI-bearing ones and an
incorrect count of the sites. The purge predicate must therefore
answer for a variant with no uri field — WorkspaceSymbol is
retained unconditionally, because a workspace-symbol query is not scoped
to any document and a rename does not invalidate it.
And that is why "clear the stores" is not enough (H2): a response already in flight when the rename happens routes on arrival and repopulates the old key after the clear. Clearing without purging in-flight routes is a race that reintroduces exactly the state it removed.
One manager-level method, forget_uri(sid, uri), doing all of it —
because fourteen call sites at the Lua layer is how one gets forgotten.
And it needs a Lua binding, which rev 7 never named (rev 8, P2).
The subscriber that calls this lives in lsp.lua, and Lua reaches only
explicit bindings. The surface is pmacs.lsp.forget_uri(server_id, uri), modelled on pmacs.lsp.forget (src/lua_bindings/mod.rs:10257)
— a closure over the shared manager that calls through and maps the
error with mlua::Error::external. Its error contract:
- Raises on an unknown
server_id, matchingforget's existing behaviour for the same input. - Succeeds silently when the URI has no state under that server. The subscriber fires for every renamed path, including buffers that never attached to a language server, so "nothing to forget" is the common case and must not be an error.
- Takes the old URI. Calling it after
didOpenof the new URI is therefore safe and order-independent with respect to step 5.
Pinned by §13 item 31, which asserts both arms — the raise and the
silent success.
Its shape is modelled on the server-scoped teardowns that already
exist. There are two of them, and rev 4 named neither correctly (W2):
LspManager::start_generation (src/lsp.rs:1307-1345, the restart-
generation flip) and LspManager::forget (src/lsp.rs:3015-3042, the
terminal-state removal). There is no fn restart. Both do the same
three things one axis over:
- Purge
pending_routeswhose route carries this URI —retain, mirroringstart_generation's:1324retain(|(sid, _), _| *sid != id)andforget's:3028. Per W3, the predicate must retainWorkspaceSymbolexplicitly. - Drain-cancel their awaiters — and this is where rev 4 pointed at
the wrong function (W1).
pending_external(:804) holds theHandle:await()side, and the contract at:801-803is explicit that it is "drained-cancelled whereverpending_routesis purged". The sweep to model on isdrain_external_cancelled(src/lsp.rs:1561-1576) — server-scoped and unconditional, settling every awaiter forsidcancelled. It is notdrain_cancelled_externals(src/lsp.rs:1596-1645), which despite the near-identical name is a per-tick, per-awaiter sweep that removes only awaiters whoseCancellationTokenwas flipped or which outlivedrequest_timeout. A rename flips no token, so modelling on the second yields a drain that silently does nothing and leaves any coroutine awaiting against the old URI parked forever. Note that neither existing sweep is URI-scoped — both range oversid.forget_urineeds a new shape: collect therids whosepending_routesentry carries the old URI, then settlepending_external[(sid, rid)]'s awaiters cancelled viaruntime.complete_external_cancelled, exactly asdrain_external_cancelleddoes per key. The route→awaiter join is therid; there is no other index. - Clear all fourteen stores plus
documentsfor the old key. Each store already has a keyedclear(diag.rs:262,hover.rs:160,completion.rs:331,semantic_tokens.rs:263, …). Note the two irregular keys:locations_storeis kind-keyed, so all four kinds must go;symbol_storeis scope-keyed and holds workspace symbols too, so only the document-scoped entry is dropped — the same asymmetry that makesWorkspaceSymbolroute-exempt in step 1.
The uncorrelated writers, and the tombstone that gates them (rev 8, F4)
forget_uri's three steps all operate on pending_routes — the
correlated path, where a response is matched to a request id. There
are writers that never go near it.
The census, re-run with "uncorrelated writers" as the lens. Counted
over production code only, with #[cfg(test)] boundaries read per file
rather than inferred from filenames. 41 writes total: 16 correlated,
19 uncorrelated store writes, 6 documents writes.
- Correlated: 16, every one inside
LspManager::absorb_routed_response(src/lsp.rs:2667), which has exactly one caller —handle_response(:2523) behindpending_routes.remove(&(sid, rid))at:2627. These are purged by construction if the route drain runs first. - Uncorrelated: 19 = 1 server-initiated notification + 3
mark_document_stalewrites + 15 Lua-callableclearbindings.
Four findings that change the design, not just the prose:
diag_storehas ZERO correlated writers. The 16 correlated writes cover 13 of the 14 stores; diagnostics are populated only by the uncorrelated path. So the one storeforget_urimost needs to protect is the one its route purge cannot help at all.publishDiagnosticsis the only uncorrelated notification writer — but not the only uncorrelated writer.handle_notification(src/lsp.rs:2878) is 22 lines, matches exactly one method string, and has no default arm; everything else reaches onlypush_event, which writes the event queue and status tracker and no URI-keyed store. So among notifications it is 1 of 1. Butpub fn mark_document_stale(&self, uri)(src/lsp.rs:3108) is a second uncorrelated writer: it takes&selfand noLspServerId, marksstale_urisin three stores for every server at once, creates URI keys, and is exposed to Lua aspmacs.lsp._mark_document_stale(src/lua_bindings/mod.rs:9742). An earlier pass of this framing checked onlyhandle_notificationand concluded "publishDiagnostics is the only one in Rust". That was the wrong lens boundary and the answer was wrong — recorded because it is the round's own defect class caught inside the round.DiagnosticStore.by_uriis keyed by URI alone (src/diag.rs:198), with noLspServerIdcomponent, unlike the other thirteen which key on(server, uri). Soforget_uri(sid, uri)cannot be server-scoped for the one store that has the uncorrelated writer. The tombstone must therefore be keyed to match whatever the store is keyed to, and the framing must not pretend a(sid, uri)tombstone protects a URI-only store.epochsis never pruned.DiagnosticStore::clearcreates anepochsentry (src/diag.rs:266,or_insert(0) += 1), as doesset(:250), and nothing removes them. Aforget_uriclearing onlyby_urileaves a URI-keyed leak behind, so the store's own forget path must drop the epoch too.
And the lens reproduces owner 6 independently. lean.lua writes
M.file_progress[uri] from
pmacs.lsp.on_notification("$/lean/fileProgress", …)
(builtin/runtime/lean.lua:648,651) through a second, Lua-side
notification dispatch table (dispatch_notification,
builtin/runtime/lsp.lua:1719, driven from :1867). It is
uncorrelated by the same mechanism, forget_uri cannot reach it, and it
is exactly §5's sixth path owner. The census arriving at it from a
different direction is the strongest evidence yet for §5's conclusion:
the Rust method handles what it can see, and the hook is the
mechanism that scales.
The gate: a bounded tombstone, keyed to match the store.
forget_uri records the forgotten URI as tombstoned; an uncorrelated
absorb for a tombstoned URI is dropped; the tombstone clears when
that URI is next did_opened, and wholesale at the two server-teardown
sites (start_generation, forget). Per finding 3 the diagnostics
tombstone is URI-keyed, not (sid, uri)-keyed, matching
by_uri; per finding 4 the forget path also drops the URI's epochs
entry.
Why not the cheaper membership gate. The tempting version needs no
new state: absorb only if (sid, uri) is in documents. It would be
a behaviour regression — servers legitimately publish diagnostics for
files the editor never opened, a crate-wide push naming a dependency,
and a membership gate drops every one. The tombstone drops only what
this editor explicitly forgot. There is precedent for exactly this
shape: handle_response already drops late arrivals via
client.cancelled_rids.remove(&rid) (src/lsp.rs:2549) — a
"forget-then-drop-late-arrivals" set. The tombstone is that pattern with
a URI key instead of a request id.
Why the existing epoch cannot serve. epochs is bumped
unconditionally by set with no epoch parameter and is read only by
epoch_for for render-cache invalidation (src/semantic_render.rs:2107).
Nothing compares an incoming write's epoch to a stored one, so a late
notification simply bumps it and looks current. stale_uris is likewise
a one-bit render-suppression flag that gates the read path only. And
LspClient::attempt (:3009) is a real generation counter but
per-server, useless for a closed URI on a live server. There is no
tombstone in the tree today — zero occurrences — so this is new state,
and the framing says so rather than implying it reuses something.
Verified absence of any existing guard, stated with evidence rather
than as a claim: grep -rn "documents.contains_key" src/ returns
zero, and the only documents membership test anywhere is
inbound_converted (src/lsp.rs:1498), which on a miss returns the
value unconverted and proceeds rather than dropping it.
absorb_publish_diagnostics (:2900) guards only that uri is a
string and diagnostics is an array, then writes.
Residue, stated: a server that legitimately publishes for the old
path after we renamed away from it is dropped until the tombstone
clears — the conservative direction, and the same trade forget_uri
already makes for the correlated stores. Two further holes are named,
not closed, because they are wider than this lane (§11): the fourteen
pub fn *_store() accessors hand out Arc<Mutex<…>> clones conferring
unrestricted write access, and the workspace/*/refresh handlers
re-pull by iterating attachments with no cross-check against
documents, so a purge can be followed by a fresh, correlated request
for a URI the editor no longer holds.
Worth stating because it is surprising, and it is now true twice:
neither server-scoped teardown clears the fourteen result stores.
start_generation clears deferred_notifications, pending_routes,
documents, and drains externals; forget clears pending_routes,
documents, externals, status_tracker, and project_servers. Rev 5
does not change that (it is a separate pre-existing question about
whether stale results should survive a restart), but it does mean
forget_uri has no precedent to copy for the store half, only for
the route/document/drain half.
Then the ordered sequence, per attachment:
- Flush any pending
didChangefor the old URI, so the server is not left with an edit it can no longer attribute. didClosethe old URI — which removes the open-document registration and nothing else.forget_uri(sid, old)— the purge/drain/clear above.- Re-run
ensure_server. Since #161 affinity keys on the detected project root, a rename across roots needs a different server; same-root renames reuse. When the server changes, step 3 runs against the old server anddidOpengoes to the new one. didOpenthe new URI with the buffer's current text and a fresh version.- Re-root the diagnostic views (below).
Re-rooting the diagnostic views: the seam, chosen (H3)
DiagnosticView.uri is set once at construction and the field's own
doc anticipates exactly this: "M5 may add re-rooting if a buffer is
renamed" (src/diag.rs:456-457). Rev 3 left it as "either a set_uri or
tear down and re-attach", which round 3 rightly rejected: neither option
explained how passive windows are reached, and the field is private
while View has no downcast, so an outside caller cannot reach it at
all. Two further facts make the naive options worse:
_attach_viewreaches only the active window. It takescore_borrow.active_window_mut()and errors if that window is not showing the buffer (lua_bindings/diag.rs:218-224). So a re-attach-per-window loop cannot be driven from Lua, and a passive split never got its overlay from this path in the first place.- A remove-and-re-push loses composition order. Overlays are an
ordered
Vec<Box<dyn View>>(window.rs:365) merged in sequence, so re-pushing puts the diagnostic underline at the end of the stack rather than where it was.
The seam: a View::rename_resource hook. View (src/view.rs)
already carries exactly this family of cross-window overlay hooks, added
by #113 round 6 for the same class of problem — overlay_identity, whose
own doc says it "lets disposal remove every window copy", and
clone_for_split. Rev 4 adds a third:
/// Retarget this overlay from `old_uri` to `new_uri` after a resource
/// rename. Default: no-op --- a view that renders nothing URI-keyed is
/// unaffected. Mutates in place, so the overlay keeps its position in
/// the window's composition order.
fn rename_resource(&mut self, _old_uri: &str, _new_uri: &str) {}
DiagnosticView overrides it to swap its own private field when the URI
matches. The field stays private; no downcast is needed; and any
future URI-bearing overlay opts in by overriding the same hook instead of
growing another special case.
The sweep has precedent too. Overlay disposal already walks every window and acts on identity:
for win in core.windows.values_mut() {
win.overlays.retain(|v| v.overlay_identity() != Some(id));
}
(lua_bindings/mod.rs:2044-2046.) The rename sweep is the same traversal
with retain replaced by a call to rename_resource — so every window
showing the buffer is reached, active or passive, in one pass, and order is
preserved because nothing is removed or re-pushed.
One thing this deliberately does not fix. A passive split that never
received a DiagnosticView still has none — _attach_view's
active-window-only restriction is a pre-existing gap (ensure_overlay
clone_for_splitcover the split-from-an-attached-window case, not the attach-while-passive case). Renaming cannot re-root an overlay that was never attached, and pretending otherwise would make acceptance 30 pass for the wrong reason. Named in §11.
Acceptance 30 is correspondingly stronger: diagnostics present before the rename, at least two windows showing the buffer, and afterwards only the new URI's diagnostics are visible and countable in both, with the old URI's store empty.
The dired subscriber, and the setter it needs (G2)
dired.lua updates any handle whose path equals or is under old,
renames its buffer, and reverts it. Rev 2 wrote that without noticing
that the module's own doc says it is impossible: "there is no
pmacs.buffer.set_name" (dired.lua:34-38), which is precisely why
Stage 1 chose buffer-per-directory over in-place repaint.
Rev 3 adds pmacs.buffer.set_name(buf, name) (Q#DR21). Three reasons
it is the right call over the alternative:
Buffer::set_namealready exists in Rust and already documents itself as "used by save-as and rename operations" (src/buffer.rs:453). Nothing new is being invented; an existing, purpose-built setter is being exposed.- §5 needs the same capability anyway for the
Buffer.namehalf of the transaction, so the Rust-side name update is in scope regardless. The Lua binding is the increment, and it is what makes the pathless case reachable. - The alternative — kill the dired buffer, recreate it under the new
name, and replace it in every window showing it — is a far larger
transaction that loses window placement, the cursor, the read-only
intercept,
set_round_trip_input, and the major mode, each of which would have to be re-established in the right order.
Uniqueness stays the caller's job, matching the Rust setter: dired
reuses claim_handle's existing <2>-variant uniquifier before setting.
Acceptance 28 therefore asserts the buffer name and that
handle_for_path dedup finds the buffer under its new path — not merely
that handle.path changed.
The workspace-edit applier (G1)
apply_workspace_edit captures origin as a string and restores with
find_or_open(origin) (lsp.lua:1338, :1352). No amount of
reconciliation reaches an already-captured Lua local, so the phantom
survives every design above. The applier itself must change:
- capture the buffer handle (
pmacs.window.buffer()), not the path; - restore with
pmacs.window.switch_buffer(origin_buf)when the handle is still valid; - no path fallback. If the origin buffer is gone, do nothing — the current code's fallback is what creates the phantom, and "return the user somewhere plausible" is not worth inventing a file that does not exist. The existing comment there already concedes the path "may have just been renamed or deleted"; it simply drew the wrong conclusion.
Ordering, which is already guaranteed
The reconciliation must complete before any awaiting coroutine
resumes, or a coroutine that renamed and then inspects a buffer sees
the pre-rename state. Verified: pmacs._async.tick calls
async_mod._tick() first and only then walks the settled ids firing
on_complete callbacks and resuming parked coroutines
(async.lua:401-421). So doing the work inside _tick's Rust closure is
correctly ordered by construction, not by luck.
The harvest (F6)
Rev 1 proposed a no-argument take_settled_renames() that drains "this
tick's" renames, which — as the review notes — needs either a second
queue or a scan of every settled entry. Neither was named, and both are
the one-off side channel COHERENCE.md §9 objects to.
Instead, tick returns a structured outcome. Rev 5's shape carried
settled and renames only, which §6 then asked to also deliver
deletions — an impossible read (R3). Rev 6 fixes the shape:
/// A settled filesystem mutation, with the paths the worker consumed.
pub enum ResourceOp {
Rename { from: PathBuf, to: PathBuf },
Remove { path: PathBuf },
}
pub struct TickOutcome {
pub settled: Vec<JobId>,
/// Successful resource mutations, in settle order.
pub resources: Vec<ResourceOp>,
}
One vector, not two fields — but NOT because it is ordered (rev 8,
F1). Rev 7 justified the single vector by claiming it preserved the
relative order of mutations settling in the same tick. That claim was
false and is withdrawn: tick (src/async_runtime.rs:1003) is
while let Ok(env) = self.main.try_recv(), a drain of the reply bus
with no execution token, so resources is in bus-arrival order,
which is not filesystem-mutation order — a worker can complete, be
descheduled before sending, and have a later mutation's reply arrive
first.
The vector stays because it is one homogeneous list of settled
mutations that consumers dispatch on by kind, which keeps
PendingJob.resource and the outcome the same shape. Nothing in the
reconciliation may depend on its sequence, and §6 states the contract
that makes that safe.
resources carries only jobs that settled PendingState::Complete
— a failed or cancelled mutation reconciles nothing, and fires no hook.
Its documentation must say bus-arrival order, not execution order,
in those words: a future consumer that reads "in settle order" and
infers causality is the exact mistake rev 7 made.
Settle identity and resource metadata still come out of one
transaction, from the loop at :1074-1108 that already borrows
pending and reads job.kind. The ~17 in-crate let _ = rt.tick();
call sites are unaffected; _tick reads .settled for the Lua table it
already builds and .resources for the reconciliation.
PendingJob gains one field, resource: Option<ResourceOp> —
rather than rev 5's rename_paths plus the separate remove-path §6
implied. dispatch_fs_rename moves both paths into the worker
closure (:871-879) and dispatch_fs_remove (:893) moves its one,
so nothing retains them either way. A single enum field rather than two
Options is deliberate: two would admit a both-Some state that cannot
occur, which is exactly the argument ResolvedTarget's own doc makes
against a shape whose "three states that cannot occur" every caller
would have to re-establish by hand (src/editor_core.rs:100-102).
§0.5 states why carrying this on the job at all is the coherent choice
over a side map, and that a general purpose/owner should later
subsume it.
Additivity
ReplyKind and the worker↔main wire are untouched; the new field and the
outcome struct are main-thread-only. pmacs-protocol is not involved
and does not bump — Stage 1 set that precedent by adding ReadDir
without one. m8_3 monkeypatches the Lua pmacs.fs.rename
(m8_3_acceptance.rs:182-184), so it never reaches the Rust path and its
count must not move; the fixture's two-phase temp-name rename
(init.lua:1197-1215) will reconcile twice, real→temp→final, landing
correctly.
6. Deleting a path something is holding (Q#DR18)
New in rev 2 (F2); given a shared seam and a revalidation rule in rev 3
(G4). pmacs.fs.remove changes disk and nothing else, so without a
policy a deleted file's buffer stays bound to a path that no longer
exists — and saving it recreates the file the user just deleted.
There are already two divergent deletion behaviors
Rev 2 wrote dired's policy and stopped, leaving the LSP path untouched
and unmentioned. Verified: apply_resource_op's delete arm
(mod.rs:3313-3341) deletes, then kills the buffer via
reg.borrow().find_by_path(&pb) — the same raw-path, first-match
lookup §5 fixes for rename, with no descendant handling and no
modified check. So an LSP-authored delete destroys unsaved work
today, silently, and a second buffer on the same path survives.
That data-loss half is PR #186's to fix (rev 7, round 6): it refuses
the delete before disk. What this lane takes from the same arm is the
lookup — descendants and duplicate path-bound buffers — and the removal
lifecycle (Q#DR27).
One seam
EditorCore::reconcile_delete(path) -> DeleteReconcile { killed, kept_modified, refused }, symmetric with reconcile_rename:
- walks the whole registry by normalized equality or path-component prefix, so descendants of a deleted directory are included and a second buffer on one path is not missed;
- kills unmodified buffers, through the full two-phase lifecycle below;
- keeps modified ones alive and returns them, so a caller can
report. (Rev 7, round 6: the synchronous caller can no longer
produce this outcome — #186 refuses the delete before disk when any
affected buffer is modified, so
apply_resource_opnever reaches a live file with a dirty buffer. The arm is retained for the asynchronous path, whichpmacs.fs.removeputs on a worker where no in-applier guard can see it.); - returns anything it could not kill separately from what it kept deliberately — the two are different events and collapsing them makes a failure look like a policy decision.
"Kills" means two phases, and no existing Rust path does both (R4)
Rev 5 said "kills" and stopped. Buffer removal in this substrate is two distinct phases, and the important part is that the only place they are composed is a Lua binding:
| Phase | What it does | Where |
|---|---|---|
| 1. Core | Refuses unknown ids and refuses to kill the last remaining buffer; drops the id from round_trip_buffers; closes side windows showing it (Q#BP10a — closed, not redirected, or an unrelated buffer is stranded in the panel slot); redirects every other window to a fallback (*scratch* or any other buffer), resetting text_view, cursor, selection, overlays, view_top and goal_col; then registry.remove |
EditorCore::kill_buffer, src/editor_core.rs:4590 |
| 2. Lua-side | Clears buffer-scoped keymaps, buffer-local config, folds, and fires the registered on_removed callbacks |
after_buffer_removed, src/lua_bindings/mod.rs:1602 |
pmacs.buffer.kill composes both (install_buffer_kill,
mod.rs:5476-5491), and its doc comment says exactly why it has to be
late-bound: it "needs an EditorCore handle to redirect any windows
showing the doomed buffer before removal".
apply_resource_op's delete arm does not. It calls
remove_buffer_and_fire (mod.rs:1592), which is registry.remove +
after_buffer_removed — phase 2 without phase 1. And
BufferRegistry::remove touches only self.buffers and self.order;
nothing in it looks at windows. So an LSP-authored delete leaves any
window displaying that buffer pointing at a removed id. That is a
third defect on that arm, alongside the missing dirty check and the
raw first-match lookup, and rev 5 named neither it nor the phase split.
So reconcile_delete composes the same pair pmacs.buffer.kill
composes, for every id it kills, and apply_resource_op's delete arm
is rerouted through it — which is what makes the window bug go away as a
side effect of the seam rather than as a separate patch. Note the
layering consequence: phase 2 lives in lua_bindings and needs &Lua,
so reconcile_delete returns the killed ids and its caller runs
phase 2 over them, exactly as pmacs.buffer.kill does. EditorCore does
not gain a Lua handle.
Failure modes, all of which a batch must survive (§9):
-
The last buffer cannot be killed.
kill_bufferreturns"cannot kill the last remaining buffer". Deleting the file behind the only open buffer therefore deletes the file and keeps the buffer — reported, not silently ignored, and returned inrefused. -
A mid-edit buffer refuses removal — and the refusal is NOT inert (rev 8, F2).
BufferRegistry::removereturnsRegistryError::ConcurrentEditwhenediting_in_progress()and leaves the registry untouched — but by thenkill_bufferhas already, in this order, dropped the id fromround_trip_buffers, closed any side window showing the buffer, and redirected every remaining window onto a fallback withtext_view,cursor,selection,overlays,view_topandgoal_colall reset. Rev 7 said to "treat a phase-1 failure as keep the buffer", which reads as though skipping phase 2 restored something. It does not — it only avoids firingon_removedfor a buffer that still exists.So
reconcile_deletepreflightsediting_in_progressbefore phase 1 and skips the buffer entirely, returning it inrefused. The preflight is sound, not merely cheap: phase 1 is entirelyEditorCore, which holds no Lua handle (C1), so nothing between the check andBufferRegistry::removecan re-enter Lua and begin an edit — the window that would make a preflight racy does not exist on this path. #186's Q#RD2 moves the same predicate into its validation phase for the same reason, so both lanes check it before mutating.A transactional kill was rejected: it needs a compensation path snapshotting and restoring window layout, side-window collapse and round-trip membership, and compensation can itself fail. A check that makes the failure unreachable is smaller than a rollback that handles it.
-
Neither failure aborts the reconciliation of other buffers. A directory delete reaching twelve descendants must not stop at the one that is mid-edit.
Both paths call it: the drain harvest for pmacs.fs.remove, and
apply_resource_op's delete arm, replacing its first-match lookup.
Both paths refuse. The policy is symmetric (rev 7 — round 6; rev 6 had them asymmetric, and that half is withdrawn):
- dired refuses the whole entry when a visited buffer is modified — the file is never deleted. A direct user gesture on a file with unsaved changes should stop, not proceed-and-cope.
apply_resource_oprefuses the operation when any affected buffer is modified — before touching disk, so nothing is deleted and nothing is removed from the registry. That is PR #186's Q#RD1, not this lane's decision, and this lane adopts it.
Rev 6 argued the opposite for the LSP arm: that the delete should proceed because "the user already accepted the refactor" and refusing mid-edit "leaves a half-applied refactor", with the orphaned buffer as accepted residue. That is withdrawn. Two reasons it was wrong beyond the cross-lane conflict:
- Accepting a refactor is not accepting the loss of edits made after it was requested, and pmacs cannot tell the two apart from inside the applier.
- The "half-applied refactor" argument leaned on an assumption about
WorkspaceEditrecovery that this document never actually checked. Checked now, against LSP 3.18 (§0, round 6): onlytextOnlyTransactionaldegrades to abort for resource operations,transactionalcovers them, and the specification states no default for a client that does not advertisefailureHandling— which pmacs does not. So the protocol does not license "deleting anyway is the safer half", and the argument had no support.
So on the synchronous path there is no orphan to reconcile. The residue that remains is the asynchronous one, below — and it is dired's alone.
Deletion is harvested, not hand-fired (G4)
Rev 2 left this ambiguous, and the two options really do produce
different primitive contracts. Rev 3 chooses the one symmetric with
rename: remove is harvested in the drain, through the same
TickOutcome.resources list, firing resource.deleted(path).
PendingJob carries the path for JobKind::FsRemove in the same
Option<ResourceOp> field it carries a rename's pair in (§5, R3) — one
field, one enum, so the two cannot both be set.
The reason is the same as Q#DR14's: a fire-and-forget pmacs.fs.remove
must reconcile too, and dired firing the hook itself after its own
:await() would protect only dired. The policy (what to delete) is
dired's preflight; the mechanism (what to reconcile once the syscall
lands) is the primitive's.
Reconciliation is order-independent, and says so (Q#DR29, new in rev 8)
Each settled mutation reconciles on its own. Nothing depends on the
relative order of two mutations that were in flight simultaneously, and
the framing promises nothing about it — because TickOutcome.resources
is bus-arrival order and the runtime establishes no execution order (§5,
F1).
That is safe rather than merely honest, for three verified reasons:
- Independent mutations commute. Disjoint paths and disjoint subtrees reconcile to the same registry state in either order, which is every case the shipped consumers can produce.
- Interdependent concurrent mutations cannot arise from any production
path. dired serializes — one coroutine per batch, awaiting each
op before dispatching the next (§9).
apply_resource_opis synchronous on the main thread and never enters the drain. And §2 verifiedpmacs.fs.renameandpmacs.fs.removehave zero production callers besides tests. - The primitive already instructs callers to serialize.
fs.lua:155-165: "If a package needs at-most-one-pending semantics for mutations, it should serialize on the package side (await each op before dispatching the next)." §9 already cites this as why dired does. A caller that ignores it owns the result.
Why no static ordering rule is offered. It was worked out rather
than waved away: rename dir→newdir racing delete dir/child.txt
needs delete-then-rename if the delete ran first on disk, and
rename-then-delete if the rename did. A fixed "deletes before renames"
rule gets one right and the other wrong — the kill misses, the rename
then rebinds the buffer onto a path whose file is gone, and it survives
pointing at nothing. There is no rule short of a real execution-order
token, which §11 defers with a checkable trigger.
The residue, stated: a third-party package that fire-and-forgets two interdependent mutations, against the documented instruction, can leave a buffer bound to a stale path or kill one that should have been rebound. Recoverable, visible, and not data loss — but real. §13 pins the independent case in both arrival orders and deliberately pins no interdependent one, because a test cannot pin an order the mechanism does not establish.
The four cases
| Case | Policy |
|---|---|
| Unmodified visited file | Delete, then kill the buffer |
| Modified visited file | Refuse that entry. Report it; the rest of the batch proceeds |
| Open buffers under a deleted directory | Same two rules, applied to every buffer whose path is under it |
| Open dired handles on the path or under it | Killed, via resource.deleted |
Refusing on modified is a deliberate divergence from Emacs, which
deletes and leaves an orphaned buffer. The reasoning: the orphan is
indistinguishable from a normal buffer, and the next C-x C-s silently
resurrects the file. A refusal is visible, recoverable, and the user can
save-or-discard and retry. PR #186 reached the same conclusion
independently for the LSP arm (its Q#RD1 — "refuse, do not prompt, do
not save, do not back up"), which is why rev 7's policy is symmetric
rather than split.
Checked twice — and still only best-effort (G4, narrowed H1)
buf:is_modified() is exposed to Lua (mod.rs:1261), so dired decides
this itself. It checks twice:
- Before the confirm, so the prompt states the skip up front:
Delete 3 entries? (1 has unsaved changes and will be skipped) (y/n). - Again immediately before dispatching each removal. The prompt is not modal against the world: another frontend attached to the same daemon can edit a buffer while it is open, and the batch is serialized (§9), so there is a real window between the answer and the n-th dispatch.
And that is where the guarantee stops. It is a pre-dispatch check, not a
lock (H1). Rev 3 said "immediately before each syscall", which was wrong
about where the boundary is: pmacs.fs.remove dispatches a worker, so
the syscall happens later, on another thread, in remove_blocking. An
edit landing between dispatch and remove_file/remove_dir is not
detected by anything, and no acceptance test can pin an interval that is
not closed — which is why rev 3's acceptance 20 (edit before y) could
never have detected this.
So the promise is stated at its real strength, the same way G6 forced for
R: a TOCTOU-bounded pre-dispatch refusal. What survives the race is
worth being precise about:
- the buffer survives with its contents —
reconcile_deletekeeps a modified buffer rather than killing it, and that half is robust, because it runs at drain time on whatever state exists then; - the file is gone.
Which means the orphaned-modified-buffer residue is dired's, and only
dired's (rev 7 — round 6). Rev 6 called it "the residue rev 3 scoped
to the LSP path applies to dired too — one deferral, two paths". After
#186 that is backwards: the LSP path has no residue, because it
refuses before disk. Dired's remains because dired never goes
through apply_resource_op — it calls pmacs.fs.remove, which
dispatches a worker (§2), so a synchronous guard inside the applier
cannot reach it at any strength. §11 carries it as one deferral, one
path.
Closing it properly needs one of two things this stage should not invent:
a reservation (some lock or generation the worker re-validates before
the syscall), or a synchronous delete path. The synchronous option is
tempting because apply_resource_op already does main-thread
std::fs::remove_*, and it would remove the need for the delete harvest
entirely — but it puts N blocking syscalls on the main thread for an
N-entry batch, and a fire-and-forget pmacs.fs.remove from any other
caller would still need the harvest. Named in §11.
7. Confirmation (Q#DR15)
Destructive operations confirm: x, D, and (in 2c) a recursive delete
and an overwriting copy. There is no helper to do it with (C4).
Stage 2 adds builtin/runtime/minibuffer.lua defining
pmacs.minibuffer.confirm { prompt, on_yes, on_no, on_cancel },
loaded before dired.lua in editor.rs's explicit sequence.
Three outcomes, because the substrate already has three (rev 8,
F5). Rev 7 exposed only on_yes, which cannot express §8's copy
contract — where declining an overwrite continues, copying the
non-colliding entries. There was no negative continuation to hang that
on. And the underlying primitive is richer than the helper was:
pmacs.minibuffer.read takes a required on_accept and an optional
on_cancel (src/lua_bindings/mod.rs:13403-13417), and the session
carries both (src/minibuffer.rs:403-405), with cancel() returning
the on-cancel callback (:353). A prompt therefore has
accept-affirmative, accept-negative and cancel — and rev 7 collapsed all
three into "call on_yes or do nothing".
| Outcome | Trigger | Runs |
|---|---|---|
| yes | y / yes, case-insensitive |
on_yes |
| no | any other accepted text, including empty RET |
on_no |
| cancel | C-g / Escape |
on_cancel — and nothing if it is absent |
The fail-closed default from Q#DR15 is unchanged: an empty RET is
still not affirmative. It now has somewhere to go instead of being
indistinguishable from silence.
Cancel does NOT fall through to on_no, and that is the decision
worth arguing with. For x and D the two are equivalent — decline
and cancel both mean "delete nothing". For C they are not: declining
means copy the non-colliding entries, a partial action, while
cancelling means abandon the batch. Defaulting cancel to on_no
would make C-g perform a partial copy, which is precisely what a user
pressing C-g is trying to avoid. So cancel is inert unless a caller
opts in, and §8's C flow is the only caller that does.
That sequence is a real edit to src/editor.rs — around thirty
include_str! entries at src/editor.rs:395-660, each naming its
chunk — so 2b touches a Rust file, which §16 now says and rev 5 did
not (R6). Ordering is load-bearing, not cosmetic: dired.lua calls
pmacs.minibuffer.confirm at load-time-registered command bodies, so
the defining chunk must run first.
It takes no completion source, and that is the decision, not an omission:
- With no source,
resolve_accepted_valuereturns typed text verbatim (:565-567), so the answer is exactly what the user typed and the shadowing question does not arise. - Affirmative is
yoryes, case-insensitively. Everything else, including an emptyRET, is no. A destructive prompt must fail closed. - The alternative —
autosave.lua's two-candidate source — happens to be safe today only by lexicographic luck.fuzzy_scorereturnsSome(0)for an empty needle (minibuffer.rs:638-640) so both candidates match, andfilter_and_sortbreaks the score tie ona.1.cmp(b.1)— plain ascending string order (:678)."no" < "yes", soselected = Some(0)resolves to"no". The right default, reached by an accident of spelling: the safe answer wins only because it happens to sort first. Relabel the pair and it inverts —{"delete", "keep"}or{"apply", "cancel"}both put the destructive answer at index 0 and make an emptyRETexecute. (Note it is spelling, not list order, that decides: the tie-break is lexicographic, so writing{"no", "yes"}changes nothing.) Not a foundation for four new destructive call sites.
The prompt states the count and the operation — Delete 3 marked entries? (y/n) — because a confirmation that does not say what it is
confirming is decoration.
Migrating autosave.lua to the helper is a named follow-up (§11), not
part of this stage. Both shapes already answer "no" to an empty RET,
so the migration is behavior-preserving; it still touches the
crash-recovery prompt, which does not belong in a dired PR.
8. The operations, individually
d / x (flag and execute). d sets D and advances. x collects
D-flagged basenames, applies §6's visited-path policy, confirms with the
count, then deletes serially (§9) via pmacs.fs.remove, which handles
files and empty directories (C5). A non-empty directory fails with the
kernel's ENOTEMPTY, reported as such in 2b; 2c's remove_dir_all
plus dired.recursive-deletes addresses it. Then revert once.
D (delete now). Same deletion path and same §6 policy, targeting
the §4 set rather than the flags.
R (rename). The entry at point, regardless of marks (§4).
Prompts with initial = the current basename and no source, so the
typed name arrives verbatim (minibuffer.rs:565-567). A bare name
resolves against handle.path; a name containing / is a path, relative
to handle.path unless absolute. Reconciles every path owner by §5,
including on a directory.
The no-clobber guarantee is a preflight, and rev 3 says so (G6).
rename_blockingcalls plainstd::fs::rename(fs.rs:492-499), which on Unix silently replaces an existing target. Dired stats the destination and refuses if it exists, but that check and the syscall are not atomic: a target created in between is overwritten. Rev 2's "refuses an existing target" overstated a best-effort, TOCTOU-bounded refusal, and acceptance 12 is reworded to promise only what is delivered. Closing it needs a no-replace primitive —renameat2(RENAME_NOREPLACE)on Linux,renamex_npon macOS, with a link/unlink fallback elsewhere — which is a portability question of its own and is deferred (§11).
w (copy filename). Carried forward from the parent's approved
table, and set-based (§4): with marks, it copies every marked name,
newline-separated; with none, the entry at point. Non-destructive, no
confirm.
It needs a public kill-ring entry point, which does not exist (G5).
push_entryis alocal function(killring.lua:93) and the publiccopy()requires a region — it callsed.region()and fails with "no region" otherwise (:184-194). Rev 3 addspmacs.killring.push(text)(Q#DR22) with exactly the semanticscopy()already establishes for non-kill text:push_entry, mirror to the OS clipboard viaed.clipboard_set, and break the kill chain (fail_kill), because a filename copy is not an appendable kill and must not merge into an adjacentC-krun.
M (chmod). New scope — needs explicit approval (F3). Prompts for an
octal mode string, no source, validated to [0, 07777] before
dispatch to match fs.chmod's own guard (fs.lua:181-183). Applies to
the §4 set.
It refuses symlink entries.
chmodfollows symlinks (fs.lua:145-153) while the listing is lstat-based, so chmodding a symlink line silently changes a different file's mode and the refreshed listing shows the link's own unchanged bits — the change appears to have done nothing. The parent framing already decided this for the wdired surface: the fixture "rejects symlink perms edits at intercept time for exactly this reason", and that decision "carries over unchanged" (dired-framing.md:543-546). Rev 1 proposed warning after the fact instead, which is materially different and contradicted approved text. A refusal is reported per entry and does not abort the batch.
+ (create directory, 2c). Prompts for a name, no source, resolved
against handle.path. opts.parents for create_dir_all.
C (copy, 2c) — full command flow (F7). Targets the §4 set.
- Destination prompt. One source prompts for a destination path; several sources require an existing directory and the command refuses before dispatching anything if the answer is not one.
- Collision scan, before any copy. Resolve every source to its destination path and stat each. This happens up front so the user is asked once, not once per file mid-batch.
- Confirm. With no collisions and one source, no prompt — a copy
onto free space is not destructive. With collisions, one confirm
naming the count:
Overwrite 2 existing files? (y/n). Declining (on_no, including an emptyRET) skips the colliding entries and copies the rest, rather than abandoning the batch, which matches §9's per-entry failure rule. Cancelling (on_cancel,C-g) abandons the whole batch and copies nothing — the distinction §7 exists to preserve, and the reasonCis the one caller that passes all three callbacks. - Dispatch with
opts.overwriteset only for the entries the user confirmed. The primitive still refuses an existing target without it, so the guard is enforced at both layers. - A directory source is refused — the parent's decision, and the honest one while no recursive-copy primitive exists.
Mode bits are preserved.
9. Execution: serialize, report, then revert (Q#DR16)
fs.lua:155-165 instructs packages needing at-most-one-pending
mutation semantics to "await each op before dispatching the next".
Stage 2 does that, for three reasons beyond obedience: a batch is
user-initiated and small; interleaved failures are attributable to a
specific entry; and awaiting reaps the pending entry, keeping dired out
of the leak in §2.
The contract:
- One coroutine per batch,
pmacs.async, awaiting each op in turn. - A per-entry failure does not abort the batch. Collect
{basename, message}, continue. - One status line at the end, naming counts:
dired: deleted 2, failed 1 (report.log: Directory not empty). Not one status per entry — the last would be the only one visible. - Marks consumed by a successful operation are cleared; marks on
entries that failed are kept, so
xagain retries exactly the failures. - Revert once, after the batch, not per entry: the listing is a wholesale repaint and N repaints for N deletions is both slower and visibly wrong.
- Every post-
awaitre-seat checkspmacs.window.buffer()first (dired.lua:400-404), the rule #165's review round produced. - No part of a batch may run inside
pmacs.window.commit_to(new in rev 5, from N2). The commit scope refuses anawait(Q#JR14b) —commit_to_refuses_an_await_and_restorespins the raise, whose message contains both"cannot await inside"and"commit_to"— and a batch is defined by awaiting each op in turn. Stage 1 already solved the same problem the same way:open_directorydoes itsread_listingawait outside the commit and puts only the non-yielding mutation inside (dired.lua:640-655). Stage 2's operations follow that shape — await the whole batch first, then, if a destination was captured, do the repaint and re-seat inside a commit. Stage 2's operations are all initiated from an already-active dired buffer rather than from a captured destination, so in practiceopts.destis nil on these paths and the ambient rule above governs; the constraint is stated because the shape must not be copied wrongly if that ever changes.
10. Staging: three PRs (Q#DR17, revised in rev 4)
Rev 4 takes the further cut, as directed in round 3. Rev 3 already recorded that 2a's Rust had outgrown "one narrowly-scoped change"; round 3 called it, and the three rounds of findings on the reconciliation half are the evidence — every one of G1, G3, H1, H2, and H3 was about the transaction, not about marks.
Rev 6 keeps the three-PR cut unchanged. The cut was re-examined
against the round-4 re-scout and again after round 5, and still holds:
2a is substrate-only, 2b is the dired surface, 2c is the three additive
primitives, and Stage 3 is wdired. Nothing that landed in the 153
commits moves work across those lines — #182 changed the shape of
dired's entry point but added no Stage 2 work, and #179/#181 added none.
Q#DR25 has left the table entirely (R1): it is the
generated-buffer-immutability lane's, not 2b's (§3.1). And the
acceptance row is corrected (R2) — rev 5's allocation put two
substrate items in 2b and one dired item in 2a.
| 2a — reconciliation | 2b — marks and operations | 2c — new primitives | |
|---|---|---|---|
| User-visible surface | none | m u U t d x D R w M |
+ C, recursive delete |
| Rust | reconcile_rename, reconcile_delete (both kill phases), ResourceOp + TickOutcome, PendingJob.resource, forget_uri (14 stores + documents + route purge + URI-scoped drain), View::rename_resource + the window sweep, apply_resource_op (rename and delete arms), apply_workspace_edit origin, pmacs.buffer.set_name |
pmacs.killring.push |
mkdir, copy, remove_dir_all; JobKind 12 → 15 |
| Lua | the two hook subscribers in lsp.lua |
all of dired.lua's mark/op layer, including its resource.renamed subscriber, plus minibuffer.lua |
two ops |
| Other files | — | src/editor.rs, to add minibuffer.lua to the explicit load sequence (R6) |
— |
| Config keys | none | none | dired.recursive-deletes |
| Acceptance | 23–38, 50–53 | 1–22, 39–41 | 42–47 |
Why 2a first, with no dired surface at all. It is a self-contained
substrate correctness fix that stands on its own merits: it closes the
path where renaming the active file through a workspace edit
materializes a phantom buffer (lsp.lua:1352); it replaces the
raw first-match, un-normalized registry lookup that both
apply_resource_op arms use with one shared prefix-aware query; and it
supplies the full removal lifecycle that neither existing path
performs (§6, Q#DR27). None of that needs dired to be worth fixing, and
none of it is dired's fault. (Rev 6 also claimed 2a closes the
LSP-authored delete that destroys unsaved work. PR #186 owns that —
it refuses before disk rather than reconciling after — so rev 7 drops
the claim. What is left is still substrate-level and still worth its own
review round.) Landing it
alone also means the LSP lifecycle work — a fourteen-store inventory, an
in-flight route purge, an awaiter drain, and a new View hook swept across
every window — gets a review round of its own rather than sharing one with
a mark column.
Why marks second rather than first. The mark layer is the more visible
work and the more pleasant to review, which is exactly the argument for
not putting it in the same PR as the substrate change: it would absorb
the attention. It also genuinely depends on 2a — R is unsafe on a
directory without the transaction, and D/x are unsafe on a visited file
without reconcile_delete.
Why 2c last and separate. Three additive fs primitives with their own
overwrite and lstat-safety semantics (§8, §13 items 42–47). Nothing in 2b
needs them; remove already covers files and empty directories (C5).
The cost, stated. Three PRs is three review cycles instead of two, and 2a ships nothing a user can see — its acceptance is entirely substrate-level (a no-await rename, a two-window diagnostic re-root, an LSP store inventory). That is the trade round 3 accepted, and the reason it is worth it: a bug in 2a is a silent data-loss bug, and those are the ones that deserve an undivided reviewer.
11. Deferred (named)
wdired— Stage 3, with the frozen fixture as its reference (C3).- The fire-and-forget pending-entry leak (§2). Pre-existing, verified,
orthogonal, observable as
pending_lengrowth. Needs its own lane; the candidate fix is a settled-entry sweep with a reap policy, which is a decision about handle lifetime, not a patch. - A general
purpose/ownerfield onPendingJob, perCOHERENCE.md§9, which should subsume §5'sresourcefield. - Migrating
autosave.luatopmacs.minibuffer.confirm(§7). - Multi-file
Rinto a target directory, and%-regexp marking — both need a target/pattern concept Stage 2 does not build. This is whyRis point-based in §4 rather than an unstated exception. - Symbolic chmod (
u+x), needing a mode-expression parser. i(insert subdirectory) — the recursive in-buffer case, already a named deferral indocs/dired-framing.md§13, and the place a shared tree primitive (COHERENCE.md§14) would land.!shell command on marks, compress, symlink, hardlink.- A no-replace rename primitive (G6) —
renameat2(RENAME_NOREPLACE)on Linux,renamex_npon macOS, link/unlink elsewhere. Until thenR's refusal is a TOCTOU-bounded preflight, which §8 states plainly. - The async race: a buffer modified after dired dispatch is still
orphaned — one deferral, one path (H1; narrowed in rev 7, round
6). Rev 6 carried this as two paths, LSP and dired. It is dired's
alone: #186 refuses the synchronous
apply_resource_opdelete before disk, so that path orphans nothing, while dired callspmacs.fs.remove, which dispatches a worker — the interval between dired's pre-dispatch check andremove_blocking'sremove_fileis not closed by anything, and a buffer modified inside it loses its file. Closing it needs a reservation the worker re-validates before the syscall, or a synchronous delete path — the latter would also make the delete harvest unnecessary for dired, but puts N blocking syscalls on the main thread for an N-entry batch. Note this is the only thing left of rev 6's orphaning story; the LSP half went away with #186 rather than being solved here. - Two wider LSP-store holes the tombstone does not close (new in
rev 8, F4). The fourteen
pub fn *_store()accessors (src/lsp.rs:1031-1114) each hand out anArc<Mutex<…>>clone, conferring unrestrictedset/clear/mark_staleon any holder — the fifteen Luaclearbindings already prove the write capability crosses the FFI boundary, and nothing in the type system stops aset. Separately, theworkspace/inlayHint/refreshandworkspace/semanticTokens/refreshhandlers (builtin/runtime/lsp.lua:1845,1853) re-pull by iteratingattachmentswith no cross-check againstdocuments, so a purge can be followed by a fresh — and route-correlated, therefore ungated — request for a URI the editor no longer holds. Both are pre-existing, both are wider than a dired stage, and both would be in scope for an LSP-lifecycle lane rather than this one. pmacs.fs.removeitself is guarded by neither lane (new in rev 7, round 6). After both land, the refusal exists at theapply_resource_opprimitive (#186) and in dired's policy layer (§6) — butpmacs.fs.removeis public Lua API with no dirty check of its own, so a third caller inherits neither guard. This is latent rather than live: §2 verifiedpmacs.fs.removehas zero production callers (onlytests/m8_1_acceptance.rs:438,439,472). Naming it because the natural reading of "both lanes guard deletion" is that the primitive is guarded, and it is not — the guards are one layer above it on each side.- A passive window that never received a
DiagnosticViewstill has none (H3)._attach_viewtakesactive_window_mut()and errors otherwise (lua_bindings/diag.rs:218-224);ensure_overlay+clone_for_splitcover split-from-attached, not attach-while-passive. Pre-existing, and rename cannot re-root an overlay that was never attached. - The server-restart teardown does not clear the fourteen result
stores (
lsp.rs:1307-1345clears routes, documents, and deferred notifications only). Pre-existing; whether stale results should survive a restart is its own question, andforget_urideliberately does not answer it. - The rooturi sink's weak wait predicate (
m4_acceptance.rs:5499) — same class as the config-sink race fixed in #174, not observed failing, and the obvious fix would trade a precise regression diff for a vague timeout. Needs a record terminator in the fake server first. Confirmed in rev 5, and now backed in-tree: #174 merged during this re-scout (main@0442d78), fixed the config sink by waiting for a complete JSONL record (ends_with('\n')rather thancontains("probe")), and deliberately left the rooturi predicate alone, adding a comment atm4_acceptance.rs:5486-5496giving the same three reasons this deferral gives. So this item is no longer a claim of this framing; it is a claim of the tree. - Recursive copy — 2c refuses directory sources; a real
copy -rprimitive is separate. - The whole generated-buffer immutability class, including dired's
instance (Q#DR25, withdrawn from this document in rev 6 — R1).
Dired's
paintwrites throughbypass_interceptover a writable rope behind an erroring intercept, soM-x buffer.undoempties a listing. So dolistview.lua,compile.lua'sensure_slot(*compilation*and*shell-command*) and the independent*search-results*panel — one idiom, four instances, and no Lua caller anywhere setsread_onlybecause there is deliberately no Luaset_read_only. Owned by thegenerated-buffer-immutabilitylane; §3.1 records what that lane inherits from this re-scout, and why neither landing order conflicts with Stage 2b. Note the two sub-shapes that lane must reconcile and this one did not have to: dired and listview write whole-buffer replaces and are the cheap half, while*compilation*,*shell-command*and*search-results*append per batch and need a streaming variant of the primitive that does not exist. - Two persisted path owners, named not fixed (rev 5, W5).
saveplace.luakeys its places file by path (load_places,:31-45;restore_active,:69-79) andrecentf.luakeeps a path MRU list (record,:36-46). Neither is an in-memory owner — both rebuild their index frompmacs.stateon every call — so a rename leaves a stale line on disk, not stale editor state. The consequence is bounded and self-correcting: entries are capped and LRU-evicted, and the worst case is a future unrelated file at the old path restoring a wrong cursor. Reconciling them would mean teaching the reconciliation transaction to rewrite persisted state, which is a persistence-arc question. Aresource.renamedsubscriber in each is the cheap fix whenever someone wants it. lean.lua'sM.file_progressis not re-rooted (rev 5, owner 6; reaffirmed in rev 6, R5). Stage 2 supplies the hook that makes a subscriber possible and does not write one — Lean's arc owns that file, and Stage 5's goal view is the consumer that would notice. Round 5 found §1 claiming the opposite; this deferral was the correct half and §1 was corrected to match it, not the other way round. Until Lean subscribes, a rename leaves its progress entry under the old URI, and nothing in Stage 2 detects that.
12. Bets
- B1. 2b needs no new
pmacs.fsop. Falsified if any ofm u U t d x D R w Mcannot be built on the existing five. (Rests on C5, which was read offremove_blockingdirectly.) (Rev 5: rev 4 wrote "2a" here, which contradicted its own §10 table — 2a ships no dired surface at all, so it cannot need an op for a key it does not bind. Corrected label; the bet is unchanged.) - B1b. The two new hooks need no new hook machinery —
run_hook_if_defined(mod.rs:1623) andpmacs.hook.run(:6005) already exist, andresource.renamed/resource.deletedare ordinary names in that registry. Falsified if firing a hook from the reconcile path needs a new dispatch mechanism. - B2. The rename rebind needs no protocol bump and leaves the
worker↔main wire untouched. Falsified by any change to
ReplyKindorSUPPORTED. - B3. The frozen
m8_1/m8_2/m8_3counts are unchanged by the reconciliation. Not obvious, and less obvious in rev 2: the fixture renames files it lists, so a test holding a buffer on a renamed path would newly see its path and now its name move, andm8_3monkeypatches the Luarenameso it never reaches the Rust path at all. The additivity gate is a real check, not a formality. - B3b. No existing LSP test changes behavior. Not obvious: the
resource.renamedsubscriber issues didClose/didOpen and may re-runensure_server, so any suite that renames a file with a server attached is in the blast radius.m4/lsp_multi_rootcounts are gated for exactly this reason. - B4. No GPU or TUI frontend change. Marks are buffer text and the
keymap is mode-scoped;
set_round_trip_inputis already set by Stage 1. - B5.
describe_key_identifies_every_default_bindingstays green without further surgery — #165 already taught it per-binding mode context, and Stage 2 only adds more bindings in the same mode. - B6 (added in rev 5; retired in rev 6, R1 — it was a bet
about Q#DR25, which is no longer this document's. Handed to §3.1 for
the
generated-buffer-immutabilitylane, where it is worth keeping: the primitive, its Lua binding and the fan-out all exist (src/buffer.rs:545,src/lua_bindings/mod.rs:3079-3095), so dired's adoption should need no Rust — falsified if it requires a new binding, aset_read_onlyexposure, or any change outsidebuiltin/runtime/dired.lua. Not obvious in one respect: dropping the intercept removes the only thing that currently produces dired's refusal message, so if any Stage 1 acceptance depends on the intercept's exact wording rather than on the substringread-only, this bet fails. §3.1 checked the one test that looked like a risk and it does not. - B8 (new in rev 7, round 6). Adopting #186's refusal costs this
lane no design change beyond deletion:
reconcile_delete's signature, its callers, the drain harvest andresource.deletedare all unaffected, because the refusal happens before the seam rather than inside it. Falsified if 2a turns out to need a different seam shape once #186 has landed — most plausibly if #186's validation phase ends up owning the affected-set query in a formreconcile_deletecannot reuse, in which case the two must be reconciled before 2a is cut rather than after. - B7 (new in rev 5, N3). The journey ratchet stays at ≥ 24
rows in
tests/journey_acceptance.rsand keeps its GPU row intests/gpu_invocation_acceptance.rs, with no row weakened. Falsified by any deletion,#[ignore], or assertion relaxation in either — and the ratchet's own doc says a green test that cannot fail must be deleted rather than kept, so "still green" is not the bet; "still biting" is.
13. Acceptance
2b — marks (stage labels follow §10's three-PR cut)
mmarks the entry at point and advances; the mark renders at_layout.MARK_STARTand no other column moves (asserted against the exported layout, not hardcoded offsets).uclears and advances;Uclears every mark;tinverts*and leavesDuntouched.- Marks survive a sort (
s) — the basename-keyed set must follow its entries to their new lines. - A mark on a basename that vanishes is pruned by a revert and is absent from the next batch.
- Marks are per-buffer: two dired buffers on two directories keep independent sets.
Rwith unrelated marks present renames the entry at point, not the marked set, and leaves every mark intact (F4 — pins that §4's point-based class is real and not an accident).
2b — operations
dthenxdeletes the flagged file, after a confirm; declining deletes nothing.xconsumesDflags only — a*-marked entry survives it — and with nothing flagged it is a reported no-op.Dwith nothing marked targets the entry at point (§4).xon an empty directory succeeds; on a non-empty directory it fails, the message names the entry, and the rest of the batch still runs.- A batch with one failure reports both counts in one status line, and the failed entry keeps its mark while the successful one loses it.
Rrenames; a bare name resolves against the listing's directory; a target observed to exist at preflight is refused — the honest contract (G6), sincestd::fs::renamewould replace one appearing afterwards.Mapplies an octal mode to the marked set; an out-of-range mode is refused before dispatch.Mrefuses a symlink entry (Q#DR19, F3/F5): the mode is reported unchanged, the target file's mode is asserted untouched, and the batch continues. (A warning-after-the-fact implementation fails this.)wcopies the marked filenames to the kill ring, newline-separated, and the entry at point when nothing is marked; the OS clipboard mirrors it and the kill chain is broken, so a followingC-kdoes not append to it.- The listing reverts once after a batch, not per entry.
2b — deletion policy (§6)
- Deleting an unmodified visited file kills its buffer.
- Deleting a modified visited file is refused; the buffer survives with its contents, and the file is still on disk.
- The confirm prompt states the skip before the user answers, not after.
- A buffer modified after the prompt appears but before
yis skipped and reported: the check is re-run before each dispatch, because another frontend can edit during the prompt and the batch is serialized. (An implementation that checks only once, up front, fails this.) This is the whole of the guarantee — the dispatch-to-syscall interval is open (H1) and deliberately has no test, because no test can pin an interval that is not closed. What §13 item 24 pins instead is the half that is robust: the modified buffer survives. - Deleting a directory kills buffers on its descendants.
- An open dired handle on a deleted directory is closed
(
resource.deleted). (Items 23–24 moved to 2a in rev 6 — R2. They exerciseapply_resource_opand the drain harvest, neither of which 2b builds.)
2a — reconciliation (§5, §6)
-
apply_resource_op's delete reaches descendants and a second buffer on the same path — the raw-path first-match lookup replaced by the shared prefix-aware, normalizing query (G4). (Rev 7, round 6: the modified-buffer half of this item moved to PR #186, which refuses the delete before disk, so by the time this lane's reconciliation runs there is no modified buffer on the synchronous path to spare. Assert the lookup fix here; #186 asserts the refusal. If #186 has not landed when 2a is implemented, this item still stands on the lookup alone.) -
A fire-and-forget
pmacs.fs.removereconciles too — never taking the handle still kills the unmodified buffer, which is what makes the drain harvest the right seam rather than dired firing the hook. -
No-await rename: dispatch
pmacs.fs.rename, never take the result, pump — the open buffer's path has moved. (Fails if the reconciliation lives at result-consumption.) -
Directory rename: a buffer open on
dir/child.txtfollowsdir→newdir. -
Every match, not the first (F5): two descendant buffers under the renamed directory and two buffers visiting the same exact path all move. (One child buffer does not defeat a first-match implementation; this does.)
-
False prefix: renaming
/…/foodoes not rebind a buffer on/…/foobar. -
Buffer name follows the path — tested in BOTH directions (F3). (a) A buffer opened by a relative path (name
foo.rs, stored path/abs/dir/foo.rs) gets its name updated, because the name normalizes to the stored path. (Rev 7's string-equality rule fails this — it is the case that motivated the rewrite.) (b) A buffer with a genuinely custom name (notes) keeps it. (A rule that updated unconditionally, or matched on basename, fails this — it is what stops the fix becoming a name-clobberer.) Both arms are required; either alone admits a wrong rule. -
An attached LSP buffer with diagnostics present before the rename, shown in at least TWO windows (H3): afterwards both windows render the new URI's diagnostics, the old URI's store is empty, and each window's overlay keeps its position in the composition order — not merely
rec.uriupdated (DiagnosticView.uriis set once at construction, and a remove-and-re-push would pass a one-window test while reordering the stack). -
The store inventory (H2): after a rename, an entry that existed under the old URI in each of the fourteen stores plus
documentsis gone, and a response already in flight at rename time does not repopulate the old key — thepending_routespurge and awaiter drain, without which the clear is undone by arrival. 31b. A latepublishDiagnosticsfor the OLD URI does not resurrect it (F4): after the rename completes, feed the manager atextDocument/publishDiagnosticsnotification naming the old URI; the old key stays empty and the new URI's diagnostics are unaffected. (This is uncorrelated — it carries no request id — so thepending_routespurge cannot see it, and item 31 passes with the bug present. Fails against aforget_uriwith no tombstone.) Its companion asserts the tombstone does not over-reach: apublishDiagnosticsfor a different, never-opened URI is still absorbed, which is what a membership gate would have broken. 31c.pmacs.lsp.forget_uri's error contract (P2): it raises for an unknown server id, and succeeds for a URI with no state under a known server. (The second arm is the one that matters — the subscriber fires for every renamed path, including buffers that never attached, so an over-strict binding would turn the common case into an error inside a hook.) -
A rename across project roots re-runs
ensure_serverand the buffer ends up attached to a different server; a same-root rename reuses the existing one (#161's affinity key). -
The workspace-edit origin: renaming the active file through the full
apply_workspace_editpath leaves no phantom empty buffer at the obsolete path, and the user is returned to the same buffer (now under its new path). (G1 — this is a change to the applier, which must capture the buffer handle; no reconciliation can reach the string it captures today.) -
When the origin buffer is gone after the edit, the applier restores nothing rather than falling back to the old path.
-
apply_resource_op's rename finds a buffer whose stored path is normalized but whose op names it un-normalized (the:3249fix). -
A failed rename reconciles nothing.
-
Additivity:
m8_1,m8_2,m8_3at unchanged counts.
2b — the shared helpers
pmacs.minibuffer.confirm— all three outcomes (Q#DR15, F5). (a) An emptyRETdoes not callon_yes;y,Y,yes,YESall do;nand arbitrary text do not. (A typed-ntest alone would not catch a completion source being reintroduced — the empty-RETarm is the one that detects it.) (b)on_noruns forn, for arbitrary text, and for emptyRET— the negative continuation §8'sCflow needs, which rev 7's helper could not express and rev 7's acceptance could not observe. (c)on_cancelruns forC-g, andon_nodoes NOT; withon_cancelomitted, cancelling runs nothing. (Fails against an implementation that routes cancel throughon_no— which would makeC-gperform a partial copy.)- Serialization (Q#DR16, F5): in a batch of N mutations, the second is not dispatched until the first has settled. Asserted by observing at most one in-flight fs job at any pump step — not by the end state, which is identical if all N were dispatched at once and awaited afterwards.
- A marked target that vanishes between the last revert and the operation is reported, not silently dropped (F5 — distinct from item 4's revert-time pruning).
2c — new primitives
+creates a subdirectory, which appears on the next listing.Ccopies a file and preserves mode bits; refuses a directory source.Cwith several marked entries requires an existing directory destination and refuses otherwise before copying anything.Conto existing targets confirms once with the collision count; declining copies the non-colliding entries and skips the rest (F7).remove_dir_allunlinks a symlink-to-a-directory rather than traversing it — pinned at the primitive, mirroringremove_blocking's lstat guard (F7).- Recursive delete happens only with
dired.recursive-deletesenabled and a confirm; disabled, the non-empty directory still fails.
2b — the dired subscriber (moved from 2a in rev 6, R2)
- An open dired buffer on the renamed directory follows it: its
handle.path, its buffer name (*dired:<new path>*), andhandle_for_pathdedup under the new path all move together (G2 — assertinghandle.pathalone would pass with the name still stale). Lives in 2b because the subscriber it tests lives indired.lua, and 2a ships no dired code (§10, §16). 2a pins the hook it attaches to instead — item 50.
2a — the hook and the removal lifecycle (new in rev 6)
-
resource.renamedfires exactly once per successful rename, with(old, new)as normalized absolute paths, and does not fire for a rename that failed or was cancelled. (R2 — without this, moving item 33 to 2b would leave 2a shipping a hook with no acceptance at all. The normalization half matters becauseResolvedTarget::Directory's doc warns that normalization does not happen on every path into the core, so a path-keyed subscriber needs the canonical form.) The symmetric assertion forresource.deletedaccompanies it. -
A killed buffer completes BOTH removal phases (R4): after a delete reconciles, an
on_removedcallback registered for that buffer has fired, and its buffer-local keymap entries and folds are gone. (Fails against an implementation that calls onlyEditorCore::kill_buffer, which does no phase-2 cleanup.) -
A window displaying the deleted buffer is redirected, not left dangling (R4): with the buffer shown in a window, deleting the file leaves that window on a valid fallback buffer, and no window holds a removed id. (Fails against
remove_buffer_and_fire, which is whatapply_resource_opuses today — it does no window cleanup at all.) -
The last-buffer and mid-edit refusals are reported, not silent (R4): deleting the file behind the only open buffer keeps the buffer and says so; and a delete reaching a directory of buffers where one refuses removal still reconciles the rest. 53b. A mid-edit refusal leaves editor state UNCHANGED — three separate assertions (F2). With the buffer
editing_in_progress, displayed in an ordinary window, shown in a side window, and present inround_trip_buffers, a delete reconciling it must leave each of the following provably untouched, asserted individually rather than as one compound check:# Assertion Bite — the mutation that must falsify it i the ordinary window still shows the buffer, with its cursor,selectionandview_topintactdrop the preflight, so kill_bufferredirects the window to the fallback beforeBufferRegistry::removerefusesii the side window is still open and still shows the buffer drop the preflight, so remove_side_windowcollapses it firstiii the buffer is still in round_trip_buffers(a semantic frontend still round-trips its keys)drop the preflight, so round_trip_buffers.removeruns first — this is the first thingkill_bufferdoes and the easiest to missEach bite is the same one-line deletion, but each assertion fails independently, which is the point: a single compound assertion can pass on two of the three and hide the third. (Rev 7 specified none of this — it said to treat the refusal as "keep the buffer", which reads as though skipping phase 2 restored something. It does not.)
-
Two INDEPENDENT concurrent mutations reconcile correctly in either arrival order (F1, Q#DR29): dispatch a rename and a delete on disjoint paths fire-and-forget, pump, and assert the end state; then repeat with the replies arriving in the opposite order and assert the same end state. (Pins that the contract really is order-independent rather than accidentally order-sensitive. Fails against a reconciliation that, say, resolves delete targets against paths already rebound by a rename in the same drain.)
-
resource.renamedandresource.deletedareall-must-succeed, not short-circuit (Q#DR28): with two subscribers registered and the first one raising, the second still runs, and the error is reported rather than swallowed. (Fails against ashort-circuitregistration, where the first subscriber's return would stop the fan-out and silently prevent every later one from reconciling — which no test asserting only "the hook fired" would catch.)
No acceptance pins interdependent ordering, deliberately. A test cannot pin an order the mechanism does not establish (§6, F1): the runtime records bus-arrival order and no execution token exists, so an "interdependent mutations reconcile in execution order" test would either be flaky or would pass by accident on a scheduler that happens to cooperate. This is the same discipline item 20 applies to H1's open dispatch-to-syscall interval — state the gap, do not fake a pin for it.
Bite obligations. Each of these must fail against a stated mutation:
| Item | Mutation it must catch |
|---|---|
| 8 | x widened to consume * marks |
| 14 | M's symlink refusal downgraded to a warning |
| 20 | the modified check run only once, before the prompt |
| 25 | the reconciliation moved to _take_result |
| 27 | find_by_path's first match instead of every match |
| 28 | a string starts_with instead of a path-component prefix |
| 30 | rec.uri updated without re-rooting the diagnostic view — and a remove-and-re-push, which passes a one-window test |
| 31 | the store clear without the pending_routes purge, so an in-flight response repopulates the old key |
| 33 | handle.path updated without the buffer name |
| 34 | the applier restoring by path instead of by buffer handle |
| 39 | a completion source added to confirm |
| 40 | the batch changed to dispatch-all-then-await |
| 50 | the hook fired for a failed rename, or fired with the un-normalized path |
| 51 | reconcile_delete calling only EditorCore::kill_buffer, so phase 2 never runs |
| 52 | reconcile_delete calling only remove_buffer_and_fire, so windows keep a removed id (this is apply_resource_op today) |
(Rev 5's items 48–49 and their bites left with Q#DR25 — R1. They belong
to the generated-buffer-immutability lane, and §3.1 hands over the one
that matters: M-x buffer.undo leaving the listing intact is the pin
with bite, and the existing dired_buffer_is_read_only_and_round_trips_input
is not coverage because it passes either way.)
Note that 51 and 52 are a matched pair, and neither alone is sufficient — each existing removal path passes one and fails the other, which is exactly why the framing had to name both phases (R4).
dired.lua is an existing file now, so scripts/bite's
swap-over-git show mode applies — but per #165's lesson, commit
before biting. Items 30, 33, and 34 came from round 2, item 31 from
round 3, and items 50–53 from round 5; each is a case where the previous
revision's design would have passed a weaker test. Note that item 20
has no bite for the interval it cannot close (H1) — only for the check
it does make.
14. Gates (per PR)
The standard suite from CLAUDE.md, plus what this work touches. 2a's
gates are the widest of the three — it changes lsp.rs, view.rs,
window.rs, editor_core.rs, and lua_bindings, so every LSP suite is
in its blast radius, not just the dired one:
cargo fmt --check; cargo clippy --workspace --all-targets -- -D warnings as its own step; cargo test --lib and --lib --features crdt; dired_acceptance (default and crdt); m8_1, m8_2,
m8_3 at unchanged counts (the additivity gate, B3);
m4_acceptance -- --skip basedpyright; lsp_multi_root_acceptance
(B3b's gate, omitted from rev 2's list); PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu; the isolated-XDG_CONFIG_HOME workspace sweep with
--no-fail-fast; git diff --check.
Two gates rev 4 could not have listed, both required of every PR in this stage (N3):
journey_acceptanceat ≥ 24 tests, all passing. It is a ratchet by its own declaration, seven of its rows assert on dired, and its step-3 and step-5 rows run through the surface Stage 2 modifies. Assert the count, not just the colour — a row silently dropped is exactly what the ratchet exists to prevent, and 2b changes the contents those rows read.gpu_invocation_acceptance, which is where #183 put the GPU journey row (public_gpu_directory_target_reaches_dired_and_leaves_the_daemon_usable). It drives a real daemon child process, so it is in the blast radius of any change to how a dired buffer is written or protected. That makes it a required gate for thegenerated-buffer-immutabilitylane too, which will make the listing rope-levelread_only— a daemon-side refusal is exactly what this test's frontend would see. Note this row is not injourney_acceptance.rs; running only that file leaves the GPU half of the journey unpinned.
(Rev 5 also added typed_edit_chain_acceptance to 2b's list on
Q#DR25's account. With Q#DR25 gone (R1) that reason goes with it — dired
joins neither the chain nor buffer.after-edit (§2, N4), and 2b changes
no write path. It is not a 2b gate.)
2a's dired-facing gate is dired_acceptance at an unchanged 25, not
a grown one: 2a ships no dired code, so a moved count there means it
touched something it should not have. 2b's is expected to grow by the
mark, operation and subscriber items.
15. Numbered decisions
-
Q#DR12 Marks are a per-handle table keyed by basename, values
*orD, pruned on every re-read following*buffer-list*'s precedent.render_entrytakes the mark as a second argument. (§3) -
Q#DR13 (narrowed in rev 2, F4;
wmoved in rev 3, G5) Operations fall into three classes: set-based (D,M,w,C) target the marked set or the entry at point; flag-based (x) consumesDflags only and never falls back to point; point-based (Ralone) acts at point regardless of marks and leaves marks untouched. A vanished basename is dropped and reported. (§4) -
Q#DR14 (widened in rev 2, F1) A rename is a transaction across every path owner, not a buffer-field update.
EditorCore::reconcile_renamewalks the whole registry, matches by equality or path-component prefix, and updates bothfile_pathandname; bothpmacs.fs.rename's drain andapply_resource_opcall it, so they cannot drift. A newresource.renamed(old, new)hook then lets path-keyed Lua consumers reconcile —lsp.luarecomputesrec.uri, re-runsensure_serverfor a cross-root move, and issues didClose/didOpen;dired.luafollows its handles.tickreturns a structuredTickOutcomeso settle identity and rename metadata stay in one transaction (F6). Ordering is guaranteed:_tickruns before any coroutine resumes. Pinned by a no-await rename. (Widened again in rev 3, G1/G3: the LSP subscriber owns a full teardown/re-attach — flush pendingdidChange,didClose, drop all five URI-keyed stores, re-runensure_server,didOpen, and re-rootDiagnosticView, whose URI is set once at construction; andapply_workspace_editmust capture the buffer handle rather than the path string, restoring withswitch_bufferand no path fallback, since no transaction can reach a captured Lua local.) (Completed in rev 4, H2/H3: the LSP half is one manager-levelforget_uri(sid, uri)covering fourteen URI-bearing stores plusdocuments, plus apending_routespurge and awaiter drain — an in-flight response otherwise repopulates the old key after the clear; and the view half is aView::rename_resourcedefault-no-op hook swept overcore.windows.values_mut(), chosen overset_urior re-attach because it reaches passive windows and preserves overlay order by mutating in place.) (§5) -
Q#DR15 (widened in rev 8, F5) Confirmation is
pmacs.minibuffer.confirm { prompt, on_yes, on_no, on_cancel }in a newbuiltin/runtime/minibuffer.lua, with no completion source; affirmative isy/yescase-insensitively and everything else — including emptyRET— is no. Three outcomes, becausepmacs.minibuffer.readalready has three: accept-affirmative, accept-negative and cancel. Rev 7 exposed onlyon_yesand so could not express §8's copy contract, where declining continues. Cancel does not fall through toon_no— withon_cancelomitted,C-gruns nothing — because forCdeclining means copy the non-colliding entries while cancelling means abandon, and defaulting cancel to decline would makeC-gperform a partial copy. (§7, §8, §13 item 39) -
Q#DR16 Batches serialize (await each op), a per-entry failure does not abort, successful marks clear while failed marks persist, and the listing reverts once at the end. (§9)
-
Q#DR17 (revised in rev 4) Stage 2 ships as three PRs, not two: 2a the resource-reconciliation transaction with no dired surface at all (rename + delete reconciliation,
forget_uriover fourteen stores,View::rename_resource, the applier fix,pmacs.buffer.set_name); 2b the mark layer plusm u U t d x D R w Mon the five existing fs ops; 2cmkdir/copy/remove_dir_allwith+ Cand recursive delete. 2a first because a bug in it is silent data loss, and because every blocking finding across three rounds landed on it. (§10) -
Q#DR18 (new in rev 2, F2; seam in rev 3, G4; policy rewritten in rev 7, round 6) Deleting a path something holds.
The seam is unchanged and is not cancelled. One shared
EditorCore::reconcile_delete, symmetric withreconcile_rename, called by both the drain harvest andapply_resource_op, replacing the latter's raw first-match lookup. Its walk is the shared prefix-aware, normalizing query — every path-bound buffer, not the first match; normalized once; component-awarePath::starts_withso/treedoes not match/tree-sibling. That query is #186's Q#RD6; whichever lane lands first owns it and the other adopts it, which is #186's own boundary wording, andreconcile_renamethen shares it.removeis still harvested in the drain like rename, firingresource.deleted(path), so a fire-and-forget remove reconciles too.The policy is now symmetric: both paths refuse. Rev 6 had dired refusing while an LSP-authored delete proceeded and orphaned the buffer as accepted residue. Withdrawn. For the synchronous
apply_resource_oppath a modified buffer in the affected set means the delete is refused before disk — nothing removed, nothing deleted — which is PR #186's Q#RD1, adopted here rather than re-decided. dired refuses the entry on the same condition.DeleteReconcile.kept_modifiedstays, and is not dead. The synchronous caller can no longer produce it, because the refusal happens earlier; the asynchronous path still can, since dired goes throughpmacs.fs.remove, which dispatches a worker that #186's in-applier guard never sees. dired's modified check therefore still runs before the confirm and again before each dispatch — but it is a pre-dispatch check, not a lock (H1), the interval from dispatch toremove_blockingstays open, and the promise is stated at that strength, as G6 forced forR. That interval is the whole of this lane's remaining orphaning residue (§11).Deliberately diverges from Emacs, which orphans the buffer and lets the next save resurrect the file — a conclusion #186 reached independently for the LSP arm (its Q#RD1: refuse, do not prompt, do not save, do not back up). (§6)
-
Q#DR19 (new in rev 2, F3)
M(chmod) is new scope beyond the parent's approved table and needs explicit approval. It refuses symlink entries, becausechmodfollows links while the listing is lstat-based, so the operation would change a different file and appear to do nothing — the same reasoning the parent already applied to the fixture's wdired perms edits and said "carries over unchanged". (§8) -
Q#DR20 (restored in rev 2, F3; corrected in rev 3, G5)
w(copy filename to the kill ring) is carried forward from the parent's approved Stage 2 table, which rev 1 dropped without saying so. It is set-based — the parent approved the binding, and Emacs copies the marked filenames; rev 2's point-only narrowing was an unapproved change of its own. Non-destructive. (§8) -
Q#DR21 (new in rev 3, G2)
pmacs.buffer.set_name(buf, name)is exposed.Buffer::set_namealready exists in Rust and already documents itself as for "save-as and rename operations"; §5 needs the same capability for theBuffer.namehalf of the transaction; and without it dired's pathless buffer cannot follow a directory rename, which its own module doc says is why buffer-per-directory exists. Uniqueness stays the caller's job, matching the Rust setter. (§5) -
Q#DR23 (new in rev 4, H2; corrected in rev 5, W1/W2/W3) The LSP rename teardown is one manager-level
forget_uri(sid, uri), not per-store calls at the Lua layer: fourteen call sites is how one gets forgotten. It purgespending_routesby URI, drain-cancels the matching awaiters (the existing contract atlsp.rs:801-803already requires this wherever routes are purged), and clears all fourteen stores plusdocuments, handlinglocations_store's kind key andsymbol_store's scope key specially. (Rev 5 corrects three facts underneath it, none of which change the decision but any of which would break the implementation: the teardowns to model on areLspManager::start_generation(lsp.rs:1307-1345) andLspManager::forget(lsp.rs:3015-3042) — there is nofn restart; the drain to model on isdrain_external_cancelled(lsp.rs:1561-1576), the unconditional server-scoped one, not the near-namesakedrain_cancelled_externals(lsp.rs:1596-1645), which only reaps token-cancelled and timed-out awaiters and would therefore drain nothing on a rename; and the route purge must retainWorkspaceSymbolunconditionally, since it is the oneResponseRoutevariant of fifteen that carries aqueryrather than auri. Neither existing drain is URI-scoped, soforget_uri's drain joins route to awaiter on therid, which is the only index available.*) (§5) -
Q#DR24 (new in rev 4, H3) Diagnostic re-rooting is a
View::rename_resource(&mut self, old, new)default-no-op hook, joiningoverlay_identityandclone_for_splitin the family #113 added for cross-window overlay problems, swept overcore.windows.values_mut()exactly as overlay disposal already is (mod.rs:2044-2046). Chosen over exposingset_uri(the field stays private, andViewhas no downcast) and over tear-down-and-re-attach (which loses composition order, and_attach_viewcannot reach a passive window at all). (§5) -
Q#DR22 (new in rev 3, G5)
pmacs.killring.push(text)is exposed, with the semanticscopy()already establishes for non-region text: push the entry, mirror to the OS clipboard, and break the kill chain.push_entryis private andcopy()requires a region, sowhas no surface without it. (§8) -
Q#DR25 (added in rev 5; withdrawn from this document in rev 6, R1) Dired's listing becoming a genuinely immutable generated buffer is not Stage 2's decision to make. The defect is real —
M-x buffer.undoempties a dired listing onmaintoday — but it is a class defect, not dired's: the same erroring-intercept-over-a- writable-rope idiom appears inlistview.lua,compile.luaand the search/grep panel, and no Lua caller anywhere setsread_only, because no Luaset_read_onlyexists to call. It is owned by thegenerated-buffer-immutabilitylane. Stage 2 does not implement it, does not gate on it, and carries no acceptance for it; §3.1 records what that lane inherits from this document's re-scout, and why the two lanes can land in either order without conflicting. (§3.1, §11) -
Q#DR26 (new in rev 6, R3) The drain outcome is one ordered sequence of settled resource mutations, not a field per mutation kind:
TickOutcome { settled, resources: Vec<ResourceOp> }withResourceOp::{Rename { from, to }, Remove { path }}, andPendingJobcarrying a singleOption<ResourceOp>. Ordered because a directory rename and a delete beneath it can settle in the same tick and reconciling them out of order targets the wrong path; one enum rather than twoOptions because two would admit a both-Somestate that cannot occur —ResolvedTarget's own doc makes that argument atsrc/editor_core.rs:100-102. Rev 5'srenames-only shape could not express deletion at all, though §6 required it to. (§5, §6) -
Q#DR27 (new in rev 6, R4) "Kill a buffer" means both removal phases, and
reconcile_deletecomposes them for every id it kills:EditorCore::kill_buffer(src/editor_core.rs:4590— last-buffer and unknown-id refusals, side-window close, window redirect to a fallback, registry removal) thenafter_buffer_removed(src/lua_bindings/mod.rs:1602— keymaps, buffer-local config, folds,on_removedcallbacks). The composition already exists in exactly one place,pmacs.buffer.kill(mod.rs:5476-5491), whose doc says it is late-bound precisely because it needs the core to redirect windows;apply_resource_opuses the other, incomplete path (remove_buffer_and_fire,mod.rs:1592) and therefore leaves a displaying window pointing at a removed id — a third defect on that arm beside the missing dirty check and the first-match lookup. Phase 2 needs&Lua, soreconcile_deletereturns the killed ids and its caller runs phase 2;EditorCoregains no Lua handle. Both refusals (last buffer, mid-editConcurrentEdit) are reported and neither aborts the rest of the batch. And the mid-edit case is preflighted, not caught:kill_bufferclearsround_trip_buffers, closes side windows and redirects ordinary windows beforeBufferRegistry::removecan refuse, so a caughtConcurrentEditleaves editor state already damaged.reconcile_deletetherefore checksediting_in_progressbefore phase 1 and skips the buffer — sound because phase 1 is pureEditorCoreand holds no Lua handle, so nothing can re-enter and begin an edit between check and removal. (§6, §13 items 51–53, 53b) -
Q#DR28 (new in rev 8, from the round-7 sweep) Both new hooks register
all-must-succeed, notshort-circuit.src/hook.rsdefines threeHookKinds and every declared hook picks one inbuiltin/hooks/default.lua; rev 7 asserted only that the mechanism existed and never said which. It matters: registeredshort-circuit, a singleresource.renamedsubscriber returning falsey would stop the fan-out and silently prevent every later subscriber from reconciling — the typed-edit chain's trap arriving through an unlabelled door.all-must-succeedreports a failing subscriber and still runs the rest, and lets no subscriber claim the event. (§13 item 55) -
Q#DR29 (new in rev 8, F1) Reconciliation is order-independent, and the framing promises nothing about the relative order of two mutations in flight simultaneously.
TickOutcome.resourcesis bus-arrival order, not execution order —tickis atry_recvdrain with no execution token — so rev 7's ordered-sequence justification is withdrawn. This is safe because independent mutations commute, no production path can produce interdependent concurrent ones (dired serializes;apply_resource_opis synchronous; the fs primitives have zero production callers), andfs.lua:155-165already instructs packages needing at-most-one-pending semantics to serialize on their own side. An execution-order token under a mutation lock was rejected: it would serialize every fs mutation through one lock to close a hazard with no production reachability and a documented caller-side remedy. Deferred with a checkable trigger (§11). No static ordering rule is offered because none works — the correct order depends on which mutation actually ran first, which is exactly what is unknown. (§5, §6, §13 item 54) -
Q#DR30 (new in rev 8, F3) A buffer's name is path-derived — and therefore updated by a rename — iff the stored name, parsed as a path and normalized, equals the buffer's stored normalized old path. Not string equality against the path, which rev 7 used: names are set from
path.display()as given while onlyfile_pathis normalized, so a relative open leaves a short name that rev 7 would have mistaken for user-chosen. The new name is written as the normalized new path, so a relatively-opened buffer acquires an absolute name — stated rather than hidden, and confined to the statusline and buffer list. (§5, §13 item 29) -
Q#DR31 (new in rev 8, F4) Uncorrelated writers are gated by a bounded per-server tombstone, not by document membership.
forget_urirecords the forgotten(sid, uri); an uncorrelated absorb for a tombstoned URI is dropped; the tombstone clears on the nextdid_openof that URI and at both server-teardown sites. The census establishes thatpublishDiagnosticsis the only uncorrelated notification writer, but not the only uncorrelated writer —pub fn mark_document_staletakes noLspServerIdand creates URI keys across three stores for every server. Two consequences the tombstone must respect:diag_storehas zero correlated writers, so the route purge protects it not at all; andDiagnosticStore.by_uriis keyed by URI alone, so the diagnostics tombstone is URI-keyed rather than(sid, uri)-keyed, and the forget path must also drop the URI's never-prunedepochsentry. A membership gate (absorb only if in documents) was rejected because servers legitimately publish for files never opened, and it would drop all of them; the precedent for the chosen shape isclient.cancelled_rids(src/lsp.rs:2549), the same forget-then-drop-late-arrivals set with a request id instead of a URI.DiagStore's existingepochscannot serve:set()bumps it on every write with no epoch parameter, so a late notification looks current. (§5, §13 items 31b–31c)
16. Branch and PR plan
Framing on dired-stage2-framing, kept after merge per the repo's
-framing convention. Then three implementation branches, each cut
fresh from main after the previous one merges — not stacked, since
each needs only a merged main and the arc has already paid for a stacked
retarget once (#104 → #105):
dired-stage2a-reconciliation— the transaction, no dired surface.dired-stage2b-marks— the mark and operation layer.dired-stage2c-primitives—mkdir/copy/remove_dir_all.
One feature, one branch, one PR; gates green before each; the ledger lane
and docs/agent-handoff.md §1 updated per their own protocols as each
lands.
Naming 2a for the substrate, not for dired, following #161's
precedent: its diff contains no dired code, and a cross-cutting
correctness fix to rename/delete reconciliation must not be reviewable
only as a dired feature. The PR body should lead with the defects it
closes on main — the workspace-edit phantom buffer, the raw
first-match lookup shared by both apply_resource_op arms, and the
incomplete removal lifecycle (Q#DR27) — because none needs dired to
be worth fixing. (Rev 7: it should not lead with the
LSP-authored delete that destroys unsaved work. That is PR #186's,
and 2a's body should say so and cite it rather than appearing to claim
it.)
Ledger note (rewritten in rev 8 — P2; the rev 5 text was stale and self-contradictory). Two things it got wrong and this corrects:
- This branch no longer touches only the framing file. It has
carried
docs/active-work.md's dired Stage 2 lane since rev 6, which is when the ledger PR (#185) merged and the lane moved onto this PR. The rev 5 text still claimed single-file scope while the very same revision was editing the ledger. The reason the lane rides this PR rather than a standalone refresh is unchanged: with several PRs open, a separate ledger PR re-conflicts on every merge, and the ledger's own protocol puts a lane refresh with the work. - The line counts were quoted, not measured. Rev 5 described the
document as 1,570 lines — a figure from two revisions earlier — and
the ledger in turn reported ~2,630. Measured at this revision:
docs/dired-stage2-framing.mdis 3,624 lines anddocs/active-work.mdis 981. A census is a reading, not a constant, and that applies to a framing's count of its own size exactly as it applies to its count of the tree. Neither number above is copied from anywhere; both were run against the tree being pushed.
docs/agent-handoff.md and COHERENCE.md remain untouched by this
branch.
Ownership warning: 2a must not run concurrently with Journey Stage 1b
2a overlaps src/editor_core.rs, builtin/runtime/lsp.lua, and the
URI-keyed LSP state with other coherence work in flight.
COHERENCE.md §20 names Journey Stage 1b as Priority 1's remainder —
compile defaults, LSP spawn-failure surfacing, bindings, and a
welcome buffer — and the LSP-failure half lands in the same files 2a
rewrites, while 1b's compile/binding half touches editor_core.rs. Two
branches editing lsp.lua's attachment lifecycle at once is the
worst-case shape for this repo: the conflicts are semantic rather than
textual, so a clean git merge proves nothing.
Neither stage may start until those files are assigned. The resolution is cheap if taken up front — 1b's LSP work is a reporting change (surface the spawn failure with guidance) and 2a's is a lifecycle change (teardown and re-attach on rename), so they can be sequenced in either order provided only one is open at a time. Taken late it is three merge rounds, which this arc has already paid twice.
Stage 2b and 2c carry no such overlap, but 2b's file list is wider
than rev 5 said (R6). It is dired.lua's mark, operation and
subscriber layer; the new builtin/runtime/minibuffer.lua; the
pmacs.killring.push binding; and src/editor.rs, because
minibuffer.lua has to join the explicit include_str! load sequence
there (src/editor.rs:395-660, ~30 entries) and must be ordered
before dired.lua. That is a one-line addition to a file no other
lane in flight is editing, so it changes no ownership conclusion — but
"2b is dired.lua plus one killring binding" was simply false, and a
reader sizing the ownership warning off it would have sized it wrong.
2c is three additive pmacs.fs primitives plus their two dired
commands.