Plugin Host (Phase 7) — WASM Component Model extension substrate

Status: ✅ Phase 7 complete (design 2026-07-01; last refreshed 2026-07-18). PH7.0–7.12 landed — the lattice-plugin-host crate, the wit/ package, the capability/WASI model, the boundary mirrors (Effect/picker/completion/grammar/event/decoration), the fuzzy-finder validation plugin (⭐ Phase-7 exit), the grammar-extension sync seam (PH7.7), the event/hook seam (PH7.8), the decoration seam (PH7.9), config/modes/keymap seams, host-services (walk), and the teardown + graceful-degradation audit, plus CI perf gates. Phase 8 landed on top (branch phase-8-plugin-loader): the lattice-plugin-loader (discovery + :plugin-load/ unload/reload + init.rs), the :plugins manager view (PL8.H), and the plugin observability stack (PO.1–PO.5 — see the sibling fragment plugin-observability.md: the boundary tracer, the gated hot-path grammar seam, the *plugin-trace* buffer views, the live plugin.trace-level option, and the wasi:logging guest import). This fragment is the detailed "what/why" that expands design.md §5.5 (Plugin Subsystem), §9 (Plugin API), §10 (extension tiers), §3.1 (core-vs-plugin split), §14 (risks). Slice sequencing + landed status lives in docs/dev/operations/slice-plans/archive/plugin-host.md (+ plugin-loader.md, plugin-observability.md); a conformance review of the whole host against the paramount goals + the three extension goals (grammar / modes / rich UI) is docs/dev/audit/plugin-host-architecture.md.

Superseded (PH7.7, 2026-07-12) — the grammar seam is synchronous, not async. §3's "Async ABI is canonical … no synchronous path from UI input to plugin code" and §4.1's "the trampoline is async + fuel-bounded" describe the pre-PH7.7 assumption. They were corrected when the grammar seam landed: a plugin motion / operator / text-object must resolve synchronously to compose with its operator and to keep dot-repeat / macros synchronous (async grammar would break operator∘motion atomicity and the keystroke contract). So grammar is the one bounded synchronous-on-keystroke seam — the renderer still never calls WASM (absolute), and every other seam (picker, completion, events, decorations, ui, config) stays async / off-keystroke. The exact invariant + the Reflex-class budget it requires are in the audit (I2 / F1). The paragraphs below are left in place for provenance with an inline supersession marker.

Scope (locked with Dhruva, 2026-07-01): Phase 7 proper — the host runtime, the capability/security model, the WIT interface set mirroring the already-exercised native trait seams, and the fuzzy-finder validation plugin. Modes-as-components (full), the bundled-plugin manager, config-as-WASM (init.rs), live-eval, and the post-1.0 ABI-freeze policy are deferred to their own fragments, cross-referenced in §12. The design spine is the exercised-trait → WIT mirror (chosen over WIT-first and layered framings): every WIT interface is derived from a real, object-safe Rust trait that ships and is exercised today, because that is exactly the mitigation design.md §14 names for "WIT design proves wrong."


1. Thesis: the boundary is a seam we already built, not a wall we bolt on

design.md §3.1 is the load-bearing principle and this fragment's spine:

Fast path stays in core. Configuration / orchestration / authoring goes to plugins. The trait surface between them is the extensibility seam.

The WASM Component Model boundary has real, measured cost (typed-call p99 < 500ns; round-trip < 5μs — §5.5.2). Anything that fires per-keystroke or holds keystroke-hot-path state would pay that cost on every input event and erode the one-frame keystroke→glyph ceiling (8.3ms @120Hz). So we earn extensibility around the hot-path state machines via traits, never inside them via WASM dispatch. Phases 4–6 deliberately grew a rich set of object-safe provider traits for exactly this moment; the plugin host ships against that concrete, exercised set rather than a speculative API.

The codebase already pre-reserved the plugin surface — this is not retrofitting:

Reserved seamWhereWhat it reserves
SourceLayer::Plugin(u32)lattice-grammar/src/source.rs:38Host-issued plugin-id provenance for registered commands
Args::Bytes(Vec<u8>)lattice-grammar/src/args.rs:18"When WASM lands, WIT-typed args replace this byte form"
CandidateData::Extension { kind_id, payload }lattice-completion/src/candidate.rs:128msgpack escape hatch for plugin candidates
SubscriptionTarget::Plugin (omitted)lattice-runtime/src/events.rs:23Bus variant deliberately absent "pending WASM hosting"
DecorationProvider (stub)lattice-mode/src/contributions.rs:44"reserved for the WIT plugin-facing contribution surface (M.10)"
Mode::required_capabilities() -> CapabilitySetlattice-mode/src/mode.rs:262Capability declaration already on the mode trait
KeymapCapability::MinorModelattice-keymap/src/registry.rs:79Write-gate: plugins may write MinorMode/Buffer layers only

The plugin host's job is to fill these reserved slots, not to invent a parallel universe.


2. Paramount-goal alignment (the whole-doc frame)

Every subsequent design choice is mapped locally; the frame:

UX (higher court): a plugin must never stall a keystroke, never crash the editor, never paint the wrong pixel. Crash isolation, fuel limits, and the async ABI make "good UX is the reliable outcome" a structural property, not a discipline. A trapping plugin yields a notification, not a dead editor. Paramount #1 (performance): protected by the fast-path/orchestration split — WASM is off the keystroke hot path by construction; per-call budgets are CI-gated (the Phase-7 exit gate). Sacrifices nothing on the hot path because nothing hot crosses the boundary. Paramount #2 (extensibility): the reason this phase exists. WIT is the canonical API; the interface set is sized against exercised traits so it is honest on day one. Paramount #3 (vim grammar): the grammar-extension WIT mirrors register_motion / register_text_object / register_operator / register_ex_command exactly — plugin motions are first-class grammar citizens, not a bolt-on command layer. Paramount #4 (asynchronicity): each plugin owns its wasmtime::Store, runs as tokio task(s), suspends at every host call. Many plugins execute in parallel across cores; the UI thread never invokes WASM directly.


3. Host runtime architecture

