diff --git a/03-DESIGN/01-to-be/20-writing-a-module.md b/03-DESIGN/01-to-be/20-writing-a-module.md new file mode 100644 index 0000000..836c2c0 --- /dev/null +++ b/03-DESIGN/01-to-be/20-writing-a-module.md @@ -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. diff --git a/03-DESIGN/01-to-be/README.md b/03-DESIGN/01-to-be/README.md index a70299b..b3e2835 100644 --- a/03-DESIGN/01-to-be/README.md +++ b/03-DESIGN/01-to-be/README.md @@ -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) | | [`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) | +| [`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