Cross-file writes

Status: built (XF.0–XF.6, 2026-08-26), and spent — org's archive, refile and capture all ship on it.

The effect was necessary and not sufficient, in two ways this document did not anticipate. A guest cannot name a file beside its own without knowing which file it is in (document.path(), OM.6b.0). And a guest that picks the target FIRST and reads the text second could not be called back into: a picker's invoke-command went through the : line, which cannot reach an action and hands an ex-command no document (OM.11.0). Both live in org-mode.md's slice plan.

Where this document and the code disagree, the code and the slice plan are what happened. Two things the build changed and the slice plan records in full: the effect applies inline rather than through ApplyEdit's deferral (next_actions is walked unconditionally, so a deferred cut would run whether or not the insert landed — the exact failure §5 forbids), and FileAnchor::resolve_line answers in lines while turning a line into a position stays host-side, because only the host has the buffer and the append position is not Position::new(line_count, 0).

Slice plan: ../operations/slice-plans/archive/cross-file-writes.md. Extends plugin-host.md (the effect vocabulary, the capability model). Unblocks org-mode.md OM.6b and OM.11.

1. What is blocked

Three org commands — org-archive-subtree, org-refile, org-capture — do the same thing: take text from here and put it in a different file. All three are ⛔ today, and confirmed rather than assumed: no effect in the WIT surface writes to a file other than the buffer's own.

  • edits and apply-edit both target a buffer. apply-edit-payload.target is a u32 BufferId, and a guest cannot learn the id of a file the editor has never opened.
  • open-buffer-at changes what is focused. It does not compose with a follow-on edit in any defined order, and nothing says the next effect in the list applies to the buffer the previous one opened.
  • host-services has walk, which reads. There is no write peer, and adding one there would be the wrong shape — see §7.

This is not plugin work. It is a missing host primitive, and it is one primitive rather than three: archive, refile and capture differ only in where the text comes from and which file it lands in.

2. The thesis

A plugin can move text into a file it has not opened, through the editor's own document pipeline, under a capability it declared.

Three words in that sentence are load-bearing.

"Through the pipeline" and not to disk. If the target file is already open in a buffer — quite likely, since org users keep their files open — a direct write makes the buffer and the disk disagree with nobody told. Routing through the document actor means the open buffer sees the edit, u undoes it, the LSP is notified, and the syntax worker reparses. All of that already works for a peer buffer (Editor::apply_targeted_edit handles a non-active target today); what is missing is only path → buffer.

"Has not opened" is the whole difficulty. The guest has no handle on the target, so it cannot compute a byte range in it. §4 is about that.

"A capability it declared"fs:write:<prefix>, the vocabulary that already exists, checked at a place that is new. §6.

3. The effect

/// Where in the target file the text lands. A *position*, not a range,
/// because the guest has never read this file — see §4.
variant file-anchor {
    /// After the last line. The common case: archive, refile and capture
    /// all append by default.
    end,
    /// Before the first line.
    start,
    /// Before this 0-based line. Out of range clamps to `end`.
    line(u32),
}

record write-to-file-payload {
    /// Absolute, or relative to the editor's working directory. Must lie
    /// within one of the plugin's `fs:write` prefixes (§6).
    path: string,
    anchor: file-anchor,
    /// The text to insert. A trailing newline is the guest's business; the
    /// host inserts exactly these bytes.
    text: string,
    /// When present, this range is removed from the buffer the action ran
    /// in — and ONLY after the insert has landed (§5).
    cut: option<range>,
    /// Create missing parent directories rather than refusing. False is the
    /// rule; a producer that owns the layout opts out of it — see §8.1.
    create-parents: bool,
}

/// Move text into another file. One effect, not two — see §5.
write-to-file(write-to-file-payload),

Native peer: Effect::WriteToFile { path, anchor, text, cut, create_parents } in lattice-grammar, so a native mode can use it too. Nothing about this is org-specific and nothing about it is WASM-specific.

4. An anchor, not a range, and the asymmetry is load-bearing

apply-edit carries an edit with a byte range. This carries an anchor. The two shapes differ, and that reads like a smell until you ask what the guest knows in each case.

For its own buffer the guest holds borrow<document>: it can read lines, count them, and compute a range that means something. For another file it holds nothing — it has never seen the bytes, so a range it invented would be a guess. end / start / line(n) are the three positions a guest can name without reading, and they are exactly the three the blocked commands need.

The asymmetry is therefore the answer to "how do you address a position in a file you cannot read", not an inconsistency to be tidied away.

It also bounds the blast radius. An insert-only primitive cannot silently destroy content in a file the user was not looking at. A range-carrying one could, on an off-by-one, and the user would find out later.