The lattice-plugin-host crate owns the wasmtime engine and the per-plugin lifecycle.

  • Runtime: wasmtime + Component Model + WASI (preview2). wit-bindgen generates the guest bindings; wasmtime::component::bindgen! generates the host side. WIT files live in wit/ (canonical, one file per interface — §5).
  • Store-per-plugin. Each plugin instance owns its own wasmtime::Store<PluginState>. Stores are independent; there is no global plugin lock. PluginState holds the plugin's capability grant, its WASI context (scoped fs/net), its resource tables (document handles, callback registries), and its fuel meter.
  • Task-per-Store. Each Store is driven by one or more tokio tasks on the multi-thread runtime. Two plugins doing CPU work run on two cores. A Store is !Send while executing but yields at every host call, so no OS thread is pinned (normal tokio multi-thread behavior). The editor actor is current_thread; plugin tasks run on the shared multi-thread runtime, never on the actor thread (paramount #4 + the CLAUDE.md no-UI-thread-work rule).
  • Async ABI is canonical for every seam except grammar. Host functions are async; a plugin host call suspends the WASM stack and releases the OS thread. This is what makes "no plugin can stall the UI, ever" hold by construction for picker, completion, events, decorations, ui, and config: there is no synchronous path from UI input to those. The one exception is the grammar seam (§4.1, PH7.7): a motion/operator/text-object apply resolves synchronously on the dispatch thread (it must, to compose with its operator), bounded by a Reflex-class fuel + epoch budget so a runaway traps well inside the frame. The renderer never calls WASM regardless. See the supersession note at the top of this fragment + audit I2 / F1.
  • AOT compile at install; module cache on disk. Cranelift compiles the component ahead of time; the artifact is cached under <config-home>/lattice/cache/plugin-modules/ (${XDG_CACHE_HOME} on Linux, Application Support on macOS, LocalAppData on Windows) so a second launch reuses it (resolves design.md §15 Q17). Re-installs and editor upgrades reuse artifacts. Per-instantiation cost is linear-memory allocation + import resolution, not codegen.

    Superseded (PH7.1b, 2026-07-01): the original proposal — a hand-rolled sha256(component_bytes + wasmtime_version + target_triple + wit_revision) key plus Component::serialize/deserialize — is not how this shipped. wasmtime 46 provides a built-in on-disk cache (Config::cache + CacheConfig::with_directory) that owns the keying and invalidation (bytes + compiler config + target + wasmtime version) and needs no unsafe. The host sets only the directory. Chosen on paramount-#2/security (keeps the workspace unsafe_code = "deny" gate intact) + heuristic #1 (less code, upstream-maintained invalidation vs. a hand-rolled key). See the PH7.1b slice.

  • Lazy instantiation. A plugin that is never invoked is never instantiated. 50 installed plugins contribute 0 instantiation cost to startup; a plugin instantiates on first invocation of one of its contributions. Cold-start budget for 50 lazily-loaded plugins: < 30ms total (§5.5.2).
  • Fuel + epoch limits. Every plugin call runs under a fuel budget (wasmtime fuel) and an epoch-interruption deadline. Exhaustion traps cleanly: the offending task is killed, a PluginCrashed event fires, the plugin is quarantined, and every other plugin, the document actor, the UI, and the LSP clients keep running.

Heuristic #1 (long-term fit, on merit): Store-per-plugin + task-per-Store is more work than a single shared interpreter Store, but it is the genuinely-better design — it is the only shape that delivers crash isolation and cross-core parallelism (paramount #4) and per-plugin capability/fuel scoping. A shared Store would couple failure domains and serialize plugin CPU. Chosen on merit, not novelty. Heuristic #2 (paramount, not other editors): wasmtime Component Model is chosen because it gives us capability sandboxing + fuel + cross-language WIT on our async substrate — not because "editor X embeds WASM." Lua/Scheme/Rhai are rejected in §11 on paramount grounds.


4. The boundary-crossing problem (the crux)

The exercised traits are object-safe (Arc<dyn Trait>) — the shape that wraps cleanly as a WASM-backed Box<dyn Trait>. But four kinds of trait content cannot cross a WASM boundary as-is. How each is resolved is where the design earns or loses paramount #1 vs #2.

4.1 Closures → callback-id + exported guest function

The grammar specs and the picker/completion providers carry boxed Rust closures (Box<dyn Fn(&Ctx) -> Result<...>>). A WASM component cannot hand the host a Rust closure. Inventory of every closure seam (from lattice-grammar/src/registry.rs):

Specclosure field(s)inputs → output
MotionSpecapplyMotionContextMotionResult
OperatorSpecapply&mut OperatorContextEffect
TextObjectSpecapplyTextObjectContextProtoRange
ExCommandSpecparse_args(rest: string, bang: bool)Args
ExCommandSpecapplyExCommandContextEffect
ActionSpecapplyActionContextEffect

Resolution: registration returns a stable id (already: MotionId/ExCommandId/…). The WIT mirror turns "spec carries a closure" into "the guest component exports a function; the host stores (command_id → guest_export_ref) and, on dispatch, calls the export by id." The host constructs the native spec with an apply closure that is a thin shim: it projects the dispatch context into an owned WIT record, calls the guest export (synchronously — superseded from "async"; fuel + epoch bounded, PH7.7), maps the returned WIT effect back to the native Effect. From the dispatcher's view (dispatcher.rs), nothing changes — it still looks up an entry by CommandId and calls a boxed Fn; the Fn just happens to trampoline into WASM.

UX (higher court) [superseded PH7.7 — sync, not async]: the trampoline is synchronous on the dispatch thread + fuel/epoch-bounded. A slow/looping plugin motion cannot freeze the keystroke because the epoch deadline traps it well inside the frame — the motion is a no-op with a logged warning, never a hang. This requires a Reflex-class budget (sub-frame), distinct from the lifecycle/async budget — the default ~1s epoch would let a plugin motion stall a keystroke (audit F1); PH7.7c sets the tight budget. Paramount #1: grammar round-trip budget < 5μs p99 (§5.5.2) is the CI gate on the boundary marshalling (measured ~40ns, PH7.7a); the guest's own compute is bounded by the Reflex fuel/epoch budget, not this gate. Built-in motions stay native (lattice-grammar) and never pay either. Paramount #3: because registration is the same insert_* path with a host-supplied SourceLayer::Plugin(id), a plugin motion is indistinguishable from a builtin to the grammar — first-class, per goal #3.

4.2 Borrows of live host state → owned snapshot projection

PickerContext<'a>, ActiveBufferSnapshot<'a>, GenerateContext<'a>, and the grammar contexts carry &'a Buffer, &'a Path, &'a str. The rope especially cannot cross. The host adapter projects each context into an owned, WIT-serializable snapshot at the call boundary. Bulk text never rides the snapshot: the guest gets a document resource handle and calls back (get-text-range(doc, range)) for the slices it needs — zero-copy at the slice level (§9.6). This preserves the §5.5.2 rule "resource handles, not copies."

Heuristic #3 (third option): the naive options were (A) copy the whole rope into the snapshot — violates paramount #1 on large buffers; (B) expose &Buffer as a raw pointer — unsound across the sandbox. The chosen (C) — owned metadata snapshot + resource-handle callbacks for text — is neither, and it is what §9.6 already commits to.

4.3 Future/Stream carriers → host-owned async adapters

PickerInitResult::Future(Pin<Box<dyn Future>>) and ::Stream(mpsc::UnboundedReceiver) are host-runtime objects, not serializable. The WIT mirror expresses async results as guest returns batches; host owns the plumbing: for one-shot the guest export is async and the host awaits it; for streaming the guest holds a host-provided result-sink resource and pushes batches, or the host polls a guest next-batch() export. Either way the Future/ mpsc stays host-side; the guest never names a tokio type. This is the picker's Inline/Future/Stream trichotomy re-expressed in Component-Model record/future/stream terms (§9.4 already anticipates stream<rg-match>).

The same async-carrier discipline extends to accept, which the native PickerSourceGenerator::accept declared synchronous — fine for a native source (accept is a pure routing→outcome translation) but not for a guest, whose accept export is async and bound to its actor task (§5.7). PH7.4c.2 adds a generic seam: accept_async(&self, ctx, routing) -> Option<AcceptFuture> (default None, so native sources are untouched). A Some return is spawned and drained the same way init's Future is — the host owns the plumbing, the guest returns one outcome. This preserves the §3 guarantee by construction: there is no synchronous path from a keystroke to plugin accept, so a slow/hostile plugin accept can never freeze the actor thread. Blocking the actor on the guest call (the naive alternative) is rejected on exactly that ground; pre-resolving accept during init is rejected too (O(N) guest calls + it would carry the init-time context, not the accept-time one).

4.4 The Effect closed enum → WIT variant mirror; partial-serde fields → explicit records

Effect is a closed ~105-variant enum in lattice-grammar (effect.rs), deliberately "the host boundary vocabulary." The WIT mirror declares an effect variant type covering it whole; operator/ex-command/action guest exports return it, and the host maps back 1:1. The partially-#[serde(skip)] candidate fields (accept_action, annotations, display_spans) and the non-serde Annotation enum get explicit WIT records — they do not ride the existing serde path. RoutingPayload::InvokeCommand / PickerAcceptOutcome::InvokeCommand carry Args; the WIT maps Args to a typed variant (retiring the Args::Bytes escape hatch for typed calls, per the args.rs module note).

Heuristic #1: mirroring the whole closed Effect enum is more upfront WIT surface than a "generic opaque effect blob," but it is the honest design: a typed variant is introspectable, versioned, and lets the host reject malformed effects at the boundary rather than at apply-time. The blob would smuggle an untyped seam into the one place §14 flags as highest-risk (WIT-design-wrong). Rejected.

4.5 In-place mutation → pass-in / return-out

CandidateRanker::rank(&mut Vec<..>) and CandidateAnnotator::annotate(&mut RenderedCandidate) mutate host-owned data by reference. Across WIT the host passes the data in and takes the reordered/annotated data out. OperatorContext holds &mut Document; document mutation becomes host calls (apply-edits(doc, edits)), never a &mut handle.


5. The WIT interface set (exercised-trait → WIT mirror)

One .wit file per interface under wit/. Each interface below names the native trait it mirrors, the adapter direction, and its Phase-7 status (⭐ = required for the Phase-7 exit; ➕ = exercised-seam coverage that hardens the WIT before the 1.0 freeze). Only ⭐ is on the Phase-7-proper critical path; the rest are designed here so the WIT is sized against the full exercised set (§14 mitigation), and land as follow-on slices.

WIT interfaceMirrors nativeAdapter directionPhase 7
bufferDocument/Buffer + apply_edithost→guest resource; guest calls back for text
host-servicestree-sitter / ripgrep / regex / fs / net / procguest→host calls, capability-gated
picker-sourcePickerSourceGenerator (source.rs:294)guest exports spec/init/accept; host wraps as Arc<dyn>
plugin (lifecycle)activate / deactivate / on-eventhost→guest exports
multibuffer-view-sourceProviderViewRegistry + Excerpt (✅ MV.1)guest declares N views (identity + input model); host registers an opener per view, seats it, fills it off-thread
scanned-excerpt-sourceScannedExcerptSource + Excerpt (✅ OM.A1)host walks + reads + parses; guest classifies one file at a time and returns rows with a sort key
completion-sourceCandidateGenerator (✅ PH7.6); Matcher/Ranker/Annotator type-mirrored onlyguest exports async generate; host produces candidates off-keystroke (LSP pattern) → native match_and_rank
grammarregister_{motion,text_object,operator,ex_command}guest exports apply/parse; host shim closures
commandCommandRegistry + CommandInvocation + Effectguest→host invoke; host→guest apply
eventsEventBus::subscribe + Event enum (✅ PH7.8)host owns mpsc + a type-erased sink; forwards each Event to the guest on-event off-keystroke
decorationsMode::gutter_decorations + GutterDecoration (✅ PH7.9)host calls guest producer OFF the render path per trigger; caches per-line data (the PH7.6 fork)
inline-decorations 📝lattice_cells::StyledSpan + Style::Element + the ExtraHighlights mergesame producer/cache shape as decorations, for byte ranges rather than lines — designed, not built (plugin-inline-decorations.md)
modesMode trait (mode.rs:148) + ModeRegistryguest declares mode; host registers Arc<dyn DynMode>
uistatus/gutter segments, popups, notifications, sprites (type-mirror ✅ PH7.9)guest→host emit data (no draw calls) — emit producer deferred
configConfigRegistry + OptionType/OptionSpecguest declares typed option; host registers into registry

A seam that spawns its own Store must be handed the config registry

Store-per-plugin (§3) means each seam's spawn_* builds a store from new_store, which starts with config_registry: None. A guest calling config.get-option / config.set-option from that seam then takes the "plugin has no config registry wired" branch: a warn! into the log and a no-op return. Nothing traps, nothing surfaces to the user, and :set <opt>? keeps reporting the compiled default — so the symptom is "my config does not apply" with a working-looking plugin behind it.

Every seam whose guest can read or write an option must therefore take a config: Option<&Arc<ConfigRegistry>> and set it on the store before driving any guest export. Today that is config, context, transient, grammar (via the trampoline) and events.

This has now been the same bug three times — the transient seam (OC.3, org-capture.md §5), the context seam, and the events seam. Each time the seam was wired end to end and each time the option path through it was the one path no test took. The events case is the sharpest illustration: the CI.5 deferred-config chain test drove modes.enable-mode from on-event and passed, but enable-mode reaches the bus, not the registry — so the documented init.rs pattern for configuring a user plugin (docs/user/init.md, "IMMEDIATE vs DEFERRED") had never been executed once. init_deferred_config.rs now asserts both halves.

A test for a new seam that exposes config must set an option through it and read the value back off the live registry, not merely observe that the guest call returned.

Registration is into the same registries, never a parallel plugin registry — and the mechanism already exists. Boot assembles EventBus, CommandRegistry, ModeRegistry, ServiceRegistry, CompletionRegistry, PickerRegistry, ConfigRegistry in Editor::boot (editor_boot.rs:225), and native subsystems contribute into them through one abstract surface: the SubsystemBoot trait (lattice-mode/src/subsystem_boot.rs:51), implemented by the host's BootContext. Each Phase-B subsystem already installs via a single call — lattice_lsp::install(&mut boot), lattice_diff::install(&mut boot), lattice_multibuffer::install(&mut boot) (editor_boot.rs:477). SubsystemBoot exposes commands_mut / modes_mut / register_service / service / inbound / wake_on_event / tick_callback / the event bus — the complete generic contribution vocabulary.

A plugin is just another subsystem that installs through the same install(boot) seam. The plugin host is a subsystem whose install walks each loaded component's declared contributions and, for each, wraps the WASM export as the corresponding Arc<dyn Trait> and calls the existing insert path on boot. No new registry, no new contribution vocabulary — the acid test (a new plugin adds ZERO Editor:: methods) is satisfied because plugins reach exactly the surface lattice_lsp and friends already reach. (Watch the documented ServiceRegistry Arc/TypeId double-Arc footgun — register and look up with the same T; subsystem_boot.rs:65.) The per-seam insert paths:

  • Picker: PickerRegistry::register_generator(Arc<dyn PickerSourceGenerator>) (source.rs:167).

  • Completion: the pub(crate) insert_* seam that takes Arc<dyn Trait> + explicit SourceLocation (registry.rs:225) — the public register_* takes impl Trait (generic, monomorphized) and is not reachable by a dyn plugin wrapper; the host uses insert_*.

  • Grammar/command: the pub(crate) insert_* path with a host-constructed SourceLayer::Plugin(id) (§4.1). The guest never supplies its own provenance.

  • Events (✅ PH7.8): the host filled the reserved SubscriptionTarget::Plugin { plugin, handler, sink } slot. The sink is a type-erased Arc<dyn Fn(Event) -> bool> (lattice_runtime::PluginEventSink) the plugin host builds over its own futures mpsc — so the bus stays channel-agnostic (no plugin-host dep in lattice-runtime); the bus calls it in the lock-dropped dispatch phase (audit M1) so a slow handler never delays the publisher or another subscriber, and a false return prunes a closed sink. The per-plugin EventActor drains the channel off the keystroke path and drives the guest on-event export. The seam is its own async events-plugin world (import events + export register-events + on-event), NOT the base plugin world — a world's exports are mandatory, so hanging on-event off plugin would force every non-observer component (incl. the WAT scaffolds) to implement it; the dedicated world matches the picker/completion/grammar precedent. A guest subscribes with a self-chosen handler id (the grammar callback precedent); the declarative filter crosses (a custom predicate is the guest filtering inside on-event). §5.10.4. (A component trap taints its instance, so a trapping plugin's later deliveries also fail — dead-until-reinstantiation, PH7.12; the guarantee is cross-plugin/host isolation.)

  • Periodic wakes (✅ OC.2): the same world carries wake-every(ms) -> wake-id / cancel-wake(id) and an on-wake(id) export. A plugin whose display changes without the buffer changing has otherwise no way to say so — org's running clock re-renders once a minute over a file that is already correct — and before this its segment could only advance on the next keystroke, which is the "works, but only after I hit something" failure the boot-composition rules exist to design out.

    It lives on the events actor, not on a scheduler of its own: a wake is a future on the actor's FuturesUnordered, selected against the bus channel, so it inherits the actor's budget, its quarantine and its task-abort teardown without any of them being re-implemented. events is not on the sync grammar linker, so a wake is unreachable from the keystroke path structurally (paramount #4). Two consequences worth naming: the actor loop now ends when the channel is closed and nothing is armed (a plugin that subscribes to nothing but arms a wake is a real shape); and a trapping on-wake quarantines exactly as a trapping on-event does, cancelling the plugin's wakes so a dead store is not re-entered once a period forever.

    The timer is injected, not owned: lattice-plugin-host keeps tokio a dev-dependency on purpose (futures was chosen over tokio::sync so the lib owns no runtime and every actor is spawned by its caller), so the host holds a Sleeper trait object the loader supplies. A harness that wires none leaves wake-every answering 0 — the same honest degradation every unwired context here uses, rather than a fabricated tick. Rejected: a host-owned scheduler thread (mirrors EpochTicker, but adds a thread and needs its own teardown story) and taking a real tokio dependency (breaks the stated invariant for one function).

  • Local wall-clock offset (✅ OC.4): host-services.local-utc-offset-seconds() -> s32, east-positive. A component's SystemTime::now() resolves through wasi:clocks, which is UTC, and the host builds each plugin's WasiCtxBuilder with no environment inheritance — so there is no TZ either. A guest thus has a correct instant and no way to render the wall-clock time the user reads. Org's CLOCK: [2026-08-28 Fri 16:02] is local by definition, so without this every clock line, every %U/%T/%t capture stamp and the agenda's "today" anchor is wrong by the user's offset — near midnight, wrong by a day.

    Not capability-gated, unlike its walk / read-file neighbours: it names no path and reaches no resource, and gating it would mean a plugin with no filesystem grant renders every timestamp in the wrong zone. Resolved per call, never cached — the offset changes at a DST boundary and when the user changes their system zone, so caching would make an editor left open overnight write clock lines an hour wrong for the rest of the session.

    This is the workspace's first timezone dependency, and the absence was deliberate: lattice-agent's log formatter renders UTC and says so, because a log line in UTC is legible. A CLOCK: line in UTC is not — it is a wrong number in the user's file. chrono with default-features = false, features = ["clock"], used for exactly Local::now().offset().local_minus_utc(). Chosen over the time crate because time's local-offset deliberately refuses in a multi-threaded process (it would answer UTC here, always) unless unsound_local_offset is on; chrono resolves the zone through iana-time-zone — already in the graph via wasmtime-wasi — rather than localtime_r.

  • A plugin can SHOW a buffer it knows by id (✅ CD.1): effect.focus-buffer(u32). The peer of apply-edit's target — a guest could edit a buffer by id and had no way to show one, while open-buffer takes a path and open-synthetic-buffer a name. Host-applied, so it works off-keystroke; an id that no longer names a buffer is a debug! no-op, since a buffer closing between an action and its effect is a race rather than a guest bug. Appended last in the effect variant so existing case indices do not move. See org-capture-drafts.md §3 H1.

  • A plugin can CLAMP a position into a buffer (✅ CD.6b): host-services.clamp-position(buffer, at) -> option<position>. A line past the end becomes the last line; a byte past its line's end becomes that end, before the newline; none means no such buffer. No text crosses. It exists because apply-edit refuses a stale position with only a debug! line, and a capture writing a link back into the buffer it started from may find that buffer shorter, or closed. Read through a buffer store stamped into every store (set_buffer_store), pinned at boot by WiredSeams::buffer_store. An unwired host reads every buffer as closed. See org-capture-drafts.md §3 H8.

  • A plugin can open a picker ALREADY NARROWED (✅ CD.6a): open-picker-payload.query, emacs completing-read's initial input. Only a live source could seed its prompt before, through its own initial_query; the node pickers are static. The seed rides Editor::pending_picker_query from the open to the seat, where it wins over a live source's own initial query, and a refused open clears it so it cannot reach the next picker. Filtering is exactly as if the text had been typed. Appended last in the record. Roam's node insert passes the active region here (CD.6).

  • A plugin can SEQUENCE work after its effects (✅ CD.3d): effect.invoke-command(command-ref): the picker's invoke-command, as an effect. Editor::invoke_command_named serves both, dispatching an action with typed args and otherwise running an ex line. It exists because a guest's host calls run during its action, before any returned effect is applied, and a failed write stops its batch (CD.3c, cross-file-writes.md §8.0). So "delete the draft only once the entry is filed" has to be an effect after the write.

  • A plugin can ASK whether a write would land (✅ CD.3b): host-services.can-write-file(path). It runs the boundary's own EffectAuthorizer::permits_write, not a copy, plus the applier's checks (not a directory, directory present, existing file readable and not read-only). Capture asks it at open, emacs's order, so a bad target is reported before anything is typed. See org-capture-drafts.md §3 H5.

  • A plugin can DELETE a file it may write (✅ CD.3): host-services.delete-file(path) -> result<_, string>, read-file's peer and host-side for the same reason: a grammar action runs on the synchronous linker, where a guest remove_file panics inside WASI's sync shim. Gated on fs:write (a read grant is not enough) with grant_permits_write, which canonicalizes the file itself first, so a symlink out of the grant is refused. Absence is ok; a directory is refused. The integration test (tests/delete_file_seam.rs) runs the delete through the sync trampoline, the only place a guest-side implementation would have failed.

  • A plugin can SEED a FILE it opens (✅ CD.2): open-buffer-at-payload gained content and activate-minor, the file-backed peers of the synthetic-buffer fields below, for the same OC.7a reason. content applies only when nothing is on disk, and the file is not already open. It is applied as an edit before the actor spawns, so the buffer is modified and :q guards it. It rests on :e opening a missing path as an empty [New] buffer, as vim does; before CD.2 that open failed, and so did lattice newfile. All three appliers (TUI, GPUI, the off-renderer drain) call one host method, Editor::open_buffer_at.

  • A plugin can SEED the buffer it opens (✅ OC.7a): open-synthetic-buffer-payload carries content, cursor and activate-minor beside name / mode-id.

    This closes a hole a guest could not work around. A NATIVE mode fills its own synthetic buffer from on_activate; the modes seam is declaration-only — a guest exports register-modes and nothing else — so a plugin mode has no such hook, and a guest emitting Effect::OpenSyntheticBuffer got a buffer it could never put one character into. The other routes are closed too: effect.apply-edit names a buffer-id this effect does not hand back, and event handlers act through APIs rather than returning effects.

    content is applied before the buffer is shown, so the first painted frame is the finished one, and is ignored when the buffer already existed — a re-open must not overwrite what the user has typed into it. activate-minor mirrors spawn-terminal-payload's field of the same name: the interesting behaviour rides a minor on a general-purpose major, which is how org's capture buffer is an org-mode buffer that also answers C-c C-c. All three are optional, and omitting them is the pre-OC.7a effect exactly.

  • A plugin picker source can style its rows (✅ PS.1): raw-candidate.display-spans, a list of {start, end, slot} over display.

    display_spans was host-only on the reasoning that render-time fields are re-derived host-side — true for a grep hit, where the host has the path and the line, and false for a row that is not a line of a file. An org-roam node title is a headline's TEXT with no stars and no source line, so there was nothing to re-derive from and every plugin picker row rendered plain.

    slot is a name, not a style: a Style is a closed enum plus an interned element id and neither crosses an ABI, but a name resolves through the same path a highlights.scm capture takes — builtin categories first (so a plugin cannot redefine keyword editor-wide), then the theme registry. A guest's row is therefore coloured by the same vocabulary and the same active colourscheme as the buffer it came from. Follows annotation-custom.slot, already the string→theme channel.

    Spans are validated at the boundary and an invalid one is dropped, not clamped — clamping paints a run the guest never asked for. The UTF-8 boundary check is the one that makes this non-optional: slicing mid-codepoint panics, and a guest computing offsets in chars instead of bytes is an ordinary bug that must not take the picker down.

  • A conceal rule can style what it leaves visible (✅ OL.1): conceal-rule.slot, per RULE.

    Conceal is general and most rules hide punctuation; only a rule whose remainder means something declares a style, so adding a rule for something else cannot accidentally paint it. What it paints is the visible remainder of each match — see conceal.md, which explains why that unit rather than a named capture group.

  • Durable plugin storage (✅ OR.1): host-services.store-put / store-get / store-delete / store-keys(prefix) / store-generation. Opaque bytes under guest-chosen strings; the host never interprets either. Scoped to the plugin's private data dir by manifest id.

    Scoping by id rather than by instance is the whole point, not a caching detail. There is no single guest instance: spawn_event_plugin, spawn_config_plugin, spawn_help_plugin, spawn_dashboard_sections and instantiate_grammar_plugin each build a separate wasmtime::Store with separate linear memory, so a plugin that keeps an index "in guest state" keeps N copies, drifting — and the drift is invisible, because every instance stays internally consistent while answering a different question from its neighbour. The host holds one PluginStore per manifest id and hands the same handle to every PluginState, so a write on the event seam is visible to a read on the grammar seam immediately: no flush, no reload. store-generation is the freshness signal that makes one-writer/many-readers work across those stores — it is host-side, so a reader sees the writer's bump without sharing memory with it.

    Keys are not paths. Nothing derives a filesystem location from a key (the store length-prefixes them into one file), so there is no traversal to defend against and no path sanitiser to keep correct. f/../../etc/passwd is data.

    Gated on a new state:write capability, granted at either trust tier. Its own capability rather than a corollary of fs:write because the two answer different questions: fs:write is reach over the user's files, which persisting an index needs none of. An ungranted plugin gets err from put/delete (naming the capability), none from get and an empty keys — the config_registry / event_emit degradation posture, never a trap.

    Five functions on host-services rather than a store interface of its own. A component's import set is fixed for the whole artefact and must resolve on every linker it is instantiated against, including the grammar seam's sync one; a new interface is a new import each world must declare and both linkers must wire, and a miss there fails the WHOLE component at instantiation rather than degrading one seam (OC.2 did exactly that with one logging::log call). host-services is already imported everywhere a store is wanted and already wired on both linkers, so the store- prefix buys structural impossibility where store.put would have bought a nicer name.

    The failure policy is agenda_cache.rs's, promoted from an agenda special case to a primitive: temp-file-and-rename, a schema version that refuses an older shape, a size cap that clears wholesale, and degradation to empty on any corruption — never to a partial read, which is how a store starts serving plausible nonsense.

    Writes are not left to Drop. The handle is cloned into tasks on the process-wide runtimes, which are statics and never dropped, so a destructor-only flush meant a store with fewer than 64 changes was never written: the project plugin's remembered list was lost on every restart. A change is written at once when the last write is at least a second old (so a burst writes at most once a second, plus every 64 changes), and the binary calls flush_plugin_stores() as it exits, before GPUI's quit on that peer, since on macOS quit may not return to main. The on-disk format is a hand-rolled length-prefixed frame rather than a serde format because the payload is bytes and every serde encoder in the tree writes a Vec<u8> as an array of tagged integers.

  • Directory watch (✅ OR.2): host-services.watch(path) / unwatch(path), gated on the same fs:read (or fs:write) grant walk and read-file check — a watch reveals filesystem activity, so it is the same authorization question and gets the same answer from the same function.

    A watcher, not a save hook, and that is a UX argument rather than an architectural one: the corpus a plugin indexes is edited from outside lattice (emacs writes a note, a git pull lands twenty, a sync daemon rewrites a directory). A save hook observes none of those, and the symptom — an index missing files you know you wrote — reads as data loss rather than as a stale cache.

    Delivery is the files-changed arm of the WIT event, through the events.subscribe a plugin already uses, so it arrives on the plugin's own actor task — off the keystroke path, and without one. Event::FilesChanged carries the host-issued plugin id and the delivery actor drops any batch whose id is not its own: the bus is a broadcast and a watch is a capability, so without that line a plugin holding fs:read over one directory would learn what changed under another plugin's. The id never crosses to a guest, because by then it is always the guest's own.

    Coalesced. Each watch owns one thread that blocks for the first event, drains everything arriving within a 300 ms quiet window, deduplicates and publishes ONE batch — so a git pull rewriting 200 files is a handful of guest calls carrying many paths, not 200 carrying one. A thread rather than a task because this crate deliberately owns no runtime and a thread blocked on recv_timeout needs none; per-watch rather than shared because a shared one would need its own routing table, shutdown protocol and teardown to save a thread that spends its life blocked. The batch cap bounds event size, not coverage: an over-large burst spills into the next batch rather than dropping paths, because a cap that silently discarded them would leave an index quietly wrong, which is the failure the seam exists to prevent.

    A watch lives on the guest's own PluginState, not in a host registry keyed by plugin. Its lifetime is then exactly the plugin instance's, so unload, quarantine and the actor's channel closing each stop it with no teardown wiring anyone can forget to write; unwatch is a remove on that map. A removal is reported as a change (the consumer stats the path), because an index that cannot see deletions offers destinations that no longer exist.

    A watch that cannot be armed is a typed Err naming which of the four refusals happened — outside the grant, no event bus on this seam, a missing path, or a watcher the platform refused — and never fatal: the plugin falls back to indexing on boot plus an explicit resync, which is degraded and honest rather than appearing to work and going stale.

  • Host-minted ids (✅ OR.3): host-services.new-uuid() -> result<string, string>, a random (v4) UUID, uppercase, canonical 8-4-4-4-12.

    Host-side for read-file's exact reason. :org-roam-id-create mints an :ID: for the headline at point, which is a grammar action: it runs on the grammar seam's synchronous linker, where wasmtime-wasi's sync shim blocks on a runtime internally and panics on a thread already inside one. A guest minting through wasi:random would work perfectly on the async picker path and take the plugin down on the grammar path — correct in every test that builds its own context, broken in the editor. The test therefore mints through the sync trampoline; one that minted from an async seam would pass against the implementation this replaces.

    Uppercase because the reference corpus is (macOS uuidgen, which org-id shells out to). Consumers must still compare ids case-insensitively: org is not consistent about case across platforms, and a link that fails to resolve over letter case looks exactly like a missing note.

    Ungated, like local-utc-offset-seconds: it names no path and reaches no resource, and gating it would mean a plugin with no filesystem grant cannot give its own records identities.

    result, not a degraded value — the one call on this seam that earns it. Its neighbours answer 0 when unwired on the argument that a legible wrong answer beats a fabricated one, but those values are read. An id is written, into the user's own file, as an :ID: that outlives the session and every other tool's view of that note; a guest handed an empty string on entropy failure would write an empty drawer and nothing would ever say so. Not a panic either — a host function unwinding through wasm frames aborts the process, which is worse than a plugin reporting that it could not mint an id.

    Sixteen bytes from getrandom with six bits pinned, rather than the uuid crate — the precedent lattice-ai's MCP session token already set ("no heavier rand/uuid dep pulled for one token"). A uuid dependency would buy parsing, a Uuid type and versions 1/3/5/7, none of which crosses a WIT string. Rejected: an org.utc-offset option, which makes the user maintain what the OS knows and breaks twice a year.

  • Modes: ModeRegistry::register(Arc<dyn DynMode>). Each plugin mode also gets an auto-generated :<mode-name> toggle ex-command, built by the shared helper lattice_grammar::registry::mode_toggle_ex_command_spec — the same helper boot's register_mode_toggle_commands uses for native modes — registered under SourceLayer::Plugin(id) by the loader's drain_mode so unload reverses it. A plugin mode is therefore togglable by :<mode-name> and shows in completion / :describe-* / :list-* exactly like a native mode.

  • Config: ConfigRegistry::register_with_typeid::<T> — the plugin declares name/type-label/default/doc; option values round-trip as strings (the OptionType parse/format contract, option_type.rs:37), so the config WIT carries only name + string-value + type_label + default-string and the host synthesizes the OptionType. Registered into the same store core options live in, so :set, :describe-option, and :customize treat plugin options uniformly. Change events flow via the existing EventPublisher sink (registry.rs:141).

The command and mode registries are runtime-mutable and read live. Both are arc_swap::ArcSwap handles: a plugin's grammar/command + mode contributions RCU-drain into them at load (and reverse on unload). Every consumer reads the live handle, so a plugin's contributions are visible everywhere native ones are — the dispatcher, :-command-name completion, and every introspection command (:describe-command, :apropos, :list-commands, :describe-key, :describe-mode, :list-modes). Completion is the one surface that needs a nudge: it caches its candidate list, so CommandRegistry carries a generation() counter bumped on every register/unregister, and the :-command generator keys its cache on it — a plugin load/unload changes the key and the fresh command set regenerates, with no manual flush. Net: a plugin-contributed command or mode is indistinguishable from a native one at every user surface.

Standing-rule check (mode ownership): every seam keeps both the contribution (spec/ keymap) and the handler body with the plugin that owns it — the host stays a thin bridge exposing generic primitives. No Editor::do_<plugin_x> method, no new Action variant per plugin: the plugin contributes ids via the registry and the handler bodies live in the component. This is the acid test from CLAUDE.md — a new plugin adds ZERO Editor:: methods.

5.13 Plugin-API introspection catalog (Facet A)

The WIT package above is the plugin API. Rather than re-document it by hand, a derived catalog answers "what CAN a plugin do" for both audiences (in-editor users via :describe-plugin-api, and plugin authors via a JSON/markdown export). The contract:

  • Single source of truth = wit/, parsed at build time. A wit-parser build step walks the package into a PluginApiCatalog { interfaces, worlds }; each ApiInterface carries name + doc + Vec<ApiFunction> (name + doc). Deriving-not-authoring is the whole point: the catalog cannot drift from the interface it documents — a new function or /// comment surfaces the moment the WIT lands. (Runtime component reflection was rejected: it needs boot-wiring and only sees loaded plugins, which is Facet B, not the static "what's possible" surface.)
  • Two fields the parser can't infer, host-authored:
    • direction (GuestExport / GuestImport / Both / TypesOnly) — world-derived. Caveat: use foo.{ty} registers an import edge indistinguishable from a callable import foo;, so an interface is GuestImport only when imported and non-empty of functions; a zero-function type bag (types) is TypesOnly. Descriptive hint, not a contract.
    • capability (Fs / Net / Proc / None) — which OS capability a seam requires, which the WIT syntax can't express. One annotation row per interface; a test asserts the annotation covers every parsed interface, so a new seam forces a deliberate capability decision before it ships. Only host-services reaches the OS today (Fs, its walk).
  • Crate placement = a wasmtime-free leaf lattice-plugin-api. wit-parser is a build dependency; the lib's only runtime input is std. So lattice-host deps the catalog for the introspection ex-commands without pulling the WASM runtime into the host — the no-per-frame-WASM invariant (§7) is preserved structurally, and the heavy lattice-plugin-host stays out of the host until Phase-8 boot-wiring. The test-only trampoline-fixture world is excluded from the catalog (it is not an API). Slice: PI.1 (see the slice plan). Facet B — the human-readable Plugin(id)→name provenance label shown in :list-commands — is PI.3+; the listing itself already includes plugin commands live (only the friendly plugin-name column is deferred, not the entries).

Multibuffer views: who owns one, and the two ways rows arrive

A plugin can own a view outright (MV.1). It declares N views through multibuffer-view-registry — each with its own id, buffer-name, view-mode and input model — and the host registers a ProviderViewOpener per declaration, so app-effect::open-provider-view and the view's gr reach it by the guest's own name. Before MV.1 this was impossible: ProviderViewOpener is a Rust closure, so a view existed only where the host had hand-built a provider, and the agenda was the only one that got built.

Two input models, because they are different cost models rather than two spellings of one. Pick by asking whether answering requires reading many files' contents.

discoverywho reads the fileguest capabilityordering
pullthe guest already knows (an index, a computed set)nobody, or the guest under its own grantfs:read only if it wants textthe guest returns FINAL order
scanthe host walks by the extensions the scan sources declarethe host, once, handing over text + treenone at allthe host stable-sorts on the guest's sort-key

The asymmetry in ordering is deliberate and turns on who can see the whole set at ordering time: a scan guest holds one file and cannot know where its rows land once the others interleave, while a pull guest computes the entire set in one call. Forcing a key on it would make the host re-sort what is already ordered and would lose orderings that are not numeric.

The scan model's advantages are measured, not stylistic: the host must read each file anyway to build the source document, so reading and parsing once buys the guest a 1–2 ms parse for a 217 ns copy (benches/agenda_scan_input.rs) and means it needs no filesystem capability. Choosing pull for a scan-shaped question costs a capability and ~50 µs/file of boundary traffic; choosing scan for a pull-shaped one walks the project to re-derive what an index already holds.

What the host still owns, and will keep owning. The buffer, the excerpt machinery, the walk, the batched reads, the sort, the group runs, and the capability gate on every path an excerpt names. A guest-supplied path is authorised at the boundary against the plugin's fs:read grant, symlinks resolved first — a plugin cannot borrow the host's filesystem authority by naming a file it could not open itself.

Whether a view is the right shape at all is a separate question, and the rule is: do you act on the rows in place, or do you go somewhere? The agenda is where you change TODO states and reschedule, so edit-propagates-to-source is the feature and it is a multibuffer. "What links here, take me there" is navigation, and that is a picker.

Design: plugin-multibuffer-views.md.

The completion seam, and the round that drives it

PH7.6 landed the export, the actor, the AsyncCompletionSource adapter and the loader's carrier mode. Nothing called generate until OR.7: the host's only async-completion driver looked up gen:lsp-completion by id and returned early unless an LSP server was attached, so a plugin source registered correctly and contributed nothing — in a way no test caught, because every test asserted on registration. Four things a source author should know as a result.

The round is a fan-out. Editor::do_async_insert_completion_requests spawns one task per enabled async source. Each reports independently and each round replaces only its own sender's rows, so a fast source's candidates appear without waiting on a slow one and arrival order does not matter. LSP is one participant among N; its preconditions (mode enabled, buffer URI, attached server) drop LSP from a round rather than dropping the round.

The guest decides whether it applies. generate-context carries line-before-cursor and language alongside prefix. A source that fires only in certain syntax reads the line and answers for itself; the host does not grow a per-source context flag the way path-context is one. Declining is ok([]), never err — an err is logged as a broken source, and "not applicable here" is the normal case.

Matching and inserting are not the same string. raw-candidate.insert-text is what lands on accept when it differs from the matched text, and completion-source-spec.accepts-non-word-query keeps the popup alive past a space for a source completing phrases rather than identifiers. Both default to the previous behaviour; both were previously reachable only through host-owned kind_id hatches a plugin cannot use.

What still does not cross. The replacement region is [anchor, cursor], computed by the host at popup-open from a word/path scan, and a source cannot widen it. A source whose insert would cover more than the prefix must verify that the anchor already covers what it means to replace — line-before-cursor is enough to check — and decline otherwise.


6. Capability & security model

  • Manifest-declared capabilities. Each plugin ships a manifest declaring requested capabilities: fs:read:<prefix>, fs:write:<prefix>, net:http:<host-allowlist>, proc:spawn, state:write (OR.1 — the host-services store-* calls), plus editor capabilities via the existing CapabilitySet (Mode::required_capabilities). The runtime enforces via wasmtime's WASI configuration — a plugin's Store is built with exactly its granted wasi:filesystem/wasi:http view.

    Refined (PH7.2, 2026-07-01): WASI-layer enforcement covers filesystem only. Each Store's WasiCtx is built from the grant's fs:* preopens (data dir writable + each granted prefix at its declared perms); a path outside the grant is unreachable because WASI has no ambient authority. net:http and proc:spawn are carried on the CapabilityGrant as metadata but are not wired into the raw WASI view — a net:http grant does not enable raw wasi:sockets, and proc:spawn does not enable subprocess spawning at the WASI layer. Both are serviced (and allowlist-/tier-checked) by the capability-gated host-services calls (PH7.3+), which read the same grant. Enabling raw sockets/subprocess for those grants would leak authority the host-services check exists to contain. So the original "wasi:filesystem/ wasi:http view" is, in v1, a wasi:filesystem view; net/proc enforcement lives at the host-services seam, not the WASI view. See the PH7.2 slice + capability.rs. ~ in an fs: prefix expands at the parse (2026-08-31). A grant is matched by prefix against a canonicalized target, and ~/org canonicalizes to nothing — so an unexpanded tilde did not narrow a grant, it voided it, and every write was refused with "outside the plugin's granted paths". That message is true and useless: it reads exactly like a capability the user never declared, when in fact they declared it and it could not be understood. Expansion goes through lattice_core::home::expand_tilde, so a Windows host resolves %USERPROFILE%. ~user is left verbatim rather than expanded against the running user's home, which would silently grant a path the manifest never asked for.

  • HOME is the one environment variable a guest gets. Otherwise the WASI context is import-free by design — no stdio, no PATH, no USER, no shell. HOME is supplied because a guest reads path options the user wrote (org.roam-directory = "~/notes"), and with no environment it cannot expand the tilde: the path reaches the host verbatim, resolves to nothing, and the feature reports an empty corpus rather than a bad path. The user's config is then simply not portable across machines. This concedes no authority — HOME is a string, knowing a path does not mount it, and every filesystem access still goes through the preopens. A plugin that learns the home directory and asks to read it is refused exactly as before.
  • Per-plugin data dir. ${XDG_CONFIG_HOME}/lattice/plugins/<plugin-name>/data/ is mounted — beside the plugin itself, and never inside an install prefix (see plugin-data-location.md); writes outside it require an explicit broader grant (§5.5.6 prerequisite 2).
  • Trust tiers. Bundled plugins inherit the editor's trust (capabilities pre-granted at build time, no prompt). User-installed plugins prompt for consent on first install (§5.5.6). proc:spawn is bundled-only in v1; user plugins ship pre-built binary recipes, not source-build spawn paths (§5.5.6 prerequisite 4).
  • Keymap write-gate. Already enforced: KeymapCapability::MinorMode permits writes only to KeymapLayer::MinorMode(_)/Buffer, denies Builtin/MajorMode/User (registry.rs:132). A plugin cannot shadow universal vim grammar.
  • Provenance is host-issued. No public API accepts a SourceLocation; the host stamps SourceLayer::Plugin(id) for every plugin contribution (source.rs:38). A plugin cannot forge a builtin/user provenance.

UX (higher court): capability prompts are the one place plugins touch consent UX; they must be legible ("git-gutter wants: read files under this repo") and never block boot — a denied capability degrades the plugin gracefully, it does not fail the editor.


7. Performance contract & CI gates (the Phase-7 exit)

The Phase-7 exit criterion (design.md §13) is: "a WASM plugin replicates the file picker without host changes; CI enforces overhead budgets." The budgets (§5.5.2), each gated in CI with a ratchet that only moves down:

Call classp50p99
Typed host function call< 100ns< 500ns
Grammar-extension round-trip< 1μs< 5μs
Status / gutter segment update< 10μs< 50μs
Picker filter pass per item< 500ns< 2μs
Major-mode event handler< 50μs< 250μs

Plus the cold-start budget (50 lazily-loaded plugins < 30ms; first-paint plugin work < 5ms).

Known gap — the picker row is currently un-gated (found 2026-08-27). The ratchet enforcing it, picker_init_round_trip_stays_within_ceiling, was removed along with the fuzzy-finder validation plugin (0d068f9b) and never re-pointed at the surviving tests/fixtures/picker-guest. So the guest→host picker path is benchmarked by nothing today, while the other five rows still gate in lattice-plugin-host/tests/perf_ratchet.rs. Recorded here rather than in a slice plan because both plans that owned it are complete and archived — this fragment is what stays. Re-pointing the ratchet at picker-guest is the fix; it is small, and it is the one row of this table CI cannot currently defend. The no-per-frame-WASM rule is absolute: the renderer never calls into a plugin on the UI tick. Plugins compute on triggers; the renderer reads cached results (the same rule that already governs gutter_decorations — the host builds the decoration snapshot off the render path and the renderer reads per-line values).


8. Lifecycle & error handling

  • Activate / deactivate / on-event are the guest's three lifecycle exports (plugin interface, mirrors design.md §9.4).
  • Crash isolation. A trap (panic, fuel exhaustion, epoch deadline, capability violation) kills only the offending Store's task, fires PluginCrashed { plugin } on the event bus, and quarantines the plugin (no auto-restart in v1). The editor, actor, other plugins, LSP, and UI are untouched. This is the §7.5 "plugin crashes mid-render" contract applied to all plugin work.
  • Graceful degradation everywhere (the four-artefact "graceful error handling" clause): unknown-source / init-error → echo, picker stays closed; grammar apply trap → motion is a no-op, logged; event handler fuel-exhaust → skipped, save/quit proceeds; capability denied → plugin loads with reduced function + a notification. Never a panic on the hot path, never a silent swallow.
  • Teardown reverses every contribution by provenance. On unload the host RCU-snapshots the command / mode / picker / decoration registries and reverses this plugin's entries. CommandRegistry::unregister_plugin(id) runs unconditionally — it removes every SourceLayer::Plugin(id) command in one pass (grammar contributions AND the :<mode> mode-toggle ex-commands), with no per-seam "did I register a command?" gate: the provenance is the token. Modes reverse via ModeRegistry::unregister + their gated keymap layer; options / subscriptions / picker + decoration sources by their returned tokens. Every reversal is idempotent, so a double-unload (or unload after a partial crash) is safe.
  • Reload seam. The host supports instance teardown + re-instantiate (Guard-Drop semantics already model mode teardown; §5.10.5). This is the hook the deferred config-as- WASM (init.rs hot-swap, §5.12.4) and the plugin manager will consume.

9. Reference validation plugin: fuzzy-finder

plugins/fuzzy-finder/ — a WASM component that registers a files picker source through the picker-source WIT interface and nothing else. It exercises: component instantiation, the buffer/host-services resource callbacks (walk workspace via a host fs call), the owned- snapshot projection (§4.2), the async result carrier (§4.3, Stream for a large tree), the typed PickerAcceptOutcome::OpenFile (§4.4), and MRU-for-free (the picker's MRU pipeline keys off RoutingPayload identity regardless of source origin). Success = the native files picker is unregistered and the WASM one is registered, and every picker test plus the overhead benches pass with zero host changes. If any primitive is painful to write, the WIT needs to grow (§5.5's second principle: the API grows from real plugins).


10. Testing strategy (four-artefact discipline)

  • Unit: WIT type round-trips (native ↔ WIT record ↔ native), capability-set enforcement, fuel-exhaustion trapping, cache-key invalidation.
  • Integration: the fuzzy-finder end-to-end through Editor::boot — register, open, filter, accept, MRU-record — asserting parity with the native picker's tests.
  • Bench (CI-gated ratchet): every row in §7, plus cold-start with N synthetic plugins. Runtime-responsiveness coverage (not just throughput) per the CLAUDE.md rule: assert plugin work lands on the multi-thread runtime, never the current_thread actor.
  • Property/fuzz: malformed component bytes, malformed WIT payloads, adversarial fuel/epoch timing — the host must reject/quarantine, never crash.

11. Rejected alternatives

  • A second in-process scripting language (Lua/Scheme/Rhai). Doubles the API surface and binding maintenance, splits the ecosystem into "plugin-shaped" vs "script-shaped." Rejected on paramount #2 (design.md §10). One substrate, one toolchain.
  • Per-frame WASM (renderer calls plugins on the UI tick). Direct paramount-#1 violation; forbidden by §5.5.2 rule 7. Plugins emit data, the renderer reads it.
  • Copying the rope across the boundary. Violates paramount #1 on large buffers; resource handles + slice callbacks instead (§4.2).
  • A generic opaque effect blob instead of mirroring the closed Effect enum. Smuggles an untyped seam into the highest-risk area (§14); rejected for a typed variant (§4.4).
  • A parallel plugin registry. Would split every surface into native-vs-plugin code paths, violating "everything is a buffer / one dispatch." Plugins register into the same registries via the existing Arc<dyn> insert seams (§5).
  • Letting the guest supply its own SourceLocation. Forgeable provenance; the host stamps SourceLayer::Plugin(id) (§6).
  • A single shared Store for all plugins. Couples failure domains, serializes plugin CPU, breaks per-plugin capability/fuel scoping (§3).

12. Deferred to later fragments (out of Phase-7-proper scope)

Each gets its own design fragment + slice plan, cross-referenced here:

  • Modes-as-components (full) — Phase 8. The modes WIT is designed here (§5) but shipping bundled major/minor modes as components is Phase 8.
  • Bundled-plugin manager & LSP server manager — Phase 8 (design.md §5.5.6). The LSP server manager is the lighthouse that validates the WIT is sized correctly.
  • Config-as-WASM (init.rs) — post-Phase-7 (design.md §5.12.2–5.12.4). Consumes the host's reload seam (§8) and a boot-capability Config facet.
  • Live-eval (*scratch:rust*rustc → dynamic load) — Phase 10 (design.md §10).
  • ABI-freeze / versioning policy — the WIT is unstable until ≥3 real plugins have exercised it (design.md §14 grammar-extension row; §15 Q7). SemVer only post-1.0.

13. Open questions (tracked; resolve during slices)

Mapped to design.md §15: Q7 (Component-Model versioning policy) — defer to §12 ABI-freeze fragment. Q9 (live-reload of plugin-defined modes) — reload seam exists (§8); mode-specific reload is Phase 8. Q15 (tree-sitter query API shape — one-shot vs cursor iterator, range scoping, query caching) — settle in the host-services slice. Q16 (async-task host primitive name/shape — spawn-task vs structured-concurrency) — settle in the runtime slice. Q17 (AOT cache invalidation key) — resolved in §3 (four-part hash). New: the streaming-result WIT shape (guest-push result-sink resource vs host-poll next-batch) — settle when the fuzzy-finder Stream path lands (§9).


14. Cross-references

  • Spec: design.md §3.1 (core/plugin split), §5.5 (subsystem), §5.10 (events), §5.12 (config), §9 (WIT sketch), §10 (tiers), §11 (layout), §13 (roadmap), §14 (risks), §15 (open Qs).
  • Exercised trait seams: lattice-picker/src/source.rs, lattice-completion/src/{traits,candidate,registry}.rs, lattice-grammar/src/{registry,dispatcher,command,effect,args,source}.rs, lattice-runtime/src/events.rs, lattice-mode/src/{mode,contributions,services,registry}.rs, lattice-keymap/src/{trie,registry,contribution}.rs, lattice-config/src/{registry,option}.rs.
  • Boot composition + subsystem-install seam: lattice-host/src/editor_boot.rs:225 (Editor::boot), lattice-host/src/boot_context.rs (BootContext), lattice-mode/src/subsystem_boot.rs:51 (SubsystemBoot — the surface plugins install through).
  • Slice sequencing: docs/dev/operations/slice-plans/archive/plugin-host.md.