Files
hq/03-DESIGN/01-to-be/19-the-module-protocol.md
T
jschoubben 77a1493df4 Renumber to 0116: another record took 0115 on main
PR #133 landed a different 0115 while this branch was open. The bus record
is now 0116, with every citation in designs 19, 25, 28 and the index
following it.

Note: cycle.py and records.py both fail on main as merged, on that record —
nothing cites it, and it rests on 0112, which is still proposed. Both
pre-date this branch and are left for their own change.
2026-09-26 18:56:34 +02:00

214 lines
10 KiB
Markdown

---
layer: to-be
status: proposed
code:
- mesh-sdk src
- mesh-tools src/broker-amqp.ts
- mesh-controller internal/link
updated: 2026-09-26
decisions:
- 02-DECISIONS/0095-the-control-plane-is-the-way-to-ask-a-module.md
- 02-DECISIONS/0106-the-bus-is-nats.md
- 02-DECISIONS/0116-the-bus-is-built-in-five-steps.md
- 02-DECISIONS/0074-the-wire-is-specified-not-the-types.md
- 02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md
- 02-DECISIONS/0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md
- 02-DECISIONS/0042-the-shape-of-an-event-on-the-wire.md
- 02-DECISIONS/0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md
---
# The module protocol
**What a module's code and the mesh say to each other.** An SDK is an implementation of this in one
language and nothing more ([ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md)).
This is a specification, so it says what is required rather than how anything is arranged. Where it
describes current behaviour that is *not yet* specified-and-conformed, it says so.
> **The wire below is the bus being replaced.** *2026-09-26.*
> [ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md) moved the mesh's bus to NATS. Everything
> in this document that names an exchange, a queue or a routing key — the event exchanges, the
> durable `<node>.<module>.events` queue, the shared `serve.<key>` queue — describes the transport
> being retired, and the conformance fixtures were captured against it.
>
> What does **not** change is this document's model, which is the part ADR 0074 decided: a floor
> plus independent capabilities, an SDK that implements what it claims and is legitimate when it
> claims less, identity taken from the sealed credential rather than the environment, at-least-once
> with dedup on `x-event-id`, and conformance as executable fixtures per capability rather than
> prose. The envelope keeps its shape ([ADR 0042](../../02-DECISIONS/0042-the-shape-of-an-event-on-the-wire.md));
> it becomes the message body.
>
> Rewriting the wire sections onto the subjects and streams of
> [design 25](25-the-bus-on-nats.md) §2–§3, and recapturing the fixtures there, is **step 3 of
> [ADR 0116](../../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md)**. Until that lands, read
> the sections below for what two implementations may not disagree *about*, and design 25 for what
> they will disagree about it *on*. A specification that silently described a retired transport
> would be worse than an absent one, because it reads as current — hence this note rather than a
> quiet edit.
## The shape of it
A **floor** every implementation needs, and three **capabilities** that are independent of each
other. An SDK implements the floor plus whatever capabilities it claims; a module is refused at
build time if it uses a capability its language's SDK does not implement.
| part | a module uses it to |
|---|---|
| **connection** | reach the broker as itself |
| **events** | emit, and react to what others emit |
| **tools** | answer questions asked of it |
| **provisioning** | give a consumer an instance of what it provides |
---
## The floor: connection
### The credential
A module is given a **sealed credential** as a file, and told where by its declaration. The
document:
| field | is | required |
|---|---|---|
| `url` | an `amqps://` URL carrying the account's user and password | yes |
| `fingerprint` | sha256 of the certificate the broker must present | yes for a scoped account |
| `node` | the machine this account was issued for | yes for a scoped account |
| `module` | the module this account was issued for | yes for a scoped account |
A plain string rather than a document is a **bootstrap URL** — unscoped, for the moment before a
mesh can issue anything. An implementation accepts both and must not treat the second as ordinary.
### Connecting
- The connection **pins the fingerprint**. It does not trust a certificate authority, and it does
not skip verification. A broker presenting a different certificate is refused, whatever else is
true of it.
- A scoped account **does not declare exchanges**. The foundation owns them; an account that may
declare one is an account that may create a parallel mesh by typo.
- An implementation **declares its own queue** and nothing else.
### Identity
**A module's node and module name come from its credential, never from its environment.**
This is not a convenience. It is what makes what a module emits match what the mesh authorised: an
environment variable can be set by anything on the machine, and a module that took its identity
from one could emit events attributing them to another module. Where an environment variable and
the credential disagree, the credential wins and the variable is overwritten.
---
## Capability: events
### The exchanges
| exchange | carries |
|---|---|
| `mesh.events` | every event |
| `mesh.events.dead` | what could not be handled |
### The queue
One **durable** queue per consumer, named `<node>.<module>.events`, with as many bindings as the
module has patterns. Durable because an event emitted while a module is restarting is exactly the
one that must not be lost.
**A message matching two bindings is delivered once**, so an implementation must match the routing
key against its own patterns locally to decide which handlers run. An implementation that ran every
handler whose exchange binding matched would run the wrong one.
### The envelope
Headers ride as AMQP headers. The body is JSON.
| header | is | required |
|---|---|---|
| `x-event-id` | a unique id, made by the emitter | yes |
| `x-source` | the module, context or node that emitted it | yes |
| `x-node` | the machine it was emitted from | yes |
| `x-time` | emit time, RFC-3339 | yes |
| `content-type` | always `application/json` | yes |
| `x-causation-id` | the event or command that caused this one | no |
| `x-schema` | a version of the body's shape | no |
**An unknown `x-` header is ignored, never refused.** An event is observed by parties that need not
all understand every header, and an implementation that refused one would make adding a header a
breaking change for everybody.
### Delivery
At-least-once. **Deduplication is on `x-event-id`**, which only the emitter can produce — a
consumer cannot tell a redelivery from a second event any other way.
### What is true, checked (2026-09-16)
Go emits all five required headers; the SDK requires exactly those. `x-causation-id` and `x-schema`
are **optional** — the SDK sets them when a handler has a causation or a schema, and reads them
back; a bare event carrying neither is correct. So the envelope agrees across the two
implementations. `x-schema` is available for versioning a body's shape and is set by whoever has a
version to declare.
---
## Capability: tools
A module's tools are its operator-facing surface.
- A tool is served from a **shared durable queue**, `serve.<key>`. Shared, so several runtimes
serving one tool compete for a call rather than each answering it.
- A call is request and reply. The reply returns through the RPC exchange `mesh.rpc`, keyed by the
caller's own reply queue — **not** through the default exchange, which would let a caller publish
into any queue on the broker.
- A caller needs a **reply queue**, and that is what a module's scoped account may not declare
([issue 049](../../04-ISSUES/049-a-module-can-serve-tools-and-nothing-can-call-them/00-report.md)).
So a module may serve tools and may not call them.
- **The control plane is the way to ask**
([ADR 0095](../../02-DECISIONS/0095-the-control-plane-is-the-way-to-ask-a-module.md)):
`ask <module> <tool> [json]` publishes on `mesh.rpc` under `<module>.<tool>` with a private reply
queue bound under its own name, and prints the answer as the module gave it. A module declares
nothing about being asked — serving a tool is being askable through the control plane. A
module-to-module call, if one is wanted, is a grant like any other and a later decision.
*How it is checked:* a tools-only bed asks a served tool through the control plane and asserts
an answer arrived, where a timeout would read differently.
---
## Capability: provisioning
A provider ships the provisioner that creates instances of what it offers
([ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md)).
| direction | carries |
|---|---|
| **grant, in** | the provision, who is asking — **a module on a machine**, not a machine — and what the consumer contributed |
| **credential, out** | the fields the provision promises a consumer |
**Who is asking is one thing with two parts.** A machine routinely runs several modules wanting the
same provision, so a grant addressed to a node alone does not name a consumer, and withdrawing one
would take another's away.
### Checked, and it agrees (2026-09-16)
This looked like the sharpest disagreement and was not one. The live wire is the contributions file
— `as`, `secret`, `node`, `at`, `values` — and it is the same on both sides. The types that
disagreed (`Grant`, `Interface` in the SDK's `contracts`) were dead: exported, imported by nothing,
describing fields the wire does not carry. They have been removed. The lesson kept: a type beside
the wire that has drifted from it is worse than none, which is why the wire is specified and
implementations are checked against it rather than trusted to still match a hand-kept shape.
---
## How an implementation is checked
Per capability, against fixtures rather than prose — a specification nobody can run is a document
two implementations drift from while both believe they conform.
| Rule | Checked by |
|---|---|
| The floor is the floor | Every implementation reads the same credential fixture, and refuses one whose fingerprint does not match what the broker presents. |
| Identity comes from the credential | A fixture sets an environment that disagrees with the credential; the emitted event carries the credential's. |
| The envelope is the envelope | An emitted event is compared header by header against a fixture; a missing required header fails, an unknown `x-` header is accepted. |
| Delivery is at-least-once | A fixture delivered twice is handled once. |
| A grant names a consumer | A grant fixture is read by every implementation and yields the same module and the same node. |
| A partial SDK is legitimate | An implementation claiming the floor and events passes those suites and is listed for them; a module using tools in that language is refused at build time with the reason. |