Skip to main content

lattice_plugin_host/
lib.rs

1//! Plugin host — the WASM Component Model extension substrate (Phase 7).
2//!
3//! Design fragment: `docs/dev/architecture/plugin-host.md`. Slice plan:
4//! `docs/dev/operations/slice-plans/plugin-host.md`. Spec: `design.md` §5.5.
5//!
6//! **PH7.1a — async runtime core.** The host now runs the canonical async
7//! ABI (`design.md` §5.5, fragment §3): the engine has `async_support`, so a
8//! plugin's lifecycle exports are `async` and a host call suspends the WASM
9//! stack rather than pinning an OS thread. Each call runs under two hard
10//! budgets — a **fuel** cap (total work) and an **epoch** deadline
11//! (wall-clock) — and either, on exhaustion, traps *cleanly*: the offending
12//! call returns a typed [`PluginHostError::Trap`], the [`Store`] is untouched
13//! by any other plugin, and the host stays live. A background epoch-ticker
14//! thread bumps the engine epoch so the wall-clock deadline actually fires.
15//!
16//! The lib owns no async runtime: methods are `async fn`, so the *caller*
17//! (the editor's multi-thread pool — never the `current_thread` actor)
18//! drives them. Running two plugins on two tasks runs them on two cores.
19//!
20//! **PH7.1b — module cache + lazy instantiation.** The AOT (Cranelift) compile
21//! of a component is cached on disk (via wasmtime's own cache, keyed on bytes +
22//! compiler config + target + wasmtime version), so a second launch reuses the
23//! cached module instead of recompiling. Lazy instantiation is *structural*
24//! here, not a new type: [`PluginHost::compile`] loads/caches a [`Component`]
25//! without instantiating it; the [`Store`] and instance are created only by an
26//! explicit [`PluginHost::instantiate`] call. When the contribution model lands
27//! (PH7.3+), that call is what a plugin's *first contribution invocation* will
28//! trigger.
29//!
30//! **PH7.2 — capability & security model.** Each plugin now instantiates
31//! under a [`CapabilityGrant`] computed from its [`PluginManifest`] and
32//! [`TrustTier`] (fragment §6). The grant is *enforced*, not advisory: each
33//! [`Store`]'s WASI view is built with exactly its granted filesystem preopens
34//! plus a private per-plugin data dir, so a plugin without an `fs:write` grant
35//! cannot reach a path outside its data dir at the WASI layer (WASI has no
36//! ambient authority). `net:http` / `proc:spawn` ride the grant as metadata for
37//! the capability-gated `host-services` seam (PH7.3+); they are deliberately
38//! *not* wired into the raw WASI view (see [`capability`]). The host also
39//! issues each plugin a monotonic [`PluginId`] and stamps
40//! [`SourceLayer::Plugin`] provenance from its own ground truth — a plugin
41//! cannot forge provenance (`lattice_grammar::source` has no public
42//! `SourceLocation` setter). See [`manifest`] and [`capability`].
43//!
44//! The end-to-end "a guest attempts a write and WASI denies it" proof lands at
45//! PH7.4 with the real `wasm32-wasip2` `fuzzy-finder` (the guest toolchain PH7.0
46//! deferred to that slice); PH7.2 proves the model at the host layer — grant
47//! computation, the grant→preopen mapping, provenance issuance — with the
48//! WASI-layer OS enforcement itself resting on wasmtime's tested guarantee.
49//!
50//! Still owned by later slices: every contribution seam (PH7.3+). The first
51//! consumer of the `plugin` lifecycle world is the user's `init.rs`; the no-op
52//! component the tests instantiate is the degenerate `init.rs`.
53
54pub mod boundary;
55pub mod boundary_app_effect;
56pub mod boundary_config;
57pub mod boundary_context;
58pub mod boundary_decoration;
59pub mod boundary_effect;
60pub mod boundary_event;
61pub mod boundary_grammar;
62pub mod boundary_picker;
63pub mod buffer;
64pub mod capability;
65pub mod completion_host;
66pub mod completion_source;
67pub mod completion_task;
68pub mod config_host;
69// TC.2 — the sticky-context producer seam. Trio mirroring `decoration_*`:
70// the bindgen world, the actor bridge, the native-trait adapter.
71pub mod context_host;
72pub mod context_source;
73pub mod context_task;
74/// CR.4: the `dashboard` guest→host section seam.
75pub mod dashboard_host;
76pub mod media_host;
77pub mod media_source;
78pub use crate::media_source::WasmMediaSource;
79pub mod decoration_host;
80pub mod decoration_source;
81pub mod decoration_task;
82// OM.A1: the `scanned-excerpt-source` guest→host producer seam.
83pub mod scan_host;
84pub mod scan_task;
85pub mod scanned_excerpt_source;
86pub use crate::scanned_excerpt_source::{WasmScannedExcerptSource, normalise_extensions};
87// XF.4: the fs:write gate for guest-returned effects, applied at the
88// boundary where the plugin's provenance is still known.
89pub mod effect_authorizer;
90pub use crate::effect_authorizer::EffectAuthorizer;
91pub mod error_parser_host;
92pub mod event_task;
93pub mod events_host;
94pub mod grammar_host;
95pub mod grammar_trampoline;
96/// CR.3: the `help` guest→host topic-registration seam.
97pub mod help_host;
98pub mod media_task;
99// LG.3c: the `language` guest→host registration seam.
100pub mod host_services;
101pub mod keymap_host;
102pub mod language_host;
103pub mod manifest;
104pub mod mode_host;
105pub mod multibuffer_view_host;
106pub mod multibuffer_view_task;
107pub mod picker_host;
108pub mod picker_source;
109pub mod picker_task;
110pub mod plugin_manager_host;
111pub mod scan_cache;
112// OR.1: the durable, plugin-scoped byte store the `host-services` `store-*`
113// calls act on. One per manifest id, shared across every seam's `Store`.
114pub mod plugin_store;
115pub mod teardown;
116// TR.2b — the `transient-source` guest→host keyed-menu seam. Trio mirroring
117// `picker_*`: the bindgen world, the actor bridge, the registry-builder adapter.
118pub mod transient_host;
119pub mod transient_source;
120pub mod transient_task;
121// TC.4 — the `theme` element-registration seam. Guest imports `register-element`
122// and the host inserts into the SAME registry builtins live in.
123pub mod sign_host;
124pub mod theme_host;
125pub mod trace;
126pub mod trampoline;
127pub mod tree_resource;
128// OR.2: the `host-services.watch` / `unwatch` seam — a debounced directory
129// watch whose batches are addressed to the plugin that armed them.
130pub mod ui_host;
131pub mod wake;
132mod watch_host;
133
134pub use boundary::WitBoundary;
135pub use capability::{
136    CapabilityGrant, FsGrant, GrantOutcome, PreopenSpec, TrustTier, build_wasi_ctx, grant,
137};
138pub use completion_source::WasmCompletionSource;
139pub use completion_task::{CompletionActor, CompletionClient};
140pub use context_source::WasmContextSource;
141pub use context_task::{ContextActor, ContextClient};
142pub use decoration_source::WasmDecorationSource;
143pub use decoration_task::{DecorationActor, DecorationClient};
144pub use manifest::{Capability, CapabilityParseError, ManifestError, PluginManifest, PluginSeam};
145pub use picker_source::WasmPickerSource;
146pub use picker_task::{PickerActor, PickerClient};
147pub use teardown::{PluginTeardown, TeardownRegistries, TeardownReport};
148pub use trace::{
149    Direction, HotGate, PluginTracePushed, PluginTraceRecord, PluginTracer, PluginTracerHandle,
150    TraceLevel, TraceOutcome,
151};
152pub use transient_source::{project_transient_context, spec_from_wit, transient_builder};
153pub use transient_task::{TransientActor, TransientClient};
154/// OC.2: the injected timer the wake seam sleeps on. The lib owns no runtime, so
155/// the caller supplies this (the loader's is backed by `tokio::time::sleep`).
156pub use wake::{Sleeper, SleeperHandle};
157/// The compiled component — the return type of [`PluginHost::compile`],
158/// re-exported so callers (the plugin loader) can name it without a direct
159/// `wasmtime` dependency.
160pub use wasmtime::component::Component;
161
162use std::path::{Path, PathBuf};
163use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
164use std::sync::{Arc, Mutex};
165use std::thread::JoinHandle;
166use std::time::Duration;
167
168use lattice_runtime::EventBus;
169
170use lattice_grammar::SourceLayer;
171use wasmtime::component::{HasSelf, Linker};
172use wasmtime::{Cache, CacheConfig, Config, Engine, Store};
173use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
174
175// Host bindings for the `plugin` lifecycle world, async per the canonical
176// ABI. The generated `Plugin` type carries async `call_activate` /
177// `call_deactivate` and an async `instantiate_async`.
178wasmtime::component::bindgen!({
179    world: "plugin",
180    path: "../lattice-wit/wit",
181    // Generate async `call_activate` / `call_deactivate` + `instantiate_async`
182    // for every export. (wasmtime 46 replaced the old top-level `async: true`
183    // with this per-function form; async is always available on the engine,
184    // so `Config::async_support` is a no-op and intentionally not called.)
185    exports: { default: async },
186    // NB: the `document` resource's host trait + `with`-mapping to
187    // `DocumentResource` land at PH7.3d, not here. bindgen only lets a `with`
188    // entry bind a resource that a *world function signature* references, and
189    // no signature takes a `document` until the picker-source `init(ctx)` seam
190    // (PH7.3d/PH7.4). `use buffer.{buffer-snapshot}` (in `plugin.wit`) emits the
191    // owned record mirror this slice projects into; the resource backing
192    // (`buffer::DocumentResource`) is ready to be `with`-mapped then.
193});
194
195/// Fuel granted for the (trivial) instantiation step, before per-call budgets
196/// take over. Generous so instantiation never trips the fuel trap; a real
197/// plugin's `activate` work is bounded by [`PluginBudget::fuel`] instead.
198const INSTANTIATION_FUEL: u64 = 1_000_000_000;
199
200/// How often the epoch-ticker thread bumps the engine epoch. The epoch
201/// deadline is expressed in ticks, so this is the wall-clock granularity of
202/// the deadline (≈1ms).
203const EPOCH_TICK_INTERVAL: Duration = Duration::from_millis(1);
204
205/// Per-call resource budget. Both limits are hard: whichever is hit first
206/// traps the call cleanly.
207#[derive(Debug, Clone, Copy)]
208pub struct PluginBudget {
209    /// Fuel (≈ units of work) a single lifecycle call may consume before it
210    /// traps with [`TrapKind::Fuel`].
211    pub fuel: u64,
212    /// Milliseconds of WALL CLOCK a single call may run before it traps with
213    /// [`TrapKind::Epoch`].
214    ///
215    /// OA.0b made that literal. It used to be enforced by counting deadline-
216    /// callback firings, which equals elapsed milliseconds only while the
217    /// guest executes guest code continuously — one epoch checkpoint crossed
218    /// per [`EPOCH_TICK_INTERVAL`]. A guest that calls host imports in a loop
219    /// is suspended for most of its wall clock and crosses far fewer, so the
220    /// budget stretched by the ratio of host time to guest time: an org agenda
221    /// scan ran 6.8 s against this set to 1_000 and never tripped.
222    ///
223    /// **The new accounting is stricter, and one consequence is worth
224    /// knowing.** Time the OS spends descheduling the thread now counts
225    /// against the call, where firing-count quietly forgave it. That is the
226    /// correct measure for the thing this guards — the editor was stalled
227    /// either way — but if a sync grammar contribution ever false-trips under
228    /// load, the fix is to raise [`Self::grammar`]'s deadline, NOT to go back
229    /// to counting firings. Fuel is the primary bound on that path by design.
230    pub epoch_deadline: u64,
231}
232
233impl Default for PluginBudget {
234    fn default() -> Self {
235        // Generous defaults: a well-behaved `activate` finishes far inside
236        // these. PH7.5 tightens them into CI-gated budgets.
237        //
238        // 5s for the same reason as `event()`: this covers the ASYNC seams
239        // (the scan producer, pickers, media, lifecycle `activate`), every one
240        // of which blocks in host imports whose duration it cannot control, and
241        // since OA.0b that time is charged to the guest. Fuel is the compute
242        // bound; this only has to stop a guest that blocks forever.
243        Self {
244            fuel: 1_000_000_000,
245            epoch_deadline: 5_000,
246        }
247    }
248}
249
250impl PluginBudget {
251    /// The Reflex-class budget for the **synchronous** grammar trampoline
252    /// (PH7.7c; plugin-host.md §7 + audit F1). Grammar `apply` / `parse_args`
253    /// run on the keystroke path (the PH7.7 fork), so — unlike the generous
254    /// lifecycle/async [`default`](Self::default) (~1s epoch) — a plugin
255    /// contribution must not stall the keystroke.
256    ///
257    /// **Fuel is the primary Reflex bound.** A grammar guest runs on the sync
258    /// linker with no async host import, so it cannot *block* (no I/O to await) —
259    /// it can only compute or spin, and both are fuel-bounded. `10M` fuel is
260    /// ~one display frame of compute (Cranelift-compiled), ample for a real
261    /// motion's arithmetic but a hard cap on a runaway loop; that is what keeps a
262    /// plugin motion off the keystroke's critical path.
263    ///
264    /// **Epoch is a jitter-proof wall-clock *backstop*, not the tripwire.** The
265    /// ticker granularity is 1ms ([`EPOCH_TICK_INTERVAL`]); a 2-tick deadline
266    /// false-positives when the OS deschedules the dispatch thread mid-call
267    /// (observed under a criterion warmup's millions of iterations). So the epoch
268    /// is set generously — it must never trip on scheduling jitter, and only
269    /// catch the pathological case fuel somehow misses (near-impossible for a
270    /// sync compute guest). A trap of either kind is caught by
271    /// the trampoline → the contribution is a no-op with a warn
272    /// ([`CommandError::Plugin`](lattice_grammar::CommandError::Plugin)), never a
273    /// hang. Armed before every guest call — distinct from the lifecycle/producer
274    /// budget by design (audit F1).
275    ///
276    /// **50ms was not jitter-proof, so this is 250.** A trivial org
277    /// `:org-clock-in` — read a line, rewrite it, emit an event — overran 50ms
278    /// and trapped whenever the machine was saturated (reproduced with one busy
279    /// loop per core). That is precisely the false-trip this deadline's own
280    /// prose said must not happen, and the fix it prescribed: raise the
281    /// deadline, never go back to counting firings.
282    ///
283    /// 250ms costs nothing real. Fuel remains the actual bound at ~one frame of
284    /// compute, so a runaway still dies on fuel in milliseconds; the epoch only
285    /// has to exceed the worst descheduling window, and 5× the observed failure
286    /// is that with room. The number is also no longer a cliff: since the strike
287    /// count in [`Quarantine::trip`], one overrun costs that call rather than
288    /// the plugin.
289    pub fn grammar() -> Self {
290        Self {
291            fuel: 10_000_000,
292            epoch_deadline: 250,
293        }
294    }
295
296    /// The budget for a plugin **event handler** (`on-event`, PH7.8c; §7 "major-
297    /// mode event handler"). Unlike grammar's Reflex budget, event delivery is
298    /// **off the keystroke path** (async, on the plugin's own actor task), and a
299    /// handler runs on the *async* linker — so it may legitimately `await` a
300    /// capability-gated `host-services` call. A tight sub-frame epoch would
301    /// false-trip such a suspend, so the **epoch is a generous backstop** (~1s,
302    /// the lifecycle default) and **fuel is the primary bound**: `100M` ≈ ~10
303    /// frames of compute, ample for a real hook (recolour a gutter, index a
304    /// symbol) yet a hard cap on a runaway loop. A trap is caught per-delivery by
305    /// the [`EventActor`](crate::event_task::EventActor) → the delivery is
306    /// skipped with a warn, the plugin stays subscribed, other subscribers are
307    /// untouched (§8). The marshalling+dispatch overhead itself is the CI-gated
308    /// `< 250µs p99` row (PH7.8d), distinct from this runaway guard.
309    ///
310    /// **5s, not 1s, and the reason is what the epoch measures.** Since OA.0b
311    /// the deadline counts wall time including the time the guest sits BLOCKED
312    /// in a host import — correct, because a guest looping over imports was
313    /// otherwise unbounded (an agenda scan ran 6.8 s against a 1 s deadline
314    /// without tripping). The cost is that a handler is now charged for host
315    /// work it cannot make faster: org's roam scan opens with one `walk` of the
316    /// corpus, and under a saturated machine that single import took 1106 ms —
317    /// so a healthy plugin trapped, and since a trap kills the instance
318    /// ([`Quarantine::trip`]) the whole index died with it.
319    ///
320    /// Raising it does not weaken what the guard is for. Fuel still caps guest
321    /// COMPUTE at ~10 frames, so a spinning handler dies in milliseconds
322    /// regardless; the epoch's remaining job is bounding a guest that blocks in
323    /// imports forever, and 5 s bounds that just as surely as 1 s. What it buys
324    /// is that legitimate work — which a guest already self-limits to 250 ms
325    /// per delivery — is 20× inside the budget instead of within noise of it.
326    pub fn event() -> Self {
327        Self {
328            fuel: 100_000_000,
329            epoch_deadline: 5_000,
330        }
331    }
332
333    /// The budget for a plugin **decoration producer** (`gutter-decorations`,
334    /// PH7.9). Like the event budget, decoration production is **off the render
335    /// path** (async, on the plugin's actor task, triggered by an edit / scroll /
336    /// diagnostic change) and the producer runs on the async linker (it may
337    /// `await` a `host-services` call — a git-gutter source reading the repo). So
338    /// the **epoch is a generous ~1 s backstop** and **fuel is the primary bound**
339    /// (`100M` ≈ ~10 frames of compute — ample for a real diff/annotate pass, a
340    /// hard cap on a runaway). A trap is caught per-call by the
341    /// [`DecorationActor`](crate::decoration_task::DecorationActor) → the trigger
342    /// yields no decorations and the cached snapshot keeps its prior value (§8, no
343    /// flicker). The §7 `< 50 µs p99` "segment update" gate is on the marshalling
344    /// + dispatch overhead, distinct from this runaway guard.
345    pub fn decoration() -> Self {
346        Self {
347            fuel: 100_000_000,
348            epoch_deadline: 1_000,
349        }
350    }
351
352    /// TC.2: budget for a `context-scopes` produce call. Deliberately the same
353    /// shape as [`Self::decoration`] and for the same reason — both are async
354    /// producers the host drives off the render path, so the guard is against a
355    /// runaway, not against latency. Context is the more expensive of the two
356    /// (a whole-buffer tree-sitter query rather than a per-line walk), and the
357    /// budget is generous enough that a legitimate query on a large file
358    /// finishes well inside it; `context.max-file-lines` is what bounds the
359    /// intended work, this is what bounds the unintended.
360    pub fn context() -> Self {
361        Self {
362            fuel: 100_000_000,
363            epoch_deadline: 1_000,
364        }
365    }
366}
367
368/// Why a lifecycle call trapped. Fuel/epoch are the *expected* runaway-guard
369/// outcomes; `Other` is any genuine wasm trap (unreachable, OOB, a guest
370/// panic).
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum TrapKind {
373    /// The call exhausted its fuel budget (a compute runaway).
374    Fuel,
375    /// The call ran past its epoch (wall-clock) deadline.
376    Epoch,
377    /// CG.4: the user pressed `<C-g>` while the call was running.
378    ///
379    /// Deliberately NOT a crash. Fuel/epoch/other mean the plugin
380    /// misbehaved and should be quarantined; this means the user changed
381    /// their mind, and quarantining a plugin for that would punish it for
382    /// being interruptible.
383    Cancelled,
384    /// Any other wasm trap (unreachable, out-of-bounds, guest panic).
385    Other,
386}
387
388impl TrapKind {
389    /// A short, stable machine label (`"fuel"` / `"epoch"` / `"trap"`) for the
390    /// [`lattice_protocol::Event::PluginCrashed`] payload and structured logs. Distinct from the
391    /// [`Display`](std::fmt::Display) impl's human sentence: subscribers match on
392    /// this without parsing prose, and it never changes with copy edits.
393    pub fn label(self) -> &'static str {
394        match self {
395            TrapKind::Fuel => "fuel",
396            TrapKind::Epoch => "epoch",
397            TrapKind::Cancelled => "cancelled",
398            TrapKind::Other => "trap",
399        }
400    }
401}
402
403impl std::fmt::Display for TrapKind {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        let s = match self {
406            TrapKind::Fuel => "out of fuel",
407            TrapKind::Epoch => "epoch deadline exceeded",
408            TrapKind::Cancelled => "cancelled by the user",
409            TrapKind::Other => "wasm trap",
410        };
411        f.write_str(s)
412    }
413}
414
415/// Typed error surface for the plugin host. No host path panics — every
416/// failure mode is a value here (the four-artefact graceful-error clause).
417/// `anyhow::Error` is wasmtime's error type; each variant carries it as
418/// `#[source]`.
419#[derive(Debug, thiserror::Error)]
420pub enum PluginHostError {
421    /// The wasmtime engine could not be built from the host config.
422    #[error("failed to build the wasmtime engine")]
423    Engine(#[source] anyhow::Error),
424
425    /// The epoch-ticker background thread could not be spawned (the OS refused a
426    /// thread). Surfaced rather than panicked — no host-construction path panics
427    /// (the "every failure mode is a value" invariant).
428    #[error("failed to spawn the epoch-ticker thread")]
429    EpochTicker(#[source] std::io::Error),
430
431    /// The component linker could not be populated with the WASI host
432    /// functions (PH7.2). A host-setup failure, surfaced rather than panicked.
433    #[error("failed to add WASI to the plugin component linker")]
434    Linker(#[source] anyhow::Error),
435
436    /// The on-disk module cache could not be initialised (bad directory,
437    /// I/O error). A caching failure must never fail the host — callers may
438    /// fall back to an uncached host — so this is surfaced, never panicked.
439    #[error("failed to initialise the plugin module cache")]
440    Cache(#[source] anyhow::Error),
441
442    /// Component bytes were malformed or failed AOT compilation.
443    #[error("failed to compile the plugin component")]
444    Compile(#[source] anyhow::Error),
445
446    /// The component compiled but could not be instantiated (a missing
447    /// import, or fuel exhausted during the start function).
448    #[error("failed to instantiate the plugin component")]
449    Instantiate(#[source] anyhow::Error),
450
451    /// A grammar plugin declared a malformed contribution spec (PH7.7c): an
452    /// `arg-spec` / `latency-class` / `surface-form` that could not cross the
453    /// boundary. Fails registration loudly (the spec is structurally wrong),
454    /// unlike a *runtime* `apply` failure, which degrades gracefully to a no-op.
455    #[error("plugin grammar spec is malformed: {0}")]
456    GrammarSpec(String),
457
458    /// A lifecycle export trapped. Fuel/epoch exhaustion and genuine wasm
459    /// traps all land here as a value — the call is a no-op with a typed
460    /// error and the host stays live.
461    #[error("plugin lifecycle export `{func}` trapped: {kind}")]
462    Trap {
463        /// The export that trapped (`"activate"` / `"deactivate"`).
464        func: &'static str,
465        /// What caused the trap (fuel / epoch / other).
466        kind: TrapKind,
467        /// The underlying wasmtime trap.
468        #[source]
469        source: anyhow::Error,
470    },
471
472    /// A guest call was routed to a per-plugin actor task
473    /// ([`picker_task`]) whose channel is closed — the task has ended and
474    /// dropped its `Store` (teardown, or a fatal error unwound the loop). The
475    /// call is a no-op with a typed error; the *caller* stays live. This is the
476    /// bridge's graceful surface for "the plugin is gone", distinct from
477    /// [`Trap`](Self::Trap) (the plugin ran but its call failed).
478    #[error("plugin actor for `{func}` is no longer running")]
479    PluginGone {
480        /// The export the caller was trying to reach (`"spec"` / `"init"` /
481        /// `"accept"`).
482        func: &'static str,
483    },
484
485    /// The plugin instance is quarantined (PH7.12): a prior lifecycle / callback
486    /// call trapped, tripping crash-quarantine, so this call short-circuits
487    /// *before* re-entering the dead `Store`. Distinct from [`Trap`](Self::Trap)
488    /// (the plugin ran and its call failed) and [`PluginGone`](Self::PluginGone)
489    /// (the actor's channel is closed): the actor is still alive, but the instance
490    /// is dead-until-reinstantiation. The `PluginCrashed` event already fired on
491    /// the first trap; this variant is the quiet no-op every subsequent call
492    /// returns until a reload (PH7.12b) mints a fresh instance.
493    #[error("plugin is quarantined; `{func}` short-circuited after an earlier crash")]
494    Quarantined {
495        /// The export the caller was trying to reach.
496        func: &'static str,
497    },
498
499    /// A value crossing the picker boundary could not be converted between its
500    /// WIT mirror and the native type — a malformed record the guest returned
501    /// (e.g. a non-UTF-8 path, §4.4), or a native value with no WIT
502    /// representation. Carries the boundary layer's message. Never lossy: a
503    /// conversion that cannot be represented is this typed error, not a silent
504    /// drop.
505    #[error("picker boundary conversion failed: {0}")]
506    Boundary(String),
507}
508
509/// Re-arm a `Store`'s fuel + epoch budget before a call, so each call gets a
510/// fresh allowance rather than sharing a running total. Shared by the
511/// [`LoadedPlugin`] lifecycle path and the [`picker_task`] actor loop.
512pub(crate) fn arm_store(
513    store: &mut Store<PluginState>,
514    budget: PluginBudget,
515) -> Result<(), PluginHostError> {
516    store
517        .set_fuel(budget.fuel)
518        .map_err(|e| PluginHostError::Instantiate(e.into()))?;
519
520    // CG.4: refresh the token this call may be cancelled by, ONCE, here.
521    // The registry is behind a mutex; the token is a plain atomic. Taking
522    // the lock per call and polling the atomic per tick is the split
523    // `foreground_cancel`'s own docs prescribe.
524    //
525    // Snapshotting also fixes the semantics: a call is cancelled by the
526    // operation armed when it STARTED. A later `arm()` (a different user
527    // action) must not reach back and kill a call already in flight.
528    let state = store.data_mut();
529    state.epoch_spent = 0;
530    state.epoch_started = Some(std::time::Instant::now());
531    state.cancel_token = state.cancel.as_ref().and_then(|c| c.current_token());
532
533    // The deadline is re-armed ONE TICK AT A TIME so the callback runs
534    // every ~1ms and can poll the token. That moves enforcement of
535    // `budget.epoch_deadline` in here — wasmtime counts to 1 repeatedly,
536    // we count the total. Same ceiling, checked by us.
537    store.set_epoch_deadline(1);
538    store.epoch_deadline_callback(move |mut ctx| {
539        let state = ctx.data_mut();
540        if let Some(token) = &state.cancel_token
541            && token.is_cancelled()
542        {
543            // Traps the guest. `classify_trap` maps this back to
544            // `TrapKind::Cancelled` so the caller can tell "the user
545            // stopped it" from "the plugin ran away".
546            return Err(wasmtime::Error::msg(CANCELLED_TRAP));
547        }
548        state.epoch_spent += 1;
549        // OA.0b: measured against the clock, NOT against the number of times
550        // this callback has run. The two agree only while the guest is
551        // executing guest code continuously — one checkpoint crossed per
552        // tick, one firing per tick. A guest that calls host imports in a
553        // loop is suspended for most of its wall clock, and wasmtime reports
554        // an exceeded deadline ONCE on re-entry however many ticks passed. So
555        // the old firing count ran arbitrarily slower than real time, and the
556        // budget it enforced was not a time budget at all: an org agenda scan
557        // ran 6.8 s against a 1 s deadline without ever tripping it.
558        if epoch_budget_exceeded(state.epoch_started, budget.epoch_deadline) {
559            return Err(wasmtime::Error::msg(EPOCH_TRAP));
560        }
561        Ok(wasmtime::UpdateDeadline::Continue(1))
562    });
563    Ok(())
564}
565
566/// Has this call outrun `epoch_deadline` milliseconds since `started`?
567///
568/// Split out so the accounting is testable without a guest: the bug OA.0b
569/// fixed lived entirely in this decision, and reproducing it end to end needs
570/// a guest that blocks in a host import, which no fixture provides.
571///
572/// `None` (nothing armed) never trips — a call with no start time recorded is
573/// not one we can time, and trapping it would be worse than letting the fuel
574/// bound catch it.
575fn epoch_budget_exceeded(started: Option<std::time::Instant>, deadline_ms: u64) -> bool {
576    let Some(started) = started else {
577        return false;
578    };
579    started.elapsed() >= std::time::Duration::from_millis(deadline_ms)
580}
581
582/// Marker in the trap message for a call stopped by `<C-g>` (CG.4).
583///
584/// A string rather than a typed error because it has to survive
585/// `wasmtime::Error`'s round-trip through the guest trap — the same
586/// reason `classify_trap` matches on `wasmtime::Trap` variants rather
587/// than carrying host types across.
588pub(crate) const CANCELLED_TRAP: &str = "lattice: plugin call cancelled by the user";
589
590/// Marker for a call that outran `budget.epoch_deadline` (CG.4 moved
591/// this enforcement out of wasmtime's countdown and into our callback).
592pub(crate) const EPOCH_TRAP: &str = "lattice: plugin call exceeded its time budget";
593
594/// Classify a wasmtime call error into a [`TrapKind`] for reporting.
595pub(crate) fn classify_trap(err: &wasmtime::Error) -> TrapKind {
596    // CG.4: the epoch callback raises these two, so they arrive as plain
597    // errors rather than `wasmtime::Trap` variants — checked FIRST,
598    // because a callback-raised error may also carry a Trap in its
599    // chain and `Interrupt` would otherwise swallow a cancellation.
600    // `{:#}` renders the WHOLE anyhow chain. `to_string()` shows only the
601    // outermost context, and wasmtime wraps a callback-raised error in
602    // trap context — so the marker is never in the top line.
603    let text = format!("{err:#}");
604    if text.contains(CANCELLED_TRAP) {
605        return TrapKind::Cancelled;
606    }
607    if text.contains(EPOCH_TRAP) {
608        return TrapKind::Epoch;
609    }
610    match err.downcast_ref::<wasmtime::Trap>() {
611        Some(wasmtime::Trap::OutOfFuel) => TrapKind::Fuel,
612        Some(wasmtime::Trap::Interrupt) => TrapKind::Epoch,
613        _ => TrapKind::Other,
614    }
615}
616
617/// Per-instance crash-quarantine, shared by every repeated-call plugin surface
618/// (the four actors — event / picker / decoration / completion — and the
619/// grammar apply path). A component trap taints its instance irrecoverably:
620/// wasmtime offers no rollback, so a `Store` that trapped once will keep
621/// failing. The first trap trips quarantine here — [`lattice_protocol::Event::PluginCrashed`]
622/// fires exactly once on the bus, an `info!` records the death — and every
623/// later call short-circuits (via [`is_tripped`](Self::is_tripped)) *before*
624/// re-entering the dead `Store`. This turns today's "tainted instance keeps
625/// re-failing, each logged" behaviour into a clean one-shot crash signal plus a
626/// silent no-op. Reload (PH7.12b) mints a fresh instance with a fresh,
627/// untripped `Quarantine`.
628///
629/// Isolation is the guarantee: tripping touches only this instance's flag and
630/// publishes one event; the actor, bus, every other plugin, and the editor are
631/// untouched.
632pub(crate) struct Quarantine {
633    plugin: PluginId,
634    bus: Arc<EventBus>,
635    tripped: bool,
636}
637
638impl Quarantine {
639    pub(crate) fn new(plugin: PluginId, bus: Arc<EventBus>) -> Self {
640        Self {
641            plugin,
642            bus,
643            tripped: false,
644        }
645    }
646
647    /// Whether a prior trap has quarantined this instance. Callers short-circuit
648    /// when this is `true` rather than re-entering the dead `Store`.
649    pub(crate) fn is_tripped(&self) -> bool {
650        self.tripped
651    }
652
653    /// Trip quarantine on a trap. Idempotent: the first call publishes exactly
654    /// one [`lattice_protocol::Event::PluginCrashed`] and logs; a second call (a race, or a caller
655    /// that forgot to check [`is_tripped`](Self::is_tripped)) is a silent no-op —
656    /// the instance is already known dead and the event has already fired.
657    pub(crate) fn trip(&mut self, func: &'static str, kind: TrapKind) {
658        // CG.4: `<C-g>` is not a crash. Fuel / epoch / other mean the
659        // plugin misbehaved and the instance is dead; cancellation means
660        // the USER changed their mind, and the instance is perfectly
661        // healthy. Quarantining here would punish a plugin for being
662        // interruptible — press `<C-g>` on a slow picker and the plugin
663        // would be dead until reload.
664        if kind == TrapKind::Cancelled {
665            tracing::debug!(
666                plugin = self.plugin.0,
667                func,
668                "plugin call cancelled by the user; instance stays live"
669            );
670            return;
671        }
672        // Epoch is NOT softer than fuel here, and the evidence is worth
673        // keeping. An epoch overrun does say something weaker about the guest —
674        // it ran long on the wall clock, which under load happens to a healthy
675        // plugin — so a strike count that tolerated one overrun was tried.
676        //
677        // It does not work, because a wasm trap does not unwind: Rust
678        // destructors never run, so a trap taken inside a
679        // `RefCell::borrow_mut()` scope leaves that cell borrowed for the life
680        // of the instance and every later call panics on it. Letting a second
681        // delivery through produced exactly that — an `Epoch` trap followed
682        // immediately by a `kind=Other` guest trap on the next event.
683        //
684        // So the instance really is dead however it trapped, and the only
685        // useful lever is not trapping: see the budgets on `PluginBudget`.
686        if self.tripped {
687            return;
688        }
689        self.tripped = true;
690        // info!, not warn!: a plugin dying is a one-shot, user-actionable event
691        // (the notification / plugin-manager surface acts on it), not a
692        // per-keystroke diagnostic. Later short-circuits are silent.
693        tracing::info!(
694            plugin = self.plugin.0,
695            func,
696            kind = kind.label(),
697            "plugin quarantined after trap"
698        );
699        self.bus.publish(lattice_protocol::Event::PluginCrashed {
700            plugin: self.plugin.0,
701            func: func.to_string(),
702            kind: kind.label().to_string(),
703        });
704    }
705}
706
707/// Map a raw wasmtime call result for a repeated-call actor export: on a trap,
708/// trip `quarantine` (fires `PluginCrashed` once) and return a typed [`Trap`];
709/// otherwise pass the value through. The paired short-circuit —
710/// `if quarantine.is_tripped() { return Err(Quarantined { func }) }` at the top
711/// of each export — keeps a dead instance from ever re-entering its `Store`.
712/// Shared by the picker / decoration / completion actors (the event actor's
713/// `deliver` is `()`-returning, so it trips inline).
714///
715/// [`Trap`]: PluginHostError::Trap
716pub(crate) fn trip_and_map<T>(
717    quarantine: &mut Quarantine,
718    func: &'static str,
719    result: wasmtime::Result<T>,
720) -> Result<T, PluginHostError> {
721    match result {
722        Ok(v) => Ok(v),
723        Err(source) => {
724            let kind = classify_trap(&source);
725            quarantine.trip(func, kind);
726            Err(PluginHostError::Trap {
727                func,
728                kind,
729                source: source.into(),
730            })
731        }
732    }
733}
734
735/// PO.2 — the traced [`trip_and_map`]: map the guest result AND emit a boundary
736/// [`PluginTraceRecord`](crate::trace::PluginTraceRecord) into `tracer` (when a
737/// seam actor was wired with one). A successful call records at `Debug` — dropped
738/// by the default `Info` gate, so there is no per-call noise unless the plugin is
739/// raised to `debug`/`trace`; a trap records at `Error`, always kept. The seams
740/// are async (off the actor thread), and emission is a cheap gated push, so this
741/// stays off the editor hot path (design §4). `fuel_delta` is `0` for now — wall
742/// time is the primary signal; fuel accounting is a later refinement.
743pub(crate) fn trip_and_map_traced<T>(
744    tracer: Option<&crate::trace::PluginTracerHandle>,
745    plugin: u32,
746    seam: PluginSeam,
747    quarantine: &mut Quarantine,
748    func: &'static str,
749    start: std::time::Instant,
750    result: wasmtime::Result<T>,
751) -> Result<T, PluginHostError> {
752    let micros = start.elapsed().as_micros() as u64;
753    let mapped = trip_and_map(quarantine, func, result);
754    if let Some(tracer) = tracer {
755        use crate::trace::{Direction, PluginTraceRecord, TraceLevel, TraceOutcome};
756        let (level, outcome) = match &mapped {
757            Ok(_) => (
758                TraceLevel::Debug,
759                TraceOutcome::Ok {
760                    micros,
761                    fuel_delta: 0,
762                },
763            ),
764            Err(PluginHostError::Trap { kind, .. }) => (
765                TraceLevel::Error,
766                TraceOutcome::Trap {
767                    kind: kind.label().to_string(),
768                    func: func.to_string(),
769                },
770            ),
771            // A non-trap host error (e.g. a linker/encode failure) — surface it
772            // at Warn without a trap classification.
773            Err(_) => (
774                TraceLevel::Warn,
775                TraceOutcome::Ok {
776                    micros,
777                    fuel_delta: 0,
778                },
779            ),
780        };
781        tracer.trace(PluginTraceRecord {
782            plugin,
783            seam,
784            direction: Direction::GuestExport,
785            call: std::borrow::Cow::Borrowed(func),
786            level,
787            outcome,
788            detail: None,
789        });
790    }
791    mapped
792}
793
794#[cfg(test)]
795mod epoch_budget_tests {
796    #![allow(clippy::unwrap_used)]
797    use super::*;
798    use std::time::{Duration, Instant};
799
800    /// The property the old accounting did NOT have. It counted callback
801    /// firings, so the budget was spent at the rate the guest happened to
802    /// cross epoch checkpoints; a guest suspended in host imports crossed
803    /// them far more slowly than the clock ran. This is measured against the
804    /// clock, so it holds whatever the guest was doing in between.
805    #[test]
806    fn the_budget_is_spent_by_the_clock() {
807        let armed = Instant::now() - Duration::from_millis(1_500);
808        assert!(
809            epoch_budget_exceeded(Some(armed), 1_000),
810            "1.5s elapsed against a 1000ms budget must trip, regardless of \
811             how many times the callback happened to run"
812        );
813        assert!(
814            !epoch_budget_exceeded(Some(Instant::now()), 1_000),
815            "a call that just started has spent nothing"
816        );
817    }
818
819    /// The boundary is inclusive: a call that has used exactly its budget has
820    /// used it up. Stated because `>` vs `>=` here is the difference between
821    /// a budget of 0 meaning "never run" and meaning "unbounded".
822    #[test]
823    fn the_boundary_is_inclusive_so_a_zero_budget_is_not_unbounded() {
824        let armed = Instant::now() - Duration::from_millis(50);
825        assert!(epoch_budget_exceeded(Some(armed), 50));
826        assert!(
827            epoch_budget_exceeded(Some(Instant::now()), 0),
828            "a zero budget trips immediately rather than never"
829        );
830    }
831
832    /// Nothing armed cannot be timed, and trapping it would be worse than
833    /// leaving it to the fuel bound — which is a hard cap either way.
834    #[test]
835    fn an_unarmed_call_is_never_tripped_by_the_clock() {
836        assert!(!epoch_budget_exceeded(None, 0));
837        assert!(!epoch_budget_exceeded(None, 1_000));
838    }
839}
840
841#[cfg(test)]
842mod trip_and_map_traced_tests {
843    #![allow(clippy::unwrap_used)]
844    use std::sync::Arc;
845
846    use super::*;
847    use crate::trace::{PluginTracer, PluginTracerHandle, TraceLevel, TraceOutcome};
848
849    fn tracer() -> PluginTracerHandle {
850        // Trace-level default so per-call `Debug` records are kept in the test.
851        Arc::new(PluginTracer::new(TraceLevel::Trace, 16))
852    }
853
854    fn quarantine() -> Quarantine {
855        Quarantine::new(PluginId(7), Arc::new(lattice_runtime::EventBus::new()))
856    }
857
858    #[test]
859    fn a_successful_call_records_a_debug_ok() {
860        let t = tracer();
861        let mut q = quarantine();
862        let out: Result<u32, _> = trip_and_map_traced(
863            Some(&t),
864            7,
865            PluginSeam::Grammar,
866            &mut q,
867            "apply-motion",
868            std::time::Instant::now(),
869            Ok(42u32),
870        );
871        assert_eq!(out.unwrap(), 42);
872        let recs = t.snapshot_plugin(7);
873        assert_eq!(recs.len(), 1);
874        assert_eq!(recs[0].call, "apply-motion");
875        assert_eq!(recs[0].level, TraceLevel::Debug);
876        assert!(matches!(recs[0].outcome, TraceOutcome::Ok { .. }));
877        assert!(matches!(
878            recs[0].direction,
879            crate::trace::Direction::GuestExport
880        ));
881    }
882
883    #[test]
884    fn a_trap_records_an_error_trap_and_trips_quarantine() {
885        let t = tracer();
886        let mut q = quarantine();
887        let out: Result<u32, _> = trip_and_map_traced(
888            Some(&t),
889            7,
890            PluginSeam::PickerSource,
891            &mut q,
892            "init",
893            std::time::Instant::now(),
894            Err(wasmtime::Error::msg("boom")),
895        );
896        assert!(out.is_err());
897        assert!(q.is_tripped(), "a trap trips the quarantine");
898        let recs = t.snapshot_plugin(7);
899        assert_eq!(recs[0].level, TraceLevel::Error);
900        assert!(
901            matches!(&recs[0].outcome, TraceOutcome::Trap { func, kind } if func == "init" && kind == "trap"),
902            "a generic error classifies as a `trap`-kind Trap outcome"
903        );
904    }
905
906    /// A trapped instance is dead however it trapped — including on epoch.
907    ///
908    /// Worth pinning, because the opposite is intuitive and was tried: an epoch
909    /// overrun says only "this ran long", which under load happens to a healthy
910    /// plugin, so tolerating one looks obviously right. It is not. A wasm trap
911    /// does not unwind, so Rust destructors never run and a trap taken inside a
912    /// `RefCell::borrow_mut()` leaves the cell borrowed for the life of the
913    /// instance; the next call panics on it. Letting a second delivery through
914    /// produced an `Epoch` trap followed at once by a `kind=Other` guest trap.
915    ///
916    /// The lever is not trapping — see the budgets on `PluginBudget`.
917    #[test]
918    fn an_epoch_overrun_quarantines_like_any_other_trap() {
919        let mut q = quarantine();
920        q.trip("on-event", TrapKind::Epoch);
921        assert!(q.is_tripped());
922    }
923
924    /// Fuel is unchanged: the guest computed a runaway, and one is enough.
925    #[test]
926    fn a_fuel_overrun_still_quarantines_immediately() {
927        let mut q = quarantine();
928        q.trip("on-event", TrapKind::Fuel);
929        assert!(q.is_tripped());
930    }
931
932    /// So is a real wasm trap — unreachable, out-of-bounds, a guest panic.
933    /// Those say the instance's own invariants are gone.
934    #[test]
935    fn a_genuine_trap_still_quarantines_immediately() {
936        let mut q = quarantine();
937        q.trip("init", TrapKind::Other);
938        assert!(q.is_tripped());
939    }
940
941    #[test]
942    fn no_tracer_is_a_no_op_but_still_maps() {
943        let mut q = quarantine();
944        // Below-the-gate + no tracer: the mapping still happens, nothing recorded.
945        let out: Result<u32, _> = trip_and_map_traced(
946            None,
947            7,
948            PluginSeam::Grammar,
949            &mut q,
950            "apply-motion",
951            std::time::Instant::now(),
952            Ok(1u32),
953        );
954        assert_eq!(out.unwrap(), 1);
955    }
956}
957
958/// A background thread that bumps the engine epoch on a fixed interval, so
959/// per-store epoch deadlines actually fire. Stopped and joined on drop.
960struct EpochTicker {
961    stop: Arc<AtomicBool>,
962    handle: Option<JoinHandle<()>>,
963}
964
965impl EpochTicker {
966    fn spawn(engine: &Engine, interval: Duration) -> Result<Self, PluginHostError> {
967        let stop = Arc::new(AtomicBool::new(false));
968        let engine = engine.clone(); // `Engine` is a cheap Arc-backed handle.
969        let stop_flag = Arc::clone(&stop);
970        let handle = std::thread::Builder::new()
971            .name("lattice-plugin-epoch".into())
972            .spawn(move || {
973                while !stop_flag.load(Ordering::Relaxed) {
974                    std::thread::sleep(interval);
975                    engine.increment_epoch();
976                }
977            })
978            .map_err(PluginHostError::EpochTicker)?;
979        Ok(Self {
980            stop,
981            handle: Some(handle),
982        })
983    }
984}
985
986impl Drop for EpochTicker {
987    fn drop(&mut self) {
988        self.stop.store(true, Ordering::Relaxed);
989        if let Some(handle) = self.handle.take() {
990            // The ticker only sleeps + bumps an epoch; the join is prompt.
991            let _ = handle.join();
992        }
993    }
994}
995
996/// Per-`Store` host state. PH7.2 gives it the plugin's WASI view (built from
997/// its [`CapabilityGrant`], §6) and the resource table WASI resources live in.
998/// A plugin's `Store` reaches exactly the filesystem its grant preopened; the
999/// [`WasiView`] impl is what wires the WASI host functions to this state.
1000///
1001/// Later slices grow this with the plugin's document-handle / callback
1002/// resource tables (PH7.3) — they share this same `ResourceTable`.
1003struct PluginState {
1004    /// The scoped WASI context (granted filesystem preopens + nothing else).
1005    wasi: WasiCtx,
1006    /// The resource table WASI (and, at PH7.3d, the `document` handle)
1007    /// resources live in.
1008    table: ResourceTable,
1009    /// The plugin's effective capability grant (PH7.2). Carried in the `Store`
1010    /// state so guest→host `host-services` calls (PH7.4b) can enforce it: those
1011    /// run host-side with full host authority, so the grant — not the WASI
1012    /// sandbox — is what bounds them.
1013    grant: CapabilityGrant,
1014    /// Grammar contributions the guest declares through the `grammar` register
1015    /// API during `register-grammar` (PH7.7b). The `grammar::Host` impl below
1016    /// records into it; the host drains it after the registration export returns
1017    /// and builds native `*Spec`s with trampoline `apply`s (PH7.7c). Empty for a
1018    /// plugin that registers no grammar (picker/completion plugins, the scaffold).
1019    grammar_contributions: grammar_host::GrammarContributions,
1020    /// Event subscriptions the guest declares through the `events` `subscribe`
1021    /// API during `register-events` (PH7.8b). The `events::Host` impl below
1022    /// records into it; the host drains it after the registration export returns
1023    /// and wires each subscription to the native `EventBus` (PH7.8c). Empty for a
1024    /// plugin that observes no events.
1025    event_subscriptions: events_host::EventContributions,
1026    /// The publish-side handle for the `register-event` / `emit-event`
1027    /// host-services (PH7.8b.2) — the plugin's identity (for the `plugin:<id>`
1028    /// provenance) plus the bus it emits onto. `Some` on the two paths that are
1029    /// handed a bus: [`PluginHost::spawn_event_plugin`] (PH7.8b.2) and
1030    /// [`PluginHost::instantiate_grammar_plugin`] (OC.1 — a grammar action
1031    /// bridging a chord to the plugin's own async side). `None` elsewhere, in
1032    /// which case an `emit-event` is a warn + drop (the honest "no bus wired
1033    /// here" degradation).
1034    event_emit: Option<EventEmitCtx>,
1035    /// The `ConfigRegistry` a config plugin registers options into / reads via the
1036    /// `config` seam (PH7.10). `Some` only for a plugin spawned onto a registry
1037    /// ([`PluginHost::spawn_config_plugin`]); `None` otherwise, in which case a
1038    /// `register-option` returns `false` and `get-option` returns `none` (the
1039    /// honest "no registry wired" degradation — the host isn't boot-wired yet).
1040    config_registry: Option<Arc<lattice_config::ConfigRegistry>>,
1041    /// TC.4: the theme registry a `theme` plugin's `register-element` inserts
1042    /// into. Wired before `register-theme-elements` runs; `None` for every
1043    /// other world (the call then logs and registers nothing).
1044    theme_registry: Option<lattice_theme::ThemeRegistryHandle>,
1045    /// TC.4: namespaced element names this plugin registered — the teardown
1046    /// tokens, mirroring `config_contributions`.
1047    theme_contributions: Vec<String>,
1048    /// SG.3a: the sign registry a `signs` plugin's `define-sign` writes into.
1049    /// Wired before `register-signs` runs; `None` for every other world (the
1050    /// call then logs and defines nothing).
1051    sign_registry: Option<lattice_mode::SignRegistryHandle>,
1052    /// SG.3a: namespaced sign names this plugin declared — the teardown
1053    /// tokens, mirroring `theme_contributions`.
1054    sign_contributions: Vec<String>,
1055    /// CR.4: section specs this plugin declared through
1056    /// `dashboard.register-section`, drained by `spawn_dashboard_sections`
1057    /// after the export returns. The host then instantiates one live guest
1058    /// per spec — a section is a function, not data.
1059    dashboard_contributions: Vec<dashboard_host::DashboardSectionSpec>,
1060    /// CR.3: topics this plugin declared through `help.register-topic`,
1061    /// drained by `spawn_help_plugin` after the export returns. Plain data —
1062    /// the loader turns them into `HelpTopic`s, so this crate never depends
1063    /// on `lattice-help`.
1064    help_contributions: Vec<help_host::HelpTopicSpec>,
1065    /// LG.3c: languages the guest declared, drained by the loader.
1066    language_contributions: Vec<language_host::LanguageSpec>,
1067    /// The plugin's manifest id (e.g. `"auto-pair"`). Set by every spawn/
1068    /// instantiate path from the manifest. Used to **auto-namespace** the
1069    /// plugin's config options — a `register-option("style")` registers
1070    /// `auto-pair.style`, and `get`/`set-option` resolve the plugin's own
1071    /// namespace first (falling back to the raw name so core options stay
1072    /// readable). `None` in the minimal test constructor (no prefixing then).
1073    plugin_name: Option<String>,
1074    /// Names of options this plugin registered via `register-option` (PH7.10),
1075    /// recorded so the host can report them after `register-options` returns (and
1076    /// as the teardown seam PH7.12 will unregister). Empty for a non-config plugin.
1077    config_contributions: Vec<String>,
1078    /// Mode declarations the guest makes through `register-mode` during
1079    /// `register-modes` (PH7.11a). The `modes::Host` impl records into it; the
1080    /// host drains it after the registration export returns and registers each
1081    /// into the `ModeRegistry` (`spawn_mode_plugin`). Empty for a non-mode plugin.
1082    mode_contributions: mode_host::ModeContributions,
1083    /// The keymap handle + command-registry snapshot a keymap plugin binds user
1084    /// keybindings against via the `keymap` seam (PL8.D.1). `Some` only for a
1085    /// plugin spawned via [`PluginHost::spawn_keymap_plugin`]; `None` otherwise,
1086    /// in which case a `register-binding` returns `false` (the honest "no keymap
1087    /// wired" degradation).
1088    keymap_ctx: Option<keymap_host::KeymapBindCtx>,
1089    /// The user keybindings this plugin bound via `register-binding` (PL8.D.1),
1090    /// recorded as teardown tokens so the loader unbinds the `KeymapLayer::User`
1091    /// entries on unload (PL8.D.2). Empty for a non-keymap plugin.
1092    keymap_contributions: Vec<keymap_host::KeymapBindingToken>,
1093    /// PO.5: the guest `logging` seam's routing target — the plugin id + tracer.
1094    /// `Some` once an async instantiate/spawn path stamps it ([`LogCtx`]); `None`
1095    /// for the sync grammar guest and any pre-tracer path (a `log` debug-drops).
1096    log_ctx: Option<LogCtx>,
1097    /// PR.6: what the guest `project` seam resolves through. `None` in a
1098    /// harness that wired no resolver; the seam then answers `none`.
1099    project: Option<ProjectCtx>,
1100    /// OA.23: resolves a composed line to the file it came from. `None` when
1101    /// no multibuffer owner wired one, which answers `none` rather than
1102    /// pretending.
1103    excerpt_source: Option<lattice_core::ExcerptSourceResolverHandle>,
1104    /// OA.27: what a provider view is currently showing. `None` when no view
1105    /// owner wired one, which answers an empty list rather than pretending.
1106    view_args: Option<lattice_core::ViewArgsResolverHandle>,
1107    /// OA.30: the counter `refresh-decorations` bumps. `None` in a harness that
1108    /// wired no editor, where the call is a no-op.
1109    decoration_epoch: Option<lattice_mode::DecorationEpochHandle>,
1110    /// CD.6b: the buffers `clamp-position` measures. `None` in a harness that
1111    /// wired no editor, where every buffer reads as absent.
1112    buffers: Option<lattice_mode::BufferStoreHandle>,
1113    /// PH7.8c: events emitted while `register-events` is still running, held
1114    /// until this plugin's subscriptions are on the bus.
1115    ///
1116    /// `Some(_)` only inside that window; `None` — publish straight through —
1117    /// everywhere else, including every later `on-event`. See
1118    /// [`crate::event_task`]'s flush for why the window exists.
1119    deferred_events: Option<Vec<(String, Vec<u8>)>>,
1120    /// CG.4: the foreground-cancel registry, stamped once per store.
1121    /// `arm_store` takes the lock once per guest call to refresh
1122    /// [`Self::cancel_token`]; the epoch callback never touches it.
1123    cancel: Option<lattice_mode::ForegroundCancelHandle>,
1124    /// CG.4: the token the epoch callback polls — a plain atomic, read
1125    /// once per millisecond inside a running guest call. Refreshed by
1126    /// `arm_store`, so each call is cancelled by the operation that was
1127    /// armed when it STARTED, not by one armed after it finished.
1128    cancel_token: Option<lattice_protocol::CancellationToken>,
1129    /// CG.4: how many times the deadline callback has fired this call.
1130    ///
1131    /// Diagnostic only since OA.0b. It used to BE the time budget — the
1132    /// callback counted its own firings and trapped at
1133    /// `budget.epoch_deadline` — which silently assumed the guest crosses
1134    /// an epoch checkpoint every tick. A guest that spends its wall clock
1135    /// inside HOST imports does not: wasmtime notices the deadline once on
1136    /// re-entry however many ticks elapsed, so the counter undercounts by
1137    /// exactly the ratio of host time to guest time. See `epoch_started`.
1138    epoch_spent: u64,
1139    /// OA.0b: when the current call was armed. The time budget is enforced
1140    /// against this, so `PluginBudget::epoch_deadline`'s documented unit —
1141    /// milliseconds — is what it actually means, for every guest rather
1142    /// than only for a compute-bound one. Reset per call by `arm_store`.
1143    epoch_started: Option<std::time::Instant>,
1144    /// OC.3 / ML.6: what the `ui` seam's modeline calls act on — the element
1145    /// registry plus the bus content updates publish onto. `Some` on the async
1146    /// spawn paths that are handed a modeline; `None` on the sync grammar store.
1147    ///
1148    /// That `None` is the guarantee, not an oversight. The plan for OC.3 said
1149    /// the seam would be "wired on the async linker only", which does not
1150    /// survive the Component Model — a plugin's import set is fixed for the
1151    /// whole component and the same artefact instantiates against the grammar
1152    /// linker too, so an import missing there fails the WHOLE plugin (TC.6 /
1153    /// CR.3 / LG.3c / OM.11, and org has been broken exactly this way once).
1154    /// So `ui` is linked on both and the modeline is kept off the keystroke path
1155    /// here instead, where it is testable.
1156    ui: Option<ui_host::UiCtx>,
1157    /// OC.2: the plugin's armed periodic wakes. `Some` only on a store whose
1158    /// actor can fire them — [`PluginHost::spawn_event_plugin`], and only when a
1159    /// [`SleeperHandle`] was installed. `None` everywhere else, in which case
1160    /// `wake-every` answers `0` and logs; a guest that treats a `0` as armed
1161    /// simply never hears back, which is a visible nothing rather than a
1162    /// plausible wrong answer.
1163    ///
1164    /// Notably `None` on the sync grammar store: `events` is not on the grammar
1165    /// linker, so the seam is unreachable from the keystroke path structurally,
1166    /// and this field is the second, redundant answer to the same question.
1167    wake: Option<wake::WakeCtx>,
1168    /// OR.1: the plugin's durable byte store, shared with every other seam
1169    /// instance of the SAME manifest id. `None` for a store built without a
1170    /// manifest name (the degenerate `instantiate` path and internal harnesses)
1171    /// — there is no id to scope a store to, so `store-get` answers `none` and
1172    /// `store-put` says why.
1173    ///
1174    /// Stamped for every store here rather than per-spawn-path, on the
1175    /// `project` / `ui` reasoning: the handle needs no plugin id allocation and
1176    /// nothing to wait for, so no path can forget it. Forgetting it is exactly
1177    /// the failure this seam exists to prevent — a picker seam holding a store
1178    /// the grammar seam cannot see is the drift, not a degradation of it.
1179    store: Option<plugin_store::PluginStoreHandle>,
1180    /// OR.5b: the picker sources this guest declared during
1181    /// `register-picker-sources`. Drained by `spawn_picker_source` after the
1182    /// export returns — the `grammar_contributions` shape.
1183    picker_contributions: picker_host::PickerContributions,
1184    /// MV.1: the views a guest declared through `register-multibuffer-view`.
1185    multibuffer_view_contributions: multibuffer_view_host::MultibufferViewContributions,
1186    /// OR.2: the directory watches this guest armed, keyed by the path it named.
1187    ///
1188    /// **On the `Store`, not in a host-side registry**, and that is the whole
1189    /// teardown story: a watch's lifetime is exactly this plugin instance's, so
1190    /// dropping the `Store` — on unload, on quarantine, on the event actor's
1191    /// channel closing — stops every watch with no bookkeeping anyone can
1192    /// forget to write. `unwatch` is then a `remove` on this map.
1193    watches: std::collections::HashMap<PathBuf, watch_host::Watch>,
1194    /// PM.7: plugins this guest declared via `plugin-manager.require` during
1195    /// `register-plugins`. Recorded here, drained by
1196    /// [`PluginHost::spawn_plugin_manager_plugin`] after the export returns —
1197    /// the host resolves/builds/loads them off-thread, never inside the guest
1198    /// call. Empty for every world that does not import `plugin-manager`.
1199    require_contributions: plugin_manager_host::RequireContributions,
1200}
1201
1202/// The bus-publish handle a plugin needs to emit custom events (PH7.8b.2). Set
1203/// on [`PluginState`] by [`PluginHost::spawn_event_plugin`] once the plugin's
1204/// identity is allocated. The subscribe side already threads the concrete
1205/// [`EventBus`] into the host ([`event_task`](crate::event_task)), so the emit
1206/// side stores it directly rather than behind a closure indirection.
1207struct EventEmitCtx {
1208    /// The host-issued identity — the `plugin:<id>` provenance stamped on every
1209    /// event this plugin declares via `register-event`.
1210    plugin_id: PluginId,
1211    /// The bus `emit-event` publishes `Event::Plugin` onto.
1212    bus: Arc<EventBus>,
1213}
1214
1215/// PO.5: what the guest `logging` seam needs to route a `log` call — the plugin's
1216/// host-issued id (keys the trace record) + the shared tracer. Set on
1217/// [`PluginState`] by each async instantiate/spawn path
1218/// ([`PluginHost::log_ctx_for`]); the `EventEmitCtx` analog for Layer 2.
1219struct LogCtx {
1220    /// The host-issued id stamped on every `PluginTraceRecord` from this plugin.
1221    plugin: u32,
1222    /// The sink the guest's log lines land in (the same tracer as the boundary
1223    /// trace, so the two interleave in `*plugin-trace*`).
1224    tracer: crate::trace::PluginTracerHandle,
1225}
1226
1227impl WasiView for PluginState {
1228    fn ctx(&mut self) -> WasiCtxView<'_> {
1229        WasiCtxView {
1230            ctx: &mut self.wasi,
1231            table: &mut self.table,
1232        }
1233    }
1234}
1235
1236/// Host impl of the `host-services` guest→host seam (PH7.4b, §5). The generated
1237/// `Host::walk` returns the WIT `result<list<string>, string>` directly as
1238/// `Result<Vec<String>, String>` — a sync host func that cannot trap, so bindgen
1239/// omits the outer `wasmtime::Result`. Walk logic + the capability gate live in
1240/// [`host_services::walk_within_grant`]; the impl just forwards with the Store's
1241/// grant.
1242impl crate::lattice::plugin_host::host_services::Host for PluginState {
1243    /// OA.23: where a line of a multibuffer came from.
1244    ///
1245    /// Forwards to the wired resolver and projects its answer. `none` on every
1246    /// way of not knowing — no resolver, not a composed buffer, a line that is
1247    /// not source text, a source with no path — because a guest asks about the
1248    /// cursor's line and a cursor can be anywhere. A non-UTF-8 path is `none`
1249    /// too: it cannot cross as a `string`, and that is the boundary's rule
1250    /// everywhere else.
1251    fn excerpt_source(
1252        &mut self,
1253        buffer: u64,
1254        line: u32,
1255    ) -> Option<crate::lattice::plugin_host::host_services::SourceLocation> {
1256        let resolver = self.excerpt_source.as_ref()?;
1257        let found = resolver.excerpt_source(lattice_core::BufferId(buffer as u32), line)?;
1258        Some(crate::lattice::plugin_host::host_services::SourceLocation {
1259            path: found.path.to_str()?.to_string(),
1260            line: found.line,
1261            buffer: found.source.0,
1262        })
1263    }
1264
1265    /// OA.23b: forwards to the wired resolver. `none` with no resolver, and
1266    /// `none` for a `buffer` no view owns — a guest can only have got the id
1267    /// from `excerpt-source`, and a view can close between the two calls.
1268    fn source_line(&mut self, buffer: u32, line: u32) -> Option<String> {
1269        self.excerpt_source
1270            .as_ref()?
1271            .source_line(lattice_core::BufferId(buffer), line)
1272    }
1273
1274    /// OA.27 `view-args`: forwards to the wired resolver.
1275    ///
1276    /// An EMPTY LIST rather than an `option` on the wire, and the WIT says why:
1277    /// every way of not knowing — no resolver, not a provider view, a view the
1278    /// host holds no state for — means "no arguments", which is exactly what a
1279    /// fresh view has and what a guest parses a default from. An `option` would
1280    /// offer a distinction with no different action behind it.
1281    fn view_args(&mut self, buffer: u64) -> Vec<String> {
1282        self.view_args
1283            .as_ref()
1284            .and_then(|r| r.view_args(lattice_core::BufferId(buffer as u32)))
1285            .unwrap_or_default()
1286    }
1287
1288    /// OA.30 `refresh-decorations`: bump the counter the refresh pump compares.
1289    ///
1290    /// No result and no error. There is nothing a guest could do with either:
1291    /// the call is advisory, the work happens on the host's next tick, and an
1292    /// unwired counter means this editor runs no decoration producers at all —
1293    /// so the guest's marks were never going to paint regardless.
1294    fn refresh_decorations(&mut self) {
1295        if let Some(epoch) = self.decoration_epoch.as_ref() {
1296            epoch.bump();
1297        }
1298    }
1299
1300    fn walk(&mut self, root: String) -> Result<Vec<String>, String> {
1301        host_services::walk_within_grant(&self.grant, &root)
1302    }
1303
1304    /// OC.5a `read-file`: the read that works on every seam, including the
1305    /// synchronous grammar dispatch path where the guest's own WASI view
1306    /// panics rather than reads. Gate + logic in
1307    /// [`host_services::read_within_grant`].
1308    fn read_file(&mut self, path: String) -> Result<String, String> {
1309        host_services::read_within_grant(&self.grant, &path)
1310    }
1311
1312    /// CD.3 `delete-file`: `read-file`'s peer, gated on `fs:write`. Gate +
1313    /// logic in [`host_services::delete_within_grant`].
1314    fn delete_file(&mut self, path: String) -> Result<(), String> {
1315        host_services::delete_within_grant(&self.grant, &path)
1316    }
1317
1318    /// CD.3b `can-write-file`: the boundary's own grant test plus the
1319    /// applier's checks, as a query. See
1320    /// [`host_services::can_write_within_grant`].
1321    fn can_write_file(&mut self, path: String) -> Result<(), String> {
1322        host_services::can_write_within_grant(&self.grant, &path)
1323    }
1324
1325    /// CD.6b `clamp-position`: `at`, moved inside `buffer` as it is now.
1326    /// `none` when no buffer has that id, or no store is wired. Logic in
1327    /// [`host_services::clamp_position`].
1328    fn clamp_position(
1329        &mut self,
1330        buffer: u32,
1331        at: crate::lattice::plugin_host::types::Position,
1332    ) -> Option<crate::lattice::plugin_host::types::Position> {
1333        let handle = self
1334            .buffers
1335            .as_ref()?
1336            .handle_for(lattice_core::BufferId(buffer))?;
1337        let (line, byte) =
1338            host_services::clamp_position(&handle.snapshot().buffer, at.line, at.byte);
1339        Some(crate::lattice::plugin_host::types::Position { line, byte })
1340    }
1341
1342    /// `register-event` (PH7.8b.2): declare a plugin-defined event into the
1343    /// runtime registry under this plugin's `plugin:<id>` provenance. Returns
1344    /// `false` on a built-in-shadow (the registry refuses it) OR when no emit
1345    /// context is wired (a plugin not spawned onto a bus — degrade gracefully to
1346    /// "not registered" rather than panic; the host isn't boot-wired yet).
1347    fn register_event(&mut self, name: String, doc: String) -> bool {
1348        match &self.event_emit {
1349            Some(ctx) => host_services::register_plugin_event(ctx.plugin_id, &name, &doc),
1350            None => {
1351                tracing::warn!(
1352                    event = %name,
1353                    "register-event ignored: plugin has no event bus wired"
1354                );
1355                false
1356            }
1357        }
1358    }
1359
1360    /// `emit-event` (PH7.8b.2): publish a plugin-defined event on the bus. A
1361    /// plugin with no emit context wired (not spawned onto a bus) degrades to a
1362    /// warn + drop — never a panic (the four-artefact graceful-failure clause).
1363    fn emit_event(&mut self, name: String, payload: Vec<u8>) {
1364        // PH7.8c: inside the `register-events` window this plugin has no bus
1365        // subscription yet, so publishing now would deliver to everyone EXCEPT
1366        // the guest that asked. Hold it; the spawn flushes once the
1367        // subscriptions are wired.
1368        if let Some(pending) = self.deferred_events.as_mut() {
1369            pending.push((name, payload));
1370            return;
1371        }
1372        match &self.event_emit {
1373            Some(ctx) => host_services::emit_plugin_event(&ctx.bus, name, payload),
1374            None => {
1375                tracing::warn!(
1376                    event = %name,
1377                    "emit-event dropped: plugin has no event bus wired"
1378                );
1379            }
1380        }
1381    }
1382
1383    /// OC.4 `local-utc-offset-seconds`: the host's current offset from UTC.
1384    /// Ungated, and resolved per call so DST is correct — see
1385    /// [`host_services::local_utc_offset_seconds`].
1386    fn local_utc_offset_seconds(&mut self) -> i32 {
1387        host_services::local_utc_offset_seconds()
1388    }
1389
1390    /// OR.3 `new-uuid`: a fresh uppercase v4 id, mintable from every seam —
1391    /// including the synchronous grammar linker, which is the only reason it is
1392    /// host-side. See [`host_services::new_uuid`].
1393    fn new_uuid(&mut self) -> Result<String, String> {
1394        host_services::new_uuid()
1395    }
1396
1397    /// OR.2 `watch`. Gated on the same `fs:read` grant `walk` and `read-file`
1398    /// check — a watch reveals filesystem activity, so it is the same
1399    /// authorization question, answered by the same function.
1400    ///
1401    /// Requires an event bus on this seam, because a watch with nowhere to
1402    /// deliver is not a degraded watch, it is a thread that runs forever and
1403    /// tells nobody. The refusal names that, so a plugin author arming one from
1404    /// the grammar seam learns why it is silent rather than assuming the
1405    /// watcher is broken.
1406    fn watch(&mut self, path: String) -> Result<(), String> {
1407        let Some(ctx) = &self.event_emit else {
1408            tracing::warn!(
1409                path = %path,
1410                "watch refused: plugin has no event bus wired on this seam"
1411            );
1412            return Err(format!(
1413                "fs watch denied: '{path}' — this seam has no event bus, so a watch could \
1414                 never be delivered (arm it from the plugin's events seam)"
1415            ));
1416        };
1417        let key = PathBuf::from(&path);
1418        if self.watches.contains_key(&key) {
1419            // Idempotent: re-arming would leave the first watch orphaned and
1420            // double every batch.
1421            return Ok(());
1422        }
1423        let watch =
1424            watch_host::watch_within_grant(&self.grant, ctx.bus.clone(), ctx.plugin_id.0, &path)?;
1425        self.watches.insert(key, watch);
1426        Ok(())
1427    }
1428
1429    /// OR.2 `unwatch`. Dropping the entry stops the OS watch and ends its
1430    /// coalescing thread. Unwatching what is not watched is `ok` — a disarm is
1431    /// idempotent, because the alternative is a guest that must track host
1432    /// state to avoid an error.
1433    fn unwatch(&mut self, path: String) -> Result<(), String> {
1434        self.watches.remove(&PathBuf::from(&path));
1435        Ok(())
1436    }
1437
1438    /// OR.1 `store-put`. Gated on `state:write`; `err` names which of the three
1439    /// refusals happened, because "the put failed" sends a plugin author
1440    /// nowhere.
1441    fn store_put(&mut self, key: String, value: Vec<u8>) -> Result<(), String> {
1442        match self.writable_store()? {
1443            Some(mut store) => store.put(&key, value),
1444            None => Err(
1445                "store put failed: this plugin has no store (loaded without a manifest id)".into(),
1446            ),
1447        }
1448    }
1449
1450    /// OR.1 `store-get`. `none` for every absent case — nothing stored, no
1451    /// grant, no store — because a reader for whom absence is ordinary cannot
1452    /// act differently on any of them.
1453    fn store_get(&mut self, key: String) -> Option<Vec<u8>> {
1454        self.readable_store()?.get(&key)
1455    }
1456
1457    /// OR.1 `store-delete`. Deleting what is not there is `ok`.
1458    fn store_delete(&mut self, key: String) -> Result<(), String> {
1459        match self.writable_store()? {
1460            Some(mut store) => store.delete(&key),
1461            None => Err(
1462                "store delete failed: this plugin has no store (loaded without a manifest id)"
1463                    .into(),
1464            ),
1465        }
1466    }
1467
1468    /// OR.1 `store-keys`. Empty for an ungranted or storeless plugin.
1469    fn store_keys(&mut self, prefix: String) -> Vec<String> {
1470        self.readable_store()
1471            .map(|s| s.keys(&prefix))
1472            .unwrap_or_default()
1473    }
1474
1475    /// OR.1 `store-generation`. `0` for an ungranted or storeless plugin — a
1476    /// number that never moves, so a reader polling it simply never rebuilds
1477    /// rather than rebuilding forever.
1478    fn store_generation(&mut self) -> u64 {
1479        self.readable_store().map(|s| s.generation()).unwrap_or(0)
1480    }
1481}
1482
1483impl PluginState {
1484    /// The store for a READ (`store-get` / `store-keys` / `store-generation`),
1485    /// or `None` when this plugin has none or was not granted `state:write`.
1486    ///
1487    /// Reads are gated on the same grant as writes. A plugin that may not
1488    /// persist has nothing of its own to read back, and letting it read would
1489    /// only expose what an earlier, granted version of itself had left there.
1490    fn readable_store(&self) -> Option<std::sync::MutexGuard<'_, plugin_store::PluginStore>> {
1491        if !self.grant.state_write {
1492            return None;
1493        }
1494        // Poisoning is recovered rather than propagated: a panic in another
1495        // thread's guest call must not turn every later store read into a
1496        // panic on the keystroke path. The bytes are unaffected — the map is
1497        // only ever mutated through methods that cannot leave it half-updated.
1498        Some(
1499            self.store
1500                .as_ref()?
1501                .lock()
1502                .unwrap_or_else(|poisoned| poisoned.into_inner()),
1503        )
1504    }
1505
1506    /// The store for a WRITE. `Err` when the grant is missing (named, so the
1507    /// author fixes their manifest); `Ok(None)` when there is no store at all.
1508    #[allow(clippy::type_complexity)]
1509    fn writable_store(
1510        &self,
1511    ) -> Result<Option<std::sync::MutexGuard<'_, plugin_store::PluginStore>>, String> {
1512        if !self.grant.state_write {
1513            // info!: user-actionable (a plugin was denied persistence), the
1514            // level `walk` / `read-file` denials use.
1515            tracing::info!("host-services store denied: the plugin has no `state:write` grant");
1516            return Err(
1517                "store denied: this plugin did not request the `state:write` capability".into(),
1518            );
1519        }
1520        Ok(self
1521            .store
1522            .as_ref()
1523            .map(|s| s.lock().unwrap_or_else(|poisoned| poisoned.into_inner())))
1524    }
1525}
1526
1527/// Host impl of the `ui` guest→host contribution seam (OC.3 / ML.6, §5). Three
1528/// sync, non-trapping functions — the `host-services` shape — over a plugin's
1529/// own modeline element. Every id is namespaced with the plugin's name before it
1530/// reaches the registry, so one plugin cannot address another's element or a
1531/// built-in. Logic + the namespacing live in [`ui_host`] so they are testable
1532/// without a `Store`; this impl is the wiring and the absent-context arm.
1533/// OR.5b: the `picker-registry` guest→host seam — where a plugin declares its
1534/// picker sources.
1535///
1536/// The `grammar::register_*` shape: a sync host func that only records into
1537/// `PluginState`, drained by the spawn path after the registration export
1538/// returns. A spec that will not cross the boundary is dropped with a `warn`
1539/// rather than failing the whole registration — one malformed source must not
1540/// cost a plugin its other ones.
1541impl crate::picker_host::bindings::lattice::plugin_host::picker_registry::Host for PluginState {
1542    fn register_picker_source(
1543        &mut self,
1544        spec: crate::lattice::plugin_host::types::PickerSourceSpec,
1545    ) {
1546        match <lattice_picker::source::PickerSourceSpec as WitBoundary>::from_wit(spec) {
1547            Ok(spec) => self.picker_contributions.push(spec),
1548            Err(error) => {
1549                tracing::warn!(%error, "register-picker-source refused: spec did not cross");
1550            }
1551        }
1552    }
1553}
1554
1555/// MV.1 — the registry a view plugin declares its views through.
1556///
1557/// The `register_picker_source` shape exactly: a sync host func that only
1558/// records into `PluginState`, drained by the spawn after the registration
1559/// export returns. Unlike the picker's, the spec does NOT convert here — a
1560/// `MultibufferViewSpec` is host-side data the provider consumes directly, so
1561/// there is nothing to fail at this point. Refusals that need the other
1562/// claimant's name (an id a native provider already owns) happen where both are
1563/// visible, in `providers::plugin_view`.
1564impl crate::multibuffer_view_host::bindings::lattice::plugin_host::multibuffer_view_registry::Host
1565    for PluginState
1566{
1567    fn register_multibuffer_view(
1568        &mut self,
1569        spec: crate::lattice::plugin_host::types::MultibufferViewSpec,
1570    ) {
1571        if spec.id.trim().is_empty() || spec.buffer_name.trim().is_empty() {
1572            // Both are names the host will look a view up by; an empty one is
1573            // unreachable rather than merely odd. Dropped with a warning so one
1574            // malformed view does not cost the plugin its others.
1575            tracing::warn!(
1576                id = %spec.id,
1577                buffer = %spec.buffer_name,
1578                "register-multibuffer-view refused: id and buffer-name must be non-empty"
1579            );
1580            return;
1581        }
1582        self.multibuffer_view_contributions.declare(spec);
1583    }
1584
1585    /// OA.15a: `refresh-view` — re-open one of this guest's views.
1586    ///
1587    /// A REQUEST, exactly like `enable-mode`: the opener needs the `&mut`
1588    /// `ModeActivator` and a plugin store cannot reach it, so this publishes
1589    /// and the Editor applies on its next tick. The typed event carries its own
1590    /// wake, so the re-scan reaches the screen without a keystroke — a plain
1591    /// `Event` variant would have landed only on the next keypress, which is
1592    /// the "works, but only after I hit something" class this codebase has paid
1593    /// for repeatedly.
1594    ///
1595    /// Validated here only for emptiness, which is the one thing that is wrong
1596    /// no matter what is registered. Whether `view` names a live provider is
1597    /// checked by the drain, where the registry is visible — the same division
1598    /// `register-multibuffer-view` already makes with `providers::plugin_view`.
1599    fn refresh_view(&mut self, view: String, args: Vec<String>) {
1600        if view.trim().is_empty() {
1601            tracing::warn!("refresh-view refused: view name must be non-empty");
1602            return;
1603        }
1604        match &self.event_emit {
1605            Some(ctx) => {
1606                ctx.bus
1607                    .publish_typed(lattice_mode::provider_view::ProviderViewRefreshRequested {
1608                        provider: view,
1609                        args,
1610                    })
1611            }
1612            // The "not boot-wired yet" case, like `emit-event` and
1613            // `enable-mode`: a warn and a drop, never a trap. A guest calling
1614            // this from a test harness with no bus is not misbehaving.
1615            None => tracing::warn!(
1616                %view,
1617                "refresh-view dropped: no bus wired (plugin not spawned onto a bus)"
1618            ),
1619        }
1620    }
1621}
1622
1623impl crate::lattice::plugin_host::ui::Host for PluginState {
1624    fn register_segment(
1625        &mut self,
1626        id: String,
1627        zone: crate::lattice::plugin_host::types::UiZone,
1628        priority: i32,
1629    ) -> bool {
1630        let id = ui_host::namespaced_id(self.plugin_name.as_deref(), &id);
1631        let Some(ctx) = self.ui.as_ref() else {
1632            tracing::warn!(
1633                element = %id,
1634                "register-segment ignored: no modeline wired on this seam"
1635            );
1636            return false;
1637        };
1638        match ui_host::register_segment(ctx, id.clone(), zone, priority) {
1639            // No teardown token is recorded here: unload reverses this plugin's
1640            // elements by NAMESPACE (`PluginTeardown::modeline_namespace`), so a
1641            // segment registered later in the plugin's life is covered too.
1642            Ok(()) => true,
1643            Err(error) => {
1644                tracing::warn!(element = %id, %error, "register-segment refused");
1645                false
1646            }
1647        }
1648    }
1649
1650    fn emit_segment(&mut self, id: String, text: String) {
1651        let id = ui_host::namespaced_id(self.plugin_name.as_deref(), &id);
1652        match self.ui.as_ref() {
1653            Some(ctx) => ui_host::emit_segment(ctx, id, text),
1654            None => {
1655                tracing::warn!(
1656                    element = %id,
1657                    "emit-segment dropped: no modeline wired on this seam"
1658                );
1659            }
1660        }
1661    }
1662
1663    fn clear_segment(&mut self, id: String) {
1664        let id = ui_host::namespaced_id(self.plugin_name.as_deref(), &id);
1665        if let Some(ctx) = self.ui.as_ref() {
1666            ui_host::clear_segment(ctx, id);
1667        }
1668    }
1669}
1670
1671/// Host impl of the `plugin-manager` guest→host seam (PM.7).
1672///
1673/// `require` **records and returns**. It performs no resolution, no clone, no
1674/// build and no load — see the module docs for why that split is load-bearing
1675/// rather than merely tidy.
1676///
1677/// A rejected spec returns `false` instead of trapping: one bad entry in a
1678/// user's `init.rs` must not take the whole config down, which is the same
1679/// graceful-degradation clause every other seam here follows.
1680impl crate::plugin_manager_host::bindings::lattice::plugin_host::plugin_manager::Host
1681    for PluginState
1682{
1683    fn require(
1684        &mut self,
1685        spec: crate::plugin_manager_host::bindings::lattice::plugin_host::plugin_manager::PluginSpec,
1686    ) -> bool {
1687        use crate::plugin_manager_host::bindings::lattice::plugin_host::plugin_manager::PluginSource as WitSource;
1688        if !plugin_manager_host::is_safe_plugin_name(&spec.name) {
1689            tracing::warn!(
1690                name = %spec.name,
1691                "require ignored: plugin name is not a single safe path component"
1692            );
1693            return false;
1694        }
1695        let source = match spec.source {
1696            WitSource::Local(path) => plugin_manager_host::RequiredSource::Local(path),
1697            WitSource::Git(g) => plugin_manager_host::RequiredSource::Git {
1698                url: g.url,
1699                rev: g.rev,
1700            },
1701            WitSource::Prebuilt(url) => plugin_manager_host::RequiredSource::Prebuilt { url },
1702        };
1703        tracing::debug!(name = %spec.name, "require recorded");
1704        self.require_contributions
1705            .record(plugin_manager_host::RequiredPlugin {
1706                name: spec.name,
1707                source,
1708                enable_mode: spec.enable_mode,
1709                pinned: spec.pinned,
1710            });
1711        true
1712    }
1713}
1714
1715/// PR.6: what the guest `project` seam needs to answer.
1716///
1717/// Both halves are required: the resolver turns a path into a project, and
1718/// the buffer store turns a `buffer` id into that path. Bundled so
1719/// `PluginState` carries one `Option` rather than two that could disagree
1720/// about whether the seam is wired.
1721#[derive(Clone)]
1722pub(crate) struct ProjectCtx {
1723    pub(crate) resolver: lattice_core::ProjectResolverHandle,
1724    pub(crate) buffers: lattice_mode::BufferStoreHandle,
1725}
1726
1727/// PR.6: host impl of the `project` guest→host seam.
1728///
1729/// Design: `docs/dev/architecture/project-resolution.md` §6. The host answers,
1730/// the guest asks — core never depends on a plugin being alive.
1731///
1732/// Both funcs return `option`, unlike the total native
1733/// `ProjectResolver::for_path`, and the two `none`s mean different things:
1734/// `root-for-buffer` returns it for an id the host does not know (untrusted
1735/// input from the guest), `root-for-path` only when no resolver is wired at all
1736/// (a stripped harness). A buffer that *exists* always resolves — one with no
1737/// path on disk reports the working directory with `kind = pwd`.
1738///
1739/// Sync host funcs. Resolution can walk the filesystem on a cache miss, but it
1740/// runs on the plugin's own store and task — never the UI or actor thread.
1741impl crate::lattice::plugin_host::project::Host for PluginState {
1742    fn root_for_buffer(
1743        &mut self,
1744        buffer: u64,
1745    ) -> Option<crate::lattice::plugin_host::project::ProjectInfo> {
1746        let ctx = self.project.as_ref()?;
1747        // A guest-supplied id is untrusted: `path_for` returning `None` is
1748        // ambiguous between "no such buffer" and "buffer has no path", so ask
1749        // the store whether it knows the buffer at all first.
1750        //
1751        // `contains_buffer`, NOT `name_for`. This used to ask `name_for(id)?`
1752        // on the belief that it "answers for every registered buffer, pathless
1753        // ones included" — the opposite of what it does. `name` is the
1754        // synthetic-name slot, so it is `None` for every buffer opened from a
1755        // file, and this seam therefore refused to resolve exactly the buffers
1756        // a user edits. The `project` plugin's `document-opened` handler got
1757        // `none` for every real file, remembered nothing, and
1758        // `:project-switch` reported "no projects remembered yet" no matter how
1759        // long the editor had been running.
1760        let id = lattice_core::BufferId(buffer as u32);
1761        if !ctx.buffers.contains_buffer(id) {
1762            return None;
1763        }
1764        let path = ctx.buffers.path_for(id);
1765        Some(to_wire(match path {
1766            Some(path) => ctx.resolver.for_path(&path),
1767            // Pathless (scratch, terminal): an empty relative path resolves
1768            // against the resolver's own pwd, so the answer stays consistent
1769            // with `:cd` rather than reading the process cwd.
1770            None => ctx.resolver.for_path(std::path::Path::new("")),
1771        }))
1772    }
1773
1774    fn root_for_path(
1775        &mut self,
1776        path: String,
1777    ) -> Option<crate::lattice::plugin_host::project::ProjectInfo> {
1778        let ctx = self.project.as_ref()?;
1779        Some(to_wire(ctx.resolver.for_path(std::path::Path::new(&path))))
1780    }
1781}
1782
1783/// Native [`lattice_core::Project`] → the wire record.
1784fn to_wire(p: lattice_core::Project) -> crate::lattice::plugin_host::project::ProjectInfo {
1785    use crate::lattice::plugin_host::project as wit;
1786    let (kind, marker) = match &p.kind {
1787        lattice_core::ProjectKind::Marker(m) => (wit::ProjectKind::Marker, m.clone()),
1788        // Empty rather than a sentinel word: a guest checking `kind` has the
1789        // answer already, and a guest rendering `marker` should show nothing
1790        // rather than the string "pwd".
1791        lattice_core::ProjectKind::Pwd => (wit::ProjectKind::Pwd, String::new()),
1792    };
1793    wit::ProjectInfo {
1794        root: p.root.display().to_string(),
1795        kind,
1796        marker,
1797    }
1798}
1799
1800/// Host impl of the `logging` guest→host seam (PO.5, Layer 2). Routes each guest
1801/// `log(level, context, message)` into the plugin's tracer as a
1802/// [`Direction::HostImport`](crate::trace::Direction::HostImport) record with
1803/// `seam = logging`, so the guest's own narrative interleaves with the boundary
1804/// trace in `*plugin-trace*`. The `context` becomes the record's `call` (the
1805/// guest's category) and the `message` its `detail`; `tracer.trace` gates it by
1806/// the plugin's level, exactly like a boundary record. A plugin with no `log_ctx`
1807/// wired (the sync grammar guest, or a pre-tracer path) degrades to a debug-drop —
1808/// never a panic (the graceful-failure clause). Sync host func (only a ring push),
1809/// so bindgen omits the outer `wasmtime::Result`.
1810impl crate::lattice::plugin_host::logging::Host for PluginState {
1811    fn log(
1812        &mut self,
1813        level: crate::lattice::plugin_host::logging::Level,
1814        context: String,
1815        message: String,
1816    ) {
1817        use crate::trace::{Direction, PluginTraceRecord, TraceOutcome};
1818        let Some(ctx) = &self.log_ctx else {
1819            tracing::debug!(%context, %message, "plugin log dropped: no tracer wired");
1820            return;
1821        };
1822        ctx.tracer.trace(PluginTraceRecord {
1823            plugin: ctx.plugin,
1824            seam: PluginSeam::Logging,
1825            direction: Direction::HostImport,
1826            // The guest's chosen category rides in `call`; the formatter renders
1827            // `logging <context>: <message>` for Layer-2 records.
1828            call: std::borrow::Cow::Owned(context),
1829            level: map_log_level(level),
1830            // Logging carries no timing/fuel — the outcome is a nominal Ok; the
1831            // formatter shows the message, not the outcome, for logging records.
1832            outcome: TraceOutcome::Ok {
1833                micros: 0,
1834                fuel_delta: 0,
1835            },
1836            detail: Some(message),
1837        });
1838    }
1839}
1840
1841/// Map a `wasi:logging`-shaped [`Level`](crate::lattice::plugin_host::logging::Level)
1842/// to a host [`TraceLevel`](crate::trace::TraceLevel). `critical` folds into
1843/// `Error` (the tracer has no separate critical tier).
1844fn map_log_level(level: crate::lattice::plugin_host::logging::Level) -> crate::trace::TraceLevel {
1845    use crate::lattice::plugin_host::logging::Level;
1846    use crate::trace::TraceLevel;
1847    match level {
1848        Level::Trace => TraceLevel::Trace,
1849        Level::Debug => TraceLevel::Debug,
1850        Level::Info => TraceLevel::Info,
1851        Level::Warn => TraceLevel::Warn,
1852        Level::Error | Level::Critical => TraceLevel::Error,
1853    }
1854}
1855
1856/// Host impl of the `grammar` guest→host register API (PH7.7b, §4.1). The guest
1857/// calls these (from its `register-grammar` export) to contribute vim grammar;
1858/// each records the declaration into the Store's [`grammar_host::GrammarContributions`]
1859/// so the host can drain it after registration and build native `*Spec`s with
1860/// trampoline `apply`s (PH7.7c). Sync + infallible (they only push — recording
1861/// cannot trap; name collisions / registry errors surface at drain time, not
1862/// here), so bindgen omits the outer `wasmtime::Result`. The bodies forward to
1863/// the accumulator (the `host_services` `walk` shape).
1864impl crate::grammar_host::bindings::lattice::plugin_host::grammar::Host for PluginState {
1865    fn register_motion(
1866        &mut self,
1867        name: String,
1868        doc: String,
1869        spec: crate::lattice::plugin_host::types::MotionSpec,
1870        callback: u32,
1871    ) {
1872        self.grammar_contributions
1873            .record_motion(name, doc, spec, callback);
1874    }
1875
1876    fn register_operator(
1877        &mut self,
1878        name: String,
1879        doc: String,
1880        spec: crate::lattice::plugin_host::types::OperatorSpec,
1881        callback: u32,
1882    ) {
1883        self.grammar_contributions
1884            .record_operator(name, doc, spec, callback);
1885    }
1886
1887    fn register_text_object(
1888        &mut self,
1889        name: String,
1890        doc: String,
1891        spec: crate::lattice::plugin_host::types::TextObjectSpec,
1892        callback: u32,
1893    ) {
1894        self.grammar_contributions
1895            .record_text_object(name, doc, spec, callback);
1896    }
1897
1898    fn register_action(
1899        &mut self,
1900        name: String,
1901        doc: String,
1902        spec: crate::lattice::plugin_host::types::ActionSpec,
1903        callback: u32,
1904    ) {
1905        self.grammar_contributions
1906            .record_action(name, doc, spec, callback);
1907    }
1908
1909    fn register_ex_command(
1910        &mut self,
1911        name: String,
1912        doc: String,
1913        spec: crate::lattice::plugin_host::types::ExCommandSpec,
1914        parse_callback: u32,
1915        apply_callback: u32,
1916    ) {
1917        self.grammar_contributions.record_ex_command(
1918            name,
1919            doc,
1920            spec,
1921            parse_callback,
1922            apply_callback,
1923        );
1924    }
1925}
1926
1927/// Host impl of the `buffer` `document` resource (AP.0.1). A grammar action
1928/// receives a `borrow<document>`; each method here resolves the borrowed handle
1929/// out of the Store's `ResourceTable` to the [`DocumentResource`](crate::buffer::DocumentResource)
1930/// the trampoline minted and forwards to its (already unit-tested) reader. Bulk
1931/// rope text never crosses — `get-text-range` slices only the requested span.
1932/// The interface-level `buffer` host trait — no free functions (the interface is
1933/// only the `document` resource + the `buffer-snapshot` record), so it is a
1934/// marker `add_to_linker` requires alongside [`HostDocument`].
1935impl crate::grammar_host::bindings::lattice::plugin_host::buffer::Host for PluginState {}
1936
1937impl crate::grammar_host::bindings::lattice::plugin_host::buffer::HostDocument for PluginState {
1938    fn get_text_range(
1939        &mut self,
1940        self_: wasmtime::component::Resource<crate::buffer::DocumentResource>,
1941        r: crate::lattice::plugin_host::types::Range,
1942    ) -> Result<String, String> {
1943        let native = lattice_protocol::position::Range::from_wit(r)?;
1944        let doc = self
1945            .table
1946            .get(&self_)
1947            .map_err(|e| format!("document handle: {e}"))?;
1948        doc.get_text_range(native)
1949    }
1950
1951    fn line_count(
1952        &mut self,
1953        self_: wasmtime::component::Resource<crate::buffer::DocumentResource>,
1954    ) -> u32 {
1955        self.table.get(&self_).map(|d| d.line_count()).unwrap_or(0)
1956    }
1957
1958    fn byte_len(
1959        &mut self,
1960        self_: wasmtime::component::Resource<crate::buffer::DocumentResource>,
1961    ) -> u64 {
1962        self.table.get(&self_).map(|d| d.byte_len()).unwrap_or(0)
1963    }
1964
1965    fn line(
1966        &mut self,
1967        self_: wasmtime::component::Resource<crate::buffer::DocumentResource>,
1968        n: u32,
1969    ) -> Option<String> {
1970        self.table.get(&self_).ok().and_then(|d| d.line_at(n))
1971    }
1972
1973    fn path(
1974        &mut self,
1975        self_: wasmtime::component::Resource<crate::buffer::DocumentResource>,
1976    ) -> Option<String> {
1977        self.table.get(&self_).ok().and_then(|d| d.path())
1978    }
1979
1980    fn drop(
1981        &mut self,
1982        rep: wasmtime::component::Resource<crate::buffer::DocumentResource>,
1983    ) -> wasmtime::Result<()> {
1984        // The host lends `borrow<document>` handles and reclaims the owned table
1985        // entry itself in the trampoline, so a guest never owns one to drop; this
1986        // fires only if that invariant changes. Delete defensively, ignore a
1987        // missing entry (already reclaimed) — never a trap on teardown.
1988        let _ = self.table.delete(rep);
1989        Ok(())
1990    }
1991}
1992
1993// TS.1: the `tree-sitter` interface `Host` — the marker bindgen requires
1994// alongside `HostTreeSnapshot` / `HostNode` (the `buffer::Host` shape), plus
1995// OT.2's one free function.
1996impl crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::Host for PluginState {
1997    /// OT.2: parse an off-buffer file and mint a `tree-snapshot` for it.
1998    ///
1999    /// Every failure is `None`, never a trap — see the WIT for why the caller
2000    /// cannot usefully tell them apart. Each arm logs at the level its cause
2001    /// deserves: a denied grant is user-actionable (`info!`, matching
2002    /// `read-file`'s and `walk`'s level), everything else is diagnostic
2003    /// (`debug!`) because a project walk hitting unparseable files is the
2004    /// ordinary case, not a problem, and `info!` here would flood
2005    /// `*messages*` on a refile over a large tree.
2006    fn parse_file(
2007        &mut self,
2008        path: String,
2009    ) -> Option<wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>> {
2010        // The capability gate first: cheapest, and the one that is a policy
2011        // answer rather than a fact about the file.
2012        if !self
2013            .grant
2014            .editor
2015            .contains(lattice_mode::CapabilitySet::TREE_SITTER)
2016        {
2017            tracing::info!(
2018                %path,
2019                "parse-file denied: plugin lacks the tree-sitter capability"
2020            );
2021            return None;
2022        }
2023        // Then the fs grant — the SAME check `read-file` makes, so a plugin
2024        // cannot reach further with a parse than it can with a read.
2025        let source = match host_services::read_within_grant(&self.grant, &path) {
2026            Ok(text) => text,
2027            Err(error) => {
2028                tracing::debug!(%path, %error, "parse-file: read failed");
2029                return None;
2030            }
2031        };
2032        // Resolve the language exactly as a buffer's would — native arms first,
2033        // then the plugin registry — so a plugin gets a tree for its own
2034        // registered extension for the same reason the editor would.
2035        let lang = lattice_syntax::Lang::detect_from_path(Some(std::path::Path::new(&path)));
2036        let mut syntax = match lattice_syntax::Syntax::for_language(lang) {
2037            Ok(Some(syntax)) => syntax,
2038            // `Plain`, or a language whose grammar is not loadable here.
2039            Ok(None) => {
2040                tracing::debug!(%path, ?lang, "parse-file: no grammar for this extension");
2041                return None;
2042            }
2043            Err(error) => {
2044                tracing::debug!(%path, ?lang, %error, "parse-file: grammar load failed");
2045                return None;
2046            }
2047        };
2048        syntax.parse(&source);
2049        let snapshot = std::sync::Arc::new(syntax.snapshot_owned());
2050        // A snapshot can exist with no tree behind it. Minting a handle whose
2051        // every method answers nothing would be worse than `none`, which says
2052        // so — the same filter the buffer path applies.
2053        if snapshot.tree().is_none() {
2054            tracing::debug!(%path, ?lang, "parse-file: parsed to no tree");
2055            return None;
2056        }
2057        match self
2058            .table
2059            .push(crate::tree_resource::TreeSnapshotResource::new(snapshot))
2060        {
2061            Ok(resource) => Some(resource),
2062            Err(error) => {
2063                tracing::debug!(%path, %error, "parse-file: resource table push failed");
2064                None
2065            }
2066        }
2067    }
2068}
2069
2070impl crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::HostTreeSnapshot
2071    for PluginState
2072{
2073    fn root(
2074        &mut self,
2075        self_: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2076    ) -> wasmtime::component::Resource<crate::tree_resource::NodeResource> {
2077        let node = self
2078            .table
2079            .get(&self_)
2080            .expect("tree-snapshot handle live for the call")
2081            .root();
2082        self.table.push(node).expect("node resource table push")
2083    }
2084
2085    fn node_at(
2086        &mut self,
2087        self_: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2088        pos: crate::lattice::plugin_host::types::Position,
2089    ) -> Option<wasmtime::component::Resource<crate::tree_resource::NodeResource>> {
2090        let pos = lattice_protocol::position::Position::from_wit(pos).ok()?;
2091        let node = self.table.get(&self_).ok()?.node_at(pos)?;
2092        self.table.push(node).ok()
2093    }
2094
2095    fn enclosing(
2096        &mut self,
2097        self_: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2098        pos: crate::lattice::plugin_host::types::Position,
2099        kinds: Vec<String>,
2100    ) -> Option<wasmtime::component::Resource<crate::tree_resource::NodeResource>> {
2101        let pos = lattice_protocol::position::Position::from_wit(pos).ok()?;
2102        let node = self.table.get(&self_).ok()?.enclosing(pos, &kinds)?;
2103        self.table.push(node).ok()
2104    }
2105
2106    fn language(
2107        &mut self,
2108        self_: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2109    ) -> String {
2110        self.table
2111            .get(&self_)
2112            .map(|ts| ts.language())
2113            .unwrap_or_default()
2114    }
2115
2116    fn compile_query(
2117        &mut self,
2118        self_: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2119        source: String,
2120    ) -> Result<wasmtime::component::Resource<crate::tree_resource::QueryResource>, String> {
2121        let query = self
2122            .table
2123            .get(&self_)
2124            .map_err(|e| format!("tree-snapshot handle: {e}"))?
2125            .compile_query(&source)?;
2126        self.table
2127            .push(query)
2128            .map_err(|e| format!("query resource push: {e}"))
2129    }
2130
2131    fn run_query(
2132        &mut self,
2133        self_: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2134        q: wasmtime::component::Resource<crate::tree_resource::QueryResource>,
2135        within: Option<crate::lattice::plugin_host::types::Range>,
2136    ) -> Vec<crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::Capture> {
2137        let within = within.and_then(|r| lattice_protocol::position::Range::from_wit(r).ok());
2138        // Collect the (owned) NodeResource captures while borrowing the table,
2139        // then release the borrows and push each into the table (a mutable
2140        // borrow) as an owned `node` handle the guest receives + drops.
2141        let results = {
2142            let (Ok(ts), Ok(qr)) = (self.table.get(&self_), self.table.get(&q)) else {
2143                return Vec::new();
2144            };
2145            ts.run_query(qr, within)
2146        };
2147        results
2148            .into_iter()
2149            .filter_map(|(name, node)| {
2150                let node = self.table.push(node).ok()?;
2151                Some(
2152                    crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::Capture {
2153                        name,
2154                        node,
2155                    },
2156                )
2157            })
2158            .collect()
2159    }
2160
2161    fn run_query_ranges(
2162        &mut self,
2163        self_: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2164        q: wasmtime::component::Resource<crate::tree_resource::QueryResource>,
2165        within: Option<crate::lattice::plugin_host::types::Range>,
2166    ) -> Vec<crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::CaptureRange> {
2167        let within = within.and_then(|r| lattice_protocol::position::Range::from_wit(r).ok());
2168        // No second pass over the table: a range is a value, so unlike
2169        // `run_query` this never needs a mutable table borrow to mint handles.
2170        let (Ok(ts), Ok(qr)) = (self.table.get(&self_), self.table.get(&q)) else {
2171            return Vec::new();
2172        };
2173        ts.run_query_ranges(qr, within)
2174            .into_iter()
2175            // A range that will not convert is dropped rather than trapping:
2176            // one unrepresentable capture must not fail the whole query.
2177            .filter_map(|(name, match_index, range)| {
2178                Some(
2179                    crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::CaptureRange {
2180                        name,
2181                        match_index,
2182                        range: range.to_wit().ok()?,
2183                    },
2184                )
2185            })
2186            .collect()
2187    }
2188
2189    fn drop(
2190        &mut self,
2191        rep: wasmtime::component::Resource<crate::tree_resource::TreeSnapshotResource>,
2192    ) -> wasmtime::Result<()> {
2193        // The trampoline lends `borrow<tree-snapshot>` and reclaims the owned
2194        // entry itself (the `document` discipline); a guest never owns one. Delete
2195        // defensively, ignore a missing entry — never a trap on teardown.
2196        let _ = self.table.delete(rep);
2197        Ok(())
2198    }
2199}
2200
2201impl crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::HostNode for PluginState {
2202    fn kind(
2203        &mut self,
2204        self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2205    ) -> String {
2206        self.table.get(&self_).map(|n| n.kind()).unwrap_or_default()
2207    }
2208
2209    fn is_named(
2210        &mut self,
2211        self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2212    ) -> bool {
2213        self.table
2214            .get(&self_)
2215            .map(|n| n.is_named())
2216            .unwrap_or(false)
2217    }
2218
2219    fn is_error(
2220        &mut self,
2221        self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2222    ) -> bool {
2223        self.table
2224            .get(&self_)
2225            .map(|n| n.is_error())
2226            .unwrap_or(false)
2227    }
2228
2229    fn byte_range(
2230        &mut self,
2231        self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2232    ) -> crate::lattice::plugin_host::types::Range {
2233        let r = self.table.get(&self_).map(|n| n.byte_range()).unwrap_or(
2234            lattice_protocol::position::Range {
2235                start: lattice_protocol::position::Position { line: 0, byte: 0 },
2236                end: lattice_protocol::position::Position { line: 0, byte: 0 },
2237            },
2238        );
2239        crate::lattice::plugin_host::types::Range {
2240            start: crate::lattice::plugin_host::types::Position {
2241                line: r.start.line,
2242                byte: r.start.byte,
2243            },
2244            end: crate::lattice::plugin_host::types::Position {
2245                line: r.end.line,
2246                byte: r.end.byte,
2247            },
2248        }
2249    }
2250
2251    fn parent(
2252        &mut self,
2253        self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2254    ) -> Option<wasmtime::component::Resource<crate::tree_resource::NodeResource>> {
2255        let node = self.table.get(&self_).ok()?.parent()?;
2256        self.table.push(node).ok()
2257    }
2258
2259    fn named_child_count(
2260        &mut self,
2261        self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2262    ) -> u32 {
2263        self.table
2264            .get(&self_)
2265            .map(|n| n.named_child_count())
2266            .unwrap_or(0)
2267    }
2268
2269    fn named_child(
2270        &mut self,
2271        self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2272        index: u32,
2273    ) -> Option<wasmtime::component::Resource<crate::tree_resource::NodeResource>> {
2274        let node = self.table.get(&self_).ok()?.named_child(index)?;
2275        self.table.push(node).ok()
2276    }
2277
2278    fn child_by_field(
2279        &mut self,
2280        self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2281        name: String,
2282    ) -> Option<wasmtime::component::Resource<crate::tree_resource::NodeResource>> {
2283        let node = self.table.get(&self_).ok()?.child_by_field(&name)?;
2284        self.table.push(node).ok()
2285    }
2286
2287    fn next_named_sibling(
2288        &mut self,
2289        self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2290    ) -> Option<wasmtime::component::Resource<crate::tree_resource::NodeResource>> {
2291        let node = self.table.get(&self_).ok()?.next_named_sibling()?;
2292        self.table.push(node).ok()
2293    }
2294
2295    fn prev_named_sibling(
2296        &mut self,
2297        self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2298    ) -> Option<wasmtime::component::Resource<crate::tree_resource::NodeResource>> {
2299        let node = self.table.get(&self_).ok()?.prev_named_sibling()?;
2300        self.table.push(node).ok()
2301    }
2302
2303    fn walk(
2304        &mut self,
2305        self_: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2306    ) -> wasmtime::component::Resource<crate::tree_resource::CursorResource> {
2307        let cursor = self
2308            .table
2309            .get(&self_)
2310            .expect("node handle live for the call")
2311            .walk();
2312        self.table.push(cursor).expect("cursor resource table push")
2313    }
2314
2315    fn drop(
2316        &mut self,
2317        rep: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2318    ) -> wasmtime::Result<()> {
2319        // Nodes ARE owned by the guest (unlike the lent snapshot handle); the
2320        // guest's generated RAII wrapper drops them when they leave scope. Delete
2321        // the table entry, ignoring a missing one — never a trap.
2322        let _ = self.table.delete(rep);
2323        Ok(())
2324    }
2325}
2326
2327// TS.2: the `query` resource is opaque — no methods beyond the implicit drop.
2328impl crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::HostQuery for PluginState {
2329    fn drop(
2330        &mut self,
2331        rep: wasmtime::component::Resource<crate::tree_resource::QueryResource>,
2332    ) -> wasmtime::Result<()> {
2333        // Guest-owned (returned by `compile-query`); dropped by the guest's RAII
2334        // wrapper. Delete defensively, ignore a missing entry — never a trap.
2335        let _ = self.table.delete(rep);
2336        Ok(())
2337    }
2338}
2339
2340impl crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::HostTreeCursor
2341    for PluginState
2342{
2343    fn current_node(
2344        &mut self,
2345        self_: wasmtime::component::Resource<crate::tree_resource::CursorResource>,
2346    ) -> wasmtime::component::Resource<crate::tree_resource::NodeResource> {
2347        let node = self
2348            .table
2349            .get(&self_)
2350            .expect("cursor handle live for the call")
2351            .current_node();
2352        self.table.push(node).expect("node resource table push")
2353    }
2354
2355    fn current_field(
2356        &mut self,
2357        self_: wasmtime::component::Resource<crate::tree_resource::CursorResource>,
2358    ) -> Option<String> {
2359        self.table.get(&self_).ok().and_then(|c| c.current_field())
2360    }
2361
2362    fn goto_first_named_child(
2363        &mut self,
2364        self_: wasmtime::component::Resource<crate::tree_resource::CursorResource>,
2365    ) -> bool {
2366        self.table
2367            .get_mut(&self_)
2368            .map(|c| c.goto_first_named_child())
2369            .unwrap_or(false)
2370    }
2371
2372    fn goto_next_named_sibling(
2373        &mut self,
2374        self_: wasmtime::component::Resource<crate::tree_resource::CursorResource>,
2375    ) -> bool {
2376        self.table
2377            .get_mut(&self_)
2378            .map(|c| c.goto_next_named_sibling())
2379            .unwrap_or(false)
2380    }
2381
2382    fn goto_parent(
2383        &mut self,
2384        self_: wasmtime::component::Resource<crate::tree_resource::CursorResource>,
2385    ) -> bool {
2386        self.table
2387            .get_mut(&self_)
2388            .map(|c| c.goto_parent())
2389            .unwrap_or(false)
2390    }
2391
2392    fn reset(
2393        &mut self,
2394        self_: wasmtime::component::Resource<crate::tree_resource::CursorResource>,
2395        n: wasmtime::component::Resource<crate::tree_resource::NodeResource>,
2396    ) {
2397        // Read the target node's path (releasing that borrow) before mutating the
2398        // cursor — the two live in the same table.
2399        let Some(path) = self.table.get(&n).ok().map(|nr| nr.path().to_vec()) else {
2400            return;
2401        };
2402        if let Ok(cursor) = self.table.get_mut(&self_) {
2403            cursor.reset_to_path(path);
2404        }
2405    }
2406
2407    fn drop(
2408        &mut self,
2409        rep: wasmtime::component::Resource<crate::tree_resource::CursorResource>,
2410    ) -> wasmtime::Result<()> {
2411        let _ = self.table.delete(rep);
2412        Ok(())
2413    }
2414}
2415
2416/// Host impl of the `events` guest→host subscription API (PH7.8b, §5). The guest
2417/// calls `subscribe` (from its `register-events` export) to observe editor
2418/// events; each records the `(filter, handler)` pair into the Store's
2419/// [`events_host::EventContributions`] so the host can drain it after
2420/// registration and wire each subscription to the native `EventBus` (PH7.8c).
2421/// Sync + infallible (it only pushes — recording cannot trap), so bindgen omits
2422/// the outer `wasmtime::Result` (the `grammar` / `host_services` shape). The
2423/// filter stays WIT-typed here and projects to native at wire time.
2424impl crate::events_host::bindings::lattice::plugin_host::events::Host for PluginState {
2425    fn subscribe(&mut self, filter: crate::lattice::plugin_host::types::EventFilter, handler: u32) {
2426        self.event_subscriptions.record(filter, handler);
2427    }
2428
2429    /// OC.2 `wake-every`: arm a periodic wake, answered by `on-wake(id)` on this
2430    /// plugin's own actor task. Returns `0` when no wake context is wired — a
2431    /// store whose actor cannot fire one, or a host with no [`SleeperHandle`]
2432    /// installed. Degrading rather than trapping is the four-artefact
2433    /// graceful-failure clause; the `warn!` is what makes the nothing visible.
2434    fn wake_every(&mut self, ms: u32) -> u32 {
2435        let plugin = self.event_emit.as_ref().map_or(0, |c| c.plugin_id.0);
2436        match self.wake.as_mut() {
2437            Some(ctx) => ctx.arm(plugin, ms),
2438            None => {
2439                tracing::warn!(ms, "wake-every refused: no wake mechanism on this seam");
2440                0
2441            }
2442        }
2443    }
2444
2445    /// OC.2 `cancel-wake`: disarm. Idempotent by contract, including on a store
2446    /// with no wake context at all.
2447    fn cancel_wake(&mut self, id: u32) {
2448        let plugin = self.event_emit.as_ref().map_or(0, |c| c.plugin_id.0);
2449        if let Some(ctx) = self.wake.as_mut() {
2450            ctx.cancel(plugin, id);
2451        }
2452    }
2453}
2454
2455/// Host impl of the `config` guest→host option seam (PH7.10, §5). The guest calls
2456/// `register-option` (from its `register-options` export) to declare options and
2457/// `get-option` to read any option's current string value. Both are sync +
2458/// non-trapping, so bindgen omits the outer `wasmtime::Result` (the
2459/// `host-services` `walk` shape). `register-option` maps the WIT `option-type` to
2460/// a native `OptionType` and registers into the SAME `ConfigRegistry` core
2461/// options use ([`config_host::register_plugin_option`]); a plugin with no
2462/// registry wired degrades to `false` / `none` (never a panic — the
2463/// four-artefact graceful-failure clause).
2464impl crate::theme_host::bindings::lattice::plugin_host::theme::Host for PluginState {
2465    fn register_element(
2466        &mut self,
2467        name: String,
2468        doc: String,
2469        default: crate::theme_host::bindings::lattice::plugin_host::theme::StyleSpec,
2470    ) -> Result<(), String> {
2471        let Some(registry) = self.theme_registry.clone() else {
2472            // Graceful, not a trap: a harness with no theme service wired is a
2473            // test shape, not a plugin error. The element simply does not
2474            // exist and rows fall back to their default style.
2475            tracing::warn!(
2476                element = %name,
2477                "register-element ignored: plugin has no theme registry wired"
2478            );
2479            return Ok(());
2480        };
2481        // Auto-namespaced by manifest id, exactly like `register-option`, so a
2482        // plugin cannot squat a bare name or shadow a builtin. An internal
2483        // caller with no manifest id gets no prefix.
2484        let Some(plugin_id) = self.plugin_name.clone() else {
2485            return Err("register-element requires a plugin identity".to_string());
2486        };
2487        let spec = crate::theme_host::style_spec_from_wit(default);
2488        let full =
2489            crate::theme_host::register_plugin_element(&*registry, &plugin_id, &name, &doc, spec);
2490        self.theme_contributions.push(full);
2491        Ok(())
2492    }
2493
2494    /// TK.5: an override for an element this plugin owns, above the theme.
2495    ///
2496    /// `register-element` supplies a default, which sits BELOW the active
2497    /// theme — so a plugin could not express "the user configured this and it
2498    /// must win" without this.
2499    fn set_element_override(
2500        &mut self,
2501        name: String,
2502        style: crate::theme_host::bindings::lattice::plugin_host::theme::StyleSpec,
2503    ) -> Result<(), String> {
2504        let Some(registry) = self.theme_registry.clone() else {
2505            // Same graceful shape as `register_element`: a harness with no
2506            // theme service is a test shape, not a plugin error.
2507            tracing::warn!(
2508                element = %name,
2509                "set-element-override ignored: plugin has no theme registry wired"
2510            );
2511            return Ok(());
2512        };
2513        let Some(plugin_id) = self.plugin_name.clone() else {
2514            return Err("set-element-override requires a plugin identity".to_string());
2515        };
2516        let spec = crate::theme_host::style_spec_from_wit(style);
2517        crate::theme_host::set_plugin_element_override(&*registry, &plugin_id, &name, spec)
2518    }
2519}
2520
2521/// SG.3a: the `signs` seam's single host func.
2522///
2523/// Namespacing and the copy-on-write registry write live in [`sign_host`] as
2524/// free functions so they are unit-testable without a `Store` (the
2525/// `theme_host` precedent). This body is the thin part: derive the namespace
2526/// from the manifest id the HOST holds — never from anything the guest passed
2527/// — and record the teardown token.
2528impl crate::sign_host::bindings::lattice::plugin_host::signs::Host for PluginState {
2529    fn define_sign(
2530        &mut self,
2531        name: String,
2532        spec: crate::sign_host::bindings::lattice::plugin_host::signs::SignSpec,
2533    ) -> Result<(), String> {
2534        let Some(registry) = self.sign_registry.clone() else {
2535            // Graceful, not a trap: a harness with no sign registry wired is a
2536            // test shape, not a plugin error. The sign simply does not exist,
2537            // and a placement naming it paints nothing rather than something
2538            // wrong.
2539            tracing::warn!(
2540                sign = %name,
2541                "define-sign ignored: plugin has no sign registry wired"
2542            );
2543            return Ok(());
2544        };
2545        // Auto-namespaced by manifest id, exactly like `register-element`, so a
2546        // plugin cannot squat a bare name or shadow a native producer's sign.
2547        let Some(plugin_id) = self.plugin_name.clone() else {
2548            return Err("define-sign requires a plugin identity".to_string());
2549        };
2550        let full = crate::sign_host::define_plugin_sign(&registry, &plugin_id, &name, spec);
2551        self.sign_contributions.push(full);
2552        Ok(())
2553    }
2554}
2555
2556/// CR.3: the `help` seam's single host func.
2557///
2558/// Validation and namespacing live in [`help_host`] as free functions so they
2559/// are unit-testable without a `Store` (the `config_host` / `theme_host`
2560/// precedent). This body is the thin part: derive the namespace from the
2561/// manifest id the HOST holds — never from anything the guest passed — and
2562/// record.
2563///
2564/// A rejection is an `Err` back to the guest, not a trap. One malformed page
2565/// costs itself; the plugin's other pages still register, and the load still
2566/// succeeds. That matches how every other declaration seam here treats bad
2567/// guest data.
2568impl crate::help_host::bindings::lattice::plugin_host::help::Host for PluginState {
2569    fn register_topic(
2570        &mut self,
2571        name: String,
2572        summary: String,
2573        body: String,
2574        related_commands: Vec<String>,
2575    ) -> Result<(), String> {
2576        let plugin_id = self.plugin_name.clone().unwrap_or_default();
2577        match crate::help_host::validate_topic(&plugin_id, &name, &summary, &body, related_commands)
2578        {
2579            Ok(spec) => {
2580                self.help_contributions.push(spec);
2581                Ok(())
2582            }
2583            Err(err) => {
2584                tracing::warn!(topic = %name, %err, "register-topic rejected");
2585                Err(err)
2586            }
2587        }
2588    }
2589}
2590
2591/// LG.3c: the `language` seam's declaration host func.
2592///
2593/// Records the guest's bytes and query sources; compiling the grammar is the
2594/// loader's job, after this store is gone (see `language_host`). A rejection
2595/// is an `Err` back to the guest, not a trap — one malformed language costs
2596/// itself, the plugin's others still register, and the load still succeeds.
2597impl crate::language_host::bindings::lattice::plugin_host::language::Host for PluginState {
2598    fn register_language(
2599        &mut self,
2600        spec: crate::language_host::bindings::lattice::plugin_host::language::LanguageSpec,
2601    ) -> Result<(), String> {
2602        let declared = spec.name.clone();
2603        match crate::language_host::validate_language(spec) {
2604            Ok(spec) => {
2605                self.language_contributions.push(spec);
2606                Ok(())
2607            }
2608            Err(err) => {
2609                tracing::warn!(language = %declared, %err, "register-language rejected");
2610                Err(err)
2611            }
2612        }
2613    }
2614}
2615
2616/// CR.4: the `dashboard` seam's declaration host func.
2617///
2618/// Records only — the host instantiates the live per-section guests after the
2619/// export returns. Section ids are NOT namespaced (see `dashboard.wit`):
2620/// replacing a built-in section by id is a supported capability, so the
2621/// namespace that defuses `help`'s collisions would defeat this seam's.
2622impl crate::dashboard_host::bindings::lattice::plugin_host::dashboard::Host for PluginState {
2623    fn register_section(
2624        &mut self,
2625        id: String,
2626        order: i32,
2627        default_enabled: bool,
2628    ) -> Result<(), String> {
2629        match crate::dashboard_host::validate_section(&id, order, default_enabled) {
2630            Ok(spec) => {
2631                self.dashboard_contributions.push(spec);
2632                Ok(())
2633            }
2634            Err(err) => {
2635                tracing::warn!(section = %id, %err, "register-section rejected");
2636                Err(err)
2637            }
2638        }
2639    }
2640}
2641
2642impl crate::config_host::bindings::lattice::plugin_host::config::Host for PluginState {
2643    fn register_option(
2644        &mut self,
2645        name: String,
2646        ty: crate::config_host::bindings::lattice::plugin_host::config::OptionType,
2647        default: String,
2648        doc: String,
2649    ) -> bool {
2650        use crate::config_host::PluginOptionKind;
2651        use crate::config_host::bindings::lattice::plugin_host::config::OptionType as WitOptionType;
2652        let kind = match ty {
2653            WitOptionType::Boolean => PluginOptionKind::Boolean,
2654            WitOptionType::Integer => PluginOptionKind::Integer,
2655            WitOptionType::String => PluginOptionKind::String,
2656        };
2657        // Auto-namespace the plugin's OWN option by its manifest id: a plugin
2658        // registering `style` contributes `auto-pair.style`, so plugins can't
2659        // collide in the global option namespace. No prefix for an internal
2660        // caller with no manifest id.
2661        let full = match &self.plugin_name {
2662            Some(id) => format!("{id}.{name}"),
2663            None => name,
2664        };
2665        // The `&self.config_registry` borrow ends with the match (the result is a
2666        // plain `bool`), so the `config_contributions` push below doesn't overlap.
2667        let registered = match &self.config_registry {
2668            Some(registry) => {
2669                config_host::register_plugin_option(registry, &full, kind, &default, &doc)
2670            }
2671            None => {
2672                tracing::warn!(
2673                    option = %full,
2674                    "register-option ignored: plugin has no config registry wired"
2675                );
2676                false
2677            }
2678        };
2679        if registered {
2680            self.config_contributions.push(full);
2681        }
2682        registered
2683    }
2684
2685    fn get_option(&mut self, name: String) -> Option<String> {
2686        let registry = self.config_registry.as_ref()?;
2687        // The plugin's OWN namespace first (`style` → `auto-pair.style`), then the
2688        // raw name — so a plugin reads its options with short names AND can still
2689        // read a core option (`tabstop`) that isn't in its namespace.
2690        if let Some(id) = &self.plugin_name
2691            && let Some(opt) = registry.lookup(&format!("{id}.{name}"))
2692        {
2693            return Some(opt.get_formatted());
2694        }
2695        registry.lookup(&name).map(|opt| opt.get_formatted())
2696    }
2697
2698    /// OC.11c: `option-diagnostic` — did the last assignment to this option
2699    /// fail, and what did it say?
2700    ///
2701    /// The seam exists because a failed assignment is a NO-OP: the option
2702    /// keeps its previous value, so a plugin reading it back cannot tell "the
2703    /// user configured this and it did not parse" from "the user never
2704    /// configured this". org-capture filed notes through a legacy fallback for
2705    /// exactly that reason.
2706    ///
2707    /// Own namespace first, then the raw name — `get-option`'s resolution, so
2708    /// a plugin asks about its own option with the short name it declared.
2709    fn option_diagnostic(
2710        &mut self,
2711        name: String,
2712    ) -> Option<crate::config_host::bindings::lattice::plugin_host::config::ConfigDiagnostic> {
2713        let registry = self.config_registry.as_ref()?;
2714        let found = self
2715            .plugin_name
2716            .as_ref()
2717            .and_then(|id| registry.failed_assignment(&format!("{id}.{name}")))
2718            .or_else(|| registry.failed_assignment(&name))?;
2719        Some(
2720            crate::config_host::bindings::lattice::plugin_host::config::ConfigDiagnostic {
2721                message: found.message,
2722                // Empty rather than optional across the boundary: WIT has
2723                // `option<string>` but an absent source and an empty one mean
2724                // the same thing to every caller — "not from a file" — and one
2725                // representation is one fewer thing for a guest to get wrong.
2726                source: found
2727                    .source
2728                    .map(|p| p.display().to_string())
2729                    .unwrap_or_default(),
2730            },
2731        )
2732    }
2733
2734    /// TC.3: `register-structured-option` — declare an option whose value has
2735    /// structure. The schema-taking peer of `register-option`, with the same
2736    /// namespacing and the same collision rule.
2737    ///
2738    /// `false` on: a malformed arena (a bad index or a cycle — see
2739    /// `boundary_config`), a `default` that does not fit the declared `schema`,
2740    /// or a name collision. In every case NOTHING is registered: an option that
2741    /// exists and cannot hold a legal value is worse than one that does not
2742    /// exist, because the plugin's own reads then fall back to a default it
2743    /// never chose.
2744    fn register_structured_option(
2745        &mut self,
2746        name: String,
2747        schema: crate::config_host::bindings::lattice::plugin_host::config::ConfigSchema,
2748        default: crate::config_host::bindings::lattice::plugin_host::config::ConfigValue,
2749        doc: String,
2750    ) -> bool {
2751        let Some(registry) = self.config_registry.as_ref() else {
2752            tracing::warn!(
2753                option = %name,
2754                "register-structured-option ignored: plugin has no config registry wired"
2755            );
2756            return false;
2757        };
2758        // Auto-namespaced exactly like `register-option`: the host owns the
2759        // namespace so plugins cannot collide, and a user sets it as
2760        // `:set org.capture-templates=…`.
2761        let full = match &self.plugin_name {
2762            Some(id) => format!("{id}.{name}"),
2763            None => name.clone(),
2764        };
2765        let schema = match crate::boundary_config::schema_from_wit(&schema) {
2766            Ok(s) => s,
2767            Err(error) => {
2768                tracing::warn!(option = %full, %error, "register-structured-option: bad schema");
2769                return false;
2770            }
2771        };
2772        let default = match crate::boundary_config::value_from_wit(&default) {
2773            Ok(v) => v,
2774            Err(error) => {
2775                tracing::warn!(option = %full, %error, "register-structured-option: bad default");
2776                return false;
2777            }
2778        };
2779        match crate::config_host::register_structured_option(registry, &full, schema, default, &doc)
2780        {
2781            Ok(()) => {
2782                self.config_contributions.push(full);
2783                true
2784            }
2785            Err(error) => {
2786                tracing::warn!(option = %full, %error, "register-structured-option rejected");
2787                false
2788            }
2789        }
2790    }
2791
2792    /// TC.3: `get-option-value` — an option's current value as a tree.
2793    ///
2794    /// Works for scalar options too, a scalar being a degenerate schema, so a
2795    /// guest that wants typed reads everywhere uses this one call rather than
2796    /// choosing per option.
2797    fn get_option_value(
2798        &mut self,
2799        name: String,
2800    ) -> Option<crate::config_host::bindings::lattice::plugin_host::config::ConfigValue> {
2801        let registry = self.config_registry.as_ref()?;
2802        // Own namespace first, then the raw name — `get-option`'s resolution,
2803        // because a guest that switches to typed reads must not also have to
2804        // change every name it passes.
2805        if let Some(id) = &self.plugin_name
2806            && let Some(opt) = registry.lookup(&format!("{id}.{name}"))
2807        {
2808            return Some(crate::boundary_config::value_to_wit(&opt.get_value()));
2809        }
2810        registry
2811            .lookup(&name)
2812            .map(|opt| crate::boundary_config::value_to_wit(&opt.get_value()))
2813    }
2814
2815    /// TC.3: `set-option-value` — set an option from a tree.
2816    ///
2817    /// Validated against the option's declared schema, so a bad field is
2818    /// refused with a PATH rather than by whatever message the plugin would
2819    /// have written. `false` on an unknown option / a value that does not fit /
2820    /// no registry — never a trap, `set-option`'s contract.
2821    fn set_option_value(
2822        &mut self,
2823        name: String,
2824        value: crate::config_host::bindings::lattice::plugin_host::config::ConfigValue,
2825    ) -> bool {
2826        let Some(registry) = self.config_registry.as_ref() else {
2827            tracing::warn!(
2828                option = %name,
2829                "set-option-value ignored: plugin has no config registry wired"
2830            );
2831            return false;
2832        };
2833        let target = self
2834            .plugin_name
2835            .as_ref()
2836            .map(|id| format!("{id}.{name}"))
2837            .filter(|full| registry.lookup(full).is_some())
2838            .unwrap_or(name);
2839        let Some(opt) = registry.lookup(&target) else {
2840            tracing::warn!(option = %target, "set-option-value failed: unknown option");
2841            return false;
2842        };
2843        let value = match crate::boundary_config::value_from_wit(&value) {
2844            Ok(v) => v,
2845            Err(error) => {
2846                tracing::warn!(option = %target, %error, "set-option-value: malformed value");
2847                return false;
2848            }
2849        };
2850        match opt.set_value(&value) {
2851            Ok(()) => true,
2852            Err(error) => {
2853                tracing::warn!(option = %target, %error, "set-option-value rejected");
2854                false
2855            }
2856        }
2857    }
2858
2859    /// `set-option` (CI.7): override an existing option's value via the SAME
2860    /// `parse_and_set_command` path `:set name=value` uses (coerce + validate +
2861    /// publish `OptionChanged`). `false` on an unknown option / invalid value /
2862    /// no registry — a logged no-op, never a trap.
2863    /// `set-option-in-buffer`: the `:setlocal` front-end for a guest.
2864    ///
2865    /// Publishes a host-internal request rather than writing, for
2866    /// `enable-mode`'s reason: the buffer-local override layer lives on the
2867    /// Editor (`buffer_local_overrides`), and the plugin host holds a
2868    /// `ConfigRegistry` handle — which is the GLOBAL layer and the wrong scope
2869    /// entirely. Writing there is what `set-option` already does.
2870    ///
2871    /// Returns `false` only for what can be judged HERE: no bus, or an option
2872    /// name the registry does not know. Validity of the value is the Editor's
2873    /// to judge when it parses, so a `true` means "the request was sent and
2874    /// names a real option", not "the value stuck".
2875    fn set_option_in_buffer(&mut self, buffer: u64, name: String, value: String) -> bool {
2876        // Namespace resolution matches `set-option`: the plugin's OWN option
2877        // wins on a short name, so a config can still reach a core option by
2878        // its full name.
2879        let target = match self.config_registry.as_ref() {
2880            Some(registry) => {
2881                let namespaced = self
2882                    .plugin_name
2883                    .as_ref()
2884                    .map(|id| format!("{id}.{name}"))
2885                    .filter(|full| registry.lookup(full).is_some());
2886                match namespaced {
2887                    Some(full) => full,
2888                    None if registry.lookup(&name).is_some() => name,
2889                    None => {
2890                        tracing::warn!(
2891                            option = %name,
2892                            "set-option-in-buffer ignored: no option by that name"
2893                        );
2894                        return false;
2895                    }
2896                }
2897            }
2898            None => {
2899                tracing::warn!(
2900                    option = %name,
2901                    "set-option-in-buffer ignored: plugin has no config registry wired"
2902                );
2903                return false;
2904            }
2905        };
2906        match &self.event_emit {
2907            Some(ctx) => {
2908                ctx.bus
2909                    .publish(lattice_protocol::Event::BufferOptionOverrideRequested {
2910                        buffer: lattice_protocol::ids::BufferId::new(buffer),
2911                        option: format!("{target}={value}"),
2912                    });
2913                true
2914            }
2915            None => {
2916                tracing::warn!(
2917                    option = %target,
2918                    "set-option-in-buffer dropped: no bus wired (plugin not spawned onto a bus)"
2919                );
2920                false
2921            }
2922        }
2923    }
2924
2925    fn set_option(&mut self, name: String, value: String) -> bool {
2926        let Some(registry) = self.config_registry.as_ref() else {
2927            tracing::warn!(
2928                option = %name,
2929                "set-option ignored: plugin has no config registry wired"
2930            );
2931            return false;
2932        };
2933        // The plugin's OWN option if it exists in its namespace (`style` →
2934        // `auto-pair.style`), else the raw name — so a plugin sets its own option
2935        // with a short name AND can set a core option by its full name.
2936        let target = self
2937            .plugin_name
2938            .as_ref()
2939            .map(|id| format!("{id}.{name}"))
2940            .filter(|full| registry.lookup(full).is_some())
2941            .unwrap_or(name);
2942        match registry.parse_and_set_command(&format!("{target}={value}")) {
2943            Ok(_) => true,
2944            Err(err) => {
2945                tracing::warn!(
2946                    option = %target,
2947                    %value,
2948                    %err,
2949                    "set-option failed (unknown option or invalid value)"
2950                );
2951                false
2952            }
2953        }
2954    }
2955}
2956
2957/// Host impl of the `modes` guest→host mode-declaration seam (PH7.11a, §5). The
2958/// guest calls `register-mode` (from its `register-modes` export) to declare a
2959/// minor mode; each records the declaration into the Store's
2960/// [`mode_host::ModeContributions`] so the host can drain it after registration
2961/// and register each into a `&mut ModeRegistry` (`spawn_mode_plugin`).
2962/// Sync + infallible (it only pushes — recording cannot trap; the register
2963/// outcome surfaces at drain time), so bindgen omits the outer `wasmtime::Result`
2964/// (the `grammar` / `config` shape). The WIT declaration projects to the native
2965/// [`mode_host::PluginModeDecl`] here (kind / policy / capability flags → native).
2966impl crate::keymap_host::bindings::lattice::plugin_host::keymap::Host for PluginState {
2967    fn register_binding(
2968        &mut self,
2969        binding_mode: crate::keymap_host::bindings::lattice::plugin_host::keymap::BindingMode,
2970        chord: String,
2971        command: String,
2972    ) -> bool {
2973        // Clone the wired context out first so the immutable borrow ends before
2974        // the `keymap_contributions` push (the `config_host` register-option
2975        // borrow-split precedent).
2976        let Some(ctx) = self.keymap_ctx.as_ref() else {
2977            tracing::warn!(
2978                chord,
2979                command,
2980                "register-binding skipped: no keymap wired (degraded — host not spawned onto a keymap)"
2981            );
2982            return false;
2983        };
2984        let keymap = ctx.keymap.clone();
2985        let commands = std::sync::Arc::clone(&ctx.commands);
2986        let plugin_id = ctx.plugin_id.0;
2987        let mode = crate::keymap_host::project_binding_mode(binding_mode);
2988
2989        let bound = crate::keymap_host::bind_user_keybinding(
2990            &keymap, &commands, plugin_id, mode, &chord, &command,
2991        );
2992        if bound {
2993            self.keymap_contributions
2994                .push(crate::keymap_host::KeymapBindingToken { mode, chord });
2995        }
2996        bound
2997    }
2998}
2999
3000impl crate::mode_host::bindings::lattice::plugin_host::modes::Host for PluginState {
3001    fn register_mode(
3002        &mut self,
3003        decl: crate::mode_host::bindings::lattice::plugin_host::modes::ModeDeclaration,
3004    ) {
3005        use crate::mode_host::bindings::lattice::plugin_host::modes::{
3006            ActivationPolicy as WitPolicy, BindingMode as WitBindingMode,
3007            ModeCapabilities as WitCaps, ModeKind as WitKind,
3008            OverridePriority as WitOverridePriority,
3009        };
3010        use crate::mode_host::{
3011            PluginKeymapBinding, PluginModeDecl, PluginModeKind, PluginModeOverride,
3012        };
3013        use lattice_keymap::BindingMode;
3014        use lattice_mode::{ActivationPolicy, CapabilitySet, ModeId, OverridePriority};
3015
3016        let kind = match decl.kind {
3017            WitKind::Major => PluginModeKind::Major,
3018            WitKind::Minor => PluginModeKind::Minor,
3019        };
3020        let policy = match decl.activation_policy {
3021            WitPolicy::Manual => ActivationPolicy::Manual,
3022            WitPolicy::Global => ActivationPolicy::Global,
3023            WitPolicy::Universal => ActivationPolicy::Universal,
3024            WitPolicy::Majors(ids) => {
3025                ActivationPolicy::Majors(ids.iter().map(|m| ModeId::new(m)).collect())
3026            }
3027        };
3028        // WIT `flags` project to the native bitflags one bit at a time (the
3029        // generated flags type is distinct from `CapabilitySet`).
3030        let mut caps = CapabilitySet::empty();
3031        caps.set(
3032            CapabilitySet::BUFFER_URI,
3033            decl.capabilities.contains(WitCaps::BUFFER_URI),
3034        );
3035        caps.set(CapabilitySet::LSP, decl.capabilities.contains(WitCaps::LSP));
3036        caps.set(
3037            CapabilitySet::TREE_SITTER,
3038            decl.capabilities.contains(WitCaps::TREE_SITTER),
3039        );
3040        caps.set(
3041            CapabilitySet::FOLDS,
3042            decl.capabilities.contains(WitCaps::FOLDS),
3043        );
3044        caps.set(
3045            CapabilitySet::WRITABLE,
3046            decl.capabilities.contains(WitCaps::WRITABLE),
3047        );
3048        caps.set(
3049            CapabilitySet::DIAGNOSTICS,
3050            decl.capabilities.contains(WitCaps::DIAGNOSTICS),
3051        );
3052
3053        // Project the keymap bindings (PH7.11b): WIT `binding-mode` → native
3054        // `BindingMode`; chord + command names cross as strings (resolved against
3055        // the `CommandRegistry` at bind time in `spawn_mode_plugin`).
3056        let keymap = decl
3057            .keymap
3058            .into_iter()
3059            .map(|b| PluginKeymapBinding {
3060                mode: match b.binding_mode {
3061                    WitBindingMode::Normal => BindingMode::Normal,
3062                    WitBindingMode::Insert => BindingMode::Insert,
3063                    WitBindingMode::Visual => BindingMode::Visual,
3064                    WitBindingMode::Select => BindingMode::Select,
3065                    WitBindingMode::Replace => BindingMode::Replace,
3066                    WitBindingMode::Command => BindingMode::Command,
3067                    WitBindingMode::Search => BindingMode::Search,
3068                },
3069                chord: b.chord,
3070                command: b.command,
3071            })
3072            .collect();
3073
3074        // MO.1: option overrides. Name and value cross as strings and are
3075        // resolved against the `ConfigRegistry` at drain — the same shape the
3076        // keymap's command names take, and for the same reason: the registry
3077        // they must agree with is native, and a declaration is only data.
3078        let options = decl
3079            .options
3080            .into_iter()
3081            .map(|o| PluginModeOverride {
3082                name: o.name,
3083                value: o.value,
3084                priority: match o.priority {
3085                    WitOverridePriority::Low => OverridePriority::Low,
3086                    WitOverridePriority::Normal => OverridePriority::Normal,
3087                    WitOverridePriority::High => OverridePriority::High,
3088                },
3089            })
3090            .collect();
3091
3092        self.mode_contributions.record(PluginModeDecl {
3093            id: decl.id,
3094            kind,
3095            policy,
3096            caps,
3097            keymap,
3098            // OM.2: crosses as a plain string; `register_plugin_mode` decides
3099            // whether it means anything (majors only) and the registry
3100            // indexes it.
3101            target_language: decl.target_language,
3102            options,
3103        });
3104    }
3105
3106    /// `enable-mode` (CI.4): request the Editor enable a minor mode globally. The
3107    /// guest can't reach the activator, so this publishes
3108    /// `Event::ModeEnablementRequested` onto the bus; the Editor flips the
3109    /// registry + re-activates open buffers. Degrades to a `warn` when no bus is
3110    /// wired (the "not boot-wired yet" case, like `emit-event`), never a trap.
3111    fn enable_mode(&mut self, id: String) {
3112        self.request_mode_enablement(id, true);
3113    }
3114
3115    /// `disable-mode` (CI.4): the inverse of [`enable_mode`](Self::enable_mode).
3116    fn disable_mode(&mut self, id: String) {
3117        self.request_mode_enablement(id, false);
3118    }
3119}
3120
3121impl PluginState {
3122    /// Shared body of `enable-mode` / `disable-mode` (CI.4): publish the
3123    /// host-internal enablement request onto the plugin's bus (the same
3124    /// `event_emit` handle `emit-event` uses).
3125    fn request_mode_enablement(&mut self, mode: String, enabled: bool) {
3126        match &self.event_emit {
3127            Some(ctx) => ctx
3128                .bus
3129                .publish(lattice_protocol::Event::ModeEnablementRequested { mode, enabled }),
3130            None => tracing::warn!(
3131                %mode,
3132                enabled,
3133                "enable-mode/disable-mode dropped: no bus wired (plugin not spawned onto a bus)"
3134            ),
3135        }
3136    }
3137}
3138
3139/// A host-issued plugin identity. Monotonic, allocated by the [`PluginHost`] at
3140/// instantiation — never supplied by the guest. It is the `u32` inside
3141/// [`SourceLayer::Plugin`], so every contribution a plugin registers (PH7.3+)
3142/// traces back to a provenance the guest cannot forge.
3143#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3144pub struct PluginId(pub u32);
3145
3146/// The default on-disk module-cache directory,
3147/// `<config-home>/lattice/cache/plugin-modules/` — wasmtime's AOT-compiled
3148/// components, keyed by wasmtime on the component bytes and its own version.
3149/// Under the config home like every other lattice path, in the `cache`
3150/// subdirectory that says it is safe to delete. Falls back to the temp dir
3151/// when no config home resolves.
3152///
3153/// Was `dirs::cache_dir()/lattice/plugin-cache/`; [`migrate_cache_dirs`]
3154/// carries an existing one over so the first launch after an upgrade does not
3155/// recompile every plugin.
3156fn default_cache_dir() -> PathBuf {
3157    lattice_config::cache_home()
3158        .unwrap_or_else(std::env::temp_dir)
3159        .join("plugin-modules")
3160}
3161
3162/// The pre-0.9.2 cache directory, read once by [`migrate_cache_dirs`].
3163fn legacy_cache_dir() -> Option<PathBuf> {
3164    dirs::cache_dir().map(|d| d.join("lattice").join("plugin-cache"))
3165}
3166
3167/// Carry the module cache under the config home, once. Returns whether
3168/// anything moved. A cache that cannot be moved is simply rebuilt.
3169pub fn migrate_cache_dirs() -> bool {
3170    let Some(legacy) = legacy_cache_dir() else {
3171        return false;
3172    };
3173    lattice_config::migrate_path(&legacy, &default_cache_dir())
3174}
3175
3176/// The default per-plugin data-dir base, `<config-home>/lattice/plugins/` —
3177/// `~/.config/lattice/plugins/` on Linux AND macOS (honouring
3178/// `$XDG_CONFIG_HOME`), `%APPDATA%\lattice\plugins` on Windows. Each plugin's
3179/// private dir is `<base>/<plugin-name>/data/` (fragment §6), beside the
3180/// plugin itself: that is the tree `require`d plugins are built into, so one
3181/// directory is a plugin's whole home. Falls back to the temp dir if no config
3182/// home resolves.
3183///
3184/// **It used to be `dirs::data_dir()/lattice/plugins/`, and that lost data.**
3185/// On Linux `dirs::data_dir()` is `~/.local/share`, which is also where
3186/// `install.sh` puts the bundled plugins under its default `--prefix ~/.local`
3187/// — and the installer upgrades by replacing that directory and `rm -rf`-ing
3188/// the old one. Every reinstall therefore deleted every plugin's store.
3189/// Reproduced against the published v0.9.0 archive, 2026-09-22.
3190/// [`migrate_plugin_data`] carries surviving directories to the new base.
3191fn default_data_dir_base() -> PathBuf {
3192    data_dir_base_from(lattice_config::config_home().as_deref())
3193}
3194
3195/// The pure resolver behind [`default_data_dir_base`], split out so the
3196/// layout is testable without touching the process environment.
3197pub fn data_dir_base_from(config_home: Option<&Path>) -> PathBuf {
3198    config_home
3199        .map(Path::to_path_buf)
3200        .unwrap_or_else(std::env::temp_dir)
3201        .join("lattice")
3202        .join("plugins")
3203}
3204
3205/// The pre-2026-09-22 data-dir base, `dirs::data_dir()/lattice/plugins/`.
3206/// Read once at boot by [`migrate_plugin_data`]; nothing else should use it.
3207pub fn legacy_data_dir_base() -> PathBuf {
3208    dirs::data_dir()
3209        .unwrap_or_else(std::env::temp_dir)
3210        .join("lattice")
3211        .join("plugins")
3212}
3213
3214/// Move each `<old>/<name>/data` directory to `<new>/<name>/data`, returning
3215/// how many moved. Idempotent, and safe to call when `old` does not exist.
3216///
3217/// **Only the `data` subdirectory moves.** On Linux the old base doubles as
3218/// the install tree, so its `<name>/` directories also hold the bundled
3219/// plugins' components and manifests; moving a whole `<name>` directory would
3220/// uninstall the plugin. A `data` directory already present at the
3221/// destination is left alone and the old one stays put — whatever a newer
3222/// lattice wrote wins over the copy this is carrying.
3223///
3224/// Failures are logged and skipped: a plugin whose data cannot be moved (a
3225/// cross-device rename, a permission error) still starts, with its old state
3226/// where it was, rather than taking the editor down at boot.
3227pub fn migrate_plugin_data(old: &Path, new: &Path) -> usize {
3228    if old == new {
3229        return 0;
3230    }
3231    let Ok(entries) = std::fs::read_dir(old) else {
3232        return 0;
3233    };
3234    let mut moved = 0;
3235    for entry in entries.flatten() {
3236        let from = entry.path().join("data");
3237        if !from.is_dir() {
3238            continue;
3239        }
3240        let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
3241            continue;
3242        };
3243        let to = new.join(&name).join("data");
3244        if to.exists() {
3245            tracing::debug!(plugin = %name, "plugin data already migrated; leaving the old copy");
3246            continue;
3247        }
3248        if let Some(parent) = to.parent()
3249            && let Err(e) = std::fs::create_dir_all(parent)
3250        {
3251            tracing::warn!(plugin = %name, error = %e, "could not create the plugin data directory");
3252            continue;
3253        }
3254        match std::fs::rename(&from, &to) {
3255            Ok(()) => {
3256                tracing::info!(plugin = %name, to = %to.display(), "moved plugin data beside the plugin");
3257                moved += 1;
3258            }
3259            Err(e) => {
3260                tracing::warn!(plugin = %name, error = %e, "could not move plugin data; leaving it in place");
3261            }
3262        }
3263    }
3264    moved
3265}
3266
3267/// The wasmtime engine, the (import-free) component linker, the on-disk module
3268/// cache, and the epoch ticker. One host per editor process; construct it once
3269/// (the engine owns Cranelift).
3270pub struct PluginHost {
3271    engine: Engine,
3272    linker: Linker<PluginState>,
3273    // A second linker for the SYNCHRONOUS grammar seam (PH7.7c). The shared
3274    // `linker` wires WASI *async* (picker/completion suspend at host calls); the
3275    // grammar trampoline calls the guest *synchronously* on the dispatch thread,
3276    // so its guest must never reach an async host import. This linker wires WASI
3277    // *sync* (`add_to_linker_sync`) + the sync `grammar` register import, so a
3278    // grammar guest's sync `instantiate` + `apply` calls have no async import to
3279    // invoke — the sync path is correct by construction, not by luck. Same
3280    // engine (shared AOT cache), just a different import table.
3281    grammar_linker: Linker<PluginState>,
3282    // A clone of the cache handed to the engine config; kept so callers can
3283    // read hit/miss stats. `Cache` is a cheap Arc-backed handle.
3284    cache: Cache,
3285    // Base dir under which each plugin's private data dir is created:
3286    // `<data_dir_base>/<plugin-id>/data/` (PH7.2, fragment §6).
3287    data_dir_base: PathBuf,
3288    // OR.1: one durable byte store per MANIFEST ID, handed to every
3289    // `PluginState` built for that id.
3290    //
3291    // Keyed by id rather than by instance, and that is the slice's whole point
3292    // rather than a caching detail. The picker seam, the grammar seam and the
3293    // event seam are separate `wasmtime::Store`s with separate memory; a store
3294    // per instance would mean N copies drifting, with find-node offering a node
3295    // `<CR>` cannot open and nothing reporting an error. Sharing the handle
3296    // makes a write on one seam visible to a read on another immediately —
3297    // before any flush, and without a reload.
3298    stores: Mutex<std::collections::HashMap<String, plugin_store::PluginStoreHandle>>,
3299    // Monotonic source of host-issued `PluginId`s. `&self` methods allocate,
3300    // so this is atomic.
3301    next_id: AtomicU32,
3302    // PO.5: the boundary tracer, so each instantiate/spawn path can stamp a
3303    // plugin's `PluginState.log_ctx` (the guest `logging` seam routes into it).
3304    // Set once by the loader (`set_tracer`) after it builds the tracer — the host
3305    // is constructed first (`install.rs`), so `OnceLock` gives set-once storage
3306    // through the shared `Arc<PluginHost>` without a constructor reorder. `None`
3307    // (unset) → a guest `log` degrades to a debug-drop, like `event_emit`.
3308    tracer: std::sync::OnceLock<crate::trace::PluginTracerHandle>,
3309    // PR.6: what the guest `project` seam answers from. Set once by boot
3310    // (`set_project_context`) after the resolver is registered; the host is
3311    // constructed first, so `OnceLock` gives set-once storage through the
3312    // shared `Arc<PluginHost>` without a constructor reorder — the `tracer`
3313    // precedent above. Unset → `root-for-*` returns `none`, which a real
3314    // editor never does and a stripped harness always does.
3315    project: std::sync::OnceLock<ProjectCtx>,
3316    // CG.4: the foreground-cancel registry `<C-g>` fires. Set once by
3317    // boot, like `tracer` / `project`; unset → guest calls run to their
3318    // budget and cannot be interrupted early, which is the pre-CG.4
3319    // behaviour.
3320    cancel: std::sync::OnceLock<lattice_mode::ForegroundCancelHandle>,
3321    // OC.2: the timer the wake seam sleeps on. Injected rather than owned
3322    // because this crate deliberately has no runtime — `tokio` is a
3323    // dev-dependency and `futures` was chosen over `tokio::sync` to keep it
3324    // that way. Set once by boot, like `tracer` / `project` / `cancel`; unset
3325    // → `wake-every` answers `0`, which is what every harness that never wires
3326    // one gets.
3327    sleeper: std::sync::OnceLock<wake::SleeperHandle>,
3328    // OA.14d: the option registry EVERY store reads through, stamped in
3329    // `new_store` like `project` rather than per spawn path.
3330    //
3331    // It used to be wired only by the seams that were noticed to need it, and
3332    // the seams that were not noticed simply answered `none` — `get-option`
3333    // inside `register-theme-elements` returned nothing at all, so org's
3334    // per-keyword colours were derived from the compiled default no matter what
3335    // anyone configured. That is the reported `todo-keyword-styles` bug, and it
3336    // is a wiring gap rather than an ordering one, so no event could have fixed
3337    // it. Reading configuration needs no plugin id and has nothing to wait for,
3338    // so like `project` there is no path that can forget it.
3339    config: std::sync::OnceLock<Arc<lattice_config::ConfigRegistry>>,
3340    // OA.23: answers "which file did this composed line come from". Abstract
3341    // for `project`'s reason — the plugin host cannot depend on
3342    // `lattice-multibuffer`, which sits above it. Unset answers `none`, which
3343    // is the honest degradation: a guest asking about an excerpt in a host
3344    // with no multibuffers is asking about something that is not there.
3345    excerpt_source: std::sync::OnceLock<lattice_core::ExcerptSourceResolverHandle>,
3346    /// OA.27: what a provider view is showing, for the `view-args` seam.
3347    view_args: std::sync::OnceLock<lattice_core::ViewArgsResolverHandle>,
3348    /// OA.30: the decoration-refresh counter, for `refresh-decorations`.
3349    decoration_epoch: std::sync::OnceLock<lattice_mode::DecorationEpochHandle>,
3350    /// CD.6b: the buffer store, for `clamp-position`.
3351    buffers: std::sync::OnceLock<lattice_mode::BufferStoreHandle>,
3352    // OC.3 / ML.6: what the `ui` seam acts on — the modeline element registry
3353    // and the bus content updates publish onto. Both halves are required (a
3354    // registry with no bus registers descriptors nothing ever repaints), so
3355    // they are set together like `project`'s resolver + buffer store. Unset →
3356    // `register-segment` returns false and `emit-segment` warns and drops.
3357    ui: std::sync::OnceLock<ui_host::UiCtx>,
3358    // Dropped last; keeps the ticker alive for the host's lifetime and stops
3359    // it on drop.
3360    _epoch_ticker: EpochTicker,
3361}
3362
3363impl PluginHost {
3364    /// Build a host with the default module-cache directory
3365    /// ([`default_cache_dir`]) and per-plugin data-dir base
3366    /// ([`default_data_dir_base`]). This is the production constructor.
3367    pub fn new() -> Result<Self, PluginHostError> {
3368        let base = default_data_dir_base();
3369        // Once, at boot: the module cache moved under the config home too, and
3370        // carrying it saves recompiling every plugin on the first launch.
3371        migrate_cache_dirs();
3372        // Once, at boot: carry plugin state written under the old base
3373        // (`dirs::data_dir()/lattice/plugins`) beside the plugins. A no-op
3374        // after the first run, and on a machine that never used it.
3375        migrate_plugin_data(&legacy_data_dir_base(), &base);
3376        Self::with_dirs(default_cache_dir(), base)
3377    }
3378
3379    /// Build a host caching compiled components under `cache_dir`, with the
3380    /// default per-plugin data-dir base. Kept as the narrow constructor the
3381    /// cache tests already use.
3382    pub fn with_cache_dir(cache_dir: impl Into<PathBuf>) -> Result<Self, PluginHostError> {
3383        Self::with_dirs(cache_dir, default_data_dir_base())
3384    }
3385
3386    /// Build a host with an explicit module-cache directory *and* per-plugin
3387    /// data-dir base. Capability tests point both at per-test tempdirs so the
3388    /// data-dir mounts and provenance are hermetic.
3389    ///
3390    /// The AOT (Cranelift) compile of a component is cached on disk by
3391    /// wasmtime, keyed on the component bytes, the compiler configuration, the
3392    /// target, and the wasmtime version — so a **second launch reuses the
3393    /// cached module** instead of recompiling (design.md §15 Q17). wasmtime
3394    /// owns the keying and invalidation; the host owns only the location.
3395    ///
3396    /// The linker is populated with the WASI (preview2) host functions once
3397    /// here; each plugin's *view* onto them is scoped per-`Store` from its
3398    /// grant (PH7.2). Components that import no WASI (the hand-written
3399    /// lifecycle fixtures) instantiate fine against the populated linker.
3400    pub fn with_dirs(
3401        cache_dir: impl Into<PathBuf>,
3402        data_dir_base: impl Into<PathBuf>,
3403    ) -> Result<Self, PluginHostError> {
3404        let cache_dir = cache_dir.into();
3405        std::fs::create_dir_all(&cache_dir).map_err(|e| PluginHostError::Cache(e.into()))?;
3406        let mut cache_config = CacheConfig::new();
3407        cache_config.with_directory(cache_dir);
3408        let cache = Cache::new(cache_config).map_err(|e| PluginHostError::Cache(e.into()))?;
3409
3410        let mut config = Config::new();
3411        // Async is always available on the engine in wasmtime 46; the generated
3412        // exports are async (see the `bindgen!` above). Fuel + epoch give the
3413        // two hard per-call budgets.
3414        config.consume_fuel(true);
3415        config.epoch_interruption(true);
3416        // Transparent AOT artifact cache: `Component::new` (in `compile`) skips
3417        // recompilation on a cache hit.
3418        config.cache(Some(cache.clone()));
3419
3420        let engine = Engine::new(&config).map_err(|e| PluginHostError::Engine(e.into()))?;
3421        let mut linker = Linker::new(&engine);
3422        // Wire the WASI host functions to `PluginState`'s `WasiView`. Async to
3423        // match the canonical ABI — a WASI host call suspends the guest stack,
3424        // never pins the caller's thread.
3425        wasmtime_wasi::p2::add_to_linker_async(&mut linker)
3426            .map_err(|e| PluginHostError::Linker(e.into()))?;
3427        // The `host-services` guest→host seam (PH7.4b). Sync host funcs are fine
3428        // in the async linker; `walk` is bounded, so it does not need to suspend.
3429        crate::lattice::plugin_host::host_services::add_to_linker::<_, HasSelf<_>>(
3430            &mut linker,
3431            |state: &mut PluginState| state,
3432        )
3433        .map_err(|e| PluginHostError::Linker(e.into()))?;
3434        // OR.5b: the `picker-registry` seam a picker plugin declares its sources
3435        // through. A sync host func that only records into `PluginState`; inert
3436        // for every world that does not import it.
3437        crate::picker_host::bindings::lattice::plugin_host::picker_registry::add_to_linker::<
3438            _,
3439            HasSelf<_>,
3440        >(&mut linker, |state: &mut PluginState| state)
3441        .map_err(|e| PluginHostError::Linker(e.into()))?;
3442        // MV.1: the `multibuffer-view-registry` seam a view plugin declares
3443        // its views through. Same shape and same inertness.
3444        crate::multibuffer_view_host::bindings::lattice::plugin_host::multibuffer_view_registry::add_to_linker::<
3445            _,
3446            HasSelf<_>,
3447        >(&mut linker, |state: &mut PluginState| state)
3448        .map_err(|e| PluginHostError::Linker(e.into()))?;
3449        // PM.7: the `plugin-manager` (`require`) seam. A sync host func — it
3450        // only records into `PluginState` — and inert for every world that
3451        // does not import `plugin-manager`, which is all of them but the
3452        // config/init world.
3453        crate::plugin_manager_host::bindings::lattice::plugin_host::plugin_manager::add_to_linker::<_, HasSelf<_>>(
3454            &mut linker,
3455            |state: &mut PluginState| state,
3456        )
3457        .map_err(|e| PluginHostError::Linker(e.into()))?;
3458        // The `events` guest→host subscription seam (PH7.8b). Wired into the
3459        // ASYNC linker — the events-plugin world's `on-event` delivery is async
3460        // (off the keystroke path), unlike the sync grammar seam. `subscribe` is
3461        // a sync host func (it only records into `PluginState`) and is inert for
3462        // worlds that don't import `events` (picker/completion/the scaffold).
3463        crate::events_host::bindings::lattice::plugin_host::events::add_to_linker::<_, HasSelf<_>>(
3464            &mut linker,
3465            |state: &mut PluginState| state,
3466        )
3467        .map_err(|e| PluginHostError::Linker(e.into()))?;
3468        // The `config` guest→host option seam (PH7.10). Sync host funcs
3469        // (`register-option` / `get-option` only touch the `ConfigRegistry`),
3470        // inert for worlds that don't import `config`.
3471        crate::config_host::bindings::lattice::plugin_host::config::add_to_linker::<_, HasSelf<_>>(
3472            &mut linker,
3473            |state: &mut PluginState| state,
3474        )
3475        .map_err(|e| PluginHostError::Linker(e.into()))?;
3476        // TC.4: the `theme` guest->host element-declaration seam. Sync host
3477        // func (`register-element` only touches the theme registry), inert for
3478        // worlds that don't import `theme`.
3479        crate::theme_host::bindings::lattice::plugin_host::theme::add_to_linker::<_, HasSelf<_>>(
3480            &mut linker,
3481            |state: &mut PluginState| state,
3482        )
3483        .map_err(|e| PluginHostError::Linker(e.into()))?;
3484        // SG.3a: the `signs` guest→host sign-declaration seam. Sync host func
3485        // (`define-sign` only writes the sign registry), inert for worlds that
3486        // don't import `signs` — but wired UNCONDITIONALLY, because an
3487        // unwired import fails instantiation of the WHOLE component, not just
3488        // the call.
3489        crate::sign_host::bindings::lattice::plugin_host::signs::add_to_linker::<_, HasSelf<_>>(
3490            &mut linker,
3491            |state: &mut PluginState| state,
3492        )
3493        .map_err(|e| PluginHostError::Linker(e.into()))?;
3494        // CR.3: the `help` guest→host topic-registration seam. Sync host func
3495        // (`register-topic` only records into `PluginState`), inert for worlds
3496        // that don't import `help`.
3497        crate::help_host::bindings::lattice::plugin_host::help::add_to_linker::<_, HasSelf<_>>(
3498            &mut linker,
3499            |state: &mut PluginState| state,
3500        )
3501        .map_err(|e| PluginHostError::Linker(e.into()))?;
3502        // LG.3c: the `language` seam. Sync host func (`register-language`
3503        // only records into `PluginState`), inert for worlds that don't
3504        // import `language`.
3505        crate::language_host::bindings::lattice::plugin_host::language::add_to_linker::<
3506            _,
3507            HasSelf<_>,
3508        >(&mut linker, |state: &mut PluginState| state)
3509        .map_err(|e| PluginHostError::Linker(e.into()))?;
3510        // CR.4: the `dashboard` declaration seam. `register-section` only
3511        // records into `PluginState`, so it is sync and safe on either
3512        // linker; inert for worlds that don't import `dashboard`.
3513        crate::dashboard_host::bindings::lattice::plugin_host::dashboard::add_to_linker::<
3514            _,
3515            HasSelf<_>,
3516        >(&mut linker, |state: &mut PluginState| state)
3517        .map_err(|e| PluginHostError::Linker(e.into()))?;
3518        // The `modes` guest→host mode-declaration seam (PH7.11a). Sync host func
3519        // (`register-mode` only records into `PluginState`), inert for worlds that
3520        // don't import `modes`.
3521        crate::mode_host::bindings::lattice::plugin_host::modes::add_to_linker::<_, HasSelf<_>>(
3522            &mut linker,
3523            |state: &mut PluginState| state,
3524        )
3525        .map_err(|e| PluginHostError::Linker(e.into()))?;
3526        // The `keymap` guest→host binding-registration seam (PL8.D.1). Sync host
3527        // func (`register-binding` resolves + binds into `KeymapLayer::User`,
3528        // recording a teardown token — no await), inert for worlds that don't
3529        // import `keymap`. Async linker: registration is off the keystroke path
3530        // (binding *resolution* stays native).
3531        crate::keymap_host::bindings::lattice::plugin_host::keymap::add_to_linker::<_, HasSelf<_>>(
3532            &mut linker,
3533            |state: &mut PluginState| state,
3534        )
3535        .map_err(|e| PluginHostError::Linker(e.into()))?;
3536        // The `logging` guest→host seam (PO.5, Layer 2). Sync host func (`log`
3537        // only pushes into the `PluginTracer` ring), wired into the ASYNC linker —
3538        // logging is off the keystroke path (NOT in the sync grammar linker, so a
3539        // grammar guest can never reach it). Inert for worlds that don't import
3540        // `logging`. Shared across the async-seam worlds via the bindgen
3541        // `with`-reuse of this one generated module.
3542        crate::lattice::plugin_host::logging::add_to_linker::<_, HasSelf<_>>(
3543            &mut linker,
3544            |state: &mut PluginState| state,
3545        )
3546        .map_err(|e| PluginHostError::Linker(e.into()))?;
3547        // PR.6: the `project` guest→host seam. Sync host funcs (a cached
3548        // directory walk), wired here beside `logging` and inert for worlds
3549        // that don't import it. Shared across the async-seam worlds via the
3550        // bindgen `with`-reuse of this one generated module.
3551        crate::lattice::plugin_host::project::add_to_linker::<_, HasSelf<_>>(
3552            &mut linker,
3553            |state: &mut PluginState| state,
3554        )
3555        .map_err(|e| PluginHostError::Linker(e.into()))?;
3556        // OC.3 / ML.6: the `ui` modeline-contribution seam. Sync host funcs (a
3557        // registry write and a bus publish), wired here beside `logging` /
3558        // `project` and inert for worlds that don't import it.
3559        crate::lattice::plugin_host::ui::add_to_linker::<_, HasSelf<_>>(
3560            &mut linker,
3561            |state: &mut PluginState| state,
3562        )
3563        .map_err(|e| PluginHostError::Linker(e.into()))?;
3564        // Multi-seam support (AP.1 spike): a single plugin `.wasm` may `provide`
3565        // grammar AND async seams (auto-pair: grammar + modes + config). Its
3566        // import set is fixed, so the async linker (used for the mode/config
3567        // drains) must ALSO satisfy the grammar + buffer imports. Both are sync
3568        // host funcs (register-* only record; `document` reads are table lookups),
3569        // so they are inert here for the async-only worlds and correct for a
3570        // combined component. Symmetric to grammar-linker below.
3571        crate::grammar_host::bindings::lattice::plugin_host::grammar::add_to_linker::<_, HasSelf<_>>(
3572            &mut linker,
3573            |state: &mut PluginState| state,
3574        )
3575        .map_err(|e| PluginHostError::Linker(e.into()))?;
3576        crate::grammar_host::bindings::lattice::plugin_host::buffer::add_to_linker::<_, HasSelf<_>>(
3577            &mut linker,
3578            |state: &mut PluginState| state,
3579        )
3580        .map_err(|e| PluginHostError::Linker(e.into()))?;
3581        // TS.1: the `tree-sitter` `tree-snapshot` / `node` resources, on the
3582        // async linker too (superset symmetry with `buffer`) — a combined plugin
3583        // instantiated here for its async drains still imports `tree-sitter` via
3584        // `apply-action`. The reads are sync host-table lookups; inert for worlds
3585        // that don't import it.
3586        crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::add_to_linker::<
3587            _,
3588            HasSelf<_>,
3589        >(&mut linker, |state: &mut PluginState| state)
3590        .map_err(|e| PluginHostError::Linker(e.into()))?;
3591        // The grammar seam's SYNC linker (PH7.7b/c). The `grammar` register API
3592        // (`register-*`) is guest→host sync host funcs (they only record into
3593        // `PluginState`). It is wired into a SECOND linker whose WASI is `sync`
3594        // — so a grammar guest instantiated here has no async host import, and
3595        // the trampoline's synchronous `apply` calls on the dispatch thread are
3596        // correct by construction (the PH7.7 fork). Same `engine`, so the AOT
3597        // cache is shared; only the import table differs from the async `linker`.
3598        let mut grammar_linker = Linker::new(&engine);
3599        wasmtime_wasi::p2::add_to_linker_sync(&mut grammar_linker)
3600            .map_err(|e| PluginHostError::Linker(e.into()))?;
3601        crate::grammar_host::bindings::lattice::plugin_host::grammar::add_to_linker::<_, HasSelf<_>>(
3602            &mut grammar_linker,
3603            |state: &mut PluginState| state,
3604        )
3605        .map_err(|e| PluginHostError::Linker(e.into()))?;
3606        // AP.0.1: the `buffer` `document` resource, so a grammar action's
3607        // `borrow<document>` param resolves. On the SYNC grammar linker only —
3608        // the reads are synchronous host-table lookups (no I/O), correct for the
3609        // dispatch-thread trampoline.
3610        crate::grammar_host::bindings::lattice::plugin_host::buffer::add_to_linker::<_, HasSelf<_>>(
3611            &mut grammar_linker,
3612            |state: &mut PluginState| state,
3613        )
3614        .map_err(|e| PluginHostError::Linker(e.into()))?;
3615        // TS.1: the `tree-sitter` resources on the SYNC grammar linker, so a
3616        // grammar action's `option<borrow<tree-snapshot>>` param resolves. Reads
3617        // are synchronous host-table lookups + parse-free tree walks (no I/O, no
3618        // parse — the tree is already there), correct for the dispatch-thread
3619        // trampoline.
3620        crate::grammar_host::bindings::lattice::plugin_host::tree_sitter::add_to_linker::<
3621            _,
3622            HasSelf<_>,
3623        >(&mut grammar_linker, |state: &mut PluginState| state)
3624        .map_err(|e| PluginHostError::Linker(e.into()))?;
3625        // OR.5b: `picker-registry` on the SYNC grammar linker too. Org provides
3626        // both `grammar` and `picker-source`, and a component's import set must
3627        // resolve on EVERY linker it is instantiated against — an import absent
3628        // here fails the WHOLE component, not one seam. That is the OC.2 scar,
3629        // and this is the fifth seam to be wired on both for it.
3630        crate::picker_host::bindings::lattice::plugin_host::picker_registry::add_to_linker::<
3631            _,
3632            HasSelf<_>,
3633        >(&mut grammar_linker, |state: &mut PluginState| state)
3634        .map_err(|e| PluginHostError::Linker(e.into()))?;
3635        // MV.1: `multibuffer-view-registry` on the SYNC grammar linker too, for
3636        // the reason directly above and with the same consequence for getting
3637        // it wrong. Org will provide `grammar` AND `multibuffer-view-source`,
3638        // and a component's import set must resolve on EVERY linker it is
3639        // instantiated against — an import absent here fails the WHOLE
3640        // component, silently, not just the one seam. That is the OC.2 scar:
3641        // one `logging::log` call once took org down entirely.
3642        crate::multibuffer_view_host::bindings::lattice::plugin_host::multibuffer_view_registry::add_to_linker::<
3643            _,
3644            HasSelf<_>,
3645        >(&mut grammar_linker, |state: &mut PluginState| state)
3646        .map_err(|e| PluginHostError::Linker(e.into()))?;
3647        // Multi-seam support (AP.1 spike): a combined plugin instantiated here for
3648        // its GRAMMAR drain still imports its `modes` / `config` seams, so the sync
3649        // grammar linker must satisfy them too. Both `register-mode` /
3650        // `register-option` / `get-option` are sync host funcs that only record
3651        // into `PluginState` (never called on the apply-action hot path — only
3652        // during the registration exports, which run on the async drains), so they
3653        // are safe here and inert for grammar-only guests. `logging` is
3654        // deliberately NOT added — the combined `auto-pair` world omits it, so the
3655        // "no logging reachable from the grammar hot path" invariant holds.
3656        crate::config_host::bindings::lattice::plugin_host::config::add_to_linker::<_, HasSelf<_>>(
3657            &mut grammar_linker,
3658            |state: &mut PluginState| state,
3659        )
3660        .map_err(|e| PluginHostError::Linker(e.into()))?;
3661        // TC.6: a multi-seam component that provides BOTH `grammar` and `theme`
3662        // (treesitter-context) is instantiated against this sync linker for its
3663        // grammar seam, and instantiation must satisfy EVERY import the world
3664        // declares — not only the ones that seam uses. Registering an element
3665        // touches the theme registry and nothing else, so it is safe on the
3666        // sync path; leaving it out simply made the whole component fail to
3667        // load, which is how this was found.
3668        crate::theme_host::bindings::lattice::plugin_host::theme::add_to_linker::<_, HasSelf<_>>(
3669            &mut grammar_linker,
3670            |state: &mut PluginState| state,
3671        )
3672        .map_err(|e| PluginHostError::Linker(e.into()))?;
3673        // SG.3a, for the TC.6 reason above, and found the same way: org
3674        // provides BOTH `grammar` and `signs` (OA.30's agenda marks declare
3675        // the `>` they paint), so its component is instantiated against this
3676        // sync linker for its grammar seam — and instantiation must satisfy
3677        // EVERY import the world declares, not only the ones that seam uses.
3678        // `define-sign` writes the sign registry and nothing else, so it is
3679        // safe here; leaving it out made the WHOLE org plugin fail to load
3680        // with "instance export `define-sign` has the wrong type", which
3681        // reads like a WIT mismatch rather than a missing linker entry.
3682        crate::sign_host::bindings::lattice::plugin_host::signs::add_to_linker::<_, HasSelf<_>>(
3683            &mut grammar_linker,
3684            |state: &mut PluginState| state,
3685        )
3686        .map_err(|e| PluginHostError::Linker(e.into()))?;
3687        // CR.3, for the TC.6 reason above: a multi-seam component providing
3688        // BOTH `grammar` and `help` is instantiated against this sync linker
3689        // for its grammar seam, and instantiation must satisfy EVERY import
3690        // the world declares, not only the ones that seam uses. Recording a
3691        // topic touches `PluginState` and nothing else, so it is safe here;
3692        // leaving it out would make the whole component fail to load.
3693        crate::help_host::bindings::lattice::plugin_host::help::add_to_linker::<_, HasSelf<_>>(
3694            &mut grammar_linker,
3695            |state: &mut PluginState| state,
3696        )
3697        .map_err(|e| PluginHostError::Linker(e.into()))?;
3698        // LG.3c, for the same TC.6 reason: a multi-seam component providing
3699        // BOTH `grammar` and `language` instantiates against this sync linker
3700        // for its grammar seam, and instantiation must satisfy EVERY import
3701        // the world declares. Recording a language touches `PluginState` and
3702        // nothing else, so it is safe here.
3703        crate::language_host::bindings::lattice::plugin_host::language::add_to_linker::<
3704            _,
3705            HasSelf<_>,
3706        >(&mut grammar_linker, |state: &mut PluginState| state)
3707        .map_err(|e| PluginHostError::Linker(e.into()))?;
3708        // CR.4: and on the sync linker, which is the one `dashboard-plugin`
3709        // actually instantiates against — `render-section` runs inside the
3710        // compositor and must not suspend.
3711        crate::dashboard_host::bindings::lattice::plugin_host::dashboard::add_to_linker::<
3712            _,
3713            HasSelf<_>,
3714        >(&mut grammar_linker, |state: &mut PluginState| state)
3715        .map_err(|e| PluginHostError::Linker(e.into()))?;
3716        crate::mode_host::bindings::lattice::plugin_host::modes::add_to_linker::<_, HasSelf<_>>(
3717            &mut grammar_linker,
3718            |state: &mut PluginState| state,
3719        )
3720        .map_err(|e| PluginHostError::Linker(e.into()))?;
3721        // PC.4, for the same TC.6 reason: the `project` plugin provides BOTH
3722        // `grammar` (its `:project-*` ex-commands) and imports `project` (it
3723        // READS roots and never supplies one), so its component is instantiated
3724        // against this sync linker for the grammar seam and instantiation must
3725        // satisfy EVERY import the world declares — not only the ones that seam
3726        // uses. Without this the WHOLE component fails to load with
3727        // "`root-for-buffer` has the wrong type / function implementation is
3728        // missing", which is how it was found.
3729        //
3730        // Safe here, and `project.wit` already promises it: "Sync, and available
3731        // in every world. It may walk the filesystem on a cache miss, but it
3732        // runs on the plugin's own store and task — never the UI or actor
3733        // thread." The host impl is a sync `fn` over a cached resolver, so there
3734        // is nothing to suspend on.
3735        crate::lattice::plugin_host::project::add_to_linker::<_, HasSelf<_>>(
3736            &mut grammar_linker,
3737            |state: &mut PluginState| state,
3738        )
3739        .map_err(|e| PluginHostError::Linker(e.into()))?;
3740        // OM.11, for the same TC.6 reason: org provides BOTH `grammar` and
3741        // `picker-source` (refile's target list), and a picker source needs
3742        // `walk`. Instantiation must satisfy every import the world declares,
3743        // so without this the whole component fails to load on the grammar
3744        // seam — which is how this was found, and what the `multiseam` fixture
3745        // now pins (it imports `host-services` for exactly this reason).
3746        //
3747        // `walk` and `read-file` are sync, bounded host funcs. What this does
3748        // NOT wire is `logging`, still deliberately absent, so "no logging
3749        // reachable from the grammar hot path" stays structural.
3750        //
3751        // **This comment used to claim the guest could already read files here
3752        // through WASI, and that was wrong.** `add_to_linker_sync` does give the
3753        // guest a filesystem view, but `wasmtime-wasi`'s sync filesystem shim
3754        // blocks on a runtime internally, and this thread is already inside one
3755        // — so a grammar action calling `std::fs::read_to_string` panics with
3756        // "Cannot start a runtime from within a runtime" rather than reading
3757        // anything. Found by org's capture trying exactly that (OC.5a).
3758        //
3759        // That is why `read-file` exists on `host-services`: a host-side read,
3760        // capability-gated like `walk`, is the only read reachable from this
3761        // seam. Async seams (picker / completion, on the async `linker` above)
3762        // are unaffected and use WASI directly — org's picker source does.
3763        crate::lattice::plugin_host::host_services::add_to_linker::<_, HasSelf<_>>(
3764            &mut grammar_linker,
3765            |state: &mut PluginState| state,
3766        )
3767        .map_err(|e| PluginHostError::Linker(e.into()))?;
3768        // OC.3 / ML.6, for the same TC.6 reason, and the plan for that slice
3769        // predicted otherwise — it said `ui` would be "wired on the async linker
3770        // only, so the modeline is structurally unreachable from the keystroke
3771        // path". That mechanism does not survive the Component Model. A
3772        // component's import set is fixed for the whole artefact, and org — the
3773        // plugin this seam exists for — provides `grammar` too, so an import
3774        // absent here fails the WHOLE plugin rather than one seam. Org has
3775        // already been broken in exactly this way once, by a single
3776        // `logging::log` call (see its world comment).
3777        //
3778        // The guarantee the plan wanted is kept one layer in instead:
3779        // `instantiate_grammar_plugin` clears `PluginState::ui`, so a grammar
3780        // action's `emit-segment` finds no context and warns + drops. That is
3781        // the `config` / `theme` / `keymap` shape, and unlike a linker omission
3782        // it is testable — `a_grammar_action_cannot_reach_the_modeline` is the
3783        // test.
3784        crate::lattice::plugin_host::ui::add_to_linker::<_, HasSelf<_>>(
3785            &mut grammar_linker,
3786            |state: &mut PluginState| state,
3787        )
3788        .map_err(|e| PluginHostError::Linker(e.into()))?;
3789        // OC.6, and the fifth instance of the same TC.6 reason. Org bridges a
3790        // chord to its own async side — the clock's session, wake and modeline
3791        // all live on its event actor (design D6) — so its component imports
3792        // `events`, and a component's import set is fixed for the whole
3793        // artefact. Without this the grammar seam of that same `.wasm` fails to
3794        // instantiate and org loses its entire keymap, not just its clock.
3795        //
3796        // All three functions are safe here. `subscribe` only records into
3797        // `PluginState`. `wake-every` finds `wake: None` on a grammar store —
3798        // that field is filled in by `spawn_event_plugin` alone — so it answers
3799        // `0` and warns, which is what keeps the periodic wake off the keystroke
3800        // path now that the import has to be reachable from it. `cancel-wake` on
3801        // a store with no wake context is a no-op by contract.
3802        crate::events_host::bindings::lattice::plugin_host::events::add_to_linker::<_, HasSelf<_>>(
3803            &mut grammar_linker,
3804            |state: &mut PluginState| state,
3805        )
3806        .map_err(|e| PluginHostError::Linker(e.into()))?;
3807        let epoch_ticker = EpochTicker::spawn(&engine, EPOCH_TICK_INTERVAL)?;
3808
3809        Ok(Self {
3810            engine,
3811            linker,
3812            grammar_linker,
3813            cache,
3814            data_dir_base: data_dir_base.into(),
3815            next_id: AtomicU32::new(0),
3816            tracer: std::sync::OnceLock::new(),
3817            project: std::sync::OnceLock::new(),
3818            cancel: std::sync::OnceLock::new(),
3819            sleeper: std::sync::OnceLock::new(),
3820            config: std::sync::OnceLock::new(),
3821            excerpt_source: std::sync::OnceLock::new(),
3822            view_args: std::sync::OnceLock::new(),
3823            decoration_epoch: std::sync::OnceLock::new(),
3824            buffers: std::sync::OnceLock::new(),
3825            ui: std::sync::OnceLock::new(),
3826            stores: Mutex::new(std::collections::HashMap::new()),
3827            _epoch_ticker: epoch_ticker,
3828        })
3829    }
3830
3831    /// OT.3b: where a plugin's private data lives — `<base>/<id>/data/`, the
3832    /// same directory `build_plugin_wasi` grants it.
3833    ///
3834    /// Exposed so a seam adapter can persist across restarts beside the
3835    /// plugin's own data (the agenda result cache), and so uninstalling a
3836    /// plugin removes what it cached. `None` for an id that is not a safe
3837    /// directory name — the same refusal `build_plugin_wasi` makes, rather
3838    /// than a second opinion about it.
3839    pub fn plugin_data_dir(&self, plugin_id: &str) -> Option<PathBuf> {
3840        crate::manifest::is_safe_plugin_id(plugin_id)
3841            .then(|| self.data_dir_base.join(plugin_id).join("data"))
3842    }
3843
3844    /// OR.1: the shared byte store for `plugin_id`, opened on first use and
3845    /// reused for every later seam instance of the same id.
3846    ///
3847    /// `None` for an id that is not a safe directory name — the same refusal
3848    /// [`plugin_data_dir`](Self::plugin_data_dir) makes, rather than a second
3849    /// opinion about it. Such a plugin gets no data mount either, so a store
3850    /// would have nowhere to live.
3851    fn store_for(&self, plugin_id: &str) -> Option<plugin_store::PluginStoreHandle> {
3852        let dir = self.plugin_data_dir(plugin_id)?;
3853        let mut stores = self
3854            .stores
3855            .lock()
3856            .unwrap_or_else(|poisoned| poisoned.into_inner());
3857        if let Some(existing) = stores.get(plugin_id) {
3858            return Some(existing.clone());
3859        }
3860        // The dir is created by `build_plugin_wasi` on the manifest paths, but
3861        // not on every path that names a plugin, so create it here too rather
3862        // than depending on an ordering the compiler cannot check. A failure
3863        // degrades at flush time (logged, dropped), never here.
3864        let _ = std::fs::create_dir_all(&dir);
3865        let handle: plugin_store::PluginStoreHandle =
3866            Arc::new(Mutex::new(plugin_store::PluginStore::open(&dir)));
3867        plugin_store::register(&handle);
3868        stores.insert(plugin_id.to_string(), handle.clone());
3869        Some(handle)
3870    }
3871
3872    /// OR.1: read one key out of a plugin's store, host-side.
3873    ///
3874    /// The store is the guest's schema and the host never interprets it — this
3875    /// hands back the same opaque bytes `store-get` would, without
3876    /// instantiating anything. It exists because a *reader* of a plugin's index
3877    /// may be host-side (a multibuffer provider) and because a test that asked
3878    /// the writing guest what it wrote would pass against a per-instance store,
3879    /// which is the exact drift the store exists to prevent.
3880    ///
3881    /// `None` for an unknown plugin id, an unsafe one, or a key with nothing
3882    /// under it — the three are indistinguishable to a caller, and all three
3883    /// mean "build it".
3884    pub fn plugin_store_get(&self, plugin_id: &str, key: &str) -> Option<Vec<u8>> {
3885        let store = self.store_for(plugin_id)?;
3886        let guard = store.lock().unwrap_or_else(|p| p.into_inner());
3887        guard.get(key)
3888    }
3889
3890    /// Install the boundary tracer (PO.5) — the loader calls this once, after it
3891    /// builds the tracer, so every subsequent instantiate/spawn stamps the
3892    /// plugin's `log_ctx` and the guest `logging` seam routes into the ring.
3893    /// Idempotent: a second call is ignored (the tracer is set once per host).
3894    pub fn set_tracer(&self, tracer: crate::trace::PluginTracerHandle) {
3895        let _ = self.tracer.set(tracer);
3896    }
3897
3898    /// Build the [`LogCtx`] for a freshly-allocated `id` — `Some` once a tracer is
3899    /// installed ([`set_tracer`](Self::set_tracer)), else `None` (a guest `log`
3900    /// then degrades to a debug-drop). Stamped onto the store in each async
3901    /// instantiate/spawn path.
3902    fn log_ctx_for(&self, id: PluginId) -> Option<LogCtx> {
3903        self.tracer.get().map(|tracer| LogCtx {
3904            plugin: id.0,
3905            tracer: tracer.clone(),
3906        })
3907    }
3908
3909    /// PR.6: hand the host what the guest `project` seam answers from.
3910    ///
3911    /// Called once at boot after the resolver is registered. Idempotent —
3912    /// a second call is ignored, like [`set_tracer`](Self::set_tracer).
3913    pub fn set_project_context(
3914        &self,
3915        resolver: lattice_core::ProjectResolverHandle,
3916        buffers: lattice_mode::BufferStoreHandle,
3917    ) {
3918        let _ = self.project.set(ProjectCtx { resolver, buffers });
3919    }
3920
3921    /// CG.4: hand the host the foreground-cancel registry, so a running
3922    /// guest call can be interrupted by `<C-g>`.
3923    ///
3924    /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
3925    pub fn set_foreground_cancel(&self, cancel: lattice_mode::ForegroundCancelHandle) {
3926        let _ = self.cancel.set(cancel);
3927    }
3928
3929    /// OC.2: hand the host the timer the `wake-every` seam sleeps on.
3930    ///
3931    /// This crate owns no runtime, so it cannot construct one — the caller that
3932    /// spawns the actors is the one that has an executor to sleep on, and it
3933    /// supplies the [`Sleeper`] here. Unset leaves `wake-every` answering `0`.
3934    ///
3935    /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
3936    pub fn set_sleeper(&self, sleeper: wake::SleeperHandle) {
3937        let _ = self.sleeper.set(sleeper);
3938    }
3939
3940    /// OA.14d: hand the host the option registry every store reads through.
3941    ///
3942    /// The floor, not a replacement for the per-seam wiring: a seam that cannot
3943    /// function without a registry (`config` itself) still takes one as an
3944    /// argument, because "this seam requires it" and "any guest may read an
3945    /// option" are different claims. What this closes is the second one, which
3946    /// had been answered seam by seam and was therefore wrong for whichever
3947    /// seams nobody had thought about — `theme` and `language` among them, the
3948    /// two org reads its keyword set from at load.
3949    ///
3950    /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
3951    pub fn set_config_registry(&self, registry: Arc<lattice_config::ConfigRegistry>) {
3952        let _ = self.config.set(registry);
3953    }
3954
3955    /// OA.23: hand the host what resolves a composed line to its source file.
3956    ///
3957    /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
3958    pub fn set_excerpt_source_resolver(&self, resolver: lattice_core::ExcerptSourceResolverHandle) {
3959        let _ = self.excerpt_source.set(resolver);
3960    }
3961
3962    /// HB.2b: whether a resolver was ever wired.
3963    ///
3964    /// Every store is stamped from this slot at creation, so a host that
3965    /// reaches its first `instantiate_*` unwired hands every guest a seam that
3966    /// answers `none` forever — and answering `none` is a legitimate reply the
3967    /// guest cannot tell apart from "this line is not composed". Exposed so the
3968    /// boot pin can assert the wiring rather than a reading of `install`.
3969    pub fn excerpt_source_wired(&self) -> bool {
3970        self.excerpt_source.get().is_some()
3971    }
3972
3973    /// OA.27: hand the host what answers "what is this provider view showing".
3974    ///
3975    /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
3976    pub fn set_view_args_resolver(&self, resolver: lattice_core::ViewArgsResolverHandle) {
3977        let _ = self.view_args.set(resolver);
3978    }
3979
3980    /// OA.27: whether a resolver was ever wired.
3981    ///
3982    /// Exposed for [`excerpt_source_wired`](Self::excerpt_source_wired)'s
3983    /// reason, and it bites harder here: an unwired `view-args` answers an
3984    /// empty list, a guest parses that as a fresh view, and every chord that
3985    /// walks the view then silently starts over from the default. There is no
3986    /// error anywhere on that path — which is precisely how the bug this seam
3987    /// replaces survived.
3988    pub fn view_args_wired(&self) -> bool {
3989        self.view_args.get().is_some()
3990    }
3991
3992    /// CD.6b: hand the host the buffer store `clamp-position` measures.
3993    ///
3994    /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
3995    pub fn set_buffer_store(&self, buffers: lattice_mode::BufferStoreHandle) {
3996        let _ = self.buffers.set(buffers);
3997    }
3998
3999    /// CD.6b: whether a buffer store was ever wired.
4000    ///
4001    /// Pinned at boot for `view_args_wired`'s reason: unwired, `clamp-position`
4002    /// answers `none` for every buffer, which a guest reads as "that buffer is
4003    /// closed". A roam link would then never be written back, with a message
4004    /// blaming a buffer that is still open.
4005    pub fn buffer_store_wired(&self) -> bool {
4006        self.buffers.get().is_some()
4007    }
4008
4009    /// OA.30: hand the host the counter `refresh-decorations` bumps.
4010    ///
4011    /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
4012    pub fn set_decoration_epoch(&self, epoch: lattice_mode::DecorationEpochHandle) {
4013        let _ = self.decoration_epoch.set(epoch);
4014    }
4015
4016    /// OA.30: whether a counter was ever wired.
4017    ///
4018    /// Pinned at boot for `view_args_wired`'s reason: unwired, the seam is a
4019    /// silent no-op and a guest's marks simply never repaint — no error on any
4020    /// path, and the symptom reads as a broken feature rather than a missing
4021    /// wire.
4022    pub fn decoration_epoch_wired(&self) -> bool {
4023        self.decoration_epoch.get().is_some()
4024    }
4025
4026    /// OC.3 / ML.6: hand the host what a plugin's `ui` modeline calls act on.
4027    ///
4028    /// Both halves at once, on the `set_project_context` reasoning: a registry
4029    /// with no bus would let a plugin register a descriptor and push content
4030    /// that nothing ever repaints — half-wired, and half-wired is the failure
4031    /// mode this seam is most likely to have (`plugin-gates-hand-guests-
4032    /// throwaway-contexts`). Unset leaves `register-segment` returning `false`.
4033    ///
4034    /// Idempotent — a second call is ignored, like [`set_tracer`](Self::set_tracer).
4035    pub fn set_modeline(&self, modeline: lattice_mode::ModelineServiceHandle, bus: Arc<EventBus>) {
4036        let _ = self.ui.set(ui_host::UiCtx { modeline, bus });
4037    }
4038
4039    /// Allocate the next host-issued [`PluginId`]. Monotonic and unique for the
4040    /// host's lifetime; the guest never influences it.
4041    fn alloc_id(&self) -> PluginId {
4042        PluginId(self.next_id.fetch_add(1, Ordering::Relaxed))
4043    }
4044
4045    /// Number of module-cache hits so far (a compiled artifact was reused from
4046    /// disk instead of recompiled). Exposed for tests and future observability.
4047    pub fn cache_hits(&self) -> usize {
4048        self.cache.cache_hits()
4049    }
4050
4051    /// Number of module-cache misses so far (a component was compiled and its
4052    /// artifact written to the cache).
4053    pub fn cache_misses(&self) -> usize {
4054        self.cache.cache_misses()
4055    }
4056
4057    /// Compile component bytes (AOT via Cranelift) into a reusable
4058    /// [`Component`]. Malformed / non-component input returns
4059    /// [`PluginHostError::Compile`] — no panic. Compilation is synchronous
4060    /// regardless of the async engine.
4061    pub fn compile(&self, bytes: &[u8]) -> Result<Component, PluginHostError> {
4062        Component::new(&self.engine, bytes).map_err(|e| PluginHostError::Compile(e.into()))
4063    }
4064
4065    /// Instantiate a compiled component into a live [`LoadedPlugin`] with the
4066    /// default per-call budget and **no capability grant** (an empty WASI view
4067    /// — zero filesystem access, no data dir). This is the degenerate load; a
4068    /// real plugin uses [`instantiate_plugin`](Self::instantiate_plugin) with a
4069    /// manifest.
4070    pub async fn instantiate(
4071        &self,
4072        component: &Component,
4073    ) -> Result<LoadedPlugin, PluginHostError> {
4074        self.instantiate_with_budget(component, PluginBudget::default())
4075            .await
4076    }
4077
4078    /// Instantiate a compiled component with an explicit per-call budget and
4079    /// **no capability grant** (empty WASI view). See [`instantiate`](Self::instantiate).
4080    pub async fn instantiate_with_budget(
4081        &self,
4082        component: &Component,
4083        budget: PluginBudget,
4084    ) -> Result<LoadedPlugin, PluginHostError> {
4085        // No grant, no data dir: the guest reaches no filesystem at all — and a
4086        // host-services `walk` from such a plugin is denied (empty grant).
4087        let wasi = WasiCtxBuilder::new().build();
4088        let (mut store, bindings) = self
4089            .instantiate_inner(component, wasi, CapabilityGrant::default(), budget, None)
4090            .await?;
4091        // PO.5: stamp the logging route once the id is allocated, so a `plugin`-
4092        // world guest's `log` reaches the tracer.
4093        let id = self.alloc_id();
4094        store.data_mut().log_ctx = self.log_ctx_for(id);
4095        Ok(LoadedPlugin {
4096            store,
4097            bindings,
4098            budget,
4099            id,
4100            grant: CapabilityGrant::default(),
4101            denied: Vec::new(),
4102            data_dir: None,
4103        })
4104    }
4105
4106    /// Instantiate a plugin **under its capability grant** (PH7.2, fragment §6).
4107    ///
4108    /// The grant is computed from `manifest` + `tier`; a private data dir
4109    /// (`<data-base>/<manifest.id>/data/`) is created and mounted writable, and
4110    /// each granted `fs:*` prefix is preopened at its own path. The resulting
4111    /// [`Store`]'s WASI view reaches **exactly** the granted filesystem and
4112    /// nothing else — a plugin without an `fs:write` grant cannot write outside
4113    /// its data dir at the WASI layer. Requested capabilities the tier withheld
4114    /// (e.g. `proc:spawn` for a user-installed plugin) are surfaced on
4115    /// [`LoadedPlugin::denied_capabilities`] so the host can notify the user;
4116    /// the load still succeeds (graceful degradation).
4117    ///
4118    /// Each instantiation gets its own `Store` (the isolation boundary) and a
4119    /// fresh host-issued [`PluginId`]. Instantiation runs with generous
4120    /// [`INSTANTIATION_FUEL`]; the tighter [`PluginBudget`] applies per call.
4121    pub async fn instantiate_plugin(
4122        &self,
4123        component: &Component,
4124        manifest: &PluginManifest,
4125        tier: TrustTier,
4126        budget: PluginBudget,
4127    ) -> Result<LoadedPlugin, PluginHostError> {
4128        let (wasi, outcome, data_dir) = self.build_plugin_wasi(manifest, tier);
4129        let (mut store, bindings) = self
4130            .instantiate_inner(
4131                component,
4132                wasi,
4133                outcome.grant.clone(),
4134                budget,
4135                Some(&manifest.id),
4136            )
4137            .await?;
4138        // PO.5: stamp the logging route once the id is allocated.
4139        let id = self.alloc_id();
4140        store.data_mut().log_ctx = self.log_ctx_for(id);
4141        Ok(LoadedPlugin {
4142            store,
4143            bindings,
4144            budget,
4145            id,
4146            grant: outcome.grant,
4147            denied: outcome.denied,
4148            data_dir: Some(data_dir),
4149        })
4150    }
4151
4152    /// Shared instantiation core: build the `Store` around `wasi`, arm the
4153    /// instantiation fuel/epoch, and instantiate the component against the
4154    /// WASI-populated linker.
4155    async fn instantiate_inner(
4156        &self,
4157        component: &Component,
4158        wasi: WasiCtx,
4159        grant: CapabilityGrant,
4160        budget: PluginBudget,
4161        name: Option<&str>,
4162    ) -> Result<(Store<PluginState>, Plugin), PluginHostError> {
4163        let mut store = self.new_store(wasi, grant, budget, name)?;
4164        let bindings = Plugin::instantiate_async(&mut store, component, &self.linker)
4165            .await
4166            .map_err(|e| PluginHostError::Instantiate(e.into()))?;
4167        Ok((store, bindings))
4168    }
4169
4170    /// Build a per-plugin `Store` around `wasi` + `grant` and arm it with the
4171    /// generous [`INSTANTIATION_FUEL`] + `budget`'s epoch (the start function's
4172    /// allowance; per-call arming via [`arm_store`] happens before each export).
4173    /// Shared by the lifecycle-world instantiation and the picker actor spawn
4174    /// (`picker_task`) so both build the same scoped `Store`.
4175    fn new_store(
4176        &self,
4177        wasi: WasiCtx,
4178        grant: CapabilityGrant,
4179        budget: PluginBudget,
4180        // The plugin's manifest id — drives config-option auto-namespacing. `None`
4181        // only for internal callers with no manifest (they register no options).
4182        name: Option<&str>,
4183    ) -> Result<Store<PluginState>, PluginHostError> {
4184        let state = PluginState {
4185            wasi,
4186            table: ResourceTable::new(),
4187            grant,
4188            grammar_contributions: grammar_host::GrammarContributions::default(),
4189            event_subscriptions: events_host::EventContributions::default(),
4190            // Set by `spawn_event_plugin` once the plugin's id is allocated; a
4191            // plugin not spawned onto a bus cannot emit (warn + drop).
4192            event_emit: None,
4193            // OA.14d: stamped for every store from the host-level registry (the
4194            // `project` shape), so ANY seam's guest can read an option. A spawn
4195            // path that takes a registry of its own still overwrites this with
4196            // the one it was handed — same `Arc` in every real wiring, and an
4197            // explicit argument where the seam genuinely cannot work without
4198            // one. `None` only when boot wired no registry at all, which is the
4199            // honest degradation (warn + false / none).
4200            config_registry: self.config.get().cloned(),
4201            // From the manifest id; drives config-option auto-namespacing.
4202            plugin_name: name.map(str::to_string),
4203            config_contributions: Vec::new(),
4204            theme_registry: None,
4205            theme_contributions: Vec::new(),
4206            sign_registry: None,
4207            sign_contributions: Vec::new(),
4208            help_contributions: Vec::new(),
4209            language_contributions: Vec::new(),
4210            dashboard_contributions: Vec::new(),
4211            // Drained by `spawn_mode_plugin` into the `ModeRegistry` after
4212            // `register-modes` returns (PH7.11a).
4213            mode_contributions: mode_host::ModeContributions::default(),
4214            // Set by `spawn_keymap_plugin`; a plugin not spawned onto a keymap
4215            // cannot bind (register-binding → false).
4216            keymap_ctx: None,
4217            keymap_contributions: Vec::new(),
4218            // Stamped by each async instantiate/spawn path (`log_ctx_for`) once
4219            // the id is allocated; `None` here + for the sync grammar guest (which
4220            // never imports `logging`) → a guest `log` is a debug-drop.
4221            log_ctx: None,
4222            // PR.6: stamped for every store below, not per-spawn-path like
4223            // `log_ctx` — resolution needs no plugin id, so there is nothing
4224            // to wait for and no path that can forget it.
4225            project: self.project.get().cloned(),
4226            // OA.23: stamped for every store like `project`, and for the same
4227            // reason — resolving a composed line needs no plugin id, so there
4228            // is nothing to wait for and no spawn path that can forget it.
4229            excerpt_source: self.excerpt_source.get().cloned(),
4230            // OA.27: stamped for every store, on the `excerpt_source` reasoning
4231            // — resolving a view's arguments needs no plugin id, so there is
4232            // nothing to wait for and no spawn path that can forget it. It has
4233            // to reach the GRAMMAR store above all, which is where the chords
4234            // that read a view's arguments actually run.
4235            view_args: self.view_args.get().cloned(),
4236            // OA.30: stamped for every store on `view_args`' reasoning. It has
4237            // to reach the GRAMMAR store above all — a mark is toggled by a
4238            // chord, and the chord is what has to say the gutter changed.
4239            decoration_epoch: self.decoration_epoch.get().cloned(),
4240            // CD.6b: stamped for every store on `view_args`' reasoning. The
4241            // GRAMMAR store is the one that needs it: a capture commits from a
4242            // chord, and that is where the caller's extent is asked.
4243            buffers: self.buffers.get().cloned(),
4244            // PH7.8c: opened by `spawn_event_plugin` around `register-events`
4245            // and closed by its flush. Every other seam publishes straight
4246            // through, which is what `None` means.
4247            deferred_events: None,
4248            cancel: self.cancel.get().cloned(),
4249            cancel_token: None,
4250            epoch_spent: 0,
4251            epoch_started: None,
4252            // OC.3: stamped for every store here rather than per-spawn-path, on
4253            // the `project` reasoning — there is nothing to wait for, so no path
4254            // can forget it. The one store that must NOT have it, the sync
4255            // grammar store, clears it explicitly in
4256            // `instantiate_grammar_plugin`, which is a line a reader can find.
4257            ui: self.ui.get().cloned(),
4258            // OC.2: only `spawn_event_plugin` fills this in — it is the one path
4259            // whose actor has somewhere to deliver an `on-wake`.
4260            wake: None,
4261            // OR.1: stamped for every store here, on the `project` / `ui`
4262            // reasoning — it needs no allocated plugin id, so there is nothing
4263            // to wait for and no spawn path that can forget it. A seam holding
4264            // a store its sibling seams cannot see IS the drift this exists to
4265            // prevent, so the wiring must not be per-path.
4266            store: name.and_then(|id| self.store_for(id)),
4267            picker_contributions: picker_host::PickerContributions::default(),
4268            // MV.1: stamped for every store like `picker_contributions`, so the
4269            // one seam that drains it cannot be the only one that has it.
4270            multibuffer_view_contributions:
4271                multibuffer_view_host::MultibufferViewContributions::default(),
4272            // OR.2: empty until the guest arms one; dropped with this `Store`.
4273            watches: std::collections::HashMap::new(),
4274            require_contributions: Default::default(),
4275        };
4276        let mut store = Store::new(&self.engine, state);
4277        store
4278            .set_fuel(INSTANTIATION_FUEL)
4279            .map_err(|e| PluginHostError::Instantiate(e.into()))?;
4280        // Default epoch behaviour is to trap on deadline; arm it generously
4281        // for instantiation (per-call arming happens before each export).
4282        store.set_epoch_deadline(budget.epoch_deadline);
4283        Ok(store)
4284    }
4285
4286    /// Compute a plugin's grant from `manifest` + `tier`, create its private
4287    /// data dir, and build the scoped WASI view — the front half of
4288    /// [`instantiate_plugin`](Self::instantiate_plugin), factored out so the
4289    /// picker actor spawn (`picker_task`) instantiates under the identical
4290    /// capability model. A data dir that cannot be created degrades to "no data
4291    /// mount" (a `warn!`, never a failed load — fragment §6).
4292    fn build_plugin_wasi(
4293        &self,
4294        manifest: &PluginManifest,
4295        tier: TrustTier,
4296    ) -> (WasiCtx, GrantOutcome, PathBuf) {
4297        let outcome = grant(manifest, tier);
4298        // SECURITY (isolation, defense-in-depth): the id is validated at parse
4299        // (`from_toml_str` rejects a path-escaping id), but a programmatic
4300        // `PluginManifest::new` bypasses that. Re-check HERE — the true security
4301        // boundary — before joining the id into a WRITABLE mount path: an unsafe
4302        // id degrades to NO data mount (an empty scoped WASI view), never an
4303        // out-of-sandbox writable dir. A crafted `/etc/cron.d` / `../../.ssh` id
4304        // can therefore never relocate the mount, grant or no grant.
4305        let data_dir = if crate::manifest::is_safe_plugin_id(&manifest.id) {
4306            let dir = self.data_dir_base.join(&manifest.id).join("data");
4307            if let Err(err) = std::fs::create_dir_all(&dir) {
4308                tracing::warn!(
4309                    path = %dir.display(),
4310                    error = %err,
4311                    "plugin data dir create failed; the data mount is degraded"
4312                );
4313            }
4314            dir
4315        } else {
4316            tracing::error!(
4317                id = %manifest.id,
4318                "plugin id is not a safe path component; refusing the data mount (no /data)"
4319            );
4320            // A path inside the base that is NOT preopened (no create, no mount):
4321            // build_wasi_ctx only preopens dirs it is told to; an absent data dir
4322            // means the guest simply has no /data. Return the (unmounted) intended
4323            // path for the caller's bookkeeping without ever creating/mounting it.
4324            self.data_dir_base.join("__invalid__").join("data")
4325        };
4326        let wasi = build_wasi_ctx(&outcome.grant, &data_dir);
4327        (wasi, outcome, data_dir)
4328    }
4329}
4330
4331/// A live plugin instance: its `Store` (holding the scoped WASI view), the
4332/// lifecycle bindings, the per-call budget, its host-issued identity and
4333/// effective grant. Dropping it tears the `Store` down (the reload/teardown
4334/// seam PH7.12 formalises).
4335pub struct LoadedPlugin {
4336    store: Store<PluginState>,
4337    bindings: Plugin,
4338    budget: PluginBudget,
4339    id: PluginId,
4340    grant: CapabilityGrant,
4341    denied: Vec<Capability>,
4342    data_dir: Option<PathBuf>,
4343}
4344
4345impl LoadedPlugin {
4346    /// The host-issued identity for this instance. The guest never influences
4347    /// it; it is the numeric id inside this plugin's provenance.
4348    pub fn id(&self) -> PluginId {
4349        self.id
4350    }
4351
4352    /// The provenance layer the host stamps for every contribution this plugin
4353    /// registers (PH7.3+). Host-issued from [`PluginId`], so a plugin cannot
4354    /// forge a builtin/user provenance — the acid test of §6.
4355    pub fn source_layer(&self) -> SourceLayer {
4356        SourceLayer::Plugin(self.id.0)
4357    }
4358
4359    /// The effective capability grant this plugin runs under.
4360    pub fn grant(&self) -> &CapabilityGrant {
4361        &self.grant
4362    }
4363
4364    /// Requested capabilities the trust tier withheld (e.g. `proc:spawn` for a
4365    /// user-installed plugin). The host turns these into a "loaded with reduced
4366    /// function" notification; the plugin still loaded.
4367    pub fn denied_capabilities(&self) -> &[Capability] {
4368        &self.denied
4369    }
4370
4371    /// The plugin's private data dir, if it was instantiated with a grant
4372    /// ([`PluginHost::instantiate_plugin`]). `None` for the degenerate
4373    /// no-grant load.
4374    pub fn data_dir(&self) -> Option<&Path> {
4375        self.data_dir.as_deref()
4376    }
4377
4378    /// Re-arm the fuel + epoch budget before a lifecycle call, so each call
4379    /// gets a fresh allowance rather than sharing a running total.
4380    fn arm_budget(&mut self) -> Result<(), PluginHostError> {
4381        arm_store(&mut self.store, self.budget)
4382    }
4383
4384    /// Call the component's `activate` export. For `init.rs` this runs the
4385    /// user's configuration; for the no-op component it returns immediately.
4386    /// Fuel/epoch exhaustion or any wasm trap surfaces as
4387    /// [`PluginHostError::Trap`] and leaves the host live.
4388    pub async fn activate(&mut self) -> Result<(), PluginHostError> {
4389        self.arm_budget()?;
4390        self.bindings
4391            .call_activate(&mut self.store)
4392            .await
4393            .map_err(|source| PluginHostError::Trap {
4394                func: "activate",
4395                kind: classify_trap(&source),
4396                source: source.into(),
4397            })
4398    }
4399
4400    /// Call the component's `deactivate` export (teardown / reload).
4401    pub async fn deactivate(&mut self) -> Result<(), PluginHostError> {
4402        self.arm_budget()?;
4403        self.bindings
4404            .call_deactivate(&mut self.store)
4405            .await
4406            .map_err(|source| PluginHostError::Trap {
4407                func: "deactivate",
4408                kind: classify_trap(&source),
4409                source: source.into(),
4410            })
4411    }
4412}