Repository metrics
- Stars
- (82 stars)
- PR merge metrics
- (Avg merge 6d 4h) (222 merged PRs in 30d)
Description
Harper Plugin Architecture Proposal
TL;DR — Reorganize Harper as a small frozen kernel surrounded by plugins that contribute providers through uniform, typed registries resolving once at load time into direct calls — zero runtime cost, measured. We're already ~70% there at the surface; this is a unification, not a rewrite, and deliberately not a grand migration: contracts + a boundary ratchet first, the Pro ABI and the commit bus next, everything else opportunistic — every step behavior-preserving and perf-gated. Performance is the veto on every decision below. Ask: review the target architecture (§1–§8) and the adoption plan (§9).
1. The vision
Harper is the platform where your code runs where your data lives. A database, cache, application runtime, and messaging broker fused into one process — so a cache hit, a database read, and a function call cost the same. That's physics no hosted backend can match: every alternative puts a network hop or a serialization boundary between compute and data. Everything in this document exists to keep that true while opening the platform up. Four claims, one substrate:
- Compute lives with data. No N+1, no serialization tax, no cache-invalidation architecture — application code holds the same live, zero-copy objects the storage engine commits.
- Every table is a live feed. Committed changes flow through a durable, resumable, transaction-framed change feed that already drives replication, MQTT, SSE/WS, and MCP subscriptions (§6.4). Real-time isn't a feature you add — it's what data does here.
- One way to extend. The same plugin boundary serves applications, the engine's own subsystems, Pro, and the community (§2–§6) — and plugins are distributable packages (§5).
- Legible to machines. The same contract surfaces as MCP tools, typed operations, and introspection; agents build on and operate Harper through the boundary people use (§6.10).
What stands in the way is the codebase, not the concept: the core has grown into a place where subsystems reach directly into each other — the record encoder calls analytics by name, auth is a closed switch, storage engines are ~79 if (isRocksDB) branches threaded through the commit path, and harper-pro installs itself by assigning functions onto shared objects and deep-importing ~40 internal modules. The fusion is the product; the coupling is not.
The organizing principle for the next era of the codebase:
Harper is a small, frozen kernel surrounded by plugins. A plugin is the unit you ship — a folder with config, lifecycle, and a trust level. What a plugin contributes are providers: implementations of typed contracts, registered through a uniform API that resolves once at load time into direct calls. The kernel is only what cannot be contributed this way: the plugin host itself, the transaction and commit pipeline, the socket acceptor, the thread fabric, and the registries and buses everything plugs into.
Two things make this Harper's version of the idea, not a generic microkernel:
- In-process and zero-copy, always. A plugin is not a process, a package boundary, or an RPC surface — it receives the same live objects the kernel uses (
import { tables } from 'harper'is the identical object identity, via the global-export + symlink mechanism that already ships). "Plugin" means decoupled through an API boundary, never isolated behind a serialization boundary. No IPC on any data path, ever. - Late binding at boot, early binding at runtime. Every extension point resolves at config load, plugin load, or database open into a direct, monomorphic reference. At runtime the hot path calls it exactly as it would call hardcoded code — because after resolution, that's what it is.
1.1 You can see it in ls
The end state — physical moves are opportunistic: per subsystem, only after the ABI exists (moving files first would break Pro's deep imports), ideally in a PR that's already rewriting that subsystem (§9). Until then the checked-in boundary map (§9) is the authoritative tier assignment — the lint enforces this architecture before the directories reflect it. Names are a review topic.
harper/
├── kernel/ # the small frozen core — everything in the §4 table
│ ├── abi.ts # versioned exports trusted plugins may depend on (§7)
│ ├── registry.ts # Registry<T> primitive (§6.1)
│ ├── facets/ # transport, auth, operations, metrics, commits, cluster…
│ ├── bus.ts # commit-observer bus: compiled per-table emitters (§6.3)
│ ├── changeFeed/ # formalized transactionBroadcast + subscription API (§6.4)
│ ├── loader/ # componentLoader, Scope, OptionsWatcher, capabilities
│ ├── resource/ # Resource.ts, transaction.ts, Table.ts commit pipeline,
│ │ # RecordEncoder, crdt (closed)
│ ├── storage/ # StorageProvider contract + system-registered engines
│ │ ├── rocks/ # (register before plugin load — boot note, §6.6)
│ │ └── lmdb/
│ ├── server/ # http.ts transport (3 backends), threads/, itc fabric
│ ├── security/ # context plumbing, TLS selector, jsLoader sandbox
│ └── config/ # bootstrap, RootConfigWatcher, kernel Joi schema
│
├── plugins/ # each folder is a PLUGIN with a capability manifest,
│ │ # contributing one or more PROVIDERS
│ ├── rest/ graphql/ mqtt/ mcp/ static/ login/ roles/ logging/ …
│ │ # → protocol handlers, content types, resources
│ ├── authentication/ # → auth middleware + basic/bearer AuthProviders
│ ├── sql/ # → queryLanguage provider (lifted sqlTranslator)
│ ├── models-openai/ models-anthropic/ models-bedrock/ models-ollama/
│ │ # → modelBackend providers (plugin-shaped today)
│ ├── analytics/ # → first commit-bus subscriber + aggregation
│ ├── local-studio/ # → static provider (lifted from operationsServer)
│ └── operations-api/ # → ops server (off startOnMainThread, retrofit step §9)
│
└── benchmarks/
├── plugin-bus/ # the microbenchmarks behind this proposal (exists today)
└── gates/ # write-throughput + latency perf gates (I-9)
And the enterprise tier — plugins, unchanged in mechanism, changed in contract:
harper-pro/ # plugins registered via HARPER_BUILTIN_COMPONENTS; import
├── replication/ # ONLY from 'harper' exports + kernel/abi.ts (§7) —
├── licensing/ # no deep ../core/** imports, no server.* assignment
├── security/ (key custody) # → scope.cluster.registerReplicationProvider(…)
└── analytics/ (profiling) # → scope.metrics / onAggregate
1.2 What a plugin looks like
# plugins/webhook-notify/harper-config.yaml
capabilities: # what it may register — ungranted = the method doesn't exist on scope (§5)
- subscribeCommits
- registerOperation
endpoint: https://hooks.example.com/harper
// plugins/webhook-notify/index.js
export function handleApplication(scope) {
const { endpoint } = scope.options;
const pending = [];
// Observe committed writes on one table (§6.3). The filter resolves at
// subscribe time; delivery is synchronous and in-thread — buffer your own async.
scope.commits.onCommit({ table: 'orders', types: ['put', 'delete'] }, (id, type, size) => {
pending.push({ id, type, size });
});
setInterval(() => {
if (pending.length) fetch(endpoint, { method: 'POST', body: JSON.stringify(pending.splice(0)) });
}, 1000);
// Contribute an operation (§6.7): permissioned, validated, and automatically
// published as an MCP tool.
scope.operations.register('webhook_status', {
access: 'super_user',
parametersSchema: { type: 'object', properties: {} },
execute: () => ({ pending: pending.length }),
});
}
One contract for everything: this is how a community package extends Harper, how Pro registers replication, and how our own subsystems register REST or analytics. Applications are the same shape at the sandboxed tier — and existing apps and plugins run unchanged, zero required edits.
1.3 The cost of the boundary: measured
The commit-observer seam, before and after:
// today (RecordEncoder.ts) — analytics called by name, gate paid on every write
if (tableToTrack && TRACKED_WRITE_TYPES.has(type)) recordAction(/* … */);
// target — a compiled per-table emitter slot (§6.3)
if (table.onWriteEmit !== null) table.onWriteEmit(id, type, size);
Priority ordering is a veto: performance first, then extensibility and maintainability. Every seam in this design was benchmarked before being proposed (method + full tables: benchmarks/plugin-bus/):
| ns/op | |
|---|---|
| Idle emit site (zero subscribers) — one null-check | ~1 (vs ~85 for today's always-on analytics capture) |
| Compiled 1-subscriber dispatch vs hardcoded direct call | Δ 0.02 (below noise) |
| Real analytics path: bus + subscriber vs today's gated hardcoded call | ~6% faster (per-write gate moves to subscribe time) |
1.4 How we get there — in brief
Not a grand migration (the full, perf-gated plan is §9):
- Contracts + a ratchet first. A boundary lint where new code complies from day one; existing violations sit on a frozen, shrinking allowlist. Feature work is never blocked.
- Two moves next. Formalize the kernel ABI Pro already depends on (unfreezes the encoder/audit/blob internals for refactoring, with a Pro canary job in CI), then land the commit bus with analytics as its first subscriber — the proven seam, and a perf win on its own.
- Everything else rides other work. Registries are built when a real provider needs them; files move when a subsystem is already being rewritten; enforcement on existing surface is a separate, later debate.
Every existing API keeps working: aliases → warnings → removal, nothing breaks without a deprecation window.
That's the hook. The rest of §1 gives the vision more depth; §2 onward is the reference design.
1.5 Today vs target
TODAY — one large core, ad-hoc seams TARGET — kernel + plugins, uniform seams
┌───────────────────────────────────┐ ┌───────────────────────────────────────────────┐
│ harper core │ │ APPLICATIONS (sandboxed tier) │
│ │ │ your Resources, routes, operations, │
│ rest graphql mqtt mcp static … │ │ metrics, model backends │
│ (already plugins, but each │ ├───────────────────────────────────────────────┤
│ registers a different way) │ │ TRUSTED PLUGINS — each contributes PROVIDERS │
│ models · operations · secrets │ │ rest graphql mqtt mcp ws → protocols │
│ (working registries — the │ │ auth-basic auth-bearer … → auth schemes │
│ pattern exists, ≠ the rule) │ │ sql graphql → query langs │
│ │ │ openai anthropic bedrock … → model backends│
│ auth: closed switch(Basic|Bearer)│ │ analytics → bus subscriber│
│ sql: hardcoded operation branch │ │ pro: replication licensing custody profile │
│ storage: isRocksDB × ~79 │ ├───────────────── KERNEL ABI ──────────────────┤
│ analytics: hardcoded call sites │ │ registries · facets · commit-observer bus · │
│ studio: hardcoded fastify route │ │ durable change feed (versioned) │
│ │ ├───────────────────────────────────────────────┤
│ harper-pro: deep-imports ~40 │ │ KERNEL (small, frozen) │
│ files, patches server.* directly │ │ plugin host & scope · config bootstrap · │
└───────────────────────────────────┘ │ Resource contract · transactions & context · │
│ commit pipeline & encoder · socket dispatch ·│
│ thread fabric & ITC · zero-copy exports · │
│ system-registered providers (rocks/lmdb, TLS)│
└───────────────────────────────────────────────┘
Read the target as trust tiers, not a call graph — calls run both ways: protocol plugins invoke application Resources, and applications reach the kernel directly (import { tables } from 'harper', the semver-stable package exports — §7); the KERNEL ABI band is the additional versioned surface trusted plugins get. Every tier holds the same live, zero-copy objects.
1.6 The seams, on the paths you care about
Where the seams sit — and equally, where there deliberately are none. Nothing here adds a step to either path; the seams replace existing hardcoded equivalents.
Write path:
table.put(record)
│ transactional() wrapper KERNEL
▼
_writeUpdate (Table.ts: version, conflict, blobs, residency) KERNEL
▼
recordUpdater (RecordEncoder.ts) KERNEL
│ 1. encode + primary write ──────────▶ store/txn objects from StorageProvider
│ (SEAM: resolved once at db-open, I-8)
│ 2. audit entry — same store txn, full encoded record
│ (NOT a seam: audit is part of the commit, §6.4)
│ 3. if (table.onWriteEmit !== null)
│ table.onWriteEmit(id, type, size)
│ (EMIT SITE: ~1 ns idle, compiled emitter when subscribed, §6.3)
▼ │
commit ├──▶ analytics subscriber (thread-local buffer, 1 s flush)
│ └──▶ other observers (invalidation hints, dev taps…)
▼
audit store 'committed'
▼
transactionBroadcast ────▶ DURABLE CHANGE FEED (resumable, txn-framed, §6.4)
├──▶ replication (pro plugin's provider)
├──▶ MQTT + durable sessions (DurableSubscriptionsSession)
├──▶ SSE / WebSocket subscriptions
└──▶ MCP resource subscriptions
Request path:
socket ──▶ backend accept (node | bun | uws) KERNEL, backend-specific
▼
Request wrapper ◀━━━ the protocol-provider ABI line (§6.2):
│ providers never see anything below it
▼
httpChain[port](request) compiled once per registration (makeCallbackChain)
├─ cors / session KERNEL middleware
├─ authentication KERNEL middleware
│ cache hit on Authorization header? ── yes ──▶ request.user (registry untouched)
│ └ no ──▶ schemes.get(schemeToken) ──▶ AuthProvider.resolve()
│ (SEAM: Map lookup on the cache-MISS path only, §6.5)
├─ … plugin middleware (before/after ordering, topo-sorted at build)
├─ content negotiation ──▶ contentTypes registry (existing Map seam)
└─ REST ──▶ Resource static method ──▶ transactional() ──▶ the write path above
1.7 Where we already are, what doesn't change
Already there: REST, GraphQL, MQTT, MCP, auth middleware, roles, login, static, logging, the operations API, and the agent all load as plugins today; the AI-model-backend registry already has exactly the registry-plus-plugin shape this proposal generalizes; the operations registry (#1736) and secrets custody are working provider seams. What's missing is uniformity (every subsystem registers differently), the ABI (Pro is coupled to internals), and the last few hardcoded subsystems.
What does not change: the zero-copy plugin model and handleApplication(scope) lifecycle; the Resource contract and transactional(); commit semantics (audit stays a synchronous part of the commit transaction; replication stays on the durable audit-backed feed); runtime performance (enforced per adoption step by benchmark gates); one repo, in-process, folders.
Why bother (one line per audience): core engineers get blast radius shrunk to contract surfaces and a kernel with a stated budget (when in doubt, out); plugin authors get one way to contribute anything instead of "monkey-patch server.getUser and hope"; Pro gets a versioned ABI instead of 40 incidental deep imports; the product gets community engines/schemes/languages without forks; performance actually improves where gates move to subscribe time; and deployments stop paying for features they don't use — today, unused subsystems still ship resident (code loaded, call sites compiled into hot paths, e.g. analytics capture checked on every write even when disabled), whereas an unloaded plugin is zero code, zero memory, zero call sites, and an unsubscribed emit site is a ~1 ns no-op.
Two audiences, two contracts — one substrate. The application tier is optimized for simplicity: least boilerplate, most convention — you get the platform with its generalized building blocks and never think about what's underneath. The plugin tier is optimized for capability: a developer who steps up to writing a plugin isn't looking for simple, they're looking for possible — hand them the building blocks themselves. Agents sharpen this split: they're knowledgeable enough to customize deeply and want maximum capability, while still needing a bright line around the few internals nobody should dig into — that line is the kernel (§4), and the capability manifest (§5) is what makes handing out powerful surface safe.
And the request-pressure angle: we keep the app-dev API deliberately simple — and should — but that simplicity means requests inevitably arrive that only a core change can satisfy today. A richer plugin layer gives those requests somewhere to go: they become plugins (ours, services', the customer's) instead of core changes waiting on a release. And when something genuinely does need core work, the boundary keeps it contained — extending one contract in one place, with dependents visible in the registry, rather than threading a change through subsystems. The simple API stays simple; the escape hatch is a documented contract instead of a fork.
This is not just reorganizing existing extension points — plugins gain real new powers, each detailed in §6: registering auth schemes (today: monkey-patch server.getUser), subscribing to commits with per-table filters (today: impossible), adding query languages, storage engines, ITC event types, index types, and certificate providers (today: all closed/hardcoded), declaring no-auth or streaming operations (today: hardcoded sets), and providing replication or other cluster services through a versioned ABI (today: assignment onto server.* + 40 deep imports). Most of the new powers are trusted-tier; the capability manifest (§5) is what makes granting them safe.
That's the vision. Everything below is the detail: terminology (§2), the rules (§3), the kernel boundary (§4), the plugin model (§5), each provider contract (§6), the ABI (§7), a rubric for future subsystems (§8), and the adoption plan (§9).
2. Terminology: plugin vs provider
Harper's language already moved once — the loader-era "component" gave way to the Plugin API (handleApplication(scope), PluginModule.ts, scope.pluginName; the loader's own deprecation warning says "Upgrade to the new Plugin API"). This proposal completes that shift and adds one word:
ships contributes dispatched by
you ──────────────────▶ PLUGIN ──────────────────▶ PROVIDER(s) ──────────────▶ kernel hot path
a folder: config, entries in typed direct call through
lifecycle, trust tier, registries: auth scheme, a compiled reference
capability manifest storage engine, operation, (resolved at load)
content type, protocol, …
| Term | Definition |
|---|---|
| Plugin | The unit of shipping, trust, and lifecycle: a folder (or injected module) with config, handleApplication(scope), a trust tier, and capability grants. Today's TRUSTED_RESOURCE_PLUGINS built-ins, HARPER_BUILTIN_COMPONENTS injections, and model-backend folders are all plugins. |
| Provider | One contribution to one typed registry: an auth scheme, storage engine, query language, model backend, content type, operation. Validated shape at registration; after resolution, a direct reference the kernel calls. |
| Application | User code built on Harper (scope.appName): Resources, routes, schemas. Sandboxed tier by default. |
| Component | Legacy/loader umbrella for "a loadable folder" (plugins + applications). Survives in code and ops (componentLoader, deploy_component); new writing prefers the specific term. |
| Registry | Kernel-owned typed map, provider id → provider (register/resolve/get/onChange). resolve() is load-time; hot paths never call it. |
| Facet | A slice of kernel API on scope (scope.transport, scope.auth, …), constructed per-plugin with only capability-granted methods. Replaces the fat server singleton. |
| Emit site | A kernel hot-path location publishing an observation through a compiled emitter slot. Zero subscribers = one null-check. |
| Kernel ABI | The versioned surface plugins may depend on: registries, facets, provider contracts, explicit kernel/abi.ts exports. Everything else is private. |
| Durable change feed | The persisted, resumable, transaction-framed stream of committed changes derived from the audit log (transactionBroadcast). Distinct from the in-memory observer bus. |
Why two words, not one:
- Cardinality is 1-to-many. Pro's replication plugin registers a replication provider and 22 operation providers and a shutdown drain. "The plugin registers four plugins into the plugin registry" is how terminology dies. (Precedent: VS Code extensions ship contributions; Terraform plugins ship providers; Java JARs ship SPI implementations.)
- Different rules attach to each. Capabilities gate what a plugin may register; invariants I-1/I-2 govern how a provider is dispatched.
- Plugins are the only way to ship a provider — with one boot-order exception. Databases open (
getTables()) before the plugin loader runs, so the built-in storage engines (and bind-time TLS) are system-registered providers: same contracts, same registries, no plugin folder. If an external engine materializes, the loader grows an early boot stage for storage-class plugins — deferred until there's a real second consumer, because every boot stage is permanent complexity.
3. The invariants
Load-bearing rules, numbered so reviews cite them ("this violates I-2"). Breaking one requires amending this design first, not an exception in code.
- I-1 — Resolve at boot, dispatch direct. Every provider boundary resolves once (config/load/db-open) into a direct reference. No per-operation lookup, scan, sort, or allocation may be introduced by a boundary. Template:
server/middlewareChain.ts → makeCallbackChain— topo-sort at registration, fold into nested pre-bound closures, one call per request. - I-2 — One provider per hot call site. Hot sites stay monomorphic: one engine per database, one auth scheme resolved before the hot call, one compiled emitter per table. Multiple implementations through one hot site → V8 megamorphic dispatch → 2–3× regressions with no obvious cause. Where providers legitimately coexist, selection happens on a cold/amortized path; the selected provider is then called directly.
- I-3 — Zero subscribers, zero cost. An emit site with no subscribers is one slot load + null-check (~1 ns, measured). No event object, no allocation, no call.
- I-4 — Filters resolve at subscribe time. A subscription's filter (table, event type) compiles into where the subscriber is stored (a per-table slot) and into the subscriber closure — never a per-event predicate scan.
- I-5 — Recompile on change, never interpret. When a registration set changes (rare, load-time), the dispatcher recompiles. Emitters and chains are never interpreted lists at runtime.
- I-6 — Synchronous emission, subscriber-buffered async. No promise, microtask, or queue per event. Async subscribers buffer their own work (the analytics model: thread-local accumulation, 1 s flush, one
postMessage/second/worker). - I-7 — Payloads are lazy or scalar. Emit sites pass scalars (id, type, size). Subscribers needing record values opt into a heavier subscription class, compiled differently.
- I-8 — The storage seam is db-open-granular. Engine selection and store/txn/audit/index construction resolve at database open. Nothing engine-conditional crosses a provider boundary per record.
- I-9 — Every adoption step is perf-gated. Write throughput + request latency (p50/p99) vs merge-base; >2% p50 throughput or >5% p99 latency regression fails the step — scheduled or opportunistic alike.
- I-10 — One system per job. Observer bus ≠ durable change feed ≠ ITC signals. Distinct delivery semantics; consumers choose by semantics, not convenience.
- I-11 — Registries are thread-local and deterministic.
handleApplicationruns per worker: identical config yields identical registrations on every worker, in deterministic (loader) order. A bus subscription observes its own thread's events; cross-thread aggregation is the subscriber's concern (via ITC — the analytics flush is the reference). Nothing may assume a registry is process-global.
4. The kernel
Kernel iff one of two reasons holds: bootstrap paradox (must exist before plugins load) or per-record/per-request hot loop (a boundary would cost per-operation work).
| Kernel subsystem | Where today | Reason |
|---|---|---|
| Plugin host | componentLoader.ts, Scope.ts, EntryHandler, OptionsWatcher |
bootstrap |
| Config bootstrap | config/configUtils.js (composed + memoized pre-plugin, #1513), RootConfigWatcher |
bootstrap |
| Resource contract | Resource.ts, ResourceInterface.ts, transactional() |
per-request; every protocol converges on it |
| Transaction + context | transaction.ts, contextStorage (ALS), DatabaseTransaction/LMDBTransaction |
per-request |
| Commit pipeline + encoder | Table.ts write path, RecordEncoder.ts — incl. the in-transaction audit write |
per-record |
| Socket accept + dispatch | server/http.ts transport, threads/socketRouter.ts |
per-request; backend-forked (Node/Bun/uWS) |
| Thread lifecycle + ITC fabric | server/threads/*, manageThreads.js |
bootstrap. (The message-type layer above it is a provider seam) |
| Zero-copy export mechanism | globals.js, symlink machinery |
it is the plugin mechanism |
| Security context plumbing | contextStorage, request-as-context |
per-request |
| Registries, facets, buses | kernel/ (target home) |
they are the kernel's API |
| System-registered providers | rocks/lmdb StorageProviders, bind-time TLS |
needed before the plugin loader runs |
| CRDT apply | resources/crdt.ts (null-prototype closed operations map) |
per-record and a security boundary (__op__ must never resolve to arbitrary code). Permanently closed. |
| Jobs runner/lifecycle | server/jobs/*, THREAD_TYPES.JOB, START_JOB ITC, hdb_job |
rides the thread fabric. Job kinds → operations registry. |
| Core upgrade directives | upgrade/directives/directivesController.ts |
run pre-plugin at boot |
| Validation library | validation/ |
shared library, not a dispatchable subsystem |
| TLS selector plumbing | security/keys.ts → createTLSSelector + hdb_certificate subscription |
certs at bind time, pre-plugin. Cert acquisition is a provider. |
Everything not on this list is contributed by a plugin or becomes so. The kernel's size is a budget, not a floor.
5. The plugin model
Loading/lifecycle (unchanged machinery): plugins and applications are discovered by the loader (which keeps its historical name, componentLoader) from componentsRoot, root-config keys, RUN_HDB_APP, and HARPER_BUILTIN_COMPONENTS injections; a plugin exports handleApplication(scope); per-plugin YAML config with hot reload (OptionsWatcher). This design changes what scope exposes (facets) and what loading validates (capabilities, config schemas) — not the lifecycle. The pre-plugin extension API (start/startOnMainThread/handleFile…) is runtime-deprecated and removed in the retrofit-enforcement step (§9), after its last first-party user (server/operationsServer.ts) migrates.
Trust tiers (all enforcement primitives exist today — security/jsLoader.ts vm/SES sandboxing, allowedPath, builtin-module and spawn allowlists; what's new is gating the registration surface, which today is completely open):
| Tier | Loader | May contribute |
|---|---|---|
| system | kernel-shipped (incl. system-registered providers) | everything |
| trusted | TRUSTED_RESOURCE_PLUGINS + HARPER_BUILTIN_COMPONENTS, native import |
everything incl. hot-path providers: storage strategies, index types, ITC event types, protocols, commit-bus subscribers, no-auth operations |
| application | sandboxed | Resources, routes, operations (declared-auth only), metrics, model backends, content types |
Capability manifest — a plugin declares what it contributes; the loader constructs its facets accordingly (generalizing the proven env: declare-then-gate flow in componentSecrets.ts):
# harper-config.yaml (plugin)
capabilities:
- registerOperation
- subscribeCommits
- registerContentType
mySetting: 42
An ungranted capability means the facet method does not exist on that plugin's scope — enforcement is structural at facet construction, never a per-call check. trusted tier implies all capabilities. A capability the tier cannot hold fails that plugin's load; others are unaffected. The vocabulary is generated, not curated: one capability per registration surface, derived mechanically from the registry family and facet methods (operations.register → registerOperation, commits.onCommit → subscribeCommits) — a new registry gets its capability for free, granularity tracks the API surface by construction, and there is no bespoke list to argue over. Adoption is ratcheted (§9): capabilities gate new facet surface from the day it ships; requiring manifests from existing plugins is the separately-debated retrofit step.
Plugins are packages. The distribution unit is an npm package as readily as a folder — the loading mechanism already exists (an unrecognized provider id in config is a module specifier loaded through plugin-trust machinery, §6.1). What packages add is policy, not machinery: a third-party package defaults to the application tier — capabilities beyond that tier are granted explicitly in the host's config, never claimed by the package itself; it asserts a minimum KERNEL_ABI_VERSION at load (§7); and it is expected to pass the conformance kit for each provider it ships (§6.9). A curated catalog is explicitly deferred — a catalog with three entries is worse than none — but nothing about the mechanism waits on one.
6. Provider contracts
6.1 The registry primitive
Generalized from the production exemplar, resources/models/backendRegistry.ts:
interface Registry<T> {
register(id: string, provider: T, opts?: { replace?: boolean }): void; // validates shape; load-time errors, never dispatch-time
resolve(id: string): T; // throwing resolve — LOAD-TIME verb; hot paths never call it
get(id: string): T | undefined;
ids(): string[]; // introspection (status / OpenAPI / MCP)
onChange(cb: () => void): void; // recompile hook for compiled dispatchers
}
Config-driven bootstrap follows the models pattern: a config block names providers by id; an unrecognized id is a module specifier loaded through plugin-trust machinery — external engines/backends install without kernel edits. Registries self-register with the kernel (inverting today's loader→bootstrapModels hard call). A resolve() inside a request handler is a review-blocking bug (I-1).
Registration lifecycle. Every registration is scoped to the registering plugin's scope lifetime: when a plugin tears down (hot reload, config change, load failure), everything it registered — providers, bus subscriptions, middleware — is deregistered and the affected dispatchers recompile (I-5). A reload is teardown + fresh load, never an incremental patch, so duplicate subscribers and stale providers cannot survive a reload. Registering an id that already exists without replace: true is a load-time error; registration order is deterministic (I-11), so identical config produces identical registries on every boot and every worker.
Failure semantics (normative per contract class — the shared rule: load-time failures fail the plugin, never the process; runtime failures are logged with plugin attribution):
| Contract | On provider failure |
|---|---|
| Bus subscriber | throw is caught, never propagates into the commit (§6.3) |
AuthProvider.resolve |
throw/reject = authentication failure for that request — the standard unauthenticated response, never a 500; bounded by existing request-timeout machinery |
| Protocol provider | errors surface through that protocol's own error handling, exactly as protocol plugins behave today |
| Storage / system provider | trusted tier; failure at db-open is fatal for that database — no silent engine fallback |
| Operation provider | the existing operation error contract (thrown errors → operation error responses) |
The family: protocol · contentType (exists) · operation (exists, #1736) · auth · storage · queryLanguage · modelBackend (exists — the template) · indexType · itcEventType · certificateProvider · secretsCustody (exists). Rejected: conflictStrategy (CRDT stays kernel) and a core migration registry (bootstrap paradox).
Facets — the plugin-facing kernel API, splitting today's fat Server interface:
| Facet | Contents | Capability |
|---|---|---|
scope.transport |
http/request/ws/upgrade/socket registration, compiled chains, contentTypes |
registerProtocol, registerContentType |
scope.auth |
registerScheme, setUserResolver, getUser, authenticateUser, onInvalidatedUser |
registerAuthScheme |
scope.operations |
register, invoke |
registerOperation |
scope.metrics |
record (= recordAction), onFlush, onAggregate |
recordMetrics (default-granted) |
scope.commits |
onCommit(filter, handler) — the observer bus |
subscribeCommits |
scope.changeFeed |
durable subscription API | subscribeChangeFeed |
scope.cluster |
nodes, shards, hostname, registerReplicationProvider |
trusted-only |
scope.models |
the existing models singleton | registerModelBackend |
| (existing) | options, resources, databaseEvents, secrets, ensureTable, handleEntry, import, logger |
as today |
Facet methods and legacy server.* members are the same underlying functions; legacy members deprecate with runtime warnings and are removed no earlier than one major version after facets ship. server.recordAnalytics — zero in-repo consumers; public-API only — becomes an alias of scope.metrics.record.
6.2 Transport and protocol providers
Three HTTP backends (Node http/https/http2, Bun, uWS) fork below the Request abstraction, with per-backend request wrappers and response serialization. Normative consequence: the protocol-provider ABI is pinned at the Request / response-descriptor line — providers receive the wrapped Request (or a socket via scope.transport.socket) and never see backend objects, raw sockets on HTTP paths, or TLS contexts. A protocol provider registers listeners with before/after ordering (compiled by topo-sort — the makeCallbackChain machinery, unchanged) and/or takes a socket listener and runs its own protocol loop. MQTT is the reference implementation (a trusted plugin on server.socket + server.getUser, including its durable-session layer).
6.3 The commit-observer bus
The synchronous, per-thread, in-process observation seam — strictest invariants (I-3…I-7) because its emit sites live in the hottest loops.
// Subscribe time (plugin load) — the filter resolves NOW (I-4):
scope.commits.onCommit({ table: 'dog', types: ['put', 'delete'] }, (id, type, size) => { ... });
// Emit site (kernel, compiled; per table / per port):
if (table.onWriteEmit !== null) table.onWriteEmit(id, type, size);
Mechanics (each measured): per-table emitter slots (null when unsubscribed; type filters compile into the subscriber closure) · compiled emitters (one subscriber → the slot holds the subscriber function itself; N → a compiled closure calling each directly, recompiled on change) · scalar payloads (record-value access is an opt-in heavier subscription class — audit/replication materialize the record regardless; analytics never needs it) · per-thread capture, cross-thread aggregation is the subscriber's concern (reference: analytics' 1 s flush + one postMessage/second/worker) · best-effort, in-thread, post-write delivery; subscriber crashes are caught, never propagated into the commit · null-payload markers tolerated.
For: metrics, cache-invalidation hints, triggers, dev taps. Not for: audit (part of the commit), replication (needs durability + resume), anything cross-thread/cross-node (I-10).
6.4 The durable change feed
Harper already has a durable event bus: the audit log + transactionBroadcast.ts — coalesced audit-log iteration, transaction framing, zero-subscriber short-circuit, resumable cursors. Consumers today: replication (Pro), MQTT incl. durable sessions (DurableSubscriptionsSession: QoS acks, last-will, cursors in hdb_durable_session), SSE/WS, MCP resource subscriptions.
This design formalizes rather than replaces it: Table.subscribe()/addSubscription becomes the documented, versioned change-feed API (durable, per-table, txn-framed, resumable, at-least-once to a live listener). The audit write stays in the kernel commit pipeline — recordUpdater writes it synchronously inside the same store transaction as the primary write; moving it post-commit changes durability semantics: rejected. The load-bearing distinction: audit is not an observer of the commit; it is part of the commit. Replication consumes the persisted entries.
And the formalized feed is not just infrastructure — it's an application-tier headline: live queries (vision claim 2, §1). The design goal: subscribing to a Resource is as ergonomic as get()-ing it — durable, resumable across restarts and reconnects, under the same permission model as reads. The substrate is done and battle-tested (it already carries replication); what's missing is the app-facing shape, which needs its own design pass.
| Mechanism | Semantics | Use when |
|---|---|---|
| Commit-observer bus | sync, in-thread, best-effort, scalar payloads, zero-cost idle | telemetry, hints, triggers |
| Durable change feed | audit-backed, resumable, txn-framed, full records | replication, messaging, subscriptions — must not miss a change |
ITC signals (signalSchemaChange/signalUserChange/…, databaseEventsEmitter) |
cross-thread metadata propagation, specific local-then-broadcast ordering (#1497) | schema/user/topology notification |
6.5 Auth providers
Auth already resolves exactly once per request in one middleware (security/auth.ts, registered by the authentication plugin; dependents order via after: 'authentication'), with fixed precedence (CORS → session → mTLS → Authorization header → session user → local bypass) and a cache keyed on the raw header. Today's only extension mechanism is monkey-patching server.getUser.
interface AuthProvider {
scheme: string; // 'Basic' | 'Bearer' | 'MyToken' — the Authorization scheme token
resolve(credentials: string, request: Request): Promise<User | undefined>; // standard User shape
}
scope.auth.registerScheme(provider); // plugin load time; replaces the closed switch
scope.auth.setUserResolver(fn); // formalized replace-and-delegate
Scheme selection is a synchronous Map lookup replacing the hardcoded switch, sitting entirely on the cache-miss path — cached requests never touch the registry; zero added steady-state cost. Providers return the standard user shape so authorizationCache/usersWithRolesMap are reused unchanged. Rejected permanently: ordered try-each-provider chains (per-request iteration + megamorphic call site, I-2). Basic/Bearer become the reference providers registered by the authentication plugin itself.
6.6 Storage providers — a construction seam, not a runtime interface
Deliberately narrower than classical pluggable storage, because the evidence says the wider version violates the performance veto. Selection already resolves once, but ~79 engine branches thread the shared paths and six are semantic: sync vs async commit-encode (Rocks putSync vs LMDB optimistic ifVersion — unifying forces Promise allocation onto the sync path), version model (version vs txnTime+localTime), snapshot semantics (disableSnapshot honored vs ignored), audit format (TransactionLog vs binary-DB — non-interchangeable; copyDb.ts doesn't migrate audit stores), conflict/retry protocol, index-store shape.
interface StorageProvider {
id: string; // 'rocks' | 'lmdb' | future
openRootStore(path: string, opts: OpenOptions): RootStore;
openAuditStore(rootStore: RootStore): AuditStore;
openIndex(rootStore: RootStore, attribute: Attribute, opts): IndexStore;
createTransaction(kind: 'chained' | 'immediate', store: RootStore): Transaction;
capabilities: { syncCommitEncode: boolean; honorsDisableSnapshot: boolean; reusableAuditIterable: boolean };
}
Resolution once per db-open (I-8). Deletes the construction-time instanceof/isRocksDB branches (txnForContext, openAuditStore, openIndex, database()). Keeps per-engine behavior as explicit strategy objects hung off the store at open (the pattern RocksIndexStore/RocksTransactionLogStore already prove) — monomorphic per table, no added per-record dispatch. Explicit non-goal: hot-swappable engines behind one uniform runtime interface. Engines are system-registered at boot (databases open before plugins load); an early boot stage for storage-class plugins is deferred until an external engine exists.
6.7 Operations providers
Already the most mature registry (#1736): same dispatch map as built-ins, cross-thread ITC bridge, permission-table integration, automatic MCP tool surfacing. Remaining gaps, in priority order (items 1–2 change behavior — they apply to newly registered operations from day one, and retrofit onto existing ones only in the enforcement step, §9; 3–5 are behavior-preserving):
- Make access declaration required — today, omitting
requiresSuperUseryields no permission entry: the op throwsOP_NOT_FOUNDfor every non-super_user while silently working for super_users.OperationDefinitiongains a requiredaccess:. - Enforce declared validation —
parametersSchemaexists but only feeds MCP; validate pre-invoke using the sharedJsonSchemaFragmentIR (one vocabulary across validation/OpenAPI/MCP). - Registry-back the hardcoded dispatch sets —
NO_AUTH_OPERATIONS,SSE_PROGRESS_OPERATIONS, therestartprefix case, the CLI's SSE/prepare tables (noAuthrestricted to trusted tier). - Bridge metadata across threads — declared access/validation must ride
announceRegisteredOperation(today only name/execution cross). - Drive ops-API OpenAPI from the registry.
6.8 The rest, briefly
- Query languages:
queryLanguage.register('sql', { parse, checkPermissions, execute })replaces the single hardcodedoperation === 'sql'branch (not hot). SQL becomes a lazily-loaded plugin; GraphQL registers for uniformity. - Index types: formalize
CUSTOM_INDEXESasindexType.register(...); trusted tier only (write hot path). - ITC event types:
itc.registerEventType(name, handler)(trusted), opening the closedITC_EVENT_TYPESenum +validateEventgate on top of the already-openonMessageByType. - Certificate providers: the
hdb_certificatetable is already the de-facto contract (rows propagate live to worker TLS contexts via thecreateTLSSelectorsubscription); a provider (ACME, vault) is a renew loop whose only output is table rows. Selector stays kernel. - Secrets custody: already a provider seam (
registerSecretDecryptor/registerSecretCustody; Pro registers the real one). - localStudio: becomes a trusted static-serving plugin (today: hardcoded fastify route). Zero hot-path involvement — the proving lift-out.
- Metrics:
scope.metrics.recordfronts the existingrecordAction; capture pipeline unchanged (thread-local buffer, 1 s flush, main-thread aggregation); kernel emit sites publish via the bus with the analytics plugin as first subscriber;addAnalyticsListener/onAnalyticsAggregatebecome subscriptions with aliases retained (the latter is Pro licensing's metering hook — ABI). - Config schemas: a plugin ships a
JsonSchemaFragmentfor its config block; the loader validates on load and every hot reload (today plugin config is entirely unvalidated —additionalProperties: true,// TODO: validate optionsinScope.ts). Failures fail the plugin, not the process. - Introspection: a registry-backed
describe_pluginsoperation — plugins loaded (tier, version), providers registered per registry, capabilities granted,KERNEL_ABI_VERSION, and the ratchet burn-down counts (§9). Nearly free to build onids(); it's the permanent home for migration status and the first thing support will ask for.
6.9 Provider conformance kits
Each provider contract ships a contract test suite — a reusable kit any implementation must pass: AuthProvider (scheme-token handling, standard User shape, rejection semantics), StorageProvider (the six documented semantic divergences as explicit capability-flag cases), commit-bus subscribers (I-3…I-7 compliance). The built-ins are the reference implementations and pass their kits in CI — that keeps the contracts mechanically true rather than aspirationally documented, and catches contract drift before an external implementer does. Kits are not speculative work: each is extracted from the tests written when its registry lands (§9, Steps 2–3), then packaged for reuse.
6.10 The agent surface
Everything above is machine-legible by construction, and that deserves a name: an agent builds on and operates Harper through the same contract people use (vision claim 4, §1). Operations registered through scope.operations already surface automatically as MCP tools (#1736) with parametersSchema descriptions; the change feed already backs MCP resource subscriptions; describe_plugins (§6.8) makes the installed system self-describing; schemas make the data navigable. Composed, that's a capability no comparable platform has in one process: an agent can discover the schema, call typed operations, subscribe to changes, and — with granted capabilities — extend the system, with no glue services between it and the data. The gaps this framing exposes are ordinary registry work already listed elsewhere: pre-invoke parametersSchema enforcement (§6.7 item 2, so an agent's malformed call fails validation rather than execution) and schema introspection exposed as an MCP resource.
7. Kernel ABI and evolution
Three concentric public surfaces: (1) the harper package exports — application-tier, semver-stable; (2) the facets and registries — provider API, additive evolution; (3) kernel/abi.ts — explicitly exported internals for trusted plugins, each export deliberate. Everything else is private and may change without notice (review convention, eventually lint on deep-import paths).
Why kernel/abi.ts must exist — the Pro contract. Verified: Pro never imports through the public harper package. Every import is a deep relative reach into ../core/... touching ~40 module files — auditStore.ts wire-format constants, RecordEncoder.ts internals (lastMetadata, lastValueEncoding, flag bits), ~14 blob.ts internals, nodeIdMapping.ts, Table.ts residency internals, transactionBroadcast.ts, manageThreads.js, security/keys.ts — and it installs itself by assignment (server.replication = {...}, server.nodes, server.shards). Consequences until Step 1 (§9): the audit wire format, encoder flag bits, blob machinery, and residency logic are frozen ABI (a refactor there is a silent Pro break and a cross-version replication break); the injection mechanism and Pro's clean seams (registerOperation ×22, registerSecretCustody, registerWorkerDataProvider, registerShutdownDrain, onAnalyticsAggregate, table.sourcedFrom) are ABI. This freeze taxes core development daily — which is why Step 1 (§9) replaces assignment with scope.cluster.registerReplicationProvider(...) and promotes the deep-import surface into named, versioned exports as the first substantive move. (Process note: Pro's core submodule is pinned at v5.1.9 while Pro source targets 5.2.0-alpha and already imports symbols absent from the pin — the effective contract is "core main"; one more reason the ABI must be explicit.)
Enforcement is mechanical, not procedural. The ABI ships as published type declarations that Pro compiles against — an ABI break becomes a compile error, not a runtime surprise — and core CI gains a Pro canary job that builds and type-checks Pro against each core PR. The break surfaces on the PR that causes it, which is the actual velocity unlock: without this, the ABI decays back into folklore the way the submodule pin already has.
Evolution policy: KERNEL_ABI_VERSION (one integer; plugins may assert a minimum at load; replication handshakes it for mixed-version clusters) · additive by default — removals/renames/semantic changes are major with ≥ one minor of runtime-warned deprecation first · deprecation is a ship vehicle (warning + docs URL + alias period + named removal phase) · provider contracts version independently of implementations · new invariants or kernel members require amending this document in the same PR.
8. Decision rubric — "should X be a plugin?"
X must exist before plugins load (config/threads/loader/storage-at-open/certs-at-bind)?
├─ yes → KERNEL (possibly a system-registered provider)
└─ no → X runs per record/request AND cannot resolve to a direct ref at load/db-open?
├─ yes → KERNEL
└─ no → X is a security boundary where open registration widens attack surface?
├─ yes → KERNEL or trusted-only registry (design doc required)
└─ no → X observes writes/requests, no durability/replay needed?
├─ yes → commit-observer bus subscriber (I-3..I-7)
└─ no → X needs every change, resumable?
├─ yes → durable change-feed consumer
└─ no → X is one implementation among alternatives?
├─ yes → a PROVIDER in the matching registry, shipped by a
│ PLUGIN (or propose a registry; name its resolution point)
└─ no → a plugin/application with Resources/routes/operations
Always: name the resolution point; show monomorphic hot dispatch (I-2);
name the perf gate that covers it (I-9).
9. Adoption plan — contracts first, ratchet, opportunistic migration
The tempting shape for a change like this is a scheduled, sequential migration. That shape has the exact failure mode this codebase already exhibits: Harper's plugin commitment is half-enforced today because adoption has always depended on a sustained dedicated effort — and dedicated efforts lose to feature work. A grand migration that stalls halfway leaves a third architecture in the tree. So the plan is built the other way around:
Doctrine: new code complies from day one; existing code migrates when touched or when there's a concrete win; nothing waits on a grand migration.
Three mechanisms make that converge instead of drift:
- Contracts land first, small and additive —
Registry<T>, facets, the bus primitive,kernel/abi.ts. No behavior change; the smallest diff that makes every later step enforceable. - A ratchet, not a cleanup — and the boundary is logical, not physical. A checked-in boundary map assigns every existing source path a tier (
kernel/kernel-abi/plugin/unassigned); the lint reads the map, so the ratchet bites from day one — long before any file moves — and a later physical move is just a rename in the map. Rules: nothing outside the kernel tier imports kernel internals; the kernel tier never imports from the plugin tier. Today's violations are frozen in an allowlist; new violations fail CI immediately; the allowlist and theunassignedbucket only shrink. Those two burn-downs are the migration status — surfaced bydescribe_plugins(§6.8), no separate tracking, no stall ambiguity, and feature work is never blocked by them. - Registries are demand-driven. A registry is built when its first real provider needs it, not speculatively. §6 is the reference design for each; an implemented-but-unused registry is public API we'd have to version forever — dormant seams are speculative surface, and YAGNI applies to kernels too.
Compatibility posture unchanged: aliases → warnings → removal; nothing breaks a public surface without a deprecation window. Every step merges only if its perf gate passes (I-9).
Step 0 — contracts + ratchet (small, immediate; zero behavior change)
-
Registry<T>primitive, facet construction, bus primitive (no emit sites yet) - Boundary map (every source path →
kernel/kernel-abi/plugin/unassigned) + lint + frozen allowlist — the ratchet, enforceable before any file moves - Perf-gate harness: write throughput (sustained
table.putops/s, single/multi-thread, both engines), REST GET/PUT p50/p99 vs merge-base; >2% p50 throughput or >5% p99 latency fails; CI manual-trigger first, then required
Step 1 — unfreeze Pro: the kernel ABI (risk: medium, cross-repo but mostly mechanical; gate: core benches + Pro replication soak)
Pro's ~40 deep imports are the single biggest drag on core velocity today: while they're implicit, the audit wire format, encoder flag bits, blob machinery, and residency logic are frozen against refactoring (§7). The ABI surface is descriptive — it's whatever Pro already imports — so formalizing it is mostly re-exporting known symbols and switching import paths. It goes first because doing it later would mean running everything else under the freeze.
-
kernel/abi.ts+KERNEL_ABI_VERSION(audit wire API, residency hooks, blob transfer, id-mapping) - Coordinated Pro PR: imports via ABI only;
scope.cluster.registerReplicationProviderreplacesserver.*assignment; ABI handshake for mixed-version clusters - ABI published as type declarations Pro compiles against; Pro canary job in core CI (build + type-check Pro against each core PR — an ABI break surfaces on the PR that causes it, §7)
Step 2 — commit-observer bus; analytics as first subscriber (risk: low — analytics loss ≠ data loss; audit untouched)
The bus is proven (§1.3), its first subscriber is the existing analytics capture as it stands on main, and it's the one step that improves performance on its own (the per-write gate moves to subscribe time). Any future analytics rework then happens behind the seam without touching the write path again.
- Bus emit sites (commit/read/request) + per-table emitter slots
- Analytics as first subscriber; per-write gates deleted;
scope.metrics.recordfacade;server.recordAnalytics→ deprecated alias;addAnalyticsListener/onAnalyticsAggregate→ subscriptions (aliases retained — Pro licensing) - Response-status string interning (
'response_' + statusper-request alloc) - First conformance kit (commit-bus subscriber, §6.9) extracted from the analytics subscriber's tests
- Exit: zero hardcoded analytics call-sites in
RecordEncoder.ts/Table.ts/http.ts/mqtt.ts; the ~6% suite-2 result reproduced end-to-end
Step 3 — feature-unlocking registries, as demand arrives (each small and independent; build in the order requests dictate)
- Auth schemes (§6.5):
registerSchemereplaces the closed switch; Basic/Bearer as reference providers passing theAuthProviderconformance kit (§6.9); retires theserver.getUsermonkey-patch (gate: request latency, warm + cold cache) - Operations gaps (§6.7): required
access:+ pre-invoke validation for newly registered operations from day one (ratchet); registry-backed dispatch sets; metadata bridging; registry-driven OpenAPI - Query languages (§6.8): when the next language request lands; SQL lifts out with it (gate:
sqlop latency parity)
DX track — parallel, starts alongside Step 2 (now that we're open source, adoption is a product problem: the one way to extend Harper must also be the easiest way — contracts nobody can easily build against don't get built against)
- Plugin scaffold (
harper create-pluginor similar): folder layout, config,handleApplication, capability manifest, tests pre-wired to the conformance kits (§6.9) - Reference-plugin walkthrough: the localStudio lift-out (§6.8) doubles as the tutorial — trivial scope, zero hot-path risk
- Minimal-kernel test harness: boot registries + facets in-process so a plugin's unit tests run without a full server
Opportunistic track — no schedule; rides other work (each individually perf-gated)
- Storage construction seam (§6.6): convert
isRocksDBconstruction branches toStorageProviderfactories asTable.ts/open paths get touched. No external engine exists, so there is no feature pressure — this is hygiene priced near zero by riding diffs that are already there. The regression-prone LMDB async-prefetch path is exactly why this should not be a scheduled big-bang. - Physical moves into
kernel//plugins/(§1.1): per subsystem, only after Step 1's ABI, ideally in the PR already rewriting that subsystem. - Lift-outs on first real consumer: localStudio → static plugin · ITC event types · index types · certificate providers.
- Change-feed formalization (§6.4): document
Table.subscribe/addSubscriptionas the versioned contract — docs + tests, no code motion.
Retrofit enforcement — separately debated; explicitly not blocking anything above (risk: low-medium; gate: boot-time budget + full suite)
Everything above ratchets new surface. Applying policy to existing surface is a distinct decision with its own costs, taken after the contracts have dogfooded on built-ins:
-
capabilities:manifests required for existing plugins; per-plugin facet construction - Config schema fragments validated on load/hot-reload for existing plugins
- Retrofit
access:declarations + pre-invoke validation onto existing operations (§6.7 items 1–2) - Migrate
operationsServer.tsoffstartOnMainThread; remove the old extension API + lapsed aliases
Rejected or deferred (recorded so they aren't re-litigated without new evidence): a scheduled, sequential grand migration (loses to feature work; the ratchet converges without one) · conflictStrategy registry (kernel — security + hot loop) · event-bus replacement of audit or transactionBroadcast · full runtime storage interface · process/package isolation of any kind · core migration registry (bootstrap paradox; per-plugin migrations are separate future work) · early boot stage for storage-class plugins (until an external engine exists) · speculative registries with no registered provider (demand-driven only).