Rejected: hand the guest a read handle for the target first. A borrow<document> minted for a path would let it compute a real range. That is two new mechanisms (opening a document on a guest's behalf, and a handle whose lifetime spans a call it did not initiate) to serve three commands that only ever append — the same "most general answer, most machinery" trade org-mode.md §6.3 already declined once for the agenda. If a plugin ever genuinely needs to replace in another file, that is when to build it.

5. One effect, not two, because the failure mode is unrepresentable

Archive is "delete the subtree here, insert it there". As two effects that is two ways to corrupt a document:

  • insert succeeds, delete fails → the subtree exists twice,
  • delete succeeds, insert fails → the subtree is gone.

The second is data loss from a keystroke, which is the failure this design exists to avoid rather than to handle gracefully.

Nothing in the current contract helps, and the reason is stronger than "there is no abort-on-failure": an effect cannot report failure at all. apply_effect_host returns (), and Effect::Many(parts) walks its parts unconditionally. So a host that wanted to stop after a failed insert has nothing to stop on — giving two ordered effects the semantics they would need means changing the signature of every effect in the vocabulary, to serve one command family.

cut folds the pair into one effect the host applies as a unit: insert first, then cut, and only if the insert landed. A failed insert leaves the source buffer untouched — the user sees an error and still has their text. The reverse ordering was considered and is worse: cutting first means a failed insert has already destroyed the original.

This is not a transaction. The two edits land in different buffers and each is separately undoable, so u in the source buffer reverses the cut and u in the target reverses the insert. Making it one undo step would mean a cross-buffer undo group, which the undo model does not have and should not grow for this.

6. The capability gate, and where it lives

fs:write:<prefix>. No new capability vocabulary: a plugin that may write under ~/org may write under ~/org, whether it does so through WASI or through this effect.

The check runs at the boundary, not at the applier, and that is the structural decision in this fragment.

An Effect is a guest return value. By the time it reaches Editor::handle_effect the dispatcher has no idea which plugin produced it — effects from a plugin and from a native mode are the same type, deliberately. So the gate cannot live there without the effect carrying a plugin id, and an id inside guest-returned data is guest-controlled input, which is exactly what provenance_ids_are_host_issued_unique_and_stamp_the_plugin_layer exists to forbid.

At the boundary the provenance is still known: the conversion runs against a Store<PluginState> that carries the plugin's CapabilityGrant. So the grammar seam's effect conversion checks the path against the grant and, on denial, replaces the effect with an Echo naming the refusal. An effect that reaches the dispatcher has already been authorised, and the dispatcher stays generic.

The check itself is host_services::grant_permits_walk's twin, and should share its shape: canonicalize both sides so a .. cannot escape, and fall back to the raw path when canonicalization fails — which still requires a literal prefix match, so it can only ever deny more, never widen. A target that does not exist yet (capture's first run) will not canonicalize, and that must not be a denial; canonicalize the nearest ancestor that does exist and re-attach the unresolved tail.

Nearest ancestor, not simply the parent — the difference is a real defect this fragment described its way into. Stopping at the immediate parent covers "the file is new in a directory that exists" and silently fails "the file is new in a directory that is new too", which is every first write into a subdirectory a plugin owns (§8.1). There the parent does not resolve either, the raw path is used, and it is compared against a canonicalized prefix — so wherever the grant sits behind a symlink the match fails. On macOS that is the common case rather than the exotic one: /tmp and /var/folders are both symlinks into /private, so a grant over a temporary directory never matched a path the check had given up on. The denial then reads exactly like a capability the user never granted, which is the worst way for it to fail.

Re-attaching a tail is still fail-safe: it can only produce a path at or below a directory that really exists, and .. inside the tail is normalised away rather than followed, so a tail cannot climb back out of the ancestor it was just resolved against.

info!, not debug!, on a denial: it is one-shot and user-actionable ("org tried to write outside its granted paths"), which is the level rule's own example.

This shape generalises. AppEffect::OpenProviderView's WIT surface is withheld today pending "the capability model for which providers a plugin may trigger" (boundary_app_effect.rs). The answer to where that check goes is the same as this one; only the policy differs. Landing this first is what makes that a policy question rather than an architecture question.

7. What happens to the target buffer

It is opened, edited, and left modified. Not saved.

If the file is already open, that buffer is reused — Editor::find_document_by_path already answers this, and getting it wrong would silently clobber the user's unsaved work. If it is not open, the host opens it as an ordinary listed document buffer in the background: it appears in :ls, :w saves it, :bd closes it, and the active pane does not move.

