Skip to content

Optional modules load through one host and are turned on under capabilities #595

Closed

Description

@V3RON

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"]
Loading

Words:

  • module host: the code in src/modules that loads each turned-on
    module and plugs its handle into the daemon.
  • hook: a call the host makes at a named step, e.g. beforeDrain on
    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 *"]
Loading

Surface and extension points

src/modules/index.ts exports:

  • OptionalModuleDefinition<C>: { name, config: ModuleConfig<C>, loadWorker(), loadGateway?() }
    (ADR 0030 §1). loadGateway is 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? }.
  • 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 is process.env;
    • nodePath, nodeVersion;
    • platforms, the drivers' platforms;
    • leaseEnvironment, the drivers' merged virtualLeaseEnvironment()
      (F17);
    • cancelPending(requesterId), which calls leasing's cancelPending
      once leasing exists. Called before that (from createWorker), it
      throws leasing is not ready yet, and the start fails naming the
      module;
    • events: { subscribe }, the bus's subscribe (F18) and nothing
      else, typed with src/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 names main.ts still 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>.enabled is false, 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 capability must carry the definition's name. A
      different name stops the start with an error naming both.
    • A loadWorker() or createWorker that 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).

What the host plugs in:

  • relayTarget is registered under the module's name.
  • mount is served at /v1/<name> and /v1/<name>/*, as the HTTP app's
    existing mount shape (F12). src/http gains only a new test file; its
    code does not change.
  • src/contract exports CORE_ROUTE_SEGMENTS, F23's ten names plus
    uplink. A test in src/http checks that every route the app
    registers starts with one of them. That test is the new file.
  • leaseEndParticipants holds one participant per turned-on module that
    has beforeDeviceReclaim, and is empty when none has. An empty list
    leaves leasing's release path unchanged (F11). main.ts passes
    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,
    as Optional module hook ran out of time with { module, hook }. The
    step then ends.
  • A hook that throws or rejects is logged at error, as
    Optional module hook failed with { module, hook, message }. The step
    goes on.
  • This holds for afterSocketClaimed too: a throw or a rejection never
    reaches DaemonServer.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):

  • loadConfig takes modules?: readonly ModuleConfigEntry[]. The core
    declares
    ModuleConfigEntry = { name, validator, defaults, gatewayKeys, check? },
    exports it from src/core/index.ts, and imports nothing from
    src/modules.
  • Config gains
    readonly capabilities: Readonly<Record<string, { readonly enabled: boolean } & Readonly<Record<string, unknown>>>>.
  • The core owns enabled. It takes enabled out of a
    capabilities.<name> section, validates it with booleanValue
    (false by default), and passes the rest to the module's validator
    over the module's defaults. A module's validator and defaults never
    name enabled, so its objectValidator never warns about it (F25).
  • A name no entry has is warned as
    Unknown config key: "capabilities.<name>" and dropped (F2).
  • check runs after the merge, beside the other cross-key checks.
  • GATEWAY_CONFIG_KEYS gains "capabilities". On a gateway, enabled is
    always read. A key outside gatewayKeys is warned with F3's text,
    naming capabilities.<name>.<key>.
  • The CLI (F4) and startDaemon (F5) pass the configs of the registry, or
    of options.optionalModules when given.

StartDaemonOptions gains
optionalModules?: readonly OptionalModuleDefinition<unknown>[], which
replaces the registry (F22).

dependency-cruiser:

  • past-index also allows definition.(ts|js) as a component's entry.
  • New rule definition-is-light: a definition.ts imports its own module
    only type-only or by dynamic-import, so reading the registry loads no
    module code.
  • New rule modules-imports-little: src/modules imports only
    src/ports, src/contract and src/core, plus src/bus type-only
    (F24).
  • New rule not-modules: src/core, src/leasing, src/drivers,
    src/gateway and src/contract import nothing from src/modules or
    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 stopAuxiliary steps run before the host's
      beforeDrain. Its agentDevice.stop() runs before the host's
      beforeDriversDispose.
  • 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
    has beforeDeviceReclaim. agent-device's own participant comes first.
  • GET /v1/stats and the simlock stats --csv ledger #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.md and CLIENT.md
    describe capabilities.

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 capabilities section, or with enabled: false, is reported off in status.get, and its loadWorker is 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 relayTargets option still wins (as today)
  • a fake module's finding reaches doctor.run as { kind: "module-unavailable", module: <name>, message, remedy }, after agent-device's; a second module whose findings() 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-device while 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's afterSocketClaimed never runs
  • when the HTTP gateway fails to bind after the socket is claimed, the daemon stops and each started module's beforeDrain and beforeDriversDispose run
  • a module that calls ctx.cancelPending in createWorker fails the start, naming the module
  • the context a fake receives has a logger named after the module, and events.subscribe delivers a bus event
  • afterSocketClaimed runs 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 afterSocketClaimed that 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
  • beforeDeviceReclaim runs after a release commits and before the device's reclaim starts
  • beforeDrain runs after agent-device's stop steps, and finishes before the HTTP gateway stops and before leasing's settle starts
  • beforeDriversDispose runs after agent-device's stop, and finishes before any driver's dispose starts
  • with two modules whose beforeDrain hangs 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 beforeDrain that throws is logged as failed, and the HTTP gateway still stops
  • a beforeDriversDispose that 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;
    • createWorker starts nothing (no process, no timer);
    • each declared hook settles within its row's deadline;
    • the name passes the check, and no two names repeat
  • loadConfig with a module entry fills its section from defaults, with enabled: false, when the file has none
  • loadConfig warns Unknown config key: "capabilities.nope" for a name no module has, and drops it
  • loadConfig validates a module's section with its validator, and warns about an unknown key inside it but not about enabled
  • loadConfig refuses a non-boolean capabilities.<name>.enabled
  • loadConfig runs a module's check against the merged config, and fails the load when it throws
  • on a gateway, loadConfig reads enabled without a warning, and warns about a key outside gatewayKeys, naming capabilities.<name>.<key>
  • config.get returns capabilities with each module's section, and the client's getConfig keeps it
  • each by-shape type in src/modules and its original are assignable both ways
  • every route createHttpApp registers starts with a name in CORE_ROUTE_SEGMENTS
  • dependency-cruiser fails a definition.ts that imports a value from its own module, and passes a type-only and a dynamic-import one
  • the protocol version is one above the base branch's

Done when

  • Every Tests line passes in the fast lane.
  • pnpm lint passes with the dependency-cruiser changes above.
  • simlock config get on a fresh worker prints "capabilities": {}.
  • main.ts imports nothing from src/modules but createModuleHost and
    types, and nothing from src/module-registry but optionalModules.

Out of scope

Depends on

Written by an agent.

Activity

  1. added
    task:draftScope written; technical spec or its spec check missing.
    on Oct 15, 2026
  2. V3RON commented on Oct 15, 2026

    @V3RON
    ContributorAuthor

    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.

  3. added
    task:readySpec checked. An agent may implement it once its Depends on allow.
    and removed
    task:draftScope written; technical spec or its spec check missing.
    on Oct 15, 2026
  4. V3RON commented on Oct 15, 2026

    @V3RON
    ContributorAuthor

    Spec updated: closure-check findings folded in; set task:ready.

    Written by an agent.

  5. self-assigned this
    on Oct 15, 2026
  6. added a commit that references this issue on Oct 15, 2026
    7dec392
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

task:readySpec checked. An agent may implement it once its Depends on allow.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions