210 lines
10 KiB
Markdown
210 lines
10 KiB
Markdown
---
|
|
layer: to-be
|
|
status: proposed
|
|
code:
|
|
- mesh-catalog modules/showcase
|
|
- mesh-controller internal/builder
|
|
- mesh-sdk src
|
|
updated: 2026-09-21
|
|
decisions:
|
|
- 02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md
|
|
- 02-DECISIONS/0053-a-step-that-runs-on-a-schedule.md
|
|
- 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.
|
|
|
|
## A file and the thing that reads it are guarded by a gate
|
|
|
|
*Written 2026-09-21, from resolving [04-ISSUES/066](../../04-ISSUES/066-a-partly-applied-declaration-leaves-a-mixed-state-with-no-rollback/00-report.md).*
|
|
|
|
The apply is not a transaction: every resource is attempted, every failure reported, and only a
|
|
failed gate stops what follows it ([ADR 0053](../../02-DECISIONS/0053-a-step-that-runs-on-a-schedule.md)).
|
|
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](../../02-DECISIONS/0099-a-step-that-runs-once-names-what-it-reads.md)). *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.
|