The module protocol, specified per capability
A floor every implementation needs and three capabilities independent of each other, so an SDK can implement the floor and events and be a real thing rather than an unfinished one. Written as a specification, which means it says what is required rather than how anything is arranged — and says plainly where it describes behaviour that is not yet true. Three places it does: x-causation-id and x-schema are specified and emitted by nothing; the Go side writes four headers and the TypeScript side declares six. A module may serve tools and may not call them, because a caller needs a reply queue its account may not declare. And the two implementations disagree about what a grant carries — in TypeScript consumer is the module, in Go it is the node and the module is From. One word, two meanings, in two halves of one mesh. Naming those in the specification rather than leaving them for conformance to discover, because a specification that only described what already works would have nothing to say about the things most likely to break. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
@@ -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 `<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.
|
||||
|
||||
### 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.<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, 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. |
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user