diff --git a/03-DESIGN/01-to-be/19-the-module-protocol.md b/03-DESIGN/01-to-be/19-the-module-protocol.md new file mode 100644 index 0000000..0e5ab07 --- /dev/null +++ b/03-DESIGN/01-to-be/19-the-module-protocol.md @@ -0,0 +1,181 @@ +--- +layer: to-be +status: proposed +code: + - mesh-sdk src + - mesh-tools src/broker-amqp.ts + - mesh-control internal/link +updated: 2026-09-15 +decisions: + - 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 +--- + +# 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 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 substrate 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 `..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. + +### Not yet true + +`x-causation-id` and `x-schema` are specified above and **emitted by nothing**. The Go +implementation writes four headers; the TypeScript one declares six. This is the drift ADR 0074 +exists about, and the first thing conformance will fail on. + +--- + +## Capability: tools + +A module's tools are its operator-facing surface. + +- A tool is served from a **shared durable queue**, `serve.`. 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, and nothing today issues an account to anything + that wants to ask. + +### Not yet true + +The caller's half has no account. Until that is settled, the only thing that can ask a module a +question is the substrate's bootstrap admin, which is not a protocol so much as a way in. + +--- + +## 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. + +### Not yet true, and it is the sharpest disagreement + +The two existing implementations do not agree on this shape. In TypeScript a grant's `consumer` is +**the module**; in Go, `Consumer` is **the node** and the module is `From`. One word, two meanings, +in two halves of one mesh. At least one is wrong and the specification above says which. + +--- + +## 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. | diff --git a/03-DESIGN/01-to-be/README.md b/03-DESIGN/01-to-be/README.md index 328f0f5..a70299b 100644 --- a/03-DESIGN/01-to-be/README.md +++ b/03-DESIGN/01-to-be/README.md @@ -28,6 +28,7 @@ document is written and this one's status becomes `implemented`. | [`16-module-coverage.md`](16-module-coverage.md) | What a module must be able to say, measured against 127 that exist | [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.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) | +| [`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) | ## Not yet written