A worked guide: one module, four capabilities, four languages
What the SDK contains, answered by exclusion as much as by inclusion. It is the protocol and nothing else — no configuration loader, because configuration arrives as files the mesh wrote; no API clients, because a Plex client changes when Plex changes and that has nothing to do with any other module; no storage, HTTP or logging, because the language has those. The test for anything proposed is ADR 0039's: does editing it recompile unrelated modules, and does it change often. Both, and it stays out. Then the worked module: events in TypeScript, tools in Go, a provisioner in Rust, a scheduled job in Python. Four artifacts, four toolchains, four processes, one module — and each part is an ordinary project in its language depending on the mesh SDK the ordinary way, so a laptop resolves what a build resolves. And publishing a package as a module capability, which makes the SDK unspecial: it is simply the first module that published a library. A Plex client belongs to the Plex module because that is the only thing that knows when Plex changed. Three things left open rather than papered over: which registry (the catalogue holds verdaccio and a forge usually serves one too, and nothing says which is ours), who may publish (a credential that does not exist), and what a range means in a mesh where everything else is pinned by digest — a mesh that can rebuild a commit and get a different library is a real change, and should be decided rather than arrived at. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
@@ -0,0 +1,175 @@
|
|||||||
|
---
|
||||||
|
layer: to-be
|
||||||
|
status: proposed
|
||||||
|
code:
|
||||||
|
- mesh-catalog modules/showcase
|
||||||
|
- mesh-control 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
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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 control plane 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 registry.** The catalogue holds a `verdaccio` module, and a forge typically serves package
|
||||||
|
registries too. Two answers exist and nothing says which is the mesh's. That has to be settled
|
||||||
|
before anything publishes.
|
||||||
|
- **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.
|
||||||
@@ -29,6 +29,7 @@ document is written and this one's status becomes `implemented`.
|
|||||||
| [`17-raising-a-mesh.md`](17-raising-a-mesh.md) | How a mesh comes into existence, and how a machine joins one that exists | [ADR 0067](../../02-DECISIONS/0067-genesis-is-a-pivot.md), [ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md), [ADR 0005](../../02-DECISIONS/0005-the-node-host.md) |
|
| [`17-raising-a-mesh.md`](17-raising-a-mesh.md) | How a mesh comes into existence, and how a machine joins one that exists | [ADR 0067](../../02-DECISIONS/0067-genesis-is-a-pivot.md), [ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md), [ADR 0005](../../02-DECISIONS/0005-the-node-host.md) |
|
||||||
| [`18-building-a-module.md`](18-building-a-module.md) | How a build is modelled, and why a recipe that is always a Dockerfile does not fit what a module is | [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md), [ADR 0039](../../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md), [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) |
|
| [`18-building-a-module.md`](18-building-a-module.md) | How a build is modelled, and why a recipe that is always a Dockerfile does not fit what a module is | [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md), [ADR 0039](../../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md), [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) |
|
||||||
| [`19-the-module-protocol.md`](19-the-module-protocol.md) | What a module's code and the mesh say to each other; an SDK is an implementation of it | [ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md), [ADR 0042](../../02-DECISIONS/0042-the-shape-of-an-event-on-the-wire.md), [ADR 0043](../../02-DECISIONS/0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md) |
|
| [`19-the-module-protocol.md`](19-the-module-protocol.md) | What a module's code and the mesh say to each other; an SDK is an implementation of it | [ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md), [ADR 0042](../../02-DECISIONS/0042-the-shape-of-an-event-on-the-wire.md), [ADR 0043](../../02-DECISIONS/0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md) |
|
||||||
|
| [`20-writing-a-module.md`](20-writing-a-module.md) | A worked guide: one module, four capabilities, four languages, and the packages it publishes | [ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md), [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md), [ADR 0039](../../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md) |
|
||||||
|
|
||||||
## Not yet written
|
## Not yet written
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user