Org-mode as a plugin

Where the code is. Everything this page describes is implemented in lattice-org-plugin, a separate repository. It is a WASM Component plugin: nothing here is compiled into the editor, and lattice has no BufferKind::Org, no Lang::Org arm and no Editor:: method for any of it. What lives in this tree is the seams the plugin contributes through — see plugin-host.md.

Status: built (2026-08-26). Archive, refile and capture — the three verbs blocked longest — closed last, on three primitives the epic turned out to need rather than one: Effect::WriteToFile (cross-file-writes.md) to carry the text, document.path() to name a file beside your own, and a picker source able to invoke an ACTION rather than an ex-line, since an ex-command receives no document handle and refile has to read the subtree at the cursor after choosing where it goes. Sequencing, per-slice outcomes and the amendments this document records: ../operations/slice-plans/archive/org-mode.md. Ledger entry: ../operations/implementation.md §"Org-mode as a plugin".

Builds on plugin-languages.md (the language seam, whose first consumer is the same plugin) and plugin-host.md (grammar, modes, config, picker-source). The agenda's host half is a multibuffer provider — multibuffer-views.md §3.7 for the provider shape and §3.7a for the provider-view seam the agenda triggers through.

Two sections carry amendments made during the build, each marked in place rather than rewritten over: §4.2 (the view carries two minors, not one) and §6.2 (extensions(), and group as a key rather than a label). Where this document and the code disagree, the code and the slice plan are what happened.

1. What was already true

Written before the build; kept because it is the starting position the rest of this document argues from.

lattice-org-plugin already contributed org the language: a tree-sitter grammar compiled to wasm, per-level headline highlights, folds over sections and blocks and drawers, and its own :help page. It rode the language and help seams and needed nothing from the host.

Visibility cycling is native and predates the plugin: z<Space> cycles a heading FOLDED → CHILDREN → SUBTREE and z<Tab> cycles the buffer OVERVIEW → CONTENTS → SHOW-ALL (AppEffect::CycleFoldAtCursor / CycleFoldsGlobal, whose doc comments name org). Org folds through the ordinary fold pipeline, so za / zR / zM work with no org-specific code anywhere.

What was missing was editing: promotion, subtree motion, TODO workflow, tables, agenda. That is org-mode the mode, and this fragment is its design.

All of it now ships except the two slices that need to write to a file other than the buffer's own (§10). The plugin rides seven seams; the host gained four generic changes and learned nothing about headlines.

2. The thesis

Org-mode is a plugin. Not "mostly a plugin with a few host hooks" — a plugin. The host learns nothing about headlines.

That is the claim under test. It held — asserted at OM.2, again at OM.A3, and greppable: no BufferKind::Org, no Lang::Org, no Editor::do_org_*, no Action::Org*. Concretely it means: no BufferKind::Org, no Lang::Org, no Editor::do_org_*, no Action::OrgPromote, no org branch in a renderer. The acid test from CLAUDE.md applies verbatim — a provider landing should require zero Editor:: method additions and zero new variants in the host's Action enum — and org is the hardest case yet to put it to, because org wants more of an editor than any plugin before it.

Two things make the claim plausible rather than aspirational.

The grammar seam already carries the right context. apply-action receives borrow<document> and option<borrow<tree-snapshot>> — the same buffer's point-in-time parse tree, acquired the same instant so their versions agree. The org plugin ships the grammar, so the tree it walks is a tree it defined. Promote-a-subtree is: read the tree, compute an edit, return Effect::Edits. The host mediates nothing.

Effect::Declined makes context-sensitive chords composable. A guest action that returns [declined] did not consume the chord; the dispatcher re-resolves as if that action's keymap layer were not there. This is what lets several modes bind the same key and let context sort it out (§4.3), which is in turn what makes the mode decomposition of §4 real rather than cosmetic.

3. What has to change host-side

Three things, all of them finishing a path the codebase already designates.

3.1 Majors over the modes seam

wit/modes.wit declares mode-kind::major and the host rejects it:

"register-mode skipped: only minor modes are supported in PH7.11a
 (majors are Phase 8)"                       — mode_host.rs:148

Org needs a major. A minor cannot serve: ActivationPolicy offers manual / global / universal / majors(list), and none of those means "buffers whose language is org". Universal would fire org's chords in every buffer in the editor.

3.2 A language index on ModeRegistry

Even with major accepted, nothing would activate it. resolve_major_mode (lattice-host/src/modes.rs:419) resolves a Document buffer through lattice_syntax::major_mode_id_for_lang, and that function reads:

// A plugin language's major mode is contributed through the
// `modes` seam by the plugin that owns it — the mode owns its
// full surface, so the host does not synthesise one here.
Lang::Plugin(_) => None,

The route is designated and closed. Opening a .org file today lands in text-mode.

The fix mirrors machinery that already exists rather than inventing any. ModeRegistry indexes majors by buffer kind at register-time, and Mode::target_buffer_kind's doc comment already promises the property we want:

Adding a new kind-bound major requires zero host-side hand edits — register the mode and the index picks it up.

So: a language index beside the kind index. Mode::target_language() -> Option<String> beside target_buffer_kind, find_major_for_lang beside find_major_for_kind, populated the same way at the same time. resolve_major_mode consults it before falling through to text-mode.

This is deliberately not org-shaped. It is the general answer to "a plugin contributed a language; which major owns it", and the native language majors (rust-mode, markdown-mode, …) can migrate onto it later, collapsing major_mode_id_for_lang's hand-written match. That migration is not in scope here — naming it as the eventual shape is, so the index is not built as a plugin-only side door.

3.3 mode-declaration.target-language

The WIT record gains one optional field. A major declaring no target language is manual-activation only, which keeps the field honest for majors bound to something other than a language later.

3.4 Drain order, which is a gate

mode-keymap-binding resolves command against the CommandRegistry at registration. Org binds <leader>ol to action:org-demote, which org itself registers through the grammar seam. So for a single plugin the loader must drain grammar before modes, or every org binding skips — logged, but silently as far as the user is concerned. This is checked first (slice OM.0) because everything downstream assumes it.

4. Mode decomposition

The plugin owns its functionality through four modes, each owning its full surface — keymap and handler bodies, per the standing rule. A mode that publishes data while the host binds its chords would be a half-migration and is the failure mode this decomposition exists to prevent.

ModeKindActivationOwns
org-modemajortarget-language = "org"headline motions, ih/ah/ir/ar text objects, promote/demote, subtree move, meta-return, toggle heading, archive, links, refile, capture, <Tab> on a headline
org-todo-modeminormajors = ["org-mode"]TODO keyword cycling, priority, tags, checkboxes + statistics cookies, timestamps
org-table-modeminormajors = ["org-mode"]TB.2: table behaviour that is genuinely org's#+TBLFM: formulas, sorting, export. Empty today; the generic surface is the host's table-mode
org-agenda-modeminormanual — the provider activates it on the view, named by the source's view-mode exportTODO change from the agenda

4.1 Why these four and not one, or ten

