pmacs/docs/destination-capture-framing.md

422 lines
22 KiB
Markdown

# A destination capture any async continuation can use
**Status: framing pass, revision 4. Pre-implementation. Awaiting
approval.**
**Revision 4 specifies the call shape the last two revisions kept
referring to without defining.** "The profile is declared at
`commit_to`" named no signature, no value set, no invalid-profile
behaviour, and nothing about the existing two-argument callers — so
#227 had no stable API to adopt and the Journey preservation promise
rested on care rather than contract. Q#DC-5 fixes that:
`commit_to(dest, body [, profile])`, a **closed** two-value set,
**omitted means `"document"`** so every existing call keeps all four
preflight checks by definition, and an unrecognized profile **errors**
rather than falling back.
**Revision 3 decides Q#DC-4, which revision 2 left contradicting
Q#DC-2 — on the primary panel API.** Q#DC-2 concluded a panel needs
only a live frontend; Q#DC-4 still returned `nil` without a document
window and told git to fall back to ambient behaviour, which is the
very bug this lane removes. Resolved: the destination's document pair
is **optional**, `capture_destination()` is **profile-blind and
argument-free**, the profile is declared at `commit_to`, and a
document-profile commit without a document pair is refused. §4 and
Q#DC-1 were updated to match rather than left to disagree.
**Revision 2 takes three review findings.** Q#DC-2's parameterization
was **incomplete** — a panel result does not depend on the captured
document window being live or non-dedicated either, not just on its
buffer, so the question now carries a full **preflight matrix** with
every omission testable. `tests/journey_acceptance.rs` joins dired as a
named **preservation suite and stop signal**; it carries the
`commit_to` scope, forged-userdata, preflight and restoration pins this
lane generalizes, and Journey Stage 1a's framing treats it as a
required gate. And **§5 (coherence impact) was missing entirely**,
which `CLAUDE.md` and `COHERENCE.md` §25 both require of
coherence-affecting work — this lane adds Lua API surface and
generalizes a Journey substrate, so it qualifies twice over.
**A prerequisite lane. PR #227 (git Stage 1) blocks on it**, and its
P1a review finding is the reason this exists.
---
## 1. Why, and why as its own lane
PR #227's review found that git's async completions mutate and display
UI without capturing the initiating frontend
(`builtin/runtime/git.lua:609`, `:854`), so a result can surface in
whichever frontend happens to be active when git exits. Run
`git.status` in frontend A, let frontend B become active, and A's panel
opens in B.
**The finding named the right mechanism.** `pmacs.window.commit_to`
exists for exactly this continuation boundary: Journey Stage 1a's
Q#JR14 built it because "the listing settles a tick or more later, and
by then the ambient frontend, selected window, and active buffer may
all name something else" (`src/editor.rs:1238-1240`).
**But it is not reachable from Lua outside one path**, which is why
this is a lane and not a line in #227:
- `commit_to` takes a `DirectoryDestinationLua`, **nonconstructible
from Lua** by deliberate design (`src/lua_bindings/mod.rs:4256`) —
userdata with no constructor and no setters, so a caller cannot
fabricate a plausible triple.
- The only site that mints one is inside the `path.open-directory`
listener dispatch (`src/editor.rs:1311`), from
`capture_directory_destination`, which is `pub(crate)`
(`src/editor.rs:1241`).
So any async Lua continuation that is **not** a directory open has no
way to say where its result belongs. Git is the first to need it; it
will not be the last.
Landing this inside #227 would put new Lua API surface, over another
lane's merged mechanism, inside a feature branch — the same folding
that was declined for the `scripts/gate` repair, for the same reason.
## 2. Ground truth
- **The captured data is already generic.**
`DirectoryDestination { frontend, window, buffer }`
(`src/editor_core.rs:159-166`) contains nothing directory-specific.
Only its **name** and its **capture site** are.
- **The blast radius of a rename is small**: 8 references across 4
files (`editor_core.rs`, `editor.rs`, `lua_bindings/mod.rs`,
`lua_bindings/window_panel.rs`). Checked, not estimated.
- **`commit_to`'s preflight is four checks**
(`src/lua_bindings/window_panel.rs:488-525`), in order: the
requesting frontend still has a layout; the destination window is
still live in it; **the window still shows the captured buffer**
(Q#JR14c stale intent); and the window is not dedicated (Q#JR14f).
- **`Handle:await` refuses inside a commit scope**
(`builtin/runtime/async.lua:87-90`) — yielding would restore the
scope while the coroutine is still parked. Any adopter awaits
*before* committing, as dired does.
- **Git's two continuations do not have the same shape**, and this is
the finding that shapes the design:
- `*git-status*` goes through `listview.open`, which resolves
`display` with a **`"panel"`** default
(`builtin/runtime/listview.lua:550`). It lands in the bottom
panel, **not** in a document window.
- `*git-diff*` calls `pmacs.window.display(buf, { select = true })`
— the **document** target, deliberately, "so the status panel it
was invoked from stays visible beside it"
(`builtin/runtime/git.lua:852-854`).
## 3. The tension this lane has to resolve
`DirectoryDestination.buffer` exists for one purpose, stated at its
definition: *"what that window held at capture time, so **stale intent
loses to the user**"* — a user who replaced the buffer while work was
in flight is newer information than the request.
**That predicate is right for a document replacement and wrong for a
panel.** The git status panel does not replace the captured window's
buffer; it opens in the bottom panel beside it. Refusing to show it
because the user switched files in the document window would be a
refusal with no relationship to what the continuation actually does —
the panel case would inherit a check about a window it never touches.
Meanwhile the diff case *is* a document replacement, and wants exactly
the dired semantics.
So a single one-size destination either **over-refuses** the panel case
or **under-checks** the document case. Q#DC-2 is where that gets
decided, and it is the substance of this lane.
## 4. The change, in outline
- **A Lua-reachable capture**, returning the same nonconstructible
userdata for the *current* frontend, **with** its document window and
buffer when it has one and without them when it does not (Q#DC-4).
The capture takes no arguments and is profile-blind; the profile is
declared at `commit_to`.
- **Generic naming.** `DirectoryDestination` becomes something that
does not lie about a git panel; `capture_directory_destination` and
the userdata type follow. 8 references (§2).
- **The directory path keeps behaving exactly as it does today** — this
lane generalizes the capture, it does not change Journey Stage 1a's
semantics.
- **No adopter in this lane.** Git's adoption is #227's, after this
lands. A prerequisite that also converts its first consumer makes the
two impossible to review separately.
## 5. Coherence impact (§20)
**Revision 1 omitted this section entirely, and it is required.**
`CLAUDE.md` and `COHERENCE.md` §25 both say a framing for
coherence-affecting work must cite the section it serves and state its
impact — and this lane adds **new Lua API surface** and generalizes a
Journey-substrate mechanism, which is coherence-affecting on both
counts. Recording the impacts as neutral where they are neutral is part
of the requirement, not a way around it.
- **§16 semantic frontend — the section this serves.** The defect it
removes is a continuation resolving its target from *ambient* state a
tick after the request, which is precisely the multi-frontend
correctness §16 exists to protect. A capture makes "which frontend
asked" a value rather than a guess.
- **§14 workbench primitives — indirect, and the honest framing is
*enabling*.** This does not add a primitive. It removes the reason an
async adopter would hand-roll frontend tracking, which is the
mechanism by which primitives acquire per-consumer idiosyncrasies.
- **Journey steps touched: none directly, one PROTECTED.** The golden
journey does not gain a step. But Journey Stage 1a's Q#JR14 substrate
is what this generalizes, and §7 makes `tests/journey_acceptance.rs`
a preservation suite precisely so a generalization cannot erode the
step it came from.
- **Interaction islands (§6): none added.** No key interception, no
dispatch precedence rung. `dispatch_key` is untouched.
- **Config registry: no setting.** Where a continuation lands is a
correctness property, not a preference, and a toggle would offer to
turn correctness off.
- **Background-work attribution (§9): NEUTRAL, and worth stating
precisely rather than skipping.** This lane adds no background work
and no new unattributable surface. It also does **not** improve §9 —
knowing which frontend a result belongs to is not knowing who asked
for it or why. That is the worker-identity lane's arc, and the two
should not be confused because both concern async continuations.
- **§10 extension trust — a small positive.** The capture keeps the
Q#JR14d property that a destination is **nonconstructible from Lua**,
so generalizing the mechanism does not widen what extension code can
fabricate. §7 re-asserts the forged-destination refusal after the
rename for exactly this reason.
## 6. Open questions
### Q#DC-1 — what does the capture take as arguments?
*My vote: **no arguments** — capture the acting frontend and its
document window from the ambient state at call time.* That is what the
existing `capture_directory_destination(frontend, window)` is handed by
its one caller, and a Lua-supplied frontend id would reintroduce the
fabrication hole the userdata design closes.
### Q#DC-2 — one destination shape, or a panel/document distinction? **(the substantive one)**
§3 is the problem. Three candidates:
1. **One shape, all four checks.** Simplest; over-refuses the panel
case, and the refusal reason would be about a window the panel does
not touch.
2. **One shape, preflight parameterized by the continuation** — the
caller declares whether it is replacing the captured window's
buffer, and the stale-intent check applies only then.
3. **Two capture kinds**, document and panel, with different preflights.
*My vote: **(2)***, with the profiles spelled out below rather than
left to implementation.
**Revision 1 said only "skip the stale-buffer check for a non-replacing
continuation", and that was incomplete.** Review is right: a panel
result does not depend on the captured **document window** at all. It
does not replace that window's buffer, so check 3 is irrelevant; it
does not occupy that window, so check 4 (dedicated) is irrelevant; and
it does not need that specific window to exist, so check 2 is
irrelevant. Retaining any of the three can reject `git.status` for a
document-window change that has nothing to do with where the panel
goes. But dropping them **without an explicit profile** is how document
replacement quietly loses its guarantees.
**The matrix, stated so every omission is deliberate and testable:**
| # | Precondition (`window_panel.rs:488-525`) | Document replacement | Frontend/panel scope |
|---|---|---|---|
| 1 | Requesting frontend still has a layout | **required** | **required** |
| 2 | Destination window still live in it | **required** | not applicable |
| 3 | Window still shows the captured buffer (Q#JR14c stale intent) | **required** | not applicable |
| 4 | Window is not dedicated (Q#JR14f) | **required** | not applicable |
**Check 1 is the entire panel profile**, and that is the honest reading
of what a panel continuation actually depends on: the frontend it was
launched from still exists. Everything else in the capture is document
state the panel never touches.
**Consequence for the capture, which follows and should not be
discovered later:** if the panel profile needs only the frontend, then
a frontend with **no document window** can still host a panel — so
Q#DC-4's "return `nil`" is right for the document profile and possibly
wrong for the panel one. That interaction is settled as part of
answering this, not after it.
**I hold the *choice* loosely, not the matrix.** (1) has a real
argument — a uniform rule is easier to reason about, and over-refusal
is safe — but it would refuse the git panel for reasons unrelated to
it, and "safe" refusals that users cannot explain are how a mechanism
gets worked around. If review prefers (1) or (3), the matrix above is
what changes, and **every cell marked "not applicable" must still be
tested as deliberately omitted** (§7) so a future reader cannot mistake
an omission for an oversight.
### Q#DC-3 — what is the type called?
*My vote: **`ViewDestination`***, with `pmacs.window.capture_destination()`
as the Lua entry point. It names what it is — a place in a view where a
continuation's result belongs — without claiming a directory or a
buffer kind.
The Q#JR14 doc comments should keep their references intact; a rename
that orphans the rationale is worse than a slightly stale name.
### Q#DC-5 — the exact Lua call shape for the profile **(new in rev 4)**
Revisions 2 and 3 said "the profile is declared at `commit_to`" and
never said **how**. That is not a detail: today's binding accepts
exactly `(dest, body)` (`window_panel.rs:453-456`), so without a
specified form #227 has no stable API to adopt against, and the
promise that existing callers keep their semantics is a hope rather
than a contract.
**The signature:**
```lua
pmacs.window.commit_to(dest, body) -- document profile
pmacs.window.commit_to(dest, body, "panel") -- panel profile
```
- **`profile` is an OPTIONAL THIRD argument**, a string, typed
`Option<String>` at the binding. No arity sniffing, no
table-or-function dispatch on argument 2 — the existing binding
chose `Value` over `AnyUserData` specifically so its error message
would stay *reachable* and name the rule, and a polymorphic second
argument would undo that.
- **Trailing, and readable in practice.** A profile after a long inline
closure would read badly, but that is not the call shape in use:
dired defines `local function commit() … end` and calls
`commit_to(opts.dest, commit)` (`builtin/runtime/dired.lua:670,717`).
Against a named body, `commit_to(dest, commit, "panel")` reads fine.
- **The value set is CLOSED: `"document"` and `"panel"`.** Exactly the
two profiles in Q#DC-2's matrix. Not an open string namespace — a
third profile is a decision, not a spelling.
- **Omitted means `"document"`.** This is the load-bearing part: every
existing `commit_to(dest, fn)` call keeps **all four** preflight
checks, unchanged, by definition of the signature. `journey_acceptance`
passing untouched (§7) then follows from the API shape rather than
from care.
- **An unrecognized profile is an ERROR**, naming the accepted values —
**not** a silent fall back to `"document"`. A fallback would hand a
caller stricter or looser checks than it asked for, which is the
failure mode the whole parameterization exists to prevent. A
non-string profile errors the same way.
**Which profile each of git's continuations takes**, so #227's adoption
is decided here rather than rediscovered: `*git-status*`**panel**
(it lands in the bottom panel, `listview.lua:550`); `*git-diff*`
**document** (it replaces a document window deliberately,
`git.lua:852-854`).
### Q#DC-4 — what happens when there is no document window? **(DECIDED in rev 3)**
**Revision 2 left this contradicting Q#DC-2 and it is the primary panel
API, so it is decided here rather than voted on.** Q#DC-2 concluded a
panel profile depends only on a live frontend — so it can commit with
no document window at all — while this question still said the capture
returns `nil` in exactly that case, and told git to fall back to
ambient behaviour. Those cannot both hold, and the fallback advice was
independently wrong: falling back to ambient **is** the P1a bug this
lane exists to remove.
**The decision:**
- **`ViewDestination { frontend, window: Option<WindowId>, buffer:
Option<BufferId> }`.** The frontend is always present; the document
pair is optional and absent exactly when the frontend has no document
window.
- **`capture_destination()` is NOT profile-aware and takes no
arguments.** It records what is there. Making capture profile-aware
would force the caller to know at *capture* time what it will do at
*commit* time, which is the opposite of why capture exists — the
whole point is to freeze the truth early and decide later.
- **The profile is declared at `commit_to`**, which is where Q#DC-2's
parameterization already lives. One place makes the decision, and it
is the place that knows. **Its exact call shape is Q#DC-5**, which
revisions 2 and 3 left unspecified.
- **A document-profile commit on a destination with no document pair is
REFUSED**, with a reason naming that, joining the four preflight
refusals rather than being a separate failure mode.
- **Capture therefore never returns `nil`** while a frontend exists,
and the "adopter degrades to ambient" advice is **withdrawn**. An
adopter with nowhere to land gets a refusal it can report; it does
not get permission to guess.
**What this changes elsewhere, so the decision does not sit alone:**
§4's outline says the capture returns userdata "for the *current*
frontend and its document window" — it returns one for the current
frontend, **with** its document window when there is one. Q#DC-1's "no
arguments" answer is unchanged and now load-bearing rather than
incidental: no arguments is what keeps capture profile-blind.
## 7. Verification
- **A captured destination survives a frontend switch**: capture in A,
make B active, commit, and assert the result lands in **A**. This is
P1a's actual failure and the reason the lane exists — asserting only
that the API returns userdata would pass on a capture that does
nothing.
- **A fabricated destination is still refused** — the existing Q#JR14d
guarantee, re-asserted after the rename so the generalization cannot
quietly open the hole it was built to close.
- **Every preflight refusal is witnessed by its own case, in BOTH
profiles** (Q#DC-2's matrix): frontend gone, window gone, stale
buffer, dedicated window — each asserted to **refuse** under the
document profile, and each of the three marked "not applicable"
asserted to **NOT refuse** under the panel profile. A deliberately
omitted check that has no test is indistinguishable from a check
someone forgot, and the next reader will restore it.
- **A legacy two-argument `commit_to(dest, body)` gets the DOCUMENT
profile** (Q#DC-5), witnessed by a check the panel profile omits —
a stale-buffer refusal. Asserting merely that it does not error would
pass on a call silently downgraded to the panel profile, which is the
regression that would quietly void Journey Stage 1a's guarantees.
- **An unrecognized profile string is REFUSED**, with a message naming
the accepted values — not silently treated as `"document"`.
- **A non-string profile is refused** the same way.
- **Capture SUCCEEDS with no document window** (Q#DC-4), returning a
destination whose document pair is absent — asserted as a successful
capture, not as `nil`.
- **A panel-profile commit on that destination SUCCEEDS**, and a
**document-profile commit on it is REFUSED** with a reason naming the
missing document window. Both halves, because asserting only the
refusal would pass on a capture that refuses everything.
- **The directory path is unchanged** — dired's existing acceptance
coverage passes untouched.
- **`tests/journey_acceptance.rs` passes UNCHANGED**, as a named
preservation suite. It carries the established contract this lane
generalizes — 27 `commit_to` references across nine named pins
including `commit_to_refuses_a_forged_destination`,
`commit_to_scopes_and_restores_on_a_normal_return`,
`commit_to_restores_when_the_callback_raises`,
`commit_to_refuses_an_await_and_restores`,
`commit_to_delivers_to_the_requesting_frontend_not_the_ambient_one`,
`a_declining_listener_cannot_redirect_the_destination`, and two
rows already named `preservation_*`. Journey Stage 1a's own framing
treats this suite as a required gate; a lane that generalizes its
substrate does not get to relax that.
- **STOP SIGNAL, for both suites.** If any existing `dired` or
`journey_acceptance` test needs editing, the generalization changed
Journey Stage 1a's semantics. That is cause to stop and report, not
to adjust the test — a suite edited to accommodate the change under
test has stopped being evidence.
- **`Handle:await` still refuses inside the scope**, including through
`pmacs.async.yield_to_next_tick` if the worker-identity lane's Q#W-7
has landed by then; if it has not, this lane does **not** add that
guard — it belongs to that lane and duplicating it would produce a
conflict for no benefit.
**What this will NOT prove:** that git surfaces in the right frontend —
that is #227's adoption, after this lands. This lane ships the
mechanism and one set of tests for the mechanism.
## 8. Not in scope
**Adopting the capture anywhere**, including git (#227 does that) and
including migrating other async continuations that have the same latent
bug — worth an audit, not this lane's work. Changing Journey Stage 1a's
directory semantics. The `commit_to` scope guard for
`yield_to_next_tick` (worker identity Q#W-7). Any protocol change —
this is entirely core + Lua bindings. Panel geometry or placement
policy, which is the bottom-panel arc's.