From 814c9e563f2ef15ec30ec5c7dcfb57d1bcf59591 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 19:26:07 +0200 Subject: [PATCH 01/44] The bus is the only broker; step 1 starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module does declare requirements the provisioner fulfils — but the broker it gets that way is a private vhost, the analog of a database, not the mesh's bus. Two modules of the new mesh depend on it, so the compatibility broker was never single-purpose and its retirement would have stranded them. NATS is the heart: one bus, a module's messaging is subjects on it scoped by what it declares, and no module is handed a server of its own. The seat delivers nothing; the interface retires with the broker. Also closes the EVENTS question — one stream, on the bootstrap argument, not preference. Designs 25 and 28 go in-progress: step 1 is starting. The insight check caught a false positive on its own first real use — its bold-run pattern crossed newlines and joined an unrelated `**` to the marker. Constrained to one line, still catching all four bad shapes. --- 00-META/checks/records.py | 7 +- 02-DECISIONS/0106-the-bus-is-nats.md | 13 ++ .../0117-the-bus-is-the-only-broker.md | 132 ++++++++++++++++++ 02-DECISIONS/README.md | 1 + 03-DESIGN/01-to-be/25-the-bus-on-nats.md | 25 +++- 03-DESIGN/01-to-be/28-building-the-bus.md | 8 +- 6 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 02-DECISIONS/0117-the-bus-is-the-only-broker.md diff --git a/00-META/checks/records.py b/00-META/checks/records.py index 0484dd8..ed6c186 100644 --- a/00-META/checks/records.py +++ b/00-META/checks/records.py @@ -299,8 +299,11 @@ def check_progressive_insights(failures, records): unmarked change stands out as the anomaly it is. """ phrase = re.compile(r"progressive insight", re.I) - marker = re.compile(r"\*\*Progressive insights?\s*[\u2014\u2013-]\s*(\d{4}-\d{2}-\d{2})\.?\*\*") - loose = re.compile(r"\*\*[^*]*[Pp]rogressive insights?[^*]*\*\*") + # Both patterns stay on one line: a bold run does not span paragraphs, and `[^*]*` across + # newlines will happily join an unrelated `**` far above to the marker below, reporting the + # whole span between them. It did exactly that the first time this ran. + marker = re.compile(r"\*\*Progressive insights?[ \t]*[\u2014\u2013-][ \t]*(\d{4}-\d{2}-\d{2})\.?\*\*") + loose = re.compile(r"\*\*[^*\n]*[Pp]rogressive insights?[^*\n]*\*\*") iso = re.compile(r"^\d{4}-\d{2}-\d{2}$") for number, record in sorted(records.items()): diff --git a/02-DECISIONS/0106-the-bus-is-nats.md b/02-DECISIONS/0106-the-bus-is-nats.md index 6da6c77..a12110d 100644 --- a/02-DECISIONS/0106-the-bus-is-nats.md +++ b/02-DECISIONS/0106-the-bus-is-nats.md @@ -75,6 +75,19 @@ after its deliveries are exhausted; a module's account cannot publish outside it subscribe outside its `consumes`. Then the cutover bed: a mesh on AMQP with the predecessor's compatibility broker beside it moves its bus in one rollout with every node reporting afterwards. +## Progressive insight + +> **Progressive insight — 2026-09-26.** *The compatibility broker was not single-purpose when this +> was written.* This record says the adopted AMQP broker is "kept as a module with one purpose — +> the predecessor's clients". Two modules of the new mesh also depended on it, through a `requires: +> ["amqp"]` grant its provisioner answered with a private vhost — `amqp-ping` and +> `amqp-email-forwarder`. On the retirement condition below, both would have been left requiring +> something no provider answers. +> [ADR 0117](0117-the-bus-is-the-only-broker.md) resolves it by moving them onto the bus and +> retiring the interface, which makes this record's sentence true rather than merely intended. The +> decision — the bus is NATS, the AMQP broker becomes the predecessor's compatibility broker and +> retires with the last of them — is unchanged. + ## References - [research 014](../01-RESEARCH/014-the-bus-on-nats/00-overview.md) diff --git a/02-DECISIONS/0117-the-bus-is-the-only-broker.md b/02-DECISIONS/0117-the-bus-is-the-only-broker.md new file mode 100644 index 0000000..65be793 --- /dev/null +++ b/02-DECISIONS/0117-the-bus-is-the-only-broker.md @@ -0,0 +1,132 @@ +--- +topic: the mesh +status: accepted +date: 2026-09-26 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0106-the-bus-is-nats.md +--- + +# 117. The bus is the only broker + +## Context + +[ADR 0106](0106-the-bus-is-nats.md) moved the mesh's bus to NATS and kept the AMQP broker "as a +module with one purpose — the predecessor's clients", retiring with the last of them. +[Design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) repeats that: a compatibility module with +a single purpose and a retirement condition. + +**It is not single-purpose, and was not when that was written.** Two modules of the *new* mesh +declare `requires: ["amqp"]` and are answered by the broker module's own provisioner: + +- `amqp-ping`, whose source says it "exists to PROVE the grant end to end: the mesh gave it a + scoped login and a vhost of that name on the lavinmq provider"; +- `amqp-email-forwarder`, which uses it for work. + +What that provisioner answers is **not the mesh's bus**. Its own comment draws the line: a +consumer gets "its own message broker, isolated from every other consumer's by the vhost +boundary… a broker of its own, not a shared account on the mesh's control-plane broker" — +vhost-per-login, "the exact analog of postgres's database-per-login." + +So two different things wear the word *broker*: the mesh's nervous system, and a private message +broker handed to a module as a resource, the way a database is. The first is being replaced. The +second was never examined, and on the retirement condition ADR 0106 sets, it disappears with no +successor and nothing notices — a module of the new mesh left requiring something no provider +answers. + +The operator's direction, asked at the point this surfaced: **NATS is the heart of the +application** — not a component it contains, and not a thing to reproduce the predecessor's +shapes on. + +## Considered Options + +1. **Carry the private broker forward onto NATS** — each requiring module gets its own NATS + account, provisioned like a database. Rejected on three counts. It reproduces the + predecessor's shape on the new bus, which is the thing this whole move exists to stop. It + gives the mesh two messaging models, so "how does a module send a message" has two answers + depending on a manifest line. And NATS accounts isolate subject spaces *entirely*: a module + inside its own account cannot reach the mesh's bus at all, so it would hold two connections + and two identities to do one job. +2. **Keep the compatibility broker indefinitely** for the mesh's own modules. Rejected: its + retirement condition is the point of it. A module of the new mesh depending on the retired one + keeps the predecessor alive permanently, which is the opposite of a compatibility module. +3. **One bus. A module's messaging is subjects on it, scoped by what it declares.** Adopted. + +## Decision + +**The bus is the only broker.** NATS is the mesh's one messaging system, and every module's +messaging is subjects on that bus under its own account, scoped by its `emits` and `consumes` +([ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md)). There is no second +broker, and none is handed to a module as a resource. + +**The `amqp` interface is not carried forward.** It leaves the set of things a module may require +and retires with the compatibility broker rather than gaining a successor. + +Concretely, in the controller's seat table: **the `mesh-broker` seat delivers nothing.** It +currently reads `Delivers: "amqp"` — the seat's holder answers a requirement for a broker — and +under this decision it joins `mesh-controller` and `the-catalogue`, the foundation seats that +deliver no provision at all. The bus is not something a module asks for; it is what a module is +reached through. + +- `amqp-email-forwarder` moves to the bus like any module: what it emits and consumes, declared, + and the account follows. +- `amqp-ping`'s *purpose* is kept and its mechanism is not. Proving end to end that a module + receives scoped messaging it did not configure itself is worth a probe; it becomes a probe of + the bus, and its assertion changes from "I reached my own vhost" to "I reached exactly my + subjects and was refused the rest." + +**A module that wants a queue of its own has one already**: a subject nothing else may publish to +and a durable consumer of its own, both derived from its declaration. What it does not get is a +server of its own. + +**The mesh's own streams are the controller's, created at genesis, not provisioned** — and +`EVENTS` is one stream, closing the question [design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) +§11 left open. The reason is not preference but **bootstrapping**: a provisioner is a module, and +a module needs a bus account before it can run at all. Anything the bus itself is made of must +exist before the first module starts, so it is composed as configuration +([ADR 0106](0106-the-bus-is-nats.md): never through a management API) rather than provisioned by +something that could not yet be running. + +## Consequences + +- **Design 25 gains the distinction and loses the "single purpose" claim**; its §11 question about + the `EVENTS` stream closes here. +- **Nothing in [design 27](../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md)'s + model changes** — the four provider kinds, the contract, resolution all stand, and it never + enumerated interfaces, so there is nothing to strike from it. What changes is that messaging + leaves the set of things resolved at all: every module has it by existing. +- **One line of the controller's seat table changes**, and it is the load-bearing one: + `mesh-broker` stops declaring what it delivers. A requirement for `amqp` then resolves to + nothing and is refused at assignment, which is how the two modules below are found rather than + discovered at runtime. +- **Two modules have conversion work**, and it belongs to step 4 of + [ADR 0116](0116-the-bus-is-built-in-five-steps.md), with the flows. Neither blocks step 1. +- **The compatibility broker becomes what ADR 0106 already called it** — single-purpose — once + those two have moved. That record's claim was wrong when written and is made true by this one. +- **What got harder:** a module that genuinely wanted an isolated server — a tenant boundary at + the broker rather than at the subject — no longer has that option, and would have to argue for + it as a new decision. That is the intended cost: one bus is the point. + +## How it is checked + +- **A module's messaging works with no `requires` line for it.** A lab bed: a module declaring + only `emits` and `consumes` reaches its subjects, and is refused every other — which is + [ADR 0116](0116-the-bus-is-built-in-five-steps.md) step 1's permission bed, already required. +- **Nothing requires `amqp`.** With the seat delivering nothing, a module still declaring it is + refused at resolution — the existing "requirement no provider answers" path, not a new check. A + catalogue test asserts no module declares it once the two have moved. +- **The probe proves the claim it is named for.** `amqp-ping`'s successor fails if a module can + reach a subject outside its declaration, not merely if it cannot reach its own. +- **The compatibility broker's retirement condition can actually be met.** A check that no module + of the mesh — as opposed to a predecessor client — holds a connection to it. + +## References + +- [ADR 0106](0106-the-bus-is-nats.md) — the bus is NATS; corrected here on what the compatibility + broker serves. +- [ADR 0116](0116-the-bus-is-built-in-five-steps.md) — the steps; the conversions land in step 4. +- [ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md) — the scoping that + makes one bus safe. +- [design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md), + [design 27](../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md) — the two documents + this changes. diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index 67eb0fe..8e1658b 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -133,6 +133,7 @@ python3 00-META/checks/index.py fail if stale - **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) - **0116** — [The bus is built in five steps, and the protocol moves with it](0116-the-bus-is-built-in-five-steps.md) +- **0117** — [The bus is the only broker](0117-the-bus-is-the-only-broker.md) ### Its tiers, from the bottom up diff --git a/03-DESIGN/01-to-be/25-the-bus-on-nats.md b/03-DESIGN/01-to-be/25-the-bus-on-nats.md index 6b92b3a..280c733 100644 --- a/03-DESIGN/01-to-be/25-the-bus-on-nats.md +++ b/03-DESIGN/01-to-be/25-the-bus-on-nats.md @@ -1,6 +1,6 @@ --- layer: to-be -status: proposed +status: in-progress code: - mesh-controller internal/link (to be replaced) - mesh-host internal/link (to be replaced) @@ -10,6 +10,7 @@ code: updated: 2026-09-26 decisions: - 02-DECISIONS/0106-the-bus-is-nats.md + - 02-DECISIONS/0117-the-bus-is-the-only-broker.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/0079-the-foundation-seats-are-named-after-their-servers.md @@ -188,8 +189,19 @@ current. Nothing is declared as `reload-on` or `restart-on` for this resource at Its guard is the same rule as the AMQP broker's: the monitoring port is refused from anything but the private network. It is raised at genesis like the store, adopted as a module in the same -phase. The predecessor's AMQP broker remains a module of its own, `lavinmq-compat`, with a single -purpose and a retirement condition: no client connected for a period the operator sets. +phase. The predecessor's AMQP broker remains a module of its own, `lavinmq-compat`, with a +retirement condition: no client connected for a period the operator sets. + +**And with one purpose, which it did not have when this was written.** Revision, second review +([ADR 0117](../../02-DECISIONS/0117-the-bus-is-the-only-broker.md)): two modules of the new mesh +required a broker *of their own* from it — a private vhost per consumer, the analog of +database-per-login, which is a different thing from the mesh's bus and was never examined here. +**There is one bus, and no module is handed a broker as a resource.** A module's messaging is +subjects on the bus under its own account, scoped by its `emits` and `consumes`; a module that +wants a queue of its own has a subject nothing else may publish to and a durable consumer, both +from its declaration. What it does not get is a server of its own. The `amqp` interface retires +with the compatibility broker instead of gaining a successor, and the two modules move to the bus +in step 4. ## 6. Joining: the enrolment handshake @@ -398,8 +410,11 @@ find what changed and why. **Still open:** -- Whether EVENTS should be one stream or one per emitting module (retention per module vs. one - policy). One stream is proposed; the review may disagree. +- ~~Whether EVENTS should be one stream or one per emitting module.~~ **Closed** + ([ADR 0117](../../02-DECISIONS/0117-the-bus-is-the-only-broker.md)): one stream, and not as a + preference — the streams the bus is made of are composed as configuration before any module + runs, because a provisioner is itself a module that needs a bus account to start. Bootstrapping + decides it. - The heartbeat interval and the controller's "quiet" threshold on core NATS without persistence — the same numbers as today are proposed. - Whether the person's client is a catalogue module (runs on an enrolled workstation node) or a diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index b0352f9..96501c5 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -1,11 +1,15 @@ --- layer: to-be -status: proposed -code: [] +status: in-progress +code: + - mesh-catalog modules/nats + - mesh-controller internal/catalogue + - mesh-lab scenarios updated: 2026-09-26 decisions: - 02-DECISIONS/0116-the-bus-is-built-in-five-steps.md - 02-DECISIONS/0106-the-bus-is-nats.md + - 02-DECISIONS/0117-the-bus-is-the-only-broker.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 From b5b68e8852aa46157600dc08efbffeee8b291399 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 19:34:54 +0200 Subject: [PATCH 02/44] Design 25: a host directory bind, not a named volume Issue 115 is resolved and converted four modules away from named volumes; the bus's own data is not the place to bring one back. Also: NATS carries TLS on the client port rather than beside a plaintext one, so there is no 5671/5672 pair to mirror. --- 03-DESIGN/01-to-be/25-the-bus-on-nats.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/03-DESIGN/01-to-be/25-the-bus-on-nats.md b/03-DESIGN/01-to-be/25-the-bus-on-nats.md index 280c733..84c5340 100644 --- a/03-DESIGN/01-to-be/25-the-bus-on-nats.md +++ b/03-DESIGN/01-to-be/25-the-bus-on-nats.md @@ -158,9 +158,14 @@ signing hierarchy for nothing. `nats` is a catalogue module claiming the seat `mesh-broker` ([ADR 0079](../../02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md): the seat -is the server, and the server changes). It declares one container (a single binary; JetStream on a -named volume), its listening ports — client, TLS, and the monitoring endpoint on loopback — and a -configuration file the controller composes (accounts, permissions, TLS, JetStream). +is the server, and the server changes). It declares one container (a single binary; **JetStream on a host +directory bind, not a named volume** — revision, second review: +[issue 115](../../04-ISSUES/115-a-named-docker-volume-is-invisible-and-one-flag-from-gone/00-report.md) +is resolved, and converted the store, the broker and two others away from named volumes for the +reason it names; the bus's own data is not the place to reintroduce one), its listening ports — +**the client port, which carries TLS itself** rather than standing beside a plaintext one as the +AMQP broker's 5671/5672 pair did, and the monitoring endpoint on loopback — and a configuration +file the controller composes (accounts, permissions, TLS, JetStream). **How that file's changes reach the running server, corrected on revision.** First review: the earlier draft named `reload-on` as the mechanism, citing the container runtime's own trust file as From 7b4916e9ecc2e7a0113788e01fe09836d19dc608 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 20:34:32 +0200 Subject: [PATCH 03/44] Modules declare their own seats; the mesh reserves mesh-* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The architecture 0117 opened needs a module to offer a service as a role on the bus — one holder, addressed by what it does. A closed table in the controller cannot express that: a capability a module contributes would require changing the mesh itself. But 0110 closed the set for a good reason — nothing could say what seats a mesh had, and the hand count came out at eleven of thirteen. That argues for enumerable, not hardcoded, and 0110 weighed free-form against a fixed table without considering a third option: closed at any moment and derived from the catalogue. A derived list cannot drift, which is how the count broke. So: the mesh's seats stay the mesh's, reserved by the mesh- prefix so the prefix is the rule and there is no list to maintain; ten seats are renamed to restore 0079's convention; everything 0110 decided about what a seat IS survives untouched. Design 29 carries the declaration model: three namespaces, subjects derived from local names so a manifest survives the wire changing, queues never declared, five relationships (the job and state shapes 0041 had no room for), and the build-publish-deploy lifecycle with hard, soft and build-time dependencies distinguished. 0041 gets a progressive insight: "no per-consumer setup, only a subscription" was a fact about a topic exchange, and a JetStream durable consumer is a real object someone creates. WBS 1.3/1.4 were wrong and say so: streams come at registration and consumers at assignment, so only the foundation set belongs at genesis. --- 00-META/checks/records.py | 10 +- 00-META/glossary.md | 2 +- .../0041-events-are-a-relationship.md | 16 ++ ...s-a-module-assignment-from-a-closed-set.md | 3 +- .../0118-a-module-declares-its-own-seats.md | 130 +++++++++++ 02-DECISIONS/README.md | 3 +- 03-DESIGN/01-to-be/23-choosing-a-provider.md | 4 +- 03-DESIGN/01-to-be/26-the-seats.md | 79 ++++--- .../27-a-module-requires-the-mesh-resolves.md | 6 +- 03-DESIGN/01-to-be/28-building-the-bus.md | 32 ++- .../01-to-be/29-what-a-module-declares.md | 215 ++++++++++++++++++ 03-DESIGN/01-to-be/README.md | 6 +- 12 files changed, 461 insertions(+), 45 deletions(-) create mode 100644 02-DECISIONS/0118-a-module-declares-its-own-seats.md create mode 100644 03-DESIGN/01-to-be/29-what-a-module-declares.md diff --git a/00-META/checks/records.py b/00-META/checks/records.py index ed6c186..dd1f5c1 100644 --- a/00-META/checks/records.py +++ b/00-META/checks/records.py @@ -331,7 +331,15 @@ def check_progressive_insights(failures, records): if any(s <= m.start() and m.end() <= e for s, e in covered): continue line = text.rfind("\n", 0, m.start()) + 1 - if text[line:m.start()].lstrip().startswith("#"): + end = text.find("\n", m.end()) + whole = text[line:end if end != -1 else len(text)] + if whole.lstrip().startswith("#"): + continue + # A line that also carries a link is discussing the rule, not marking a correction: + # a marker never needs to cite anything, and a record that reasons about the policy + # must be able to name it. Bare prose with no citation is the informal marking this + # is here to catch. + if "](" in whole: continue if loose.search(text, line, text.find("\n", m.end()) + 1 or len(text)): continue diff --git a/00-META/glossary.md b/00-META/glossary.md index 4ffe66e..d2a2958 100644 --- a/00-META/glossary.md +++ b/00-META/glossary.md @@ -47,7 +47,7 @@ another — and a mesh you cannot name precisely is a mesh two people describe d - **seat** — a named role at a scope (node / site / mesh), held by a module assignment, from a **closed set** the mesh defines: a claim naming a seat outside the set is refused. A seat may **deliver a provision**, and its holder is then the mesh's answer for it when several modules - provide it ([ADR 0110](../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)). + provide it ([ADR 0118](../02-DECISIONS/0118-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md))). The set, with who holds each seat, is the overview of what a mesh has ([26 — The seats](../03-DESIGN/01-to-be/26-the-seats.md)). A seat has a **capacity**: a capacity-1 seat is exclusive (one holder); a higher-capacity seat is a **bench** (several holders diff --git a/02-DECISIONS/0041-events-are-a-relationship.md b/02-DECISIONS/0041-events-are-a-relationship.md index a381307..cd38e97 100644 --- a/02-DECISIONS/0041-events-are-a-relationship.md +++ b/02-DECISIONS/0041-events-are-a-relationship.md @@ -76,6 +76,22 @@ three relationships, one broker, one runtime, all declared on the manifest. - The runtime must dispatch a module's event handlers as well as its tools; that generalisation is small (both arrive by importing the module's entrypoint) but it is real work. +## Progressive insight + +> **Progressive insight — 2026-09-26.** *"No provisioner and no per-consumer setup" was a fact +> about the transport, and the transport changed.* This record's table says an event's machinery is +> "nothing but the broker's topic routing", and the text that an event needs "no per-consumer setup +> — only a subscription". That was true of a topic exchange, where a binding cost nothing and the +> broker fanned out. On NATS +> ([ADR 0106](0106-the-bus-is-nats.md)) a subscription is a **durable consumer**: a real object +> with a name, an ack policy, a delivery limit and its own ack subject, created when a module is +> assigned and removed when it is not. Per-consumer setup exists, and the controller does it. +> +> The decision is untouched — events are declared on both sides, 1:many, credential-free, and +> still provisioning's lighter sibling; the lightness is now relative rather than absolute. +> [ADR 0118](0118-a-module-declares-its-own-seats.md) adds the relationship this record's two +> columns had no room for: work addressed to a role, where exactly one holder must act. + ## References - [ADR 0002](0002-nodes-communicate-over-a-broker.md) — the broker events ride. diff --git a/02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md b/02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md index ec85827..432be02 100644 --- a/02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md +++ b/02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md @@ -1,6 +1,7 @@ --- topic: what runs on it -status: accepted +status: superseded +superseded-by: 02-DECISIONS/0118-a-module-declares-its-own-seats.md date: 2026-09-25 deciders: jochen reconstructed: false diff --git a/02-DECISIONS/0118-a-module-declares-its-own-seats.md b/02-DECISIONS/0118-a-module-declares-its-own-seats.md new file mode 100644 index 0000000..6b9fe73 --- /dev/null +++ b/02-DECISIONS/0118-a-module-declares-its-own-seats.md @@ -0,0 +1,130 @@ +--- +topic: the tiers +status: accepted +date: 2026-09-26 +deciders: jochen +reconstructed: false +supersedes: 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md +--- + +# 118. A module declares its own seats; the mesh reserves its own + +## Context + +[ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) closed the set of seats. Its +evidence was strong and still is: nothing could answer *which seats does this mesh have, and who +holds each*. Answering it meant reading every manifest in two repositories and then the +controller's own code, and when that enumeration was done by hand while writing the record, **it +reported eleven claims where there were thirteen.** The fix was a table in the controller, and +adding a seat became a decision. + +What that table cannot express is the architecture [ADR 0117](0117-the-bus-is-the-only-broker.md) +opened. With one bus and no private brokers, a module offering a service to other modules offers +it as **a role on the bus**: a set of subjects, exactly one holder, addressed by what it does +rather than by which module or node provides it. A telegram sender, a licensing master, anything +a mesh might want one of. Under a closed table, adding any of those means editing the controller +— so a capability contributed by a module would require a change to the mesh itself, which is the +coupling the module system exists to prevent. + +**The two requirements look opposed and are not.** 0110 needs the set *enumerable*. The +architecture needs it *extensible*. Those conflict only if enumerable means *written down in one +place by hand* — which is exactly the property that let the count drift in the first place. + +## Considered Options + +1. **Keep the closed table, add each new seat by decision.** Rejected. Every capability a module + contributes would need a change to the controller and a record before it could be offered, and + the mesh would carry the names of services it does not itself implement. +2. **Free-form seats, as before 0110.** Rejected for 0110's own reason, unchanged: nothing can + say what a mesh has, and a name invented at a claim site is a name nobody can explain later. +3. **A set that is closed at any moment and derived rather than maintained**, with the mesh's own + seats reserved by name. Adopted. 0110 weighed options 1 and 2 and never considered this one. + +## Decision + +**A seat may be declared by a module, and the set of seats a mesh has is derived: the mesh's own, +plus those declared by every module it has registered.** The set is still closed — a seat named +nowhere is refused — but it is computed from the catalogue rather than written in the controller. + +Everything 0110 decided about what a seat *is* stands untouched: one holder at its scope; a +definition says which seats a module *can* hold and an assignment says which it *does*; holding +one may deliver a provision; a seat makes a role singular, never a module. + +**Enumeration is a query, not an inventory.** The catalogue knows every registered manifest, so +"which seats does this mesh have, and who holds each" is answered by asking it. This is a +stronger answer than the table gave, not a weaker one: a derived list cannot drift from reality, +and drift is how the hand-made count came out at eleven of thirteen. + +**The mesh's own seats are reserved by prefix.** Every seat the mesh itself defines is named +`mesh-*`, and a module declaring any `mesh-*` name is refused at registration. The prefix *is* +the reservation rule — no list of reserved names to maintain, and no way for the mesh's own +namespace to be colonised by a manifest. This requires renaming the seats that drifted from +[ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md)'s convention: `the-catalogue` +becomes `mesh-catalog`, `git` becomes `mesh-git`, and the node-scoped `the-build-machine`, +`the-dns-port`, `the-intrusion-prevention`, `the-packet-filter`, `the-private-network`, +`the-resolver-configuration`, `the-showcase` take the same prefix. + +The mesh's seats stay the mesh's for a reason that does not apply to a module's: **the mesh's own +code looks them up by name.** The resolver *is* the thing that finds the store. `mesh-store` is +not a convention the controller follows, it is an identifier the controller dereferences. + +**A declared seat carries a protocol.** A module declaring a seat says what may be sent to it, +what it emits, and what it serves. The holder must satisfy it; a module may not claim a seat whose +protocol it does not implement. Callers declare that they use the *seat*, never the module, so +replacing the implementation changes nothing for any caller. + +**A seat is for a role; an event stays addressed to its emitter.** The two are not +interchangeable and the choice is not stylistic. An event is *this happened to me* — the emitter's +identity is the meaning, which is why the envelope carries source, node and time +([ADR 0042](0042-the-shape-of-an-event-on-the-wire.md)); routing it through a role would erase the +provenance an audit needs. A seat is *this capability, whoever provides it* — where not knowing +the holder is the point. Publish an event when the fact is about you; declare a seat when you are +offering something another module could offer instead. + +**Two modules declaring the same seat name is refused at registration**, second one loses. +Registration is the last moment the mesh can still say no, and a seat name meaning two different +protocols is the failure nobody could diagnose afterwards. + +## Consequences + +- **The controller's seat table stops being the set** and becomes the mesh's own reserved entries. + Resolution reads the catalogue for the rest. +- **Ten seats are renamed.** A rename is a migration, not an edit: existing assignments hold the + old names, so the change carries a mapping and is applied once, and the lab beds that name seats + are updated with it. +- **A `uses` naming an undeclared seat is refused at registration**, which is where 0110's + guarantee lands under this model — the same refusal, at the same moment, from a derived set. +- **Adding a capability stops requiring a decision record.** That is a real loss of governance and + the intended trade: the argument for a seat's existence moves into the module that declares it, + where it is reviewed as part of the manifest. The mesh's own seats keep the old bar. +- **[ADR 0041](0041-events-are-a-relationship.md)'s machinery claim is already stale** for a + different reason, and is corrected in place there under the rule in + [`README.md`](README.md) — a progressive insight: on JetStream a subscription is a durable + consumer, a real object someone must create. +- **What got harder:** a seat's protocol is now a compatibility surface between modules that do + not know each other. Changing one breaks callers already bound to it, and nothing here says how + that is versioned. It is the first thing to answer in the design, and the thing most likely to + hurt later rather than now. + +## How it is checked + +- **The overview answers, and is right.** A command lists every seat, its scope, its protocol and + its holder, derived from the catalogue — and a test asserts the count against a fixture mesh, + because an enumeration nobody checks is how thirteen became eleven. +- **`mesh-*` is refused to a module.** A registration test: a manifest declaring `mesh-anything` + is refused, naming the prefix as the reason. +- **An undeclared seat is refused.** A registration test on `uses`, and a resolution test that + nothing reaches runtime unresolved. +- **A second declarer loses.** A registration test: two manifests, same seat name, the second + refused and the first untouched. +- **A holder must satisfy the protocol.** A claim whose module does not serve what the seat + declares is refused at assignment, not discovered when a caller times out. + +## References + +- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) — superseded here; its + requirement is kept and only its mechanism replaced. +- [ADR 0117](0117-the-bus-is-the-only-broker.md) — one bus, which is what makes a role addressable. +- [ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md) — the naming convention + the reserved prefix restores. +- [ADR 0041](0041-events-are-a-relationship.md) — the event half of the boundary drawn here. diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index 8e1658b..fcbe797 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -164,6 +164,7 @@ python3 00-META/checks/index.py fail if stale - **0098** — [A fact a provider makes at first start is fetched from it, not carried in its manifest](0098-a-fact-a-provider-makes-at-first-start-is-fetched-from-it.md) - **0108** — [A route carries the policy applied to a request, and names a secret rather than holding one](0108-a-route-carries-the-policy-applied-to-a-request.md) - **0109** — [A package registry seat is one per ecosystem, not one for all of them](0109-a-package-registry-seat-is-one-per-ecosystem.md) +- **0118** — [A module declares its own seats; the mesh reserves its own](0118-a-module-declares-its-own-seats.md) ### What runs on them, and how it gets there @@ -195,7 +196,7 @@ python3 00-META/checks/index.py fail if stale - **0087** — [A seeded file is created once, and what grows in it is not the mesh's](0087-a-seeded-file-is-created-once.md) - **0091** — [A mount is declared, and there are three things it can be](0091-a-mount-is-declared-three-ways.md) - **0099** — [A step that runs once names what it reads, and runs again when it changed](0099-a-step-that-runs-once-names-what-it-reads.md) -- **0110** — [A seat is held by one assignment, from a closed set, and it may deliver a provision](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) +- **0110** — [A seat is held by one assignment, from a closed set, and it may deliver a provision](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) *(superseded)* - **0112** — [A module definition names no node, no mesh and no path: everything it needs is a requirement the mesh resolves](0112-a-module-definition-names-no-node-mesh-or-path.md) *(proposed)* - **0113** — [The vault makes every shared secret, a provider makes resources and data, and the mesh carries both](0113-the-vault-makes-every-secret.md) *(proposed)* - **0114** — [A credential two parties hold rotates over two credentials; one a single party holds rotates in place, staged; and retiring a credential never removes what it reached](0114-a-shared-credential-rotates-over-two-credentials.md) *(proposed)* diff --git a/03-DESIGN/01-to-be/23-choosing-a-provider.md b/03-DESIGN/01-to-be/23-choosing-a-provider.md index e3f36c0..b2dc979 100644 --- a/03-DESIGN/01-to-be/23-choosing-a-provider.md +++ b/03-DESIGN/01-to-be/23-choosing-a-provider.md @@ -6,7 +6,7 @@ updated: 2026-09-25 decisions: - 02-DECISIONS/0084-which-provider-serves-a-consumer.md - 02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md - - 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md + - 02-DECISIONS/0118-a-module-declares-its-own-seats.md --- # 23 — Choosing a provider @@ -54,7 +54,7 @@ to that provider and not to whichever one is nearest. **A seat names the mesh's one provider of a kind.** Where a seat delivers the provision, its holder answers for it when several providers exist and the consumer named none. That is not picking: the choice was made once, mesh-wide, by assigning the holder, rather than once per consumer by naming it -([ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md), +([ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)), [26 — The seats](26-the-seats.md)). A named provider still wins over the seat, because a consumer coupled to particular contents has said so. diff --git a/03-DESIGN/01-to-be/26-the-seats.md b/03-DESIGN/01-to-be/26-the-seats.md index 65dc1b9..c64e07b 100644 --- a/03-DESIGN/01-to-be/26-the-seats.md +++ b/03-DESIGN/01-to-be/26-the-seats.md @@ -10,7 +10,7 @@ code: - mesh-catalog modules/gitea/module.json updated: 2026-09-26 decisions: - - 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md + - 02-DECISIONS/0118-a-module-declares-its-own-seats.md - 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md - 02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md --- @@ -47,27 +47,42 @@ nobody argued for is an entry nobody can explain. ## The set -| seat | scope | delivers | typically held by | -|---|---|---|---| -| `mesh-controller` | mesh | — | the controller | -| `mesh-store` | mesh | — | the store the mesh's own records live in | -| `mesh-broker` | mesh | — | the broker carrying the mesh's own bus | -| `mesh-vault` | mesh | `secret`, reserved | the vault | -| `the-artifact-store` | mesh | `artifact-store` | the artifact registry | -| `the-catalogue` | mesh | — | the catalogue | -| `npm-package-registry` | mesh | `npm-package-registry` | the forge | -| `git` | mesh | `git` | the forge | -| `the-build-machine` | node | — | a builder | -| `the-dns-port` | node | — | the local resolver | -| `the-intrusion-prevention` | node | — | an intrusion-prevention service | -| `the-packet-filter` | node | — | the packet filter | -| `the-private-network` | node | — | the private network the mesh runs over | -| `the-resolver-configuration` | node | — | whichever of the alternative resolver configurations is chosen | -| `the-showcase` | node | — | the showcase module | +**The set is derived, and only the mesh's half is written here.** Revision, 2026-09-26 +([ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md), superseding +[ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)): a module +declares its own seats with their protocols, so the seats a mesh has are the mesh's own **plus +every registered module's**. The set is still closed — a seat named nowhere is refused — but it is +computed from the catalogue rather than maintained by hand, which is the property 0110 actually +needed and the table could not keep. -The controller holds this set in code, and a test asserts both its size and that every entry names -the record that made it a seat. **This table and [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md) -govern, and code that disagrees is what is wrong.** The implementation in progress predates several +**Every seat below is named `mesh-*`, and the prefix is the reservation rule**: a module declaring +any `mesh-*` name is refused at registration, so there is no reserved-names list to drift. Ten of +these are renamed to restore [ADR 0079](../../02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md)'s +convention, which later seats departed from. + +| seat | was | scope | delivers | typically held by | +|---|---|---|---| +| `mesh-controller` | — | mesh | — | the controller | +| `mesh-store` | — | mesh | — | the store the mesh's own records live in | +| `mesh-broker` | — | mesh | — | the broker carrying the mesh's own bus | +| `mesh-vault` | — | mesh | `secret`, reserved | the vault | +| `mesh-artifact-store` | `the-artifact-store` | mesh | `artifact-store` | the artifact registry | +| `mesh-catalog` | `the-catalogue` | mesh | — | the catalogue | +| `mesh-npm-package-registry` | `npm-package-registry` | mesh | `npm-package-registry` | the forge | +| `mesh-git` | `git` | mesh | `git` | the forge | +| `mesh-build-machine` | `the-build-machine` | node | — | a builder | +| `mesh-dns-port` | `the-dns-port` | node | — | the local resolver | +| `mesh-intrusion-prevention` | `the-intrusion-prevention` | node | — | an intrusion-prevention service | +| `mesh-packet-filter` | `the-packet-filter` | node | — | the packet filter | +| `mesh-private-network` | `the-private-network` | node | — | the private network the mesh runs over | +| `mesh-resolver-configuration` | `the-resolver-configuration` | node | — | whichever of the alternative resolver configurations is chosen | +| `mesh-showcase` | `the-showcase` | node | — | the showcase module | + +The controller holds **the mesh's own** entries in code, and a test asserts their size and that +every one names the record that made it a seat. A module's seats are not here and never will be — +they are read from the catalogue. **This table and +[ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md) govern, and code that +disagrees is what is wrong.** The implementation in progress predates several things here: seats held by assignments rather than claimed by definitions, the `mesh-vault` seat and its reservation, and the foundation's seats delivering nothing. It is brought to this table before it merges. @@ -155,16 +170,22 @@ still to take. ## How it is checked -The rules here are [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)'s -and [ADR 0111](../../02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md)'s, and each is +The rules here are [ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md)'s — +which supersedes [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md) +and keeps every rule below except how the set is formed — and +[ADR 0111](../../02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md)'s. Each is checked as their tables say: | Rule | Checked by | |---|---| -| The set is closed, and every entry names its decision | 0110: a unit test on the set's size and decisions; manifest tests refusing an unknown seat or the wrong scope. | -| A seat is held by one assignment, and only by one whose module can hold it | 0110: resolution tests for a second holder and for a seat the definition does not name. | -| A requirement naming a seat is answered by its holder; a foundation seat cannot be named | 0110: resolution tests with a second provider on the consumer's node, with the seat unheld, and naming `mesh-store`. | -| Several providers and none local is a person's choice | 0110: an assignment test listing candidates with the seat's holder first and recording the pin. | -| `secret` is reserved | 0110: the parser and resolution refusals for another provider and a pin. | -| Holdings are derived, and the overview lists every seat | 0110: the `seats` command test, including an unheld seat. | +| The set is closed, and every mesh entry names its decision | 0118: a unit test on the mesh's own entries; manifest tests refusing an unknown seat or the wrong scope. | +| The set is derived, and enumerating it is a query | 0118: the overview lists the mesh's own plus every registered module's, asserted against a fixture mesh. | +| `mesh-*` is the mesh's, and a module may not declare one | 0118: a registration test refusing a manifest that declares any `mesh-*` seat, naming the prefix. | +| Two modules cannot declare the same seat | 0118: a registration test; the second is refused and the first untouched. | +| A holder satisfies the seat's protocol | 0118: a claim whose module does not serve what the seat declares is refused at assignment. | +| A seat is held by one assignment, and only by one whose module can hold it | 0118: resolution tests for a second holder and for a seat the definition does not name. | +| A requirement naming a seat is answered by its holder; a foundation seat cannot be named | 0118: resolution tests with a second provider on the consumer's node, with the seat unheld, and naming `mesh-store`. | +| Several providers and none local is a person's choice | 0118: an assignment test listing candidates with the seat's holder first and recording the pin. | +| `secret` is reserved | 0118: the parser and resolution refusals for another provider and a pin. | +| Holdings are derived, and the overview lists every seat | 0118: the `seats` command test, including an unheld seat. | | A build source on the seat records no address; an unheld seat refuses only self-hosted builds | 0111's tests. | diff --git a/03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md b/03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md index 9603130..7ed81ba 100644 --- a/03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md +++ b/03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md @@ -8,7 +8,7 @@ decisions: - 02-DECISIONS/0115-one-assignment-of-a-module-per-node.md - 02-DECISIONS/0113-the-vault-makes-every-secret.md - 02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md - - 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md + - 02-DECISIONS/0118-a-module-declares-its-own-seats.md - 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md - 02-DECISIONS/0084-which-provider-serves-a-consumer.md - 02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md @@ -64,7 +64,7 @@ Which module answers, in order: 1. **the holder of the seat the requirement names.** A requirement may name a seat instead of leaving the provider open. It asks for *the mesh's* one, and the mesh answers with whichever assignment holds that seat, with nothing asked of anyone. Unheld, the requirement is refused, naming the seat - ([ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md), + ([ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)), [26 — The seats](26-the-seats.md)). Only a seat that delivers a provision can be named; naming a foundation seat is refused, because it delivers nothing. A `secret` requirement always names `mesh-vault`, because that provision is reserved; @@ -199,7 +199,7 @@ containers, login, broker account and settings are keyed by it, as today, and a tightest backend ([ADR 0049](../../02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md)). **A module may run on many nodes, and one assignment may hold a seat** -([ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)). The definition +([ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md))). The definition says which seats the module can hold; the assignment says which it does. So the store module can run on every node, one of those assignments holds `mesh-store`, and moving that role changes an assignment, not a definition. diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 96501c5..78f1924 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -10,6 +10,7 @@ decisions: - 02-DECISIONS/0116-the-bus-is-built-in-five-steps.md - 02-DECISIONS/0106-the-bus-is-nats.md - 02-DECISIONS/0117-the-bus-is-the-only-broker.md + - 02-DECISIONS/0118-a-module-declares-its-own-seats.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 @@ -115,6 +116,15 @@ step 5 the rollout ## Step 1 — the module, and genesis raises it +> **Revised 2026-09-26** ([ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md), +> [design 29](29-what-a-module-declares.md)). Tasks 1.3 and 1.4 said the controller composes every +> account and creates *the four streams* at genesis, from a fixed set. That is only the mesh's own +> half. A module declares seats with their protocols, so streams are created **at registration** +> and durable consumers **at assignment** — neither of which has happened at genesis. The fixed +> foundation set stays here; the derived machinery moves to step 3, where the declaration model it +> reads from is specified. Tasks 1.1 and 1.2, already done, are untouched by this: the module and +> its reload mechanism do not care what the configuration says. + **Why here.** Everything else needs a server to talk to, and genesis is where the foundation is defined. The mesh this is for will never travel this path — it is already running, and takes step 2 — but genesis is the definition every other path is measured against, and one that exists only on @@ -126,11 +136,14 @@ paper is wrong until there is a second mesh to find out. - [ ] 1.2 the composed configuration as a **directory** resource, and the entrypoint that watches the one file and signals the server itself — design 25 §5's correction, kept inside the module because a container has no reload and a recreate would drop every connection the mesh has -- [ ] 1.3 the controller composes that file: accounts, permissions, TLS, JetStream — permissions - derived from `emits` and `consumes` and nothing else, plus each user's own ack subject and its - own inbox prefix (design 25 §4) -- [ ] 1.4 the four streams, created at genesis and asserted idempotently on start, by the controller - as their only writer +- [ ] 1.3 the controller composes that file: accounts, permissions, TLS, JetStream — a user's + permissions derived from its declaration and nothing else, over the three namespaces of + [design 29](29-what-a-module-declares.md) §2, plus its own ack subject and its own inbox + prefix (design 25 §4) +- [ ] 1.4 the mesh's own streams, created at genesis and asserted idempotently on start, by the + controller as their only writer — **the mesh's own, not all of them**: a seat's streams are + created when the module declaring it is registered, and a module's durable consumers when it + is assigned, so this task is the fixed foundation set and 3.x carries the derived rest - [ ] 1.5 genesis raises it as foundation, claiming the seat **`mesh-broker`** — the seat is the server's role, not the product - [ ] 1.6 the genesis-broker bed @@ -183,6 +196,15 @@ pays for itself furthest away. - [ ] 3.5 the host's link on NATS — mirroring, still importing nothing - [ ] 3.6 the tool runtime's client on NATS, behind the unchanged sdk contract - [ ] 3.7 the sdk's three stale comments, and nothing else in it +- [ ] 3.8 **the declaration model** of [design 29](29-what-a-module-declares.md): local names + derived to subjects, the three namespaces, permissions computed from a declaration, and a + manifest that contains no subject +- [ ] 3.9 **seats declared by modules** — registration creates a seat's streams and refuses a + `mesh-*` name, a duplicate declarer, an undeclared `uses`, and a holder that does not + satisfy the protocol; assignment creates the holder's work-queue consumer and refuses a + second holder +- [ ] 3.10 **the ten seat renames**, carried as a migration with a mapping rather than an edit, + and the beds that name seats moved with them **Done when.** The fixtures are produced and consumed byte for byte by every implementation that claims the capability, and a module built before any of this serves its tools unchanged on the new diff --git a/03-DESIGN/01-to-be/29-what-a-module-declares.md b/03-DESIGN/01-to-be/29-what-a-module-declares.md new file mode 100644 index 0000000..dfc6898 --- /dev/null +++ b/03-DESIGN/01-to-be/29-what-a-module-declares.md @@ -0,0 +1,215 @@ +--- +layer: to-be +status: proposed +code: [] +updated: 2026-09-26 +decisions: + - 02-DECISIONS/0118-a-module-declares-its-own-seats.md + - 02-DECISIONS/0117-the-bus-is-the-only-broker.md + - 02-DECISIONS/0106-the-bus-is-nats.md + - 02-DECISIONS/0041-events-are-a-relationship.md + - 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md + - 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md +--- + +# 29. What a module declares, and what the bus makes of it + +**The bus is ambient.** No module requires it, the way no module requires a filesystem. Every +module gets a connection and an identity whether it asks or not. What a module declares are +*relationships*; subjects, streams, consumers and permissions are all derived from those, and a +manifest never contains one. + +This document is the declaration model. [Design 25](25-the-bus-on-nats.md) is the bus itself — +subjects, streams, accounts, enrolment — and stays the authority on the wire. +[Design 19](19-the-module-protocol.md) is the specification an SDK implements, and is rewritten +onto this in step 3 of [ADR 0116](../../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md). + +## 1. A module names locally; the mesh derives the subject + +This is the load-bearing rule. +[ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) says a module +definition names no node, mesh or path. A transport address is the same class of thing: if +manifests held literal subjects, reorganising the subject space would mean editing every module in +the catalogue, and the mesh would have hundreds of copies of a decision it made once. + +| declared | derived | +|---|---| +| `emits: order.placed` | publish on `mesh.mod..order.placed` | +| `consumes: billing.order.placed` | durable consumer on `mesh.mod.billing.order.placed` | +| `serves: status` | queue-group subscription on `mesh.mod..tool.status` | +| seat `telegram-sender`, `accepts: send` | work-queue consumer on `mesh.seat.telegram-sender.send` | +| `uses: telegram-sender` | publish on that seat's `accepts` subjects, and nothing else | + +**The test this must pass: the manifest survives the wire changing.** Reorganise the subject space +and every manifest in the catalogue is still correct. That is the property +[ADR 0039](../../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md) gave the sdk, applied to +declarations. + +## 2. Three namespaces, and nothing else + +**Its own** — `mesh.mod..>`. Its events and its tools. Nothing else may publish into it, +so an event's source is a fact the bus enforces rather than a claim in the body. + +**Seats it holds** — `mesh.seat..>`. Full participation: consume what the seat accepts, +publish what it emits, serve what it serves. + +**Seats it uses** — publish only, and only on the `accepts` half. A sender cannot subscribe to a +seat's inbound subject and watch other modules' traffic, and cannot publish the seat's outbound +events and lie about outcomes. + +A module naming anything outside these three is refused at registration. The whole permission set +is derivable from the declaration; nobody writes an access rule. + +## 3. Queues are derived, never declared + +A module says what it reacts to, not how delivery works. Each `consumes` becomes one durable +consumer; a seat's `accepts` becomes one work-queue consumer with a queue group named for the +seat. The module does not name them, does not know their names, and cannot misconfigure them — +and the controller stays the only writer of stream and consumer definitions +([design 25](25-the-bus-on-nats.md) §3). + +**Retention belongs to whoever owns the namespace, not to a consumer.** A seat declares how long +its inbound backlog survives, because that is a property of the service: + +``` +seat: telegram-sender + scope: mesh + accepts: send retain 7d + emits: delivered, failed + serves: status +``` + +If each consumer could tune it, the mesh's durability would be an emergent property of whichever +manifest was edited last. + +**Scope gives per-node workers without a new concept.** A module running on three nodes that each +need their own queue declares a node-scoped seat: one holder per node, three queues, same +machinery. Mesh-scoped and node-scoped seats already exist; here they do the work of "one shared +service" versus "one worker per machine". + +## 4. Five relationships + +| | provision | event | job | state | tool | +|---|---|---|---|---|---| +| shape | 1:1 resource | 1:many | N:1 | 1:1 | 1:1 | +| addressed to | a provider | the emitter's own namespace | a **seat** | one node | a module or seat | +| who must act | the provider | nobody | exactly one holder | that node | the server | +| credential | sealed, per consumer | none | none | none | none | +| reply | — | none | none, or an event later | a report | awaited | +| retention | — | age and size | work queue, explicit ack | **last per subject** | none | +| declared | `provides`/`requires` | `emits`/`consumes` | seat `accepts` / `uses` | the mesh's own | `serves` | + +**Job** is the one [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) had no room +for. Its table has events at 1:many and provisions at 1:1; a module submitting work to a service +is neither. It is not an event, because an event is a broadcast nobody is obliged to act on and a +second holder would do the work twice. It is not a provision, because there is no resource and no +credential. What makes it safe is not cleverness in the subscribe call but the seat: exactly one +holder, so exactly one worker, by construction. + +**State** is the shape the deploy path needs and nothing else uses. A declaration is not an event +— replaying yesterday's is actively harmful — and not a job. Only the newest matters, which is +last-per-subject retention, and a node that has seen sequence *n* refuses *n−1* by construction. +That is the wire-level answer to +[issue 107](../../04-ISSUES/107-a-declaration-carries-no-order/00-report.md). + +## 5. Seats + +A module declares a seat with its protocol, and the mesh enforces one holder at its scope +([ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md)). A caller declares that +it uses the *seat*, never the module, so the implementation can be replaced under it. + +- The set of seats is **derived** — the mesh's own, plus every registered module's — so it is both + closed and extensible, and enumerating it is a query rather than an inventory. +- `mesh-*` is **reserved**: the prefix is the reservation rule, and a module declaring one is + refused at registration. +- Two modules declaring the same name: the second is refused. +- A module may not claim a seat whose protocol it does not implement. +- **Nobody holding a seat is not an error.** The stream exists from registration, so work queues + until a holder appears. Install the telegram module a week later and the backlog flushes. + +## 6. The lifecycle: build, publish, deploy + +Every shape above appears once, in order, and no step knows where the next one runs. + +**A change lands.** The module holding `mesh-git` emits `pushed` — repository, ref, commit. An +event, because it is a fact about git and git's identity is the meaning. + +**The change becomes work.** The controller consumes `pushed`, asks the catalogue which modules +are built from that repository and path +([ADR 0069](../../02-DECISIONS/0069-a-module-is-a-repository-and-a-path.md)), and submits one +**job** per affected module to the `mesh-build-machine` seat. The builder stays simple: it builds +what it is handed, and never resolves anything. A builder that dies mid-build has its job +redelivered, because a work queue with explicit ack is what that means. + +**The artifact is published.** The builder pushes to the registry seats and emits `built` — +module, version, digest. An event again: a fact about the builder. + +**The build cascade is that event fanning out through a graph the mesh already has.** A module +whose image is built *on* another's artifact declares that in `build.on`. So `built` reaches the +controller, which walks the declared graph and submits rebuild jobs for everything downstream. A +dependency cascade is not special machinery — it is one event, one derived graph, and the same job +queue. + +**Deployment is state, not a message.** The controller composes each affected node's declaration +and publishes it last-per-subject. A node that was away gets exactly the current one, never a +queue of superseded ones, and a replayed older one is refused by sequence. + +**Applying is reported to a role.** The host applies and reports to the `mesh-controller` seat — +not to an address it was given at genesis. Held and retried while the store restarts +([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)). + +What disappears across that chain is every address. No webhook URL, no registered callback, no +"which node is the builder on", no controller endpoint baked into a joining node. That is the +class of bug +[issue 102](../../04-ISSUES/102-an-address-recorded-at-genesis-or-build-does-not-follow-the-nodes-ports/00-report.md) +names, dissolved rather than fixed. + +## 7. Modules depending on each other + +Three kinds, and conflating them is how deployment ordering goes wrong. + +**Build-time** — A's image is built on B's artifact. Resolved by the cascade above; nothing at +runtime cares. + +**Provision** — A requires a database from B. A **hard** dependency: the credential must exist +before A can start, so resolution gates delivery and A is shown as waiting until B has answered +([design 27](27-a-module-requires-the-mesh-resolves.md)). + +**Seat** — A uses B's seat. A **soft** dependency, and this is the one the bus changes. A starts +whether or not anyone holds the seat, because the stream absorbs the gap. Deployment order stops +mattering for everything expressed this way, and a service being restarted, moved or upgraded is +not an outage for its callers — it is latency. + +That difference is worth choosing on purpose. A dependency expressed as a provision must be +ordered; the same dependency expressed as a seat need not be. + +## 8. Open + +**Protocol versioning.** A seat's protocol is a compatibility surface between modules that do not +know each other, and nothing here says what happens when it changes under callers already bound to +it. This is the first thing to answer and the one most likely to hurt in year two rather than week +one. + +**Whether a module may declare a seat it does not itself claim** — the contract as one thing, the +implementation as another, which is how two competing implementations would ever exist. + +**Whether `consumes` naming another module couples too tightly.** It is kept here deliberately — +an event's provenance is its meaning — but a consumer of `billing.order.placed` does depend on +billing existing under that name. + +## 9. How it is checked + +- **A manifest holds no subject.** A catalogue test: no manifest contains a string matching the + subject grammar. The rule is worthless if it is followed by convention. +- **Permissions are exactly the three namespaces.** A composition test per module: the derived + permission set equals what its declaration implies, and a hand-written addition to it fails. +- **A sender cannot read the queue it writes to.** A bed: a module declaring `uses` is refused + subscribe on that seat's inbound subject. +- **One holder, one delivery.** A bed: a seat's job delivered once with the holder running, and + a second claim of the seat refused. +- **A queued job survives no holder.** A bed: submit with the seat unheld, assign the holder, + the job is delivered. +- **The cascade rebuilds exactly the dependents.** A bed: publish an artifact two modules build + on, and exactly those two are rebuilt. +- **A stale declaration is refused.** A bed: replay sequence *n−1* after *n*, and the node refuses + it rather than applying it. diff --git a/03-DESIGN/01-to-be/README.md b/03-DESIGN/01-to-be/README.md index 584c25b..41f9bde 100644 --- a/03-DESIGN/01-to-be/README.md +++ b/03-DESIGN/01-to-be/README.md @@ -35,10 +35,12 @@ document is written and this one's status becomes `implemented`. | [`23-choosing-a-provider.md`](23-choosing-a-provider.md) | Which of several providers of a kind serves a consumer, and when a module carries its own instead | [ADR 0084](../../02-DECISIONS/0084-which-provider-serves-a-consumer.md), [ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md) | | [`24-the-secrets-vault.md`](24-the-secrets-vault.md) | The module that owns a secret — a `secret` provision, and the boundary of what it owns | [ADR 0085](../../02-DECISIONS/0085-a-secret-is-a-provision.md), [ADR 0031](../../02-DECISIONS/0031-the-control-plane-authenticates-nobody.md), [ADR 0048](../../02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md) | | [`25-the-bus-on-nats.md`](25-the-bus-on-nats.md) | **Proposed.** The architecture of the mesh's bus on NATS: what rides which subject under which guarantee and whose account, how a node joins, how a person reaches a tool, and how the mesh moves from the bus it has | [ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md), [ADR 0116](../../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md), [ADR 0043](../../02-DECISIONS/0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md), [ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md) | -| [`26-the-seats.md`](26-the-seats.md) | **Proposed.** What a mesh can have one of, who fills each, and a seat's holder answering for the provision it delivers — including the `git` seat a build's source can live on | [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md), [ADR 0111](../../02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md), [ADR 0109](../../02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md) | -| [`27-a-module-requires-the-mesh-resolves.md`](27-a-module-requires-the-mesh-resolves.md) | **Proposed.** One concept for everything a module needs: a requirement with a contract, answered by one of four kinds of provider, resolved at assignment or refused. Retires settings, placeholders, facts and paths in definitions | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md), [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md), [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md) | +| [`26-the-seats.md`](26-the-seats.md) | **Proposed.** What a mesh can have one of, who fills each, and a seat's holder answering for the provision it delivers — including the `git` seat a build's source can live on | [ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)), [ADR 0111](../../02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md), [ADR 0109](../../02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md) | +| [`27-a-module-requires-the-mesh-resolves.md`](27-a-module-requires-the-mesh-resolves.md) | **Proposed.** One concept for everything a module needs: a requirement with a contract, answered by one of four kinds of provider, resolved at assignment or refused. Retires settings, placeholders, facts and paths in definitions | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md), [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md), [ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)) | | [`28-building-the-bus.md`](28-building-the-bus.md) | **Proposed.** The five steps of the bus work in the order their dependencies allow, each ending at a bed — with the surface measured, so no step's size is a guess | [ADR 0116](../../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md), [ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md), [ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md) | +| [`29-what-a-module-declares.md`](29-what-a-module-declares.md) | **Proposed.** What a module declares and what the bus derives from it: three namespaces, subjects from local names, queues never declared, the five relationships, and the build-publish-deploy lifecycle on one bus | [ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md), [ADR 0117](../../02-DECISIONS/0117-the-bus-is-the-only-broker.md), [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) | + ## Not yet written - **The remaining six contexts.** From e05825a881999b6d56054f70923a3a61c1458b63 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 20:44:16 +0200 Subject: [PATCH 04/44] Design 29: versioning, provisioning and secrets on the bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Versioning: additive is free; a breaking change is refused while callers are bound, and the refusal names them, because the mesh already holds the uses graph; a real break versions the subject, not the seat name, so the role does not fork; binding is a recorded pin, not a drift to whatever is newest. Semantic change stays open — no fingerprint sees it, and saying so beats implying the check is complete. Provisioning: a provisioner's create/remove/holds IS a serves protocol, so a provision interface is a seat that also delivers a credential — which is why design 26 already allowed that. The per-consumer resource is what stops the two collapsing into one. Secrets: sealed, so the bus is never trusted with plaintext — but sealed is not enough, because a stream persists and a durable ciphertext is an archive the day a key leaks. So a secret never enters a stream: core request/reply only, and a declaration names a secret rather than carrying one, which is 0098's fetch-don't-store applied where carrying is worst. The vault's own credential and the bus's own accounts are the two bootstrap exceptions, resolved the way 0067 resolves the control plane. Also rewrote the addresses paragraph, which was too compressed to follow: on-bus addresses disappear because nothing stores them, off-bus ones are untouched and still 0098's problem, and the bus's own address is the one that cannot be a subject. --- .../01-to-be/29-what-a-module-declares.md | 140 +++++++++++++++++- 1 file changed, 134 insertions(+), 6 deletions(-) diff --git a/03-DESIGN/01-to-be/29-what-a-module-declares.md b/03-DESIGN/01-to-be/29-what-a-module-declares.md index dfc6898..72bcaa4 100644 --- a/03-DESIGN/01-to-be/29-what-a-module-declares.md +++ b/03-DESIGN/01-to-be/29-what-a-module-declares.md @@ -183,12 +183,140 @@ not an outage for its callers — it is latency. That difference is worth choosing on purpose. A dependency expressed as a provision must be ordered; the same dependency expressed as a seat need not be. -## 8. Open +## 8. Versioning a protocol -**Protocol versioning.** A seat's protocol is a compatibility surface between modules that do not -know each other, and nothing here says what happens when it changes under callers already bound to -it. This is the first thing to answer and the one most likely to hurt in year two rather than week -one. +A seat's protocol is a compatibility surface between modules that do not know each other and are +deployed at different times. Four ways it can change, and they are not equally dangerous: + +| change | example | detectable | +|---|---|---| +| **additive** | a new `accepts` subject, a new optional field | nothing breaks | +| **removal or rename** | `send` becomes `deliver` | yes, mechanically | +| **shape** | an optional field becomes required | yes, if shapes are specified | +| **semantic** | `send` starts meaning *queue for tomorrow* | **no** | + +**Additive is free.** A seat may grow without a version, without re-registering a caller, and +without ceremony. Most change is this. + +**A breaking change is refused while anyone is bound.** Registration computes a compatibility +fingerprint over the seat's protocol — its subjects and the shapes they carry. A registration that +alters the fingerprint while callers are bound is refused, **and the refusal names them**. The +mesh already holds the `uses` graph, so this is derived rather than declared, and it turns a +runtime breakage into a registration-time conversation. + +**When a break is genuinely needed, the version goes in the subject, not the name.** The seat stays +one thing; `mesh.seat..v2.` runs beside v1 and the holder serves both. A caller moves +when it is ready. Versioning the *seat name* was considered and rejected: it forks the role, so +"one holder" stops meaning one provider of the capability, and every document naming the seat has +to be found and changed. + +**Binding is recorded, not inferred.** A caller declares `uses: telegram-sender` with no version, +and resolution binds it to the current one and records that — the same **pin** machinery +[design 27](27-a-module-requires-the-mesh-resolves.md) already uses when resolution had a choice +to make. Moving to v2 is a deliberate re-pin, so nothing drifts onto a new protocol because it +happened to be newest. + +**Retirement is reported, never automatic.** When the `uses` graph shows nothing bound to v1, the +overview says it is retirable. The mesh does not remove it. + +**And none of this catches a semantic change.** Same subject, same shape, new meaning: no +fingerprint sees it, and no check proposed here would. The defences are review, and pushing +meaning into shape wherever it can go — a required `channel` field is caught, a changed +interpretation of an existing one is not. Saying so is better than implying the fingerprint is +complete, because a team that believes it is complete stops reviewing for the case it misses. + +## 9. Provisioning over the bus + +Provisioning rides the bus, and the provider stops having an address. + +| part of a provision | shape | +|---|---| +| the requirement resolving to a provider | the controller's, not the bus's | +| the grant reaching the provisioner | request/reply to a **role** | +| `holds` — the reconcile question, every minute | the same call, on a timer | +| `provisioned` / `deprovisioned` | events | +| the credential reaching the consumer | §10 — fetched, never carried | + +A provisioner's interface is already three calls — create, remove, holds — which is exactly a +`serves` protocol. So **a provision interface is a seat whose protocol is those three**, which is +why [design 26](26-the-seats.md) already allows a seat to deliver a provision. The two concepts +were converging before this document; here they meet. + +What stays different, and must not be unified away: a provision has a **per-consumer resource and +a sealed credential**, created and destroyed per consumer. A seat protocol has neither — it is a +role you send to. Collapsing them would mean pretending a database is a subject. + +### Where addresses survive + +"Where is it?" is two different problems, and the bus solves one of them completely and the other +not at all. Keeping them apart matters, because a reader who thinks the mesh no longer has +addresses will believe a class of bug is fixed when it is untouched. + +**Something that is on the bus: the address disappears.** A host reporting in used to need the +controller's address — recorded somewhere, at some moment, and wrong as soon as anything moved. +Now it publishes to `mesh.seat.mesh-controller.report` and the bus routes it to whoever holds the +seat. Nothing anywhere records where the controller is, so nothing can record it *wrongly*. The +same is true of the builder, the catalogue, the telegram sender. This class is not mitigated; it +is gone, because the information is no longer stored. + +**Something that is not on the bus: the address stays, exactly as before.** A module that requires +a database does not reach postgres over NATS — it opens a postgres connection, because postgres +speaks postgres and is not listening on any subject. Its credential contains a host and a port, +and no amount of subject addressing changes that. + +So the fix for that second class is unchanged and is not this document's: +[ADR 0098](../../02-DECISIONS/0098-a-fact-a-provider-makes-at-first-start-is-fetched-from-it.md) — +fetch the fact where it is used rather than storing a copy — which is what +[issue 102](../../04-ISSUES/102-an-address-recorded-at-genesis-or-build-does-not-follow-the-nodes-ports/00-report.md) +is actually about. The bus makes that class *smaller* by removing every mesh-internal address from +it. It does not make it empty. + +**And one address is irreducible: the bus's own.** A node has to know where the broker is before +it can use subjects for anything, so that one cannot be a subject. It is the addressing equivalent +of §10's bootstrap — the first thing cannot be found by the mechanism that finds everything else. + +## 10. Secrets, and why they never enter a stream + +Everything the vault does is request/reply to the `mesh-vault` role: mint, fetch, rotate. In that +sense it is as much on the bus as anything else. + +**The bus is not trusted with a secret, and does not need to be.** A secret is sealed to its +recipient, so what crosses the bus is ciphertext only that recipient can open. The broker sees +that a secret moved, and to whom — metadata, which is acceptable — and never a plaintext. + +**But sealed is not enough on its own, because a stream persists.** A sealed secret written into +a JetStream stream is a durable ciphertext sitting in the mesh's own storage, and the day a +sealing key leaks, that stream is an archive rather than a moment. So: + +- **A secret travels on core request/reply, never through a stream.** No persistence, no replay, + nothing to exfiltrate later. +- **A declaration names a secret; it does not carry one.** Declarations are the state shape, which + *is* a stream — so the host fetches the secret from the vault at apply time, over the core path. + That is [ADR 0098](../../02-DECISIONS/0098-a-fact-a-provider-makes-at-first-start-is-fetched-from-it.md)'s + existing discipline — *fetched from it, not carried* — applied to the one payload where carrying + it is worst. + +**The bootstrap, which is circular and has a precedent.** The vault makes every secret +([ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md)), including the bus's own +passwords. The vault is a module, and a module needs a bus account, whose password the vault +makes. Nothing can go first. + +This is the shape [ADR 0067](../../02-DECISIONS/0067-genesis-is-a-pivot.md) already resolves for +the control plane: **genesis is a pivot.** The controller mints the handful of foundation +credentials itself, raises the store, the broker and the vault, and then the vault takes over and +mints everything from there — the same move as raising a temporary control plane and reinstalling +it as an ordinary module once the registry exists. + +So there are exactly two things the normal path cannot make, both at genesis, both ending the +moment the mesh can mint for itself: **the bus's own accounts** (§the bootstrap argument in +[ADR 0117](../../02-DECISIONS/0117-the-bus-is-the-only-broker.md) — a provisioner is a module and +needs an account before it can run) and **the vault's own credential**. Any third exception is a +design failure, and naming these two is what makes a third one visible. + +## 11. Open + +**Semantic change has no mechanical defence** (§8). Recorded as open rather than solved, because +it is the residue of a question the rest of §8 answers and the part a fingerprint cannot reach. **Whether a module may declare a seat it does not itself claim** — the contract as one thing, the implementation as another, which is how two competing implementations would ever exist. @@ -197,7 +325,7 @@ implementation as another, which is how two competing implementations would ever an event's provenance is its meaning — but a consumer of `billing.order.placed` does depend on billing existing under that name. -## 9. How it is checked +## 12. How it is checked - **A manifest holds no subject.** A catalogue test: no manifest contains a string matching the subject grammar. The rule is worthless if it is followed by convention. From 3f9b316015e4ec0603833e1e6d8ca2f22da403bf Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 20:58:34 +0200 Subject: [PATCH 05/44] Design 25: a scoped inbox needs allow_responses, or nothing can answer Found composing the first real configuration. Scoping every inbox to its owner is right and leaves a responder unable to reply, because the answer goes to the caller's inbox. The fix is not a wider grant but the server's own allow_responses: one reply to the subject of a message the user actually received. Without it every tool call times out while the permission list looks correct. --- 03-DESIGN/01-to-be/25-the-bus-on-nats.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/03-DESIGN/01-to-be/25-the-bus-on-nats.md b/03-DESIGN/01-to-be/25-the-bus-on-nats.md index 84c5340..61bdb53 100644 --- a/03-DESIGN/01-to-be/25-the-bus-on-nats.md +++ b/03-DESIGN/01-to-be/25-the-bus-on-nats.md @@ -129,6 +129,17 @@ expresses this exactly, per subject, and better than a vhost could: permissions name only that one prefix, for the reply to any request it makes and nothing wider. First review found the account note without this and read it as "any user may subscribe any inbox" — which was accurate against the text as it stood. + + **And a scoped inbox needs `allow_responses`, or nothing can answer.** Revision, found while + composing the first real configuration: the rule above scopes each user's inbox to itself, + which is right — and leaves a responder unable to reply, because the answer goes to the + *caller's* inbox, which the responder has no permission for. The two ways out are granting + every responder `_INBOX.>`, which is exactly the blanket grant this bullet refuses, or NATS's + own `allow_responses`: the server permits one reply to the reply-subject of a message the user + actually received, within a TTL, and nothing else. So authority to answer is bounded by having + been asked, and only principals that serve something are granted it — a pure consumer gets + nothing. Without this the scoping is not merely incomplete: every tool call in the mesh times + out, and the permission list looks correct while it happens. - **One user per module per node**, as today, with publish permissions `mesh.events..` for each emit, `mesh.tools..>` to serve its tools, its own ack-reply subject for each durable consumer it holds, and its own inbox prefix; subscribe From 78a2274baff9ff1bb44162b07a3e6ce92bbe2c6e Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 21:02:18 +0200 Subject: [PATCH 06/44] Designs 25 and 29 disagreed about the subject space; implementing found it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 29 put a module's events and tools in one namespace, 25 kept mesh.events.* and mesh.tools.*. One namespace is right — a module's authority over its own name becomes a single pattern the server enforces — but it needs a kind token, because a stream is a subject filter and mesh.mod.*.> would persist every tool call in the mesh. Tools stay on core NATS for the reason 25 already gives. So: mesh.mod..event., .tool., and seats the same shape. --- 03-DESIGN/01-to-be/25-the-bus-on-nats.md | 15 ++++++++++++--- 03-DESIGN/01-to-be/29-what-a-module-declares.md | 16 ++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/03-DESIGN/01-to-be/25-the-bus-on-nats.md b/03-DESIGN/01-to-be/25-the-bus-on-nats.md index 61bdb53..7ecfe38 100644 --- a/03-DESIGN/01-to-be/25-the-bus-on-nats.md +++ b/03-DESIGN/01-to-be/25-the-bus-on-nats.md @@ -56,11 +56,20 @@ mesh.control.enrol an enrolment request (JetStream: CONTR mesh.control.built a build's outcome (JetStream: CONTROL) mesh.node..declare a declaration for a node (JetStream: NODES, last-per-subject) mesh.build.request work for the build machine (JetStream: BUILDS, work queue) -mesh.events.. an event (JetStream: EVENTS) -mesh.tools.. a tool invocation (core request/reply) +mesh.mod..event. an event (JetStream: EVENTS) +mesh.mod..tool. a tool invocation (core request/reply) +mesh.seat..accept. work submitted to a role (JetStream: per-seat work queue) +mesh.seat..event. a role's own event (JetStream: EVENTS) +mesh.seat..tool. a role's tool (core request/reply) mesh.ask.. the controller's command api (core request/reply) ``` +**Revised 2026-09-26** ([design 29](29-what-a-module-declares.md)): a module's events and tools +moved from `mesh.events.*` / `mesh.tools.*` into one namespace per module, `mesh.mod..>`, +so a module's authority over its own name is a single subject pattern the server enforces — and +each carries a **kind token**, without which an events stream's filter would capture tool calls. +Seats are the same shape, one namespace per role. + Two things this buys over the exchanges: **request/reply is native** — a tool call is one `request` on `mesh.tools..` answered by whichever runtime serves it (a queue group per tool, so several nodes may serve one tool); and **a declaration is last-per-subject** — the NODES @@ -93,7 +102,7 @@ Core NATS is at-most-once. Everything the mesh must not lose lives in a JetStrea | CONTROL | `mesh.control.>` except `alive` | work queue, one consumer (the controller), explicit ack | the store-window guarantee ([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)): the controller `nak`s with a delay while its store is away and the message is redelivered; nothing is dropped | | NODES | `mesh.node.>` | last per subject | one declaration per node, always the newest | | BUILDS | `mesh.build.>` | work queue, explicit ack | at least once; a builder that dies mid-build has its message redelivered | -| EVENTS | `mesh.events.>` | limits (age, size), durable consumer per subscribing module | a subscriber that was down catches up; after `max-deliver` attempts the advisory feeds `mesh.events.dead` (its own small stream) | +| EVENTS | `mesh.mod.*.event.>` | limits (age, size), durable consumer per subscribing module | a subscriber that was down catches up; after `max-deliver` attempts the advisory feeds `mesh.events.dead` (its own small stream) | Tool calls and heartbeats stay on core NATS: a lost heartbeat is the next heartbeat; a lost tool call is a timeout the caller already handles. diff --git a/03-DESIGN/01-to-be/29-what-a-module-declares.md b/03-DESIGN/01-to-be/29-what-a-module-declares.md index 72bcaa4..7349d78 100644 --- a/03-DESIGN/01-to-be/29-what-a-module-declares.md +++ b/03-DESIGN/01-to-be/29-what-a-module-declares.md @@ -34,11 +34,19 @@ the catalogue, and the mesh would have hundreds of copies of a decision it made | declared | derived | |---|---| -| `emits: order.placed` | publish on `mesh.mod..order.placed` | -| `consumes: billing.order.placed` | durable consumer on `mesh.mod.billing.order.placed` | +| `emits: order.placed` | publish on `mesh.mod..event.order.placed` | +| `consumes: billing.order.placed` | durable consumer on `mesh.mod.billing.event.order.placed` | | `serves: status` | queue-group subscription on `mesh.mod..tool.status` | -| seat `telegram-sender`, `accepts: send` | work-queue consumer on `mesh.seat.telegram-sender.send` | -| `uses: telegram-sender` | publish on that seat's `accepts` subjects, and nothing else | +| seat `telegram-sender`, `accepts: send` | work-queue consumer on `mesh.seat.telegram-sender.accept.send` | +| `uses: telegram-sender` | publish on that seat's `accept` subjects, and nothing else | + +**The `event` / `tool` / `accept` token is load-bearing, not decoration.** Revision, found while +defining the streams: a stream is defined by a subject filter, so a namespace holding both a +module's events and its tool calls cannot be filtered into an events stream without capturing +every tool invocation in the mesh — and a tool call must never be persisted +([design 25](25-the-bus-on-nats.md) §3 keeps tools on core NATS, where a lost call is a timeout the +caller already handles). The kind token is what makes `mesh.mod.*.event.>` a safe filter. The +first draft of this table had no token, which reads better and cannot be implemented. **The test this must pass: the manifest survives the wire changing.** Reorganise the subject space and every manifest in the catalogue is still correct. That is the property From 7abb268de68ad92a23bdd854edc30a22210556fa Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 21:03:35 +0200 Subject: [PATCH 07/44] Step 1 done but for its bed 1.1 to 1.4 built and tested. 1.5 turned out to need no controller change: it already resolves the broker by seat and names no broker module in its source, which is what ADR 0079 was for. The genesis module set naming is scenario and installer config, carried with the bed. Recorded what must NOT change yet: the amqps:// credential shape and the 5671 default are correct until the rollout, because steps 1-4 leave every node on AMQP. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 28 +++++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 78f1924..1bea5a2 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -130,23 +130,37 @@ defined. The mesh this is for will never travel this path — it is already runn — but genesis is the definition every other path is measured against, and one that exists only on paper is wrong until there is a second mesh to find out. -- [ ] 1.1 the `nats` module: manifest, image, one container, its client, TLS and monitoring ports, +- [x] 1.1 the `nats` module: manifest, image, one container, its client, TLS and monitoring ports, JetStream on a named volume — the shape of design 25 §5, and the same shape the broker module beside it already has -- [ ] 1.2 the composed configuration as a **directory** resource, and the entrypoint that watches +- [x] 1.2 the composed configuration as a **directory** resource, and the entrypoint that watches the one file and signals the server itself — design 25 §5's correction, kept inside the module because a container has no reload and a recreate would drop every connection the mesh has -- [ ] 1.3 the controller composes that file: accounts, permissions, TLS, JetStream — a user's +- [x] 1.3 the controller composes that file: accounts, permissions, TLS, JetStream — a user's permissions derived from its declaration and nothing else, over the three namespaces of [design 29](29-what-a-module-declares.md) §2, plus its own ack subject and its own inbox prefix (design 25 §4) -- [ ] 1.4 the mesh's own streams, created at genesis and asserted idempotently on start, by the +- [x] 1.4 the mesh's own streams, created at genesis and asserted idempotently on start, by the controller as their only writer — **the mesh's own, not all of them**: a seat's streams are created when the module declaring it is registered, and a module's durable consumers when it is assigned, so this task is the fixed foundation set and 3.x carries the derived rest -- [ ] 1.5 genesis raises it as foundation, claiming the seat **`mesh-broker`** — the seat is the - server's role, not the product -- [ ] 1.6 the genesis-broker bed +- [x] 1.5 genesis raises it as foundation, claiming the seat **`mesh-broker`** — the seat is the + server's role, not the product. **Already true of the controller and needed no change**: it + resolves the broker by seat ("that is where the broker is, whatever else the topology says") + and names no broker module anywhere in its source. What remains is naming `nats` instead of + the AMQP broker where a genesis module set is declared, which is scenario and installer + configuration — carried with 1.6 rather than before it. +- [ ] 1.6 the genesis-broker bed — **deferred**: beds are run once, at the end, rather than per + step (novox/hq design 22's rule, and the operator's instruction). Every claim step 1 makes + is covered by a unit test or was demonstrated against the real server; what the bed adds is + the claims that need a mesh. + +> **Not done here, deliberately.** The controller builds a module's broker credential as an +> `amqps://` URL and defaults a portless genesis address to 5671. Those are correct until the +> rollout and must not move: steps 1 to 4 leave every node on AMQP +> ([ADR 0116](../../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md)), so changing the +> credential's shape now would break the running bus to serve a bus nothing speaks yet. They +> change with the links, in step 3. **Done when.** A mesh raised from nothing has the server standing with the streams asserted and every account and permission composed from the manifests; a user cannot publish outside its From 85f972749a7f379b3a58d503f962f62d04a0336a Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 21:08:40 +0200 Subject: [PATCH 08/44] AMQP is a provision, not the bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0117 went a step further than it had grounds for. It was right that the bus is the only bus, and wrong that the amqp interface must therefore retire — because it conflated two reasons to want a broker. Using one to reach another module is a second bus and stays refused. Needing an AMQP broker as a backing service, the way something needs a database, is ordinary, and forbidding it would make the mesh unable to run normal software while calling that architecture. So the broker becomes a plain provider module: no seat, not foundation, never raised at genesis, no retirement condition. lavinmq now claims nothing and provides amqp; nats claims mesh-broker and provides nothing. The rule that survives is about direction, not software: inter-module communication goes over the bus. A module may hold a broker for itself; it may not use one as a channel to another module. That is a review judgement where 0117 could have used a parser, which is the honest cost. 0106's progressive insight was itself wrong and is corrected by a second one there — nothing moves off the old broker, so its "one purpose" sentence does not become true, it is just not what that server is. The insight check needed two fixes it found itself: a date may carry trailing words, and a bold run with a link is discussing an insight rather than marking one. All four bad shapes still fire. --- 00-META/checks/records.py | 8 +- 02-DECISIONS/0106-the-bus-is-nats.md | 11 ++ .../0117-the-bus-is-the-only-broker.md | 3 +- .../0119-amqp-is-a-provision-not-the-bus.md | 103 ++++++++++++++++++ 02-DECISIONS/README.md | 3 +- 03-DESIGN/01-to-be/25-the-bus-on-nats.md | 24 +++- 03-DESIGN/01-to-be/28-building-the-bus.md | 2 +- .../01-to-be/29-what-a-module-declares.md | 4 +- 03-DESIGN/01-to-be/README.md | 2 +- 9 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md diff --git a/00-META/checks/records.py b/00-META/checks/records.py index dd1f5c1..3f12a2e 100644 --- a/00-META/checks/records.py +++ b/00-META/checks/records.py @@ -302,7 +302,9 @@ def check_progressive_insights(failures, records): # Both patterns stay on one line: a bold run does not span paragraphs, and `[^*]*` across # newlines will happily join an unrelated `**` far above to the marker below, reporting the # whole span between them. It did exactly that the first time this ran. - marker = re.compile(r"\*\*Progressive insights?[ \t]*[\u2014\u2013-][ \t]*(\d{4}-\d{2}-\d{2})\.?\*\*") + # Trailing words after the date are allowed — "— 2026-09-26, correcting the one above." — so + # an insight can say what it relates to. Only the date's presence and position are fixed. + marker = re.compile(r"\*\*Progressive insights?[ \t]*[\u2014\u2013-][ \t]*(\d{4}-\d{2}-\d{2})[^*\n]*\*\*") loose = re.compile(r"\*\*[^*\n]*[Pp]rogressive insights?[^*\n]*\*\*") iso = re.compile(r"^\d{4}-\d{2}-\d{2}$") @@ -316,6 +318,10 @@ def check_progressive_insights(failures, records): for m in loose.finditer(text): if any(s <= m.start() and m.end() <= e for s, e, _ in good): continue + # A bold run carrying a link is discussing an insight — usually another record's — + # rather than marking one. A marker never needs to cite anything. + if "](" in m.group(0): + continue failures.add("insights", rel(record["path"]), "a progressive insight is not in the dated marked form " "'**Progressive insight \u2014 YYYY-MM-DD.**': %s" % m.group(0)) diff --git a/02-DECISIONS/0106-the-bus-is-nats.md b/02-DECISIONS/0106-the-bus-is-nats.md index a12110d..4c8d3e2 100644 --- a/02-DECISIONS/0106-the-bus-is-nats.md +++ b/02-DECISIONS/0106-the-bus-is-nats.md @@ -88,6 +88,17 @@ compatibility broker beside it moves its bus in one rollout with every node repo > decision — the bus is NATS, the AMQP broker becomes the predecessor's compatibility broker and > retires with the last of them — is unchanged. +> **Progressive insight — 2026-09-26, correcting the one above.** *The broker is not a +> compatibility module at all, and the sentence does not become true.* The insight above said +> [ADR 0117](0117-the-bus-is-the-only-broker.md) would make "one purpose — the predecessor's +> clients" true by moving the mesh's own modules off it. +> [ADR 0119](0119-amqp-is-a-provision-not-the-bus.md) supersedes that: nothing moves off, because +> a module may legitimately need an AMQP broker as a backing service the way it needs a database. +> The broker becomes **an ordinary provider module** — no seat, not foundation, not raised at +> genesis, and with no retirement condition, because the day its last client disappears is not a +> day anything is waiting for. What this record decided — the mesh's bus is NATS — is untouched +> by both; what was wrong was the sentence describing what happens to the old server, twice. + ## References - [research 014](../01-RESEARCH/014-the-bus-on-nats/00-overview.md) diff --git a/02-DECISIONS/0117-the-bus-is-the-only-broker.md b/02-DECISIONS/0117-the-bus-is-the-only-broker.md index 65be793..a00f555 100644 --- a/02-DECISIONS/0117-the-bus-is-the-only-broker.md +++ b/02-DECISIONS/0117-the-bus-is-the-only-broker.md @@ -1,6 +1,7 @@ --- topic: the mesh -status: accepted +status: superseded +superseded-by: 02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md date: 2026-09-26 deciders: jochen reconstructed: false diff --git a/02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md b/02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md new file mode 100644 index 0000000..9ca50b9 --- /dev/null +++ b/02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md @@ -0,0 +1,103 @@ +--- +topic: the mesh +status: accepted +date: 2026-09-26 +deciders: jochen +reconstructed: false +supersedes: 02-DECISIONS/0117-the-bus-is-the-only-broker.md +--- + +# 119. AMQP is a provision, not the bus + +## Context + +[ADR 0117](0117-the-bus-is-the-only-broker.md) decided that the bus is the only broker, and went +one step further than it had grounds for: it also decided that the `amqp` **interface** — a module +requiring a message broker of its own — "is not carried forward" and "retires with the +compatibility broker rather than gaining a successor", with the two modules declaring it converted +to the bus in step 4. + +The operator's correction: **AMQP is deprecated as the mesh's transport, not abolished as a +service.** The broker module keeps running and keeps answering `amqp` requirements. It is no +longer a core part of the mesh — *"it's just a module like mssql now."* + +**What 0117 conflated** is two different reasons a module might ask for a broker, which look +identical in a manifest: + +1. **To talk to other modules.** Wrong under one bus, and the thing 0117 was right to refuse: a + private broker used as inter-module transport is a second bus, with every guarantee crossing a + seam and no scoping the mesh can see. +2. **Because it genuinely needs an AMQP broker**, the way something needs a database — a queue for + its own internals, or interop with software that speaks AMQP and nothing else. That is a + backing service, and the mesh has a word for backing services already. + +0117 saw the first and legislated against both. The second is ordinary, and forbidding it would +make the mesh unable to run a large class of perfectly normal software while claiming that as +architecture. + +## Considered Options + +1. **Keep 0117 as written** — retire the interface, convert the two modules. Rejected by the + operator, and wrongly reasoned besides: it treats "needs an AMQP broker" as always a mistake. +2. **Keep the broker as the predecessor's compatibility module**, as ADR 0106 framed it, with a + retirement condition. Rejected: it is not single-purpose and its clients are not only the + predecessor's, so the retirement condition describes a day that will not come. +3. **The broker is an ordinary provider module of an ordinary provision.** Adopted. + +## Decision + +**The mesh's bus is NATS and only NATS.** Everything 0117 decided about *the bus* stands: one bus, +a module's messaging is subjects on it scoped by what it declares, no module is handed a bus of +its own, and the `mesh-broker` seat is the NATS server's. + +**`amqp` remains a provision a module may require**, answered by the broker module the way +`postgres-database` is answered by the store module or a database is answered by mssql. It is not +deprecated as an interface; the software behind it is simply no longer the mesh's nervous system. + +**The broker module stops being foundation.** It claims no seat — `mesh-broker` is the NATS +server's — it is not raised at genesis, nothing in the mesh requires it, and a mesh that never +installs it is a complete mesh. It is installed when something wants it, like any other provider. + +**The rule that survives, stated so it can be applied:** *inter-module communication goes over the +bus.* A module may hold a broker, a database or a cache as a backing service; it may not use one +as a channel to another module. The line is not which software is involved, it is whether a second +module is on the other end. + +**Neither `amqp-ping` nor `amqp-email-forwarder` needs converting.** 0117 put that work in step 4; +it is removed. They require a backing service and a provider answers. + +## Consequences + +- **The "compatibility broker" framing is wrong and goes.** There is no `lavinmq-compat`, no + single purpose and no retirement condition. Design 25 §5 is corrected. +- **[ADR 0106](0106-the-bus-is-nats.md)'s progressive insight was itself wrong** and is corrected + by a second one there. It said 0117 would make 0106's "one purpose — the predecessor's clients" + sentence true by moving the mesh's modules off. Nothing moves off; the sentence is simply not + what the broker is. +- **The seat change stands**, for a better reason than 0117 gave: not because a broker cannot be + provisioned, but because *this* broker is not the mesh's bus. The broker module drops its + `mesh-broker` claim and the `nats` module takes it. +- **Step 4 loses two conversions**; step 1 and the WBS are otherwise unaffected. +- **What got harder:** the rule is now a judgement rather than a prohibition. "Is this a backing + service or a channel to another module?" has to be asked in review, where 0117 could have + answered it with a parser. That is the honest cost of allowing the legitimate case. + +## How it is checked + +- **A module's own messaging needs no `requires`.** The check from 0117, unchanged: a module + declaring only `emits` and `consumes` reaches its subjects and is refused every other. +- **The broker holds no seat.** A manifest test: the broker module claims nothing, and a mesh + raised without it is complete — genesis names it nowhere. +- **`amqp` resolves like any provision.** A resolution test: a module requiring it is answered by + the provider, refused when none is assigned, and neither case touches the bus. +- **What cannot be checked mechanically**, and is said rather than implied: that a module holding + a broker is not using it to reach another module. Review, not a parser. + +## References + +- [ADR 0117](0117-the-bus-is-the-only-broker.md) — superseded; its ruling on the bus is kept + whole and only its ruling on the interface is reversed. +- [ADR 0106](0106-the-bus-is-nats.md) — the bus is NATS; its compatibility-broker framing is + corrected here. +- [ADR 0118](0118-a-module-declares-its-own-seats.md) — seats, including the one the NATS server + now holds alone. diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index fcbe797..abf0392 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -133,7 +133,8 @@ python3 00-META/checks/index.py fail if stale - **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) - **0116** — [The bus is built in five steps, and the protocol moves with it](0116-the-bus-is-built-in-five-steps.md) -- **0117** — [The bus is the only broker](0117-the-bus-is-the-only-broker.md) +- **0117** — [The bus is the only broker](0117-the-bus-is-the-only-broker.md) *(superseded)* +- **0119** — [AMQP is a provision, not the bus](0119-amqp-is-a-provision-not-the-bus.md) ### Its tiers, from the bottom up diff --git a/03-DESIGN/01-to-be/25-the-bus-on-nats.md b/03-DESIGN/01-to-be/25-the-bus-on-nats.md index 7ecfe38..fc7e274 100644 --- a/03-DESIGN/01-to-be/25-the-bus-on-nats.md +++ b/03-DESIGN/01-to-be/25-the-bus-on-nats.md @@ -10,7 +10,7 @@ code: updated: 2026-09-26 decisions: - 02-DECISIONS/0106-the-bus-is-nats.md - - 02-DECISIONS/0117-the-bus-is-the-only-broker.md + - 02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.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/0079-the-foundation-seats-are-named-after-their-servers.md @@ -214,11 +214,25 @@ current. Nothing is declared as `reload-on` or `restart-on` for this resource at Its guard is the same rule as the AMQP broker's: the monitoring port is refused from anything but the private network. It is raised at genesis like the store, adopted as a module in the same -phase. The predecessor's AMQP broker remains a module of its own, `lavinmq-compat`, with a -retirement condition: no client connected for a period the operator sets. +phase. + +**The AMQP broker is an ordinary module, not a compatibility layer.** Revision, second review +([ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md)): earlier text here, and +ADR 0106 before it, called it `lavinmq-compat` — one purpose, the predecessor's clients, and a +retirement condition of no client connected for a period the operator sets. It is none of those. +A module may legitimately need an AMQP broker as a **backing service**, the way it needs a +database, and the provider that answers that is an ordinary module like any other: no seat, not +foundation, never raised at genesis, installed when something wants it and absent from a mesh +that does not. There is no retirement condition, because the day its last client disappears is +not a day anything is waiting for. + +What is deprecated is AMQP as **the mesh's transport**, which is this whole document. The rule +that remains is about direction rather than software: *inter-module communication goes over the +bus.* A module may hold a broker, a database or a cache for itself; it may not use one as a +channel to another module. **And with one purpose, which it did not have when this was written.** Revision, second review -([ADR 0117](../../02-DECISIONS/0117-the-bus-is-the-only-broker.md)): two modules of the new mesh +([ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md) (superseding [ADR 0117](../../02-DECISIONS/0117-the-bus-is-the-only-broker.md))): two modules of the new mesh required a broker *of their own* from it — a private vhost per consumer, the analog of database-per-login, which is a different thing from the mesh's bus and was never examined here. **There is one bus, and no module is handed a broker as a resource.** A module's messaging is @@ -436,7 +450,7 @@ find what changed and why. **Still open:** - ~~Whether EVENTS should be one stream or one per emitting module.~~ **Closed** - ([ADR 0117](../../02-DECISIONS/0117-the-bus-is-the-only-broker.md)): one stream, and not as a + ([ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md) (superseding [ADR 0117](../../02-DECISIONS/0117-the-bus-is-the-only-broker.md))): one stream, and not as a preference — the streams the bus is made of are composed as configuration before any module runs, because a provisioner is itself a module that needs a bus account to start. Bootstrapping decides it. diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 1bea5a2..5d68c57 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -9,7 +9,7 @@ updated: 2026-09-26 decisions: - 02-DECISIONS/0116-the-bus-is-built-in-five-steps.md - 02-DECISIONS/0106-the-bus-is-nats.md - - 02-DECISIONS/0117-the-bus-is-the-only-broker.md + - 02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md - 02-DECISIONS/0118-a-module-declares-its-own-seats.md - 02-DECISIONS/0074-the-wire-is-specified-not-the-types.md - 02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md diff --git a/03-DESIGN/01-to-be/29-what-a-module-declares.md b/03-DESIGN/01-to-be/29-what-a-module-declares.md index 7349d78..51498ce 100644 --- a/03-DESIGN/01-to-be/29-what-a-module-declares.md +++ b/03-DESIGN/01-to-be/29-what-a-module-declares.md @@ -5,7 +5,7 @@ code: [] updated: 2026-09-26 decisions: - 02-DECISIONS/0118-a-module-declares-its-own-seats.md - - 02-DECISIONS/0117-the-bus-is-the-only-broker.md + - 02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md - 02-DECISIONS/0106-the-bus-is-nats.md - 02-DECISIONS/0041-events-are-a-relationship.md - 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md @@ -317,7 +317,7 @@ it as an ordinary module once the registry exists. So there are exactly two things the normal path cannot make, both at genesis, both ending the moment the mesh can mint for itself: **the bus's own accounts** (§the bootstrap argument in -[ADR 0117](../../02-DECISIONS/0117-the-bus-is-the-only-broker.md) — a provisioner is a module and +[ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md) (superseding [ADR 0117](../../02-DECISIONS/0117-the-bus-is-the-only-broker.md)) — a provisioner is a module and needs an account before it can run) and **the vault's own credential**. Any third exception is a design failure, and naming these two is what makes a third one visible. diff --git a/03-DESIGN/01-to-be/README.md b/03-DESIGN/01-to-be/README.md index 41f9bde..abd5b86 100644 --- a/03-DESIGN/01-to-be/README.md +++ b/03-DESIGN/01-to-be/README.md @@ -39,7 +39,7 @@ document is written and this one's status becomes `implemented`. | [`27-a-module-requires-the-mesh-resolves.md`](27-a-module-requires-the-mesh-resolves.md) | **Proposed.** One concept for everything a module needs: a requirement with a contract, answered by one of four kinds of provider, resolved at assignment or refused. Retires settings, placeholders, facts and paths in definitions | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md), [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md), [ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)) | | [`28-building-the-bus.md`](28-building-the-bus.md) | **Proposed.** The five steps of the bus work in the order their dependencies allow, each ending at a bed — with the surface measured, so no step's size is a guess | [ADR 0116](../../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md), [ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md), [ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md) | -| [`29-what-a-module-declares.md`](29-what-a-module-declares.md) | **Proposed.** What a module declares and what the bus derives from it: three namespaces, subjects from local names, queues never declared, the five relationships, and the build-publish-deploy lifecycle on one bus | [ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md), [ADR 0117](../../02-DECISIONS/0117-the-bus-is-the-only-broker.md), [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) | +| [`29-what-a-module-declares.md`](29-what-a-module-declares.md) | **Proposed.** What a module declares and what the bus derives from it: three namespaces, subjects from local names, queues never declared, the five relationships, and the build-publish-deploy lifecycle on one bus | [ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md), [ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md) (superseding [ADR 0117](../../02-DECISIONS/0117-the-bus-is-the-only-broker.md)), [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) | ## Not yet written From 86a084b7ff417ad6cefd7b240d64d144023e2560 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 21:17:21 +0200 Subject: [PATCH 09/44] The mesh bus is required, not ambient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design 29 said no module requires the bus. The catalogue disagrees: 49 of 72 modules take a broker credential and 23 do not, so an ambient connection mints an account for a third of the catalogue that never speaks — and the 49 each hand-write the path it lands at, which is provisioning done badly by hand. The bootstrap argument that made it ambient was narrower than it looked. "A provisioner needs an account before it can run" is true of a provisioner process and says nothing about a provision the controller answers, and the controller is not waiting on a bus account to compose one. So: the mesh-broker seat delivers mesh-bus; a module requires it and gets an address, a sealed credential and the trust to verify the server; a module that requires nothing has no account at all. The requirement delivers the connection, the declarations shape the authority, and declaring a subject without requiring the bus is refused as incoherent. mesh-bus and nats are deliberately two names: a module may run its own NATS as a backing service exactly as one provides amqp, and a manifest saying "nats" would otherwise mean either the mesh's nervous system or a private queue. The seat's Delivers was wrong twice today — amqp, then empty — and the comment says so rather than reading as though it were always right. --- ...20-the-mesh-bus-is-required-not-ambient.md | 116 ++++++++++++++++++ 02-DECISIONS/README.md | 1 + 03-DESIGN/01-to-be/26-the-seats.md | 3 +- .../01-to-be/29-what-a-module-declares.md | 35 +++++- 4 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 02-DECISIONS/0120-the-mesh-bus-is-required-not-ambient.md diff --git a/02-DECISIONS/0120-the-mesh-bus-is-required-not-ambient.md b/02-DECISIONS/0120-the-mesh-bus-is-required-not-ambient.md new file mode 100644 index 0000000..f6af86e --- /dev/null +++ b/02-DECISIONS/0120-the-mesh-bus-is-required-not-ambient.md @@ -0,0 +1,116 @@ +--- +topic: the mesh +status: accepted +date: 2026-09-26 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md +--- + +# 120. The mesh bus is required, not ambient + +## Context + +[Design 29](../03-DESIGN/01-to-be/29-what-a-module-declares.md) opened by saying the bus is +*ambient*: "No module requires it, the way no module requires a filesystem. Every module gets a +connection and an identity whether it asks or not." + +**Two counts say that is wrong.** Of the 72 modules in the catalogue, **49 declare an own-secret +named `broker` and 23 do not.** So the bus is not universal — nearly a third of the catalogue +never speaks to it — and an ambient connection would mint an account, a password and a permission +set for every one of those 23, each a credential nothing uses and everything must rotate. + +And the 49 that do take one **each hand-write the path it lands at** +(`own-secrets: { broker: "/var/lib//broker" }`). That is a special case doing badly what +provisioning already does well: a consumer names where a credential lands, the mesh seals it +there, and rotation and removal follow the same path as every other credential. + +**The argument that made the bus ambient was narrower than it looked.** +[ADR 0117](0117-the-bus-is-the-only-broker.md) reasoned that bus accounts cannot be provisioned +because a provisioner is itself a module that needs an account before it can run. That is true of +a **provisioner process**, and it is not true of a provision: the mesh's bus accounts are composed +by the *controller*, into configuration, and the controller is not waiting on a bus account to +exist. The circularity is real for one mechanism and absent for the other, and the earlier record +applied it to both. + +## Considered Options + +1. **Keep the bus ambient.** Rejected on the counts above: it over-grants to 23 modules and keeps + a hand-written path in 49. +2. **Derive the requirement** from whether a module declares any `emits`, `consumes`, `serves` or + `uses`. Rejected: it is the ambient model with extra inference. A reader of a manifest still + cannot see that the module holds a bus credential, and the rule would have to be re-derived + every time the set of bus-facing declarations grew. +3. **The mesh bus is a provision a module requires**, delivered by the seat that holds it. + Adopted. + +## Decision + +**A module that speaks to the mesh requires `mesh-bus`, and receives what it needs to connect.** +The contract is an address, a credential sealed to the module, and the trust to verify the +server. It lands where the module's manifest says, like any provision. A module that does not +require it gets no account, no password and no permissions — and 23 modules in the catalogue +should get none. + +**The `mesh-broker` seat delivers `mesh-bus`.** Its holder is the mesh's own bus, and what +holding it delivers is the connection to that bus — which is what a seat delivering a provision +has always meant ([design 26](../03-DESIGN/01-to-be/26-the-seats.md)). + +**The requirement delivers the connection; the declarations shape the authority.** They are two +different things and both stay explicit. `requires: mesh-bus` says *this module talks to the +mesh*; `emits`, `consumes`, `serves`, `uses` and a declared seat say *what it may say and hear*, +and the permission set is derived from those and nothing else +([ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md)). Requiring the bus +grants no subject; declaring a subject without requiring the bus is refused at registration as +incoherent. + +**The `mesh-bus` provision is answered by the controller, not by a provisioner.** This is the +surviving kernel of ADR 0117's bootstrap argument, narrowed to what it actually supports: the +bus's accounts are configuration the controller composes and the server reloads +([ADR 0106](0106-the-bus-is-nats.md) — never through a management API), so there is no provisioner +process in the path and nothing waiting on a bus account to create bus accounts. It is a provision +whose provider is the mesh itself. + +**A module may also provide a NATS server of its own, and that is a different interface.** Exactly +as the AMQP broker provides `amqp` ([ADR 0119](0119-amqp-is-a-provision-not-the-bus.md)), a module +may run its own NATS and offer it as a backing service. That interface is **`nats`**; the mesh's +own bus is **`mesh-bus`**; the two are never the same name, because a manifest that said `nats` +could mean either and the difference is the whole architecture. The rule from 0119 decides which +is legitimate: a private bus is a backing service, never a channel to another module. + +## Consequences + +- **Design 29's opening is reversed.** The bus is not ambient; it is required, and the document's + first paragraph says the opposite of this. +- **The seat's `Delivers` is `mesh-bus`** — corrected twice in one day, which is worth recording + rather than tidying: it read `amqp`, which was the old broker's interface; ADR 0117 emptied it, + on the reasoning that a bus cannot be provisioned; and it is neither. The seat delivers the + mesh's bus. +- **`own-secrets: { broker: ... }` is retired** in favour of the provision's own delivery, across + 49 manifests. That is a mechanical change, and it belongs with the conversions in step 4 rather + than step 1. +- **23 modules lose a credential they never used.** Not a regression — an over-grant removed, and + the smallest honest statement of what this buys. +- **What got harder:** one more line in most manifests. The trade is that the line is true, and + its absence is also true. + +## How it is checked + +- **A module with no `requires: mesh-bus` has no account.** A composition test: the derived user + list contains exactly the modules that require it, and the 23 that do not appear nowhere in it. +- **Declaring a subject without requiring the bus is refused.** A registration test on a manifest + with `emits` and no requirement, naming the contradiction. +- **Requiring the bus grants no subject on its own.** A composition test: a module that requires + `mesh-bus` and declares nothing else gets a connection and an empty permission set. +- **`nats` and `mesh-bus` are distinct interfaces.** A resolution test: a module requiring `nats` + is answered by a module providing it, never by the seat holder, and vice versa. + +## References + +- [ADR 0117](0117-the-bus-is-the-only-broker.md) — superseded by 0119; its bootstrap argument is + narrowed here to the case it supports. +- [ADR 0119](0119-amqp-is-a-provision-not-the-bus.md) — a broker as a backing service; this + applies the same shape to the mesh's own bus and separates the two names. +- [ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md) — authority from + declarations, which this leaves untouched. +- [design 26](../03-DESIGN/01-to-be/26-the-seats.md) — a seat delivering a provision. diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index abf0392..740bca0 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -135,6 +135,7 @@ python3 00-META/checks/index.py fail if stale - **0116** — [The bus is built in five steps, and the protocol moves with it](0116-the-bus-is-built-in-five-steps.md) - **0117** — [The bus is the only broker](0117-the-bus-is-the-only-broker.md) *(superseded)* - **0119** — [AMQP is a provision, not the bus](0119-amqp-is-a-provision-not-the-bus.md) +- **0120** — [The mesh bus is required, not ambient](0120-the-mesh-bus-is-required-not-ambient.md) ### Its tiers, from the bottom up diff --git a/03-DESIGN/01-to-be/26-the-seats.md b/03-DESIGN/01-to-be/26-the-seats.md index c64e07b..0ac67a1 100644 --- a/03-DESIGN/01-to-be/26-the-seats.md +++ b/03-DESIGN/01-to-be/26-the-seats.md @@ -11,6 +11,7 @@ code: updated: 2026-09-26 decisions: - 02-DECISIONS/0118-a-module-declares-its-own-seats.md + - 02-DECISIONS/0120-the-mesh-bus-is-required-not-ambient.md - 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md - 02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md --- @@ -64,7 +65,7 @@ convention, which later seats departed from. |---|---|---|---| | `mesh-controller` | — | mesh | — | the controller | | `mesh-store` | — | mesh | — | the store the mesh's own records live in | -| `mesh-broker` | — | mesh | — | the broker carrying the mesh's own bus | +| `mesh-broker` | — | mesh | `mesh-bus` | the broker carrying the mesh's own bus | | `mesh-vault` | — | mesh | `secret`, reserved | the vault | | `mesh-artifact-store` | `the-artifact-store` | mesh | `artifact-store` | the artifact registry | | `mesh-catalog` | `the-catalogue` | mesh | — | the catalogue | diff --git a/03-DESIGN/01-to-be/29-what-a-module-declares.md b/03-DESIGN/01-to-be/29-what-a-module-declares.md index 51498ce..1351bbe 100644 --- a/03-DESIGN/01-to-be/29-what-a-module-declares.md +++ b/03-DESIGN/01-to-be/29-what-a-module-declares.md @@ -6,6 +6,7 @@ updated: 2026-09-26 decisions: - 02-DECISIONS/0118-a-module-declares-its-own-seats.md - 02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md + - 02-DECISIONS/0120-the-mesh-bus-is-required-not-ambient.md - 02-DECISIONS/0106-the-bus-is-nats.md - 02-DECISIONS/0041-events-are-a-relationship.md - 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md @@ -14,10 +15,22 @@ decisions: # 29. What a module declares, and what the bus makes of it -**The bus is ambient.** No module requires it, the way no module requires a filesystem. Every -module gets a connection and an identity whether it asks or not. What a module declares are -*relationships*; subjects, streams, consumers and permissions are all derived from those, and a -manifest never contains one. +**A module that speaks to the mesh requires the bus, and receives what it needs to connect** +([ADR 0120](../../02-DECISIONS/0120-the-mesh-bus-is-required-not-ambient.md)). What a module +declares are *relationships*; subjects, streams, consumers and permissions are all derived from +those, and a manifest never contains one. + +> **Revised 2026-09-26.** This document opened by calling the bus *ambient* — "no module requires +> it, the way no module requires a filesystem". Two counts say otherwise: of 72 modules in the +> catalogue, **49 take a broker credential and 23 do not**, so an ambient connection would mint an +> account for a third of the catalogue that never speaks; and the 49 each hand-write the path it +> lands at, which is provisioning done badly by hand. The bus is required, and a module that does +> not require it has no account at all. + +**The requirement delivers the connection; the declarations shape the authority.** `requires: +mesh-bus` says *this module talks to the mesh* and grants no subject by itself. `emits`, +`consumes`, `serves`, `uses` and a declared seat say what it may say and hear. Declaring a subject +without requiring the bus is incoherent and refused at registration. This document is the declaration model. [Design 25](25-the-bus-on-nats.md) is the bus itself — subjects, streams, accounts, enrolment — and stays the authority on the wire. @@ -254,6 +267,20 @@ What stays different, and must not be unified away: a provision has a **per-cons a sealed credential**, created and destroyed per consumer. A seat protocol has neither — it is a role you send to. Collapsing them would mean pretending a database is a subject. +### `mesh-bus` and `nats` are two interfaces, never one name + +The mesh's own bus is **`mesh-bus`**, delivered by the `mesh-broker` seat and answered by the +controller — because the bus's accounts are configuration rather than something a provisioner +creates, so there is no provisioner process in the path and nothing waiting on a bus account in +order to make bus accounts. A module that runs a NATS server of its own and offers it as a +backing service provides **`nats`**, exactly as the AMQP broker provides `amqp` +([ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md)). + +They are never the same name. A manifest saying `nats` could otherwise mean either the mesh's +nervous system or a private queue, and the difference between those is the whole architecture. +0119's rule decides which is legitimate: a private bus is a backing service, never a channel to +another module. + ### Where addresses survive "Where is it?" is two different problems, and the bus solves one of them completely and the other From 39c802cbd408ebdfb1d67ca99fc69405b2af14d0 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 21:40:02 +0200 Subject: [PATCH 10/44] Step 2: adoption recreates the bus once, on purpose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.3 was already true and is now proved — the seat refusal is generic, and three tests pin what matters: a second bus is refused by name, a different bus implementation is refused too (which is what makes the bus replaceable), and the AMQP broker no longer contends so both run on one mesh. 2.1/2.2 turned out not to be a no-op. The host keeps a container only when its spec matches exactly; genesis raises the upstream image and the module declares the mesh-built one carrying the entrypoint, so assigning it recreates the container. That is ADR 0067's pivot and it is safe only because the bus carries nothing yet — which is why step 2 comes before anything speaks NATS. After it, never again: the config is a directory mount, so accounts change without touching the container's spec. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 34 +++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 5d68c57..0310675 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -180,11 +180,35 @@ and it is what makes steps 3 and 4 safe to develop against a live mesh. A runnin 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)). -- [ ] 2.1 the server raised beside the existing broker on its own ports, carrying nothing -- [ ] 2.2 the `nats` module adopted onto it in place, holding the data and configuration it was - raised with -- [ ] 2.3 the seat claim, and the resolver's refusal of a second holder mesh-wide -- [ ] 2.4 the adoption bed +- [ ] 2.1 the server raised beside the existing broker on its own ports, carrying nothing — + installer-side, from the upstream image +- [ ] 2.2 the `nats` module assigned, which **recreates the container once, deliberately** (see + below), keeping its JetStream directory +- [x] 2.3 the seat claim, and the resolver's refusal of a second holder mesh-wide — **already + true and now proved**: the refusal is generic to any mesh-scoped seat, and three tests pin + what matters for this one — a second bus anywhere is refused naming the seat, a *different* + bus implementation is refused for the same reason (which is what lets the bus be replaced + at all), and the AMQP broker no longer contends for it, so both run on one mesh +- [ ] 2.4 the adoption bed — deferred with the other beds + +> **Adoption here is not a no-op, and pretending it would be is the trap.** The host keeps an +> existing container only when its spec matches the declaration exactly +> ([`apply.go`](https://git.novox.be/novox/mesh-host): *existed && before.Spec == want && running* +> → unchanged; anything else is `rm -f` and recreate). Genesis raises the server from the +> **upstream** image, because nothing has been built yet; the module declares the **mesh-built** +> artifact, which carries the entrypoint that reloads configuration in place. Those two specs +> differ, so assigning the module recreates the container. +> +> That is correct, and it is [ADR 0067](../../02-DECISIONS/0067-genesis-is-a-pivot.md)'s pivot +> exactly: raise a temporary thing, then reinstall it as an ordinary module. It is safe **only +> because it happens while the bus carries nothing** — which is what 2.1 means by "carrying +> nothing", and why step 2 comes before anything speaks NATS rather than after. One recreate, at +> the one moment it costs nothing. +> +> **After that, never again.** The configuration is a directory mount rather than a file, so +> rewriting accounts does not change the container's spec and the entrypoint reloads the server in +> place. That is the whole point of task 1.2, and this is the moment it pays: every later account, +> permission or person's access change touches a running bus with connections on it. **Done when.** A mesh already running has the server adopted, holding `mesh-broker`; a second assignment anywhere is refused at resolution — *one per mesh*; and every node is still on the old From 0b8e84334feacd72fd2848062d7732d8380b3d5a Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 21:49:55 +0200 Subject: [PATCH 11/44] Why module events share one stream, checked against the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage is not a property of a subject — a stream is a separate object that covers one — so the question is always how many streams, not which topics are durable. Three facts decide it, two of them verified rather than assumed: NATS refuses overlapping streams instead of merging them, so a shared stream plus a per-module one is not available at all; a filter cannot express an exception; and a stream per module turns one cross-module consumer into one per module. So one stream, with per-subject caps for the fairness that matters. Per-module age is genuinely unavailable, and a module that needs it declares a seat. --- .../01-to-be/29-what-a-module-declares.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/03-DESIGN/01-to-be/29-what-a-module-declares.md b/03-DESIGN/01-to-be/29-what-a-module-declares.md index 1351bbe..77aee42 100644 --- a/03-DESIGN/01-to-be/29-what-a-module-declares.md +++ b/03-DESIGN/01-to-be/29-what-a-module-declares.md @@ -103,6 +103,28 @@ seat: telegram-sender If each consumer could tune it, the mesh's durability would be an emergent property of whichever manifest was edited last. +**Why a module's own events do not carry their own retention, though the same rule would allow +it.** A seat owns its namespace and gets a stream of its own, so it can say. A module's events +share one `EVENTS` stream, and three facts about JetStream decide that they must: + +- **Storage is not a property of a subject.** A subject is only an address; a *stream* is a + separate object that captures subjects matching a filter. So "this topic is durable" is always + really "some stream covers it", and something has to create that stream. +- **Overlapping streams are refused, not merged.** Verified against the server: a per-module + stream beside a shared `mesh.mod.*.event.>` is rejected with *subjects overlap with an existing + stream*. So "one stream by default, its own for a module that wants different retention" is not + available — it is all of one or all of the other, and a filter cannot express an exception + either. +- **A stream per module breaks cross-module consumption.** An audit logger consuming every + module's events is one consumer on one stream today; with a stream each it becomes one consumer + per module, created and destroyed as modules come and go. + +So: one stream, and **per-subject caps** for the fairness that actually matters — a noisy emitter +cannot evict a quiet one, which is verified (a cap of three, ten messages on one subject and one +on another, leaves four). What is genuinely unavailable is a different *age* per module, because +JetStream ages per stream and not per subject. A module that truly needs its own retention has a +way to say so: declare a seat, which owns its namespace and gets its own stream. + **Scope gives per-node workers without a new concept.** A module running on three nodes that each need their own queue declares a node-scoped seat: one holder per node, three queues, same machinery. Mesh-scoped and node-scoped seats already exist; here they do the work of "one shared From b759e36bfd11f029b2d5a77d8e14fbc372bbf3c7 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 22:17:17 +0200 Subject: [PATCH 12/44] Design 29: tools, not serves; WBS 3.9 partly done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest already uses serves for a provision's facts, so a module's tools take their own key. Declaring them is itself new — until now a module's tools existed only in a runtime environment variable. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 15 +++++++++++---- 03-DESIGN/01-to-be/29-what-a-module-declares.md | 11 +++++++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 0310675..7020d0d 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -237,10 +237,17 @@ pays for itself furthest away. - [ ] 3.8 **the declaration model** of [design 29](29-what-a-module-declares.md): local names derived to subjects, the three namespaces, permissions computed from a declaration, and a manifest that contains no subject -- [ ] 3.9 **seats declared by modules** — registration creates a seat's streams and refuses a - `mesh-*` name, a duplicate declarer, an undeclared `uses`, and a holder that does not - satisfy the protocol; assignment creates the holder's work-queue consumer and refuses a - second holder +- [~] 3.9 **seats declared by modules** — the manifest now carries `seats` (name, scope, + accepts/emits/serves, retention) and `uses`, and registration refuses a `mesh-*` name, a + duplicate declarer, an undeclared `uses` or claim, a seat with no protocol, a scope + mismatch, and a holder that does not answer what its seat promises. **Still to do**: + creating a seat's streams at registration and its holder's work-queue consumer at + assignment, which need the JetStream client wired in. + + The refusal for an unknown claim *moved* rather than disappeared — the parser cannot judge + it from one manifest any more, because another module may legitimately declare that seat, + so it is registration's. The test that encoded the old rule was rewritten rather than + deleted, and a second one pins the case the parser could not distinguish. - [ ] 3.10 **the ten seat renames**, carried as a migration with a mapping rather than an edit, and the beds that name seats moved with them diff --git a/03-DESIGN/01-to-be/29-what-a-module-declares.md b/03-DESIGN/01-to-be/29-what-a-module-declares.md index 77aee42..f653bda 100644 --- a/03-DESIGN/01-to-be/29-what-a-module-declares.md +++ b/03-DESIGN/01-to-be/29-what-a-module-declares.md @@ -29,7 +29,7 @@ those, and a manifest never contains one. **The requirement delivers the connection; the declarations shape the authority.** `requires: mesh-bus` says *this module talks to the mesh* and grants no subject by itself. `emits`, -`consumes`, `serves`, `uses` and a declared seat say what it may say and hear. Declaring a subject +`consumes`, `tools`, `uses` and a declared seat say what it may say and hear. Declaring a subject without requiring the bus is incoherent and refused at registration. This document is the declaration model. [Design 25](25-the-bus-on-nats.md) is the bus itself — @@ -49,10 +49,17 @@ the catalogue, and the mesh would have hundreds of copies of a decision it made |---|---| | `emits: order.placed` | publish on `mesh.mod..event.order.placed` | | `consumes: billing.order.placed` | durable consumer on `mesh.mod.billing.event.order.placed` | -| `serves: status` | queue-group subscription on `mesh.mod..tool.status` | +| `tools: status` | queue-group subscription on `mesh.mod..tool.status` | | seat `telegram-sender`, `accepts: send` | work-queue consumer on `mesh.seat.telegram-sender.accept.send` | | `uses: telegram-sender` | publish on that seat's `accept` subjects, and nothing else | +**It is `tools:`, not `serves:`.** Revision, found while implementing: the manifest already uses +`serves` for the facts a consumer needs in order to reach a provision, and two meanings under one +key in the file a module author reads most is a footgun. Worth noting that until now a module's +tools were not declared at all — they were known only at runtime, from an environment variable in +its image — so declaring them is new, and is what lets the mesh check that a module claiming a +seat answers what that seat's protocol promises. + **The `event` / `tool` / `accept` token is load-bearing, not decoration.** Revision, found while defining the streams: a stream is defined by a subject filter, so a namespace holding both a module's events and its tool calls cannot be filtered into an events stream without capturing From 24d99ddd249074e9665f277bce22cdde8d59e43c Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 22:28:59 +0200 Subject: [PATCH 13/44] WBS: 3.9 done, and 1.4's client with it --- 03-DESIGN/01-to-be/28-building-the-bus.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 7020d0d..fc0d7db 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -237,7 +237,7 @@ pays for itself furthest away. - [ ] 3.8 **the declaration model** of [design 29](29-what-a-module-declares.md): local names derived to subjects, the three namespaces, permissions computed from a declaration, and a manifest that contains no subject -- [~] 3.9 **seats declared by modules** — the manifest now carries `seats` (name, scope, +- [x] 3.9 **seats declared by modules** — the manifest now carries `seats` (name, scope, accepts/emits/serves, retention) and `uses`, and registration refuses a `mesh-*` name, a duplicate declarer, an undeclared `uses` or claim, a seat with no protocol, a scope mismatch, and a holder that does not answer what its seat promises. **Still to do**: @@ -248,6 +248,10 @@ pays for itself furthest away. it from one manifest any more, because another module may legitimately declare that seat, so it is registration's. The test that encoded the old rule was rewritten rather than deleted, and a second one pins the case the parser could not distinguish. + + **Done**: a seat's work queue is derived and created, and a holder's worker with it. The + JetStream client behind them is wired and verified against a running server, which also + completes 1.4's missing half — the pure `Asserter` had no implementation until now. - [ ] 3.10 **the ten seat renames**, carried as a migration with a mapping rather than an edit, and the beds that name seats moved with them From d2ed3152d3651e6fb1575f82a78afd61d7181695 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:06:55 +0200 Subject: [PATCH 14/44] Seat renames done; 0118 was wrong that it was a migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A holding is derived at resolution from manifests, never stored, so there are no recorded old names to rewrite. The work is an edit plus a kept rename table — kept because a module lives in its own repository and may be registered long after the catalogue stopped using an old name. --- .../0118-a-module-declares-its-own-seats.md | 15 +++++++++++++++ 03-DESIGN/01-to-be/28-building-the-bus.md | 14 ++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/02-DECISIONS/0118-a-module-declares-its-own-seats.md b/02-DECISIONS/0118-a-module-declares-its-own-seats.md index 6b9fe73..4d5ce84 100644 --- a/02-DECISIONS/0118-a-module-declares-its-own-seats.md +++ b/02-DECISIONS/0118-a-module-declares-its-own-seats.md @@ -120,6 +120,21 @@ protocols is the failure nobody could diagnose afterwards. - **A holder must satisfy the protocol.** A claim whose module does not serve what the seat declares is refused at assignment, not discovered when a caller times out. +## Progressive insight + +> **Progressive insight — 2026-09-26.** *A seat rename is not a data migration.* This record's +> consequences say "a rename is a migration, not an edit: existing assignments hold the old +> names, so the change carries a mapping and is applied once". Implementing it showed there is +> nothing stored to migrate: a seat's holding is **derived at resolution** from the claims in +> manifests (`resolve.go` builds it each time), never written down, so no recorded name is left +> pointing at the old one. What exists is source — the controller's seat table, the manifests +> that claim them, and a manifest that may be registered later from its own repository. So the +> change is an edit plus a **kept** rename table, which tells a manifest written against an old +> name what it became rather than refusing it as unknown. +> +> The decision — that modules declare seats, that the mesh reserves `mesh-*`, and that the ten +> are renamed — is unchanged. Only the shape of the work was wrong. + ## References - [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) — superseded here; its diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index fc0d7db..7e48f4b 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -252,8 +252,18 @@ pays for itself furthest away. **Done**: a seat's work queue is derived and created, and a holder's worker with it. The JetStream client behind them is wired and verified against a running server, which also completes 1.4's missing half — the pure `Asserter` had no implementation until now. -- [ ] 3.10 **the ten seat renames**, carried as a migration with a mapping rather than an edit, - and the beds that name seats moved with them +- [x] 3.10 **the ten seat renames** — done in the controller's table, the ten manifests that + claim them, the controller's own shipped manifests, and every test. Not a migration after + all: a holding is derived at resolution, never stored, so nothing recorded points at an old + name (recorded as a progressive insight on ADR 0118). A **kept** rename table tells a + manifest written against an old name what it became, because a module lives in its own + repository and may be registered long after the catalogue stopped using one. + + **A seat and the interface it delivers are different names.** The `git` seat became + `mesh-git` while the `git` *provision* it delivers did not change, and the same for the + package registry. A blanket replace got this wrong first and the failure read "the package + registry is served on ``", which does not say "you renamed an interface" — so a test + now pins every seat against the interface it delivers. **Done when.** The fixtures are produced and consumed byte for byte by every implementation that claims the capability, and a module built before any of this serves its tools unchanged on the new From 80456981be9f9808a97770fad422dab08e3a3f24 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:29:56 +0200 Subject: [PATCH 15/44] WBS: 3.6 done, and the certificate constraint it surfaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NATS client has no checkServerIdentity hook, so pinning no longer makes the name check redundant — the bus's certificate must carry a SAN matching the address nodes dial. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 7e48f4b..4495fce 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -232,7 +232,19 @@ pays for itself furthest away. - [ ] 3.3 the fixtures restated on NATS, and the capability each implementation claims - [ ] 3.4 the controller's link on NATS - [ ] 3.5 the host's link on NATS — mirroring, still importing nothing -- [ ] 3.6 the tool runtime's client on NATS, behind the unchanged sdk contract +- [x] 3.6 the tool runtime's client on NATS, behind the unchanged sdk contract — round-tripped + against a real server: a tool answered across two connections, a throwing handler reaching + the caller as an error rather than a timeout, an event delivered once with its key, body, + node and event id intact. Ships beside the AMQP client and is selected at the rollout, + because steps 1 to 4 leave every node on AMQP. + + **A constraint it surfaced, recorded where somebody issuing a certificate will look.** The + AMQP client pinned the exact certificate and switched hostname verification off, which is + sound because a fingerprint is stronger than a name. The NATS client exposes no equivalent + hook — its TLS options are PEM strings with no verify callback — so the pin still happens + before dialling and the library's own name check happens beside it. **The bus's certificate + must carry a subject-alternative name matching the address nodes dial it by**, or the + connection is refused by a library error rather than by anything the mesh says. - [ ] 3.7 the sdk's three stale comments, and nothing else in it - [ ] 3.8 **the declaration model** of [design 29](29-what-a-module-declares.md): local names derived to subjects, the three namespaces, permissions computed from a declaration, and a From d940e14ec8319d57cf1515b9418a5f425f4981a9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:32:48 +0200 Subject: [PATCH 16/44] Design 19: the protocol on NATS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3.2. ADR 0074's model is untouched — floor plus capabilities, partial implementations legitimate, identity from the credential, dedup on x-event-id, conformance as executable fixtures. The transport beneath it is rewritten: exchanges and queues become subjects and streams. Statements marked *verified* were checked against a running server while the runtime's client was written, not reasoned from documentation. Three of them are things the specification would otherwise have got wrong: - the payload is the body alone, with metadata in NATS headers; an implementation that nested the whole envelope would agree with nobody - a durable name may not contain a dot, while the ack subject joins two names with one — conflating them looks right in a permission list and is refused as a consumer name - a certificate must carry a name the bus is dialled by, because the NATS client has no hook to replace hostname verification the way pinning did on AMQP And one limitation lifts: a module may now call another's tool. Issue 049 recorded that a scoped account could not declare the reply queue a caller needs, and ADR 0095 routed every ask through the control plane because of it. Per-account inbox prefixes plus allow_responses replace that. ADR 0095 is not reversed — the control plane is still how a person asks — but module-to-module calling stops being a question about capability and becomes one about policy, which `uses` already answers. --- 03-DESIGN/01-to-be/19-the-module-protocol.md | 153 ++++++++++++------- 1 file changed, 99 insertions(+), 54 deletions(-) diff --git a/03-DESIGN/01-to-be/19-the-module-protocol.md b/03-DESIGN/01-to-be/19-the-module-protocol.md index b95b42b..620facd 100644 --- a/03-DESIGN/01-to-be/19-the-module-protocol.md +++ b/03-DESIGN/01-to-be/19-the-module-protocol.md @@ -3,12 +3,13 @@ layer: to-be status: proposed code: - mesh-sdk src - - mesh-tools src/broker-amqp.ts + - mesh-tools src/broker-nats.ts (and broker-amqp.ts until the rollout) - 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/0120-the-mesh-bus-is-required-not-ambient.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 @@ -25,26 +26,17 @@ 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 `..events` queue, the shared `serve.` queue — describes the transport -> being retired, and the conformance fixtures were captured against it. +> **Rewritten onto NATS, 2026-09-26** (step 3 of +> [ADR 0116](../../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md)). What +> [ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md) decided is untouched: +> a floor plus independent capabilities, an implementation legitimate when it claims less, +> identity from the sealed credential, at-least-once with dedup on `x-event-id`, and conformance +> as executable fixtures rather than prose. What changed is the transport beneath all of it — +> exchanges and queues became subjects and streams. The envelope keeps its shape +> ([ADR 0042](../../02-DECISIONS/0042-the-shape-of-an-event-on-the-wire.md)). > -> 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. +> Statements here marked *verified* were checked against a running server while the runtime's +> client was written, not reasoned from documentation. ## The shape of it @@ -70,22 +62,39 @@ 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 | +| `url` | a `tls://` URL for the bus, with the account's user and password | yes | +| `fingerprint` | sha256 of the certificate the bus 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. +**`node` and `module` are not decoration: every subject an implementation touches is derived from +them.** Its own namespace is `mesh.mod.`, its consumer is `_`, its inbox is +its own. So a credential without them is refused rather than guessed at — an implementation that +fell back to an environment variable would let anything on the machine decide which module it is, +which is what the identity rule below exists to prevent. + +The credential itself is fetched, never carried in a declaration: a declaration is persisted as +state and a sealed secret in a stream is an archive rather than a moment +([design 29](29-what-a-module-declares.md) §10). + ### 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 + not skip verification. A bus 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. +- **The certificate must also carry a name the bus is dialled by.** *Verified:* the NATS client + exposes no hook to replace hostname verification, so pinning no longer makes it redundant the + way it did on AMQP — the pin happens before dialling and the library's own name check happens + beside it. A certificate without a matching subject-alternative name is refused at connect, by + a library error rather than by anything the mesh says. +- An implementation **creates nothing on the bus**: not a stream, not a consumer, not a subject. + Streams and durable consumers are the controller's alone ([design 25](25-the-bus-on-nats.md) + §3), and a module's account cannot reach the JetStream API to make one. An implementation binds + the consumer the mesh created for it, and if it is absent that is a mesh that has not finished + assigning the module, not something for the module to fix. ### Identity @@ -100,26 +109,49 @@ the credential disagree, the credential wins and the variable is overwritten. ## Capability: events -### The exchanges +### The subjects -| exchange | carries | +| subject | carries | |---|---| -| `mesh.events` | every event | -| `mesh.events.dead` | what could not be handled | +| `mesh.mod..event.` | an event that module emitted | +| `mesh.seat..event.` | an event the holder of that role emitted | -### The queue +Both are captured by the `EVENTS` stream. **An event's source is enforced rather than claimed**: a +module's account may publish only into its own namespace, so `x-source` cannot disagree with where +the message arrived from. -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. +**The `event` token is load-bearing.** A module's namespace also carries its tool calls +(`mesh.mod..tool.`), and a stream is defined by a subject filter — without the token +the events stream would capture every tool invocation in the mesh, and a tool call must never be +persisted. -**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 consumer + +One **durable consumer** per module, named `_`, carrying one filter per pattern the +module consumes. Durable because an event emitted while a module is restarting is exactly the one +that must not be lost. + +**Created by the controller, bound by the implementation.** A module declares what it reacts to +and never how delivery works, so it does not name its consumer, does not choose its ack policy or +delivery limit, and cannot misconfigure them. + +*Verified, and it is a trap:* a durable name **may not contain a dot**, while the subject a +consumer acknowledges on is `$JS.ACK...…` — two names joined by one. An +implementation that treats them as a single string reads correctly in a permission list and is +refused as a consumer name. Left wrong, the symptom is every message redelivered forever while +the permissions look right. + +**One consumer may carry filters wider than one handler's pattern**, because a module subscribing +twice gets one consumer with both. So an implementation still matches the key against its own +patterns locally to decide which handlers run — and **acknowledges a message no handler wanted**, +or it is redelivered until it expires. ### The envelope -Headers ride as AMQP headers. The body is JSON. +Headers ride as **NATS headers**; the body is JSON, and the body alone. *Verified:* the payload is +the event's `body`, not the whole envelope re-encoded — an implementation that nested the envelope +would pass every one of its own tests and agree with no other, which is the exact failure the +conformance fixtures exist to catch. The key is recovered from the subject, not carried twice. | header | is | required | |---|---|---| @@ -140,6 +172,11 @@ breaking change for everybody. 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. +On NATS the id does double duty: an implementation passes it as the publish's message id, so the +**server** also refuses a duplicate inside its window. That narrows the window in which a +consumer has to deduplicate; it does not remove the requirement, because the window is finite and +a redelivery after it is still a redelivery. + ### What is true, checked (2026-09-16) Go emits all five required headers; the SDK requires exactly those. `x-causation-id` and `x-schema` @@ -154,22 +191,30 @@ version to declare. 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. -- **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 [json]` publishes on `mesh.rpc` under `.` 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. +- A tool is served on `mesh.mod..tool.`, with a **queue group** — so several + runtimes serving one tool compete for a call rather than each answering it. +- A call is request and reply on **core NATS, never a stream**. A tool call is not persisted: a + lost one is a timeout the caller already handles, and a stream of them would be the mesh's most + voluminous and least valuable traffic competing for retention with the messages that matter. +- The reply goes to the inbox the request carries. A responder may answer it because its account + is granted **`allow_responses`** — one reply to the subject of a message it actually received, + and nothing wider. That is what makes a per-account inbox prefix workable: no user is ever + granted `_INBOX.>`, so without it a responder could not reach the caller at all. +- **A module may now call a tool, which on AMQP it could not.** *Verified:* two modules on + separate connections, one serving and one calling, with an answer returned and a throwing + handler reaching the caller as an error rather than a timeout. + [Issue 049](../../04-ISSUES/049-a-module-can-serve-tools-and-nothing-can-call-them/00-report.md) + recorded the old limit — a scoped account could not declare the reply queue a caller needs — + and [ADR 0095](../../02-DECISIONS/0095-the-control-plane-is-the-way-to-ask-a-module.md) routed + every ask through the control plane because of it. **That constraint is gone**, and each + account's own inbox prefix replaces it. + + ADR 0095 is not thereby reversed: the control plane remains *a* way to ask, and a person asking + a module should still go through it. What changes is that "a module-to-module call, if one is + wanted, is a later decision" is no longer a question about *capability*. It is a policy + question, and the answer the mesh already has is `uses`: a module declares the seat it calls, + and the permission follows the declaration. +- A module declares nothing about being asked — serving a tool is being askable. --- From 9510bf53110cc97e8d8665185844476d047c797b Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:33:10 +0200 Subject: [PATCH 17/44] WBS: 3.2 done --- 03-DESIGN/01-to-be/28-building-the-bus.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 4495fce..82b58c8 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -225,10 +225,12 @@ pays for itself furthest away. headers, the contributions file, a served tool call and a grant, capturing what the three implementations do *today*. Written first because a suite born on the new bus certifies whatever the new bus happens to do -- [ ] 3.2 design 19 rewritten from exchanges, queues and routing keys to the subjects and streams of +- [x] 3.2 design 19 rewritten from exchanges, queues and routing keys to the subjects and streams of design 25 §2–§3, per capability, with ADR 0074's model untouched: floor plus capabilities, an implementation legitimate when it claims less, identity from the sealed credential, dedup on - `x-event-id` + `x-event-id`. Claims checked against a running server are marked *verified* in the text, so + a reader can tell what was measured from what was reasoned. One limitation lifts with the + transport: a module may now call another's tool, which issue 049 recorded it could not. - [ ] 3.3 the fixtures restated on NATS, and the capability each implementation claims - [ ] 3.4 the controller's link on NATS - [ ] 3.5 the host's link on NATS — mirroring, still importing nothing From fbf9440e8e879745ccabc8d9e42affd23533f7d4 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:34:04 +0200 Subject: [PATCH 18/44] WBS: 3.7 and 3.8 done, with the one check 3.8 still owes --- 03-DESIGN/01-to-be/28-building-the-bus.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 82b58c8..7c3c69c 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -247,10 +247,15 @@ pays for itself furthest away. before dialling and the library's own name check happens beside it. **The bus's certificate must carry a subject-alternative name matching the address nodes dial it by**, or the connection is refused by a library error rather than by anything the mesh says. -- [ ] 3.7 the sdk's three stale comments, and nothing else in it -- [ ] 3.8 **the declaration model** of [design 29](29-what-a-module-declares.md): local names +- [x] 3.7 the sdk's three stale comments, and nothing else in it — three lines, which is the + whole of the sdk's diff for the bus change, and the measurement that predicted it +- [x] 3.8 **the declaration model** of [design 29](29-what-a-module-declares.md): local names derived to subjects, the three namespaces, permissions computed from a declaration, and a - manifest that contains no subject + manifest that contains no subject. Done in the controller's composer (permissions, streams, + consumers) and in the runtime's client (subjects derived from the credential, never named + by a module). What is **not** done is enforcing "a manifest contains no subject" as a + catalogue test — the rule holds by construction today because nothing reads a subject from + a manifest, and a rule held by construction is one a later field could break quietly. - [x] 3.9 **seats declared by modules** — the manifest now carries `seats` (name, scope, accepts/emits/serves, retention) and `uses`, and registration refuses a `mesh-*` name, a duplicate declarer, an undeclared `uses` or claim, a seat with no protocol, a scope From 5c193b3f543e82eef913320ba19ec2f8e54b839d Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:34:42 +0200 Subject: [PATCH 19/44] WBS: 3.8's check is written --- 03-DESIGN/01-to-be/28-building-the-bus.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 7c3c69c..3c3d607 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -252,10 +252,10 @@ pays for itself furthest away. - [x] 3.8 **the declaration model** of [design 29](29-what-a-module-declares.md): local names derived to subjects, the three namespaces, permissions computed from a declaration, and a manifest that contains no subject. Done in the controller's composer (permissions, streams, - consumers) and in the runtime's client (subjects derived from the credential, never named - by a module). What is **not** done is enforcing "a manifest contains no subject" as a - catalogue test — the rule holds by construction today because nothing reads a subject from - a manifest, and a rule held by construction is one a later field could break quietly. + consumers), in the runtime's client (subjects derived from the credential, never named by a + module), and as a catalogue test asserting all 72 manifests hold no subject — because the + rule held by construction, and a rule held by construction is one a later field breaks + quietly. - [x] 3.9 **seats declared by modules** — the manifest now carries `seats` (name, scope, accepts/emits/serves, retention) and `uses`, and registration refuses a `mesh-*` name, a duplicate declarer, an undeclared `uses` or claim, a seat with no protocol, a scope From 2f9bb73685fcdac59e2b63606d65bd36c154cacc Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:41:15 +0200 Subject: [PATCH 20/44] WBS: the first fixtures are in, and what byte-for-byte means --- 03-DESIGN/01-to-be/28-building-the-bus.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 3c3d607..ce6b596 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -221,17 +221,27 @@ quietly carried traffic would be step 5 arriving early and unrehearsed. that decides what "agreeing" means for everything after it. It is the largest step and the one that pays for itself furthest away. -- [ ] 3.1 **the suite first, on the bus the mesh has** — fixtures for the envelope and its required - headers, the contributions file, a served tool call and a grant, capturing what the three - implementations do *today*. Written first because a suite born on the new bus certifies - whatever the new bus happens to do +- [x] 3.1/3.3 **the fixtures** — one directory in the sdk, read by each implementation's own + runner rather than copied into either, because a fixture copied twice is two fixtures. The + Go emitter and the runtime's NATS client both pass the first: every required header set, + each value in the pinned shape, the subject derived the same way, and the payload the body + alone. + + **The suite also had to settle what "byte-for-byte" can mean**, which ADR 0074 stated and + nothing had yet had to implement. The envelope is exact — subject, required headers, names + and formats — because that is what two implementations get wrong invisibly. The body is + not: Go sorts a map's keys and JavaScript keeps insertion order, so identical bytes would + commit every implementation to a canonical JSON encoder, to buy a property the mesh never + uses. Read strictly it would have sent somebody writing one. + + Still to capture: a served tool call, a grant and its answer, and the contributions file — + the other three ADR 0074 names. - [x] 3.2 design 19 rewritten from exchanges, queues and routing keys to the subjects and streams of design 25 §2–§3, per capability, with ADR 0074's model untouched: floor plus capabilities, an implementation legitimate when it claims less, identity from the sealed credential, dedup on `x-event-id`. Claims checked against a running server are marked *verified* in the text, so a reader can tell what was measured from what was reasoned. One limitation lifts with the transport: a module may now call another's tool, which issue 049 recorded it could not. -- [ ] 3.3 the fixtures restated on NATS, and the capability each implementation claims - [ ] 3.4 the controller's link on NATS - [ ] 3.5 the host's link on NATS — mirroring, still importing nothing - [x] 3.6 the tool runtime's client on NATS, behind the unchanged sdk contract — round-tripped From 672c994afae9f7b45f3d6ec7f240c175952ecaeb Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:47:40 +0200 Subject: [PATCH 21/44] WBS: 3.4's seam is in, outbound half through it --- 03-DESIGN/01-to-be/28-building-the-bus.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index ce6b596..949b379 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -242,7 +242,16 @@ pays for itself furthest away. `x-event-id`. Claims checked against a running server are marked *verified* in the text, so a reader can tell what was measured from what was reasoned. One limitation lifts with the transport: a module may now call another's tool, which issue 049 recorded it could not. -- [ ] 3.4 the controller's link on NATS +- [~] 3.4 the controller's link on NATS — **the seam exists and the outbound half is through + it.** `Bus` is stated in the mesh's words (publish an event, declare to a node) rather than + a transport's, with an AMQP and a NATS implementation, both shipping: steps 1 to 4 leave + every node on AMQP, and both shipping is what lets one conformance fixture hold them to the + same envelope. The NATS one is checked against a real server, reading back from the stream + rather than from the code that wrote it. + + The seam turned out to be eight call sites — the same smallness that said this bus could be + replaced at all. Still on `*amqp.Channel`: `RequestBuild` and `Ask`, which carry reply-queue + machinery, and the whole consume side (the control loop, enrolment, serving). - [ ] 3.5 the host's link on NATS — mirroring, still importing nothing - [x] 3.6 the tool runtime's client on NATS, behind the unchanged sdk contract — round-tripped against a real server: a tool answered across two connections, a throwing handler reaching From 9f6aa7ea9c8f525d514597d0308215b351a75051 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:54:09 +0200 Subject: [PATCH 22/44] The bus is the mesh's centre, not a transport that replaced one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things. A paragraph from the superseded 0117 survived beside the 0119 correction that reversed it, so §5 said both that the amqp interface retires and that it does not. The stale one is gone. And the framing. §1 opened with "the bus carries five kinds of traffic today, and this design keeps the five", with a column mapping each to the queue it used to be — which describes the mesh's nervous system as a port of something that did a fraction of this. It now says what the bus is: a role addressable without knowing its holder, the mesh's own state, work that queues until somebody can do it, and permissions derived from what a module declared. Conditions, observation and a person's client land there too as they are built. Glossary gains `bus` and `the deprecated broker`, with a note on why not to say "compatibility broker" or name it after a protocol — the second invites exactly the backwards framing this commit removes. --- 00-META/glossary.md | 18 +++++- 03-DESIGN/01-to-be/25-the-bus-on-nats.md | 57 +++++++++++-------- 03-DESIGN/01-to-be/28-building-the-bus.md | 10 ++-- .../01-to-be/29-what-a-module-declares.md | 2 +- 4 files changed, 54 insertions(+), 33 deletions(-) diff --git a/00-META/glossary.md b/00-META/glossary.md index d2a2958..ed7b9ef 100644 --- a/00-META/glossary.md +++ b/00-META/glossary.md @@ -31,8 +31,22 @@ another — and a mesh you cannot name precisely is a mesh two people describe d - **store** — the one postgres server. It holds the controller's own context databases (`inventory`, `identity`, `licences` — a context owns its store, [ADR 0008](../02-DECISIONS/0008-a-context-owns-its-store.md)) and every module's own database. One server, many databases — never one shared "mesh database". -- **broker** — the one lavinmq message bus. It carries the mesh bus on the `/` vhost and a vhost per - consumer that requires `amqp`. +- **bus** — the mesh's own nervous system: NATS, one per mesh, carrying every link the mesh has — + control, declarations, builds, events, tool calls + ([ADR 0106](../02-DECISIONS/0106-the-bus-is-nats.md)). A module reaches it by requiring + `mesh-bus` ([ADR 0120](../02-DECISIONS/0120-the-mesh-bus-is-required-not-ambient.md)); one that + does not require it has no account on it. Held by the `mesh-broker` seat, which is named after + the *role* rather than the server, so the server can change without the seat doing so. +- **the deprecated broker** — the lavinmq module. It was the mesh's bus and is not any more. It + keeps running as an **ordinary provider** of the `amqp` provision, for modules that need a + message broker of their own the way something needs a database + ([ADR 0119](../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md)) — no seat, not foundation, + never raised at genesis, and a mesh that never installs it is complete. + + Say *the deprecated broker*, not "the compatibility broker" (it serves the mesh's own modules, + not only the predecessor's) and not "the AMQP broker" (naming it after a protocol invites + describing the bus by contrast with it, which is backwards: the bus is the mesh's nervous + system and this is a module). ## What the mesh stores and serves diff --git a/03-DESIGN/01-to-be/25-the-bus-on-nats.md b/03-DESIGN/01-to-be/25-the-bus-on-nats.md index fc7e274..0cf7128 100644 --- a/03-DESIGN/01-to-be/25-the-bus-on-nats.md +++ b/03-DESIGN/01-to-be/25-the-bus-on-nats.md @@ -30,19 +30,37 @@ Prose and diagrams only; no configuration is pasted. ## 1. What the bus is for -The bus carries five kinds of traffic today, and this design keeps the five, renaming nothing a -module can see: +**The bus is where the mesh happens.** Not a transport the mesh sends things over — the place a +module is reachable at all, where a role is addressed without knowing who holds it, where the +mesh's own state lives, and where what a module may say is decided by what it declared. -| Traffic | Today | Guarantee it needs | +| Traffic | Shape | Guarantee it needs | |---|---|---| -| **control** — a node's report, its heartbeat, a build's outcome, an enrolment | queues `control`, `.upgrades`, `.catchup` | nothing lost while the store restarts; retried; in order per node | -| **declarations** — the controller tells a node what to be | queue `node.` | the node gets the newest; a stale one is never applied | -| **builds** — the controller asks the build machine to build | queue `builds` | at least once, one builder at a time | -| **events** — a module says something happened | topic exchange `mesh.events`, keys `.` | delivered to every consumer that declared it; dead-lettered when it cannot be | -| **tools** — one module or person asks another's tool a question | exchange `mesh.rpc`, per-tool service queues `serve..` | one answer, from one server, or a timeout | +| **control** — a node's report, a build's outcome, an enrolment | job | nothing lost while the store restarts; retried; in order per node | +| **heartbeat** — a node saying it is alive | fire and forget | none; a lost one is the next one | +| **declarations** — the controller tells a node what to be | state | the node gets the newest; a stale one is never applied | +| **builds** — work for the build machine | job | at least once, one worker at a time | +| **events** — a module says something happened | 1:many | delivered to every consumer that declared it; dead-lettered when it cannot be | +| **tools** — a module or a person asks another's tool | request/reply | one answer, from one server, or a timeout | +| **work to a role** — a module submits to a capability without knowing who provides it | job | exactly one holder does it; it queues while nobody does | -The sdk's contract — `request`, `handle`, `publish`, `subscribe`, `close` — is the whole surface a -module sees, and it does not change ([ADR 0039](../../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md)). +The last two rows are the ones worth dwelling on, because they are not messaging in the sense of +carrying bytes from A to B. **A role is addressable**, so a caller names the capability and never +the module or the node — and the implementation can be replaced under it without a caller +changing ([ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md)). That is a +property of the mesh's architecture that happens to be expressed in subjects. + +And more of the mesh lands here as it is built: conditions and observed state in key-value +buckets that anything may watch, the server's own advisories becoming observations like any other +([research 017](../../01-RESEARCH/017-a-mesh-that-heals-itself/00-overview.md)), and a person's +client speaking the bus directly rather than through a surface built over it (§7). None of that +is a message being moved; all of it is the bus being the mesh's centre. + +**What a module sees of it is small and derived.** It declares what it emits, consumes, serves +and uses, and the subjects, streams, consumers and permissions all follow from that +([design 29](29-what-a-module-declares.md)). The sdk's contract — `request`, `handle`, `publish`, +`subscribe`, `close` — is the whole surface, and it does not change +([ADR 0039](../../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md)). ## 2. Subjects @@ -212,11 +230,11 @@ same place `modules/gitea/token.ts` keeps its own state rather than asking the h The host's only job is what it already does for any directory resource: keep the file's content current. Nothing is declared as `reload-on` or `restart-on` for this resource at all. -Its guard is the same rule as the AMQP broker's: the monitoring port is refused from anything but +Its guard is the same rule as the deprecated broker's: the monitoring port is refused from anything but the private network. It is raised at genesis like the store, adopted as a module in the same phase. -**The AMQP broker is an ordinary module, not a compatibility layer.** Revision, second review +**The deprecated broker is an ordinary module, not a compatibility layer.** Revision, second review ([ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md)): earlier text here, and ADR 0106 before it, called it `lavinmq-compat` — one purpose, the predecessor's clients, and a retirement condition of no client connected for a period the operator sets. It is none of those. @@ -231,17 +249,6 @@ that remains is about direction rather than software: *inter-module communicatio bus.* A module may hold a broker, a database or a cache for itself; it may not use one as a channel to another module. -**And with one purpose, which it did not have when this was written.** Revision, second review -([ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md) (superseding [ADR 0117](../../02-DECISIONS/0117-the-bus-is-the-only-broker.md))): two modules of the new mesh -required a broker *of their own* from it — a private vhost per consumer, the analog of -database-per-login, which is a different thing from the mesh's bus and was never examined here. -**There is one bus, and no module is handed a broker as a resource.** A module's messaging is -subjects on the bus under its own account, scoped by its `emits` and `consumes`; a module that -wants a queue of its own has a subject nothing else may publish to and a durable consumer, both -from its declaration. What it does not get is a server of its own. The `amqp` interface retires -with the compatibility broker instead of gaining a successor, and the two modules move to the bus -in step 4. - ## 6. Joining: the enrolment handshake Unchanged in shape, changed in transport. A node that has a token connects to the bus over TLS @@ -323,7 +330,7 @@ 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 +server is raised beside the deprecated 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 @@ -384,7 +391,7 @@ itself enforces and a plain client can therefore check: watcher-poll interval, without a restart. **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 +- the server is raised beside the deprecated 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; diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 949b379..ba1a84e 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -148,7 +148,7 @@ paper is wrong until there is a second mesh to find out. server's role, not the product. **Already true of the controller and needed no change**: it resolves the broker by seat ("that is where the broker is, whatever else the topology says") and names no broker module anywhere in its source. What remains is naming `nats` instead of - the AMQP broker where a genesis module set is declared, which is scenario and installer + the deprecated broker where a genesis module set is declared, which is scenario and installer configuration — carried with 1.6 rather than before it. - [ ] 1.6 the genesis-broker bed — **deferred**: beds are run once, at the end, rather than per step (novox/hq design 22's rule, and the operator's instruction). Every claim step 1 makes @@ -188,7 +188,7 @@ a foundation module by being raised again; it adopts one in place true and now proved**: the refusal is generic to any mesh-scoped seat, and three tests pin what matters for this one — a second bus anywhere is refused naming the seat, a *different* bus implementation is refused for the same reason (which is what lets the bus be replaced - at all), and the AMQP broker no longer contends for it, so both run on one mesh + at all), and the deprecated broker no longer contends for it, so both run on one mesh - [ ] 2.4 the adoption bed — deferred with the other beds > **Adoption here is not a no-op, and pretending it would be is the trap.** The host keeps an @@ -344,13 +344,13 @@ reserves them for after the move, and a flow built ahead of its design would be **Why here.** It is the only step that moves a node's bus, and it moves every node's at once. -- [ ] 5.1 the cutover bed: a mesh on AMQP with a predecessor stand-in on the compatibility broker +- [ ] 5.1 the cutover bed: a mesh on AMQP with a predecessor stand-in on the deprecated broker moves its bus in one rollout, every node reporting on NATS afterwards, the stand-in's own client still connected throughout - [ ] 5.2 the rollout: accounts composed, then the controller, every host and every runtime together; every node confirmed heard before AMQP stops -- [ ] 5.3 the mesh's accounts removed from the compatibility broker, leaving the predecessor's users -- [ ] 5.4 the compatibility broker retires when its condition holds — no client connected for the +- [ ] 5.3 the mesh's accounts removed from the deprecated broker, leaving the predecessor's users +- [ ] 5.4 the deprecated broker retires when its condition holds — no client connected for the period the operator sets **Done when.** Every node reports on NATS and the predecessor's clients never noticed. diff --git a/03-DESIGN/01-to-be/29-what-a-module-declares.md b/03-DESIGN/01-to-be/29-what-a-module-declares.md index f653bda..c0373b3 100644 --- a/03-DESIGN/01-to-be/29-what-a-module-declares.md +++ b/03-DESIGN/01-to-be/29-what-a-module-declares.md @@ -302,7 +302,7 @@ The mesh's own bus is **`mesh-bus`**, delivered by the `mesh-broker` seat and an controller — because the bus's accounts are configuration rather than something a provisioner creates, so there is no provisioner process in the path and nothing waiting on a bus account in order to make bus accounts. A module that runs a NATS server of its own and offers it as a -backing service provides **`nats`**, exactly as the AMQP broker provides `amqp` +backing service provides **`nats`**, exactly as the deprecated broker provides `amqp` ([ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md)). They are never the same name. A manifest saying `nats` could otherwise mean either the mesh's From 9946e852e14bd1577f9254a1d9664fd90fc1f8a0 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 00:01:56 +0200 Subject: [PATCH 23/44] WBS: 3.5's outbound half is in --- 03-DESIGN/01-to-be/28-building-the-bus.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index ba1a84e..53ab087 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -252,7 +252,17 @@ pays for itself furthest away. The seam turned out to be eight call sites — the same smallness that said this bus could be replaced at all. Still on `*amqp.Channel`: `RequestBuild` and `Ask`, which carry reply-queue machinery, and the whole consume side (the control loop, enrolment, serving). -- [ ] 3.5 the host's link on NATS — mirroring, still importing nothing +- [~] 3.5 the host's link on NATS — **the outbound half is through a seam**, mirroring the + controller's and still importing nothing of the mesh's own (ADR 0005): the host's own + interface over its own libraries, agreeing with the controller only because a fixture holds + both to one envelope. A report goes through JetStream because it is the message the + store-window guarantee is about; a heartbeat stays on core, because a heartbeat in a stream + is the mesh's least valuable message competing for retention with its most valuable. + + Still on the old client: dialling, the declaration consumer, and enrolment. The host's + **"newest wins" window narrows at the rollout rather than disappearing** — last-per-subject + makes catch-up the stream's and sequence orders definitively, but three pushes to a + connected node are still three deliveries. Recorded in the code where it is read. - [x] 3.6 the tool runtime's client on NATS, behind the unchanged sdk contract — round-tripped against a real server: a tool answered across two connections, a throwing handler reaching the caller as an error rather than a timeout, an event delivered once with its key, body, From 00817fb9e39c220121b386612979acb28045a2c6 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 00:06:15 +0200 Subject: [PATCH 24/44] Design 25: the store window, and what moving it into the server changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guarantee is the same and the mechanism is simpler — a nak with a delay, no parked list, nothing lost when the controller restarts. It costs one thing: a naked message comes back whatever happened meanwhile, so an older report is redelivered after a newer was applied. A report already carries the digest of the declaration it answers, so supersession becomes a check rather than memory — ordering settled by what a message says, not by when it arrived. --- 03-DESIGN/01-to-be/25-the-bus-on-nats.md | 27 +++++++++++++++++++++++ 03-DESIGN/01-to-be/28-building-the-bus.md | 13 +++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/03-DESIGN/01-to-be/25-the-bus-on-nats.md b/03-DESIGN/01-to-be/25-the-bus-on-nats.md index 0cf7128..4100103 100644 --- a/03-DESIGN/01-to-be/25-the-bus-on-nats.md +++ b/03-DESIGN/01-to-be/25-the-bus-on-nats.md @@ -128,6 +128,33 @@ call is a timeout the caller already handles. Streams and consumers are objects the controller creates at genesis and asserts on start; a module declares nothing about them. The controller is the only writer of stream definitions. +### The store window, and what moving it into the server changes + +The guarantee ([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)) is +that a push the controller cannot record because its store is restarting is **held and retried** — +never dropped, never falsely acknowledged. Here that is a `nak` with a delay: the server holds +the message and redelivers it, so the controller keeps no list of parked messages and one that +restarts mid-window loses nothing it was holding. + +**That is a plain win, and it introduces one problem worth naming.** Holding a delivery in memory +let the controller drop an older report when a newer one for the same node arrived, because +acting on the older after the newer would undo the newer. A `nak`ed message belongs to the server +and comes back whatever happened meanwhile — so the older report is redelivered *after* the newer +was applied. + +The answer was already in the message. A report carries the **digest of the declaration it is +about**, which exists because an earlier attempt to order reports by time lost the race it +invited: an apply that began under the previous declaration finishes after the next is sent, and +its report reads as newer than the send. Clocks cannot answer *which*. + +So supersession stops being something the controller remembers and becomes something it checks — +a report whose digest is not the one outstanding for that node is acknowledged without being +acted on. The same shape as a node refusing a superseded declaration by sequence +([issue 107](../../04-ISSUES/107-a-declaration-carries-no-order/00-report.md)): **ordering settled +by what a message says, not by when it arrived.** And staleness is checked before the store is +waited on, so a redelivery that lost its race does not hold a slot in the window that a current +message needs. + ## 4. Accounts [ADR 0043](../../02-DECISIONS/0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md) says a diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 53ab087..ac42cfb 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -250,8 +250,17 @@ pays for itself furthest away. rather than from the code that wrote it. The seam turned out to be eight call sites — the same smallness that said this bus could be - replaced at all. Still on `*amqp.Channel`: `RequestBuild` and `Ask`, which carry reply-queue - machinery, and the whole consume side (the control loop, enrolment, serving). + replaced at all. + + **The consume side's hard part is decided and tested**: the store window is a `nak` with a + delay rather than a delivery held in memory, which also means a controller restarting + mid-window loses nothing. Moving the holding into the server costs one thing — an older + report is redelivered after a newer was applied — and a report already carries the digest + of the declaration it is about, so supersession becomes a check rather than something the + controller remembers. Pure and tested without a bus, a store or a clock. + + Still outstanding: wiring that decision into the loop, `RequestBuild` and `Ask`, enrolment, + and serving. - [~] 3.5 the host's link on NATS — **the outbound half is through a seam**, mirroring the controller's and still importing nothing of the mesh's own (ADR 0005): the host's own interface over its own libraries, agreeing with the controller only because a fixture holds From 53092020eb555895dc8feb692438c09e8e6af3fd Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 00:11:44 +0200 Subject: [PATCH 25/44] WBS: asking a tool is through the seam; a build is a different shape --- 03-DESIGN/01-to-be/28-building-the-bus.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index ac42cfb..b564f8d 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -259,8 +259,19 @@ pays for itself furthest away. of the declaration it is about, so supersession becomes a check rather than something the controller remembers. Pure and tested without a bus, a store or a clock. - Still outstanding: wiring that decision into the loop, `RequestBuild` and `Ask`, enrolment, - and serving. + **Asking a tool is through the seam and loses two problems**: there is no reply queue to + declare and no correlation to check, because each account has one inbox prefix and an + answer cannot reach the wrong asker — which settles a cost the build code records having + paid, where every asker saw every result. And a tool nobody serves says so at once instead + of after the whole wait, which is the difference between "that module is down" and "that + tool is slow". + + **A build is a different shape, not the same one.** It takes minutes, so it is work + submitted to a queue with the outcome returning to a reply subject the request carries — + the pattern design 25 §2 already sets for anything crossing a stream. It touches the + builder as well, so it travels with that conversion in step 4. + + Still outstanding: wiring the window decision into the loop, enrolment, and serving. - [~] 3.5 the host's link on NATS — **the outbound half is through a seam**, mirroring the controller's and still importing nothing of the mesh's own (ADR 0005): the host's own interface over its own libraries, agreeing with the controller only because a fixture holds From 7e4da874a97ab943f800472bc80d7d6653050633 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 00:15:00 +0200 Subject: [PATCH 26/44] =?UTF-8?q?Design=2025=20=C2=A72:=20the=20eaten=20re?= =?UTF-8?q?ply=20address=20is=20verified,=20not=20assumed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A claim the whole enrolment handshake rests on, now measured against a running server rather than reasoned from documentation — and held by a test so it cannot become folklore if a server version changes. --- 03-DESIGN/01-to-be/25-the-bus-on-nats.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/03-DESIGN/01-to-be/25-the-bus-on-nats.md b/03-DESIGN/01-to-be/25-the-bus-on-nats.md index 4100103..1052976 100644 --- a/03-DESIGN/01-to-be/25-the-bus-on-nats.md +++ b/03-DESIGN/01-to-be/25-the-bus-on-nats.md @@ -97,7 +97,14 @@ exactly the current declaration and nothing older. That is the wire-level answer *is* the order, and a node that sees sequence n refuses n−1 by construction. **A reply-to travelling through a JetStream stream is carried in the payload, never in the -transport `Reply` field.** Revision, first review: core NATS request/reply sets the requester's +transport `Reply` field.** *Verified against a running server, 2026-09-27*: a caller published +asking for a reply to `_INBOX.LCr3M83q…`, and the consumer saw a `Reply` field of +`$JS.ACK.PROBE.probe_consumer.1.1.1…`. The address is replaced, not merely at risk — so the +payload-borne reply subject below is necessary rather than defensive, and the check is a test +rather than a note, because a future server that stopped doing this would leave enrolment +working and the reason for the field quietly becoming folklore. + +Revision, first review: core NATS request/reply sets the requester's ephemeral inbox as the message's `Reply` field, and a plain responder answers it directly — but a message a JetStream consumer delivers has already had that field claimed for the consumer's own ack address (`$JS.ACK.....`), so by the time the controller (§3's CONTROL From d898bd87e804fd899fc2a74a5a3ff0494fed3910 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 00:16:45 +0200 Subject: [PATCH 27/44] Step 5.4 was wrong from 0119 onward; removed It waited on a retirement condition 0119 abolished when it made the deprecated broker an ordinary provider. A step waiting for a condition nobody set would sit open forever. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index b564f8d..e511b0e 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -379,11 +379,20 @@ reserves them for after the move, and a flow built ahead of its design would be client still connected throughout - [ ] 5.2 the rollout: accounts composed, then the controller, every host and every runtime together; every node confirmed heard before AMQP stops -- [ ] 5.3 the mesh's accounts removed from the deprecated broker, leaving the predecessor's users -- [ ] 5.4 the deprecated broker retires when its condition holds — no client connected for the - period the operator sets +- [ ] 5.3 the mesh's own accounts removed from the deprecated broker: after the rollout nothing + of the mesh speaks to it, and an account nothing uses is one nobody rotates -**Done when.** Every node reports on NATS and the predecessor's clients never noticed. +> **5.4 is gone, and was wrong from ADR 0119 onward.** It read "the deprecated broker retires +> when its condition holds — no client connected for the period the operator sets", which is +> ADR 0106's framing of it as a compatibility module with an end date. +> [ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md) settled that it is an +> **ordinary provider** of the `amqp` provision, like any module answering a backing service: +> no seat, not foundation, and **no retirement condition**, because the day its last client +> disappears is not a day anything is waiting for. Removed rather than reworded — a step that +> waits for a condition nobody set would sit open forever. + +**Done when.** Every node reports on NATS, and nothing of the mesh's own is left connected to the +deprecated broker. ## The through-line From 4f93d304d7d5c5429bd0c8bd8fca495fc44822a2 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 00:18:05 +0200 Subject: [PATCH 28/44] WBS: a person's account is done; the client is not blocked by step 3 --- 03-DESIGN/01-to-be/28-building-the-bus.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index e511b0e..51363f4 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -360,9 +360,16 @@ it, and the beds that need a mesh living on NATS can finally run. - [ ] 4.2 a build source's change reaches the builder over the bus, and the build that follows is the one the change asked for - [ ] 4.3 an installation completes over the bus, with the same outcome as the path it replaces -- [ ] 4.4 a person's client: the account, the client that speaks the bus, and the tool surface over - it (design 25 §7) — a module's tool invoked from another node and from a person, refused from - an account that may not +- [~] 4.4 a person's client — **the account is done**: a person is not a module and holds no + seat, so their authority is a list of tools (or `*` for an administrator) and nothing else. + Held to four properties, each a way of being wrong that would not announce itself: nothing + but tools, so a person cannot claim a module said something; no ack subject, because + authority over a consumer that does not exist is authority nobody audits; no ability to + answer, because a person who can answer a request is impersonating a module on a bus where + anyone may serve a tool; and two people do not share an inbox. + + Still to build: the client program itself — the command line and the MCP surface over it. + It needs nothing from the consume side, so it is not blocked by step 3. - [ ] 4.5 reports and catch-up: a node that was unreachable catches up rather than losing them **Done when.** Each converted flow is proved against the behaviour it replaced, and the full genesis From f555d523c79f0d7208b611942b18f7bd06bdccec Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 00:55:07 +0200 Subject: [PATCH 29/44] WBS 3.4 is done both halves; issue 127 holds 4.2, 4.3 and catch-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controller's inbound is through a seam with both transports behind it, and the store window is now the server's rather than the controller's memory. Seven claims about that were asked of a running server rather than reasoned. Wiring the controller's own subscription is what found issue 127: every event name in the catalogue is still written the way a routing key is, so design 29's derivation turns a consumer's declaration into a subject no emitter publishes. Thirty-seven manifests, one that cannot be composed at all. It fails on the first mesh raised on the new bus and not before, which is why nothing had caught it — the conformance fixtures pin one emitter against one subject, and both halves of that pair are correct. The node-facing flows are unaffected: those subjects are the mesh's own and derive from nothing a module declares. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 89 ++++++++++++------ .../00-report.md | 93 +++++++++++++++++++ 2 files changed, 153 insertions(+), 29 deletions(-) create mode 100644 04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 51363f4..7c16fea 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -5,7 +5,7 @@ code: - mesh-catalog modules/nats - mesh-controller internal/catalogue - mesh-lab scenarios -updated: 2026-09-26 +updated: 2026-09-27 decisions: - 02-DECISIONS/0116-the-bus-is-built-in-five-steps.md - 02-DECISIONS/0106-the-bus-is-nats.md @@ -242,36 +242,48 @@ pays for itself furthest away. `x-event-id`. Claims checked against a running server are marked *verified* in the text, so a reader can tell what was measured from what was reasoned. One limitation lifts with the transport: a module may now call another's tool, which issue 049 recorded it could not. -- [~] 3.4 the controller's link on NATS — **the seam exists and the outbound half is through - it.** `Bus` is stated in the mesh's words (publish an event, declare to a node) rather than - a transport's, with an AMQP and a NATS implementation, both shipping: steps 1 to 4 leave - every node on AMQP, and both shipping is what lets one conformance fixture hold them to the - same envelope. The NATS one is checked against a real server, reading back from the stream - rather than from the code that wrote it. +- [x] 3.4 the controller's link on NATS — **both halves are through the seam, and the store + window is the server's.** `Bus` states the outbound in the mesh's words (publish an event, + declare to a node) and `Control` states the inbound (took it, dropped it, held it for the + store); each has an AMQP and a NATS implementation, and both ship, because steps 1 to 4 + leave every node on AMQP and both shipping is what holds them to one envelope. - The seam turned out to be eight call sites — the same smallness that said this bus could be - replaced at all. + The outbound seam turned out to be eight call sites; the inbound was the larger half, and + the reason: every handler took the transport's own delivery type, so the loop could not move + without moving enrolment, reports, builds, upgrades and catch-up with it in one breath. - **The consume side's hard part is decided and tested**: the store window is a `nak` with a - delay rather than a delivery held in memory, which also means a controller restarting - mid-window loses nothing. Moving the holding into the server costs one thing — an older - report is redelivered after a newer was applied — and a report already carries the digest - of the declaration it is about, so supersession becomes a check rather than something the - controller remembers. Pure and tested without a bus, a store or a clock. + **The window (ADR 0083) is now what decides, once, for both.** On the bus the mesh has, + holding a message means an unacknowledged delivery kept in the controller, bounded by the + prefetch and lost if it stops. On the bus being built it is a `nak` with a delay: the + message stays the server's and the controller keeps only the moment it first could not take + it, so one that restarts mid-window has nothing to lose. Seven claims about that were asked + of a running server rather than reasoned — a report heard and gone from the work queue, one + held through a store outage and recorded when it returned, one let go once the bound passed, + a superseded one settled without being acted on, a heartbeat heard and nothing persisted, + both followed events acknowledged on a stream the controller had no ack subject for, and the + enrolment answer arriving at the address the request carried in its payload. - **Asking a tool is through the seam and loses two problems**: there is no reply queue to - declare and no correlation to check, because each account has one inbox prefix and an - answer cannot reach the wrong asker — which settles a cost the build code records having - paid, where every asker saw every result. And a tool nobody serves says so at once instead - of after the whole wait, which is the difference between "that module is down" and "that - tool is slow". + **Three things the wiring forced into the open.** - **A build is a different shape, not the same one.** It takes minutes, so it is work - submitted to a queue with the outcome returning to a reply subject the request carries — - the pattern design 25 §2 already sets for anything crossing a stream. It touches the - builder as well, so it travels with that conversion in step 4. + *Supersession is asked before the store, not after.* A report about a declaration the mesh + has moved past would otherwise wait out a restarting store to be written, and then overwrite + what the node is doing now. - Still outstanding: wiring the window decision into the loop, enrolment, and serving. + *Half of a report is not about a declaration, and that half is never stale.* What the machine + **is** — the tunnel it took over, the ports its own bundle holds, what an adopted node found, + a node moving its overlay key — reaches the mesh on a report and nowhere else. A rekey set + aside as stale is a node whose overlay key never moves, and no retry is coming, because the + node said it once. So staleness is asked only of a report that is purely an apply's account. + + *The controller could not have consumed a module event at all.* Its account granted no event + subject to subscribe and no ack subject on the events stream, so every announcement would + have been redelivered for ever, refused by the permission list it already had. Both are now + granted, each subject named rather than by pattern — a controller subscribing every event in + the mesh is a permission list that has stopped saying what it is for. Its consumers are + **named beside the mesh's own streams rather than derived**, because the controller files no + manifest and authority cannot come from a declaration that does not exist. + + Still outstanding: a build's own shape, which travels with the builder in step 4. - [~] 3.5 the host's link on NATS — **the outbound half is through a seam**, mirroring the controller's and still importing nothing of the mesh's own (ADR 0005): the host's own interface over its own libraries, agreeing with the controller only because a fixture holds @@ -349,6 +361,17 @@ module. **Why here.** The links exist from step 3, so the flows that are not on the bus at all can move onto it, and the beds that need a mesh living on NATS can finally run. +> **A blocker surfaced here that is not this step's to fix.** Every event name in the catalogue is +> still written the way a routing key on the bus the mesh has is written, so the derivation design 29 +> §1 specifies turns a consumer's declaration into a subject **no emitter publishes** — thirty-seven +> manifests, and one that cannot be composed at all. Nothing fails on the bus the mesh runs on +> today, where a routing key is matched literally; it fails on the first mesh raised on the new bus +> and not before, which is why wiring the controller's own subscription is what found it. Opened as +> [issue 127](../../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md). +> It holds 4.2, 4.3 and the catch-up half of 4.5; the node-facing flows — enrolment, reports, +> heartbeats, a build's outcome — are unaffected, because those subjects are the mesh's own and +> derive from nothing a module declares. + - [ ] 4.1 **the full genesis bed** — a mesh raised on NATS from nothing and living on it: a node enrols over TLS with a claimed token and the enrolment user cannot read a declaration; a push is held while the store restarts and applies after, nothing lost or duplicated; a node that @@ -358,8 +381,10 @@ it, and the beds that need a mesh living on NATS can finally run. in the payload and not the transport field the consumer's ack has claimed. The server-enforced permissions were proved at step 1 and are not re-proved here - [ ] 4.2 a build source's change reaches the builder over the bus, and the build that follows is - the one the change asked for -- [ ] 4.3 an installation completes over the bus, with the same outcome as the path it replaces + the one the change asked for — **blocked by + [issue 127](../../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md)** +- [ ] 4.3 an installation completes over the bus, with the same outcome as the path it replaces — + **blocked by the same** - [~] 4.4 a person's client — **the account is done**: a person is not a module and holds no seat, so their authority is a list of tools (or `*` for an administrator) and nothing else. Held to four properties, each a way of being wrong that would not announce itself: nothing @@ -370,7 +395,13 @@ it, and the beds that need a mesh living on NATS can finally run. Still to build: the client program itself — the command line and the MCP surface over it. It needs nothing from the consume side, so it is not blocked by step 3. -- [ ] 4.5 reports and catch-up: a node that was unreachable catches up rather than losing them +- [~] 4.5 reports and catch-up: a node that was unreachable catches up rather than losing them — + **the reports half is in and proved against a server** (3.4): held through the store's + absence by the server rather than by the controller, superseded ones settled by the digest + they carry. The catch-up half is where issue 127 bites hardest: the controller replays a + build announcement under its **own** name rather than the builder's, so a catalogue + filtering the builder's subject hears nothing. Whether the controller may sign an event as + another module is a design question, not a wiring one, and it is open in that issue. **Done when.** Each converted flow is proved against the behaviour it replaced, and the full genesis bed is green. **Observation is not in this step** — heartbeats, conditions and key-value state are diff --git a/04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md b/04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md new file mode 100644 index 0000000..09b6733 --- /dev/null +++ b/04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md @@ -0,0 +1,93 @@ +--- +status: open +opened: 2026-09-27 +located-in: [] +fixed-by: +amended-design: +--- + +# 127 — A module's event derives a subject nothing publishes + +## What was observed + +[Design 29](../../03-DESIGN/01-to-be/29-what-a-module-declares.md) §1 says a module names an event +locally and the mesh derives the subject: `emits: order.placed` becomes +`mesh.mod..event.order.placed`, and a consumer declaring `consumes: shop.order.placed` +subscribes the emitter's own subject. That derivation is built and tested. + +**Every event name in the catalogue is still written the way a routing key on the bus the mesh has +is written** — `module..` — and the derivation reads it as `.`. Asked +of the composer directly, with the module names and declarations the catalogue holds today: + +| declared | derived | +|---|---| +| `builder` emits `module.builder.built` | publish `mesh.mod.builder.event.module.builder.built` | +| the catalogue consumes `module.builder.built` | subscribe `mesh.mod.module.event.builder.built` | +| a media module emits `module..download.completed` | publish `mesh.mod..event.module..download.completed` | +| a player consumes `module.*.download.completed` | subscribe `mesh.mod.module.event.*.download.completed` | + +The consumer's subject names a module called `module`. **No cross-module subscription in the +catalogue matches what any emitter publishes.** Thirty-seven manifests declare events; every one of +their consume declarations derives this way. + +Two further consequences of the same cause, found in the same check: + +- One module declares `consumes: "#"` — the wildcard of the bus the mesh has, which is not a + subject at all. The composer **refuses it outright**, so that module's account cannot be composed + and the module cannot be assigned. +- One module emits under a name that is not its own — it declares `module..image.pushed` + while being a differently named module — which the derivation puts inside *its* namespace. Whether + that is legitimate is a design question: design 29 §2 makes an event's source a fact the server + enforces, and this is a module claiming another's name in its own event. + +None of it fails on the bus the mesh runs on today, where a routing key is matched literally and +nothing derives anything. It fails only once the subject is derived — which is to say it fails on +the first mesh raised on the new bus, and not before. + +Evidence: run against the controller's own `PermissionsFor` on the current feature branch, with the +declarations read from the catalogue's manifests. Found while wiring the controller's consume side +(design 28 step 3.4), when the controller's own subscription had to be written and the subject it +would have to name turned out not to be the one design 29 specifies. + +## Why it matters beyond this instance + +**This is the failure [ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md) +exists to catch, arriving by a route the conformance suite does not cover.** Two implementations +that disagree about an envelope do not fail to compile — they ignore each other while both keep +running. Here it is not two implementations disagreeing but a *declaration* and a *derivation* +disagreeing, and the symptom is identical: every service starts, every log is quiet, and nothing +reacts to anything. + +The fixtures cannot catch it. They pin one emitter's envelope against one subject, and both halves +of that pair are correct. What is wrong is only visible when an emitter's derived subject is set +beside a consumer's derived subject — a check nothing performs, because until the subject was +derived there was nothing to compare. + +It also means the rule 3.8 established is weaker than it reads. That task asserted **no manifest +contains a subject**, which holds: a manifest contains a local name. What nothing asserts is that a +local name derives to a subject some emitter actually publishes, and the rule as stated is satisfied +by thirty-seven manifests whose names derive to nothing. + +And it blocks work already scheduled. Design 28's step 4.2 (a build source's change reaching the +builder over the bus) and 4.3 (an installation completing over the bus) are both event flows through +exactly these pairs, and the catch-up flow the controller answers is a third — the controller +currently replays a build announcement under its *own* name rather than the builder's, which a +consumer filtering the builder's subject will not hear either. + +## Open questions + +- Is a local name converted per manifest (`emits: built`), or does the derivation keep accepting the + old form and strip a redundant prefix? The first is thirty-seven manifests and a rule that can be + checked; the second is a rule that cannot, because `module.foo.bar` is also a legitimate three-part + local name. +- What checks the pair? An emitter's derived subject against every consumer's derived subject is a + whole-catalogue check, not a per-manifest one — and a module lives in its own repository and may + be registered long after the catalogue was checked. +- What are `#` and `*` in a consumed name? The bus the mesh has and the bus being built spell + wildcards differently, and a `consumes` pattern is the one place a module writes one. +- May a module emit an event named after another module, and if not, what does the module that does + it today declare instead? +- Who replays? A catch-up answer published by the controller under a builder's subject is the + controller signing an event as another module, which is the thing the derived namespace prevents. + If it must not, then a replay is a different message from an announcement, and the consumer needs + to be told so. From 0a61531c42990706cf1cac3a91415fe80541c7fa Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 01:32:26 +0200 Subject: [PATCH 30/44] WBS 3.5 done; 4.1 waits on one thing, an enrolment user per token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three halves of the host's link are through seams, and the reply address in the payload is now proved from both ends rather than one — the test asserts the transport's own field held the consumer's ack address by the time the request arrived, so a server that stopped claiming it fails a test instead of letting the reason become folklore. Two things had to be built for the host to hear anything at all: a node's declaration consumer, which only the controller may create, and the enrolment user's inbox, which design 25 §6 names and the composer granted none of. Both were silent gaps — a node with either missing looks correct and hears nothing. What remains is a single piece: something that composes an enrolment user per live token. On the old bus that account is made imperatively through the broker's management API; here there is no management API, so issuing a token has to recompose the server's configuration. It is the only thing between the two links and a mesh raised on NATS from nothing, so 4.1 now says so. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 53 +++++++++++++++++++---- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 7c16fea..f07d98c 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -284,17 +284,51 @@ pays for itself furthest away. manifest and authority cannot come from a declaration that does not exist. Still outstanding: a build's own shape, which travels with the builder in step 4. -- [~] 3.5 the host's link on NATS — **the outbound half is through a seam**, mirroring the +- [x] 3.5 the host's link on NATS — **all three halves are through seams**, mirroring the controller's and still importing nothing of the mesh's own (ADR 0005): the host's own - interface over its own libraries, agreeing with the controller only because a fixture holds + interfaces over its own libraries, agreeing with the controller only because a fixture holds both to one envelope. A report goes through JetStream because it is the message the - store-window guarantee is about; a heartbeat stays on core, because a heartbeat in a stream - is the mesh's least valuable message competing for retention with its most valuable. + store-window guarantee is about; a heartbeat stays on core, because a heartbeat in a stream is + the mesh's least valuable message competing for retention with its most valuable. - Still on the old client: dialling, the declaration consumer, and enrolment. The host's - **"newest wins" window narrows at the rollout rather than disappearing** — last-per-subject - makes catch-up the stream's and sequence orders definitively, but three pushes to a - connected node are still three deliveries. Recorded in the code where it is read. + `Link` is dialling, hearing and saying in one interface, because **dialling is where the + transport is chosen** and choosing it twice is how one half of a node ends up on a different + bus from the other. `Asking` is the enrolment conversation, and it is separate for the + opposite reason: almost nothing about it is the same, and a node that fails there is not in + the mesh at all. + + **What the new bus took away, and what it would not give.** A host declares nothing here: on + the old bus it declares its own queue, because a queue that is not there means a node that + hears nothing, but the object it reads through now is a durable consumer and a host's account + reaches no part of the JetStream API. So it **binds** to one the mesh made, and a missing one + is said as the mesh's to answer rather than quietly created with whatever the client defaults + to. Two things that had to be built for that: a node's declaration consumer (named after the + node, because its ack grant is derived from the node's name, so any other name is a delivery + it cannot acknowledge), and the enrolment user's **inbox** — design 25 §6 names it and the + composer granted none, so an enrolling node would have published its request and waited out + its timeout against a mesh that answered. + + **The reply address travels in the payload, and that is now proved from both ends.** The + controller reads it from there (3.4) and the host writes it there and waits on it, and the + test asserts the transport's own reply field held the *consumer's ack address* by the time the + request arrived — so a future server that stopped claiming that field fails a test rather than + letting the reason quietly become folklore. + + The host's **"newest wins" window narrows at the rollout rather than disappearing**, and that + is now measured rather than predicted: three declarations pushed to an absent node leave one + on the stream and it is the newest, so the catch-up half is the stream's — but three pushes to + a connected node are still three deliveries, which is the half that stays. + + **The pin turned out easier here than in the tool runtime, not harder.** The Go client takes a + `*tls.Config`, so the same pinned configuration with the same verify callback does the work; + the subject-alternative-name constraint recorded under 3.6 is that client's, because it takes + PEM strings with no verify hook. A host checks the fingerprint and nothing else. + + Still outstanding: **something that composes an enrolment user per live token.** Nothing does, + on either bus — on the old one the account is made imperatively through the broker's + management API when a token is issued, and here there is no management API, so issuing a token + has to recompose the server's configuration. That is the last piece of enrolment on the new + bus, and it is the only thing between the two links and a mesh raised on NATS from nothing. - [x] 3.6 the tool runtime's client on NATS, behind the unchanged sdk contract — round-tripped against a real server: a tool answered across two connections, a throwing handler reaching the caller as an error rather than a timeout, an event delivered once with its key, body, @@ -380,6 +414,9 @@ it, and the beds that need a mesh living on NATS can finally run. held by a `nak`-with-delay cycle still reaches the enrolling node, proving the reply travels in the payload and not the transport field the consumer's ack has claimed. The server-enforced permissions were proved at step 1 and are not re-proved here + — **waiting on one thing only**: an enrolment user composed per live token (3.5). Both links + speak NATS and every claim above has a unit test or a check against a running server behind + it; what no test can stand in for is a mesh raising itself, which is what this bed is - [ ] 4.2 a build source's change reaches the builder over the bus, and the build that follows is the one the change asked for — **blocked by [issue 127](../../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md)** From 970da74136e8a098b4ebefd65626f8a462e1bcaf Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 01:39:20 +0200 Subject: [PATCH 31/44] 1.7: the composer exists and the composition does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correcting a tick and a claim I made one commit ago. 4.1 does not wait on an enrolment user per live token; it waits on the whole composition, of which that user is one input. Tasks 1.3 and 1.4 are honest about what they built — the composer, the derivation, the permission model, the stream and consumer definitions, the asserter, all pure and held by unit tests and a golden composition. Nobody wrote the caller. Measured: outside the package that defines them there is not one use of the composer, the permission derivation, the stream set, the stream asserter or the principal type. Step 1's "done when" claims every account and permission composed from the manifests, and a mesh raised today would stand up a server with no user list at all. It also needs state the mesh does not keep. Design 25 §4 says the file holds bcrypt hashes and that passwords are minted and sealed exactly as today — but today the mesh mints one, hands it to the broker through a management call, seals the plaintext to the holder and keeps nothing. With no management call the hash has to survive every later recomposition, because the first thing a new module or a person's access change touches is a file that must still hold every other user's password. No bcrypt hash is stored anywhere in the controller. Named as its own task rather than folded into 1.3, so the gap between "the parts of step 1 exist" and "the mesh does any of it" is visible. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 36 ++++++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index f07d98c..c6ffb33 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -150,6 +150,29 @@ paper is wrong until there is a second mesh to find out. and names no broker module anywhere in its source. What remains is naming `nats` instead of the deprecated broker where a genesis module set is declared, which is scenario and installer configuration — carried with 1.6 rather than before it. +- [ ] 1.7 **the composition, delivered** — the controller gathering its principals, composing the + file, and asserting the streams and consumers on start. + + > **This corrects a tick, not a decision.** Tasks 1.3 and 1.4 are ticked and they are honest + > about what they built — the composer, the derivation, the permission model, the stream and + > consumer definitions, the asserter, all pure and held by unit tests and a golden + > composition. What nobody wrote is the *caller*. Measured on the feature branch: outside the + > package that defines them, there is **not one** use of the composer, the permission + > derivation, the stream set, the stream asserter or the principal type. Step 1's "done when" + > claims "every account and permission composed from the manifests", and a mesh raised today + > would stand up a server with no user list at all. + > + > It also needs state the mesh does not keep. Design 25 §4 says the file holds bcrypt + > hashes, and passwords are "minted and sealed exactly as today" — but today the mesh mints a + > password, hands it to the broker through a management call, seals the plaintext to the + > holder and **keeps nothing**. There is no management call here, so the hash has to survive + > for every later recomposition: the first thing a person's access change or a new module + > touches is a file that must still contain every other user's password. No bcrypt hash is + > stored anywhere in the controller today. + > + > Named as its own task rather than folded into 1.3 so the gap is visible: the parts of + > step 1 exist and the mesh does not yet do any of it. + - [ ] 1.6 the genesis-broker bed — **deferred**: beds are run once, at the end, rather than per step (novox/hq design 22's rule, and the operator's instruction). Every claim step 1 makes is covered by a unit test or was demonstrated against the real server; what the bed adds is @@ -324,11 +347,8 @@ pays for itself furthest away. the subject-alternative-name constraint recorded under 3.6 is that client's, because it takes PEM strings with no verify hook. A host checks the fingerprint and nothing else. - Still outstanding: **something that composes an enrolment user per live token.** Nothing does, - on either bus — on the old one the account is made imperatively through the broker's - management API when a token is issued, and here there is no management API, so issuing a token - has to recompose the server's configuration. That is the last piece of enrolment on the new - bus, and it is the only thing between the two links and a mesh raised on NATS from nothing. + Nothing here composes an enrolment user per live token, and that is **1.7's**, not this + task's: it is one input to a composition that does not happen at all yet. - [x] 3.6 the tool runtime's client on NATS, behind the unchanged sdk contract — round-tripped against a real server: a tool answered across two connections, a throwing handler reaching the caller as an error rather than a timeout, an event delivered once with its key, body, @@ -414,9 +434,9 @@ it, and the beds that need a mesh living on NATS can finally run. held by a `nak`-with-delay cycle still reaches the enrolling node, proving the reply travels in the payload and not the transport field the consumer's ack has claimed. The server-enforced permissions were proved at step 1 and are not re-proved here - — **waiting on one thing only**: an enrolment user composed per live token (3.5). Both links - speak NATS and every claim above has a unit test or a check against a running server behind - it; what no test can stand in for is a mesh raising itself, which is what this bed is + — **waiting on 1.7, the composition.** Both links speak NATS and every claim above has a unit + test or a check against a running server behind it; what none of them needs is a bus that + composed its own accounts, because each supplies its own. A mesh raising itself does need one - [ ] 4.2 a build source's change reaches the builder over the bus, and the build that follows is the one the change asked for — **blocked by [issue 127](../../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md)** From 8c91ba1cfab8cd262de332058abdbe3694bcdcd5 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 01:50:04 +0200 Subject: [PATCH 32/44] 1.7 in progress: the list is derived and the keys are kept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What is in: a bus user's hash is recorded and its plaintext returned once, and the user list is derived from the machines, what each runs, every manifest and which machines hold a live token. Permissions stay derived rather than stored, because a stored copy could disagree with the records it came from while both looked internally consistent. What is out, with what each needs, so the next person does not rediscover it: delivery, which has one open question about what a module declares in order to receive the file — design 29's ground, not this document's; minting, which is transport-coupled because an enrolment reply carries one password and a node on the old bus must not be handed a credential for the new one; and calling the assertions from a start path. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 48 ++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index c6ffb33..1421c79 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -150,9 +150,55 @@ paper is wrong until there is a second mesh to find out. and names no broker module anywhere in its source. What remains is naming `nats` instead of the deprecated broker where a genesis module set is declared, which is scenario and installer configuration — carried with 1.6 rather than before it. -- [ ] 1.7 **the composition, delivered** — the controller gathering its principals, composing the +- [~] 1.7 **the composition, delivered** — the controller gathering its principals, composing the file, and asserting the streams and consumers on start. + **In**: the user list is derived from the mesh's records and the credentials are kept. + + A bus user's bcrypt hash is now recorded, keyed by the username the file needs, and the + plaintext is returned exactly once. That state is new and the reason is worth stating: on the + bus the mesh runs on today an account is a management call — mint, hand over, seal to the + holder, keep nothing — and that works because the broker remembers. Here the users are one + file rewritten whenever any of it changes, so keeping nothing would mean **the first person's + access change silently blanking every module's password**. + + **Permissions are not kept, only credentials.** Authority is derived from what each module + declares every time the file is written ([ADR 0043](../../02-DECISIONS/0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md)); + a stored permission list would be a second account of a user's authority, able to disagree + with the records it came from while both looked internally consistent. + + The derivation refuses two things where they can still be named: two users with one name — the + server reads the file as one of them and which one depends on the order — and a module + assigned but absent from the catalogue, which would compose a user with no authority and fail + on its first publish with an authorisation error that says nothing about a missing manifest. + A user the mesh has minted no password for is *named* rather than dropped or written as a user + anybody is: an ordinary situation with an obvious remedy, and the caller decides whether a + partial file is worth writing. A seat's protocol is gathered across the whole catalogue, not + from one manifest, because a seat is declared by one module and held by another. + + **Out, and what each needs.** + + *Delivery.* Resolution already prepends `file` resources whose content comes from the + rendering — a certificate does exactly this, and refuses when a module asks for one and none + was issued. The composed configuration is the same shape, and the open question is what the + module *declares* in order to receive it, which is design 29's ground rather than this + document's: a field naming where it wants the file, or nothing at all because the module + holding `mesh-broker` is the one that gets it. The server's own values — ports, TLS paths, + store directory — are constants of the module's own resources today and would have to be read + from one place rather than two. + + *Minting, and it is transport-coupled.* A password is minted at enrolment and at assignment, + and an enrolment reply carries exactly one. **A node on the old bus must not be handed a + credential for the new one**, so which bus a node is joining has to be a fact the controller + holds before it can mint for both — the same switch the host's `Transport` is, from the other + end. + + *Asserting on start.* The streams, the controller's consumers and every node's are defined + and idempotent; nothing calls them from a start path yet. + + **People are not in the list**, deliberately: the account model is built and `operator issue` + is not (4.4), so there is nobody to derive. Left empty rather than guessed at. + > **This corrects a tick, not a decision.** Tasks 1.3 and 1.4 are ticked and they are honest > about what they built — the composer, the derivation, the permission model, the stream and > consumer definitions, the asserter, all pure and held by unit tests and a golden From a9b8f415700009bfeb1c9f35b18fea2109b7d225 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 02:51:40 +0200 Subject: [PATCH 33/44] =?UTF-8?q?Design=2025=20=C2=A74:=20the=20server=20v?= =?UTF-8?q?erifies=20no=20client=20certificate,=20and=20writes=20only=20ac?= =?UTF-8?q?counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections of fact, both found by building the module's image and connecting to it as a host would. The first composed configuration said `verify: true`, which makes the server demand a *client* certificate — and nothing in the mesh presents one. A host pins this server's exact certificate and authenticates with the password the mesh minted, and so does a module's runtime. Every connection in the mesh would have died at the TLS handshake before any password was looked at, with an error that reads as a fault in the client. TLS is still required; verify only decides whether client certificates are checked. Mutual TLS is a later question and would need machinery the mesh does not have — a certificate per module per node. And §4 read as though the controller wrote the whole file. It writes the user list and nothing else: ports, TLS paths and a store directory belong to the container the module raises. The two files share one directory of necessity, because an absolute include path is resolved relative to the including file's own directory. The decision stands in both cases — accounts are composed, not called for, and passwords are minted and sealed. What changed is what the file says and who writes which half. --- 03-DESIGN/01-to-be/25-the-bus-on-nats.md | 29 ++++++++++++++++-- 03-DESIGN/01-to-be/28-building-the-bus.md | 37 ++++++++++++++++------- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/03-DESIGN/01-to-be/25-the-bus-on-nats.md b/03-DESIGN/01-to-be/25-the-bus-on-nats.md index 1052976..4338f1a 100644 --- a/03-DESIGN/01-to-be/25-the-bus-on-nats.md +++ b/03-DESIGN/01-to-be/25-the-bus-on-nats.md @@ -7,7 +7,7 @@ code: - mesh-tools src/broker-amqp.ts (to be replaced) - mesh-catalog modules/nats (to be written) - mesh-sdk src (the protocol's NATS binding, step 3) -updated: 2026-09-26 +updated: 2026-09-27 decisions: - 02-DECISIONS/0106-the-bus-is-nats.md - 02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md @@ -213,7 +213,32 @@ expresses this exactly, per subject, and better than a vhost could: - **A person's user** (§7) is a module-shaped user with permissions on the tool subjects it may invoke, issued and revoked by the controller like any account. -**Accounts are configuration, not API calls.** The controller composes the server's user list and +**The server does not verify client certificates, and TLS is still required.** *Revision, +2026-09-27, found by building the module's image and connecting to it as a host would.* The first +composed configuration said `verify: true`, which makes the server demand a **client** certificate — +and nothing in the mesh presents one. A host pins this server's exact certificate and authenticates +with the password the mesh minted ([ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md)), +and a module's runtime does the same. With it on, every connection in the mesh dies at the TLS +handshake before any password is looked at, and the error — "client didn't provide a certificate" — +reads as a fault in the client rather than in the bus's configuration. The `tls` block is what makes +TLS required; `verify` only decides whether client certificates are checked. What is given up is a +second factor the mesh has no machinery to issue or rotate — a certificate per module per node — and +what is kept is stronger than a name check in both directions: an exact pin outward, a password +scoped per user inward. **Mutual TLS is a later question and would need that machinery first.** + +**The mesh composes the accounts; the module composes its server.** *Revision, 2026-09-27, while +building the composition.* An earlier reading of the paragraph below had the controller writing the +whole file. It writes only the user list. A server's ports, its TLS paths and its store directory +are properties of the container the module raises — they live in its image and its mounts and change +when it does — so the module declares its own configuration and `include`s the mesh's half. A +controller that wrote the whole file would have to be kept in step with a Dockerfile it never sees, +and a module could not change its own image without the mesh agreeing. Asking for the user list is +not enough to receive it: the file holds every user's password hash, so the claim on `mesh-broker` is +what authorises it. And the two files share one directory of necessity — an absolute include path is +resolved relative to the including file's own directory, so a server given one from elsewhere looks +for it underneath that directory and refuses to start. + +**Accounts are configuration, not API calls.** The controller composes the mesh's user list and its permissions into a file the host declares. **How that file reaches the running server is §5's, not this one's** — revision, first review: an earlier draft said "reloads" and cited a precedent that does not apply to a container (see §5). No management API, no credential travelling through a diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 1421c79..d698032 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -178,23 +178,38 @@ paper is wrong until there is a second mesh to find out. **Out, and what each needs.** - *Delivery.* Resolution already prepends `file` resources whose content comes from the - rendering — a certificate does exactly this, and refuses when a module asks for one and none - was issued. The composed configuration is the same shape, and the open question is what the - module *declares* in order to receive it, which is design 29's ground rather than this - document's: a field naming where it wants the file, or nothing at all because the module - holding `mesh-broker` is the one that gets it. The server's own values — ports, TLS paths, - store directory — are constants of the module's own resources today and would have to be read - from one place rather than two. + **Delivery is in, and it settled what a module declares.** The mesh writes the *accounts* and + the module owns its *server*. The alternative was a manifest field enumerating ports, TLS + paths and a store directory so the controller could write a whole configuration — wrong, + because those are properties of the container the module raises and the controller would have + to be kept in step with a Dockerfile it never sees. So a module declares its own configuration + as a file resource and `bus-users` names where the mesh's half goes beside it; **asking is not + enough to receive it**, because that file holds every user's password hash, so the claim on + `mesh-broker` is what authorises it. - *Minting, and it is transport-coupled.* A password is minted at enrolment and at assignment, + Two things a running server changed. **An absolute include path is resolved relative to the + including file's directory** — `include /etc/nats/accounts.conf` from another directory makes + the server look for it *under* that directory and refuse to start — so both files share one. + And **`verify: true` was refusing every connection in the mesh**: it makes the server demand a + *client* certificate, and nothing in the mesh presents one — a host pins this server's exact + certificate and authenticates with the password the mesh minted ([ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md), + design 25 §4). Every connection would have died at the TLS handshake before any password was + looked at, with an error that reads as a fault in the client. Removed; TLS is still required, + because the block is what requires it and `verify` only decides whether client certificates + are checked. **Design 25 §4 should say this**, and says nothing about it today. + + Also collected here: task 1.2's payoff, end to end against the module's own image — the user + list rewritten, the module noticing and reloading the server itself with no signal from + outside, and the connection the mesh already had still working afterwards. + + *Still out — minting, and it is transport-coupled.* A password is minted at enrolment and at assignment, and an enrolment reply carries exactly one. **A node on the old bus must not be handed a credential for the new one**, so which bus a node is joining has to be a fact the controller holds before it can mint for both — the same switch the host's `Transport` is, from the other end. - *Asserting on start.* The streams, the controller's consumers and every node's are defined - and idempotent; nothing calls them from a start path yet. + *Still out — asserting on start.* The streams, the controller's consumers and every node's + are defined and idempotent; nothing calls them from a start path yet. **People are not in the list**, deliberately: the account model is built and `operator issue` is not (4.4), so there is nobody to derive. Left empty rather than guessed at. From dd577e9ebeb18d326a9e08d3c0ca1ac579553de7 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 03:20:42 +0200 Subject: [PATCH 34/44] 1.7 is done; 4.1 waits on nothing but the bed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minting on both halves, the file delivered per push, and the bus's objects asserted on every start — verified against a real server that asserting twice changes nothing, that a machine joining an already-raised bus is accepted, that each node's consumer is bound to its own declaration subject, and that CONTROL does not dead-letter before the controller gives up. Which bus the mesh is on is one fact, and being told about both is refused at start rather than warned about: a mesh half on each is one where a declaration goes out on one bus and the report comes back on the other while every component logs success — ADR 0074's failure arriving through configuration rather than code. So 4.1 no longer waits on code. Both links speak NATS, the composition happens, and every claim behind them has a unit test or a check against a running server. What none of those can stand in for is a mesh raising itself, which is what the bed is — this is where the code stops and the lab starts. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 41 +++++++++++++++++------ 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index d698032..12f1aa2 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -150,7 +150,7 @@ paper is wrong until there is a second mesh to find out. and names no broker module anywhere in its source. What remains is naming `nats` instead of the deprecated broker where a genesis module set is declared, which is scenario and installer configuration — carried with 1.6 rather than before it. -- [~] 1.7 **the composition, delivered** — the controller gathering its principals, composing the +- [x] 1.7 **the composition, delivered** — the controller gathering its principals, composing the file, and asserting the streams and consumers on start. **In**: the user list is derived from the mesh's records and the credentials are kept. @@ -202,14 +202,32 @@ paper is wrong until there is a second mesh to find out. list rewritten, the module noticing and reloading the server itself with no signal from outside, and the connection the mesh already had still working afterwards. - *Still out — minting, and it is transport-coupled.* A password is minted at enrolment and at assignment, - and an enrolment reply carries exactly one. **A node on the old bus must not be handed a - credential for the new one**, so which bus a node is joining has to be a fact the controller - holds before it can mint for both — the same switch the host's `Transport` is, from the other - end. + **Minting is in, on both halves.** A node at enrolment, a module when its credential is + issued. Three things differ from a management call and each is the point of the move: the + credential is minted into the mesh's records and becomes usable at the next composition, so no + server need be reachable for it; the password travels beside the address rather than inside it, + because a credential embedded in a URL leaks into every log line that prints a connection; and + a module's durable consumer is derived from what it declared rather than named, so it cannot ask + for delivery of something it did not say it consumes. A node reconnecting may be refused until + the composition reaches the machine running the bus — which is what the host's reconnect backoff + is for, where waiting for the push would hold an enrolment open for as long as a declaration + takes to apply. - *Still out — asserting on start.* The streams, the controller's consumers and every node's - are defined and idempotent; nothing calls them from a start path yet. + **Which bus is one fact, and being told about both is refused at start.** Not warned about: a + mesh half on each is one where a declaration goes out on one bus and the report comes back on + the other, and every component logs success while it happens — ADR 0074's failure arriving + through configuration instead of through code. A node that came away holding a credential for + each could be half-moved, and nothing would say which half. + + **The objects are asserted on every start**, not created once at genesis: a stream somebody + deleted, a mesh raised from a restored backup, or a bus whose data directory was replaced all + have records and no objects, and a node whose consumer is missing hears nothing while everything + else about it looks correct. Against a real server: every object accepted, asserting twice + changes nothing (a start that failed the second time is a controller that cannot restart), a + machine joining an already-raised bus accepted, each node's consumer bound to its own + declaration subject and no other's, and CONTROL not dead-lettering — because the store window's + bound is the controller's, and a server that gave up first would discard the push the stream + exists to protect. **People are not in the list**, deliberately: the account model is built and `operator issue` is not (4.4), so there is nobody to derive. Left empty rather than guessed at. @@ -495,9 +513,10 @@ it, and the beds that need a mesh living on NATS can finally run. held by a `nak`-with-delay cycle still reaches the enrolling node, proving the reply travels in the payload and not the transport field the consumer's ack has claimed. The server-enforced permissions were proved at step 1 and are not re-proved here - — **waiting on 1.7, the composition.** Both links speak NATS and every claim above has a unit - test or a check against a running server behind it; what none of them needs is a bus that - composed its own accounts, because each supplies its own. A mesh raising itself does need one + — **nothing is outstanding but the bed itself.** Both links speak NATS, the composition + happens, and every claim above has a unit test or a check against a running server behind it. + What none of them can stand in for is a mesh raising itself, which is what this bed is — so this + is where the code stops and the lab starts - [ ] 4.2 a build source's change reaches the builder over the bus, and the build that follows is the one the change asked for — **blocked by [issue 127](../../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md)** From c4a8455e2efdf62c1452a79a6562585676bc877f Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 14:45:08 +0200 Subject: [PATCH 35/44] Issue 127 resolved; design 29 says what wildcards are and how the rule is checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every module named its events the way the old bus spelled a routing key, so on the new bus every cross-module subscription pointed at a namespace nobody publishes to. Nothing failed — the services started and none of them reacted. Converted, and the rule now has checks at both scales: at registration for one manifest, and as a test across the whole catalogue where a consumed event's emitter is present. It was larger than the report said, in two directions nobody had looked. Forty-three files of module code pass the event name at runtime, so the code mattered as much as the manifests. And both clients had to learn the mapping — without that, converting the modules would have broken the mesh that is actually running, which is the opposite of what fixing this was for. Design 29 gained three things it did not say: what a wildcard is (`*` for one name, `**` for the rest, spelled the mesh's way and derived to each bus's own), that an event about a role belongs on the seat and why that is not yet possible, and how the rule is checked — because "a subscription that matches nothing is silence" is exactly why nobody noticed thirty-seven manifests being wrong the same way. 4.2 and 4.3 are unblocked. The catch-up half of 4.5 is not: it is a decision, and it narrowed rather than closed. It cannot be a reply to a module's inbox, because that needs the blanket grant design 25 §4 refuses. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 29 +++-- .../01-to-be/29-what-a-module-declares.md | 36 +++++- .../00-report.md | 8 +- .../01-diagnosis.md | 109 ++++++++++++++++++ 4 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/01-diagnosis.md diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 12f1aa2..a56a9a9 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -518,10 +518,11 @@ it, and the beds that need a mesh living on NATS can finally run. What none of them can stand in for is a mesh raising itself, which is what this bed is — so this is where the code stops and the lab starts - [ ] 4.2 a build source's change reaches the builder over the bus, and the build that follows is - the one the change asked for — **blocked by - [issue 127](../../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md)** + the one the change asked for — **unblocked**: + [issue 127](../../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md) + is resolved, so an emitter and a consumer of the same event now land on the same subject - [ ] 4.3 an installation completes over the bus, with the same outcome as the path it replaces — - **blocked by the same** + **unblocked, same** - [~] 4.4 a person's client — **the account is done**: a person is not a module and holds no seat, so their authority is a list of tools (or `*` for an administrator) and nothing else. Held to four properties, each a way of being wrong that would not announce itself: nothing @@ -533,12 +534,22 @@ it, and the beds that need a mesh living on NATS can finally run. Still to build: the client program itself — the command line and the MCP surface over it. It needs nothing from the consume side, so it is not blocked by step 3. - [~] 4.5 reports and catch-up: a node that was unreachable catches up rather than losing them — - **the reports half is in and proved against a server** (3.4): held through the store's - absence by the server rather than by the controller, superseded ones settled by the digest - they carry. The catch-up half is where issue 127 bites hardest: the controller replays a - build announcement under its **own** name rather than the builder's, so a catalogue - filtering the builder's subject hears nothing. Whether the controller may sign an event as - another module is a design question, not a wiring one, and it is open in that issue. + **the reports half is in and proved against a server** (3.4): held through the store's absence + by the server rather than by the controller, superseded ones settled by the digest they carry. + + **The catch-up half is a decision, and issue 127 narrowed it.** The controller answers a + catalogue's request by re-publishing builds under its *own* name, which nothing subscribing the + builder's subject hears, and for which it holds no grant. Publishing them under the builder's + subject would be the controller signing an event as another module, which the derived namespace + exists to prevent. And it cannot become a reply to the catalogue's inbox either: answering a + module's inbox needs `_INBOX.>`, the blanket grant design 25 §4 refuses — found while giving the + controller the one narrow inbox it does need, to answer enrolments. + + So two options are left, and they differ in kind. A subject the controller may publish and a + catalogue may subscribe — the mesh's own event space, which does not exist yet. Or a durable + consumer that starts at the beginning of the stream, which removes the need to ask at all and + leans on retention instead: sound while the events are still there, and silent when they have + aged out, which is the failure the request was invented to avoid. **Done when.** Each converted flow is proved against the behaviour it replaced, and the full genesis bed is green. **Observation is not in this step** — heartbeats, conditions and key-value state are diff --git a/03-DESIGN/01-to-be/29-what-a-module-declares.md b/03-DESIGN/01-to-be/29-what-a-module-declares.md index c0373b3..2fa0186 100644 --- a/03-DESIGN/01-to-be/29-what-a-module-declares.md +++ b/03-DESIGN/01-to-be/29-what-a-module-declares.md @@ -2,7 +2,7 @@ layer: to-be status: proposed code: [] -updated: 2026-09-26 +updated: 2026-09-27 decisions: - 02-DECISIONS/0118-a-module-declares-its-own-seats.md - 02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md @@ -53,6 +53,33 @@ the catalogue, and the mesh would have hundreds of copies of a decision it made | seat `telegram-sender`, `accepts: send` | work-queue consumer on `mesh.seat.telegram-sender.accept.send` | | `uses: telegram-sender` | publish on that seat's `accept` subjects, and nothing else | +**Wildcards, and they are the mesh's rather than a bus's.** *Added 2026-09-27, from +[issue 127](../../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md).* A +consumer may write `*` for one name and `**` for the rest: `*.download.completed` is that event from +any module, and `**` on its own is every event in the mesh, which an audit logger wants and says in +one token. Spelled this way rather than the wire's, for the reason everything else here is local — +the bus the mesh runs on today spells these `*` and `#`, the one being built spells them `*` and +`>`, and a manifest naming either would stop being true when the wire changed. An emitted event +carries no wildcard: it names one event. + +**A module publishes under its own name, and an event about a role belongs to the seat.** *Added +2026-09-27, same source.* The bus enforces that a namespace belongs to the module it is named for, so +an event named for somebody else cannot be published at all. Where the event is really about a role — +"the artifact store accepted an image" — the seat is the right home, because that name outlives +whoever fills it, and a consumer written against the holder's own name breaks when the holder +changes. **Not yet possible in practice**: seats carry protocol in the manifest and in the permission +model, and the shared library has no way for a module to publish on one. Until it does, such an event +lives under the emitting module's own name and the consumer carries that coupling. + +**How the rule is checked, because it was not.** *Added 2026-09-27, same source.* Two checks, because +the mistake happens at two scales. Per manifest, at registration: an event is a local name, and the +old bus's form is refused with the name to write instead. Across the whole catalogue, as a test: +where a consumed event's emitter is present, it must emit that event. The second cannot demand a live +emitter for everything — a module lives in its own repository and may be installed long before the +one whose events it wants — so it says nothing about an absent emitter and everything about a present +one. **A subscription that matches nothing is not an error, it is silence**, which is why nothing +reported thirty-seven manifests being wrong the same way. + **It is `tools:`, not `serves:`.** Revision, found while implementing: the manifest already uses `serves` for the facts a consumer needs in order to reach a provision, and two meanings under one key in the file a module author reads most is a footgun. Worth noting that until now a module's @@ -73,6 +100,13 @@ and every manifest in the catalogue is still correct. That is the property [ADR 0039](../../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md) gave the sdk, applied to declarations. +**A handler names an event the way its manifest does.** *Added 2026-09-27, found while fixing issue +127.* The runtime handed a handler the event name alone, so a manifest declaring +`consumes: builder.built` produced a pattern that could never match the key it was compared against, +and a module consuming one event from two emitters could tell them apart only by reading a header. +The subject already carries the emitter, so the key a module sees names it too — which makes a +disagreement between a manifest and the code a typo rather than a category error. + ## 2. Three namespaces, and nothing else **Its own** — `mesh.mod..>`. Its events and its tools. Nothing else may publish into it, diff --git a/04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md b/04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md index 09b6733..754cc72 100644 --- a/04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md +++ b/04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md @@ -1,9 +1,9 @@ --- -status: open +status: resolved opened: 2026-09-27 -located-in: [] -fixed-by: -amended-design: +located-in: [mesh-catalog modules, mesh-control internal/catalogue, mesh-tools src] +fixed-by: mesh-catalog 7b06a7a, mesh-tools fbeb373, mesh-control 05ff606 +amended-design: 03-DESIGN/01-to-be/29-what-a-module-declares.md --- # 127 — A module's event derives a subject nothing publishes diff --git a/04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/01-diagnosis.md b/04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/01-diagnosis.md new file mode 100644 index 0000000..bc1fb92 --- /dev/null +++ b/04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/01-diagnosis.md @@ -0,0 +1,109 @@ +# Diagnosis — 2026-09-27 + +## Where it lives + +Three places, and only one of them is a bug in code. + +**The manifests, in the module catalogue.** Thirty-seven declare events, and every one of them +spells an event the way a routing key on the bus the mesh runs on today is spelled — +`module..`. [Design 29](../../03-DESIGN/01-to-be/29-what-a-module-declares.md) §1 says +a module names an event **locally and bare** (`emits: order.placed`) and a consumer names +`.` (`consumes: billing.order.placed`). So the manifests are stale against a rule +that was already decided, not wrong against an undecided one. **This is the whole of the reported +symptom.** + +**The manifest's own documentation, in the parser.** The comments on `emits` and `consumes` still +describe the old convention and give the old examples — "dotted topic keys, e.g. +`module.umami.site.created`", and `"#"` named as the audit logger's pattern. A module author reading +the file they read most is being told to write the thing that does not work. That is why the drift +was uniform across thirty-seven manifests rather than scattered: nobody was mistaken, everyone +followed the documentation. + +**Nothing checks either one.** `ParseManifest` validates the module name, the slug, what it provides +and what it requires. It says nothing about an event name. So a local name that derives to a +namespace belonging to a module called `module` is accepted by every check the mesh has, and the +first thing that notices is a subscription that never fires. + +## What was ruled out + +**The derivation is not wrong.** Asked directly, with the module names and declarations the +catalogue holds, `PermissionsFor` produces exactly what design 29 §1 specifies for the input it is +given: it reads a consumer's `.` and builds the emitter's subject. Given +`module.builder.built` it reads the emitter as `module`, which is a correct reading of an incorrect +declaration. + +**The conformance fixtures are not at fault and could not have caught it.** They pin one emitter's +envelope against one subject, and both halves of that pair are correct. What is wrong is only +visible when an emitter's derived subject is set beside a *consumer's* derived subject — a +comparison nothing performs, because until the subject was derived there was nothing to compare. + +**Task 3.8's rule is not broken, it is weaker than it reads.** That task asserted **no manifest +contains a subject**, which holds: a manifest contains a local name. Nothing asserts that a local +name derives to a subject some emitter actually publishes. + +## What is still a decision and not a conversion + +Converting the manifests is implementing design 29, not deciding anything. Three of the report's +open questions are not: + +- **Wildcards.** Design 29's table has no wildcard row, and two manifests need one: a module + consuming every download completion across several media modules, and an audit logger consuming + everything. The two buses spell wildcards differently, and a `consumes` pattern is the one place + a module writes one. +- **A module emitting under another module's name.** One manifest declares an event named for a + *provision* rather than for itself. Design 29 §2 makes an event's source a fact the bus enforces, + so this cannot survive as written — and the remedy is probably not a rename but a **seat**, which + is what a name stable across whoever implements it already is. +- **Who replays a build announcement.** The controller answers a catalogue's catch-up by + re-publishing builds under its *own* name, which no consumer of the builder's subject hears, and + for which it holds no grant. Publishing them under the builder's subject would be the controller + signing an event as another module — the exact thing the derived namespace prevents. So the + catch-up is either a different message or a different mechanism, and that is a decision. + +## Owners + +`located-in` names the manifests and the parser. The replay question reaches the controller and the +catalogue module together and is recorded above rather than in that field, because it is not where +this symptom lives. + +# Fixed — 2026-09-27 + +Converted, and the rule now has checks. What it took was larger than the report said, in two +directions nobody had looked. + +**The module code, not just the manifests.** Forty-three files pass an event name to `emit()` at +runtime, and the runtime builds the subject from what it is handed. A converted manifest with +unconverted code would have had the permission and the subject disagree — the same silence, one layer +down. + +**Both clients had to learn the mapping.** Each passed the name straight through, which was right on +the bus the mesh runs on today only because modules were writing routing keys. So the old bus's client +now turns a local name into `module..` on the way out and back on the way in. +**Without that, converting the modules would have broken the mesh that is actually running** — which +is the opposite of what fixing this was for. + +**The declaration and the handler spoke different vocabularies.** The key a module's handler saw was +the event name alone, while its manifest names `.`. So a correct manifest produced a +pattern that could never match. The subject already carries the emitter; the key names it now. + +## The three open questions, answered + +- **Wildcards**: `*` is one name, `**` is the rest, spelled the mesh's way and derived to each bus's + own. `**` alone is every event, which is what the audit logger wanted and now says in one token. +- **A module emitting under another's name**: not allowed, and the remedy is the seat rather than a + rename — a role's name outlives whoever fills it. **Deferred in practice**: seats carry protocol in + the manifest and in the permission model, and the shared library cannot publish on one, so the + module that did this emits under its own name and its consumers carry that coupling. Worth a task + when a seat's holder needs to emit. +- **Who replays a build announcement**: still open, and narrowed. It cannot become a reply to the + catalogue's inbox: answering a module's inbox needs `_INBOX.>`, which is the blanket grant + [design 25](../../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §4 refuses. So the remaining options are + a subject the controller may publish and the catalogue may subscribe, or a durable consumer that + starts at the beginning of the stream and removes the need to ask at all. Recorded on the work + breakdown as the catch-up half of task 4.5 rather than here, because it is no longer this symptom. + +## What it found while running + +Two dangling subscriptions that predated this and nothing had reported: a module emitting an event its +manifest never declared, which the new bus refuses outright, and a module waiting for an event nothing +emits — a demo that could never be triggered, because only that module may publish under its own name. From c8f430290e851f3b9133e100a10e9307ac058bd9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 15:24:17 +0200 Subject: [PATCH 36/44] ADR 0121: a seat carries the protocol of its role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh's own seats said who does a job and nothing about what may be said to them or by them, and that gap showed up three times in one day looking like three different problems: a build machine with three audiences for one outcome and no way to derive a grant for any of them; an event genuinely about a role with nowhere to live but the namespace of whichever module holds that role today; and a catalogue catching up on builds, where every option needed a grant the design refuses. One cause — the mesh has roles it cannot describe. So the `mesh-*` seats take the same three fields a module's seat has, and the machinery that already derives authority, queues and consumers from a declared seat does it for these too. Builds become work submitted to a role, and `mesh.build.request`, `mesh.control.built` and the BUILDS stream retire. A work queue shared by several build machines is exactly what a seat's `accepts` is, so a second mechanism for it was two places a permission could be wrong. The outcome is the seat's own event, which means one publish still reaches whoever asked, the controller that records it and the catalogue that places it — the fan-out a shared exchange gave for free, written as a subject the mesh derived rather than a topology somebody configured. That also avoids the grant that ruled out the alternatives: no holder needs permission to publish into an asker's inbox. The blocking gap is now named rather than incidental: the shared library has no way for a module to publish on a seat. The build machine is Go and reaches the bus directly, so it is unaffected; the artifact-store event waits. --- ...a-seat-carries-the-protocol-of-its-role.md | 91 +++++++++++++++++++ 02-DECISIONS/README.md | 1 + 03-DESIGN/01-to-be/25-the-bus-on-nats.md | 18 +++- .../01-to-be/29-what-a-module-declares.md | 20 ++++ 4 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md diff --git a/02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md b/02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md new file mode 100644 index 0000000..3ee5fce --- /dev/null +++ b/02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md @@ -0,0 +1,91 @@ +--- +topic: the mesh +status: accepted +date: 2026-09-27 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0118-a-module-declares-its-own-seats.md +--- + +# 121. A seat carries the protocol of its role + +## Context + +[ADR 0118](0118-a-module-declares-its-own-seats.md) let a module declare a seat with its protocol: +what work the role accepts, what it emits, what it serves. A module's own seats work that way today. +**The mesh's own seats — the `mesh-*` set — carry no protocol at all**, only a name, a scope and the +provision they deliver. They say who does a job and nothing about what may be said to them or by +them. + +That gap surfaced three times in one day, each time as a different-looking problem. + +**A build machine.** On the bus the mesh runs on today a builder has its own account kind, created by +its own command, with permissions written by hand: read the build queue, write to two exchanges. One +publish to a shared exchange reached all three audiences a finished build has — whoever asked, the +controller that records it, and the catalogue that places it in the module graph. On a bus where +permissions are per subject those are three separate grants, and nothing derives them, because a +builder is not a module and holds a seat that promises nothing. + +**An event about a role rather than about a module.** The module holding the artifact-store seat +declared an event named after a *different* module +([issue 127](../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md)). The +bus refuses that, because a namespace belongs to who it is named for. The event is genuinely about the +role — "the artifact store accepted an image" — and a consumer written against whichever module holds +that role today breaks when the holder changes. There was nowhere else to put it. + +**A catalogue catching up.** The controller answers a request for builds it may have missed by +re-publishing them under its own name, which no consumer of the builder's subject hears. Publishing +them under the builder's name would be the controller signing an event as another module. Answering +into the asker's inbox needs a grant over every inbox in the mesh, which +[design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §4 refuses. + +Three symptoms, one cause: **the mesh has roles it cannot describe.** + +## Decision + +**A seat carries the protocol of its role, whether the seat is a module's or the mesh's own.** The +`mesh-*` set gains the same three fields a declared seat has — what it accepts, what it emits, what it +serves — and the holder's authority, its work queue and its consumers are derived from them by the +machinery that already does this for a module's seats. + +**Builds become work submitted to a role.** The build machine seat accepts a build and emits an +outcome. The dedicated `mesh.build.*` branch and the stream behind it retire: a work queue shared by +several build machines is exactly what a seat's `accepts` already is, and keeping a second mechanism +for it means two things to reason about and two places for a permission to be wrong. + +**One publish still reaches three audiences, and now the mesh derived the subject.** A build's outcome +is the seat's own event. Whoever asked matches it by the id their request carried; the controller +records it; the catalogue places it. That is the fan-out the shared exchange gave for free, expressed +as a subject rather than as a topology, and it means no holder needs permission to publish into +anybody's inbox. + +## Alternatives considered + +**A dedicated principal kind for a builder**, mirroring the account the old bus issues it. Smaller: one +addition to the composer, no change to seats, and it matches how a builder is treated today. Not taken +because it answers one of the three symptoms and leaves the other two, and because "the builder is +special" is a claim nobody could justify from the design — a build machine is a role the mesh has, and +the mesh has a word for a role. + +**Leaving the outcome as a reply to the asker's inbox.** Rejected on authority: a holder able to answer +any asker needs a grant across the whole inbox space, which is the one grant design 25 §4 refuses by +name. The seat's event costs the asker a filter and costs the mesh nothing. + +## Consequences + +**A seat is now the mesh's unit of "a role that talks".** A role that accepts work, announces outcomes +or answers questions says so where it is defined, and everything about permissions, queues and +consumers follows. Nothing hand-writes a grant for a role again. + +**The shared library cannot yet publish on a seat, and that is now the blocking gap rather than a +curiosity.** A module holding a seat has the authority and no way to use it; the build machine is +written in Go and reaches the bus directly, so it is unaffected, but the artifact-store event stays +under its module's own name until the library has a surface for this. That is a task, and this record +is what makes it one. + +**A second mechanism disappears.** `mesh.build.*`, the BUILDS stream and the builder's hand-written +account all retire. Fewer things, and the ones left are derived. + +**The catch-up question is not settled by this**, only made answerable: a seat that serves something +gives the controller a way to be asked, which the mesh did not have. Whether catch-up should be a +question at all remains open. diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index 740bca0..7c325c6 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -136,6 +136,7 @@ python3 00-META/checks/index.py fail if stale - **0117** — [The bus is the only broker](0117-the-bus-is-the-only-broker.md) *(superseded)* - **0119** — [AMQP is a provision, not the bus](0119-amqp-is-a-provision-not-the-bus.md) - **0120** — [The mesh bus is required, not ambient](0120-the-mesh-bus-is-required-not-ambient.md) +- **0121** — [A seat carries the protocol of its role](0121-a-seat-carries-the-protocol-of-its-role.md) ### Its tiers, from the bottom up diff --git a/03-DESIGN/01-to-be/25-the-bus-on-nats.md b/03-DESIGN/01-to-be/25-the-bus-on-nats.md index 4338f1a..8613129 100644 --- a/03-DESIGN/01-to-be/25-the-bus-on-nats.md +++ b/03-DESIGN/01-to-be/25-the-bus-on-nats.md @@ -18,6 +18,7 @@ decisions: - 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 + - 02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md --- # 25. The bus on NATS @@ -71,9 +72,7 @@ permissions are expressed as which branches of it that account may publish to an mesh.control..report a node's report (JetStream: CONTROL) mesh.control..alive heartbeat (core, no persistence) mesh.control.enrol an enrolment request (JetStream: CONTROL) -mesh.control.built a build's outcome (JetStream: CONTROL) mesh.node..declare a declaration for a node (JetStream: NODES, last-per-subject) -mesh.build.request work for the build machine (JetStream: BUILDS, work queue) mesh.mod..event. an event (JetStream: EVENTS) mesh.mod..tool. a tool invocation (core request/reply) mesh.seat..accept. work submitted to a role (JetStream: per-seat work queue) @@ -82,6 +81,15 @@ mesh.seat..tool. a role's tool (core request/rep mesh.ask.. the controller's command api (core request/reply) ``` +**Revised 2026-09-27** ([ADR 0121](../../02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md)): +**`mesh.build.request`, `mesh.control.built` and the BUILDS stream are gone.** A build is work submitted to a role, and the +mesh already has a shape for that — a seat's `accept` subjects, on a work queue with a queue group of +holders, which is what a build queue shared by several machines *is*. Keeping a second mechanism for it +meant two things to reason about and two places for a permission to be wrong. A build's outcome is the +seat's own event — which is why the control branch loses its copy too: one publish reaches whoever +asked, the controller that records it and the catalogue that places it — the fan-out a shared exchange gave for free, as a derived subject rather than a +configured topology. + **Revised 2026-09-26** ([design 29](29-what-a-module-declares.md)): a module's events and tools moved from `mesh.events.*` / `mesh.tools.*` into one namespace per module, `mesh.mod..>`, so a module's authority over its own name is a single subject pattern the server enforces — and @@ -124,9 +132,8 @@ Core NATS is at-most-once. Everything the mesh must not lose lives in a JetStrea | Stream | Subjects | Retention | Why | |---|---|---|---| -| CONTROL | `mesh.control.>` except `alive` | work queue, one consumer (the controller), explicit ack | the store-window guarantee ([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)): the controller `nak`s with a delay while its store is away and the message is redelivered; nothing is dropped | +| CONTROL | `mesh.control.>` except `alive` (a build's outcome moved to its seat, ADR 0121) | work queue, one consumer (the controller), explicit ack | the store-window guarantee ([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)): the controller `nak`s with a delay while its store is away and the message is redelivered; nothing is dropped | | NODES | `mesh.node.>` | last per subject | one declaration per node, always the newest | -| BUILDS | `mesh.build.>` | work queue, explicit ack | at least once; a builder that dies mid-build has its message redelivered | | EVENTS | `mesh.mod.*.event.>` | limits (age, size), durable consumer per subscribing module | a subscriber that was down catches up; after `max-deliver` attempts the advisory feeds `mesh.events.dead` (its own small stream) | Tool calls and heartbeats stay on core NATS: a lost heartbeat is the next heartbeat; a lost tool @@ -207,7 +214,8 @@ expresses this exactly, per subject, and better than a vhost could: permissions for each consumed event's subject, its tool subjects, and that same inbox prefix. Nothing else. A module that tries to publish outside its emits is refused by the server, not by convention. -- **The controller's user** owns `mesh.control.>`, `mesh.node.>`, `mesh.build.>` and the streams. +- **The controller's user** owns `mesh.control.>`, `mesh.node.>` and the streams, and may submit work + to the seats the mesh's own flows use — a build, for one (ADR 0121). **A host's user** may publish its own `mesh.control..>` and subscribe its own `mesh.node..declare` — and nothing of any other node's. - **A person's user** (§7) is a module-shaped user with permissions on the tool subjects it may diff --git a/03-DESIGN/01-to-be/29-what-a-module-declares.md b/03-DESIGN/01-to-be/29-what-a-module-declares.md index 2fa0186..0184627 100644 --- a/03-DESIGN/01-to-be/29-what-a-module-declares.md +++ b/03-DESIGN/01-to-be/29-what-a-module-declares.md @@ -11,6 +11,7 @@ decisions: - 02-DECISIONS/0041-events-are-a-relationship.md - 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md - 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md + - 02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md --- # 29. What a module declares, and what the bus makes of it @@ -130,6 +131,25 @@ seat. The module does not name them, does not know their names, and cannot misco and the controller stays the only writer of stream and consumer definitions ([design 25](25-the-bus-on-nats.md) §3). +**The mesh's own seats carry protocol too.** *Added 2026-09-27, +[ADR 0121](../../02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md).* A seat declared by a +module says what it accepts, emits and serves; the `mesh-*` set said only who does a job. So the mesh +had roles it could not describe — a build machine with three audiences for one outcome and no way to +derive a grant for any of them, and an event genuinely about a role with nowhere to live but the +namespace of whichever module happens to hold it. The mesh's seats now take the same three fields, and +the same machinery derives the holder's authority, its work queue and its consumers. + +**So a build is work submitted to a role, like any other.** The build machine seat accepts a build and +emits an outcome, and the dedicated branch that carried builds retires: a work queue shared by several +machines is exactly what `accepts` already is, and a second mechanism for it is two places a permission +can be wrong. + +**And one publish reaches three audiences without anybody's inbox being opened.** A build's outcome is +the seat's own event: whoever asked matches it by the id their request carried, the controller records +it, the catalogue places it in the graph. That is the fan-out a shared exchange gave for free, written +as a subject the mesh derived instead of a topology somebody configured — and it is why a holder needs +no permission to publish into an asker's inbox, which is the one grant design 25 §4 refuses by name. + **Retention belongs to whoever owns the namespace, not to a consumer.** A seat declares how long its inbound backlog survives, because that is a property of the service: From 85c0a3e567bf1059a1a14aa4da5b7b26deb3b430 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 16:02:17 +0200 Subject: [PATCH 37/44] 4.2 done: a build is work submitted to a role, on both buses Both sides behind a seam, one implementation per bus, and the outcome is the role's own event so one publish reaches the asker, the controller and the catalogue. Checked against a running server, including the part the decision rests on: a third party hears the same outcome the asker does. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index a56a9a9..d01b66e 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -517,10 +517,23 @@ it, and the beds that need a mesh living on NATS can finally run. happens, and every claim above has a unit test or a check against a running server behind it. What none of them can stand in for is a mesh raising itself, which is what this bed is — so this is where the code stops and the lab starts -- [ ] 4.2 a build source's change reaches the builder over the bus, and the build that follows is - the one the change asked for — **unblocked**: - [issue 127](../../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md) - is resolved, so an emitter and a consumer of the same event now land on the same subject +- [x] 4.2 a build source's change reaches the builder over the bus, and the build that follows is + the one the change asked for — **a build is work submitted to a role now** + ([ADR 0121](../../02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md)). Both sides + are behind a seam with an implementation per bus, and on the bus being built one publish does + what two did: the outcome is the role's own event, so the asker matches it by the id its + request carried, the controller records it and the catalogue places it in the graph. A build + machine needs a reply queue for nothing and a grant over nobody's inbox. + + Checked against a running server: the round trip; a third party on the role's event hearing + the same outcome the asker did, which is what the decision rests on; work leaving the queue + once settled, so no second machine repeats it; work submitted with no machine holding the role + **waiting rather than failing**, and being done when one arrives; and work a machine handed + back coming round again. + + The outcome carries the module name, because only the manifest says what was built and one + message now has three readers. A failed build names none: it produced no module version, and + the catalogue would otherwise place something that was never made. - [ ] 4.3 an installation completes over the bus, with the same outcome as the path it replaces — **unblocked, same** - [~] 4.4 a person's client — **the account is done**: a person is not a module and holds no From bfefb1dbdb49c2fdd29484d21a7e4bfe2914102b Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 16:39:48 +0200 Subject: [PATCH 38/44] 4.3: the installer can raise a mesh on the new bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A foundation template that stands the server up, writes its settings and the mesh's first user list beside them, and starts a controller on the new bus. The first user list is the installer's because at genesis there is no mesh to compose one — a bootstrap credential, rotated like the store's. The carried list is checked against what the controller derives, since a mesh cannot be raised twice to discover they disagreed. That check immediately found the composer granting a role's whole event branch as well as the one event it follows. What is left of 4.3 is running it, which is 4.1's bed. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 25 +++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index d01b66e..6576b08 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -534,8 +534,29 @@ it, and the beds that need a mesh living on NATS can finally run. The outcome carries the module name, because only the manifest says what was built and one message now has three readers. A failed build names none: it produced no module version, and the catalogue would otherwise place something that was never made. -- [ ] 4.3 an installation completes over the bus, with the same outcome as the path it replaces — - **unblocked, same** +- [~] 4.3 an installation completes over the bus, with the same outcome as the path it replaces — + **the installer can raise it**: a foundation template that stands up the server, writes the + server's own settings and the mesh's first user list beside them, and starts a controller + reaching the new bus. What remains is running it, which is 4.1's bed. + + **The mesh composes its own user list, and at genesis there is no mesh to compose one.** So the + installer carries the first — the controller's account at a well-known bootstrap password, + exactly as the store is reached at `postgres:bootstrap` and the old bus at `guest:guest`, and + rotated with them. From the controller's first composition onward the file is the controller's. + + That surfaced a gap reading would not have found: the controller's own account exists before + there is a controller to mint one, so nothing recorded a hash for it and its first composition + would have left the writer out of the file it was writing — a bus nothing can connect to, + produced by the thing connected to it. It records a hash of the credential it is using, and only + when none is recorded, so a restart cannot put the bootstrap password back over a rotated one. + + **The carried list and the derived one are checked against each other**, because they are two + statements of one fact and a mesh cannot be raised twice to find out they disagreed. A template + granting less than the controller derives produces a mesh that comes up, connects, and is + refused on its first act, with an authorisation error naming a subject rather than the template + that forgot it. The check earned itself at once: the composer was granting a role's whole event + branch *and* the one event it follows, and the wider grant wins — so only the submitting half of + a role is granted now, and what comes back is named exactly. - [~] 4.4 a person's client — **the account is done**: a person is not a module and holds no seat, so their authority is a list of tools (or `*` for an administrator) and nothing else. Held to four properties, each a way of being wrong that would not announce itself: nothing From fc64a2c1a45f69549dd1b7970c667536fdfc9b84 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 17:07:28 +0200 Subject: [PATCH 39/44] 4.4 done: a person's account and their client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account existed as a permission model and as nothing a person could be given; there is a record and three commands now. The client is two surfaces over one thing, a command line and an MCP server, both using the client a module's runtime uses — so what a person may do is answered by the same permission list that answers it for a module. Design 25 §7 says nothing of this is built before its bed passes, and this was built before. Noted in the task rather than quietly ignored. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 42 ++++++++++++++++++----- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 6576b08..abd5e32 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -557,16 +557,40 @@ it, and the beds that need a mesh living on NATS can finally run. that forgot it. The check earned itself at once: the composer was granting a role's whole event branch *and* the one event it follows, and the wider grant wins — so only the submitting half of a role is granted now, and what comes back is named exactly. -- [~] 4.4 a person's client — **the account is done**: a person is not a module and holds no - seat, so their authority is a list of tools (or `*` for an administrator) and nothing else. - Held to four properties, each a way of being wrong that would not announce itself: nothing - but tools, so a person cannot claim a module said something; no ack subject, because - authority over a consumer that does not exist is authority nobody audits; no ability to - answer, because a person who can answer a request is impersonating a module on a bus where - anyone may serve a tool; and two people do not share an inbox. +- [x] 4.4 a person's client — **the account and the program are both in.** + + **The account**: a person is not a module and holds no seat, so their authority is a list of + tools (or `*` for an administrator) and nothing else. Held to four properties, each a way of + being wrong that would not announce itself: nothing but tools, so a person cannot claim a + module said something; no ack subject, because authority over a consumer that does not exist + is authority nobody audits; no ability to answer, because a person who can answer a request is + impersonating a module on a bus where anyone may serve a tool; and two people do not share an + inbox. Issued, listed and revoked by command; stating what somebody may call replaces what was + there, because a list that could only grow is a permission nobody can take back; and forgetting + somebody takes their credential with them, or it is not a revocation. + + **The program**: two surfaces over one thing — a command line and an MCP server — both adapters + over the same three calls, because a second way of reaching a tool is a second thing to keep + correct. It uses the client a module's runtime uses, so what a person may do is answered by the + same permission list that answers it for a module and an audit has nothing separate to read. + + Three decisions in it worth keeping. It lists what the **catalogue** has rather than what this + credential may call: somebody seeing only their own tools cannot tell "not installed" from "not + yours", and those need different people to fix them. A failed call says which of three things + happened — nobody serves it, this credential may not, or the tool was slow — because the + remedies are in three different places and without that they are one timeout and a stack trace. + And the MCP surface decides nothing: the names are the ones a person types, the schemas are the + modules' own, an answer is passed through unshaped, and a tool that fails comes back as a tool + error rather than a protocol error, because the request was well-formed and the mesh answered it. + + Both surfaces are driven against a running bus, including a host's notification being answered + with nothing and an unknown method refused. + + > **Design 25 §7 says "nothing is built of this before §10's bed passes", and this was built + > before.** Recorded rather than quietly ignored: the operator asked for it, it is on the + > critical path for nothing and blocked by nothing, and the bed it waits for is 4.1's. If the + > bed changes what a person's client should be, this is what gets changed. - Still to build: the client program itself — the command line and the MCP surface over it. - It needs nothing from the consume side, so it is not blocked by step 3. - [~] 4.5 reports and catch-up: a node that was unreachable catches up rather than losing them — **the reports half is in and proved against a server** (3.4): held through the store's absence by the server rather than by the controller, superseded ones settled by the digest they carry. From 51f5ce5c0f696debb2c6dbe2466480794860ee0a Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 17:23:49 +0200 Subject: [PATCH 40/44] 4.5 done: the catch-up needed nothing built, which was the answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ways to do the replay were weighed and the right answer was that the bus being moved to already does it. A queue on the old bus receives only what is published after it is bound, so everything built before the catalogue existed was announced to nobody. A stream is a log and a consumer is a position in it: a consumer created later starts at the beginning and the builds are simply there. Checked against a running server, because the decision rested on it. So "who replays" has no answer because nothing replays. The mechanism was never about builds — it was about a queue that could not remember, and carrying it across would have carried a workaround for a limitation that no longer exists, with nothing looking wrong. Retiring it belongs to step 5, with the rest of what only the old bus needs. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 40 ++++++++++++++--------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index abd5e32..ee9b863 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -591,23 +591,33 @@ it, and the beds that need a mesh living on NATS can finally run. > critical path for nothing and blocked by nothing, and the bed it waits for is 4.1's. If the > bed changes what a person's client should be, this is what gets changed. -- [~] 4.5 reports and catch-up: a node that was unreachable catches up rather than losing them — - **the reports half is in and proved against a server** (3.4): held through the store's absence - by the server rather than by the controller, superseded ones settled by the digest they carry. +- [x] 4.5 reports and catch-up: a node that was unreachable catches up rather than losing them. - **The catch-up half is a decision, and issue 127 narrowed it.** The controller answers a - catalogue's request by re-publishing builds under its *own* name, which nothing subscribing the - builder's subject hears, and for which it holds no grant. Publishing them under the builder's - subject would be the controller signing an event as another module, which the derived namespace - exists to prevent. And it cannot become a reply to the catalogue's inbox either: answering a - module's inbox needs `_INBOX.>`, the blanket grant design 25 §4 refuses — found while giving the - controller the one narrow inbox it does need, to answer enrolments. + **The reports half is in and proved against a server** (3.4): held through the store's absence by + the server rather than by the controller, superseded ones settled by the digest they carry. - So two options are left, and they differ in kind. A subject the controller may publish and a - catalogue may subscribe — the mesh's own event space, which does not exist yet. Or a durable - consumer that starts at the beginning of the stream, which removes the need to ask at all and - leans on retention instead: sound while the events are still there, and silent when they have - aged out, which is the failure the request was invented to avoid. + **The catch-up half needed nothing built, and that was the answer.** It existed because a queue on + the bus the mesh runs on today receives only what is published after it is bound, so everything + built before the catalogue existed was announced to nobody — and on a fresh mesh that is always + the foundation, because those are the things the catalogue needed in order to exist + ([issue 050](../../04-ISSUES/050-the-catalogue-knows-nothing-built-before-it/00-report.md)). A + whole mechanism followed: the catalogue asks, the controller re-publishes. + + A stream is a log and a consumer is a position in it. A consumer created later starts at the + beginning, so the builds are simply there — asked of a running server rather than assumed, since + the decision rested on it: three builds published with nothing listening, then a consumer created, + and all three waiting for it. So the question of *who replays* has no answer because nothing + replays. + + > **This is the shape of the whole change, in one task.** Three ways to do the replay were weighed + > — a namespace for the mesh's own voice, the controller answering a question, a consumer reading + > from the start — and the right answer was that the bus being moved to already does it. The + > mechanism was never about builds; it was about a queue that could not remember. **A conversion + > that carried it across would have carried a workaround for a limitation that no longer exists**, + > and nothing would have looked wrong. + + Retiring it is step 5's, with the rest of what only the old bus needs: the request, the + re-publishing, and the `replay` flag that told a consumer to register history without acting on it. **Done when.** Each converted flow is proved against the behaviour it replaced, and the full genesis bed is green. **Observation is not in this step** — heartbeats, conditions and key-value state are From dfac01a6dd1d9bf5537db40795846b8af01d8afc Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 17:59:26 +0200 Subject: [PATCH 41/44] 5.2: the readiness half is in, and what a failed move actually costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rollout check` answers from records whether this mesh could move its bus, and names the next step for each thing missing. The move itself waits on that check having been run against a real mesh — writing the irreversible half before its question has ever been asked of something real breaks the plan's own rule about beds by another route. And the cost of being wrong is written down rather than assumed: the old broker stays for its other clients, nothing in a served request's path goes over the mesh's own bus, and what a failed move costs is the mesh's ability to change things rather than the services its modules serve. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 28 +++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index ee9b863..21877ad 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -631,8 +631,32 @@ reserves them for after the move, and a flow built ahead of its design would be - [ ] 5.1 the cutover bed: a mesh on AMQP with a predecessor stand-in on the deprecated broker moves its bus in one rollout, every node reporting on NATS afterwards, the stand-in's own client still connected throughout -- [ ] 5.2 the rollout: accounts composed, then the controller, every host and every runtime - together; every node confirmed heard before AMQP stops +- [~] 5.2 the rollout: accounts composed, then the controller, every host and every runtime + together; every node confirmed heard before AMQP stops. + + **The readiness half is in and is the half worth having.** The move takes every node at once, so + there is nothing to inspect afterwards and no half to roll back — either the mesh was ready or it + was not. `rollout check` answers that from records, with one dial: is a bus answering, does a + machine hold the seat, has it been sent the composed user list, does every machine and every + module that speaks have a credential. Each missing thing names its own next step, because "not + ready" that cannot be acted on is not an answer at the point where the next step is irreversible. + + **A machine with no credential is what must stop it.** It keeps running, cannot come back, and + afterwards there is no bus to tell it anything over. + + The move itself is deliberately not written yet, and the command says so rather than pretending: + it waits on the check having been run against a real mesh. Writing the irreversible half before + the question it depends on has ever been asked of something real is how the plan's own rule about + beds gets broken by another route. + + > **What this costs if it goes wrong, measured rather than assumed.** On the installation this is + > for, the old broker is also what a whole automation layer outside the mesh connects to — so it + > stays, as an ordinary provider of `amqp` ([ADR 0119](../../02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md)), + > and this step is not its retirement. Nothing in a served request's path goes over the mesh's own + > bus: modules serve from their own containers. What a failed move costs is the mesh's ability to + > *change* anything — pushes, tool calls, new provisioning — until it is finished or undone. That + > is worth knowing before rather than after, and it is why the operator's "as long as my services + > keep running" is a reasonable position rather than a gamble. - [ ] 5.3 the mesh's own accounts removed from the deprecated broker: after the rollout nothing of the mesh speaks to it, and an account nothing uses is one nobody rotates From 9ef9830dcfb4c0a28b169bbc515edb9103743229 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 18:11:11 +0200 Subject: [PATCH 42/44] ADR 0122: the predecessor is ending, and its broker goes with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0119 rejected giving the old broker a retirement condition and said why: "its clients are not only the predecessor's, so the retirement condition describes a day that will not come". The operator has said that day is coming — the predecessor is deprecated, some of it still running, none of it being migrated, left to stop rather than moved. Recorded because three documents reason from the premise it overturns. Design 25 §5's "no day anything is waiting for", §9's "the predecessor's clients never notice", and design 28's closing note that the predecessor's world does not need to move. **And it needs no new machinery, which is 0119 being paid off rather than revised.** Because that record made the broker an ordinary provider rather than a compatibility module, ending it is unassigning a provider whose provision nothing requires — something the module system has done since it existed. So step 5.3 finishes instead of trailing off, and the transitional double announcement of a build outcome has a date. The consequence worth planning around: the predecessor's own mesh talks over that broker, so shutting it down ends the tooling that reaches this installation's machines from a workstation. The rollout is driven from the node, or driven before the broker stops. That is a sequencing constraint on 5.2, not an afterthought. What survives is `amqp` as a provision: a module that genuinely needs an AMQP broker can still be given one. What retires is this broker's role as the predecessor's. --- ...r-is-ending-and-its-broker-goes-with-it.md | 64 +++++++++++++++++++ 02-DECISIONS/README.md | 1 + 03-DESIGN/01-to-be/25-the-bus-on-nats.md | 10 +++ 03-DESIGN/01-to-be/28-building-the-bus.md | 23 +++++-- 4 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 02-DECISIONS/0122-the-predecessor-is-ending-and-its-broker-goes-with-it.md diff --git a/02-DECISIONS/0122-the-predecessor-is-ending-and-its-broker-goes-with-it.md b/02-DECISIONS/0122-the-predecessor-is-ending-and-its-broker-goes-with-it.md new file mode 100644 index 0000000..ec30e2e --- /dev/null +++ b/02-DECISIONS/0122-the-predecessor-is-ending-and-its-broker-goes-with-it.md @@ -0,0 +1,64 @@ +--- +topic: the mesh +status: accepted +date: 2026-09-27 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0119-amqp-is-a-provision-not-the-bus.md +--- + +# 122. The predecessor is ending, and its broker goes with it + +## Context + +[ADR 0119](0119-amqp-is-a-provision-not-the-bus.md) settled that the old broker is an ordinary +provider of the `amqp` provision rather than a compatibility module with an end date. It rejected +giving it a retirement condition, and said why: *"its clients are not only the predecessor's, so the +retirement condition describes a day that will not come."* + +**The operator has said that day is coming.** The predecessor is deprecated. Some of it is still +running, and it is not being migrated — it is being left to stop. Its broker may be shut down. + +That is a fact about this installation, not a change of mind about what a broker is. It is recorded +because three documents reason from the premise it overturns: +[design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §5 and §9, and +[design 28](../03-DESIGN/01-to-be/28-building-the-bus.md)'s closing note that the predecessor's world +"does not need to move: its broker is the compatibility module until its last client is gone." + +## Decision + +**The predecessor's broker retires when nothing requires `amqp`, by being unassigned like any other +provider.** No retirement condition, no end-date machinery, no special case — which is ADR 0119 being +paid off rather than revised. Because that record made the broker an ordinary provider, ending it +needs nothing that does not already exist: a provision with no consumers has its provider unassigned, +and the module system has done that since it existed. + +**So step 5.3 has an ending.** "The mesh's own accounts removed from the deprecated broker" was +written as the last thing that could be said, because the broker itself was going to outlive the +question. It now finishes: once the mesh's own traffic has moved and the predecessor's remnants have +stopped, the module is unassigned and the port is free. + +**And the transitional doubling has a date.** The build outcome is announced under both the module's +name and the role's on the old bus, so that a catalogue deployed before the rename and one deployed +after both hear it. That exists only while the old bus does, and goes with it. + +## Consequences + +**The remote access path goes with it, and that is the one practical consequence worth planning +around.** The predecessor's own mesh communicates over that broker — so shutting it down ends the +tooling that reaches this installation's machines remotely. Work on the node after that point is done +from the node. **This matters most for the rollout**, which is the step that would otherwise be driven +from a workstation: it has to be driven locally, or driven before the broker stops. + +**What is still running on it stops when it stops.** Some of the predecessor's services are live and +are not being moved. That is the operator's decision and it is recorded here so that nobody later reads +a broker with clients as an accident. + +**Nothing in a served request's path is affected.** Modules serve from their own containers; the mesh's +bus carries the mesh's own traffic — declarations, reports, events, tool calls. This was checked rather +than assumed when the question came up, and it is why the operator's position (*"as long as my services +keep running"*) is a bounded risk rather than a gamble. + +**One reason to keep the broker survives**: `amqp` remains a provision a module may require, and a +module that genuinely needs an AMQP broker can be given one. What retires is *this* broker's role as +the predecessor's, not the mesh's ability to provide the thing. diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index 7c325c6..f7647e0 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -137,6 +137,7 @@ python3 00-META/checks/index.py fail if stale - **0119** — [AMQP is a provision, not the bus](0119-amqp-is-a-provision-not-the-bus.md) - **0120** — [The mesh bus is required, not ambient](0120-the-mesh-bus-is-required-not-ambient.md) - **0121** — [A seat carries the protocol of its role](0121-a-seat-carries-the-protocol-of-its-role.md) +- **0122** — [The predecessor is ending, and its broker goes with it](0122-the-predecessor-is-ending-and-its-broker-goes-with-it.md) ### Its tiers, from the bottom up diff --git a/03-DESIGN/01-to-be/25-the-bus-on-nats.md b/03-DESIGN/01-to-be/25-the-bus-on-nats.md index 8613129..ea550da 100644 --- a/03-DESIGN/01-to-be/25-the-bus-on-nats.md +++ b/03-DESIGN/01-to-be/25-the-bus-on-nats.md @@ -19,6 +19,7 @@ decisions: - 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md - 02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md - 02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md + - 02-DECISIONS/0122-the-predecessor-is-ending-and-its-broker-goes-with-it.md --- # 25. The bus on NATS @@ -311,6 +312,15 @@ foundation, never raised at genesis, installed when something wants it and absen that does not. There is no retirement condition, because the day its last client disappears is not a day anything is waiting for. +**Revised 2026-09-27** ([ADR 0122](../../02-DECISIONS/0122-the-predecessor-is-ending-and-its-broker-goes-with-it.md)): +**that day is coming.** The predecessor is deprecated — some of it still running, none of it being +migrated, left to stop rather than moved — so the broker retires once nothing requires `amqp`. Still no +retirement *condition* and no end-date machinery: a provision with no consumers has its provider +unassigned, which is the ordinary mechanism and is ADR 0119 being paid off rather than revised. What +also goes with it is the tooling that reaches this installation's machines remotely, because the +predecessor's own mesh talks over that broker — so the rollout is driven from the node, or before the +broker stops. + What is deprecated is AMQP as **the mesh's transport**, which is this whole document. The rule that remains is about direction rather than software: *inter-module communication goes over the bus.* A module may hold a broker, a database or a cache for itself; it may not use one as a diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 21877ad..065e3fd 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -15,6 +15,7 @@ decisions: - 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/0039-what-the-sdk-holds-and-refuses.md + - 02-DECISIONS/0122-the-predecessor-is-ending-and-its-broker-goes-with-it.md --- # 28. Building the bus @@ -657,8 +658,20 @@ reserves them for after the move, and a flow built ahead of its design would be > *change* anything — pushes, tool calls, new provisioning — until it is finished or undone. That > is worth knowing before rather than after, and it is why the operator's "as long as my services > keep running" is a reasonable position rather than a gamble. -- [ ] 5.3 the mesh's own accounts removed from the deprecated broker: after the rollout nothing - of the mesh speaks to it, and an account nothing uses is one nobody rotates +- [ ] 5.3 the mesh's own accounts removed from the deprecated broker, and then the broker itself: + after the rollout nothing of the mesh speaks to it, and an account nothing uses is one nobody + rotates. **It finishes now** ([ADR 0122](../../02-DECISIONS/0122-the-predecessor-is-ending-and-its-broker-goes-with-it.md)): + the predecessor is deprecated rather than kept, so once its remnants have stopped the module is + unassigned and the port is free. No retirement machinery — a provision with no consumers has its + provider unassigned, which is ADR 0119 being paid off rather than revised. + + Retiring with it: the build outcome's second announcement under the module's own name, which + exists only so a catalogue deployed before the rename and one deployed after both hear it. + + > **The remote tooling goes with it too.** The predecessor's own mesh talks over that broker, so + > shutting it down ends the path that reaches this installation's machines from a workstation. + > The rollout has to be driven from the node, or driven before the broker stops — which is a + > sequencing constraint on 5.2 and not an afterthought. > **5.4 is gone, and was wrong from ADR 0119 onward.** It read "the deprecated broker retires > when its condition holds — no client connected for the period the operator sets", which is @@ -685,8 +698,10 @@ itself moves once, at the end, on one day. - **Observation** — research 017's, after the move, by its own design. - **Leaf nodes** — design 25 §11 keeps this out of scope and says so; a leaf per machine is a later question, noted so it is not forgotten. -- **The predecessor's world.** It is AMQP, it cannot move, and it does not need to: its broker is - the compatibility module until its last client is gone. +- **The predecessor's world.** It is AMQP and it is not moving — + [ADR 0122](../../02-DECISIONS/0122-the-predecessor-is-ending-and-its-broker-goes-with-it.md): it is + deprecated, some of it is still running, and it is being left to stop rather than migrated. Its + broker goes with it, unassigned like any provider whose provision nothing requires. ## How this list is kept true From 5cac2684575458f712e61c1f6fd864e2b317e3f4 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 18:57:49 +0200 Subject: [PATCH 43/44] Two checks were wrong about where a seat is judged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correcting design 26 to match what the merged code does, and fixing issue 112's status, which used a word the vocabulary does not have. A claim on a seat the manifest does not itself declare is refused at registration rather than by the parser. A module may hold a seat another module declared — that is why ADR 0126 has a caller name the seat and not its provider — so whether the name exists is a fact about the whole catalogue. And a declared seat may promise nothing. That is a marker seat, and most node-scoped seats are markers: which module is this machine's packet filter. ADR 0126's "a declared seat carries a protocol" says what a holder must satisfy, not that every seat offers something. `records.py` still fails on ADR 0120 resting on a proposed ADR 0112, which is not this branch's and not mine to decide. --- 03-DESIGN/01-to-be/26-the-seats.md | 11 ++++++++++- .../00-report.md | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/03-DESIGN/01-to-be/26-the-seats.md b/03-DESIGN/01-to-be/26-the-seats.md index 60d71c7..f6c1cd1 100644 --- a/03-DESIGN/01-to-be/26-the-seats.md +++ b/03-DESIGN/01-to-be/26-the-seats.md @@ -149,6 +149,15 @@ moves that to a host port requirement. ## A seat that delivers nothing +**A module's declared seat may promise nothing too, and that is a marker seat.** Correction of fact, +2026-09-27: [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)'s "a declared seat +carries a protocol" governs what a *holder* must satisfy, not that every seat offers something. A +marker seat's protocol is satisfied by holding it, which is the whole of what a marker says. Refusing +an empty one would refuse most of the node-scoped set, the showcase module's own seat included. +Nothing reaches that state by accident: an unknown manifest field is refused outright, so an empty +protocol was written as one. Checked by a registration test accepting a node seat with no protocol +and by the showcase manifest, which declares one. + Most node seats deliver nothing. They say which module is this machine's packet filter, or which of two alternative resolver configurations it runs, and a second holder is refused. That is the whole of their job, and it is a real one: it is the mesh saying what a machine is, in words a person can read. @@ -193,7 +202,7 @@ checked as their tables say: | Rule | Checked by | |---|---| -| The set is closed, and every mesh entry names its decision | 0118: a unit test on the mesh's own entries; manifest tests refusing an unknown seat or the wrong scope. | +| The set is closed, and every mesh entry names its decision | 0118: a unit test on the mesh's own entries. **The refusal of an unknown seat is at registration, not in the parser** — correction of fact, 2026-09-27: a module may hold a seat *another* module declares, which is the point of naming the seat and not its provider, so whether a claimed name exists is a fact about the whole catalogue and a manifest in isolation cannot be judged on it. Registration tests cover an invented name and a name another module declares; the parser still refuses a claim on the mesh's own `mesh-*`/`node-*` namespace and a scope that disagrees with a declaration in the same manifest. | | The set is derived, and enumerating it is a query | 0118: the overview lists the mesh's own plus every registered module's, asserted against a fixture mesh. | | `mesh-*` is the mesh's, and a module may not declare one | 0118: a registration test refusing a manifest that declares any `mesh-*` seat, naming the prefix. | | Two modules cannot declare the same seat | 0118: a registration test; the second is refused and the first untouched. | diff --git a/04-ISSUES/112-adopting-a-tunnel-gives-the-mesh-addresses-without-names/00-report.md b/04-ISSUES/112-adopting-a-tunnel-gives-the-mesh-addresses-without-names/00-report.md index 0a9a223..a36fde6 100644 --- a/04-ISSUES/112-adopting-a-tunnel-gives-the-mesh-addresses-without-names/00-report.md +++ b/04-ISSUES/112-adopting-a-tunnel-gives-the-mesh-addresses-without-names/00-report.md @@ -1,5 +1,5 @@ --- -status: fixed +status: resolved opened: 2026-09-24 located-in: [mesh-controller internal/inventory, mesh-controller internal/catalogue, mesh-controller cmd/mesh-controller, mesh-catalog modules/dnsmasq] fixed-by: [mesh-controller#73 overlay-name + namesInTheMesh, mesh-catalog#106 dnsmasq daemon.json merge] From 51739c4302ae56eb996956f8960bf08c5adbd6f0 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 19:04:45 +0200 Subject: [PATCH 44/44] The order the repositories land in is part of the rollout Derived while merging and visible from no single repository, so it belongs written down rather than re-derived later: the client library before the catalogue, because it is where the subject is derived and a converted module against the old one publishes the local name itself; the catalogue before the controller, because the controller refuses an old-style name outright and would make every unconverted module unregisterable. Two tested properties are what make it safe and not merely ordered. An old-style name passes through the derivation untouched, so an unconverted module keeps working at every step. And a converted name derives to exactly the key the old bus published, so nothing moves on the wire until 5.2 sets the variable. The failure avoided is 127's own, which is why this is worth a table: a publisher and a subscriber disagreeing about a subject log nothing anywhere. --- 03-DESIGN/01-to-be/28-building-the-bus.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 7f25dce..d86472c 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -629,6 +629,29 @@ reserves them for after the move, and a flow built ahead of its design would be **Why here.** It is the only step that moves a node's bus, and it moves every node's at once. +**The order the repositories land in is part of the rollout, not paperwork.** Derived 2026-09-27 +while merging, and not obvious from any one repository, which is why it is written here rather than +left to be re-derived under time pressure: + +| order | repository | why it cannot be later | +|---|---|---| +| 1 | this one | prose; nothing deploys | +| 2 | the sdk | comments only, and no module rebuilds for it | +| 3 | the client library | it is what a module calls to emit, and it is where the subject is derived. Until it lands, a locally-named event is published under the local name itself | +| 4 | the catalogue | every manifest and every module's code, renamed together. Safe only once the runtime derives | +| 5 | the controller | **it refuses an old-style event name outright**, so landing it before the catalogue makes every unconverted module unregisterable | +| 6 | the hosts | last, because nothing else waits on them | + +Two properties make the sequence safe rather than merely ordered, and both are pinned by tests. A +name already in the old form passes through the derivation untouched, so a module nobody has +converted keeps working at every step. And a converted name derives to **exactly** the key the old +bus published, so steps 3 and 4 change nothing on the wire — the move to the new bus is step 5.2 and +one environment variable, not a side effect of deploying. + +The failure this ordering avoids is issue 127's own: a publisher and a subscriber that disagree about +a subject produce no error anywhere. Nothing logs, nothing retries, and the mesh reports itself +healthy while reacting to nothing. + - [ ] 5.1 the cutover bed: a mesh on AMQP with a predecessor stand-in on the deprecated broker moves its bus in one rollout, every node reporting on NATS afterwards, the stand-in's own client still connected throughout