Divide the bus work into five steps, each proved on its own

The NATS change was recorded as one undivided item, which hid three gaps:
a mesh already running had no adoption path, the protocol specification did
not know its transport was being replaced, and nothing was runnable until
everything was. Dividing it is what surfaced them.
This commit is contained in:
2026-09-26 18:19:56 +02:00
parent 782d5ace04
commit fe0c1e9da2
4 changed files with 294 additions and 32 deletions
@@ -0,0 +1,142 @@
---
topic: the mesh
status: accepted
date: 2026-09-26
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0106-the-bus-is-nats.md
---
# 115. The bus is built in five steps, and the protocol moves with it
## Context
[ADR 0106](0106-the-bus-is-nats.md) decided the bus is NATS and described the change as one thing:
built beside the migration, cut over in one rollout after its core. The architecture was written as
[design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) and revised once after review. What neither
says is how the work is divided, and three gaps follow from that.
**The whole of the build is one point.** Design 25 §9 numbers four items. The first — "the `nats`
module, the controller's and host's link on NATS, the runtime's client — built and proven in the
lab" — is every line of code the change requires; the other three are the rollout. A step of that
size ends at nothing provable until it ends at everything, which is the failure
[design 22](../03-DESIGN/01-to-be/22-the-work-ahead.md) opens by naming: *a phase that ends at a
claim is a phase that went missing without anything complaining.*
**There is no adoption path.** Design 25 §5 says the broker "is raised at genesis like the store,
adopted as a module in the same phase" — which describes a mesh being raised from nothing. The mesh
this is for is already running, and a running node gets a foundation module by adoption in place
([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md)), not by genesis. §9 goes
straight from "built beside" to "one rollout" and never crosses that gap.
**The wire changes and the protocol specification does not know it.** Design 25 §8 says a module
sees nothing new. That is true of the SDK's contract — `request`, `handle`, `publish`, `subscribe`
— and false of the wire underneath it.
[ADR 0074](0074-the-wire-is-specified-not-the-types.md) settled that what an SDK implements is a
*specified wire*, checked by fixtures that must match byte for byte, precisely because two
implementations that disagree about an envelope do not fail to compile. That specification is
[design 19](../03-DESIGN/01-to-be/19-the-module-protocol.md), and it is written entirely in AMQP:
exchanges, a durable per-consumer queue named `<node>.<module>.events`, a shared `serve.<key>`
queue — sixteen occurrences of an AMQP term across the document. Design 25 does not cite design 19
anywhere, and design 19 does not cite ADR 0106. So the record that says *what two implementations
may not disagree about* still describes the bus being replaced.
## Considered Options
1. **Keep ADR 0106's shape — build it all, cut over once.** Rejected: not for its rollout, which
is right, but because it leaves the build a single step of unknown length with no intermediate
anyone can run. The three gaps above were found by dividing it; they were invisible while it
was one item.
2. **Cut over incrementally by traffic kind** — events to NATS first, then tools, then control,
the mesh on two buses meanwhile. Rejected: ADR 0106 already rejected two buses, and this is
that with extra steps. The store-window guarantee
([ADR 0083](0083-one-push-leaves-the-mesh-consistent.md)) is exactly the one that cannot cross
a seam, and control is exactly the traffic that carries it.
3. **Five steps, each ending at something provable, the cutover still one rollout.** The build is
divided; the bus still moves once. Adopted.
## Decision
**The bus is built in five steps. Each ends at something a lab bed proves, and no step's proof
waits for the one after it. The cutover remains a single rollout** — dividing the build does not
divide the bus.
**Step 1 — genesis raises the broker.** The `nats` module and a mesh raised on it from nothing.
This is built and proven even though the mesh it is for will never travel this path, because
genesis is where the foundation is *defined*: the only place the mesh comes from nothing, and the
definition every other path is measured against. A genesis path that exists only on paper is one
nobody discovers is wrong until there is a second mesh.
**Step 2 — adoption puts the broker in the seat.** A mesh already running receives the broker by
adoption in place, and the seat it claims is **`mesh-broker`** — unchanged.
[ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md) named the foundation seats
after the server's *role* rather than the product for exactly this case, and ADR 0106 restated it:
the broker module changes, the seat does not. A seat named after the product would have to be
renamed by every change the seat exists to survive.
**Step 3 — the protocol gets a NATS binding.** ADR 0074 stands unamended: the mesh defines a module
protocol, an SDK is an implementation of it in one language and nothing more, the protocol is split
per capability, and conformance is executable fixtures per capability rather than prose. What
changes is what the specification specifies. Design 19's wire section is rewritten from exchanges
and queues to subjects and streams; the fixtures are recaptured on NATS; every SDK claims the
capabilities it passes, and a language may arrive with connection and events alone.
**The SDK gains no conveniences in the process.** [ADR 0039](0039-what-the-sdk-holds-and-refuses.md)
refuses frequent-and-cascading code in the shared library, and ADR 0074 restates it — an SDK is
"not a convenience layer, not a place for helpers to accumulate." A new transport is the moment
that pressure is highest and the reason to hold hardest: the predecessor's shared library is the
cautionary tale ADR 0039 opens with, and it did not become that in one decision. Code shared among
a module's own features stays in that module.
**Step 4 — the core speaks NATS.** The controller's link, the host's link and the tool runtime's
client, and with them the flows that are today carried by something other than the bus: a build
source's change reaching the builder, an installation, a module's own reports. Each is a
conversion with a named before and after, not a rewrite. Observation — heartbeats, conditions,
key-value state — is [research 017](../01-RESEARCH/017-a-mesh-that-heals-itself/00-overview.md)'s
and stays there; that effort already reserves it for after the move, and this step does not
pre-empt its design.
**Step 5 — deployment.** The rollout ADR 0106 decided, unchanged: the controller, every host and
every runtime move together, each node confirmed to have heard before AMQP stops. What this step
adds is that steps 1 to 4 *ship ahead of it* without moving any node's bus — the module exists in
the catalogue, the protocol is specified, the code is written and beds pass, and the running mesh
is still on AMQP throughout. The bus moves on one day, at the end, once.
## Consequences
- **Design 25 §9 is replaced by these five steps** and §10's beds are attributed to the step each
proves, so no proof waits for the last step.
- **Design 19 is stale in its wire section from today** and says so in its own frontmatter and
opening until step 3 rewrites it. A specification that describes the bus being replaced is worse
than an absent one, because it reads as current.
- **`nats-broker` is not a seat.** The module is `nats`; the seat is `mesh-broker`.
- **Steps 1 to 4 leave every node on AMQP.** A step can be abandoned, or reordered after step 2,
without a rollback — the cost of being wrong is bounded until step 5.
- **What got harder:** five steps mean five proofs rather than one, and step 3 rewrites a design
other designs cite, so their references are checked when it lands. Dividing the work does not
reduce it.
## How it is checked
- **Per step, a bed, and the bed named in design 25 §10 against the step it belongs to.** Step 1:
a mesh raised on NATS from genesis. Step 2: the broker adopted into a mesh already running, and
a second holder of `mesh-broker` refused at resolution. Step 3: the conformance suite passing per
capability, on NATS, for every SDK that claims it — and a module built before the binding serving
its tools unchanged. Step 4: each converted flow proved against the behaviour it replaced. Step 5:
the cutover bed, then the rollout with every node reporting.
- **Step 3 is not done when the code runs.** It is done when the fixtures match byte for byte
across implementations, which is ADR 0074's own test and the only one that catches two SDKs
quietly ignoring each other.
- **A step that cannot name what its bed proves is not a step**, and is divided further before it
is started.
## References
- [ADR 0106](0106-the-bus-is-nats.md) — the decision this divides.
- [ADR 0074](0074-the-wire-is-specified-not-the-types.md) — the protocol and its conformance suite;
step 3 is its NATS binding, not a replacement.
- [ADR 0039](0039-what-the-sdk-holds-and-refuses.md) — why step 3 adds no helpers.
- [ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md) — why the seat is
`mesh-broker`.
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) — the adoption step 2 uses.
- [design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md), [design 19](../03-DESIGN/01-to-be/19-the-module-protocol.md) — the two documents this changes.
+1
View File
@@ -95,6 +95,7 @@ python3 00-META/checks/index.py fail if stale
- **0104** — [A provision may be answered by an adapter to the predecessor](0104-a-provision-may-be-answered-by-an-adapter-to-the-predecessor.md)
- **0105** — [The mesh adopts the predecessor's tunnel in place](0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md)
- **0106** — [The bus is NATS](0106-the-bus-is-nats.md)
- **0115** — [The bus is built in five steps, and the protocol moves with it](0115-the-bus-is-built-in-five-steps.md)
### Its tiers, from the bottom up
+24 -1
View File
@@ -5,9 +5,11 @@ code:
- mesh-sdk src
- mesh-tools src/broker-amqp.ts
- mesh-controller internal/link
updated: 2026-09-21
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/0115-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
@@ -23,6 +25,27 @@ language and nothing more ([ADR 0074](../../02-DECISIONS/0074-the-wire-is-specif
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 0115](../../02-DECISIONS/0115-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
+127 -31
View File
@@ -6,9 +6,14 @@ code:
- mesh-host internal/link (to be replaced)
- mesh-tools src/broker-amqp.ts (to be replaced)
- mesh-catalog modules/nats (to be written)
updated: 2026-09-24
- mesh-sdk src (the protocol's NATS binding, step 3)
updated: 2026-09-26
decisions:
- 02-DECISIONS/0106-the-bus-is-nats.md
- 02-DECISIONS/0115-the-bus-is-built-in-five-steps.md
- 02-DECISIONS/0074-the-wire-is-specified-not-the-types.md
- 02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md
- 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
- 02-DECISIONS/0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md
- 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md
- 02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md
@@ -221,39 +226,94 @@ bridged. It is three things:
Nothing is built of this before §10's bed passes; the MCP surface is a thin adapter over (2).
## 8. What a module sees
## 8. What a module sees, and what the wire does
Nothing new. `publish` on an envelope becomes a publish on `mesh.events.<module>.<event>`;
`subscribe` with a pattern becomes a durable JetStream consumer on the matching subject filter;
`request`/`handle` become a NATS request and a queue-group subscription on
`mesh.tools.<module>.<tool>`. The envelope's shape ([ADR 0042](../../02-DECISIONS/0042-the-shape-of-an-event-on-the-wire.md))
is unchanged; it is the message body. A module built today runs on the new runtime without a
rebuild — that is the test of ADR 0039, and it is in §10.
**The contract a module is written against does not change.** `publish` on an envelope becomes a
publish on `mesh.events.<module>.<event>`; `subscribe` with a pattern becomes a durable JetStream
consumer on the matching subject filter; `request`/`handle` become a NATS request and a queue-group
subscription on `mesh.tools.<module>.<tool>`. The envelope's shape
([ADR 0042](../../02-DECISIONS/0042-the-shape-of-an-event-on-the-wire.md)) is unchanged; it is the
message body. A module built today runs on the new runtime without a rebuild — that is the test of
ADR 0039, and it is in §10.
## 9. Moving from the bus the mesh has
**The wire underneath it changes completely, and that is a specification, not an implementation
detail.** Revision, second review ([ADR 0115](../../02-DECISIONS/0115-the-bus-is-built-in-five-steps.md)):
an earlier draft of this section said "nothing new" and stopped there, which read as though the
change were contained inside the runtime. It is not.
[ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md) settled that an SDK is an
implementation of a *specified wire*, checked by fixtures that must match byte for byte — because
two implementations that disagree about an envelope do not fail to compile, they ignore each other
while both keep running. That specification is
[design 19](19-the-module-protocol.md), and it is written in exchanges, a durable per-consumer queue
named `<node>.<module>.events`, and a shared `serve.<key>` queue. Every one of those is gone here.
Per ADR 0106: built beside, cut over once, after the core.
So design 19 is rewritten from exchanges and queues to the subjects and streams of §2 and §3, its
fixtures are recaptured on NATS, and each SDK re-claims the capabilities it passes. That is step 3
of §9, and until it lands design 19 says in its own opening that its wire section describes the bus
being replaced. What is *not* rewritten: ADR 0074's model — protocol split per capability, an SDK
that implements the floor and events alone is legitimate, conformance executable per capability —
and ADR 0039's refusal. A new transport is when the pressure to grow the shared library is highest;
nothing is added to it here.
1. The `nats` module, the controller's and host's link on NATS, the runtime's client — built and
proven in the lab (§10) while the migration continues on AMQP. Modules converted meanwhile
target the sdk contract and are untouched by this.
2. The cutover is one rollout, previewed: the controller assigns `nats` to the hub (raised beside
the AMQP broker on its own ports), composes every node's and module's account into it, then
rolls out the controller, every host and every runtime built for NATS. Each node's host connects
to the new bus as it comes up and reports; the controller confirms every node heard before it
stops listening on AMQP. The predecessor's clients never notice: their broker is the
compatibility module and stays.
3. The AMQP-side mesh accounts are removed from the compatibility broker; it keeps only the
predecessor's users. The bus's port settings follow ADR 0100 like any port.
4. The compatibility broker retires when its retirement condition holds.
## 9. Moving from the bus the mesh has: five steps
What is not done: no dual-bus period for the mesh's own traffic, no bridge, no module rebuilt.
Per ADR 0106 the bus moves once. Per
[ADR 0115](../../02-DECISIONS/0115-the-bus-is-built-in-five-steps.md) the *build* is five steps,
each ending at something §10 proves, so that no part of this waits on the whole of it. Dividing the
build does not divide the bus: steps 1 to 4 leave every node on AMQP, and step 5 is still one
rollout.
**Step 1 — genesis raises the broker.** The `nats` module of §5, and a mesh raised on it from
nothing. This is built and proven although the mesh it is for will never travel this path: genesis
is where the foundation is *defined*, the one place the mesh comes from nothing, and the definition
every other path is measured against. A genesis path that exists only on paper is one nobody finds
wrong until there is a second mesh. *Ends at: the genesis bed.*
**Step 2 — adoption puts the broker in its seat.** A mesh already running does not get a foundation
module by being raised again; it adopts one in place
([ADR 0100](../../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md)). The
server is raised beside the AMQP broker on its own ports, carrying no mesh traffic yet, and the
`nats` module is adopted onto it. The seat it claims is **`mesh-broker`**, unchanged — the
foundation seats are named after the server's role rather than the product
([ADR 0079](../../02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md)) for
exactly this case, and a seat named after the product would need renaming by every change the seat
exists to survive. *Ends at: the adoption bed.*
**Step 3 — the protocol gets its NATS binding.** §8's other half: design 19 rewritten from
exchanges and queues to subjects and streams, its conformance fixtures recaptured on NATS, each SDK
re-claiming the capabilities it passes. ADR 0074's model is unamended and ADR 0039's refusal holds —
no helper layer arrives with the new transport. A language may still arrive in pieces: connection
and events first, tools and provisioning when something needs them. *Ends at: the conformance suite,
per capability, per implementation.*
**Step 4 — the core speaks NATS.** The controller's link, the host's link, the tool runtime's
client — and with them the flows carried today by something other than the bus: a build source's
change reaching the builder, an installation, a module's own reports. Each is a conversion with a
named before and after. Observation — heartbeats, conditions, key-value state — belongs to
[research 017](../../01-RESEARCH/017-a-mesh-that-heals-itself/00-overview.md), which already
reserves it for after the move; this step does not pre-empt its design. Modules converted meanwhile
target the sdk contract and are untouched by any of it. *Ends at: each converted flow proved against
the behaviour it replaced.*
**Step 5 — the rollout.** Unchanged from ADR 0106 and previewed: the controller composes every
node's and module's account into the server standing since step 2, then rolls out the controller,
every host and every runtime built for NATS. Each node's host connects to the new bus as it comes up
and reports; the controller confirms every node heard before it stops listening on AMQP. The
predecessor's clients never notice — their broker is the compatibility module and stays. Then the
AMQP-side mesh accounts are removed from it, leaving only the predecessor's users, and it retires
when its retirement condition holds. The bus's port settings follow ADR 0100 like any port.
*Ends at: the cutover bed, then the rollout itself.*
What is not done, at any step: no dual-bus period for the mesh's own traffic, no bridge, no module
rebuilt.
## 10. How it is checked
Two lab beds, both required green before any node's bus moves.
**A bed per step, and each is green before the step after it starts** — the division in §9 is only
real if the proofs divide with it. A step that cannot name what its bed proves is not a step, and is
divided further before it is started.
**The bus bed** — a mesh raised on NATS from genesis:
**Step 1 — the genesis bed**, a mesh raised on NATS from nothing:
- a node enrols over TLS with a claimed token, and the enrolment user cannot read a declaration;
- a push composes; the store is stopped; the push is held (nak with delay), the store returns, the
push applies, nothing was lost or duplicated;
@@ -273,12 +333,40 @@ Two lab beds, both required green before any node's bus moves.
field a consumer's ack has already claimed;
- the `nats` container is not recreated when only its composed configuration file changes, and
a change to that file is live (a new user can connect, a revoked one cannot) within one
watcher-poll interval, without a restart;
- a module built before this design serves its tools unchanged on the new runtime.
watcher-poll interval, without a restart.
**The cutover bed** — a mesh on AMQP with a predecessor stand-in on the compatibility broker moves
its bus in one rollout; every node reports on NATS afterwards; the stand-in's client on AMQP is
still connected throughout.
**Step 2 — the adoption bed**, a mesh already running that has never had this server:
- the server is raised beside the AMQP broker on its own ports and the `nats` module is adopted
onto it in place, holding the data and the configuration it was raised with;
- the seat it claims is `mesh-broker`, and a second assignment of it anywhere in the mesh is
refused at resolution — *one per mesh*, as ADR 0079 requires;
- every node stays on AMQP throughout and nothing routes to the adopted server. This is the
check that makes steps 3 and 4 safe to run against a live mesh: adoption that quietly carried
traffic would be step 5 arriving early and unrehearsed.
**Step 3 — conformance, per capability, per implementation:**
- the fixtures of design 19, recaptured on NATS, produced and consumed byte for byte by every SDK
that claims the capability — the test ADR 0074 set, and the only one that catches two
implementations quietly ignoring each other;
- an SDK implementing connection and events alone passes those two and claims nothing more,
rather than failing as a whole;
- a module built before this design serves its tools unchanged on the new runtime — the test of
ADR 0039;
- the shared library gained nothing but the binding: its surface is the protocol and the
primitives, and a helper that arrived with the transport is a review failure, not a detail.
**Step 4 — each converted flow against the behaviour it replaced:**
- a build source's change reaches the builder over the bus, and the build that follows is the one
the change asked for;
- an installation completes over the bus, with the same outcome the path it replaces produced;
- a module's reports arrive, and a node that was unreachable catches up rather than losing them;
- nothing of research 017's observation work is present — no heartbeat semantics, no condition
store — because that design is not written yet and a flow built ahead of it would have to be
rebuilt.
**Step 5 — the cutover bed:** a mesh on AMQP with a predecessor stand-in on the compatibility
broker moves its bus in one rollout; every node reports on NATS afterwards; the stand-in's client
on AMQP is still connected throughout.
Unit tests hold the controller to composing accounts from `emits`/`consumes` and nothing else, to
creating the four streams and asserting them idempotently, and to spending a token exactly once;
@@ -287,7 +375,15 @@ to mapping the sdk contract onto subjects exactly as §8 says.
## 11. Open, for the review
**Closed by this revision** (first review, recorded in `MIGRATION-LOG.md`, 2026-09-24): the
**Closed by the second revision** (2026-09-26, [ADR 0115](../../02-DECISIONS/0115-the-bus-is-built-in-five-steps.md)):
the build was one undivided item (§9 is now five steps, each with its own bed in §10); there was no
adoption path for a mesh already running (§9 step 2); and §8 said a module sees "nothing new"
without distinguishing the sdk's contract, which does not change, from the specified wire, which
changes entirely and is design 19's (§8, §9 step 3). Two smaller corrections: the seat is
`mesh-broker` and not the product's name, and the shared library gains no conveniences with the new
transport.
**Closed by the first revision** (recorded in `MIGRATION-LOG.md`, 2026-09-24): the
`reload-on`/container mismatch (§5), the eaten reply subject on a CONTROL-stream message (§2, §6),
the missing ack permission (§4), and the un-scoped reply inbox under one account (§4). Each is
named where it was wrong, not silently fixed, so a reader comparing against the first version can