The test is not "is this feature self-contained" — most are. It is whether another major would want the behaviour, per the minor-mode-over-duplication rule.

  • Tables are the clearest yes. A markdown buffer wants the same <Tab>-aligns-and-advances editing.

    This section predicted the wrong resolution, and TB.2 settled it the other way. The prediction was that org-table-mode's activation policy would grow a major and the mode would be renamed — generalising by widening from here. What that missed is that markdown-mode is a native major, so a table mode owned by this plugin makes markdown table editing require the org plugin installed and enabled, absent otherwise with nothing to announce the gap. Only the host can serve both, and it already owned the engine: lattice-mode/src/modes/table/ has carried the parse-measure-pad core since HP.1, naming a table-mode as its next consumer, and measuring by display width where this plugin's copy counted chars.

    So the generic surface moved OUT rather than widening in place, and org-table-mode keeps what is genuinely org's. See table-mode.md §1 and §5.

  • TODO workflow groups the surface that operates on a headline's metadata rather than its structure — and checkboxes (- [ ]) exist in markdown too. Same argument, one step weaker.

  • Agenda is a different buffer with a different keymap; putting its chords on org-mode would fire them in ordinary org files.

  • Everything else stays on org-mode. Minting org-link-mode and org-timestamp-mode would be modes-per-feature, which is the same error as crates-per-feature.

4.2 The agenda view carries TWO minors, and the split is not arbitrary

The agenda view is a multibuffer. multibuffer-mode is its major (target_buffer_kind = Multibuffer), and a provider contributes a minor activated on the view — ProjectSearchMode is ModeKind::Minor and the search provider activates it with activate_minor_by_id (providers/search.rs:912).

Amended at OM.A3. This section originally gave gr refresh and jump-to-source to org-agenda-mode along with the TODO change. The plugin cannot have the first two, and the reason is structural rather than a matter of taste: refreshing the agenda means re-running the host's walk, which is AppEffect::OpenProviderView — and that effect's plugin surface is deliberately withheld (boundary_app_effect.rs) pending the capability model for which providers a plugin may trigger. A plugin gr could bind the chord and not do the work.

It is also the better split on merit. Refreshing a host-built view is host machinery, and the second agenda-source plugin — the markdown TODO scanner the whole extensions() design exists for — inherits gr rather than re-deriving it. Re-derivation is the copied-keymap failure the minor-mode rule forbids, one layer up.