That last part matters. A plugin's write must not steal focus — the user pressed <leader>o$ to archive a subtree, not to navigate somewhere.

"An ordinary listed document buffer" has to be literally true, and for a while it was not. resolve_path_to_buffer_creating spawned the document, registered it and stopped — no Lang::detect_from_path, no syntax handle. So the buffer it produced was ordinary in :ls and nowhere else: no highlighting, and — because the major is resolved from the language — text-mode instead of the language's own major.

The damage stuck rather than being repaired on next open, and that is the part worth remembering. do_edit finds the buffer by path and takes its "already open, switch to it" branch, so opening the file afterwards handed the user back the same defective buffer. It presented as org capture committing a note into a file that then had no org-mode and no colour, however you opened it.

The buffer now gets its syntax through build_open_syntax, the same chooser do_edit uses, and not through install_inmemory_syntax. Two reasons, both load-bearing:

  • build_open_syntax reads the live language registry. install_inmemory_syntax reads self.lang_registry, a boot snapshot, and a plugin language RCUs its grammar in after boot — so for org, the very language this was reported against, the snapshot answers None. That fix would have worked for every native language and for none of the plugin ones.
  • It picks a synchronous or deferred parse by SYNC_PARSE_MAX_BYTES. install_inmemory_syntax always parses inline, and a capture target is exactly the file that grows: filing one note into a large org file must not pay the freeze that threshold exists to prevent (paramount #1).

Not saved by default, and this is convention-following rather than laziness: emacs's org-refile and org-archive-subtree both leave the target buffer modified, with saving behind a separate option. The user reviews and writes. A plugin that silently writes files is a different and much larger authority than one that edits buffers.

7.1 save: bool — the reversal, and what it cost

This section previously read "Rejected: a save: bool in the payload. It costs nothing to add and it quietly moves the 'did a plugin touch my disk' line. Leaving it out means the answer is uniformly no, which is an easier thing for a user to know."

OC.9 reversed that, and the paragraph is kept above rather than deleted because the argument in it is still correct — for the producers that existed when it was written. What it missed is a producer whose entire contract is durability.

org-capture is that producer, and emacs is unambiguous about it. org-capture-finalize runs (unless (org-capture-get :no-save) (save-buffer)) — saving is the DEFAULT there, and :no-save exists as the opt-out. The asymmetry with org-refile is not an inconsistency in emacs: a refile moves text you are looking at and can review, a capture files text you are finished with and have already dismissed the buffer for. So the convention evidence that justified "not saved" for refile and archive points the other way for capture, and reading it as one uniform rule was the error.

There is also a consequence the original rejection could not have weighed, because the agenda did not exist yet: anything that reads the FILE cannot see an unsaved write. The org agenda scan reads through host-services.read-file — from disk — so a captured TODO was invisible to a refresh no matter how correct the target buffer was. "The user can press :w" is not an answer when the user never sees the buffer.

What the flag actually costs, stated plainly rather than smuggled:

  • The uniform answer is gone. "Did a plugin touch my disk" is now per-producer rather than a flat no. That is a real loss and it is the thing the original paragraph was protecting.
  • It is bounded by the same grant. save reaches disk only where path's fs:write check already let the guest create or overwrite the file, so it widens when the write becomes durable, never what is reachable.
  • It cannot persist a failure. The save runs last — after the insert and after any cut — and the failed-insert branch returns before reaching it, so a write that did not land saves nothing.
  • The default did not move. Every producer that does not ask keeps §7's behaviour exactly. false remains the rule; refile and archive still pass it.

Rejected instead: a separate Effect::SaveFile { path }. It looks more composable and is a strictly larger authority — "save any granted file at any time" rather than "persist the file I just wrote" — and as a second effect it would run whether or not the write it follows had landed, which is the coupling this vocabulary spends cut to avoid. Durability is a property of the write, so it belongs on the write.

Rejected: saving unconditionally in the applier. No ABI change, but it converts refile and archive to disk-writers too, against both emacs convention and the whole of §7.

Rejected: host-services.write-file. A host-services import would be a direct disk write and would bypass everything §2 argues for — the open buffer would not see it, undo would not cover it, the LSP would not hear about it. It is also the wrong direction: host-services is what the guest asks the host for mid-call; an edit is something the guest returns, so it belongs in the effect vocabulary with every other mutation.

8. Failure behaviour

Every path degrades to "the user's text is still where it was".

  • Path outside the grant → the effect is replaced with an Echo at the boundary and never reaches the dispatcher. Logged at info!.
  • Target unreadable / not UTF-8 / a directory → echo, no edit, cut does not run.
  • Insert failscut does not run. The source buffer is untouched.
  • Target file does not exist → created, if its parent directory exists and is within the grant. Capture's first run is exactly this. A missing parent is an echo rather than a mkdir -p unless the producer asked — see §8.1.
  • cut range out of bounds → the insert has already landed and the cut is skipped with a warn!. Duplicated text is recoverable by hand; lost text is not, so this asymmetry is deliberate.

8.0 A write that does not land stops the rest of its action (CD.3c)

Every refusal above — outside the grant, unresolvable, insert failed — also stops the effects after the write in the same action, both at the boundary (the authorizer drops the rest of the Many) and in the applier (apply_effect_host returns early). The effects before it stand.

The rule "degrades to the user's text is still where it was" was only half true without this. A producer that files and then moves on — capture's [write, close the buffer] — closed its buffer over a failed write, and the text was in neither place. That happened live whenever a capture target was outside the grant. emacs has the same shape and the same answer: org-capture-finalize calls save-buffer, and an error there unwinds the rest of finalize, so the capture buffer is still on screen.

The boundary used to do the opposite, deliberately: "one denied write must not silently cancel the other things an action did". That is right for independent effects and wrong for a commit, and a producer whose effects really are independent does not put a write first.

A failed save does not stop the batch. With save: true the text has already landed in the target buffer when the save fails, and the dirty-buffer guard protects it. Stopping would leave the producer's state saying "not filed" about text that is filed, and a retry would file it twice.

8.1 create_parents, and why it is opt-in rather than the default

Creating directories is a larger authority than creating a file, and a typo'd path must not silently build a tree. That is the rule, and it stays the default.

It is the wrong answer for one case, though, and the case is not marginal: a producer writing into a directory that is part of the layout it owns. org-roam's daily/YYYY-MM-DD.org is the example that forced the field. The folder is named by org.roam-dailies-directory, an option with a default; no user ever types it; and refusing means the very first :org-roam-dailies-today on a fresh corpus fails — the one use where the feature has to work. Told to mkdir daily first, a user is being asked to do the plugin's filing for it.

So the producer declares intent and the default is unchanged for everyone else. Archive writes beside its source, refile targets a headline the picker already walked, and capture targets a path the user typed into a template — that last one is precisely the typo case, and it stays false.

The grant still bounds it. A plugin's path is checked against its fs:write prefixes at the boundary (§6) before this field is read, so asking widens what is created inside the grant and never what is reachable outside it.

Rejected: the authorizer does the mkdir for any permitted path. It needs no ABI change, and the boundary is the one place that still knows whose effect this is — a real argument. But it makes the authority implicit: every plugin with an fs:write grant would silently gain directory-creation over its whole subtree, including for a path the user typed. It also gives authorize a side effect, so a draft the user abandons still leaves a directory behind. The producer asking is the narrower and more legible rule.

Rejected: refuse, and tell the user to create the directory. Zero cost, and it loses on the higher court — emacs simply creates the journal folder, and a feature whose first use is an error message is a feature people conclude is broken.

9. Performance

Off the keystroke path in every sense that matters, but not free: opening a file reads it, and the read is synchronous with the effect.

The bound is the same one :e lives with, and the file is one the user named through a command they invoked. It does not run per frame, per keystroke, or per tick. The one thing to hold: the read must not land on the editor actor's current_thread runtime as a blocking call inside a hot dispatch — see the slice plan for where it goes.

No new per-keystroke cost, so no new bench gate. The existing grammar round-trip ratchet still covers the guest call that returns the effect.

10. Scope

In: the write-to-file effect, its native peer, the boundary capability gate, background open-or-reuse, insert-then-cut ordering, and the failure behaviour above.

Out, as cuts rather than omissions: saving the target (§7), creating parent directories (§8), replacing a range in another file (§4), and cross-buffer undo grouping (§5).

Not this fragment's problem: which providers a plugin may trigger (OpenProviderView). This lands the enforcement shape that question will reuse; the policy is separate.

11. Paramount-goal alignment

#1 Performance. Nothing on the keystroke path changes. The file read is bounded, user-initiated, and off the actor thread.

#2 Extensibility. This is the goal the fragment serves. Three org commands unblock, and so does every future plugin that needs to write beside itself — a note-taker filing into a journal, a codegen helper writing a sibling module, a test scaffolder. None of them need a host change, and the host still learns nothing about org.

#3 Vim modal editing. Untouched: the effect is returned by an action that was reached through the ordinary grammar. u works in both buffers.

#4 Asynchronicity. The read is off the actor thread; the edit goes through the document actor like every other edit.