The SDK side of design 57 (ADR 0348, ADR 0349): seal (issue 650); the provider helper that answers a provision's consumers from the plan and the runtime's state, with the consumers' file, names on the plan entry and StateBus (issues 678, 679, 776, 788, 792, 804); and the runtime's mesh/provision messages (issue 685). Squashed from the feature's local branch; each issue's commit is named on its issue. Co-Authored-By: Claude <noreply@anthropic.com>
11 KiB
mesh-sdk
The stable spine a Novox Mesh module's own code builds against — the tool it uses to plug into the mesh, and nothing that belongs to a specific module or to another tier.
It earns its place by rarely changing (novox/hq ADR 0039). The test for anything here: if editing it recompiles unrelated modules and it changes often, it does not belong. Per-module code (a service's API client, its tool implementations, its create-a-resource adapter) lives in the module, never here — that coupling is exactly what turned the old SDK into constant maintenance and made every edit rebuild every module.
What is in it
Five small areas, each a thing a module's tools/ or provisioner/ imports:
contracts — the runtime shapes module code touches
Not the manifest schema (the control plane owns and parses that, in Go). The shapes a module's
running code receives and returns: a provisioning grant and its credentials, the
mesh interface definitions (what an analytics grant contains, what an oidc-client grant
contains — the contract both a provider and a consumer conform to), and the tool and event types.
These change rarely and deliberately; when one does, a rebuild of everything is correct.
provider-loop — the reconcile harness every provider on the grant files shares
Called provisioner until design 57 (novox/hq issue 678); that word is mesh-provisioner's now. The old path and
names (/provisioner, runProvisioner, provisionerTools) stay aliases until the providers importing them move to
the Go helper that answers consumers over the bus (go/provider, design 57 B2 and B3) and this loop retires with the
grant files. Its events and tools keep their names (provisioner.failing, provisioner_retirement, …): the
controller and the operator read them.
The loop is identical for postgres, redis, minio and umami: watch the grants directory, for each
requested grant call the provider's adapter, write the sealed credential, handle withdrawal, and
keep converging (watch). That loop lives here. A module provides only the adapter — "create
a database", "create a umami site" — which is the volatile, per-service half and belongs in the
module. This is the ADR 0039 line drawn through provisioning: stable loop here, per-service create
in the module.
A consumer the mesh stops asking for is retired, not withdrawn whole (novox/hq ADR 0230). ACTIVE
becomes RETIRED only once the same set of consumers has gone unasked in five consecutive passes that
read the file AND for ten minutes (stableForMs, STABLE_FOR_MS; a pass that cannot read the file
starts both again). An adapter with retire disables the consumer's access, reversibly, and marks it;
reenable (optional) undoes that before create when it is asked for again. An adapter without
retire is mark-only: nothing in the backend changes — the consumer keeps its access — and the
harness marks it "retired, access kept, needs deletion", says so loudly and announces it. remove is
never called to retire; it is the delete of an adapter without delete, reached only through a
person's provisioner_delete. A stable set of more than 3, or more than half of those held where more
than one is held, is not retired without a person: the provider waits for
provisioner_retire_approve or provisioner_retire_reject, and says provisioner.retirement
(waiting). Asked for again, a retired consumer is created — re-enabled
— at once. Given module, the four tools (provisioner_retirement, provisioner_retire_approve,
provisioner_retire_reject, provisioner_delete) are registered for it; provisionerTools(handle)
builds them otherwise. Additions and changes are never delayed.
A provider that keeps failing a consumer says so on the bus (novox/hq ADR 0224). A consumer whose
create, check (holds) or secret has failed for five minutes without one success between
(failingAfterMs) is announced provisioner.failing — the consumer, its machine, the class of error
(credentials-rejected, unreachable, secret-unreadable, refused; an adapter's classify is
believed over the words), the error with the password removed, since when and how many attempts — and
again every fifteen minutes while it lasts. The first success after it is provisioner.recovered, as is
a consumer no longer asked for or retired, and the first success for each consumer since the process
started. Every event goes through the announce option, which defaults to the SDK's emit: a provider
says them without asking, and a failed publish is said in its journal, never failing the act.
tools — the tool-serving harness
registerModuleTools, the ToolDefinition type, and the registry/worker that loads a module's
tools and exposes them through the mesh's command surface. How a tool is declared and served is
settled; it does not change when an individual tool does. The tools themselves, and the API client
they call, live in the module.
messaging — the broker and event framework
The broker client (connect, request/reply, publish/subscribe), the event-consumer a module uses to react to mesh events, and the envelope/routing-key conventions. Transport primitives.
Taking an event — the one rule
A module's handler (on(pattern, handler) here, stdio.Subscribe in Go, the same in every language)
takes an event by returning, and asks for it again by throwing (Go: returning an error). Nothing
else counts: what a handler returns is not read.
On the wire, the runtime hands the bundle mesh/event with the envelope and acknowledges the event on
the bus when the answer is anything but a JSON-RPC error — {} (what the SDKs send), null, or no
result at all. An error answer, no answer within the runtime's event timeout (two minutes), or the
bundle exiting leaves it unacknowledged: it is offered again after five seconds, up to the module
consumer's maximum deliveries, after which the bus gives it up and the controller raises a condition
(max-deliveries). The runtime's line for an event not taken quotes a handler's error as the
handler's own words, so it is never mistaken for the runtime's.
So, for a handler:
- Throw only when the work was not done — the service refused, was unreachable, the store failed. Never for something that went right but answered oddly: an API that answers an empty body is a success, and reading it as JSON is a throw that has every event offered five times, its work done five times, and a false alarm raised (novox/hq issue 276).
- Delivery is at-least-once, so the work must be safe to do twice, or the handler skips an event
whose
x-event-id(event.id) it already did — remembering it only once the work succeeded, so one that failed is done when it is offered again. - An event no handler matches is answered at once and taken.
- A subscribe the runtime refused leaves no handler behind, so asking again until it is taken hands each event to the handler once.
Checked by the SDKs' stdio tests (TypeScript test/stdio.test.ts, Go go/stdio_test.go) and the
runtime's (node-tools/internal/launch, mesh-tools).
state — a module's current state on the bus
state(name) reaches a state the module declared it keeps (state in its manifest) or reads
(reads, as <module>.<name>): get, put, delete, keys, and watch, which hands the current
values and then every change. Through the runtime that launched the bundle, which refuses what the
module did not declare. Current values, never history and never a secret (novox/hq ADR 0201).
seats and asks — asking a person, answering on a channel (Go)
A seat may name its caller or its kind (novox/hq ADR 0259): stdio.SeatPublish submits an accept or says
an event under the module's own name or kind, stdio.SeatTake takes a held seat's work, stdio.SeatProve
and stdio.SeatAnswer carry a proof (a code typed by a person, by request and reply, never kept),
stdio.SeatRecord reads a record kept for the module, and State(...).Create and .Update write by
compare-and-set. The runtime does each only as far as the module's membership lists. go/asks is the
contract on top: the Ask an asker publishes and the checks it is held to (its Whole is what a channel that proves
who answers shows in place of its Explanation, and its Details what the Details answer shows, hq issue 383), the Warrant it acts on
(For, Says), and the Message, Choice, LinkAsked and Code a channel takes and says, with the
subjects each travels on.
go/asker is an asking module's client (ADR 0259 §10): Ask keeps an ask in the module's own state and then
publishes it under the module's name; Cancel; Where reads its state from the router's record; Listen and
Take hand the module each warrant once — marked acted on by compare-and-set before the module acts, and only
for an ask it made, an option it offered, at that option's level, before it expired. A lost compare-and-set is
stdio.ErrChanged, told by the runtime's error code.
State(...).Increment(key, attempts) is a counter on top of the two (for numbers handed out once, such as an
issue's): it reads the key, writes one more at the revision read, and on a lost write reads and tries again, at
most attempts times; past that it is stdio.ErrChanged and nothing was counted.
primitives — sealing, semver, resolved-env
Seal/unseal for secrets, semantic version comparison, and the accessor a module uses to read its own resolved environment.
What is NOT in it, and where it goes
- A module's API client and its tool implementations → in the module. (The Plex client, the
Umami client,
tools/plex.ts— all module-local. ADR 0039.) - Delivery machinery — build executor, bundler, dependency resolver, artifact manager, feature handlers → mesh-controller. It co-evolves with the pipeline.
- Host synchronisers — config-sync, ufw config, vhost generation, systemd, health checks → mesh-host's apply engine. The host applies these; they are not module code.
- Domain logic — tasks, workflows, agents, provider integrations → tier-2 contexts.
That is why this stays small: everything volatile has somewhere else to be.
Using it
A module's tools:
import { registerModuleTools, ToolDefinition } from "@novox/mesh-sdk/tools";
import { UmamiClient } from "../client.js"; // the client lives in the module, not here
registerModuleTools("umami", (env) => getUmamiTools(new UmamiClient(env.UMAMI_URL)));
A provider's loop:
import { runProviderLoop, Grant, Credential } from "@novox/mesh-sdk/provider-loop";
runProviderLoop("analytics", {
async create(grant: Grant): Promise<Credential> { /* create a umami site, return the grant */ },
async remove(grant: Grant): Promise<void> { /* delete the site */ },
});
The adapter is the only thing the module writes; the watching, sealing and grant-file handling are the harness's.
Where the reasoning lives
Design and decisions are in novox/hq:
02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md— the stability rule this repository is02-DECISIONS/0040-what-a-module-is.md— a module, and why its per-module code lives in the module