So the view carries two minors and each owns its full surface:

  • agenda-view-mode (native, lattice-multibuffer, beside the provider — the ProjectSearchMode shape verbatim): gr through refreshable-view-mode's cascade, with the refresh body in the same crate. Jump-to-source comes free from MultibufferMode.
  • org-agenda-mode (the plugin's): the TODO chords and their handler bodies, on org's own rows.

Neither is a half-migration: each holds both the binding and the body of what it claims.

How the host activates a mode it cannot name. No ActivationPolicy can express "the buffer this provider just built" — majors(["multibuffer-mode"]) would fire org's chords in project-search results and magit diffs. So the agenda-source world gained one export, view-mode: func() -> option<string>: the source names a minor, and the provider activates it on the view. The host learns a mode id and never learns what its chords do. This is the ABI addition §"Why the agenda is last" reserved the right to make once, informed by what org turned out to need.

4.3 The decline chain

<Tab> is bound by two org modes and one builtin. Minor layers rank above major layers, and org-table-mode is active in every org buffer, so its binding is reached first:

"Active in every org buffer" has a precondition that is easy to read past and was in fact missed: majors = ["org-mode"] says where the minor may activate, and enablement says whether it may at all — auto_activatable_minors filters on enablement before policy. So the mode must also be in the plugin manifest's default_modes, or this chain never has a first hop and <Tab> in a table does nothing. It was absent there until 2026-08-30.

<Tab>  →  org-table-mode : in a table?    align + next cell
                            else          [declined]
       →  org-mode       : on a headline? cycle (AppEffect::CycleFoldAtCursor)
                            else          [declined]
       →  Builtin        : jump-list forward

Two hops. If Declined did not chain past more than one layer, the decomposition would collapse — one mode would have to own every <Tab> meaning, and org-table-mode would stop being separable. So the chain is a tested property, not an assumed one (OM.5). <C-a> / <C-x> decline the same way past org-todo-mode to the builtin increment.

The cost is honest and benched: every <Tab> in an org buffer costs a guest round-trip even when it does nothing. It is budgeted under the existing grammar gate (§8).

5. The keymap

5.1 Convention, and where lattice's dispatcher refuses it

The standing UX rule says lead with cross-editor convention, and the precedent is magit-keys-follow-evil-magit: follow the vim community's port, not the emacs original. The org analogue of evil-collection-magit is nvim-orgmode, and it is the baseline.

Several of its chords cannot be expressed here, and the reason is structural rather than incidental. KeymapTrie::lookup returns Bound the moment the walk lands on a node carrying a terminal binding (trie.rs:157). >, < and c are each a terminal Normal binding to an operator (keymap_normal.rs:909-920); vim's doubled forms are operator-pending bindings (keymap_normal.rs:1146-1154), not two-key paths in Normal. And binding-mode in the WIT deliberately excludes operator-pending — "internal grammar states, not plugin-bindable."

So >>, <<, >s, <s, cit and ciT would be dead bindings: > / < / c fire first, every time.

Three ways out were considered.

  • Shadow the operators — bind <, >, c as terminal actions at the org layer. Rejected: org buffers would lose the indent and change operators outright. No ciw, no >ap. That trades a paramount goal (#3, strict vim semantics) for muscle memory in one filetype.
  • Open operator-pending to plugins — lift the binding-mode exclusion. Rejected for now: it exposes an internal grammar state the WIT closes on purpose, and cit would additionally need a text object named t, which org has no claim to.
  • Move them into <leader>o, and add text objects — chosen.

5.2 The set

Reachable nvim-orgmode chords are kept verbatim:

]]  [[         next / prev headline
g{             parent headline            (native zp also works)
<Tab>          cycle subtree              (native z<Space> also works)
<S-Tab>        global cycle               (native z<Tab> also works)
<C-c><C-c>     toggle checkbox at point  (org's; the context dispatcher)
<C-c><C-x><C-b> set boxes in region/subtree to one state (org's)
<C-a> <C-x>    timestamp component up / down
<leader>oa     agenda          <leader>oc  capture
<leader>or     refile          <leader>oo  open link at point
<leader>oK oJ  move subtree up / down
<leader>o$     archive subtree <leader>o,  priority
<leader>o'     edit src block
<leader><CR>   meta-return

The unreachable ones move into the same prefix, using evil-org's directional letters so the mnemonic survives:

<leader>oh  ol   promote / demote headline      (nvim: << >>)
<leader>oH  oL   promote / demote subtree       (nvim: <s >s)
<leader>ot  oT   TODO cycle forward / back      (nvim: cit ciT)
<leader>o:       set tags                       (nvim: <leader>ot)

One deviation beyond necessity: nvim-orgmode's <leader>ot is tags. TODO cycling is the more frequent verb and t the stronger mnemonic for it, so tags move to <leader>o: — which reads as :tag:. Documented in :help org so a nvim-orgmode user is told rather than surprised.

<Tab>, <S-Tab>, <C-a> and <C-x> shadow native bindings inside org buffers only. That is not new: lattice-magit already binds <Tab> / <S-Tab> / ]] / [[ mode-locally.

5.3 Text objects, which are the better half of the trade

The chords that could not be transplanted have a more vim-idiomatic replacement than the <leader>o slots they landed in. Org registers text objects through grammar's register-text-object:

ih  ah    headline (inner / around)
ir  ar    subtree  (inner / around)

(Corrected during OM.4: this fragment first said is/as for subtree, but s is already vim's sentence object and org has no business shadowing it. nvim-orgmode itself uses ir/ar — following the convention we already chose fixes the collision rather than creating one.)

and the ordinary operators composedar deletes a subtree, yah yanks a headline, >as indents one, gcas comments one. No org-specific chord is involved in any of those. This is paramount goal #3 working as designed: the grammar is the public API, and a plugin extends the vocabulary rather than bolting a parallel command set beside it.

5.4 C-c C-c, and why its arms are not one function

Emacs' org-ctrl-c-ctrl-c is the key an org user presses when they want this thing here acted on. It reads the cursor's context and dispatches: a headline sets tags, a checkbox toggles, a table realigns, a #+ keyword line re-reads setup, a dynamic block updates, a source block evaluates. It is the most-pressed key in org and lattice does not have it.

Every individual verb it reaches is already here — <C-c><C-q> tags, <C-c><C-c> checkbox, <leader>t| align — so what is missing is the dispatch, not the work. Sequencing is in org-entry-editing.md.

The arms do not all belong to org, and that is the design. A guest cannot invoke a registered command: there is no Effect::Invoke, and the only action names a guest may hand the host (confirm.yes-action, open-prompt.on-submit-action) require a user interaction to reach. So an org-side dispatcher could not call action:table-align even though that action exists and org buffers already have it — org would have to re-implement table alignment, which is precisely what table-mode (TB.1, shared with markdown) exists to prevent.

The answer is the layer stack rather than a bigger seam. C-c C-c is a shared chord, so each mode that has something to do with it binds it and declines when it does not apply. table-mode binds it to align and returns Effect::Declined outside a table; org's major binds it to its own dispatcher, one layer below; org-capture-mode binds it to finalize, one layer above, and never declines. The dispatcher peels one layer per decline and preserves the prefix, so the chain composes — the case its own comment cites is table-mode's <Tab> falling through to org's fold cycle, which is this shape exactly.

This keeps every arm's body with the mode that owns the buffer it acts on, which is the standing rule, and it means adding an arm later (a future babel block, a clocktable) is a binding on the mode that owns that, not an edit to a growing match in org.

The fallback is a message, not silence. A context with no arm echoes what it saw — emacs answers C-c C-c can do nothing useful at this location, and the alternative here is the failure this codebase keeps paying for: a key that does nothing, indistinguishable from a missing binding.

What has no arm, and why, so the list is a record rather than an omission: #+ keyword lines (org's setup comes from lattice's typed config, not from in-file keywords — there is nothing to re-read), dynamic blocks (no clocktable writer; the agenda's clock report is a virtual row, OA.16), source blocks (no babel), footnotes (no footnote support). Each becomes an arm when its feature lands, on the mode that lands it.

5.5 Properties are a drawer, and writing one is not set_property

:PROPERTIES: is read in three places already — the agenda's Row carries a headline's drawer, complete.rs reads :KEY: for repeaters, roam reads :ID: — and written in exactly one: roam_index's id_drawer_insert, which finds or creates a drawer to put an :ID: in.

org-set-property (emacs' C-c C-x p) is that writer generalised, with two differences that are not cosmetic. A key that is already present must be replaced, where the :ID: case refuses (a second :ID: is a file org cannot read; a second :CATEGORY: is just a stale line). And complete.rs's private set_property deliberately does not create a drawer — right for LAST_REPEAT, which should not manufacture three lines on a plain repeating task, and wrong for a user who typed the command.

Where the drawer goes was an open question, and the answer was a defect (OE.0, resolved 2026-09-04). tree-sitter-org's section rule is headline, [plan], [property_drawer], [body], subsection* — a SEQ, so the plan comes first. id_drawer_insert inserted at headline_line + 1 unconditionally, which on a headline carrying SCHEDULED: put the drawer above the planning line.

Parsed, that is not merely non-canonical. The plan field disappears; SCHEDULED: <2026-09-04 Fri> becomes a paragraph inside body, with its timestamp not even tokenised as one. agenda.rs reads the date from section.child_by_field("plan") — so an :ID: minted on a scheduled TODO moved it out of the dated agenda into the undated block, and the file still read correctly to a human, which is why nothing caught it. Both callers now take the insertion point from one helper, roam_index::drawer_line_for.

A related limitation surfaced while confirming it, worth knowing and not worth fixing here: a plan is one line to this grammar. SCHEDULED: on one line and DEADLINE: on the next parses only the first as plan; the second is body prose, so its date is invisible to the agenda. Org itself accepts both spellings. That is the pinned grammar's behaviour, not lattice's, and it moves when the grammar does.

5.6 Structure editing, and the two seams it needed

Everything above acts on a headline. Promote, demote, move a subtree, insert a sibling, archive, refile — the outline half of org is here and has been since OM.3.

The other half is not. A plain list item is invisible to this plugin. checkbox::parse_item requires a literal [ ], and strip_bullet is private to it, so - milk is a line of prose to every action org registers. There is no insert-item, no indent-item, no move-item, no bullet cycling, no ordered-list renumbering. org-meta-return is headline-only, where emacs' M-RET has always dispatched on what is under the cursor. And the whole insert family beyond a bare sibling — emacs' M-S-RET, its subheading and TODO variants — is absent.

That is the gap. Filling it turned out to be less about org than about two things the editor could not express, which is why this section is mostly about them.

5.6.1 The chord vocabulary is a portability question, not a taste one

Org's structure UI is built on a two-bit modifier space: Meta means "structural", and Shift means "the bigger version" — with the subtree, or the TODO variant. M-RET / M-S-RET, M-<arrows> / M-S-<arrows>. It is the most transferable muscle memory org has, and §5.1's rule says lead with convention on a user-facing surface.

lattice-protocol's chord types express every one of them — KeyMods carries ALT, and SpecialKey::Enter takes modifiers like any other special (chord.rs:83-101). GPUI's adapter passes all of it through untouched (gpui_chord.rs:134). So the protocol was never the constraint.

The TUI was. runtime.rs enables raw mode, the alternate screen, bracketed paste and mouse capture, and never pushes KeyboardEnhancementFlags. Without the kitty keyboard protocol a terminal has no way to say "Shift and Enter" — it sends a bare \r, the same byte Enter alone sends. The split is sharp and worth recording, because it is not the one intuition predicts:

FamilyWire formTUI todayTUI + H1GPUI
<M-Up/Down/Left/Right>CSI 1;3 X
<M-S-Up/Down/Left/Right>CSI 1;4 X
<M-CR>ESC prefix✅ where Option=Meta
<S-CR> <C-CR> <M-S-CR>✗ bare \r
<C-t> <C-d>C0

The arrow half transplants verbatim. Modified arrows are ordinary CSI sequences every terminal has sent since xterm, so the indent/move half of emacs' vocabulary needed nothing. Only the Enter-with-Shift half is unreachable, and only that half motivates H1: push DISAMBIGUATE_ESCAPE_CODES behind crossterm's supports_keyboard_enhancement() probe, with a ui.keyboard_enhancement option to force it off and a pop on teardown. (Underscore within the namespace, matching ui.nerd_fonts; a bool plus the probe is the auto-or-off tri-state, so it needs no enum.)

Two guards on H1, both because the failure mode is a terminal left in a state the user cannot type out of. The probe is not trusted blindly — the option exists so a terminal that lies about support is recoverable without editing source. And the pop must survive a panic, not only a clean exit, on the same reasoning that put the alternate-screen restore where it is.

Nothing is reachable only through a modifier. Every chord below is a second spelling of an ActionId that also has a <leader>o… form. A user on Terminal.app loses keystrokes, never verbs — which is what makes H1 an improvement rather than a dependency.

5.6.2 Insert, Normal and Visual, and what decides which

<leader> cannot be typed in Insert mode, and the majority of these verbs are ones you want while composing — you are three words into a list item when you discover it should be nested. So the binding mode is part of the design here in a way it has not been for any previous org slice.

The line is what the verb needs from you:

  • Insert — verbs invoked while composing. Create the next thing; fix the level of the thing you are typing. You are mid-line and cannot leave.
  • Normal — verbs that restructure what already exists. You navigate to a thing and act on it: promote, move, change kind, cycle bullet.
  • Visual — the Normal verbs, applied to every item in the region.
INSERT   <M-CR> <M-S-CR>              create
         <C-t> <C-d>                  indent / outdent this item

NORMAL   <M-Left> <M-Right>           promote/demote  |  outdent/indent item
         <M-S-Left> <M-S-Right>       ...with subtree / sub-items
         <M-Up> <M-Down>              move past a sibling
         <leader>oi                   insert a subheading (a child, not a sibling)
         <leader>o-   <C-c>-          cycle bullet type
         <leader>o_                   toggle line <-> list item
         <leader>o*   <C-c>*          toggle line <-> headline
         <leader>oh ol oH oL oK oJ    (existing, kept)

VISUAL   the Normal verbs, over every item in the region

<leader>oi is the one new letter, and it is the letter OA.27 left deliberately free — that slice moved the clock family under <leader>ox… and recorded that i, "the natural prefix for inserting things", was being left open rather than filled to justify the reorganisation. This is the insert group it was left open for. Subheading has no modifier chord because emacs gives it none either: it is a command you invoke, not a gesture.

Insert is deliberately the smallest of the three. The restructuring verbs are all reachable one <Esc> away, and an Insert-mode keymap that grows to mirror Normal is how a filetype stops feeling like the editor it is in.

<C-t> / <C-d> are the Insert spelling of indent/outdent, not <M-Left> / <M-Right>. Those two are vim's own Insert-mode indent pair (keymap_entry.rs:473-474), so an org buffer teaching them to understand a list bullet is the smaller surprise — and it is a genuine improvement over the builtin, which shifts by shiftwidth and knows nothing about renumbering or sub-items. Which is also exactly why they must Effect::Declined off a list item: they are shared chords with a real meaning underneath, the same argument that made <C-a> / <C-x> the only declining actions in this plugin (§OM.9). <M-Left> and its peers have nothing underneath and so consume.

<C-c>- and <C-c>* are safe for the reason every <C-c> chord here is safe: <C-c> is a prefix, never a terminal binding. <C-c><C-c> is the one exception and must stay the only one — a terminal node kills every longer chord grown beneath it, because KeymapTrie::lookup answers Bound at the first binding and never consults children.

5.6.3 The list model, and why Checkboxes is rebuilt on it

list.rs is a Lists navigator shaped exactly like headline.rs's Headlines and checkbox.rs's Checkboxes: tree-first over the grammar's list / listitem / bullet nodes, indent-based fallback when the buffer has no parse.

Bullet   ::= Dash | Plus | Star | Ordered { n, Dot | Paren }
Item     { line, indent, bullet, checkbox: Option<Check>, content_byte }
Lists    { item_at, enclosing_item, item_end, siblings, children, list_span }

Checkboxes is rebuilt on top of it rather than kept beside it. Checkboxes::item_at is, once Lists exists, Lists::item_at() filtered to items that carry a box — and leaving two independent list walkers in one plugin is the silent-drift failure that prefer-minor-modes-over-duplication names. The two would agree on the day they were written and diverge on the first grammar bump, in a way that shows up as a cookie that stops updating rather than as a test failure. The rewrite is real work and it is the right shape (heuristic #1); the tally and cookie logic above it does not move.

Ordered lists renumber as part of the same edit. Insert, move, indent and outdent all change what 1. should say, and renumbering in a second edit would leave a u that restores the numbers but not the structure. This is the rule the cookie roll-up already follows and for the same reason: a list showing [2/3] above one ticked box is a worse state to be left in than either end.

5.6.3a A parent's checkbox is derived, not stored (OX.1)

A statistics cookie was the only thing that rolled up. An item's own box never moved, so Check::Partial[-] — was parsed and rendered and nothing ever produced it: the only references in the crate were the parser, the formatter, and two unit tests.

Org's model is that a parent's box is a function of its direct children, computed by org-list-struct-fix-box:

childrenparent
some [ ] and some [X][-]
any [-][-]
any [X][X]
any [ ][ ]
no boxed childrenleft alone

The last row is not the same as [ ] and collapsing the two would blank the box of any item whose sub-items happen to carry none.

Deepest-first, and the toggled line is included. A parent's new box is an input to its own parent's box and to the grandparent's cookie, so the passes run from the deepest line outward — org sorts parent-list by decreasing indentation for the same reason. The lines the user just toggled are recomputed too, not only their ancestors, because org recomputes every parent in the list.

That inclusion is what makes a parent's box read-only: you toggle it, and it is immediately recomputed from children that did not move. This is not a restriction added on top of the model, it is the model, and org behaves identically.

"Toggling a parent is a no-op" is the easy summary and is wrong in one case: a parent whose box disagrees with its children is CORRECTED rather than left alone. A hand-written [X] over mixed children becomes [-] — neither the value it had nor the value the toggle asked for. The accurate rule is that a parent always ends at its derived value. It has one consequence worth stating plainly — without another way in, a long list could only ever be completed one leaf at a time. §5.6.3b is that way in.

Two implementation notes that were each a bug first:

  • The last rewrite of a line wins. A line is written twice in one pass — once by the toggle, once by the derivation that overrules it — and taking the first is exactly the read-only rule failing to hold.
  • Box and cookie are computed into one string. A line can carry both (- [-] parent [1/2]); computing them separately has the second discard the first.

5.6.3b C-c C-x C-b — the other verb (OX.2)

Org has exactly two checkbox keys and lattice now has the same two:

<C-c><C-c><C-c><C-x><C-b>
one itemtoggles ittoggles it
a regiondrives all to one
a headline(dispatches on the headline)drives the whole subtree to one

§5.6.3a is what makes the second necessary rather than convenient: a parent's box cannot be set directly, so without it a long list could only be completed one leaf at a time.

OX.3 retired <C-Space>. It was bound here under a comment calling it "org's own binding", which was simply false — C-SPC is emacs' set-mark-command, and evil-org does not rebind checkboxes at all. Its Visual peer carried OS.10's per-box region flip, which org has no equivalent of and which went with the chord; toggle_checkbox lost its region branch at the same time, since nothing could reach it. Muscle memory is the dominant cost on a surface like this (the UX-convention rule), and an invented third chord spends it for nothing.

Neither key adds a checkbox to an item that has none — org does that only under C-u, and putting a box on every bullet in a subtree because the user wanted to tick three is a far larger edit than the key implies.

5.6.4 One gesture, several meanings — and the arms call the bodies

<M-CR> and the Meta-arrows dispatch on what is under the cursor, the way §5.4's C-c C-c does:

At point<M-CR><M-S-CR>
checkbox itemnew plain item, same indentnew checkbox item
plain list itemnew plain itemnew checkbox item
headlinenew sibling, after the subtreenew sibling with the first TODO keyword
table rowdeclines — table-mode owns it
preamble / proseEffect::None

The box belongs to the chord, not to the item. <M-CR> never inserts one; <M-S-CR> always does. That is org's own rule rather than a simplification of it: org-meta-return reaches an item through (call-interactively #'org-insert-item), so the checkbox argument is the prefix arg — nil — and org-list-insert-item builds the bullet from (and checkbox "[ ]"); org-insert-todo-heading calls (org-insert-item 'checkbox) and documents it ("When called at a plain list item, insert a new item with an unchecked check box"). The new box is empty whichever way it arrives — copying [X] would tick a task nobody has done.

The checkbox row read the other way round until 2026-09-14: <M-CR> mirrored the item's own shape and <M-S-CR> inverted it, so the pair meant "same kind" / "other kind". The symmetry is tidier than org and loses to it on the UX-convention rule — on a checkbox list, which is where org users press these most, it made <M-CR> do <M-S-CR>'s job and left <M-S-CR> — the chord whose name means give me a checkbox — taking the box away.

The Meta-arrows dispatch the same way: on a headline <M-Right> demotes, on a list item it indents.

The arms call the bodies the dedicated chords call. <M-Right>'s headline arm invokes the same function <leader>ol does; its list arm invokes the same one <C-d> does. Two spellings of one verb that could drift is what §5.4 already refused, and the gesture-named ActionIds (org-meta-right, org-shift-meta-left, …) exist because they dispatch — naming them for a context they only sometimes have would be the lie. org-promote-headline and its peers stay as the unambiguous headline-only actions behind <leader>oh / ol / oH / oL.

The table arm declines rather than implementing anything, for §5.4's reason exactly: a guest cannot invoke a registered command, and org re-implementing row insertion is what table-mode exists to prevent. table-mode binds <CR> in Insert already (TB.4); <M-CR> joins it there, and org's decline falls through one layer to reach it.

<CR> does not auto-continue a list, and that is a decision rather than an omission. VSCode, Zed, Obsidian and Logseq all do it; emacs org and vim do not. Shadowing <CR> in Insert across every org buffer to gain it would make the most-pressed key in the editor context-dependent inside one filetype, for a gesture <M-CR> already spells explicitly. Revisit if asked for; do not default it on.

5.6.5 What the Visual verbs needed, and why the fix is small

Applying a verb to a region looked like it needed a new seam. It did not — it needed a field that already exists one layer over.

None of the three guest entry points can see both a region and the text: apply-action gets a cursor plus doc and tree but no range; apply-operator gets a range and no document at all; the ex-command-context range mirror was deliberately never landed (the grammar Range is recursive and carries a plugin RangeId, which a WIT record cannot express).

But lattice-mode's ActionContext has carried selection: Option<Range> since MG.18e, added so magit could stage a selected part of a hunk, and the dispatcher's Range::Selection resolver already handles linewise, charwise and blockwise correctly (dispatcher.rs:756). What is missing is only that lattice-grammar's ActionContext — the one a plugin action arrives through — never gained the field, so the WIT mirror had nothing to copy.

H2 is that field and its mirror: selection on the grammar ActionContext, populated from the existing resolver; selection: option<range> on the WIT record; three lines in project_action_context. The precedent is exact — OC.10 added cursor and buffer-id to ex-command-context for the identical reason, stated in the WIT itself: a command reached that way was seeing strictly less than the same command reached by a chord. A Visual-mode plugin action is in precisely that position today.

Not apply-operator. Giving the operator seam a document is the larger and arguably better fix — it would make text-transforming plugin operators possible at all, which today they are not, and <leader>o-ap would come free from grammar composition. It is recorded here as a known gap with an owner-shaped description, and it is not this work: none of the verbs in this section need it, and taking it on would put a WIT change and its host wiring in front of every one of them.

Not a selection field on actions instead of range args, either. That reading — actions quietly become range-aware — is the one that would violate paramount goal #3, because operator composition over Range::Selection is how this editor is supposed to express "apply to the region". H2 does not introduce that: it reports the selection that the dispatcher has already resolved, to a seam that natively sees it.

5.6.6 Refusals

Every verb refuses rather than guessing, and every refusal says so:

  • A level-1 subtree does not promote — shifting only the children that could move turns a child into a sibling of its own parent. The existing restar rule, unchanged.
  • A move stops at its parent. <M-Up> swaps with the previous sibling and does nothing at either end of the chain, rather than splicing an item into another list's children.
  • An outdent at column zero is refused, not silently converted into a headline. <leader>o* is how a list item becomes a headline, and it is a different gesture on purpose.
  • <M-CR> in a file's preamble answers Effect::None — with no enclosing headline there is no level to inherit, and guessing level 1 would make the key mean something different depending on where the cursor happened to be.

The fallback everywhere else is a message. A key that does nothing is indistinguishable from one that is unbound, which is the failure class this codebase keeps paying for.

5.6.7 Performance, stated rather than skipped

Heuristic #5 asks for bench coverage alongside a design change. The plugin ships no bench harness, and this section does not invent one.

The honest accounting: the cost added per keystroke is one Lists walk, bounded by the enclosing list's own edges exactly as Headlines is bounded by the subtree's — it reads lines outward until the list ends, not to the ends of the file. The boundary cost is the same apply-action round trip every existing org chord already pays, and that is ratcheted, by CI's grammar-extension budget of < 5µs p99. Building a plugin-local criterion harness to measure a bounded string walk underneath a budget that already fires would be ceremony, not coverage. If the round-trip ratchet moves when this lands, that is the signal, and it is already wired.

Sequencing is in org-structure-editing.md.

6. The agenda

The agenda as a dashboard has its own fragment. org-agenda.md covers what the view becomes: agenda-aware colour over a reused display-span seam, layered display modes that each own one concept as virtual rows, headline-only rows, block cycling on <Tab>, a tags/todo query language and org-agenda-custom-commands. This section stays the account of what the agenda is — the multibuffer, the seam, the walk, the sort, the sections. Sequencing lives in docs/dev/operations/slice-plans/archive/org-agenda.md.

MV.3 (2026-08-30): the agenda is a plugin-owned view now. *agenda*, the provider name and the reuse policy come from org's own view-spec, declared through multibuffer-view-source; the host supplies the machinery. What §6.2 describes below — the seam, the walk, the sort, the grouping — is unchanged and still exactly how the rows arrive. What changed is that the view is org's rather than a host constant, so org is one consumer of a seam any plugin can use instead of the one feature the host built a provider for. See plugin-multibuffer-views.md.

The seam that feeds it was renamed agenda-sourcescanned-excerpt-source in the same phase: its record is an excerpt plus an ordering plus a group header, with nothing org-specific in it, and a project-TODO or tags view should not have to register as an "agenda source".

6.1 It is a multibuffer, literally

Excerpt { source: BufferId, start_line, end_line, header } is what an agenda row is. The agenda is excerpts of headline lines drawn from many files — which is the search provider's shape with a different predicate.

Taking that seriously buys, from machinery that already ships and is tested: jump-to-source, edit-propagates-to-source, headerline async status, stale-source handling, and refresh. The second of those is the one that decided it. Org's agenda is a place you change TODO states and reschedule from, and those edits hit the file. An agenda you can only read is a lesser feature wearing the name.

The grouping question — agenda groups by date across files, multibuffer headers are per-file — resolves without touching the excerpt model: view.append_excerpts is insertion-ordered with the provider choosing the order, ExcerptHeader.title is a free string, and an empty title renders no header row. A date group is therefore "title on the first excerpt, "" on the rest".

6.1a Sections (AS.1)

Emacs's agenda is not one list. org-agenda-custom-commands composes blocks — a day's agenda, then NEXT items, then a tags match — into one buffer, and that composite is what people actually build dashboards out of. Lattice's agenda was the single date-grouped list, which meant two things were unreachable: any view that is not a calendar, and any TODO without a date.

The second was the sharper bug. * TODO Write the thing with no SCHEDULED: was not a row under any configuration — the scan returned None the moment it found no stamp — so the most ordinary line in an org file could not appear in the view whose job is to show you your tasks.

A row is now a candidate; each section decides whether it wants it. One headline can produce several rows: an overdue [#A] TODO appears under Overdue, under its date, and under the priority block. That is what a dashboard is for, and it is legal because append_excerpts does not dedup — two excerpts over one source range are two rows of one view.

The shipped set, in order: Overdue (past-dated, not done), Agenda (today through org.agenda-span, default 7 — emacs's own default), Unscheduled (the previously-invisible class), Priority A.

No ABI change, and why that is the design rather than a happy accident

§6.2's entry already carries everything sections need, because all three of its view-shaping fields were specified as opaque and guest-owned:

  • sort-key — "the guest owns what it means". Section rank packed into the high digits makes a section's rows a contiguous run under the host's stable sort. A DAY_BIAS keeps the day term non-negative so a pre-epoch date cannot borrow into the rank digits and file a 1969 row under the wrong section.
  • group — a key, not a label. Prefixed with the rank, so two sections containing the same date do not merge into one run.
  • label — a free string, so a block section titles itself and a date section keeps its 2026-08-25 Tue (today) header.

So the host gained nothing, learned nothing, and needs no arm for what a section is. The one shape change is at the guest's scan: map became flat_map.

This is the seam's design working as intended rather than being worked around — had sort-key been specified as "the date" or group as "the day", a multi-section agenda would have needed a WIT change and every other scanned-excerpt-source consumer would have paid for org's feature.

Folding (AF.1)

The agenda folds by section and date group — the header runs a user sees — not by source file.

Folding by file is the multibuffer default and is wrong here for the reason §6.1 exists: agenda rows interleave across files by date. A file-boundary fold spans that file's first excerpt to its last, so collapsing a file whose entries bracket the view swallows every other file's rows in between, and the fold's header claims rows that are not its.

MultibufferDocumentHandle therefore carries a declared FoldGrouping, set by the provider at view creation. It is a declaration rather than a heuristic because the shipped providers genuinely disagree about what a group is:

ProviderHeader titleGrouping
searchthe file path, on every excerptSourceFile
project-diffthe hunk's line numberSourceFile (titles are neither unique nor a key)
agendathe run's label on the first row, empty afterHeaderRuns

HeaderGroupFoldProvider reads all three with one rule: a non-empty title differing from the current group starts a group, an equal one continues it, and an empty one continues it. That last clause is what makes the rule shared rather than agenda-shaped — an empty title already means "renders no header row", i.e. "I belong to the group above", so folding reuses the encoding the renderer already relies on. On a file-grouped layout it produces exactly the bounds FileBoundaryFoldProvider does, which is asserted rather than assumed.

A group's fold identity is its title, so gr keeps collapsed groups collapsed across a refresh that mints new BufferIds — PD.5a's reasoning applied to the thing the user was actually collapsing. The hit-count badge lives in its own field rather than in title, so a changing count does not change identity.

With AS.1's sections this gives the granularity the view is built around: zM collapses to the section and date headers, zR opens them.

The agenda opens collapsed (AF.2). Its major is multibuffer-mode, not org-mode, so it never saw the foldlevel=0 the org major declares and fell back to the global 99 — a view whose entire structure is blocks, opening with every block expanded.

The override is declared on org-agenda-mode, the plugin's minor, and the scoping is the point: multibuffer-mode is also project search, project diff and the references view, none of which should open collapsed. org-agenda-mode activates on agenda views and nothing else, so it is the narrowest mode that owns the question. It is a layer, so :setlocal foldlevel=99 in the agenda still wins and the user's global setting is untouched.

What a closed group shows is its header row, its first row, and a ⋯ N lines summary — not the header alone. A fold's head is a content row and an agenda header is a virtual row sitting outside the fold, so "collapse to the header alone" is not expressible in the fold model. One row of preview per block is arguably the better read regardless.

Configuration

org.agenda-span bounds the dated block. org.agenda-sections (AS.2) replaces the built-in set wholesale — a string whose value is TOML, which is capture-templates' shape for capture-templates' reason: an option is boolean | integer | string, and a list of records cannot reach one otherwise.

One option serves both config homes with no new seam. lattice.toml sets it declaratively; init.rs sets the identical string through config::set_option, which is already how a user reaches any plugin option (auto-pair.style is the shipped example). Precedence between the two is the config resolver's existing layering, so there is no second ordering rule and no third place to look.

[[section]]
title = "Inbox"
when = "undated"      # overdue | days | undated | any
todo-only = true

[[section]]
title = "This week"
when = "days"
days = 7              # absent ⇒ org.agenda-span
min-priority = "B"    # a ceiling: "B" admits [#A] and [#B]

overdue and days group by day (a date header each); undated and any render one header, their own title.

The guest cannot report a broken set, and what that forces

Calling logging::log from the guest breaks the component. It makes the component IMPORT logging, org's multi-seam linker does not wire that import, and the whole component then fails to instantiate — tried and reverted at OC.2, recorded there as the fifth repeat of the TC.6 multi-seam-linker rule. It is a host fix, still outstanding.

So a malformed set reports itself through the only channel the guest owns: the section titles, which are the view's own headers. It falls back to the built-in blocks with the parse error prefixed onto the first one. Two properties this deliberately keeps:

  • Fall back, never show nothing. An empty agenda and a correct-but-empty agenda are indistinguishable, and "you have no tasks" is the single worst thing this view can say incorrectly. A broken config costs you your layout, never your rows.
  • The complaint rides the first section, not a synthetic one. A section with no rows renders no header at all — the host attaches a group title to a ROW — so a notice-only block would be invisible in exactly the case where the user most needs to read it.

When the host wires logging for multi-seam guests, the notice moves to a log line and the fallback stays.

6.2 The seam follows error-parser

The host must read each file anyway to build the source Document (providers/search.rs:657-690: spawn_blocking read, DocumentBuilder, spawn_document, view.add_source). So it reads once and hands the text over, rather than the guest reading it a second time through WASI.

interface agenda-source {
    /// One agenda row the guest recognised in a file.
    record entry {
        /// 0-based line of the headline, `error-parser`'s convention.
        line: u32,
        /// Last 0-based line of the excerpt, inclusive.
        end-line: u32,
        /// Grouping KEY. Rows that sort adjacently and share a key render
        /// under one header — how a date group shows one header for N
        /// rows drawn from N files.
        group: string,
        /// The header title, used when this row starts a group.
        label: string,
        /// Host stable-sorts across files on this. The guest owns what
        /// it means (an epoch day, a priority rank).
        sort-key: s64,
    }
}

world agenda-source-plugin {
    import agenda-source;
    import logging;
    import project;

    /// File extensions this source wants offered, without the dot.
    /// Called once at load. See "the host does not know what an org
    /// file is", below.
    export extensions: func() -> list<string>;
    /// AF.1: the paths to scan — each a FILE or a DIRECTORY. Called PER
    /// SCAN, unlike `extensions`: this comes from user configuration
    /// (org reads `org.agenda-files`) and has to follow a `:set`.
    /// Empty = "no opinion", and the host scans the project root as
    /// before. The world imports `config` so a source can answer it.
    export roots: func() -> list<string>;
    /// Drop per-scan state. Called before the first file of a scan.
    export begin: func();
    /// Scan one file; return its agenda rows.
    export scan: func(path: string, text: string) -> result<list<entry>, string>;
}

Host: walk (bounded, fs:read-gated), read off-thread, scan per file, stable-sort by sort-key, append excerpts, publish MultibufferExcerptsReady, drive the headerline. Guest: everything org — which headlines are agenda-worthy, TODO / SCHEDULED: / DEADLINE: parsing, date arithmetic, grouping, ordering.

The guest touches no filesystem: no WASI preopens, no walk capability.

group is a key, not a label — the amendment OM.A1 made to the shape first sketched here, where the two fields were redundant and group was documented as "empty = same as the previous entry". A guest cannot know which of its rows will land first once every other file's rows are interleaved by the sort, so it cannot decide which one carries the header. The host compares keys after sorting and titles the first row of each run; the rest get an empty ExcerptHeader.title, which renders no header row. §6.1's grouping mechanism is unchanged — only who decides.

The host does not know what an org file is. The sketch above once said the walk was ".org only", which contradicts §11's own claim that every host change here is generic. extensions is what fixes it: the source declares what it wants offered, resolved once at load and cached beside the producer, so the walk's per-file test is a string compare. A markdown TODO scanner then appears in the same view with no host change at all.

Two alternatives were rejected. Offer every project file to every source — one boundary crossing carrying the full text of every file in the tree, which is precisely the producer-critical-path cost §8 warns about. Resolve the extensions from the plugin's language seam (the PluginLangRegistry already indexes by_extension) — it would make an agenda source require a language seam when the two are independent contributions.

One bad file must not fail the agenda, so scan returns a result and an err skips that file with a debug log while the walk continues — error-parser's rule, because it is the same failure class. begin failing is different: that source's per-scan state is then unknown, so it is dropped from this scan and the others carry on.

6.3 Rejected alternatives

  • A generic plugin-view seam extending dashboard's rows-and-spans fragment. Read-only plus links; acting from the agenda would need a separate write path, so org's core agenda verb would be re-derived rather than inherited.
  • Mode lifecycle + owner-write — unblock on-activate for plugin modes and give a mode a write handle to its own buffer. The most general answer and the most faithful to mode ownership, but it is two new mechanisms, and the agenda would then hand-roll grouping, jump-to-source and refresh that multibuffer already has.
  • A picker-source — ships today with zero host work, and is honestly goto-TODO rather than an agenda: no date-grouped view, no acting from it.

The rejected options are not wrong so much as differently scoped; if the view seam or mode lifecycle lands for another reason, nothing here blocks it.

A link is org's only construct that is simultaneously markup to be hidden and a thing to be activated. Both halves are org's, and neither is roam's — an org file with no roam index in sight still wants [[file:diagram.png][the wiring]] to read as three words and to open on <CR>.

7.1 Rendering, which is a host primitive org merely configures

Links render through conceal.md. Org contributes two rules to the language seam and contributes nothing else; the mechanism, the coordinate maths and the mode scoping are the host's.

(\[\[[^]]+\]\[)[^]]+(\]\])     hide [1, 2]   described link
(\[\[)([^]]+)(\]\])            hide [1, 3]   bare link

A bare link keeps its target visible. [[https://example.com]] renders as https://example.com, not as nothing. Emacs draws the same line, and the reason is not deference: a link whose only text is its target has nothing left to show once the target is hidden, and an invisible activatable region is worse than visible markup.

Why patterns and not the parse tree, which is the question a reader arriving from §6 will ask, because everything else in this plugin was migrated onto the tree. Two independent answers. tree-sitter-org has no link rule — [[id:X][Title]] is undifferentiated expr tokens inside item or paragraph, so there is nothing to capture. And the tree is absent during a reparse, so tree-driven conceal would flicker between concealed and raw while the user types: a pixel change to content they did not edit, which is a standing veto. links.rs already recorded that second reason for its own text scanning, and it is the same reason here for the same construct.

This is not a retreat from "structure from the tree, characters from the text". It is that rule applied honestly: a link has no structure in this grammar, so there is none to read.

7.2 Following, and why <CR> is safe here

<CR> opens the link under the cursor and declines otherwise, through the same chain §4.3 describes and tests:

<CR>  →  org-mode  : on a link?  open it
                     else        [declined]
      →  (nothing, today — see below)

An earlier revision of this section put "Builtin: first non-blank of the next line" on that last row. That was vim, not lattice. <CR> is unbound in Normal mode for a Document buffer: keymap_normal.rs binds it only as the z<CR> suffix, and input.rs routes a bare <CR> to Action::FollowLink only for Help, Dashboard, Oil and FileTree buffers. Lattice has no equivalent of vim's + / <CR> motion, which is a real gap in the vim grammar and a separate piece of work from this one.

So today the decline is observationally a no-op, and a test cannot distinguish it from Effect::None — the same limitation OM.5 records for <Tab>. It is still the right answer for two reasons a test cannot see: it is the honest one, and org composes for free the day <CR> gains a Document-buffer meaning.

Two actions, not one, and this is forced rather than chosen. org-open-link (bound to <leader>oo) must answer Effect::None on a miss; org-follow-link (bound to <CR>) must answer Effect::Declined. The vocabulary has no way to say "it depends", and the reason they differ is the standing hazard: Declined re-runs a multi-key chord's trailing key alone, so declining from <leader>oo would fire bare o — "open a line below and enter Insert". A missed link would start editing the buffer. <CR> is a single key, so there is no trailing key and no hazard.

<leader>oo stays. It is the explicit form, it works when the cursor is not inside the link's span, and removing a working chord to make room for a new one costs a user's muscle memory for nothing.

The cost is one guest round-trip per <CR> in an org buffer even when the cursor is nowhere near a link — the same honest cost §4.3 already accepts for <Tab>, budgeted under the same grammar gate (§8).

7.3 id: is recognised before it is resolvable

Target gains an Id arm. Nothing in org can resolve it: an :ID: is a key into a corpus, and finding the file holding it means an index, which is org-roam.md's subject.

So org ships id: as a recognised kind that fails honestly<CR> on [[id:6F398E54-…]] says there is no index rather than silently doing nothing. That is deliberately not the same as leaving id: unclassified, which would make it fall through to the file branch and produce "no such file: id:6F398E54-…", an error that blames the wrong thing and sends the user looking for a file.

8. Performance

Paramount goal #1. Org adds guest calls to the keystroke path, and the budget is the existing grammar gate — typed call < 500 ns p99, grammar-extension round-trip < 5 µs p99, measured at ~340 ns release (PH7.7d).

Benched:

  • Org's apply-action round-trip for promote / demote / TODO cycle.
  • The <Tab> decline path, specifically. It is the one org path that costs a guest call on keystrokes that do nothing, and it fires twice per press through the §4.3 chain. If anything here threatens the gate, it is this.
  • Agenda scan throughput per file. Off the keystroke path, but on a producer's critical path — a guest that blocks in scan backs up the agenda the way a slow error-parser backs up a build.

Parse cost is already recorded and needs no new work: a wasm grammar parses 2.0× cold, 1.25× incremental against native, flat across file size (LG.1, benchmarks.md).

Nothing org does runs per frame. The renderer reads folds and highlights from caches that already exist; no_per_frame_wasm_guard continues to hold.

9. Failure behaviour

Every path degrades the way the seam it rides already does — which is the point of riding them.

  • A guest action returning err is logged at debug and the contribution is a no-op. The buffer is untouched.
  • A fuel or epoch trap is caught, the plugin quarantines, and the chord no-ops. Never a hang on the keystroke path.
  • A malformed org file during a scan is skipped with a debug log and the scan continues. One bad file must not fail the agendaerror-parser's rule, because it is the same failure class.
  • A trap mid-scan quarantines the plugin and leaves the agenda showing what it collected, with the headerline saying it stopped. Partial-and-honest beats empty-and-silent.
  • An unparseable chord or unknown command in a mode declaration skips that one binding, logged — already mode-keymap-binding's contract, and the reason §3.4 is a gate.
  • Unloading org removes the language, the grammar contributions, all four modes, the keymap layers, the help topics and the agenda provider. The multi-seam teardown fix (PluginTeardown::seam_ids) is what makes that true, and it was found by this plugin.
  • Diagnostics are debug!, never info!. A per-<Tab> decline at held-key rates would flood *messages*.

10. Scope

In: structure editing, text objects, TODO workflow, priority, tags, checkboxes with statistics cookies, timestamps, links, refile, capture, agenda, tables (alignment, cell/row motion, row and column insert and move).

Out, as cuts rather than omissions: export backends, babel / source execution, table formulas, column view, org-roam.

Was blocked on a host primitive, now built: archive, refile and capture all move text into a file other than the buffer's own, and no effect could. cross-file-writes.md is the answer — a host-mediated write-to-file effect gated on fs:write. Two more were needed and neither was foreseen: document.path() (OM.6b.0), because an archive's target is derived from the source file's own name; and a picker source able to invoke an ACTION with typed args (OM.11.0), because refile picks a target and only THEN reads the subtree at the cursor — and the ex-line route a picker had is closed to actions and hands an ex-command no document. All three ship.

Clocking ships (OC.1–OC.11). It was deferred here for needing "persistent 'currently clocked' state and a modeline contribution", and the first half of that turned out to be wrong in a useful way: there is no persistent state. An unterminated CLOCK: [start] with no --end is a running clock, so the buffer is the whole record — clock-out and clock-cancel re-derive their target structurally, and clocking out works on a clock started before the editor was last opened. Guest state exists only to feed the modeline and to remember the last clocked entry for :org-clock-goto / :org-clock-resume, and losing it costs a segment, never a fact.

The modeline half was real, and it closed a gap rather than consuming one: plugins had no way to contribute a modeline element at all, so clocking landed ML.6 (the ui seam) on the way past.

What clocking exposed is worth more than the feature. Three seams promised something they could not deliver, each invisible from the guest side: a grammar action could call emit-event and be silently dropped (OC.1); a plugin could not be woken at all, so anything periodic waited for a keystroke (OC.2); and an ex-command was handed no cursor and no buffer id while still being offered an apply-edit effect it had no way to construct (OC.10). Each is the same shape — a seam wired end to end that answers nothing — and none was reachable by a test that built its own context.

<leader>o' ships as a narrow to the block body (AppEffect::NarrowLines); a true indirect buffer in the block's own major mode is post-v1.

11. Paramount-goal alignment

#1 Performance. Every org path is either off the keystroke path (agenda) or inside the measured grammar budget (actions). The decline chain is the one new per-keystroke cost and it is benched by name. No per-frame work is added.

#2 Extensibility. This is the goal org exists to test. The editor learns headlines, TODO keywords, agenda scheduling and table alignment without a line of org in the host. The three host changes (§3) are all generic — a language index, a WIT field, a lifted restriction — and none names org.

#3 Vim modal editing. Org extends the grammar rather than escaping it: text objects that compose with every existing operator, motions that compose with counts and operators, and a keymap that refuses to shadow c / < / > even though that would have been the easy way to match nvim-orgmode's chords.

#4 Asynchronicity. The agenda scan is off-thread by construction — the host reads with spawn_blocking and the guest runs in the seam's async actor task. Results reach the screen through MultibufferExcerptsReady, an event with a wake already wired (boot.wake_on_event), so no keypress is needed to see them.