Architecture
Where this comes from
The design follows the thesis in the companion research vault (../sms-research):
safe live self-modification needs a substrate (revertible effects + reactive dependencies, from Cordis /
the Spatiotemporal Composability paper), a judge the agent cannot reach (the lesson of the Darwin Gödel
Machine’s objective hacking), and persistence (which DeepSeek Harness deliberately skipped: “scratch, not
memory”). No shipped system had all three; this repo is an attempt to — packaged so that other applications
can adopt it. docs/research-positioning.md maps every concept to code and carries the threat model.
What we copied, and from whom:
| From | Taken | Changed |
|---|---|---|
| Linux | a small generic kernel; drivers behind contracts; everything else loadable | drivers are npm packages picked at composition time |
| Cordis / dsh | fiber per plugin, dispose-on-failed-start, inject/provide keys, façade-style ctx, immutable versions with a moving current; “consumers depend on service definitions, never providers” | effects are a closed vocabulary so undo is derived by the host, never written by the (LLM) author |
| dsh | everything is a plugin; human approves the visible half | approval is a judge policy (sensitiveScopes/Points), not baked in; there is no visible half — the baseline is headless |
| pi | the loader loop is enough — no “modify yourself” API beyond the tools the agent has | the loader is plugin_define → judge → kernel; no file system needed |
| DGM | a judge that is out of reach | trust tiers inside the kernel + authorization tokens, instead of hiding |
| Strapi / Directus | declarative collections with auto CRUD/REST/hooks | collections are just an extension point owned by a plugin, on any SQL engine |
Rings
Deeper is more abstract — the Linux kernel / OSI shape. A package imports its own ring or below; the tree is the
diagram, and scripts/quality/layers.ts is the same model as code (pnpm quality enforces it,
docs/dependency-graph.md is generated from it by pnpm graph).
ring 4 examples/* composition roots: pick drivers, add plugins, listen
ring 3 packages/baseline @sms/baseline: createBaseline() · createTenantPool() · testkit · re-exports every plugin
ring 2 packages/plugins/* │ packages/drivers/{store,host,executor,model,objects}/*
features, platform-free │ one contract each, platform-specific; never import a plugin
http · collections · judge · agent-loop · admin-api · scheduler · auth · rbac · orm · meta · rpc · automation · audit
ring 1 packages/core/plugin-sdk │ packages/core/contracts
what agent code sees: │ the waist: Database · Store · Host · TenantResolver · ModelService · ObjectStore
cap.provide/contribute/ │ (kernel types only — never plugin-sdk)
get/invoke/log + channel │
ring 0 packages/core/kernel loader · registry · fibers · derived undo · event log · trust tiers · Executor interface
The kernel is the single root; every ring above it is a pair of siblings that never import each other and meet
only in the ring above. Ring 1 splits ergonomics (plugin-sdk) from vocabulary (contracts); ring 2 is the
hourglass: plugins and drivers are siblings on top of contracts, and neither column imports the other. Plugins are also platform-free (no node:*, no SDKs) so they run wherever the kernel runs.
Plugins depend on definitions, never providers (the Cordis rule; Linux modules link against exported symbols, not each other’s source). Each plugin has two faces:
@sms/plugin-x/contract (src/contract.ts) | @sms/plugin-x (src/index.ts) | |
|---|---|---|
| holds | X_KEY/X_POINT constants, service + item types, validators, pure helpers | everything in the contract, plus createXPlugin() / xPlugin |
| imported by | other plugins (the only allowed subpath between plugins) | composition roots: baseline, apps, tests |
| may reach | contracts, kernel types, other contracts | anything below ring 3 |
The manifest is the runtime counterpart: inject (hard — the reconciler waits for it and unloads the fiber
before its provider) and optional (soft — read with ctx.kernel.getValue, present in FiberView). The gate
cross-checks the three: a runtime import of a sibling’s contract, a getValue(key) call, and the manifest must
agree. The plugin graph is a DAG.
The kernel
Kernel
├─ keys name → { value, owner } + KeyMeta { minProviderTrust, minConsumerTrust }
├─ points name → Contribution[] + ExtensionPointMeta { minContributorTrust, validate }
├─ fibers id → { manifest, trust, enabled, state, effects[] }
├─ settle() reconciler: enabled ∧ inject satisfied → activate; disabled ∨ dependency gone → deactivate
├─ log append-only events; foldTarget(events) = what should be installed/enabled
└─ executor Executor.load(bundle, HostBridge) for code the kernel will not run in-process
Fiber lifecycle: inactive → loading → active → unloading → inactive, or loading → failed. Activation
applies manifest.contributes first (declarative), then runs the plugin’s activate (in-process for system
plugins; through the executor for agent plugins). Every provide/contribute pushes an AppliedEffect with
its inverse. A throw anywhere unwinds what ran.
Ordering (Cordis Thm 63, “provider outlives consumers”): deactivating a fiber first deactivates every active
fiber that injects one of its provided keys, recursively, then undoes its own effects. settle() then
re-activates whatever became satisfiable again.
Trust tiers: system (installed by the composition root, in-process, holds ctx.kernel) and agent
(proposed at run time; never holds KernelApi). Keys and points carry minimum trust; Registry.get/provide/ contribute check it every time. Kernel.propose() refuses an agent manifest without a MountAuthorization
minted by Kernel.authorize() — which is only reachable through ctx.kernel, i.e. from system code. A
forged or reused nonce, or a manifest whose code hash differs from the authorized one, is rejected. An agent
manifest whose id belongs to a system fiber is rejected (RESERVED_ID) — otherwise “update” would replace the
judge itself — and uninstall refuses system fibers unless the composition root passes force.
Executor boundary: plugin code never receives objects. It gets cap whose calls go over a bridge to the
kernel’s HostBridge, which re-checks inject and trust on every get/invoke. Functions inside
contributed items are marshalled as { $fn: id } and turned into async proxies by the kernel, so a tool
handler written by the agent is callable by the host, and stops working the instant the fiber is disposed.
Values flowing to plugin code are JSON snapshots.
Drivers (the “device” layer)
@sms/contracts defines what a host must supply; the baseline and plugins depend on that, never on an engine:
| Contract | Used by | Drivers |
|---|---|---|
Database { dialect, exec, query(sql, ?-params), columns, close } | collections (key database), store-sql | sqlite (node:sqlite), postgres (pg/PGlite; schema per tenant), do (Durable Object SqlStorage), d1 (one D1 per tenant) |
Store { db, log, blobs, close } | kernel (log, blobs) | createSqlStore(db) in store-sql turns any Database into a Store |
ObjectStore { put, get, head, delete, list } | file-handling plugins (key objects) | s3 (AWS / MinIO / R2 over S3, SigV4 on fetch), r2 (bucket binding), memory (tests) |
Host { listen(handler) } | composition roots | node (node:http), fastify (mount under a prefix, encapsulated scope), cloudflare (Worker fetch + DO class) |
TenantResolver | createTenantPool, createCloudflareApp | tenantFromHeader, tenantFromSubdomain, custom |
Executor (kernel) | kernel, judge’s load gate | node (worker_threads), quickjs (WASM) |
ModelService | agent loop (key model) | claude (Claude Agent SDK, claude login), gpt (Codex SDK, codex login), claude-api (Messages API), gpt-api (Responses API), openrouter (Chat Completions), gpt-oauth (Codex backend, personal login), scripted (tests) |
Every store driver passes packages/core/contracts/test/database-contract.ts and the collections contract suite;
object stores pass packages/core/contracts/test/objects-contract.ts; both executors pass the same executor tests;
model drivers pass packages/core/contracts/test/model-contract.ts. See docs/drivers.md.
Persistence
The event log is the source of truth; mounted state is a cache. plugin/installed (with manifest and code
hash), plugin/enabled, plugin/disabled, plugin/uninstalled fold into a target map. Kernel.restore()
re-creates agent fibers from that map (fetching code from the blob store by hash) and settles. System plugins
are re-installed by the composition root each boot; their enabled flag is taken from the log.
fiber/*, judge/* and chat/* events are audit only. Tables: kernel_events, kernel_blobs,
c_<collection>; withdrawing a collection or field keeps its table/column.
Tenancy
The kernel is single-tenant. createTenantPool({ resolve, tenant }) boots one baseline per tenant id on
its own store (file / schema / DO) and routes each request; on Cloudflare the Durable Object is that pool
entry. Tenant ids are validated because they become file and schema names. See docs/tenancy.md.
The judge (a plugin)
Gates (the gate point, system-only): manifest → trust → source-lint → load (activate in a throwaway
executor with a recording bridge; provided keys must be declared; unknown points fail). Then policy: sensitive
scopes/points → pending human approval (/api/approvals). Then mount, then invariant contributions run
against the live kernel; a violation rolls back and, for an update, restores the previous version from the
log + blobs.
The judge is unreachable from agent code by four independent facts: judge is a system-only key; gate and
invariant are system-only points; authorization minting lives on KernelApi; and system plugin ids are
reserved (the judge additionally requires agent ids to start with agent.). The services other plugins rely on
(http, collections, model, agent, database) can only be provided by system fibers. Tests:
packages/core/kernel/test, packages/plugins/judge/test, packages/baseline/test.
Admin surface (headless)
plugin-admin-api contributes JSON routes only: /api/state, /api/catalog, /api/chat, plugin
enable/disable/uninstall, approvals, /api/events. Users, groups and models come from @sms/plugin-auth,
@sms/plugin-rbac and @sms/plugin-orm; createBaseline installs all three (auth: false opts out of the
first two) and plugs rbac into the ORM’s guard: anonymous requests are denied, users pass the access matrix
and record rules ($uid = caller), sudo contexts bypass. Mutating routes require the bearer token
(createBaseline({ adminToken })) or the nod of an admin-authorizer contribution (system-only point — an
auth plugin admitting its own admin sessions). There is no HTML and no UI extension point in the baseline; a
consumer renders whatever it wants on top (docs/building-a-ui.md). Agent-authored UI, if a product wants it,
is a consumer-defined extension point marked sensitive.
Containment
| Executor | Boundary |
|---|---|
executor-node | worker_threads: steering, not a security boundary (dsh’s label for node:vm). Removes process, fetch, require; shares nothing but the RPC port; the judge’s source-lint refuses obvious escapes. Dev use. |
executor-quickjs | QuickJS compiled to WASM: its own heap (memoryLimitBytes), CPU deadline per slice (sliceMs, interrupt handler), no I/O, no import, no timers; the only host function is __host(method, json). Runs on Node and, with the single-file variant, inside a Cloudflare Worker where eval/new Function are unavailable. Production use. |
Neither executor can reach the judge or the kernel; that boundary is the trust tier, not the sandbox.
Cloudflare (one Worker, one Durable Object class)
| Piece | Node | Cloudflare |
|---|---|---|
| Executor | executor-node / executor-quickjs | executor-quickjs (single-file WASM variant) |
| Store | store-sqlite / store-postgres | store-do (DO SQLite: same tables) / store-d1 (one D1 per tenant) |
| Objects | objects-s3 | objects-r2 (bucket binding) / objects-s3 |
| HTTP entry | host-node / host-fastify | host-cloudflare: Worker fetch → TENANT.get(idFromName(tenant)) → DO → app.handle |
| Everything else | unchanged | unchanged |
Dynamic Workers (worker_loader) would give a stronger per-plugin boundary but was ruled out for v1 to keep to
one traditional Worker; QuickJS provides the per-plugin boundary inside the DO.
Packaging
Development runs TypeScript directly; releases are git tags pointing at detached release commits with dist/ committed
and workspace:* rewritten to github:…#tag&path:/packages/x, cut by .github/workflows/cd.yml when a vX.Y.Z tag is pushed
after pnpm check (scripts/release.ts, docs/releasing.md).
Package exports carry an sms-source condition for in-repo source resolution and default to dist/.
Extension points shipped
| Point | Owner | Item | Agent may contribute |
|---|---|---|---|
route | http | { method, path, handler(req) } | yes (human by default) |
schedule | scheduler | { name, everyMs, handler(run) } | yes (human by default) |
group | rbac | { name, implies? } | no (system) |
access | rbac | { model, group, read?, write?, create?, unlink? } | no (system) |
rule | rbac | { name, model, groups?, domain, ops? } | no (system) |
model | orm | { name, fields, displayField?, order? } | yes |
model-field | orm | { model, name, spec } | yes |
model-method | orm | { model, name, handler(env, ids, args), access? } | yes |
model-hook | orm | { model, on, when, handler(env, payload) } | yes |
model-onchange | orm | { model, field, handler(env, values) } | yes |
view / action / menu | meta | views with inheritance, actions, menu tree (opt-in) | yes |
automation | automation | { id, model, trigger, watch?, filter?, action } | yes (human by default) |
audit-track | audit | { model, fields[] } | yes |
admin-authorizer | admin-api | { name, authorize(req) } | no (system) |
collection | collections | { name, fields[] } | yes |
collection-field | collections | { collection, field } | yes |
collection-hook | collections | { collection, on, handler(record, previous) } | yes |
tool | agent-loop | { name, description, input_schema, handler(input) } | yes |
prompt | agent-loop | { section, text, order } | yes |
gate | judge | { name, run(ctx) } | no (system) |
| (auth) | auth | contributes route ×3 and admin-authorizer auth-session; keys identity / identity-admin | — |
invariant | judge | { name, check(kernel) } | no (system) |
Keys: database (system↔system), http, collections, model (system provides), judge (system↔system),
agent (system↔system).