docs(framing): revision 2 of the resource-op delete guard

Review round 1 approved the refusal strategy in principle and rejected
revision 1 as written. Six blocking points, all accepted, plus three
further overclaims found by the requested sweep. Q#RD1 and Q#RD5 are
settled yes; Q#RD9 is settled no and is withdrawn.

Q#RD2 conflated inspection with removal. Revision 1 removed the buffer
before the filesystem call, which fires arbitrary Lua `on_removed`
callbacks while the file still exists and accepts losing the buffer if
the deletion then fails. The sequence is now `stat/no-op -> enumerate
and validate -> mutate filesystem -> reconcile`. Validation inspects
`is_modified` and `editing_in_progress` without removing anything, so a
failed deletion leaves buffers intact automatically and `on_removed`
still observes the path already gone.

Q#RD3 overclaimed whole-batch atomicity. `documentChanges` are
sequential, so an earlier edit can dirty a clean buffer and an earlier
rename can move a modified buffer into a later delete's subtree, after
the snapshot. LSP 3.18 assigns `FailureHandlingKind.Abort` to any edit
containing resource changes --- "all operations executed before the
failing operation stay executed" --- so the protocol itself declines to
promise what revision 1 claimed. The preflight is now described as an
early conflict check, with robustness coming from per-op `pcall`, an
always-sent server response, and best-effort origin restore.

The lookup cannot be `EditorCore::find_buffer_for_path`: it normalizes
but delegates to the first-match-only `find_by_path`, and
`pmacs.buffer.from_file` creates path-bound buffers with no dedup, so a
clean first match can hide a modified second. Q#RD6 now requires a full
scan with component-aware `Path::starts_with`.

Recursive deletion now inspects the tree but reconciles only the exact
path, so the parked lifecycle defect stays exact-path rather than
becoming tree-wide. Q#RD4 holds at both layers, so the preflight cannot
reject an absent path the primitive treats as a no-op.

The prompt argument was overclaimed and that was my error.
`pmacs.lsp.send_response` takes `request_id` as an ordinary value, so a
`workspace/applyEdit` can be answered on a later tick, and a callback
continuation would reuse the existing minibuffer shadow rather than add
a seventh dispatcher rung. Prompting is expensive and separately
scoped, not impossible; the section now claims only what the evidence
carries.

Sweep found three more of the same defect class --- an absence or a
guarantee asserted rather than established:

  * a durable error surface does exist (`append_to_errors_buffer` ->
    `*errors*`), so Q#RD7 now records the refusal there as well;
  * no caller reliably surfaces a raise, because the async path routes
    uncaught coroutine errors through the undefined `pmacs.error`;
  * pmacs advertises no `workspace.workspaceEdit` capability at all ---
    no `documentChanges`, no `resourceOperations`, no
    `failureHandling`.

Adds seven acceptance pins with their bite obligations, adds the
`docs/active-work.md` lane the ledger requires for every open PR, and
drops the two-PR plan: #186 is revised in place and becomes the
implementation PR.

Still PROPOSED. No runtime code. Implementation begins only after
explicit user approval.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
This commit is contained in:
Levi Neuwirth 2026-07-28 18:23:52 -04:00
parent aae113ba76
commit e1e9b44154
2 changed files with 781 additions and 455 deletions

View File

