diff --git a/docs/journey-stage1a-framing.md b/docs/journey-stage1a-framing.md new file mode 100644 index 0000000..36f40e4 --- /dev/null +++ b/docs/journey-stage1a-framing.md @@ -0,0 +1,913 @@ +# Journey Stage 1a — open a directory, on one path + +**Status: framing, rev 5, awaiting approval.** +**Serves `COHERENCE.md` §2 (the golden product journey), §19 (coherence +acceptance tests), §20 Priority 1.** + +## 0. Revision history + +- rev 1 (2026-07-26) — first framing. Scouted against `main` @ `d400f30`. +- rev 2 (2026-07-26) — review round 1. Q#JR2 withdrawn (its ground truth + was false); destination pinning added; the resolver chain restructured + around append-only hook registration; `ResolvedTarget` typed; + `display_file`'s contract specified; the GPU-framing supersession named. +- rev 3 (2026-07-26) — review round 2. Two blockers, two contract gaps: + - **Fail-closed was not failure-atomic** (§4.4). dired mutates handle + state — claim, listing, `prev`, paint — *before* it ever attempts + `display`, so rev 2's "does nothing" left a hidden buffer and could + corrupt an existing handle's `prev`. **Per the review's decision, 1a + now carries the destination-scope substrate**: a `commit_to` + primitive that revalidates and enters the captured frontend's scope + *before* any dired mutation, so the whole post-await commit — `prev` + capture, claim, paint, display, seat — executes against the captured + destination or not at all. + - **Expected-buffer validation** (Q#JR14): the destination carries the + buffer it was requested against, so a user who replaces the bootstrap + buffer mid-listing is not overwritten by stale launch intent. Rev 2's + window-only pin said launch intent wins; it should not, and B2's + "before the user can act" was false (§8). + - **Acceptance 6 was still vacuous**, and the blanket "each fails with + the change reverted" rule cannot hold for preservation guards. §6 is + split into new-behavior acceptances and preservation pins, each pin + naming the targeted mutation that falsifies it (§6.0). + - **Hook error policy specified** (Q#JR15): in a short-circuit hook a + raise and a `false` both yield `proceed = false` (`hook.rs:299-323`); + only `HookOutcome.errors` distinguishes them. An error now stops the + chain *and* suppresses the fallback. + - The fallback slot is described honestly as an **unowned singleton** + (§0.5), not an "ownership-carrying registration". +- rev 4 (2026-07-26) — review round 3. Three substrate details and one + inverted bite mutation: + - **`InteractiveCommandOrigin` was the wrong mechanism, twice over** + (§2.11). It does not scope the APIs rev 3 claimed — no-arg + `pmacs.window.buffer()` reads `core.active_buffer_id()` directly + (`mod.rs:12547`) and `move_to_line` mutates the core's ambient active + window (`mod.rs:12703`) — so `prev` capture and cursor seating stayed + ambient. And it is *authenticated interactive-command authority*: + entering it would make dired's `paint` satisfy the pre-edit unfold + guard (`mod.rs:1391`), `invoke_interactive`'s rotation (`:5400`), and + terminal command context (`:8515`). Rev 4 uses a **separate scoped + frontend override** that also swaps `core.active_frontend`, and + `commit_to` does not touch the interactive origin (Q#JR14e). + - **`dest` becomes nonconstructible userdata** (Q#JR14d). As a table it + is shared across hook listeners, so an earlier listener could mutate + the destination and decline — redirecting later listeners or dired — + and any Lua could fabricate a valid triple. + - **Preflight was missing replaceability** (Q#JR14f). Exact display + also refuses a window dedicated to another buffer + (`editor_core.rs:3566`), so a live destination holding its expected + buffer could still refuse *after* dired claimed and painted — rev 2's + hidden-buffer failure through another door. + - **N8's falsifier was inverted** (§6.1). Both a claim and a raise give + `proceed == false`, so keying the fallback on `proceed` alone is + *correct*; `errors` decides the extra report, not the fallback. +- rev 5 (2026-07-26) — review round 4. The remaining predicate input and + acceptance details: + - **Replaceability now names the incoming buffer** (Q#JR14f). The + shared predicate takes `Option` and serves all three existing + consumers: exact display passes its requested buffer, + `probe_display_target` passes its existing-buffer result, and + `commit_to` passes `None` because dired's replacement does not exist + yet. Thus a destination dedicated to its still-current bootstrap + buffer is refused before dired mutates anything. + - **N6c is executable:** the first listener catches the userdata + mutation rejection and declines, the second verifies the token stayed + unchanged and declines, and only the fallback commits. + - The dired accessor spelling, revision heading, and Stage 2 ledger + claim are corrected. + +--- + +## 0.5. Coherence impact (`COHERENCE.md` §20, required since #163) + +- **Journey steps.** §20's first-named arc; 1a takes the broken half of + **step 3**. After 1a, `pmacs .` opens the directory. Steps 4 and 6–12 + do not change grade. §2's verdict table and §20 Priority 1's "State: + broken at step 3" line are rewritten in this PR per §25. +- **Interaction islands: adds none, removes one.** No new keymap, mode, + or modal surface; the directory arm routes into #165's dired buffer — + the "must not invent a second directory surface" constraint. The + unification (§3) removes an island: startup and the daemon bootstrap + resolve paths through two independently-written implementations today. +- **Config registry.** Adds no keys. The directory fallback is a function + slot, not a setting — `ConfigValue` is four scalars and a handler is + none of them (the reason terminal profiles could not be settings, #173). +- **Ownership, stated honestly (rev 3).** That slot is an **unowned + singleton**: last writer wins, no owning package, no `SourceLocation`, + no removal lifecycle, and it does not appear in any inspection surface. + That is a real §13 gap and this framing does not dress it up — §20 + Priority 3 is deliberately deferred, and 1a is not the place to invent + ownership machinery for one slot. **Named migration:** when Priority 3 + lands registration ownership and `pmacs.hook.remove`, the slot becomes + an ordinary lowest-priority hook subscription carrying its owner, and + this primitive is deleted rather than extended. +- **Background-work attribution (§9).** No new `JobKind` variant, no new + `PendingJob` field; the listing uses `pmacs.fs.read_dir`, whose kind + #165 added. Neutral. +- **Frontend parity (§16).** Both frontends get the behavior from the + same primitive. One asymmetry ships knowingly: the GPU path displays + its pre-existing bootstrap buffer until the listing settles (§8 B2). +- **New substrate (rev 3, revised rev 4–5).** `commit_to` (§4.4) is a + general fix for a general problem — *every* post-await + `pmacs.window.*` call in the tree acts on the ambient frontend by + documented design (`dired.lua:68-73`). 1a introduces it for one caller + and does not migrate the others; that migration is named as deferred + rather than smuggled in. It adds a **scoped frontend override** + distinct from `InteractiveCommandOrigin` (§2.11), deliberately: a + background continuation gets destination scope **without** acquiring + interactive-command authority, which keeps the "programmatic vs + interactive" distinction the unfold guard, command boundaries, and the + terminal surface all depend on. + +--- + +## 1. What Stage 1a ships + +1. **`pmacs .` opens the directory**, on the local TUI path and the + daemon/GPU bootstrap path, routed into #165's dired buffer. +2. **One path-resolution primitive** — `EditorState::open` adopts + `EditorCore::resolve_target_buffer` wholesale. +3. **A scoped-destination commit primitive** (§4.4) so an async open + lands where it was requested, or nowhere. +4. **The first cross-subsystem journey acceptance suite** (§19). + +Not in 1a — Stage 1b: a compile keybinding and `cargo build`/`test` +defaults from the existing `ProjectKind::Cargo`, LSP spawn-failure +guidance (§1.2), a welcome buffer. + +--- + +## 2. Ground truth (scouted 2026-07-26, `main` @ `d400f30`; re-verified rev 4) + +### 2.1 `pmacs .` still exits 1, and why + +`load_file` (`src/file_io.rs:81`) does `File::open` — which succeeds on a +directory — then `read_to_end`, returning `EISDIR`. Not +`ErrorKind::NotFound`, so every `NotFound` arm is skipped and the error +propagates; `main` prints and exits (`src/main.rs:400-403`). + +### 2.2 There are two path-open implementations, not one + +`resolve_target_buffer` (`editor_core.rs:885`) documents itself as *"One +primitive, so two path-normalization, dedup, and hook transactions cannot +drift apart."* Callers: `display_file` (`window_panel.rs:402`) and the +daemon bootstrap (`daemon.rs:1641`). **Local startup is not one of them** +— `EditorState::open` (`editor.rs:757`) hand-writes the same shape. + +| | `EditorState::open` | `resolve_target_buffer` | +|---|---|---| +| Stored buffer path | **normalized** — `set_buffer_path` normalizes internally (`editor_core.rs:810-822`) | **normalized** — same setter | +| Displayed name | `path.display()` raw (`editor.rs:772`) | `path.display()` raw | +| `NotFound` arm | empty path-backed buffer, `[new file]` | identical | +| Dedup | none | `find_buffer_for_path` | +| Window install | `replace_active_buffer` — drops the startup scratch (`editor.rs:797`) | none; caller installs | +| Error type | `io::Error`, bare | `String`, prefixed `cannot open {path}: ` | + +**The two agree on every observable except the error prefix and the +window install.** Rev 1 claimed a raw-vs-normalized split and built a +decision, a bet, and an acceptance on it; all three were withdrawn in rev +2. Rev 3 draws the further consequence the review identified: because the +implementations already agree, **no equivalence assertion can prove the +unification happened** — such a test passes on the pre-image. §6.0 +restructures the acceptance list around that. + +The unification's value is therefore (a) the directory arm reaching +startup once rather than being written twice, and (b) closing drift the +primitive was created to prevent and did not. Not a behavior fix. + +### 2.3 dired creates its own buffer and refuses adoption + +`claim_handle` (`dired.lua:486`) creates the buffer, applies the +read-only intercept, `set_round_trip_input`, and the `dired` major mode. +Its comment is explicit that finding a buffer by name is **not** +adoption. Handles are pathless. No Lua `buffer.set_name` / +`set_file_path` exists; dired Stage 2 (PR #171 §5) is scoped to add one. + +### 2.4 The listing is async; the bootstrap reply is not + +`read_listing` (`dired.lua:462`) awaits `pmacs.fs.read_dir` and its +comment says *"Must run inside `pmacs.async`"*. The daemon bootstrap is +one synchronous block: `open_initial_target` (`daemon.rs:1624`) → +`initial_target_snapshot` (`:1823`) → `InitialTargetResult::Opened` +(`:1888`), with the GPU frontend blocking on the reply before creating +its window (`pmacs-gpu/src/attach.rs:551`). + +`tick_async` resuming a coroutine in the frame its result arrives does +**not** bound the listing to one frame — the worker must still finish. +`tests/dired_acceptance.rs:103`'s `pump` drives until parked-coroutine +*and* pending-job counts both reach zero: *"nothing dired does is +observable until this returns."* + +### 2.5 Post-await, dired acts on the ambient frontend — by design + +`dired.lua:68-73`: *"`pmacs.window.*` calls made after the await act for +the **ambient** active frontend, since interactive origin does not +survive the tick boundary; and `pmacs.editor.move_to_line` acts on the +ambient **buffer**, which is why every post-await re-seat is guarded."* + +Correct for an interactive `C-x d`. Wrong for a startup open that must +land in a specific frontend's specific window. + +**And the ambient reach is wider than `display`.** `open_directory` +(`dired.lua:607-655`) after the await, in order: + +1. `read_listing` — the await; +2. `handle_for_path(canonical)` / `claim_handle(canonical)` — **creates a + buffer**, applies intercept/mode, registers a handle; +3. assigns `entries`, `errors`, `sort_mode`; +4. `handle.prev = pmacs.window.buffer()` — **reads the ambient buffer**; +5. `paint(handle)` — mutates the buffer; +6. `display(handle, opts, departed)` — the first call that could refuse; +7. `seat_cursor` — `move_to_line` on the ambient buffer; +8. `kill_departed`. + +`lookup_window` refuses a foreign window id (`window_panel.rs:202-212`), +but only at step 6. **Rev 2's "fails closed, does nothing" was false**: +steps 2–5 have already run. A refusal leaves a hidden dired buffer and a +registered handle, and step 4 can capture an unrelated frontend's buffer +as `prev`. §4.4 fixes this by revalidating and scoping *before* step 2. + +### 2.6 Subscribers exist before the hook fires — but ordering is fixed + +`EditorState::new()` loads the builtin runtime (dired at `editor.rs:539`) +then user `init.lua` (`:609`, `cfg(not(test))`). `HookRegistry::add` +**appends** (`hook.rs:240`); no prepend, no priority, no removal +(`COHERENCE.md` §13 names `pmacs.hook.remove`'s absence as a Priority 3 +prerequisite). **A builtin subscriber always runs before any user +subscriber, forever.** + +### 2.7 Short-circuit cannot distinguish a claim from a crash + +`run_short_circuit` (`hook.rs:299-323`) returns `proceed: false` for a +literal `false` return **and** for a raising callback; only +`HookOutcome.errors` (non-empty in the second case) tells them apart. +A resolver chain that keys only on `proceed` treats a broken user +callback as a successful claim. Q#JR15 decides the policy. + +### 2.8 `open_initial_target` reasserts after hooks + +It re-checks the buffer exists (`daemon.rs:1665-1670`) then reinstalls it +into the origin document window, rehoming if a hook closed it +(`:1673-1690`). §4.5's design does not fight this. + +### 2.9 This deliberately supersedes part of the GPU initial-target framing + +`docs/gpu-initial-target-framing.md` Q#GT6 (`:278`) lists `IsADirectory` +among initial-target failures; its acceptance 10 (`:550`) requires *"a +directory/permission-denied target returns a specific failure before +ready/window creation"*. **1a supersedes the directory half only.** +Permission-denied, invalid path bytes, session teardown, and the +"existing daemon remains connectable" clause keep their contract. The +superseded assertions are amended in that framing in this PR, per §25. + +### 2.10 `display_file`'s directory failure is load-bearing today + +`builtin/commands/default.lua:724` wraps `display_file` in a `pcall` +whose comment says *"only a real failure (a directory, a permission +error) reaches here"*, pinned by +`find_file_accepting_a_directory_reports_instead_of_raising` +(`tests/find_file_acceptance.rs:235`). §4.6 answers to it. + +### 2.11 There is a scope mechanism, and it is the wrong one + +`acting_frontend` (`window_panel.rs:46-50`) reads +`InteractiveCommandOrigin` app data, falling back to +`core.active_frontend_key()`; `InteractiveCommandOrigin::enter(fid)` +(`editor.rs:63-69`) returns an RAII guard. Rev 3 proposed reusing it. +Two independent reasons it cannot be: + +**(a) It does not scope what rev 3 claimed.** Only the window-panel +bindings consult `acting_frontend`. Two of dired's post-await steps do +not go through it at all: + +- **no-arg `pmacs.window.buffer()`** — step 4's `prev` capture — reads + `core.active_buffer_id()` directly (`mod.rs:12547`). Its comment is + explicit that this is deliberate and infallible, and states the + assumption it rests on: *"dispatch sets `active_frontend` to the acting + frontend before running a command, so the two agree on every real + path."* +- **`pmacs.editor.move_to_line`** — step 7's cursor seating — is + `cc.borrow_mut().move_to_line(line)` on the core's ambient active + window (`mod.rs:12703`). + +So entering the interactive origin would scope `display` and leave `prev` +capture and seating ambient — precisely the two steps §2.5 identifies as +corrupting. + +**(b) It is authenticated user-command authority, and a startup +continuation must not impersonate one.** `InteractiveCommandOrigin` is +what distinguishes a user command's edit from a plugin's or the data +API's. Three consumers would be misled: + +- the **pre-edit unfold** guard (`mod.rs:1385-1400`), whose doc calls it + *"the scoped authority that distinguishes a user command's edit from a + plugin's or the data API's programmatic one"* — dired's `paint` would + satisfy it and unfold at the edit site; +- `invoke_interactive`'s command-boundary rotation (`:5400`), which + raises without it and would silently succeed with it; +- `terminal_command_frontend` / `active_terminal_view_key` (`:8515`, + `:8527`), which treat its presence as "an interactive frontend context". + +Q#JR14e therefore introduces a **separate** override. Note that the +`window.buffer()` comment above is not an obstacle but a specification: +swapping `core.active_frontend` for the scope's extent is exactly what +makes its stated assumption true for a continuation, restoring the +invariant rather than working around it. + +--- + +## 3. The unification (Q#JR1) + +`EditorState::open` becomes a thin caller of `resolve_target_buffer`, +keeping `replace_active_buffer` (which drops the startup scratch, Q#JR3) +and keeping its "fire the hook after the core borrow ends" structure +(`editor.rs:786-795`) — listeners re-enter `pmacs.editor.*` and re-borrow +the core (Q#JR1a). + +**Q#JR4** — startup errors gain the `cannot open {path}: ` prefix. +`pmacs /root/secret` names the file, which today's bare message does not. +This is the *only* user-visible change from the unification (§2.2). + +**Q#JR12** — a directory argument counts as "had a file argument" and +suppresses desktop restore, on Q#DS7's reasoning: a positional argument +means "open this", not "restore my session". + +--- + +## 4. The directory arm, the resolver, and the destination + +### 4.1 Q#JR5 — a typed result + +```rust +pub enum ResolvedTarget { + Buffer { id: BufferId, fire: HookKind }, + Directory { path: PathBuf }, // normalized: absolute, ~-expanded, lexically clean +} +``` + +Rev 1's `(Option, HookKind)` admitted states that cannot occur. +`resolve_target_buffer` checks `path.is_dir()` ahead of the load. + +**Q#JR8** — the `Directory` variant carries an explicitly normalized +path. It is *not* free: normalization lives inside `set_buffer_path`, and +this arm creates no buffer, so nothing would normalize anything and the +local caller would still hold `"."`. Same lesson as the Lean 4 arc's URI +affinity — a handler keying state by path must never receive `"."`. + +**Q#JR5b** — `editor_core::HookKind` and `hook::HookKind` are unrelated +types sharing a name; both are written path-qualified in every file this +PR touches, and `window_panel.rs:37`'s bare import is changed to match. + +**Q#JR6** — Rust creates no buffer for a directory. A placeholder needs +reaping, is reinstalled by §2.8's reassert, and — if dired adopted it — +would drag in dired Stage 2's rename prerequisite (§2.3). + +### 4.2 Where the directory arm is consumed + +`EditorState::open` and `open_initial_target` dispatch the resolver +chain. `display_file` does not (§4.6). + +### 4.3 Q#JR7 — a user-only hook, then a replaceable fallback + +Given §2.6, "a package subscribes ahead of dired" is unreachable. So the +two roles are split: + +**The chain.** `path.open-directory`, `kind = "short-circuit"`, fired +first. Returning `false` claims the directory and stops the fan-out. **No +builtin subscribes** — the rule that makes "user code runs first" true +under append-only registration, stated in the hook's own description. + +**The fallback.** If unclaimed, the arm calls the directory handler — a +function slot defaulted by `dired.lua`: + +```lua +pmacs.path.set_directory_handler(function(path, dest) + open_async(path, { dest = dest }, nil, "dired") +end) +``` + +Users replace it, chain it (capture the previous value first), or +**disable** it (`set_directory_handler(nil)`), which is what makes +acceptance 10's unclaimed path reachable. It is an unowned singleton +slot, with the honest accounting and named migration in §0.5. + +**Q#JR15 (new) — a raising callback stops the chain *and* suppresses the +fallback.** §2.7 shows `proceed` alone cannot distinguish a raise from a +claim. Policy: inspect `HookOutcome.errors`; when non-empty, report +through `*errors*` **and** `pmacs.editor.set_status`, and do **not** run +the fallback. Rationale: this preserves the existing short-circuit +contract (a raising `buffer.before-save` callback already vetoes the +save), and running the fallback after a user's resolver crashed would +open dired on a directory the user's code may have been mid-way through +handling. The cost — a broken user callback disables directory opening +until fixed — is visible, reported through two surfaces, and preferable +to silently ignoring the user's resolver. + +*Deferred, named:* hook priority/prepend is the general fix for §2.6 and +belongs with `pmacs.hook.remove` in Priority 3. When it lands, the +fallback becomes an ordinary lowest-priority subscription. + +### 4.4 Q#JR14 (rev 5) — the scoped-destination commit + +**The blocker rev 2 missed:** §2.5 shows dired mutates handle state at +steps 2–5 and only reaches a refusable call at step 6. "Fails closed, +does nothing" was false — a refusal left a hidden buffer, a registered +handle, and a `prev` captured from whichever frontend happened to be +ambient. Per the review's decision, **1a carries the substrate fix.** + +**Q#JR14d — the destination is an opaque capability, not a table.** +`dest` is **nonconstructible userdata**, created only by Rust, holding +three private ids: + +| field (private) | source | purpose | +|---|---|---| +| frontend | local: `FrontendId::LOCAL`; bootstrap: the attaching `frontend_id` | the scope to commit in | +| window | local: the active window; bootstrap: `origin_window` (`daemon.rs:1637`) | where the listing goes | +| buffer | the buffer that window holds at capture time | **stale-intent detection** | + +A table would be wrong in two ways, both reachable: the *same* `dest` is +passed to every hook listener in turn, so an earlier listener could +mutate it and then decline — redirecting later listeners or the fallback +— and any Lua could fabricate a plausible triple and call `commit_to` +directly. Userdata makes both unrepresentable rather than merely +discouraged. + +The only accessor is read-only `dest:window()`, which dired needs for its +exact `display{window = …}` target. `commit_to` accepts **only** this +userdata and revalidates its private contents itself; it never trusts a +caller-supplied id. + +**Q#JR14e — a separate scoped frontend override, not the interactive +origin.** §2.11 gives both reasons. Rev 4 adds a distinct app-data +override with resolution order: + +``` +acting_frontend = scoped override → interactive origin → ambient +``` + +Its RAII guard **also** swaps `core.active_frontend` and restores it on +drop, which is what covers the core-ambient APIs `acting_frontend` never +sees (`window.buffer()` no-arg, `move_to_line`). `commit_to` does **not** +enter `InteractiveCommandOrigin`, so a startup continuation never +acquires interactive-command authority. + +**The primitive.** `pmacs.window.commit_to(dest, fn)`: + +1. **Preflight, before running anything** — the destination's frontend + has a registered view; its window is live in that view's layout; the + window still holds the captured buffer (Q#JR14c); and the window is + **replaceable** (Q#JR14f). +2. On any failure, returns `false, reason` **without calling `fn`** — so + nothing is claimed, painted, or captured. +3. On success, enters the scoped override for the dynamic extent of `fn` + and calls it. Inside, `display{window = …}`, no-arg + `window.buffer()`, `move_to_line`, and every other ambient primitive + resolve against the captured destination — which is why a `frontend` + option on `display` alone would have been insufficient. + +**Q#JR14f — preflight must establish replaceability, through the same +predicate every exact-target probe and display uses.** Exact display +refuses a window that is `dedicated` unless it already shows the +*incoming* buffer (`editor_core.rs:3566`). The distinction is +load-bearing here: `dest.buffer` is the captured bootstrap buffer, not +dired's future buffer. Passing it as the incoming buffer would approve a +window dedicated to that bootstrap buffer; dired would then claim and +paint its different buffer, and exact display would refuse afterward — +rev 2's hidden-buffer failure through another door. + +The eligibility test is therefore extracted once, with the semantic +input `incoming: Option`: + +| caller | input | dedicated-window result | +|---|---|---| +| `display_buffer` exact-target arm | `Some(request.buffer_id)` | eligible only when already showing that buffer | +| `probe_display_target` | its existing `Option` | preserves today's load-before-placement probe contract | +| `commit_to` preflight | `None` | always ineligible — the replacement does not exist yet | + +`probe_display_target` already carries the correct `Option` +shape (`editor_core.rs:3470-3483`), so leaving it on a private copy while +sharing only the other two would preserve the same drift this extraction +exists to remove. Core unit coverage pins the three decisive rows: +dedicated + `Some(current)` is eligible; dedicated + `Some(other)` is +refused; dedicated + `None` is refused. + +**Q#JR14b — `fn` must not await.** The scope is an RAII guard on the +Rust stack; a yield inside it would let the guard's extent and the +coroutine's suspension diverge, restoring the override while the +continuation is still parked. `commit_to` sets a flag that `Handle:await` +checks and raises on, naming the rule. Enforced, not documented — pinned +by N6. + +**Atomicity, stated precisely.** `commit_to` is atomic **against +destination-precondition failure**: if any preflight check fails, no +callback runs and nothing is mutated. It is **not** a transaction over +the callback — if `fn` raises halfway through, `commit_to` restores the +scope and propagates, but whatever `fn` already mutated stays mutated. +Rolling that back would require dired to make its claim/paint sequence +undoable, which is a dired change well beyond 1a. What 1a guarantees is +that the *destination* checks happen before the first mutation, which is +the failure the review identified. + +**dired's change.** `open_directory` keeps `read_listing` (the await) +outside, then performs steps 2–8 inside a single `commit_to` callback, +displaying with `{ window = dest:window() }` rather than the ambient +`switch_buffer`. On a `false` return it reports through +`pmacs.editor.set_status` and returns, having mutated nothing. + +**Q#JR14c — stale intent loses to the user.** If the destination window +now holds a different buffer than at capture, the request is stale and +**fails closed**. Rev 2's window-only pin said launch intent overwrites +whatever the user did meanwhile; that was wrong, and it rested on B2's +"before the user can act", which §2.4 disproves — a large directory takes +many frames and the user can act in every one of them. The user's action +is newer information than the launch argument. + +**What this buys, stated as the review framed it:** competing frontend +activity no longer turns a valid startup request into a nondeterministic +no-op. A live, unchanged destination receives its listing regardless of +what other frontends did meanwhile. Fail-closed is reserved for a +destination that is genuinely dead or stale. + +*Deferred, named:* migrating dired's other post-await paths (`C-x d`, +tree descent/ascent, refresh) and every other ambient post-await +`pmacs.window.*` call in the tree onto `commit_to`. 1a introduces the +primitive for the startup path and does not sweep; the sweep is its own +PR with its own acceptance, and this framing does not pretend the general +problem is solved. + +### 4.5 Q#JR9 — what the bootstrap reply names, and what it shows + +`open_initial_target` on a `Directory` installs nothing: it dispatches the +resolver, then replies `Opened { buffer_id }` naming the buffer the fresh +view's primary document window already holds. §2.8's reassert reasserts +that same buffer — already correct, therefore harmless. + +**That buffer is not necessarily `*scratch*`.** `build_fresh_frontend_view` +clones **LOCAL's primary document buffer** (`daemon.rs:2997`) — M10.9 made +attaching frontends share LOCAL's buffer so overlays fire; the +bottom-panel arc narrowed it to the *primary document* buffer so a TUI +panel could not become a new frontend's document. If LOCAL holds a real +document, `pmacs --gpu .` briefly displays and snapshots that unrelated +document. + +**Decision: accept and document.** A bootstrap placeholder re-creates +everything Q#JR6 rejected to fix a transient, and the session genuinely +*is* showing LOCAL's document — the same thing a no-argument `--gpu` +attach shows. Acceptance N5 pins it with a deliberately non-scratch LOCAL +primary so it is observed rather than assumed. + +### 4.6 Q#JR13 — `display_file` keeps its directory error + +`display_file` does **not** dispatch the resolver. On +`ResolvedTarget::Directory` it raises: + +- the message names the path and the directory reason (an improvement on + the raw `EISDIR` text, and the only user-visible change here); +- the active buffer, window layout, and selected window are unchanged — + nothing created, nothing switched; +- `find_file_accepting_a_directory_reports_instead_of_raising` passes + **unmodified**. + +`display_file` is "put this file in a window", not a CLI router. Routing +it into dired would silently change `C-x C-f` on a directory, in a PR +about the CLI, through a `pcall` arm whose comment guarantees the +opposite. + +*Deferred, named:* Emacs's `find-file` does open dired on a directory, +and that is reasonable eventual behavior. It is a find-file UX decision +with its own acceptance, belonging to the dired arc or 1b. When taken it +is a small change at `default.lua:724`, and the pinned test above is what +gets deliberately rewritten. + +--- + +## 5. The journey acceptance suite (§19) + +New: `tests/journey_acceptance.rs`, seeded with steps 2 (launch +unconfigured), 3 (open a real project), and 5 (edit immediately), +driving the **real startup entry point** — a directory arm with no +production caller passes every direct-call test. Steps 6–12 enter as +later stages make them real; the file is a ratchet. + +Every dired-dependent assertion pumps to quiescence using +`dired_acceptance.rs:103`'s idiom (parked coroutines *and* pending jobs +at zero), never a fixed frame count (§2.4). + +--- + +## 6. Acceptance + +### 6.0 Two kinds of pin, and why the distinction matters + +Rev 2 asserted that every acceptance "fails with the change reverted". +The review is right that this cannot hold for preservation guards — and +rev 2's acceptance 6 was the proof: because both implementations already +agree on every observable (§2.2), an equivalence assertion passes on the +pre-image. **Behavioral equivalence cannot demonstrate structural reuse.** +The list is therefore split, and each preservation pin names the +*targeted mutation* it is bite-tested against: + +- **(N) New-behavior acceptances** — must fail on full revert. +- **(P) Preservation pins** — legitimately green on the pre-image; + falsified by a named targeted mutation, not by revert. + +That local startup reaches the new directory behavior is proven by N1, +not by any equivalence assertion — which is also why rev 2's acceptance 6 +is **removed rather than recast**: it proved nothing N1 does not. + +### 6.1 New-behavior acceptances (N) + +- **N1** `pmacs .` in a project directory exits 0 and, after pumping to + quiescence, the active buffer is dired's, listing that directory. + Today: exit 1. +- **N2** Daemon/GPU bootstrap with a directory initial target receives + `InitialTargetResult::Opened`, not `Failed`, and after quiescence the + document window shows the dired buffer. Supersedes the GPU framing's + acceptance 10 for directories (§2.9). +- **N3** `pmacs .` on an unreadable directory reports through dired's + status path and leaves the session running — no exit 1, no half-built + buffer. +- **N4 — delivery despite competing frontends (the blocker's positive + half).** Two registered frontends; a directory bootstrap for frontend + A; frontend B dispatches unrelated activity (buffer switch, window + focus) while the listing is in flight. After quiescence the listing is + in **A's** captured window, and B's active buffer and window are + unchanged. Falsified by reverting `commit_to` to the ambient + `switch_buffer`. +- **N5** Bootstrap with a deliberately **non-scratch** LOCAL primary + document buffer: the reply's `buffer_id` is that buffer, and after + quiescence the window shows dired (Q#JR9, §4.5). +- **N6 — `commit_to` scopes and restores, on every exit path.** Three + cases, each asserting that **both** the scoped override and + `core.active_frontend` return to their prior values: (a) `fn` returns + normally; (b) `fn` raises; (c) `fn` awaits and is refused (Q#JR14b). + Case (c) additionally asserts the raise names the rule. Rev 3 checked + only the interactive origin's restoration on the success path, which + §2.11 shows is neither the right value nor enough paths. Falsified by + dropping the flag, or by restoring on success only. +- **N6b — `commit_to` refuses a forged destination.** A Lua-constructed + table with plausible `frontend`/`window`/`buffer` fields is rejected as + a type error, and userdata cannot be constructed from Lua (Q#JR14d). + Falsified by accepting a table. +- **N6c — a declining listener cannot redirect the destination.** Two + listeners: the first receives `dest`, attempts mutation inside `pcall`, + observes the read-only rejection, and declines; the second verifies + `dest:window()` still names the original window and also declines; then + the fallback commits there (Q#JR14d). Falsified by passing a shared, + mutable table. +- **N7 — the resolver chain.** `path.open-directory` is short-circuit and + first-claimant-wins, exercised through an **ordinary user-registered + listener** (no builtin subscribes, §4.3): two listeners, the first + returns `false`, the second must not run, and the fallback must not + run. Falsified by `all-must-succeed` or `accumulate`. +- **N8 — a raising callback suppresses the fallback *and* is reported + (Q#JR15).** A listener that raises: the fallback does not run, the + directory does not open, and the failure reaches both `*errors*` and + the status line. + *Falsifier, corrected in rev 4:* keying the fallback on `proceed` alone + is **already correct** for suppression — §2.7 shows a raise gives + `proceed == false` just as a claim does. `errors` decides the *report*, + not the fallback. So N8 is falsified by either (a) running the fallback + when `errors` is non-empty — i.e. treating a raise as a decline — or + (b) mutating the short-circuit outcome so a raise yields + `proceed = true`. Rev 3 named the inverse mutation, which does not + falsify anything. +- **N9** The hook and the handler receive a **canonical absolute path** — + firing on `.` from a known cwd delivers that cwd, not `"."` (Q#JR8). +- **N10** With the handler slot cleared and no listener claiming, + `pmacs .` exits **0**, leaves the bootstrap buffer in place, and sets a + status naming the path (Q#JR10). +- **N11** `pmacs .` → dired lists → `RET` on a listed file visits it → a + self-insert lands in **that file's** buffer. (Rev 1 self-inserted into + the dired buffer, whose intercept rejects every edit, `dired.lua:506`.) + +### 6.2 Preservation pins (P), each with its falsifying mutation + +- **P1 — precondition failure is atomic (the blocker's negative half).** + **Three** destination failures, each asserted the same way — after + quiescence the buffer count is unchanged, **no dired buffer or handle + exists for that path**, no window's buffer changed, and a status names + the failure: + 1. **dead** — the destination window was closed; + 2. **stale** — its buffer was replaced (Q#JR14c); + 3. **ineligible** — it is `dedicated` to its still-current captured + buffer, but dired's incoming replacement does not exist yet + (Q#JR14f, completed rev 5). This is the case a preflight that + mistakenly passes `dest.buffer` as the incoming buffer approves and + `display` then refuses *after* dired has claimed and painted. + *Mutation:* move the preflight from before `claim_handle` to after + `paint` — rev 2's design. P1 fails on all three; rev 2's acceptance 3b + passes. *Second mutation, for case 3 specifically:* pass + `Some(dest.buffer)` instead of `None` to the shared eligibility + predicate while keeping liveness and stale-buffer validation. Only case + 3 fails — which is the point of separating it. +- **P2 — stale intent loses (Q#JR14c).** The user replaces the + destination window's buffer while the listing is in flight; their + buffer survives and dired does not overwrite it. + *Mutation:* drop `dest.buffer` from revalidation (rev 2's window-only + pin). P2 fails. +- **P3 — dired's existing handles are not corrupted.** With a dired + buffer already open in another frontend, a failed startup open leaves + that handle's `prev`, entries, and cursor untouched. + *Mutation:* restore the ambient `handle.prev = pmacs.window.buffer()` + outside the scope (§2.5 step 4). +- **P4 — the startup scratch is still dropped (Q#JR3).** + `EditorState::open` leaves exactly one buffer. + *Mutation:* replace `replace_active_buffer` with a bare + `install_buffer_in_window`. +- **P5 — the `NotFound` arm survives the refactor.** A nonexistent path + yields an empty path-backed buffer with `[new file]` and fires no hook. + *Mutation:* delete the `NotFound` arm from `resolve_target_buffer`. +- **P6 — `display_file` keeps its contract (Q#JR13).** It raises on a + directory naming path and reason; active buffer, layout, and selected + window unchanged; `find_file_accepting_a_directory_reports_instead_of_raising` + passes unmodified. + *Mutation:* route `display_file` into the resolver chain. +- **P7 — desktop restore stays suppressed (Q#JR12).** + *Mutation:* pass `false` for `had_file` on the directory path. +- **P8 — startup errors name the file (Q#JR4).** A non-`NotFound`, + non-directory failure produces a message containing `cannot open` and + the path. *(Legitimately N-shaped for the prefix, P-shaped for the + failure itself; listed here because the failure behavior is preserved + and only the message changes.)* + +`scripts/bite` runs over the new suite. A VACUOUS report on any N is a +blocker; each P's named mutation is run as its bite check, since revert +cannot falsify it. + +--- + +## 7. Deferred (named) + +- **Migrating the rest of the tree onto `commit_to`** (§4.4) — dired's + other post-await paths and every other ambient post-await + `pmacs.window.*` call. Its own PR, its own acceptance. +- **Hook priority / prepend**, with `pmacs.hook.remove`, in §20 Priority + 3 — at which point the fallback slot becomes an ordinary lowest-priority + subscription and §0.5's unowned-singleton gap closes. +- **True adoption (option B).** Rust creates the buffer, dired adopts — + one buffer, no transient — but it needs dired Stage 2's rename / + clear-path capability (§2.3). Dired Stage 3; Q#JR6 does not block it. +- **The bootstrap transient** (§4.5, §8 B2). +- **`C-x C-f` on a directory opening dired** (§4.6). +- **Multiple path arguments** (`main.rs:227`, `:232`, `:240`). +- **`pmacs .` opening a panel** rather than the document window. +- Stage 1b and the rest of §20 Priority 1. + +--- + +## 8. Bets + +- **B1 — "one thing opens a directory" holds.** If a picker and dired + should both run, short-circuit is wrong and the hook must become a + resolver returning a target. +- **B2 (corrected twice) — the bootstrap transient is acceptable.** The + window shows its pre-existing buffer **until the listing settles** — + not "one frame" (rev 1), and **not** "before the user can act" (rev 2): + §2.4 disproves the bound and Q#JR14c is the consequence — the user + *can* act, so stale intent must lose. The bet is only that the + transient is visually acceptable at process start. +- **B3 — withdrawn** (rev 2). There was no path-normalization change. +- **B4 — failing closed on a genuinely dead or stale destination is + better than guessing.** Narrowed in rev 3: it applies only after + revalidation says the destination is gone, not to any competing + activity (N4). +- **B5 — `commit_to`'s no-await rule is livable.** Every commit step + dired performs after the listing is synchronous today, so the rule + costs nothing here. If a future handler genuinely needs to await + mid-commit, the primitive needs a re-entrant design and this bet is + what will have failed. +- **B6 (rev 5) — extracting the eligibility predicate is + behavior-preserving.** Q#JR14f shares one predicate between + `commit_to`'s preflight, `probe_display_target`, and `display_buffer`'s + exact-target arm rather than writing a third copy. The bet is that the + two existing callers' behavior survives the extraction unchanged — + core unit tests pin the `Option` matrix, and + `bottom_panel_stage1_acceptance` catches placement-level drift, which + is why it is in the gate list. The alternative has no extraction risk + and a certain cost: a future eligibility rule added to one copy reopens + Q#JR14f's exact hole. Taking the risk tests can catch over drift they + cannot. + +--- + +## 9. Gates + +``` +cargo fmt --check +cargo clippy --workspace --all-targets -- -D warnings # own step +cargo test --lib +cargo test --lib --features crdt +cargo test --test journey_acceptance +cargo test --test dired_acceptance +cargo test --test find_file_acceptance # P6, unmodified +cargo test --test gpu_initial_target_acceptance # §2.9 supersession +cargo test --test theme_faces_acceptance # EditorState::open caller +cargo test --test m4_acceptance -- --skip basedpyright # 4 open() callers +cargo test --test bottom_panel_stage1_acceptance # commit_to touches display +PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu +cargo test --workspace -- --skip basedpyright +git diff --check +``` + +`m4_acceptance` and `theme_faces_acceptance` call `EditorState::open` +directly (§2.2) — the unification's blast radius. `find_file_acceptance` +and `gpu_initial_target_acceptance` encode contracts this PR preserves +(§4.6) and supersedes (§2.9). `bottom_panel_stage1_acceptance` is +included because `commit_to` scopes the frontend that `display`'s +placement policy resolves against and Q#JR14f extracts the exact-target +eligibility rule that suite already pins. + +--- + +## 10. Sequencing + +**1a implements after PR #177 merges.** #177 touches `src/daemon.rs` and +`src/editor.rs`; §2.8's reassert logic sits next to its census work. +#179 also touches `src/editor.rs`. + +No dired code is in flight — #169 and #171 are docs-only and no open PR +touches `builtin/runtime/dired.lua` (verified 2026-07-26). 1a's dired +change (a handler registration, plus wrapping `open_directory`'s +post-await commit in `commit_to`) does not collide with dired Stage 2, +which is unapproved for implementation. The `commit_to` wrap is a larger +dired change than rev 2's, touching the body Stage 2's rename work also +touches. + +**Rev 5 — decided: 1a stays ahead of dired Stage 2.** Stage 2 is not in +implementation, and the scoped commit boundary gives it a better shape to +build on than it would have had — a rename transaction across five path +owners is exactly the kind of multi-step commit that wants a validated, +scoped destination rather than ambient state. **Obligation this creates:** +when 1a lands, dired Stage 2 re-scouts and revises its framing around +`commit_to` before implementation; that revision is a prerequisite of +Stage 2's branch, recorded here and in `docs/active-work.md` so it is not +discovered late. + +--- + +## 11. Numbered decisions + +- **Q#JR1** `EditorState::open` adopts `resolve_target_buffer` wholesale. +- **Q#JR1a** The hook fires outside the core borrow. +- **Q#JR2** *Withdrawn (rev 2)* — its premise was false. +- **Q#JR3** The scratch drop (`replace_active_buffer`) is preserved. +- **Q#JR4** Startup errors gain the `cannot open {path}: ` prefix. +- **Q#JR5** `resolve_target_buffer` returns a typed `ResolvedTarget`. +- **Q#JR5b** Both `HookKind` types are written path-qualified. +- **Q#JR6** Rust creates no buffer for a directory. +- **Q#JR7** `path.open-directory` is a short-circuit **user-only** chain; + builtins do not subscribe; dired is a replaceable fallback slot. +- **Q#JR8** `ResolvedTarget::Directory` carries an explicitly normalized + path. +- **Q#JR9** The bootstrap reply names the window's pre-existing buffer — + LOCAL's primary document buffer, not necessarily scratch. Accepted and + documented. +- **Q#JR10** An unclaimed directory with the handler cleared exits 0 with + a status message. +- **Q#JR12** A directory argument suppresses desktop restore. +- **Q#JR13** `display_file` keeps its directory-is-an-error contract. +- **Q#JR14** The destination `{frontend, window, buffer}` is captured at + resolve time; `commit_to` preflights and scopes the **entire** + post-await commit. +- **Q#JR14b** A `commit_to` callback must not await; enforced, not + documented. +- **Q#JR14c** Stale intent loses to the user: a replaced destination + buffer fails closed. +- **Q#JR14d** `dest` is nonconstructible userdata with a read-only + `window()` accessor — not a table a listener can mutate or Lua can + forge. +- **Q#JR14e** A **separate** scoped frontend override, resolved ahead of + the interactive origin and also swapping `core.active_frontend`. + `commit_to` never enters `InteractiveCommandOrigin`. +- **Q#JR14f** Preflight establishes **replaceability** via the same + `Option` eligibility predicate used by + `probe_display_target` and `display_buffer`; `commit_to` passes `None` + because its replacement does not exist yet. +- **Q#JR15** A raising resolver callback stops the chain **and** + suppresses the fallback, reported through `*errors*` and the status + line. + +--- + +## 12. Branch and PR plan + +One feature, one branch, one PR: `journey-stage1a-directory-open`. + +1. Commit this framing. +2. Unification (§3) + P4, P5, P7, P8. +3. `ResolvedTarget` + the directory arm + the resolver chain, fallback + slot, and error policy (§4.1–4.3) + N7, N8, N9, N10; `display_file`'s + preserved contract (§4.6) + P6. +4. The scoped frontend override + the shared eligibility predicate + (Q#JR14e, Q#JR14f), then `commit_to` and the opaque destination + (§4.4) + N4, N6, N6b, N6c, P1, P2, P3. The override and the predicate + extraction land first as separable core changes: both are testable + without dired, and the predicate's `Some(current)` / `Some(other)` / + `None` unit matrix plus `bottom_panel_stage1_acceptance` must prove the + extraction behavior-preserving before anything depends on it. +5. `tests/journey_acceptance.rs` (§5) + N1, N2, N3, N5, N11. +6. `COHERENCE.md` §2 verdict table and §20 Priority 1 rewritten per §25; + `docs/gpu-initial-target-framing.md` Q#GT6 + acceptance 10 amended for + the superseded directory case (§2.9); `docs/agent-handoff.md` §1 and + `docs/active-work.md` updated.