Process modules
A process module is a third-party extension that runs out of
process, isolated from the host by a purpose-built JSON-RPC-over-stdio
protocol (core/moduleproto), not MCP. The host supervises one child per
module per replica; a crash, hostile publisher, or bad upgrade in the child
can never take the host down or read its memory.
The value is operational independence: a process module can be
installed, upgraded, crashed, and revoked without touching the host
binary. The host owns every schema and every registration; the child
holds zero database credentials and zero signing keys. This doc is the
operator-facing contract. The full design rationale (transport, capability
model, sandbox, migration, lifecycle, UI) lives in the #37 design note.
What a process module is
A process module is defined by a content-addressed, operator-approved
descriptor: a Go value (ProcessModuleDescriptor) whose fields are
authoritative at runtime. The running child only ever cross-checks
digests at handshake; it never supplies values. A mismatch on digest,
identity, or an extra grant is terminal (the module is quarantined to
Failed, never silently restarted). A generation bump is the way back:
BumpGeneration, a grant revoke or an artifact change re-arms a
Failed module, and the new child goes through the same digest checks.
app.RegisterProcessModule(framework.ProcessModuleDescriptor{ Name: "billing", Version: "1.2.0", ArtifactPath: "/opt/gofastr-modules/billing-1.2.0", ArtifactSHA256: "ab12…", // SHA-256 of the executable, verified before exec SurfaceSHA256: "cd34…", // digest of the canonical surface (routes+tools+grants) TrustTier: framework.TrustUntrusted, Routes: []framework.RouteDeclaration{ {ID: "invoice", Method: "GET", Path: "/billing/invoice/{id}"}, }, Tools: []framework.ToolDigest{ {ID: "total", SHA256: "ef56…"}, // optional MCP tool surface }, RequestedGrants: []access.Permission{"invoices:read"}, MigrationGroup: "billing", }, framework.ApprovedGrants{"invoices:read"})
The fields:
- Routes: the HTTP routes, host-registered behind the existing module route gate. The child cannot add, rename, or reshape routes.
- Tools: the optional MCP tools the module declares (see below).
Digests are byte-compared against
module.tool.listat handshake. - RequestedGrants: the verbatim
resource:verblist the operator reviews at install. Effective grants = requested ∩ approved. - TrustTier: selects the runner:
TrustTrusted(crash isolation only, for dev or in-house modules) orTrustUntrusted(requires a probe-passing sandbox, fail-closed otherwise). - MigrationGroup: the
#33migration group the module owns.
Install + operator approval
Installation is out of band: the operator places the approved artifact
on disk and hands the descriptor + the approved-grant subset to
RegisterProcessModule. There is no registry, marketplace, or dependency
solver. The v1 trust anchor is a content-addressed digest an operator
explicitly approves after reviewing the verbatim resource:verb list,
not a signature/PKI (no signing authority, key distribution, or revocation
is defined yet).
Artifact SHA-256 pins accept uppercase or lowercase hexadecimal. Case-only differences identify the same artifact during spawning, installation, supervisor reconciliation, and migration approval; they do not trigger an upgrade or clear an existing migration approval.
Process modules default to disabled at install, and a never-enabled
module's routes answer 404. The operator enables one via
app.ProcessModules().Enable(ctx, "billing"); app.Modules().Enable, the
app_module_enable / app_module_disable MCP tools and the supervisor all
drive the one stored state, and a toggle takes effect before the call
returns. Enabling after App.Start spawns the child. Disable, grant
revoke, and upgrade (artifact change) are all live and propagate across
replicas through the SQL-backed ProcessModuleStore.
RevokeGrants(ctx, name, grants) takes the named grants out of the
module's effective set, keeps the rest and bumps the generation; it
refuses an empty grants or a revoke pattern covering no held grant.
Patterns use the same resource:verb wildcard matcher as runtime access:
revoking orders:* removes held orders:read and orders:write grants.
A partial revoke under a retained wildcard is refused without changing
grants, revocation history or generation. Revoke the wildcard in full,
or use SetEffectiveGrants to replace it with explicit narrower grants.
The store writes it as a compare-and-swap on
the generation it read (ProcessModuleStore.RemoveEffectiveGrants), so
two revokes at once both stick. The store also records the revoke in
DesiredState.RevokedGrants (the revoked_grants column), so it
outlives the next boot. SetEffectiveGrants keeps that set in step: a
grant the replacement drops is recorded as revoked, and one it gives back
is no longer. A request body over 1 MiB, or one whose
base64-encoded module.http frame would not fit the module's
Limits.FrameBytes (about three quarters of it), answers 413 rather than
reaching the child truncated or failing as a retryable 503.
Every boot calls RegisterProcessModule again, and so does every
replica sharing the store. It compares the descriptor's grants, less the
row's recorded revokes, with the stored row
(ProcessModuleStore.InstallOrUpdate, whose InstallOutcome it reports
in RegisterResult.Outcome, and whose resulting grants it reports in
RegisterResult.EffectiveGrants):
- Same artifact and grants (order and repeats ignored): nothing is written, and the module keeps its enabled state and generation.
- New artifact, same grants: the digest is updated and the
generation bumped, and the module stays enabled. A replica restarts
its child only once its own descriptor names the new digest: during a
rolling deploy, a replica still on the old build drains its child and
holds the module in
InstalledDisabled(its routes answer 503) until its deploy registers the new artifact. - A grant the row does not hold (
InstallGrantsChanged): the grants and digest are updated, the generation bumped, and the module disabled until an operator enables it again, with a Warn naming the previous and new grants (RegisterResult.PreviousGrants). - Grants dropped, none added (
InstallGrantsNarrowed): the grants and digest are updated and the generation bumped. The module keeps its enabled state, since nothing new runs. - A runtime revoke stays in force: a descriptor that still requests
a revoked grant does not get it back, and that alone is no grant
change. Give a revoked grant back with
SetEffectiveGrants.
The routes reflect the stored enabled state from the first request after
boot. Registering one name twice in the same process is still refused
with ErrModuleInstalled and leaves the row alone.
The trust boundary
- Zero database credentials in the child. The host brokers all data
and runs all DDL. The child's reverse
host.*calls (entity query/ create/update/delete, search, event emit) are re-dispatched through the same CRUD chokepoint as live HTTP, so owner/tenant/permission + token-scope re-run on the re-attached caller identity. - Capability model = module-grant ∩ caller-authority. The required permission is derived from the trusted method + canonical host resource, never from a child-supplied string (the confused-deputy control).
CrossOwnerReadis non-grantable to a module. A descriptor requesting it (or a wildcard broad enough to subsume it) is rejected at install; belt-and-suspenders, the broker strips it on both the module-grant and delegated-caller paths. A module never brokers data in a cross-owner/cross-tenant frame.- Delegation is an in-memory, replica-local handle, not a signed token. The host mints it for an inbound call, the child echoes it on reverse calls, and the host re-attaches that request's caller context. An exfiltrated handle is meaningless on another replica.
- Reverse calls are bounded both ways. The host serves at most
Limits.Inflightof a child's reverse calls at once and answers the overflow with an inflight-cap error. It also holds at most that many answers waiting behind a blocked write: a child that keeps sending calls but stops reading stdin faults its peer withmoduleproto.ErrStalled, after which the peer serves nothing more. An answer stops counting when its write starts, so a child at its cap that reads every answer never trips it. - A protocol fault restarts the module. When the host peer records a
terminal fault (a malformed, over-cap or unsolicited frame, or a
stalled transport), the supervisor kills the child and counts the exit
as a crash: the module goes through
Crashed→Backoff→Startinglike any other crash, andCircuitThresholdfaults insideCircuitWindowopen its restart circuit. The fault cancels the context of every reverse call the host peer is serving, asCloseand the end of the transport do. A module must read stdin for as long as it sends requests.
The two-layer 404 / 503 gate
A disabled module is indistinguishable from uninstalled: its routes 404
and its MCP tools are omitted + refused. An enabled-but-down module
(crashed, starting, draining, lease-failing) is a retryable temporary
outage: routes return 503 + Retry-After, tools are listed but return a
retryable-unavailable error. This split is load-bearing: the router gate
can only 404, so the Ready layer lives in the proxy handler and the tool
handler. Upgrades exploit it: a module stays Enabled (no 404) but not-Ready
(DrainingUpgrade) while the old generation drains and the new one starts.
A disable or upgrade drain runs the same steps as the shutdown drain below,
under the same DrainPerModule budget, and closes the module's peer once
the module.drain call is done, which cancels its in-flight reverse
calls. A drain also cancels a spawn still starting for the old state: that
child is killed instead of installed, so a revoke or disable that lands
mid-start never leaves a second child serving under the old grants.
A drain caused by a disable, or by a revoke (an upgrade whose new grant
set no longer covers one the child holds), refuses the child's host.*
reverse calls from the moment it starts, with CodeCapabilityDenied. The
child still gets its drain budget to wind down, but a revoked grant is
gone at once rather than when the child exits. An upgrade that takes no
grant away, a widening (orders:read to orders:*), and the shutdown
drain keep reverse calls open so the child can finish queued work.
Shutdown
App.Shutdown drains process modules after the HTTP server has drained
and before batteries stop, so a child's in-flight reverse calls still reach
the queue and the DB. Every child drains at once: it gets module.drain,
then EOF on stdin, and is killed when its budget runs out
(SupervisorConfig.DrainPerModule, 30 s by default, capped by the
Shutdown context's deadline). The budget counts from module.drain, and
the drain call waits at most half of it (2 s at most) for an answer. A
child that stopped reading stdin, so the drain frame cannot be written,
is still killed on time. However the child ends (it exits at EOF, is
killed at the deadline, or crashes), the host then kills what is left of
its process group on Unix, so a helper it started there does not outlive
it. The drain closes the supervisor first, so no
module restarts during or after shutdown, and a child still starting when
it closes is killed instead of installed. ProcessModuleSupervisor.Close
stops the loops and kills the children without the drain; after a drain it
does nothing.
Sandbox trust tiers (the honest limit)
A bare subprocess is not a security boundary. TrustUntrusted requires
a SandboxRunner whose backend passes a P1–P7 conformance probe (distinct
OS principal, no inherited secret/fd, no network egress, filesystem
confinement, resource limits, no privilege re-escalation). An untrusted
module with no probe-passing backend does not run; there is no silent
downgrade to the trusted runner.
The backends are per-OS wrapper commands (Linux: bwrap + prlimit;
macOS: sandbox-exec; Windows: AppContainer + Job Object). Linux is the
platform where an untrusted module runs: the bwrap backend passes every
probe on a host with bubblewrap, util-linux prlimit and unprivileged user
namespaces. Some probe properties are structurally unreachable on a
stock host elsewhere: distinct-uid and resource caps on macOS,
network-egress denial on Windows. The built-in macOS profile also permits
reads across the user's temporary directories and /etc, so its probes
report P2 (host secrets in temporary directories) and
P5 (host files such as /etc/passwd) as breaches, not passes. It is not
a filesystem confidentiality boundary for those locations.
So: an untrusted module is unrunnable
on stock macOS/Windows until the operator installs a conforming backend.
Fail-closed refusal is the correct, honest enforcement, not a gap to paper
over.
What the Linux sandbox gives a module
The child runs as uid/gid 65534 in its own user, pid, network, IPC and
UTS namespaces, with every capability dropped. It sees a read-only /usr
(plus /bin, /lib*, /sbin), /etc/ld.so.cache and /etc/localtime
and nothing else of /etc, its package directory read-only, and its
scratch directory read-write as its working directory. Everything else,
the root and /tmp included, is read-only or absent: a module writes
only to its working directory. It has no network but loopback inside its
own namespace.
Resource caps are rlimits set inside the namespaces: open files, processes
(threads count), and data memory. SandboxOpts sets them; a zero takes the
backend default of 1024 files, 256 processes and 1 GiB. The memory cap
counts every writable mapping, the Go runtime's own and each cgo thread's
stack included, so a cgo-linked Go module needs about 128 MiB before its
main runs. CPU is not capped (that needs a delegated cgroup v2).
On a root host, bwrap itself starts as host uid 65534: the kernel never applies the process cap to host root, namespace or not. The child is then a separate host account as well as a separate namespace uid. That account owns the spawn's scratch directory, and it must be able to read the package directory and the binary; one only root can read fails the spawn, closed. On a non-root host the child is the host user outside its namespaces, kept away from the host's files and processes by the mounts and the pid namespace.
Unprivileged user namespaces are off on some distros and restricted by
AppArmor on others (Ubuntu 24.04 ships an AppArmor profile that lets
bwrap create them). Where they are unavailable, bwrap fails at spawn,
every probe fails and untrusted modules are refused.
A probe passes only when it proves the property, never because a test
could not run. P3 looks for the planted fd by device and inode, so the
child's own descriptors do not count. P4 dials loopback and LAN listeners
the runner opened and reached itself before the spawn, and with no such
control it reports unreachable. P6 holds every fd and process open at
once and counts only EMFILE and EAGAIN as the cap (a missing binary
or a timeout is unreachable), and an allocating sub-child must be killed
or refused short of twice the memory limit. CPU throttling is not probed.
Probe output replayed to the operator (a ProbeResult.Detail, e.g.
inside the conformance report NewSandboxRunner embeds in
ErrSandboxUnavailable) is scrubbed of terminal control bytes — C0
except newline and tab, plus DEL — the same contract the codegen
extension path applies to child stderr, so a sandbox wrapper's or child's
output cannot drive the operator's terminal from inside an error string.
Hosting an untrusted module
The app builds the sandbox runner itself. The first TrustUntrusted
descriptor passed to RegisterProcessModule probes the backend with the
P1–P7 suite, once for the life of the App, and hands the result to the
supervisor. When the backend is missing or fails a probe,
RegisterProcessModule panics with UntrustedNoSandboxError, and the
message carries the conformance report. A trusted-only app never probes.
WithSandboxBackend(b)picks the backend. Without it the app uses the host's built-in one (bwrapon Linux,sandbox-execon macOS). A customSandboxBackendmust pass the same probes.WithSandboxBackend(nil)makes every untrusted registration fail closed.app.ProcessModuleSandbox()returns the runner, or the error that refused it, probing on the first call. A host that must still boot on a machine without a conforming sandbox calls it first and registers the untrusted module only when it returns nil. The error is a*SandboxConformanceErrorwhoseReportholds the per-probe results; it unwraps toErrSandboxUnavailable. A backend'sWrappanic during conformance becomes an unreachable probe and this same refusal; it does not crash the host or start an unconfined child.
app := framework.NewApp(framework.WithDB(db)) if _, err := app.ProcessModuleSandbox(); err != nil { log.Printf("untrusted modules cannot run here: %v", err) } else { app.RegisterProcessModule(desc, approved) // desc.TrustTier == TrustUntrusted }
The probe child is your app's own binary. Each probe re-runs the
running executable with GOFASTR_SANDBOX_PROBE=<id> in its environment.
The framework's package init sees that variable, runs the probe body,
prints one result line and exits before your main starts. There is no
hook to call from main, so there is none to forget, and no helper
binary to ship beside the app. Two things follow:
- Package
initfunctions that run before the framework's run in the probe child too, inside the sandbox under test with a scrubbed environment. Aninitthat needs the network or a secret fails there, as it would in a confined module. GOFASTR_SANDBOX_PROBEis reserved. A process started with it set runs one probe and exits.
ProbeResult.Reported says whether the probe child printed its result
line. A report with an unreported probe tells you about the wiring, not
the backend.
examples/processmodule-demo/host
hosts the demo module as an untrusted module, and its -check-sandbox
flag prints this host's report as JSON. On Linux with bubblewrap it runs
the module confined; on macOS (no distinct uid, no resource caps) it
refuses the module and prints which probes failed. -trust=trusted runs
the same module with crash isolation only.
Migrations (per-module schema + role)
The host runs all DDL under a restricted per-module Postgres schema + role, keyed to the module's migration group:
CREATE SCHEMA IF NOT EXISTS module_billing; CREATE ROLE module_billing_role LOGIN PASSWORD '…' NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; GRANT USAGE, CREATE ON SCHEMA module_billing TO module_billing_role; ALTER ROLE module_billing_role SET search_path = module_billing; REVOKE ALL ON SCHEMA public FROM module_billing_role;
Three load-bearing points:
search_pathis a convenience, NOT the fence. It is session-mutable; theREVOKEonpublicis the real boundary: the role holds no privileges outside its own schema regardless ofsearch_path.- The DDL session authenticates AS
module_billing_role(a separate login role, member of nothing), not an elevated session thatSET ROLEs down. Only then isRESET ROLEa no-op andSET ROLE <an elevated role>refused. - The tracking table is schema-local (
module_billing._migrations), so the runner's advisory-lock + checksum-integrity + single-transaction atomicity are preserved with zero runner changes (core/migrateis fed, never forked). - Distinct module names derive distinct schemas. The schema and role
are
module_<name>for a name made of lowercase letters and digits. A name the identifier sanitizer would fold (billing-1,billing_1,Widget) carries a 12-hex digest of the original name (module_billing_1_<digest>), so two operator-approved modules can never share one schema behind one role, where theREVOKEfence would bound each to the other's objects.
The short-lived migration coordinator (App.NewModuleMigrationCoordinator)
loads approved SQL from the digest-verified artifact, validates it against
the group rules (every migration's group == the descriptor's group; default-
group migrations are rejected; duplicate (group, version) and digest
mismatches are rejected), runs a non-authoritative lint (flags anything
beyond plain CREATE TABLE / ALTER TABLE ADD COLUMN / CREATE INDEX for
review; the role is the real boundary, there is no SQL parse-allowlist),
then provisions the schema+role, runs Up under the advisory lock
authenticated as the role, and stamps MigrationsAppliedAt so the
supervisor lets the module reach Ready.
The stamp belongs to one artifact. When a replica boots a module whose
artifact digest differs from the stored one, InstallOrUpdate clears
MigrationsAppliedAt with the generation bump, so the new version waits
for its own migrations: a supervisor whose descriptor declares a
migration group drains its running child and holds the module in
InstalledDisabled until the coordinator runs again with the new
descriptor. A grants-only change keeps the stamp. Apply stamps only
while the row still names the descriptor's ArtifactSHA256, so a
coordinator run for the old artifact that finishes after the upgrade
returns an error without stamping (its DDL has already run).
coord, _ := app.NewModuleMigrationCoordinator( framework.WithCoordinatorAdminDSN(postgresURL), ) coord.Apply(ctx, desc, []framework.ApprovedMigration{ {Version: 1, Name: "init", Up: "CREATE TABLE invoices (id int)", SHA256: "…"}, })
The coordinator runs Up over a DSN built from the admin DSN with the
module role's login in place of the admin's. Of the admin DSN's query
it keeps only the keys that say where the server is and how to reach
it: host, hostaddr, port, dbname, the ssl* settings,
connect_timeout, target_session_attrs, load_balance_hosts,
application_name, fallback_application_name, krbsrvname and
krbspn. Everything else is dropped: a user, password, passfile
or service would name a login, and options, search_path or any
other startup setting would override what provisioning set on the role.
SQLite is not a third-party DDL boundary: it has no roles/GRANT/
schemas. The coordinator rejects an untrusted module's migrations on
SQLite, loud (fail-closed). Trusted/dev-only modules may run on SQLite.
Group names do not make SQLite DDL safe; groups are bookkeeping.
No auto table-drop. Disable leaves schema + rows intact; uninstall
removes registration but drops nothing. A destructive
DROP SCHEMA module_M CASCADE is a separate, privileged control-plane
action, never an uninstall side effect.
MCP tools
A module may declare AI-agent-callable tools in its
descriptor. The host, not the child, registers each tool into its
existing core/mcp.Server under a namespaced id:
module.<name>.<tool>so two modules cannot collide and every call is attributable. At handshake
the host fetches module.tool.list and requires byte-equality with the
descriptor digests; a child that adds, renames, or reshapes a tool is
quarantined. A tool invocation forwards module.tool.call to the live
child through the same capability broker as module.http, but the
calling agent's credentials are not forwarded: the tool's reverse host.*
calls run as an anonymous caller within the module's grants, so
owner-scoped reads are refused by construction. Re-attaching the agent's
roles there would let a tool read across owners. A tool can do nothing an HTTP
route with the same grants couldn't; there is no separate tool-permission
vocabulary.
UI rendering
A module owns screen logic only. It returns a bounded, declarative
ui.node.v1 node tree, a closed, host-owned component enum with typed
scalar props, which the host validates, maps to design-system components,
renders, and hydrates. The module never emits raw HTML/CSS/JS or
data-cui-* attributes; action references resolve to installed routes,
which the host maps to the real runtime RPC URLs.
The closed validator lives in core-ui/uinodev1
(uinodev1.Validate): it enforces the whole-tree caps (depth ≈ 32, nodes ≈
500, per-prop strings ≈ 4 KiB, and each props array at 128 items by default),
the closed component enum, typed scalar
props (no id/class/style/data-* passthrough: data-cui-* and on*
are unrepresentable, not merely denied), host-relative-only URL schemes,
and action_ref shape. A forged tree is whole-tree rejected.
Limits.MaxPropItems bounds table rows, columns and cells per row, plus
detail-list and key-value items, even when their text is empty. Hosts can
override that cap; zero selects the default. Overflow rejects the whole tree.
The proxy renders a validated tree through
framework/uihost/uinoderender: each component
maps to a framework/ui / core-ui/html primitive with the host assigning
every id, class, ARIA attribute, and data-cui-rpc URL; the module supplies
none. A node's action_ref resolves against the module's own declared
routes; a ref naming no declared route fails the render closed (a buffered
503), and any validation or render error is likewise fail-safe; the forged
or malformed content never reaches the wire. The gate test
TestGate_UIContainment proves both halves end to end: a clean tree renders
to real design-system markup (200 text/html), a forged tree is rejected
(503, no leaked attribute).
Building a module
A module child is a plain Go binary that speaks moduleproto over stdio. It
depends only on core/moduleproto plus the
standard library: no framework/*, no MCP, no DB driver. The canonical,
runnable example is
examples/processmodule-demo/main.go,
which is also the child the go/no-go gate suite
(framework/processmodule_gate_test.go) drives end to end. Read it alongside
this section.
The shape is fixed:
- Open a
moduleproto.Codecoveros.Stdin/os.Stdoutand construct amoduleproto.Peerin theRoleChildrole. - Register handlers for the host → module methods you serve:
module.handshake(echo the host's expectedinstance_id+desired_generation+surface_sha256; a mismatch is terminal),module.ready(warmup gate),module.health,module.http(your routes),module.drain, and optionallymodule.tool.list/module.tool.call(module.cancelis built into the Peer; do not re-register it). - Call
peer.Start(), then block untilpeer.Done()(clean EOF on stdin) orpeer.FatalDone(), and exit non-zero on a fault. A faulted peer answers nothing more while its read loop keeps draining stdin, so a child that stays up leaves every host call to wait out its deadline; once it exits, the supervisor restarts the module.
Cancelling an in-flight call
module.cancel aborts one inbound request's context. CancelParams.RequestID
is the canonical decimal string of the inbound frame id — the value the
host's Call assigned to the frame it is cancelling, formatted with no
padding or sign. Exactly one spelling is accepted: "7" cancels frame 7,
while "07", " 7" and "notes-7" are silently ignored, so a
near-miss can never abort a neighbouring request.
The host sends it for both forwarded call kinds: a proxied module.http
request and an MCP module.tool.call, once the caller's context ends
(client gone or the module's call deadline passed).
Note that this is not HTTPRequestParams.RequestID, which is a
diagnostics-only <module>-<counter> label and cancels nothing. The two
fields were documented as the same value and were not, which is why
module.cancel never fired on the proxy path.
Hosts get the frame id from Peer.CallWithID, which is Call plus a
callback:
raw, err := peer.CallWithID(ctx, moduleproto.MethodHTTP, params, func(frameID uint64) { go watchCancel(peer, frameID, ctx) // arm the watcher for THIS frame })
The callback fires synchronously, after the pending entry is registered and
before the frame is written, so a watcher armed inside it cannot miss a
response. It must not block — the write is waiting on it. It is not called
when no id was allocated (the peer is closed, the method is empty, the
context has already ended, or the in-flight cap is reached). Plain Call is unchanged and still correct
wherever the caller has nothing to cancel.
Either call returns once its context ends, even while its request frame
is still waiting to be written to a peer that stopped reading. The write
carries on in the background and holds the call's in-flight slot until
it returns, so stuck writes never exceed the cap. A call whose context
had already ended writes nothing. Peer.Notify (how module.cancel is
sent) works the same way, with its own cap: at most Limits.Inflight
notification writes wait on a peer that stopped reading, and a Notify
past that returns moduleproto.ErrInflightCap without writing.
A call that gave up still expects its answer: the peer remembers the id
and drops the late response instead of faulting on it. It remembers up to
1,024 ids still owed an answer; an answer that arrives frees its slot, so
a long-running handler that ignores module.cancel keeps its slot however
many other calls are given up and answered meanwhile. A response naming an
id the peer never sent, or one already answered, is still a fault.
codec, _ := moduleproto.NewCodec(os.Stdin, os.Stdout, moduleproto.DefaultMaxFrameBytes) peer := moduleproto.NewPeer(codec, moduleproto.RoleChild) peer.Handle(moduleproto.MethodHandshake, func(_ context.Context, p json.RawMessage) (any, error) { var hp moduleproto.HandshakeParams _ = json.Unmarshal(p, &hp) return moduleproto.HandshakeResult{ Proto: moduleproto.ProtoRange{Min: 1, Max: 1}, Identity: moduleproto.Identity{ Name: hp.Expected.Name, Version: hp.Expected.Version, InstanceID: hp.Expected.InstanceID, DesiredGeneration: hp.Expected.DesiredGeneration, }, SurfaceSHA256: hp.Expected.SurfaceSHA256, }, nil }) // …module.ready / module.health / module.http (by RouteID) / module.drain… peer.Start() select { case <-peer.Done(): case <-peer.FatalDone(): } if err := peer.FatalError(); err != nil { fmt.Fprintln(os.Stderr, "peer fault:", err) os.Exit(1) }
Three contracts the child MUST honor (the host enforces them; violation is
terminal Failed or a per-call 503):
- The descriptor is authoritative. Routes, tools, requested grants, and
the digest fields come from the operator-approved
ProcessModuleDescriptor, never from the child.module.handshakeonly echoessurface_sha256;module.tool.listmust be byte-equal to the descriptor's tool digests. A child that adds a route/tool or reshapes a tool is quarantined. - Reverse data access is brokered. The child holds zero DB credentials.
To read host data it issues
host.entity.query(and create/update/delete/ search/event) viapeer.Call, echoing theCallerblock the host attached to the inboundmodule.http/module.tool.callso the host re-attaches the caller's context. The host derives the required permission from the trusted method + entity name (never a child-supplied string), checks it againstmodule-grant ∩ caller-authority, and re-dispatches through the same CRUD chokepoint as live HTTP. The demo's/itemsroute is a worked example. - Bodies are fully buffered. A
module.httpresponse is oneHTTPResponseResultwith ajson,text, orui.node.v1body. There is no streaming in v1: the host buffers the whole response before committing headers, so a child that dies mid-call yields a buffered 503, never a truncated 200. A status outside 200–599 is answered the same 503. - The host owns the response headers. The response is served on the
host's origin, so only
Cache-Control,Content-Language,ETag,Expires,Last-ModifiedandRetry-Aftercross from the child, and itsVaryadds to the host's. Everything else stays the host's:Set-Cookie, the security headers, CORS,LocationandRefreshare dropped.Content-Typeis always the body kind's (application/json,text/plain, ortext/htmlfor a validatedui.node.v1tree), withX-Content-Type-Options: nosniff, so ajsonortextbody never runs as markup.
To install a built child, compute the digest of its routes and tools
(framework.ComputeSurfaceSHA256) and tool digests
(framework.ModuleToolDigest) from the same routes and tools the child
serves, pin the executable's SHA-256, and RegisterProcessModule the
descriptor; the gate test's demoDescriptor helper shows the exact
bookkeeping.
Common mistakes
- Treating
search_pathas the isolation fence. It is session-mutable and the publisher canSET search_path TO public, module_Mfreely. The fence is theREVOKE: the role holds no privileges outside its own schema. Claiming otherwise is the common, wrong take on fixedsearch_path. - Forgetting to run the migration coordinator. A module with a
declared migration group never reaches Ready until the coordinator stamps
MigrationsAppliedAt; it looks "stuck disabled" with proxy 503s. The same holds after every artifact upgrade: the new artifact clears the stamp, so run the coordinator as part of the deploy. - Expecting an untrusted module to run on stock macOS / Windows, or on a
Linux host without bubblewrap,
prlimitor unprivileged user namespaces. Sandbox backends need a probe-passing conforming wrapper; until the operator provisions one, fail-closed refusal is correct, not a bug. - Writing to
/tmpfrom an untrusted module. There is no writable/tmpin the Linux sandbox; write to the working directory. - Registering an untrusted module the host may not be able to confine,
with nothing to catch the refusal.
RegisterProcessModulepanics on a host without a conforming sandbox. Callapp.ProcessModuleSandbox()first when the app has to boot there anyway. - Assuming group names make SQLite DDL safe. SQLite has no roles/
GRANT/schemas; an untrusted module's migrations are rejected on SQLite loud (fail-closed). Groups are bookkeeping, not a boundary. - Putting the Ready check in the router gate. The router gate can only 404; Ready lives in the proxy handler (503 + Retry-After) and the tool handler (retryable), so an enabled-but-down module is retryable, not uninstalled-looking.
- Expecting disable / uninstall to drop tables. Disable leaves schema
+ rows intact; uninstall removes registration only.
DROP SCHEMA … CASCADEis a separate privileged action.