10 KiB
layer, status, code, updated, decisions
| layer | status | code | updated | decisions | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| to-be | proposed |
|
2026-09-21 |
|
Writing a module
A worked guide. One module, four capabilities, four languages, and the packages it publishes.
The reference for what can be said is 18-building-a-module; the
reference for what the code and the mesh say to each other is
19-the-module-protocol. This is how you actually write one.
First: what is in the SDK, exactly
The protocol, and nothing else (ADR 0074). An SDK is an implementation of the module protocol in one language. If something is not in the protocol it does not belong in an SDK, and that rule is what stops it becoming the 34,000-line shared library this design exists to avoid (ADR 0039).
Split the way the protocol is split:
| the SDK gives you | so that you can |
|---|---|
| connection — reads the sealed credential, pins the certificate, takes your identity from it | reach the broker as this module on this machine, and not be able to claim otherwise |
| events — emit, subscribe, the envelope, dedup | react to what happens in the mesh |
| tools — register and serve | be asked questions |
| provisioning — receive a grant, return a credential | give a consumer an instance of what you provide |
What it does not give you, deliberately:
- No configuration loader. Configuration arrives as files the mesh wrote and environment the mesh set. Reading a file is not a thing an SDK needs to teach.
- No API clients. A Plex client belongs in the Plex module. It changes when Plex changes, which has nothing to do with any other module — frequent and cascading is the disease.
- No storage, no HTTP framework, no logging library. Use the language's.
If you find yourself wanting to add something to the SDK, the test is ADR 0039's: does editing it recompile unrelated modules, and does it change often? Both, and it does not belong.
A module with four capabilities, in four languages
A module is one piece of software (ADR 0040) and may still be written in several languages — each artifact names its own, compiles alone and is packed alone.
showcase/
module.json
events/ ← TypeScript: reacts to what the mesh does
tools/ ← Go: answers questions
provisioner/ ← Rust: grants instances of what it provides
ingest/ ← Python: a scheduled job
The manifest
{
"module": "showcase",
"build": { "artifacts": [
{ "name": "events", "kind": "bundle", "language": "typescript",
"entrypoints": ["index.js"] },
{ "name": "tools", "kind": "bundle", "language": "go",
"entrypoints": ["tools"] },
{ "name": "provisioner", "kind": "bundle", "language": "rust",
"entrypoints": ["provisioner"] },
{ "name": "ingest", "kind": "bundle", "language": "python",
"entrypoints": ["ingest.py"] }
]},
"resources": [
{ "id": "events", "type": "process", "name": "showcase-events",
"artifact": "events", "run": ["node", "index.js"] },
{ "id": "tools", "type": "process", "name": "showcase-tools",
"artifact": "tools", "run": ["./tools"] },
{ "id": "grants", "type": "process", "name": "showcase-grants",
"artifact": "provisioner", "run": ["./provisioner"] },
{ "id": "ingest", "type": "process", "name": "showcase-ingest",
"artifact": "ingest", "run": ["python", "ingest.py"],
"schedule": "0 3 * * *" }
]
}
Four artifacts, four toolchains, four processes, one module. Nothing here says how any of them
is hosted — that is the mesh's, and it is why these are process rather than four containers.
Step 1 — say what it is, in each language's own terms
Each part is an ordinary project in its language, depending on the mesh SDK for that language the way it would depend on anything:
events/package.json "@novox/mesh-sdk": "^1.2.0"
tools/go.mod require novox.example/mesh-sdk v1.2.0
provisioner/Cargo.toml mesh-sdk = "1.2"
ingest/pyproject.toml dependencies = ["mesh-sdk~=1.2"]
Resolved from the mesh's own package registries, which are the ordinary registries for each
ecosystem, hosted by the mesh. An author does nothing unusual: npm install, go mod tidy,
cargo build, pip install all work, and a developer's laptop resolves exactly what a build does.
Step 2 — write each part against its capability
Each uses only the part of the protocol it needs. The event consumer never learns what a grant is.
events/index.ts on("module.builder.built", …) → the events capability
tools/main.go tool("showcase_state", …) → the tools capability
provisioner/main.rs grant → credential → the provisioning capability
ingest/ingest.py emit("module.showcase.ingested", …) → events, emitting only
Step 3 — the mesh does the rest
You do not write a Dockerfile, a unit file, or a port number. The builder picks the toolchain from the language, compiles each artifact alone, and publishes it. The controller assigns the machine and the ports; the host writes the units.
What it costs you to use four languages
Honestly: four sets of dependencies to keep current, and four SDKs that must agree. The mesh makes it possible, not free. A module in one language is simpler, and the reason to use four is that one of them genuinely suits a part better — not that you can.
Publishing a package is a capability
A module may publish libraries as well as run code. The SDK is not special; it is simply the first module that did this, and its own consumers are the mesh's modules.
{
"module": "plex",
"build": { "artifacts": [
{ "name": "server", "kind": "upstream", "from": "plexinc/pms-docker@sha256:…" },
{ "name": "client-ts", "kind": "package", "language": "typescript", "from": "clients/typescript" },
{ "name": "client-rust", "kind": "package", "language": "rust", "from": "clients/rust" },
{ "name": "client-py", "kind": "package", "language": "python", "from": "clients/python" }
]}
}
A package artifact is built and published to the mesh's registry for that ecosystem, under the
version its own project file declares. Another module then depends on it the ordinary way:
"@novox/plex-client": "^2.0.0"
Why this belongs to modules rather than being a separate thing. A client for a piece of software changes when that software changes, and the module that owns the software is the only thing that knows. Putting the Plex client anywhere else is the shared-library disease with extra steps.
What this leaves open
- Which private registry. That there is one is settled —
ADR 0014 says each module consumes its dependencies
from the private registry, the mesh's own shared library included. Which software serves it is
not: the catalogue holds
verdaccio, and a git host usually serves package registries too. - And the bootstrap does not have one. ADR 0014 assumes a registry exists; on a fresh mesh nothing has installed one when the first SDK is built. That is the same pivot as everything else and it has not been designed.
- Who may publish. A builder pushing a package needs an account on that registry, which is a credential in the bootstrap path and does not exist yet.
- Versions and ranges. Everything else the mesh delivers is pinned by digest, and a range is resolved at build time from whatever the registry holds. A lock file records what was chosen, and the builder records that as the build edge — but a mesh that can rebuild a commit and get a different library is a real change from how everything else here works, and it should be a decision rather than a consequence.
A file and the thing that reads it are guarded by a gate
Written 2026-09-21, from resolving 04-ISSUES/066.
The apply is not a transaction: every resource is attempted, every failure reported, and only a
failed gate stops what follows it (ADR 0053).
So a push can half-happen, and the pairing that matters is a configuration file beside the
service that reads it. A module keeps that pair correct by declaring them in this order: the file;
a run-once container that validates it; the service, with restart-on naming the file. A file
the validator refuses reaches the disk and nothing else — the gate fails, the service after it is
left as it was, and the machine reports the push failed. The service never serves what the
validator refused. That is the grouping, made from what the manifest already has; no primitive
withholds a file from the disk, and a service that reads its file live rather than at start is the
one shape this does not protect, and should not be written.
How it is checked: the lab's coupled-pair spike declares exactly this pair, pushes a refused file, and asserts the file on disk is the new one, the service serves the old one, and the machine reports the push failed.
A run-once step may itself name what it reads under restart-on; for a step the word means
run again — a step that fetches a fact from a provider names the binding it reads, and runs
again when the provider moved. The service that consumes what the step made names the step, so it
is recreated with the new fact
(ADR 0099). How it is
checked: the host's unit tests run a step again when its named file changed and not otherwise,
and recreate a container naming a step after the step ran.