Configuration & init lifecycle
How a user's configuration reaches the editor, and — the load-bearing part — how config that targets a plugin not yet loaded still applies at the right time. This fragment settles the init.rs lifecycle, the plugin-lifecycle event, and the available-vs-enabled minor-mode model as one coherent story.
Status: design settled (2026-07-19, with Dhruva). Supersedes the current implementation, where
init.rsloads async, after other plugins like any discovered plugin. Slice plan:../operations/slice-plans/archive/config-and-init.md.
1. Why — two problems
P1 — Config authority + lifecycle. init.rs is the user's config, not a third-party extension. Loading it through the async plugin-discovery path (after boot, after other plugins) means its config races a live editor — a mode activates a frame late, an option flips after a subsystem already read it. The re-activation complexity this forces is a symptom.
P2 — Config precedes the plugin it configures. The vim/emacs muscle memory is to configure a plugin before it loads — let g:plugin_x = 1 / (setq …) / (with-eval-after-load 'pkg …). An imperative call (enable-mode("auto-pair-mode")) has nowhere to land if auto-pair isn't loaded yet. This is the crux; it defeats a "init.rs pokes live state" model.
2. The model — init-first, event-deferred, reactive
init.rs loads first (before any other plugin), does immediate config for targets that exist (native options, builtin keymaps, custom commands), and registers deferred handlers for targets that don't exist yet — keyed to a typed plugin-load event. When a plugin finishes loading, its plugin-loaded event fires and the matching handlers run, calling the plugin's now-present APIs.
[core services up] → [init.rs LOADS FIRST] → [first render]
· immediate: set-option, keymaps │
· deferred: on-plugin-loaded(name) ──┐ │
│ ▼
[plugins load ASYNC, off the boot thread, never blocking] ─┘ each finishes →
auto-pair, git-gutter, … Event::PluginLoaded{name}
│
▼
matching deferred handler runs →
enable-mode / set-option / bind-key
This is with-eval-after-load / vim's lazy autocmd, built on the event bus the design already unifies (§5.10, "hooks ≡ autocmds ≡ typed event subscriptions"). The handler calls APIs, never : command strings (event-handlers-call-apis-not-commands).
Diagram names are conceptual — the literal API is the seam modules. Above,
set-option/enable-mode/on-plugin-loadedname the operations, not function names. In realinit.rs: immediate config isconfig::set_option(…)in theregister_optionsexport; a deferred handler is aPluginLoadedsubscription inregister_events+ amatchinon_eventcallingmodes::enable_mode(…)/config::set_option(…). See §6's example and../../user/init.md.
Why this is the right shape:
- Async plugin loading is preserved — plugins still load off the boot thread;
init.rsregistered handlers and returned. The first frame is never blocked on a plugin's cold-start (the plugins.md principle survives). - P2 dissolves — the handler runs when the plugin loads, so every call hits a present target. No pending-value store, no adoption-on-register.
- Graceful by construction — a plugin that fails or isn't installed never fires
plugin-loaded, so its handler never runs. No error path to write. - init.rs is clean — immediate config at top level, deferred config in
on-plugin-loadedhandlers. The emacs init.el shape exactly.
3. Boot lifecycle + ordering (the one constraint)
init.rs MUST load — and its subscriptions register — before any other plugin fires plugin-loaded, or a handler could miss its event. So the loader install sequence becomes:
- Core services up (event bus, the option / mode / command / keymap registries).
- Apply
lattice.toml(static values) into the config registry. - Load
init.rs(<config>/lattice/init/) — runs its top-level, registering immediate config +plugin-loadedsubscriptions. Awaited before step 4. - Discover + load plugins (
<config>/lattice/plugins/) async — each firesplugin-loadedon completion → the deferred handlers run.
init.rs still loads before the first render for its immediate config; its deferred (plugin) config lands as each plugin's cold-start completes — an eventual-consistency window (a frame or two, CLAUDE.md-sanctioned: "absent then present", never a flicker of wrong state).
4. Plugin-lifecycle events (the new primitive)
Event::PluginLoaded { name, id } and Event::PluginUnloaded { name, id } — mirroring the existing Event::PluginCrashed (enum variant + EventKind + the WIT event / event-kind mirror + a plugin-loaded events-seam subscription).
Contract: plugin-loaded fires after a plugin's full drain — every seam registered (its modes, options, commands all exist) — so a handler observes a fully-loaded plugin. Published by the loader at load_discovered completion. plugin-unloaded fires after teardown reverses a plugin's contributions (the :plugin-unload / crash-teardown path), so a handler can tear down its own dependent setup.
A general primitive: lazy-loading, per-plugin setup, and plugin dependency ordering all ride it later — not auto-pair scaffolding.
5. Config front-ends + read discipline
lattice.toml (static option overrides, declarative) and init.rs (programmatic: logic, loops, conditionals, keymaps, custom commands, deferred handlers) are two front-ends over the SAME config registry — the "static settings stay declarative, logic stays code, one toolchain" split (CLAUDE.md). TOML covers what it can express; init.rs is the escape hatch. Both set option values through the same path: TOML at boot, init.rs via the config-seam set-option (CI.7), which wraps the :set name=value machinery (coerce + validate + OptionChanged). Without set-option, init.rs could only declare options, not override values — so it's load-bearing for the "two value-setting front-ends" claim.
Read discipline (the plugin contract): a config consumer reads its options at use time, or subscribes to OptionChanged — it does NOT cache-at-load. Because config may be applied around or after a plugin loads (a plugin-loaded handler runs just after the plugin registered its options), a cache-at-load consumer would miss it. auto-pair already complies (it reads auto-pair.style per keystroke, not at load).
6. Minor-mode enablement — available vs enabled
The user, not the plugin author, owns which extension modes are live (the emacs global-minor-mode model: a package provides electric-pair-mode; you enable it). Today a plugin declaring ActivationPolicy::Global auto-activates on every document buffer with no consent — wrong for a third-party contribution.
Declaration ≠ enablement:
- A plugin-declared minor mode is registered but inert.
ActivationPolicynow describes only its eligible scope (where it applies when on), not whether it self-activates.auto_activatable_minorsreturns a minor iffadmits(major, kind) AND enabled(id). - Native modes are unchanged —
ModeRegistry::registerauto-enables them (snippet / LSP / help stay first-party, intrinsic, on). A newregister_available(used byregister_plugin_mode) registers without enabling — so plugin modes are available-but-off. enable-mode(id)/disable-mode(id)(modes seam) flip enablement (RCU on the mode-registry handle). Called from anon-plugin-loadedhandler in init.rs, or a future:mode-enable.- Re-activation: because plugins load async after render, enabling a mode must activate it on buffers already open — bounded (O(open buffers) whose major admits it). The one piece of real machinery here; legitimate anyway (
:reload-config,:mode-enableneed it too).
Superseded for core plugins (2026-07-20, PM.3/PM.4). auto-pair is now a core plugin: it ships prebuilt, is discovered at boot, and is enabled by a config gate — its manifest declares
default_mode = "auto-pair-mode", and the loader auto-registersauto-pair.enabled(defaulttrue) that turns the mode on out of the box. So auto-pair needs noinit.rsto enable it; the user configures it (config::set_option("auto-pair.enabled", "false")/"auto-pair.style"inregister_options) at the top level, and:set auto-pair.enabled=falsetoggles it live. Beyond the config gate, every registered plugin mode also gets the auto-generated:<mode-name>toggle ex-command (built by the sharedmode_toggle_ex_command_spec, registered underSourceLayer::Plugin(id)bydrain_mode), so:auto-pair-modetoggles it on the active buffer and it resolves in:describe-command/:apropos/:-completion exactly like a native mode. Seeplugin-manager.md§7. Theon-plugin-loaded → enable-modepattern below remains the tool for user plugins with off-by-default modes (and for any deferred config of a not-yet-loaded plugin).
A user plugin that declares no default mode is available-but-off; the user's init.rs turns it on when it loads. There is no setup() / on_plugin_loaded() / set_option() — those are the model; the real API is the wit-bindgen Guest trait (register_events / on_event) plus the seam modules (modes::enable_mode, config::set_option). You subscribe to PluginLoaded in register_events and react in on_event:
// init.rs — a combined world providing `events`, importing `modes` + `config`.
use lattice::plugin_host::types::{Event, EventFilter, EventKind};
use lattice::plugin_host::{config, events, modes};
impl Guest for Component {
fn register_events() {
events::subscribe(
&EventFilter {
kinds: Some(vec![EventKind::PluginLoaded]),
path_globs: None,
major_modes: None,
},
1, // your handler id
);
}
fn on_event(handler: u32, ev: Event) {
if let (1, Event::PluginLoaded(p)) = (handler, ev) {
if p.name == "my-linter" {
modes::enable_mode("my-linter-mode");
config::set_option("my-linter.strict", "true"); // optional
}
}
}
}
The full annotated shape (imports, plugin.toml, the combined world) is in the user guide, ../../user/init.md; lattice --scaffold-init writes a compilable starter.
7. Paramount-goal alignment
- #2 Extensibility — init.rs (and any plugin) reacts to any plugin's lifecycle via typed events; configuring a plugin never requires it present when init.rs runs.
- #4 Async — async plugin loading preserved (no boot block); the event bus is the async coupling; eventual-consistency is the accepted, bounded compromise.
- #1 Performance — init.rs registers handlers and returns; zero per-frame / per-keystroke cost; deferred config runs off the hot path.
- UX (higher court) — matches vim/emacs muscle memory; no surprise auto-activation of third-party behavior; "absent then present", never wrong.
8. Rejected alternatives
- Pending-value config store (buffer config for not-yet-registered options, adopt on register) — rejected: the event-deferred handler achieves the same and covers imperative refs (keymaps, commands), not just option values, with no persistent unclaimed-values layer. The store solves a more general problem we don't have; premature (heuristic #1).
- Blocking boot (load all bundled plugins + init.rs before first render so every reference resolves) — rejected: sacrifices fast startup and violates "no plugin cold-start delays startup". The event model keeps async loading.
- init.rs as a normal async-discovered plugin (loaded after other plugins) — rejected: its subscriptions must precede the events it reacts to, so it loads first; and it's config authority, not a discovered extension.
- Imperative
enable-modeat init.rs top level (against a maybe-absent plugin) — rejected: the target may not exist; the deferredon-plugin-loadedhandler is the fix.
9. API / seam surface touched
- events — new
plugin-loaded(+plugin-unloaded)event-kind/eventarm;init.rssubscribes with a name filter. - modes —
enable-mode(id)/disable-mode(id);register_available(host-side) for plugin modes. - config —
set-option(name, value)(CI.7): the value-setting front-end that makesinit.rssymmetric withlattice.toml. Backed by the SAMEConfigRegistry::parse_and_set_commandpath:set name=valueuses (type-coerce- validate + publish
OptionChanged);falseon unknown option / invalid value / no registry. Without itinit.rscould only declare options, not set values — a crippled front-end; it's the missing half, not a bolt-on.
- validate + publish
- loader — publishes
plugin-loaded/plugin-unloaded; loadsinit.rsbefore plugin discovery. - world — lattice ships no combined "init" world (a mega-world would force a user to implement every
register-*export even for a keymap-only config). A multi-seaminit.rsdeclares its OWN combined world listing exactly the seams it uses — the AP.1.0 multi-seam pattern;providesnames the seams it contributes into (config/keymap/events/grammar), while a seam it only calls (modes, forenable-mode) is imported but not provided.