18 KiB
A destination capture any async continuation can use
Status: framing pass, revision 3. Pre-implementation. Awaiting approval.
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_totakes aDirectoryDestinationLua, 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-directorylistener dispatch (src/editor.rs:1311), fromcapture_directory_destination, which ispub(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:awaitrefuses 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 throughlistview.open, which resolvesdisplaywith a"panel"default (builtin/runtime/listview.lua:550). It lands in the bottom panel, not in a document window.*git-diff*callspmacs.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.
DirectoryDestinationbecomes something that does not lie about a git panel;capture_directory_destinationand 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.rsa 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_keyis 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:
- 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.
- 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.
- 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-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. - 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
nilwhile 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.
- 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.rspasses UNCHANGED, as a named preservation suite. It carries the established contract this lane generalizes — 27commit_toreferences across nine named pins includingcommit_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 namedpreservation_*. 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
diredorjourney_acceptancetest 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:awaitstill refuses inside the scope, including throughpmacs.async.yield_to_next_tickif 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.