Files
hq/03-DESIGN/01-to-be/20-writing-a-module.md
T
jschoubben 90e4a368dc ADR 0081: a decision nothing cites is not yet in the chain
Decisions were the one link the cycle checks skipped, and measuring found 19 of 70 records
orphaned — the credential flow and the module-runtime cluster among them, which is how a
stale premise about a settled decision survived in working memory. cycle.py now refuses an
accepted record nothing cites; the 19 got true homes (design frontmatter, the playbook that
implements 0021, META for the process records). The overview names the practice: spec-driven
development with provenance.

https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-17 22:36:33 +02:00

181 lines
8.2 KiB
Markdown

---
layer: to-be
status: proposed
code:
- mesh-catalog modules/showcase
- mesh-controller internal/builder
- mesh-sdk src
updated: 2026-09-15
decisions:
- 02-DECISIONS/0074-the-wire-is-specified-not-the-types.md
- 02-DECISIONS/0040-what-a-module-is.md
- 02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md
- 02-DECISIONS/0053-a-step-that-runs-on-a-schedule.md
---
# 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`](18-building-a-module.md); the
reference for *what the code and the mesh say to each other* is
[`19-the-module-protocol`](19-the-module-protocol.md). This is how you actually write one.
## First: what is in the SDK, exactly
**The protocol, and nothing else** ([ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md)).
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](../../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md)).
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](../../02-DECISIONS/0040-what-a-module-is.md)) 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
```json
{
"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.
```json
{
"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](../../02-DECISIONS/0014-no-npm-workspace.md) 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.