35 KiB
Framing — apply_resource_op delete destroys unsaved work
Revision 1. Status: PROPOSED — needs explicit user approval before
implementation. DO NOT implement, DO NOT merge. Lane:
resource-op-delete-guard, worktree ../pmacs-resource-op-delete,
based on githubsucks/main @ ad41cf1.
This is a live data-loss bug, reproduced four ways against ad41cf1
(§1.1). A language server can destroy a buffer's unsaved edits and
the file that would have held them, with no prompt, no status message,
and no error return.
Revision history
Revision 1. First cut. Every claim about pmacs in §1 was verified by
reading the tree at ad41cf1 and, for §1.1, by running throwaway tests
in this worktree (removed before commit — this lane ships no runtime
code). Every claim about Emacs is marked as such in §1.9 and was
verified against lisp/progmodes/eglot.el on emacs-mirror/emacs
master, not from recollection.
0. Coherence impact (COHERENCE §20)
- Journey step 6, "Receive language intelligence" (§2), and by consequence step 5, "Edit immediately" — the loss is of exactly the edits step 5 grades as "genuinely excellent". §2's verdict table grades step 6 Partial; this lane does not raise that grade, it removes a way the step can destroy the user's work. A journey that eats unsaved edits at step 6 is not a journey worth protecting, so this serves Priority 1 ("treat regressions as release blockers") as a correctness floor rather than a feature.
- Interaction islands: none added. This is the load-bearing constraint, not an afterthought. §6 grades islands "weak, and growing by one island per modal feature", and the count is six. A confirmation prompt for this op would need a seventh (§2.2), which is the single strongest argument against the prompt option.
- Config registry: not adopted. No knob is proposed. The refusal is
unconditional. An
lsp.confirm-server-edits-style setting is the natural future adopter and is parked in §6, not shipped here. - Background-work attribution: unchanged.
- No audited claim in COHERENCE.md changes, so under §25 no COHERENCE edit rides this PR.
1. Ground truth (scouted and verified @ ad41cf1)
1.1 The bug, reproduced
pmacs.buffer.apply_resource_op with kind = "delete" destroys a
modified buffer and the file backing it. Reproduced in this worktree by
throwaway acceptance tests against ad41cf1 (written, run, then
removed — no runtime code ships in this lane). Four distinct modes:
(a) The reported bug. Open a file, edit it without saving, apply a delete op for its path:
PRE: modified=true text="UNSAVED EDIT ORIGINAL ON DISK\n"
apply_resource_op result: Ok(())
buffers before=3 after=2
file still on disk? false
The call returns Ok(()). The file is gone. The buffer is gone. The
text existed in exactly one place and now exists nowhere.
(b) ignore_if_not_exists = true destroys the buffer having done no
filesystem work at all. When the path is already absent, the delete
arm skips the remove_file — and then falls through to the buffer
reconciliation anyway:
file gone; buffers=3; buffer holds the only copy
result: Ok(())
buffers after=2
CONFIRMED: ignore_if_not_exists=true did ZERO fs work yet still destroyed the only copy.
This is the sub-case where the buffer is most certainly the only
copy, and it is the one arm where the op explicitly decided to do
nothing. Compare the create arm, which returns early
(return Ok(())) under the analogous ignore_if_exists condition; the
delete arm's Err(NotFound) branch does not return, it falls through.
(c) recursive = true fails in the opposite direction. A recursive
directory delete removes the whole tree from disk but reconciles no
buffer, because the lookup is for the directory path and buffers hold
file paths:
recursive delete result: Ok(())
inner file exists? false
buffers before=3 after=3
inner buffer still in registry? true
The most destructive arm does the least reconciliation. Here the data survives — in an orphaned buffer pointing at a path that no longer exists — which is strictly safer than (a) and is why the fix cannot be "make delete behave like the recursive case".
(d) Removal is not kill_buffer. The delete path leaves a window
bound to a removed BufferId, and can drive the registry to empty:
victim is the active buffer? true
delete result: Ok(())
window.buffer():is_valid() after delete => Ok(Boolean(false))
editor.file_path() after delete => Ok(Nil)
buffers now: 1
delete of the LAST buffer: Ok(())
buffers after: 0 (0 => registry driven empty)
EditorCore::kill_buffer (src/editor_core.rs:4590) explicitly refuses
the last buffer and rebinds every window to a fallback. This path does
neither. (d) is named here for the record and is parked — see §6 and
Q#RD8; it is a different failure from data loss and it is shared with
pmacs.buffer.remove.
1.2 There is no dirty check at any link in the chain
Three links, verified by reading each:
-
apply_resource_op's delete arm (src/lua_bindings/mod.rs:3313) — stats the path, callsremove_file/remove_dir/remove_dir_all, thenfind_by_path, thenremove_buffer_and_fire. Nois_modifiedanywhere in the arm. -
remove_buffer_and_fire(src/lua_bindings/mod.rs:1592) — four lines:registry.borrow_mut().remove(id), thenafter_buffer_removed.after_buffer_removed(src/lua_bindings/mod.rs:1602) clears keymaps, config, folds and fireson_removedcallbacks. No dirty check. -
BufferRegistry::remove(src/buffer_registry.rs:127) — has exactly one guard, and it is not this one:if let Some(buf) = self.buffers.get(&id) && buf.editing_in_progress() { return Err(RegistryError::ConcurrentEdit { ... }); }That refuses re-entrant removal from inside an edit intercept (T M7.4). It says nothing about unsaved content.
The user's description of the bug is accurate at every link. Modes (b), (c) and (d) are additional and were not in the report.
1.3 The ordering is disk-first, so no guard placed later can help
The arm performs the irreversible filesystem operation before it has
even looked for a buffer. By the time any is_modified check could run,
the file is gone. This is the single most important structural fact in
this document: a fix that adds a dirty check to the buffer-reconcile
block is not a fix. It converts "file deleted, buffer destroyed" into
"file deleted, buffer orphaned" — mode (c) — which is better, but the
user's on-disk file is still gone and they never consented.
1.4 is_modified is available, singular, and not on pmacs.buffer
- One field,
Buffer::is_modified: bool(src/buffer.rs:164); one accessorBuffer::is_modified()(src/buffer.rs:473); one public mutatorBuffer::mark_clean()(src/buffer.rs:488). There is nodirty, nosaved_revision, no public setter to true. - Zero features refuse or confirm on it today.
kill_buffer(src/editor_core.rs:4590) does not check it;editor.quit(builtin/commands/default.lua:250) has a veto hook (editor.before-quit) that nothing subscribes to with a modified check;dired.revertdoes not check it. The one behavioural consumer is autosave'sgatherfilter (src/autosave.rs:363), which includes rather than refuses. So this lane introduces the first refusal in the codebase keyed on unsaved state, and should be read as setting that precedent. - Lua reaches it as the userdata method
buf:is_modified()(src/lua_bindings/mod.rs:1261) and aspmacs.describe.buffer(id).modified(:6359). It is not a key on thepmacs.buffermodule table. Relevant because the batch preflight (Q#RD3) is Lua and needs a path-keyed query it does not have today.
1.5 Who calls this, and what happens to a raised error
pmacs.buffer.apply_resource_op has exactly one production caller:
apply_workspace_edit (builtin/runtime/lsp.lua:1301), at :1346.
That function has three callers:
| # | Call site | Origin | Error disposition |
|---|---|---|---|
| 1 | handle_server_requests (lsp.lua:1815), call at :1836 |
server-initiated workspace/applyEdit |
Swallowed silently. The pump is driven by pcall(handle_server_requests) (lsp.lua:1892). A raise unwinds past the pcall(pmacs.lsp.send_response, ...) that answers the request, so the user sees nothing and the server is never answered. |
| 2 | LSP rename (lsp.lua:2311) |
user, M-x |
apply_workspace_edit never returns nil for an op failure, so a raise propagates out of the pmacs.async coroutine. |
| 3 | code action apply (lsp.lua:2373) |
user, M-x |
as #2. |
Only path 1 is fully unattended. This matters: the refusal cannot be delivered by raising, or the most dangerous path reports nothing and hangs the server's request. That is a design constraint, not a nicety (Q#RD7).
Note also src/rename.rs:25, which documents the division of labour:
rename.rs parses and never mutates; Lua drives pmacs.buffer.*
"so the application strategy stays configurable". The application
strategy is the thing this framing is choosing.
1.6 A partial batch is already possible today — verified
The applier's loop (lsp.lua:1340-1349) calls apply_resource_op
unprotected. Two delete ops where the second raises:
batch result: Err(... "apply_resource_op delete: No such file or directory (os error 2)")
a.txt still exists? false (false ⇒ partial batch)
The first op stayed applied. So partial application on I/O error is the status quo, not something a refusal would introduce. This substantially weakens the "is a partially-applied WorkspaceEdit worse than the data loss?" objection — the partial batch already exists and data loss is strictly worse than a class of failure the code already tolerates.
It also shows the preflight's documented contract is narrower than its
comment implies. lsp.lua:1287-1291 says the applier "refuses to mutate
anything unless every URI it touches resolves to a real file path
first". True — but URI resolution is the only thing preflighted. The
plan loop (:1302-1336) validates nothing about the filesystem or the
buffer registry. The preflight phase exists and is the natural place
to add a second precondition (Q#RD3).
1.7 The rename arm is more careful, and differently careful
Directly above the delete arm, "rename" (mod.rs:3291) does
std::fs::rename, then find_by_path, then
core.borrow_mut().set_buffer_path(id, Some(to)). It rebinds the
buffer and preserves its contents and its modified state. Delete
destroys. The asymmetry is the whole bug: rename treats the buffer
as the valuable thing and the path as the mutable attribute; delete
treats the buffer as a cache of the file.
Both arms share two latent defects, already recorded against the dired
arc: find_by_path (src/buffer_registry.rs:168) is exact Path
equality, first match only, called with the raw path while stored paths
are normalized on write. EditorCore::find_buffer_for_path
(src/editor_core.rs:935) is the normalizing wrapper that exists and is
bypassed. See docs/dired-framing.md:807-819 and the ledger note at
docs/active-work.md:605, both of which claim the rename side of
this for dired Stage 2. §6 draws the boundary.
1.8 What pmacs cannot do, established rather than assumed
-
A prompt cannot be issued from inside this binding.
pmacs.minibuffer.read(src/lua_bindings/mod.rs:13380) is asynchronous-by-callback: it registerson_accept/on_canceland returns immediately.Minibuffer::accept(src/minibuffer.rs:334) deliberately returns the callback rather than calling it, with the reason stated at:332— "firing user code from inside the minibuffer would re-enter the registry". The answer arrives on a later keystroke, through the event loop.apply_resource_opis a synchronous Rust closure that performs itsstd::fscalls inline and returns; there is no point at which it can suspend. There is noy_or_nhelper in the tree at all — a named deferral indocs/dired-framing.md:854. -
Autosave cannot be used as a pre-delete backup. Verified against
src/autosave.rs:- there is no per-buffer write entry point; the only public writer is
sweep(:261), which walks the whole registry; sweepskips clean buffers (:363);- removing a buffer purges its recovery file — the
on_removedcallback registered atbuiltin/runtime/autosave.lua:167-169callsdiscard_buffer(src/autosave.rs:511), and a sweep-time GC (:290-306) catches whatever the callback misses. Pinned bytests/autosave_acceptance.rs:702. - deleting the file flips the recovery's status to
Stale, andStaleis never auto-offered.
So "back up, then delete" is self-defeating four times over: no entry point, wrong filter, the backup is deleted by the very removal it was protecting against, and what survives is never surfaced.
- there is no per-buffer write entry point; the only public writer is
-
pmacs.editor.set_statusis the available report channel (src/lua_bindings/mod.rs:13036), cleared at the top of everydispatch_key. There is no*Messages*buffer and no*warnings*buffer.pmacs.erroris referenced inasync.luaand never defined.
1.9 Prior art — claims about Emacs, not pmacs
Verified against lisp/progmodes/eglot.el, emacs-mirror/emacs
master. Everything in this subsection is a statement about Emacs.
Eglot orders the operations the other way round. Its do-delete:
(do-delete (path &key recursive ignoreIfNotExists &allow-other-keys)
(let ((exists (file-exists-p path)))
(when (and (not exists) (not ignoreIfNotExists))
(eglot--error "File %s does not exist" path))
(when exists
;; Kill buffer if the file is visited
(let ((buf (find-buffer-visiting path)))
(when buf (kill-buffer buf)))
(delete-file path recursive))))
The buffer is killed before the file is deleted. In Emacs,
kill-buffer on a modified file-visiting buffer prompts, so the consent
gate sits ahead of the irreversible step by construction. (Eglot does
not check kill-buffer's return value, so declining the kill still
deletes the file — but the buffer, and therefore the text, survives.
Even Eglot's failure mode is strictly milder than pmacs's.)
Note also the exists guard: Eglot's ignoreIfNotExists path does
not fall through to the buffer kill. That is exactly the asymmetry
mode (b) exposes in pmacs.
Eglot confirms server-initiated edits by default, as a whole-batch
decision made before anything is applied. eglot-confirm-server-edits
defaults to '((t . maybe-summary)). The prepare/decide/apply structure
is explicit: prepare builds a list of closures touching nothing, then
eglot--confirm-server-edits decides, then apply-all runs. The
maybe-* decisions skip the prompt only when the batch is peaceful:
(peaceful
(and
all-text-edits
(cl-loop for op in prepared
always (find-buffer-visiting (cadddr op)))))
all-text-edits is a conjunction over the whole batch, so a batch
containing any create/rename/delete is never peaceful and always
prompts under the default. Confirmation is all-or-nothing and
strictly precedes mutation; there is no mid-batch interrupt.
The transferable lessons (now claims about what pmacs should do):
the consent gate belongs before the irreversible step; the decision is
made over the whole batch during a preparation phase; and
ignoreIfNotExists must short-circuit the buffer half too.
1.10 apply_resource_op has no direct test coverage
grep -rn "apply_resource_op" tests/ src/ returns 4 lines: one doc
comment in src/rename.rs, and three inside the binding's own
definition in src/lua_bindings/mod.rs. Zero tests name it.
It is exercised indirectly by exactly one acceptance,
m4_15_workspace_edit_resource_ops_apply_in_order
(tests/m4_acceptance.rs:4014), driven by the resourceops mode of the
fake server (src/bin/pmacs_fake_lsp.rs:834), which emits an ordered
create / edit / rename / delete. Its deleted file c.rs is never
opened in a buffer, so the entire buffer-reconciliation half of the
delete arm is untested. That suite and that fake are where this lane's
acceptances belong.
2. The decision space
2.1 Recommended — refuse before touching disk, at both layers
Refuse the delete when it would destroy unsaved work, and refuse it before the filesystem call. Do this at the Rust primitive (the invariant) and in the applier's existing preflight (batch atomicity).
Concretely:
- Layer 1, the primitive. The delete arm reconciles the buffer
registry first. If the path resolves to a modified buffer, it
returns an error and touches nothing. Inverting the order is what
makes the guard possible at all (§1.3), and it also closes a smaller
hole: today a
ConcurrentEditrefusal fromBufferRegistry::removearrives after the file is already gone. - Layer 2, the applier preflight.
apply_workspace_edit's plan loop (lsp.lua:1302-1336) gains a second precondition alongside URI resolution, and returns its existingnil, messagewhen any delete op targets a modified buffer — so nothing in the batch is mutated.
Both layers are needed, and neither is redundant:
- Layer 1 alone leaves partial batches (§1.6): op 1 deletes, op 2 refuses, and the user is left mid-edit with no way back.
- Layer 2 alone leaves the primitive armed.
pmacs.buffer.apply_resource_opis public Lua API; dired Stage 2 and any package can call it directly. A guard that lives only in one caller is exactly the shape the project has been burned by — "pin the guard through the real path".
Why this beats the runners-up, in one sentence each:
- It is the only option that is possible — prompting is architecturally unavailable (§1.8) — and it is the option Emacs's structure already endorses (§1.9): consent gate before the irreversible step, decision taken over the whole batch during preparation.
2.2 Prompt the user — rejected
Rejected on three independent grounds, any one sufficient.
- Architecturally unavailable.
apply_resource_opis synchronous Rust; prompts are callback-continuations resumed by a later keystroke (§1.8). Making this work means restructuring the applier into a callback chain carrying the remaining plan as a closure upvalue, with revalidation of every precondition at each resumption — a large, independently-risky change to LSP edit application, for a guard. - It costs an interaction island. The alternative to a callback chain is a Rust modal shadow (the query-replace shape). That is a seventh dispatcher shadow. COHERENCE §6 grades this area "weak, and growing by one island per modal feature" and records that terminal copy mode was deliberately engineered not to become the seventh. Spending that budget on an error path would be a poor trade.
- It cannot serve the most dangerous caller. The server-initiated
path (§1.5, row 1) must answer
workspace/applyEditsynchronously withapplied: true | false. There is no user turn available inside it, and a prompt that resolves three keystrokes later cannot produce that answer.
Note that "what happens to the rest of the batch?" — the question that makes prompting genuinely hard — is dissolved by the recommendation rather than answered: the decision is taken in the preflight, before any op runs, so there is no rest-of-batch to strand.
2.3 Save first, then delete — rejected
Silently converts an unsaved edit into a committed one and then destroys
it. It is more destructive than refusing, not less: it overwrites the
user's on-disk original — the copy they might have wanted — immediately
before removing the file. It also cannot be relied on: save_inner
refuses when the file changed on disk since it was read
(src/editor_core.rs:1908, guard at :1917), so the fallback question is unanswered and
we are back to refusing.
2.4 Back up the contents somewhere recoverable — rejected
Rejected on evidence, not taste. §1.8 establishes that the existing
autosave machinery defeats this four ways, the decisive one being that
removing the buffer deletes the recovery file — the backup is
destroyed by the very operation it exists to survive. Building a
parallel side-store outside autosave/ means inventing a second
recovery surface with its own discovery, GC and lifecycle, to make a
destructive operation feel safe. Refusing is cheaper and honest.
2.5 Key the behaviour on LSP-versus-user provenance — rejected
Superficially attractive because Eglot's default keys on exactly this
(eglot-confirm-server-edits). But: apply_resource_op takes no
provenance argument and there is no ambient caller identity to read.
Adding one makes the primitive's safety depend on a caller-supplied
flag — any caller that omits it, or passes the permissive value, is
unguarded, which is the failure mode the whole lane exists to remove.
COHERENCE §10 (extension trust classes) is unbuilt, so there is no
existing trust dimension to key on either. The refusal is
unconditional and provenance-blind. A future
lsp.confirm-server-edits setting can loosen it once a prompt
mechanism exists; §6.
3. Decisions
Q#RD1 — Refuse. Do not prompt, do not save, do not back up
An apply_resource_op delete whose target resolves to a modified buffer
fails, changing nothing on disk and nothing in the registry.
Rationale: §2.2–§2.5. This is the first refusal in the codebase keyed on
unsaved state (§1.4) and is intended as the precedent for kill_buffer
and editor.quit, which have the same gap.
Q#RD2 — Reconcile the registry before touching the filesystem
The delete arm's order inverts: resolve the buffer set, decide, and only
then call remove_file / remove_dir / remove_dir_all. This is what
makes the guard expressible (§1.3) and it also moves the pre-existing
ConcurrentEdit refusal ahead of the irreversible step.
What inverting breaks, considered: the current order means a
successful remove_buffer_and_fire implies the file is already gone,
so on_removed subscribers observe a consistent "file and buffer both
gone" world. After inversion, on_removed fires with the file still
present for the remainder of the call. Two mitigations: the fs call
follows immediately with no yield point in between (Lua callbacks run
synchronously inside after_buffer_removed), and if the fs call then
fails the correct end state is ambiguous either way — today it cannot
happen because the buffer removal is unreachable on fs failure. Q#RD9
records the resolution.
Q#RD3 — The batch aborts in the preflight, before any op runs
apply_workspace_edit's plan loop gains a modified-buffer precondition
for delete ops and returns nil, message. This reuses the applier's
existing, documented abort contract — "aborts the whole edit cleanly,
origin buffer untouched, rather than half-applying"
(lsp.lua:1287-1291) — and all three callers already handle the
nil, message shape (§1.5). It is Eglot's prepare/decide/apply shape
(§1.9) implemented in the phase pmacs already has.
The preflight needs a path-keyed modified query, which Lua lacks today
(§1.4). The minimal addition is a pmacs.buffer surface answering
"is there a modified buffer at or beneath this path"; its exact shape is
an implementation choice, but it must be one query so the preflight
and the primitive cannot drift apart.
Q#RD4 — ignore_if_not_exists short-circuits the buffer half too
When the path is absent and ignore_if_not_exists is set, the arm
returns early without touching the registry — matching the create
arm's existing return Ok(()) idiom and Eglot's exists guard (§1.9).
Mode (b) is not a special case of the main bug; it is a missing early
return, and a fix aimed only at the "we actually deleted something"
branch leaves it live.
Q#RD5 — recursive deletes are prefix-aware, or the guard has a documented bypass
Mode (c) proves that recursive = true reconciles nothing, so an
exact-path guard is bypassed entirely by the most destructive arm: a
server that sends {kind: "delete", uri: <dir>, recursive: true} walks
straight past it. A guard with a trivial, reachable bypass is not a
guard, so the check must cover every buffer whose path lies beneath
the deleted directory, not merely one whose path equals it.
This overlaps dired Stage 2 and the boundary is drawn explicitly.
docs/dired-framing.md:807-819 and docs/active-work.md:605 claim
prefix-aware, normalize-before-lookup rebinding for the rename side.
This lane takes the delete side only, because without it this lane
ships nothing. The two want the same helper; whichever lands second
adopts the first's. If the user prefers, the alternative is to sequence
this lane after dired Stage 2 and consume its helper — but the bug is
live and dired Stage 2 is unframed for this, so shipping first is
recommended.
Q#RD6 — Lookups normalize; "modified" means Buffer::is_modified
The guard resolves paths through the normalizing wrapper
(EditorCore::find_buffer_for_path, src/editor_core.rs:935) rather
than raw find_by_path, because a lookup miss is a silent guard
bypass (§1.7). "Modified" is Buffer::is_modified() — the single
existing predicate (§1.4). No new notion of dirtiness is introduced.
Explicitly not guarded: a clean buffer. A delete whose target is
open but unmodified proceeds and removes the buffer, exactly as today.
Overreach here would break m4_15 and, more importantly, would make the
LSP's legitimate deletes fail for users who merely have the file open.
Q#RD7 — The refusal is reported on every path, and never by raising alone
Per §1.5, a raise on the server-initiated path is swallowed by
pcall(handle_server_requests) and the server is left unanswered. So:
- Preflight refusal (all three callers) returns
nil, message. Callers 2 and 3 already render that toset_status; caller 1 already turns it into{ applied = false, failureReason = ... }and sends the response. - Primitive refusal still raises — it must, being a Rust binding — but that is now a defence-in-depth path reached only by direct callers, because the preflight catches the LSP path first.
The message names the buffer and says what to do: save it, or use the buffer-level command to discard. It must not be a bare errno.
Q#RD8 — The window/last-buffer defects do not land here
Mode (d) is real and is parked (§6). Two reasons. It is a different
failure (a dangling window and an empty registry, not data loss), and it
is shared with pmacs.buffer.remove rather than specific to delete.
And there is a trap that makes the obvious fix wrong. The two removal
paths clean disjoint sets: kill_buffer handles the last-buffer
refusal, round_trip_buffers, side-window collapse and window rebinding,
but not keymaps, config, folds or on_removed callbacks;
remove_buffer_and_fire handles exactly the latter and none of the
former. Neither is a superset of the other, so "just call kill_buffer
instead" would silently regress four cleanups. Unifying them is its own
lane with its own census.
Q#RD9 — On filesystem failure after the buffer is removed, the buffer wins
Given Q#RD2's inversion, a delete can now fail after the buffer is
gone. The buffer is not restored. Rationale: the buffer removal is only
reached for a clean buffer (Q#RD1), so nothing unsaved is at stake, and
re-inserting a buffer would need a new registry primitive and would
resurrect it with a fresh BufferId that no window, keymap or callback
refers to. The op reports the fs error as it does today. This is a
deliberate, narrow widening of the failure surface and is called out so
review can reject it rather than discover it.
4. Bets (falsifiable)
- B1 — Refusing breaks no legitimate server workflow. A server deleting a file the user has unsaved edits in is a conflict the user must resolve; no server needs that delete to succeed silently. Falsified by a real server whose normal operation deletes files the user is actively editing.
- B2 — The preflight is the right layer for batch atomicity.
Falsified if a
WorkspaceEditlegitimately depends on a delete whose precondition can only be evaluated after an earlier op runs (e.g. a rename that moves the modified buffer out of the delete's path first). This is the sharpest risk in the design and acceptance 8 pins the behaviour so the failure is loud rather than silent. - B3 — Prefix-aware checking does not over-refuse. Falsified if a common workflow deletes a directory while an unrelated modified buffer sits beneath it and the refusal is judged unhelpful.
- B4 — Inverting the order breaks no
on_removedsubscriber. Evidence: the fs call follows synchronously with no yield in between. Falsified by a subscriber that stats the path.
5. Acceptance
Each criterion states the pre-image it must fail against. A test that passes against its pre-image has no bite and is rejected.
-
A delete op targeting a modified buffer refuses, and the file survives. Assert three things together: the call fails, the buffer is still in the registry with its exact unsaved text, and
path.exists()is still true. Bite: fails againstad41cf1unmodified (today:Ok(()), file gone, buffer gone). Asserting only that the buffer survived is vacuous — that is mode (c)'s behaviour, which this lane must not ship. Theexists()assertion is the load-bearing one. -
A delete op targeting a clean open buffer still succeeds, file removed and buffer removed. Bite: fails against an over-broad guard that refuses whenever a buffer is open. Assert both directions, per the bottom-panel lesson that a blanket rewrite passes an "everything moved" test.
-
ignore_if_not_exists = trueon an absent path leaves a modified buffer intact (Q#RD4). Bite: fails against a fix that guards only the branch where the fs delete actually ran — reproduce mode (b) exactly: file removed behind pmacs's back first, then the op. -
recursive = trueon a directory containing a modified buffer's file refuses, and the whole tree survives (Q#RD5). Bite: fails against an exact-path-equality guard. Assert the inner file still exists — not just that the buffer does, which is already true today (mode (c)). -
Whole-batch atomicity: a
documentChangeswhose second op is a blocked delete leaves the first op unapplied (Q#RD3). Bite: fails against a primitive-only fix. §1.6 verified that op 1 currently stays applied, so this is a real behaviour change and the assertion must name op 1's effect (e.g. a file that must still exist, or must not yet have been created). -
The refusal reaches the server on the unattended path. Drive a server-initiated
workspace/applyEditcarrying a blocked delete and assert the server receives a response withapplied = falseand a non-emptyfailureReason. Bite: fails against a fix that refuses by raising — §1.5 established thatpcall(handle_server_requests)(lsp.lua:1892) swallows the raise and the response is never sent. This is the "pin the guard through the outermost user-reachable seam" obligation; a direct-call test onapply_resource_opdoes not satisfy it and is rejected as insufficient for this criterion. -
The user-initiated paths report on the status line. LSP rename and code action each surface a message naming the buffer. Bite: fails against a fix that returns
nilwithout a message, or whose message is a bare errno. -
The B2 risk is pinned: a batch that renames the modified buffer's file and then deletes the old path is refused by the preflight rather than silently mis-evaluated. Whichever behaviour review chooses, it is asserted, so the limitation is a documented decision rather than an accident.
-
m4_15_workspace_edit_resource_ops_apply_in_orderstays green unmodified, pinning "no regression to the ordered-resource-op path" from outside. Itsc.rsis never opened, so it exercises exactly the unguarded case that must keep working (Q#RD6). -
Every new test is checked with
scripts/biteand none reports VACUOUS.
6. Parked — not deferred-and-forgotten
- Mode (d): the dangling window and the emptiable registry (§1.1,
Q#RD8). Real, reachable, shared with
pmacs.buffer.remove. Needs its own lane and a census, because the two removal paths clean disjoint sets and the obvious unification regresses four cleanups. kill_bufferandeditor.quithave the same gap (§1.4): both discard unsaved work with no check.editor.before-quitexists as a veto channel with no subscriber. This lane sets the precedent; those are separate lanes.- A
y_or_nhelper, and with it any confirm-instead-of-refuse option. Already a named deferral (docs/dired-framing.md:854). lsp.confirm-server-edits, the config-registry adopter that would let a user loosen Q#RD1 once a prompt mechanism exists (§2.5).- The rename side of prefix-aware, normalizing lookup — dired Stage 2's, per Q#RD5.
- A general transient-keymap layer (COHERENCE §6), the prerequisite that would make §2.2 cheap rather than impossible.
7. Gates
Full suite per CLAUDE.md: cargo fmt --check; cargo clippy --workspace --all-targets -- -D warnings as its own step; cargo test --lib; cargo test --lib --features crdt; the touched acceptance
suites; cargo test --test m4_acceptance -- --skip basedpyright;
PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu; git diff --check.
Touched suites: m4_acceptance (the resource-op home, §1.10) and
lsp_dispatch_seams_acceptance. dired_acceptance is a watch item for
Q#RD5's shared lookup change.
Gate the pushed tree, not the worktree — commit first, then gate.
This framing-only PR ships no runtime code, so its own gate is
git diff --check plus a docs read.
8. Branch plan
resource-op-delete-guard, worktree ../pmacs-resource-op-delete, one
PR. This framing is the entire first PR. Implementation does not
begin until the user approves the design — specifically Q#RD1 (refuse
rather than prompt), Q#RD5 (take the delete side of prefix-awareness now
rather than sequencing behind dired Stage 2), and Q#RD9 (the buffer is
not restored on fs failure).
Files this lane will touch when approved: src/lua_bindings/mod.rs
(the delete arm and a modified-at-or-beneath query),
builtin/runtime/lsp.lua (the preflight), tests/m4_acceptance.rs and
src/bin/pmacs_fake_lsp.rs (a fake mode carrying a blocked delete).
It will not touch src/daemon.rs, pmacs-protocol/, or
builtin/runtime/dired.lua. No protocol change.