Repository navigation
Optional modules load through one host and are turned on under capabilities #595
Description
Part of #591.
Scope
Adds the optional-module host and contract from ADR 0030 on workers, and
the capabilities config namespace, proven with fake modules that tests
register. After this PR, a module that is defined and registered:
- is turned on under
capabilities.<name>, and loaded only then; - reports its capability and serves its relay target;
- serves HTTP routes under
/v1/<name>; - has its doctor findings reported;
- has its hooks called at their steps.
agent-device does not move in this PR. Its hand wiring in
src/daemon/main.ts stays and runs beside the host until #597. The
registry ships empty.
Test fixtures that build a whole Config gain capabilities: {}. That
edit is mechanical and is not counted in this task's size.
Kept whole at the maintainer's choice (2026-10-15). Its Tests list is
above the ~30-line guide. The seam offered was a separate task for the
capabilities config namespace, ahead of the host.
In short
A maintainer can define an optional module in one directory and turn it on
with capabilities.<name>.enabled. Nothing a user runs changes yet, except
that simlock config get shows an empty capabilities object.
flowchart LR
C["loadConfig(modules)"] --> H["module host (new)"]
H -->|on| L["loadWorker() → createWorker(ctx)"]
H -->|off| O["capability: off"]
L --> D["DaemonServer: capabilities, relay targets,<br/>routes, lease-end, doctor, stop steps"]
Words:
- module host: the code in
src/modulesthat loads each turned-on
module and plugs its handle into the daemon. - hook: a call the host makes at a named step, e.g.
beforeDrainon
daemon stop. - step: one place in the daemon's start or stop where the host runs
one hook for every module.
If this goes wrong: simlock daemon stop could hang, a module's routes or
capability could be missing, or a worker could drop off its gateway
because its status no longer parses.
Technical spec
Facts
| # | Fact | Source |
|---|---|---|
| F1 | loadConfig takes filesystem, systemStats, configPath, overrides and warn, and nothing about modules. |
src/core/config.ts:364-370 |
| F2 | A key with no validator is warned as Unknown config key: "<path>" and dropped, at the top level and inside an object. |
src/core/config.ts:806-808; src/core/validation.ts:29-33 |
| F3 | A gateway warns about a key outside GATEWAY_CONFIG_KEYS with Ignoring "<key>": it configures a worker, and this daemon runs in gateway mode. |
src/core/config.ts:477-487, :505-511 |
| F4 | The CLI calls loadConfig twice, both in buildCliEnvironment. |
src/cli/index.ts:285, :338 |
| F5 | The daemon calls loadConfig once in startDaemon, and branches to the gateway after it. |
src/daemon/main.ts:175, :218 |
| F6 | config.get returns the daemon's config as it is; the client parses the reply with configSchema, and so does the worker view a gateway builds; z.object drops keys it does not name. |
src/daemon/dispatcher.ts:788, :817; src/simlock-client/client.ts:868; src/contract/schemas.ts:835 |
| F7 | A capability name must match ^[a-z][a-z0-9-]{0,31}$. A capability list holds at most 16 entries, with no name twice. |
src/contract/schemas.ts:1054, :1073-1076 |
| F8 | CapabilitySource is { name, read(): Promise<{ state, reason? }> }. |
src/daemon/dispatcher.ts:111-114 |
| F9 | RelayTarget is (request, tenant, leaseId | undefined) => Promise<Response>, and RelayTargets is a record by name. |
src/daemon/relay.ts:18-24 |
| F10 | LeaseEndParticipant.leaseEnded(ended: EndedLease, signal). Leasing bounds the wait with lease.endWaitMs. |
src/leasing/lease-ports.ts:28-39; src/leasing/create-leasing.ts:165-172 |
| F11 | Any lease-end participant makes leasing defer every background reclaim. | src/leasing/lease-release-coordinator.ts:276, :354-356 |
| F12 | HttpMount is { prefix, handle(request, identity) }. The app registers core routes first and mounts at /v1/<prefix> and /v1/<prefix>/* last, behind agent auth. |
src/http/app.ts:83-89, :227, :722-726 |
| F13 | TokenIdentity is { requesterId, role }, with role "agent" | "operator" | "worker". |
src/http/token-store.ts:15, :29-32 |
| F14 | DaemonServer calls onSocketClaimed only once the socket is claimed. On stop, it awaits stopAuxiliary, then settle, then dispose, with no deadline around any of them. |
src/daemon/server.ts:488, :638, :651-652 |
| F15 | In startDaemon: doctor optionalModules at main.ts:329, createLeasing with leaseEndParticipants at :363-372, capabilitySources at :484, relayTargets at :522-527, dispose at :543, stopAuxiliary at :571-583, onSocketClaimed at :592, HTTP mounts at :631. |
src/daemon/main.ts at those lines |
| F16 | dispose stops agent-device, then disposes the drivers. |
src/daemon/main.ts:548-560 |
| F17 | agent-device's supervisor gets process.env and the drivers' merged virtualLeaseEnvironment(). |
src/daemon/main.ts:334-341; src/core/driver.ts:352 |
| F18 | The bus's subscribe(event, handler) returns an unsubscribe function. |
src/bus/index.ts:493 |
| F19 | The daemon's dispatch type is HttpDispatch. |
src/http/dispatcher-session.ts:14 |
| F20 | dependency-cruiser treats each directory under src/ with an index.ts as a component, entered only through index.ts or testing.ts, and records dynamic-import and type-only as dependency types. |
.dependency-cruiser.mjs:1-33, :161; dependency-cruiser 18.4.0 |
| F21 | Capacity strategies are the registry precedent: a definition with validator and defaults, a registry, and a describe.each suite. |
src/core/capacity/strategy.ts:104-109; src/core/capacity/strategies/index.ts:12-15; src/core/capacity/contract.test.ts:22 |
| F22 | StartDaemonOptions has relayTargets and the agent-device test options. |
src/daemon/main.ts:129-145 |
| F23 | The HTTP app's core routes start with catalog, components, devices, events, healthz, lease-requests, leases, status, tokens and workers; the uplink is served at /v1/uplink outside the app. |
src/http/app.ts route registrations; src/http/server.ts:43, :95 |
| F24 | The bus's types (EventName, EventHandler) are declared in src/bus and exported by neither the core nor the contract. |
src/bus/index.ts:392-416, :493 |
| F25 | validateConfigLayer and objectValidator warn Unknown config key for any key their shape does not name. |
src/core/validation.ts:21-38 |
| F26 | afterSocketClaimed's caller, onSocketClaimed, runs before convergence and before the HTTP gateway starts; a convergence or HTTP failure after it stops the daemon through the normal stop. |
src/daemon/main.ts:592-616, :680-688; src/daemon/server.ts:484-506 |
Modules touched
flowchart LR
main["daemon/main.ts *"] --> host["modules (types, host) *new*"]
main --> reg["module-registry *new*"]
cli["cli/index.ts *"] --> reg
cli --> cfg["core/config.ts, core/index.ts *"]
main --> cfg
http["http: one new test file *"]
contract["contract/schemas.ts, protocol.ts *"]
dc[".dependency-cruiser.mjs *"]
Surface and extension points
src/modules/index.ts exports:
OptionalModuleDefinition<C>:{ name, config: ModuleConfig<C>, loadWorker(), loadGateway?() }
(ADR 0030 §1).loadGatewayis declared here; agent-device's gateway side runs as an optional module #598 uses it.ModuleConfig<C>:{ validator, defaults: C, gatewayKeys: readonly string[], check?(section: C, config: Config): void },
in the capacity strategy shape (F21).WorkerModule:{ capability, relayTarget?, mount?, findings?, hooks? }.mountis(deps: MountDeps) => ModuleHandler, and
ModuleHandleris(request, identity) => Promise<Response>.findingsreturnsreadonly ModuleFinding[], andModuleFindingis
{ message, remedy }. The host addskind: "module-unavailable"and
module: name, the shape Doctor reports every optional module with one module-unavailable finding #596 sets.
WorkerHooks: function-valued properties (ADR 0030 §3):
readonly afterSocketClaimed?: () => void | Promise<void>,
readonly beforeDeviceReclaim?: (lease, signal) => Promise<void>,
readonly beforeDrain?: () => Promise<void>,
readonly beforeDriversDispose?: () => Promise<void>.WorkerContext<C>:name,config: C,daemonConfig: Config;logger, the daemon logger's child named after the module;clock,filesystem,processRunner,processSupervisor,
tcpProbe,dataDirectory;environment, which isprocess.env;nodePath,nodeVersion;platforms, the drivers' platforms;leaseEnvironment, the drivers' mergedvirtualLeaseEnvironment()
(F17);cancelPending(requesterId), which calls leasing'scancelPending
once leasing exists. Called before that (fromcreateWorker), it
throwsleasing is not ready yet, and the start fails naming the
module;events: { subscribe }, the bus'ssubscribe(F18) and nothing
else, typed withsrc/bus's types (F24).
MountDeps:{ dispatch }, the daemon's dispatch (F19), given when
the HTTP app is built.createModuleHost.
Types from other components are declared by shape: the capability source
(F8), the relay target (F9), the lease-end participant and EndedLease
(F10), the dispatch (F19) and the mount (F12). A type test checks that
each copy and its original are assignable both ways. The bus's types are
imported type-only from src/bus (F24).
src/module-registry/index.ts exports optionalModules, an empty list
in this task. A module's entry is its definition.ts.
createModuleHost({ role: "worker", definitions, config, context, wiredElsewhere })
is async. main.ts calls it after loadConfig and before createLeasing.
It returns a handle, and main.ts imports only it and the types:
{
capabilitySources, relayTargets, leaseEndParticipants,
mounts(deps): HttpMount-shaped[], findings(),
afterSocketClaimed(), beforeDrain(), beforeDriversDispose(),
}What the host does at start:
-
It checks every definition's name:
- against F7's pattern;
- for duplicates, against the other names and against
wiredElsewhere, the capability namesmain.tsstill wires by hand
(["agent-device"]until agent-device runs as an optional module on workers #597); - against
CORE_ROUTE_SEGMENTS(below), whether or not HTTP is on.
It also checks that the capability list (every module plus
wiredElsewhere) fits F7's limit of 16. Any of these failing stops the
start with an error naming the module. -
For a definition whose
capabilities.<name>.enabledisfalse, or whose
section is missing, it reports a capability source answering
{ state: "off" }, and calls nothing of the module. -
For each one turned on, it awaits
loadWorker()and calls
createWorker(ctx), in registry order.- The handle's
capabilitymust carry the definition's name. A
different name stops the start with an error naming both. - A
loadWorker()orcreateWorkerthat throws stops the start with an
error naming the module. - Creating starts nothing. A start that fails before
onSocketClaimed
(a later module, a later step before the socket, a lost socket race)
drops the modules already created. They are created and not started,
with nothing to stop. A failure after it (convergence, the HTTP
gateway) stops the daemon through the normal stop, which runs every
module's stop hooks (F26).
- The handle's
What the host plugs in:
relayTargetis registered under the module's name.mountis served at/v1/<name>and/v1/<name>/*, as the HTTP app's
existing mount shape (F12).src/httpgains only a new test file; its
code does not change.src/contractexportsCORE_ROUTE_SEGMENTS, F23's ten names plus
uplink. A test insrc/httpchecks that every route the app
registers starts with one of them. That test is the new file.leaseEndParticipantsholds one participant per turned-on module that
hasbeforeDeviceReclaim, and is empty when none has. An empty list
leaves leasing's release path unchanged (F11).main.tspasses
agent-device's participant first, then the host's.
Hooks run through one table:
| Hook | Called from | Awaited | Deadline |
|---|---|---|---|
afterSocketClaimed |
onSocketClaimed (F14) |
no | — |
beforeDeviceReclaim |
the lease-end participant (F10) | by leasing | lease.endWaitMs (F10) |
beforeDrain |
stopAuxiliary, after agent-device's steps and before the HTTP gateway stops (F15) |
yes | Hook deadline |
beforeDriversDispose |
dispose, after agentDevice.stop() and before the drivers (F16) |
yes | Hook deadline |
- For one step, every module's hook starts at once, and the step waits at
most the deadline in total. - A module whose hook is still running at the deadline is logged at warn,
asOptional module hook ran out of timewith{ module, hook }. The
step then ends. - A hook that throws or rejects is logged at error, as
Optional module hook failedwith{ module, hook, message }. The step
goes on. - This holds for
afterSocketClaimedtoo: a throw or a rejection never
reachesDaemonServer.start.
findings() returns each turned-on module's findings, stamped with
kind: "module-unavailable" and module: name. A module whose
findings() throws is logged at warn, as
Could not read optional module findings with { module, message }; only
its findings are dropped. main.ts:329 passes the
doctor agent-device's findings, followed by host.findings().
Config (src/core/config.ts):
loadConfigtakesmodules?: readonly ModuleConfigEntry[]. The core
declares
ModuleConfigEntry = { name, validator, defaults, gatewayKeys, check? },
exports it fromsrc/core/index.ts, and imports nothing from
src/modules.Configgains
readonly capabilities: Readonly<Record<string, { readonly enabled: boolean } & Readonly<Record<string, unknown>>>>.- The core owns
enabled. It takesenabledout of a
capabilities.<name>section, validates it withbooleanValue
(falseby default), and passes the rest to the module's validator
over the module's defaults. A module's validator and defaults never
nameenabled, so itsobjectValidatornever warns about it (F25). - A name no entry has is warned as
Unknown config key: "capabilities.<name>"and dropped (F2). checkruns after the merge, beside the other cross-key checks.GATEWAY_CONFIG_KEYSgains"capabilities". On a gateway,enabledis
always read. A key outsidegatewayKeysis warned with F3's text,
namingcapabilities.<name>.<key>.- The CLI (F4) and
startDaemon(F5) pass the configs of the registry, or
ofoptions.optionalModuleswhen given.
StartDaemonOptions gains
optionalModules?: readonly OptionalModuleDefinition<unknown>[], which
replaces the registry (F22).
dependency-cruiser:
past-indexalso allowsdefinition.(ts|js)as a component's entry.- New rule
definition-is-light: adefinition.tsimports its own module
onlytype-onlyor bydynamic-import, so reading the registry loads no
module code. - New rule
modules-imports-little:src/modulesimports only
src/ports,src/contractandsrc/core, plussrc/bustype-only
(F24). - New rule
not-modules:src/core,src/leasing,src/drivers,
src/gatewayandsrc/contractimport nothing fromsrc/modulesor
src/module-registry.
Contract and event changes
configSchema gains
capabilities: z.record(capabilityNameSchema, z.object({ enabled: z.boolean() }).catchall(z.unknown()))
(ADR 0030 §5). This PR bumps the protocol by one. No event changes.
Other code on the same state
- agent-device's hand wiring in
main.ts(F15) runs beside the host
until agent-device runs as an optional module on workers #597:- The capability list is agent-device's entry first, then the host's,
in registry order. - Relay targets and mounts are merged.
- On stop, agent-device's
stopAuxiliarysteps run before the host's
beforeDrain. ItsagentDevice.stop()runs before the host's
beforeDriversDispose.
- The capability list is agent-device's entry first, then the host's,
options.relayTargets(F22) still spreads last, so a test's target
wins, as today.- Leasing's lease-end path (F11) changes only when a turned-on module
hasbeforeDeviceReclaim. agent-device's own participant comes first. GET /v1/statsand thesimlock stats --csvledger #348 (another feature,task:draft) also bumps the protocol. The one
that merges second bumps again.
Rules in play
architecture.md:- rule 5: hooks are direct calls, never bus handlers;
- rule 10: each by-shape copy needs its type test;
- rules 11 and 12: one budget per step; a start that fails before the
socket is claimed leaves created modules not started, and one that
fails after it runs their stop hooks through the normal stop; - rules 18–21: the host is a handle, and it names no module.
safety.md: config is a claim; validate it with the module's validator.testing.md: each hook row is a claim its test must be able to fail.documentation.md:CONFIGURATION.md,HTTP-API.mdandCLIENT.md
describecapabilities.
Tests
Seam: startDaemon({ optionalModules: [fakes], drivers: [FakeDriver], configOverrides })
with status.get, HTTP requests, doctor.run, a lease release and
daemon.stop(), modelled on src/daemon/worker-capabilities.e2e.test.ts.
The conformance suite is modelled on src/core/capacity/contract.test.ts.
Config goes through loadConfig, modelled on src/core/config.test.ts.
- a module with no
capabilitiessection, or withenabled: false, is reportedoffinstatus.get, and itsloadWorkeris never called - a module turned on is loaded once and reports the state its capability source answers
- the capability list is agent-device's entry first, then registered modules in registry order
- a module turned on serves
/v1/<name>and/v1/<name>/*through its handler, behind agent auth - a module named like a core route's first segment (
status) fails the start, with HTTP off too - with agent-device on and a fake module registered, both modules' routes and relay targets answer, and both capabilities are listed
- a relay request to a module's name reaches its relay target with the tenant and lease id; a test's
relayTargetsoption still wins (as today) - a fake module's finding reaches
doctor.runas{ kind: "module-unavailable", module: <name>, message, remedy }, after agent-device's; a second module whosefindings()throws loses only its own - the start fails, naming the module, for each of:
- a capability name that differs from the definition's;
- two definitions with one name, or a definition named
agent-devicewhile it is wired by hand; - a name that fails the capability name check;
- 16 fake modules plus agent-device wired by hand (17 capabilities);
- a
loadWorker()that throws
- when the second of two modules throws in
createWorker, the first module'safterSocketClaimednever runs - when the HTTP gateway fails to bind after the socket is claimed, the daemon stops and each started module's
beforeDrainandbeforeDriversDisposerun - a module that calls
ctx.cancelPendingincreateWorkerfails the start, naming the module - the context a fake receives has a logger named after the module, and
events.subscribedelivers a bus event afterSocketClaimedruns once after the socket is claimed, the start does not wait for it, and it never runs in a daemon that lost the socket race- an
afterSocketClaimedthat throws or rejects is logged as failed, and the daemon still starts - with no module that has
beforeDeviceReclaim, leasing gets no participant and a release reclaims as today beforeDeviceReclaimruns after a release commits and before the device's reclaim startsbeforeDrainruns after agent-device's stop steps, and finishes before the HTTP gateway stops and before leasing'ssettlestartsbeforeDriversDisposeruns after agent-device's stop, and finishes before any driver'sdisposestarts- with two modules whose
beforeDrainhangs and settles at once, both start together; on a fake clock the step ends at 30 s with only the hanging one logged as ran out of time, and the stop goes on - a
beforeDrainthat throws is logged as failed, and the HTTP gateway still stops - a
beforeDriversDisposethat throws, and one that runs past 30 s on a fake clock, are each logged, and the drivers are still disposed - the conformance suite runs over the registry and the test fakes:
- off means not loaded, and
off; createWorkerstarts nothing (no process, no timer);- each declared hook settles within its row's deadline;
- the name passes the check, and no two names repeat
- off means not loaded, and
loadConfigwith a module entry fills its section from defaults, withenabled: false, when the file has noneloadConfigwarnsUnknown config key: "capabilities.nope"for a name no module has, and drops itloadConfigvalidates a module's section with its validator, and warns about an unknown key inside it but not aboutenabledloadConfigrefuses a non-booleancapabilities.<name>.enabledloadConfigruns a module'scheckagainst the merged config, and fails the load when it throws- on a gateway,
loadConfigreadsenabledwithout a warning, and warns about a key outsidegatewayKeys, namingcapabilities.<name>.<key> config.getreturnscapabilitieswith each module's section, and the client'sgetConfigkeeps it- each by-shape type in
src/modulesand its original are assignable both ways - every route
createHttpAppregisters starts with a name inCORE_ROUTE_SEGMENTS - dependency-cruiser fails a
definition.tsthat imports a value from its own module, and passes atype-onlyand adynamic-importone - the protocol version is one above the base branch's
Done when
- Every Tests line passes in the fast lane.
pnpm lintpasses with the dependency-cruiser changes above.simlock config geton a fresh worker prints"capabilities": {}.main.tsimports nothing fromsrc/modulesbutcreateModuleHostand
types, and nothing fromsrc/module-registrybutoptionalModules.
Out of scope
- Moving agent-device onto the host (agent-device runs as an optional module on workers #597).
- The gateway side:
GatewayContext,createGateway(agent-device's gateway side runs as an optional module #598).
Depends on
Written by an agent.
Activity
V3RON commentedon Oct 15, 2026
Spec updated: findings from the first spec check folded in (order is now #596 → #595 → #597 → #598; split loaders, own registry component, routes under the module's name, concurrent stop steps per ADR 0030's amendment).
Written by an agent.
V3RON commentedon Oct 15, 2026
Spec updated: closure-check findings folded in; set task:ready.
Written by an agent.
feat: optional modules load through one host and are turned on under …
test: config.get fixtures carry capabilities (#595)
docs: capabilities, optional module routes, modules and module-regist…
docs: daemon manual names the module host (#595)
fix: index exports only what other components import (#595)
docs: manuals beside CLAUDE.md, no file names that do not exist (#595)
test: the context a module receives carries its section without enabl…
fix: index exports the module config types, stop-order and reclaim te…
fix: the module config types the index exports are marked as the cont…
docs: the index's unused-type notes say no module is registered yet (#…
feat(modules): optional modules load through one host and are turned …