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.
214 lines
10 KiB
Markdown
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. |
|