Decision records
ADR 0043 — Plugin packaging: minimal kernel, adapters as plugins, 1P/3P separation
- Status: Accepted (2026-07-13)
- Date: 2026-07-13
- Builds on: ADR 0006 (static, replay-safe composition — not dynamic plugin magic), ADR 0033 (SPI map + portable conformance suites), and the P5/P6/P10 principles in architecture.md.
- Part of: packaging / repository structure only — the framework kernel and public runtime behaviour are not modified. No new runtime protocol, no discovery, no hook bus.
- Related: requirements + evaluation (
loom/docs/requirements/2026-07-13-composition-layer-plugins.md) (the CL1–CL8 constraints this ADR operates under).
Context
The composition-layer evaluation (the related requirements doc) rejected a runtime plugin
system and accepted, at most, a composition-time contributor model: a plugin is a package
that implements an existing SPI and/or returns a Partial<LoomConfig>, statically imported and
passed to defineConfig. The kernel never learns plugins exist.
What remained was a packaging decision. A grounded inventory of all 53 packages found:
- The kernel mechanism packages (
core,event-runtime,snapshot-runtime,replay-vm,effect-runtime,workflow-runtime,scheduler,wire-abi) and the SPI-contract packages (worker-runtime,projection-runtime,llm,blueprint-runtime,registry,telemetry, and thedispatcherinterface) are generic — they carry mechanism, not vendor. - ~11 packages are concrete adapters that exist because of a specific external system:
store-postgres(pg),platform-cloudrun(GCP),store-crypto,audit-chain,worker-runtime-remote(HTTP),worker-server,mcp,a2a,renderer-react,renderer-terminal— plus the SQLite drivers currently buried insideevent-runtime/snapshot-runtime. - Two concrete entanglements block a naïve move (both verified in source):
event-runtimeshipsbetter-sqlite3as a hard dependency and holds theSqliteEventStore/SqliteBranchRegistry/SqliteTimerStorealongside theEventStoreinterface and the in-memory reference. The kernel event package literally carries a native DB driver.workflow-runtime(kernel) imports@loom/a2ui(createA2UIEmitterinWorkflowContext.ts). a2ui’s emitter contract is fused into theuiKERNEL_SYSCALL, so a2ui is not a leaf adapter.
The maintainer additionally requires a first-party / third-party split: 1P plugins in this
repo under the @loom/ scope; 3P plugins in a separate loom-plugins community repo, with
authors free to keep plugins in any repo they like.
Decision
D1 — A minimal kernel; concrete adapters become plugins
Classify every package by the litmus test “does it exist because of a specific external system (DB engine, cloud vendor, wire protocol, UI framework)?” Yes → plugin. Generic mechanism → kernel. Generic optional pattern (retry, saga, reasoning, memory, …) → advisory, stays put (these are “generic enough” and are explicitly not adapters). In-memory / local reference implementations stay next to their SPI (the kernel needs them to boot and self-test).
Add a plugins/* glob to pnpm-workspace.yaml, peer to the existing packages/* / apps/* /
tools/* convention. Move the adapter packages there. Package names are the contract, not
folder paths — pnpm/turbo resolve by name, so relocation is inert for importers; only
path-coupled config (workspace globs, tsconfig references/paths) updates.
D2 — Split the two packages that mix an SPI with a vendor driver
- Extract the SQLite drivers into a new plugin
@loom/plugin-sqlite(takesbetter-sqlite3with it).event-runtime/snapshot-runtimekeep the interface + in-memory reference and shed the native dependency. The@loom/event-runtime/memorysubpath is unchanged. - Leave
@loom/dispatcherintact: its interface +LocalDispatcher(reference) are kernel-grade;PlacementDispatcher/SupervisingDispatcherare advisory policies, not vendor adapters. Do not fragment it into three packages. a2ui: keep theA2UIEmittercontract in the kernel/SPI tier (it backs theuisyscall). Splitting the concrete patch/event protocol into a plugin is deferred (see Non-goals) — do it only if the wire format ever needs to be swappable.
D3 — Preset inversion (resolves the storage-kind fork on 1P/3P grounds)
@loom/app today hard-depends on @loom/store-postgres and lazy-loads SQLite from
event-runtime, and the storage union names vendors (kind:"postgres"|"sqlite"). A third-party
store plugin can never add a kind: arm to the core union — so vendor kinds make 1P and 3P
asymmetric. Therefore each store plugin exports a preset returning a StorageConfig:
storage: postgresStorage({ url }) // from @loom/plugin-postgres
storage: sqliteStorage({ dir }) // from @loom/plugin-sqlite
@loom/app keeps only kind:"memory" (default) and kind:"custom" (the escape hatch presets
resolve to), and drops its vendor dependencies. 1P and 3P storage backends are now wired
identically. Trade-off, accepted: one extra import versus the old zero-import kind sugar. This
is the CL1–CL7 (opts) => Partial<config> pattern applied to the (singleton) storage seam.
D4 — First-party vs third-party is organisational, never mechanical
Both use the identical contract (§Plugin contract). The kernel treats them the same; there is no 1P/3P branch anywhere in the runtime.
- 1P plugins live in this repo under
plugins/, published as@loom/plugin-<name>(npm scopes are single-level, so@loom/pluginsis realised as theplugins/workspace dir plus an optional@loom/pluginsumbrella meta-package that re-exports the set — not a package namespace). 1P plugins are conformance-gated in loom CI, versioned with the kernel, and security-reviewed by core. - 3P plugins live in a separate
loom-pluginscommunity repo (the curated-but-not-core home) or in any repository the author chooses. Convention: name themloom-plugin-<name>(or@scope/loom-plugin-<name>) and tag the npm keywordloom-pluginfor discovery — mirroringeslint-plugin-*/vite-plugin-*. They depend on the published@loom/*SPI packages only. No central registry is required or wanted: a plugin is a statically-imported value passed todefineConfig(P5), so “use a plugin” is always an explicitimport, never discovery. - Promotion 3P→1P is allowed when a plugin meets the 1P bar (maintained by/with core, conformance-gated in loom CI, security-reviewed). Demotion is the reverse.
D5 — Make “plugin” a checkable category, not just a folder
- Every plugin’s test runs the matching conformance suite from
@loom/testing/conformance(ADR 0033). 1P runs it in loom CI; 3P is expected to run the published vectors in its own CI. - Add a CI layering guard: no
packages/*(kernel/SPI/advisory) may depend onplugins/*. A small test over the workspace graph (reusing@loom/spec’s manifest machinery) fails the build if an adapter creeps inward. This is the structural guarantee that keeps the kernel minimal over time; it is also what flags the a2ui exception explicitly.
The plugin contract (normative)
A Loom plugin is a package that:
- provides one or more concrete implementations of an existing SPI (store, dispatcher,
worker, telemetry adapter, renderer, LLM provider, effect/protocol client), and/or an optional
preset
(opts) => Partial<LoomConfig>(or a singleton value:StorageConfig,ExecutionDispatcher, …); - is statically imported and passed to
defineConfig— no discovery, no config-string resolution, nonode_modulesscanning (P5, CL2); - contributes only to existing seams; introduces no kernel hook site (P10, CL3) and never a reducer into the fold — durable custom facts are ordinary app events folded by user-space projections (K5, CL4);
- is byte-identical to hand-wiring after boot — provable, zero post-boot overhead (G2, CL5);
- ships conformance tests for each SPI it implements (D5);
- is sugar, never a gate: hand-wiring the same objects into
defineConfigstays fully supported and documented (P6, CL6).
There is deliberately no LoomPlugin interface, no setup() lifecycle object, no contributes
manifest at this stage — a plugin is just a package that exports adapters and/or a preset. (The
manifest/validation layer of CL8 is deferred until ≥2 plugins actually interact and need
policing.)
Security note (load-bearing)
A preset and any adapter constructor run at composition time with full ambient host authority, and cannot be sandboxed — K7 data-plane isolation governs tool effects at runtime, not the code that builds your runtime. Consequences:
- 1P plugins are vetted by core; that is the trust basis for the
@loom/scope. - 3P plugins are the app author’s supply-chain responsibility. Importing a 3P plugin is
granting it boot-time host authority. The
loom-pluginsrepo README must carry a vetting checklist; the docs must state that a 3P plugin is as trusted as any dependency youimport. - This raises the stakes on the K7/K12 isolation gap but does not change it: the guarantee was
always “tool effects are isolated,” never “composition code is.” Promoting the contributor model
to a published
LoomPluginbrand with a manifest (CL8) stays blocked until that authority story has an answer.
Consequences
- Kernel = ~8 mechanism + ~6 SPI-contract packages, none naming a vendor or shipping a native
driver.
event-runtime/snapshot-runtimelosebetter-sqlite3. plugins/= ~11 conformance-tested adapters under@loom/plugin-*; advisory modules untouched.@loom/appdrops its@loom/store-postgresdependency; storage backends (1P and 3P) are symmetric.- The public
@loom/sdkbarrel currently re-exports some adapters (a2a,a2ui,mcp,renderer-terminal). Relocation does not change that; whether the blessed barrel should re-export adapters at all is a separate public-surface decision (tracked, not decided here). - Renaming
@loom/store-postgres→@loom/plugin-postgres(etc.) is a breaking rename; ship thin re-export shims under the old names for one release, then remove.
Non-goals / explicitly deferred
- No runtime plugin protocol, hook bus, dynamic discovery, or fold extensibility (ADR 0006, CL1–CL4 forbid them).
- No
LoomPlugininterface /contributesmanifest /setup()lifecycle yet (CL8). - No dispatcher fragmentation; no moving in-memory/local reference impls out of the kernel.
- a2ui contract/impl split deferred until the UI wire format needs to be swappable.
- Advisory packages stay in
packages/— they are generic optional modules, not adapters.
Implementation status (2026-07-13)
Landed on branch feat/plugin-packaging-1p-3p, each phase built + tested + linted green:
- Done — Phase 0:
plugins/*workspace tier;@loom/speclayering guard (findLayeringViolations, transitive) enforced in CI. ADR 0044 (compiler) recorded. - Done — Phase 2 (SQLite split):
@loom/plugin-sqlitecreated;@loom/event-runtimeand@loom/snapshot-runtimedroppedbetter-sqlite3from their dependencies (kernel packages now depend only on@loom/core). 20 importers relocated; kernel tests take the plugin as a devDependency (guard checks runtime deps only). - Done — Phase 3 (preset inversion):
sqliteStorage()/postgresStorage()presets;@loom/appdropped its vendor dependencies and thekind:"sqlite"/"postgres"union arms + theloadNodeModulehack. 1P and 3P storage backends now wire identically. Examples migrated. - Done — Phase 1/4 (storage family): relocated + renamed
store-postgres → @loom/plugin-postgres,store-crypto → @loom/plugin-crypto,audit-chain → @loom/plugin-audit-chain, all underplugins/. 3P authoring guide:docs/plugins.md. - Done — all remaining adapters relocated:
worker-runtime-remote → @loom/plugin-worker-remote,worker-server → @loom/plugin-worker-server,platform-cloudrun → @loom/plugin-cloudrun,renderer-react → @loom/plugin-renderer-react,mcp → @loom/plugin-mcp,a2a → @loom/plugin-a2a,renderer-terminal → @loom/plugin-renderer-terminal.plugins/now holds all 11 first-party adapters;packages/is kernel + SPI + advisory + tooling only. - Done — sdk re-exports 1P plugins per the decision below: light plugins (
a2a,mcp,renderer-terminal) in the main barrel; native/heavy store plugins behind@loom/sdk/sqliteand@loom/sdk/postgressubpaths so a bare@loom/sdkimport stays lean. - Deferred by design: the a2ui contract/impl split (Phase 5) and the
LoomPluginmanifest/brand (CL8) remain gated as recorded above. - Decided (2026-07-13):
@loom/sdkre-exports 1P plugins — this is curation, not the forbidden mechanical 1P/3P asymmetry (D4 bans a runtime branch; a barrel re-export is neither). Guardrail: dependency-light 1P plugins (protocols, renderers) re-export from the main barrel; native/heavy ones (plugin-sqlite→better-sqlite3,plugin-postgres→pg) re-export via a subpath (@loom/sdk/sqlite,@loom/sdk/postgres) so a bareimport "@loom/sdk"stays lean and browser-safe — preserving the very native-dep-shedding win this ADR lands. 3P plugins are never in the barrel (they are not core’s to vouch for); both remain explicit imports, so value-level symmetry holds.
Phasing
- Guard first — land the CI layering check against today’s graph (annotates the a2ui exception). The invariant is enforced before anything moves.
- Relocate clean adapters (
git mvtoplugins/, names stable) — pure move, green build. - SQLite split into
@loom/plugin-sqlite; ship the store-conformance suite with it. - Preset inversion (D3);
@loom/appdrops vendor deps. - 1P rename to
@loom/plugin-*with shims; stand up theloom-pluginsrepo + author template + vetting checklist. - a2ui split — only if/when the wire format must be swappable.
Source: docs/adr/0043-plugin-packaging-and-1p-3p-separation.md in the loom repository.