@ -459,6 +459,78 @@ has **no branch and no framing yet**.
`FrontendView.fold_projection` to `true` for semantic frontends, which
Stage 2 deliberately left `false` (Q#FD21).
## Resource-op delete guard lane — PR #186 OPEN, PROPOSED, DO NOT MERGE
- Portable branch: `githubsucks/resource-op-delete-guard`; worktree
`../pmacs-resource-op-delete`. **PR #186**, base `main`, forked from
`ad41cf1` with **no drift** (`main` is still `ad41cf1`). Currently
framing only — `docs/resource-op-delete-guard-framing.md`, revision 2
— plus this lane entry. No runtime code yet.
- **This PR becomes the implementation PR.** Revision 2 dropped rev 1's
framing-PR-then-implementation-PR plan as a one-feature/one-branch/
one-PR violation. The framing is revised in place; implementation
commits land on this same branch **only after explicit user
approval**.
- **Live data-loss bug, reproduced four ways against `ad41cf1`.**
`pmacs.buffer.apply_resource_op`'s delete arm removes the path from
disk and *then* drops any buffer bound to it, with no dirty check at
any link — not the arm, not `remove_buffer_and_fire`, and not
`BufferRegistry::remove`, whose only guard is `editing_in_progress`.
Reachable through any language server's `WorkspaceEdit`. The four
modes: (a) the plain case returns `Ok(())` with file and buffer both
gone; (b) `ignore_if_not_exists = true` does **zero** filesystem work
and still destroys the buffer; (c) `recursive = true` reconciles
**nothing**, so a whole tree leaves orphaned buffers — the most
destructive arm does the least reconciliation, and it bypasses any
exact-path guard; (d) removal is not `kill_buffer`, so windows are
left bound to a removed `BufferId` and the registry can be driven to
**empty**.
- **Approved in principle after review round 1**, revision 1 rejected.
Settled: refuse unconditionally; take delete-side prefix-awareness now
rather than waiting on #171. Withdrawn: rev 1's buffer-first ordering.
The design is now `stat/no-op → enumerate and validate → mutate
filesystem → reconcile`, which keeps `on_removed`'s "path already
gone" invariant and makes a failed deletion leave buffers intact
automatically.
- **Four facts a re-scout should not have to rediscover**, all verified
at `ad41cf1`:
- **No caller reliably surfaces a raise.** The server pump runs under
`pcall(handle_server_requests)` (`builtin/runtime/lsp.lua:1892`), so
a raise unwinds past the `send_response` and the server is never
answered; and the two user-initiated paths route uncaught coroutine
errors through `pmacs.error`, which is **undefined** (11 call sites
in `builtin/`, zero definitions). Refusals must travel as values.
- **A partial batch is already the status quo** — verified: two delete
ops, the second raises, the first stayed applied. LSP 3.18 says so
too: resource-op-bearing edits get `FailureHandlingKind.Abort`,
"all operations executed before the failing operation stay
executed". Any framing claiming batch atomicity here is wrong.
- **`find_by_path` is singular and duplicates are reachable.**
`BufferRegistry::find_by_path` returns the first match in insertion
order, `EditorCore::find_buffer_for_path` inherits that, and
`pmacs.buffer.from_file` creates path-bound buffers with **no
dedup** — so a clean first match can hide a modified second. The
guard needs a full scan with component-aware `Path::starts_with`.
- **pmacs advertises no `workspace.workspaceEdit` capability at all**
`"applyEdit": true` but no `documentChanges`, no
`resourceOperations`, no `failureHandling`; `grep -rn
failureHandling` returns 0. Parked, not fixed here.
- **Ownership claim, per the dired lane's own warning below:** dired
Stage 2a is "rename/delete reconciliation substrate" and overlaps
`builtin/runtime/lsp.lua`. **This lane claims the delete half of that
substrate and the `apply_workspace_edit` applier for its duration**;
dired Stage 2 keeps the rename half. Whichever lands second adopts the
first's shared lookup helper. Do not run the two concurrently over
`builtin/runtime/lsp.lua` without re-splitting that claim.
- Files the implementation will touch: `src/lua_bindings/mod.rs`,
`builtin/runtime/lsp.lua`, `tests/m4_acceptance.rs`,
`src/bin/pmacs_fake_lsp.rs`. **Not** `src/daemon.rs`,
`pmacs-protocol/`, `builtin/runtime/dired.lua`,
`docs/agent-handoff.md` or `COHERENCE.md`. No protocol change.
- Recovery from a clean checkout:
`git fetch githubsucks && git worktree add ../pmacs-resource-op-delete
-b resource-op-delete-guard githubsucks/resource-op-delete-guard`.
## dired Stage 2 framing lane — PR #171 OPEN, STALE, DO NOT MERGE AS-IS
- Portable branch: `githubsucks/dired-stage2-framing` (head `ab42a79`,

File diff suppressed because it is too large Load Diff