Tenancy
The kernel is single-tenant on purpose. One kernel = one event log, one set of plugins, one database, one judge state. Multi-tenancy is a host concern: resolve the tenant from the request, give each tenant its own kernel on its own store. Nothing is shared between tenants except code, so an agent plugin that one tenant approves can never touch another tenant’s data or schema.
request ──resolve──▶ tenantId ──▶ kernel[tenantId] ──▶ http.handle(request)
│
└── store: sqlite file / postgres schema / durable object
On Node / Fastify: createTenantPool
import { createTenantPool, tenantFromHeader } from '@sms/baseline'
import { openPostgresStore } from '@sms/store-postgres'
const tenants = createTenantPool({
resolve: tenantFromHeader('x-tenant'), // or tenantFromSubdomain(), or (req) => jwt(req).org
tenant: async (id) => ({ // BaselineOptions for this tenant
store: await openPostgresStore({ connectionString: DATABASE_URL, schema: `tenant_${id}` }),
executor, model, plugins: [invoicesPlugin],
adminToken: tokens[id],
}),
idleMs: 30 * 60_000, // close kernels idle for 30 min; they re-boot on demand
})
await createFastifyHost({ app, prefix: '/sms' }).listen(tenants.handle)
- Tenant ids are validated (
^[a-z0-9][a-z0-9_-]{0,62}$) because they become file / schema names. - Booting is memoised per tenant; a failed boot is retried on the next request.
pool.get(id)returns the tenant’sBaseline(for jobs, migrations, tests);tenants()lists the booted ones;evict(id)closes one;close()closes all.- A request whose tenant does not resolve gets
onMissing(request)(bothcreateTenantPoolandcreateCloudflareApptake it) or a 400no tenantby default. - Postgres: one Pool per tenant with
search_pathpinned to the schema; the store creates the schema. SQLite: one file per tenant (openSqliteStore(data/${id}.db)).
Cost model: a warm kernel is a few objects plus the executor instances of its agent plugins (QuickJS: one
small WASM runtime each). Thousands of idle tenants are fine with idleMs; keep hot ones warm.
On Cloudflare: one Durable Object per tenant
const app = createCloudflareApp<Env>({
resolve: (req, env) => tenantFromSubdomain({ root: env.ROOT_DOMAIN, fallback: 'dev' })(req),
binding: 'TENANT',
tenants: (env) => (env.CRON_TENANTS ?? '').split(',').filter(Boolean), // who the cron trigger ticks
tenant: ({ tenantId, env, store }) =>
createBaseline({ name: tenantId, store, executor: new QuickJSExecutor({ variant: import('@jitl/quickjs-singlefile-mjs-release-sync') }), model, plugins }),
})
export const TenantKernel = app.DurableObject
export default { fetch: app.fetch, scheduled: app.scheduled }
tenantFromSubdomain({ root, fallback, reserved }) maps acme.example.com → acme, the bare root /
localhost / IPs → fallback, acme.localhost → acme (local dev without DNS) and refuses reserved
labels (www, api, …). resolve receives env, so root domains can come from wrangler vars.
Scheduled jobs (@sms/plugin-scheduler): a Durable Object cannot enumerate itself, so the Worker’s
scheduled() ticks the tenants tenants(env) returns (a wrangler var, a KV list, a registry DO you keep).
The tick travels as an internal request the public fetch can never forge. On Node,
setInterval(() => pool.tick(), 60_000) does the same for every booted tenant.
The Worker’s fetch resolves the tenant and forwards to TENANT.get(idFromName(tenantId)); the DO opens
@sms/store-do on its own SQLite (or whatever the store option returns, e.g. openD1Store(env.DB)), boots the kernel once and keeps it in memory until the platform evicts
it (the log + blobs survive in DO storage; restore() re-mounts plugins on the next boot). Single-threaded
per DO, transactional storage, global addressing — the Durable Object is the tenant pool.
Sharing across tenants
Share code (system plugins, drivers, the model) — pass the same objects to every tenant. Do not share a
Store, an Executor instance’s plugin instances, or admin tokens. If you need cross-tenant features
(a marketplace of approved agent plugins, say), implement them as a system plugin that talks to other
kernels through the pool — never by pointing two kernels at one log.