diff --git a/00-META/checks/records.py b/00-META/checks/records.py index 0484dd8..3f12a2e 100644 --- a/00-META/checks/records.py +++ b/00-META/checks/records.py @@ -299,8 +299,13 @@ 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. + # 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}$") for number, record in sorted(records.items()): @@ -313,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)) @@ -328,7 +337,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..851b201 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 0128](../02-DECISIONS/0128-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 0127](../02-DECISIONS/0127-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 @@ -47,7 +61,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 0126](../02-DECISIONS/0126-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..030037c 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 0126](0126-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/0106-the-bus-is-nats.md b/02-DECISIONS/0106-the-bus-is-nats.md index 6da6c77..47decab 100644 --- a/02-DECISIONS/0106-the-bus-is-nats.md +++ b/02-DECISIONS/0106-the-bus-is-nats.md @@ -75,6 +75,30 @@ 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 0125](0125-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. + +> **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 0125](0125-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 0127](0127-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/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..8211324 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 @@ -9,6 +9,17 @@ extends: 0009-modules-and-the-graph.md # 110. A seat is held by one assignment, from a closed set, and it may deliver a provision +> **Narrowed, not replaced — 2026-09-27, on merging two lines of work.** This was marked superseded by +> [ADR 0126](0126-a-module-declares-its-own-seats.md), and that overstated it: 0126 says in as many +> words that *"everything 0110 decided about what a seat is stands untouched"*. What moved is where the +> set lives and who may add to it — +> [0126](0126-a-module-declares-its-own-seats.md) lets a module declare one and makes the set derived, +> [0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) names the mesh's own +> for their scope, and [0122](0122-a-seat-is-data-a-rename-is-a-database-update.md) moves them out of +> code into a table. **What a seat *is* — one holder at its scope, a definition saying what a module +> can hold against an assignment saying what it does, a role made singular rather than a module — is +> this record and still current**, which is why those three rest on it. + ## Context [ADR 0009](0009-modules-and-the-graph.md) introduced claims: a module declares something diff --git a/02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md b/02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md index cb925b0..317ec90 100644 --- a/02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md +++ b/02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md @@ -40,7 +40,7 @@ undeclare can do. Found reviewing the uplink modules - the uplink modules would have stopped the network manager, taking the machine off the only link the mesh reaches it by. -[ADR 0117](0117-a-machines-uplink-is-a-seat.md) answered that for its own modules with a +[ADR 0125](0117-a-machines-uplink-is-a-seat.md) answered that for its own modules with a service declared with no `state`. Every other module that declares a unit it did not make is exposed in the same way, and relying on each author to remember an opt-out is how the next one is missed. @@ -91,7 +91,7 @@ records the state it first found the unit in, and undeclaring returns the unit t - The service's settings the mesh wrote are given back by their own resources (a kept original restored, a region or keys removed). A running service keeps running on what it read until it next reads its configuration; the mesh does not restart it to make it notice. -- A service declared with no `state` (ADR 0117) remains the way to say the mesh must not +- A service declared with no `state` (ADR 0125) remains the way to say the mesh must not **start** a unit either; undeclared, it is forgotten. ## Consequences @@ -113,7 +113,7 @@ records the state it first found the unit in, and undeclaring returns the unit t ## References - [issue 130](../04-ISSUES/130-undeclaring-a-service-stops-it/00-report.md): the finding -- [ADR 0117](0117-a-machines-uplink-is-a-seat.md): the uplink modules, and a service with no state +- [ADR 0125](0117-a-machines-uplink-is-a-seat.md): the uplink modules, and a service with no state - [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md): what is given back, and how - mesh-host `internal/apply/apply.go` (`remove`, the service case) diff --git a/02-DECISIONS/0119-a-taken-tunnels-predecessor-is-retired.md b/02-DECISIONS/0119-a-taken-tunnels-predecessor-is-retired.md index d9bdade..219c55c 100644 --- a/02-DECISIONS/0119-a-taken-tunnels-predecessor-is-retired.md +++ b/02-DECISIONS/0119-a-taken-tunnels-predecessor-is-retired.md @@ -53,7 +53,7 @@ is removed from where its unit reads it.** reporting it. - **The mesh never brings it back.** Undeclaring the private network does not restore the found tunnel: the mesh stopped it, and nothing is started on the way out - ([ADR 0118](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md)). A machine whose + ([ADR 0126](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md)). A machine whose private network is unassigned has no tunnel until it is assigned again — which is what unassigning it means. @@ -83,5 +83,5 @@ is removed from where its unit reads it.** - [ADR 0105](0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md): the take, and why it keeps the found configuration during it - [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md): kept originals -- [ADR 0118](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md): nothing is started on the way out +- [ADR 0126](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md): nothing is started on the way out - mesh-host `internal/apply/takeover.go` diff --git a/02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md b/02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md index 9a3ace4..046163d 100644 --- a/02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md +++ b/02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md @@ -78,7 +78,7 @@ closed set stays what its name says it is: the *system's* roles, not everyone's. - **`the-packet-filter` → `node-packet-filter`** and **`the-intrusion-prevention` → `node-intrusion-prevention`** — names kept as-is but for the prefix. "Packet filter" stays distinct from "firewall", which would swallow intrusion-prevention too. -- **`the-uplink` → `node-uplink`** ([ADR 0117](0117-a-machines-uplink-is-a-seat.md)). Unheld, so it +- **`the-uplink` → `node-uplink`** ([ADR 0125](0117-a-machines-uplink-is-a-seat.md)). Unheld, so it renames with no migration. - **The registry seats — `the-artifact-store`, `npm-package-registry` (→ `mesh-artifact-store`, `mesh-npm-package-registry`) — and `git` (→ `mesh-git`) — are decided but deferred.** They each @@ -124,7 +124,7 @@ migration, nothing to strand. ## References - [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) — the closed set this refines -- [ADR 0117](0117-a-machines-uplink-is-a-seat.md) — `the-uplink`, renamed here to `node-uplink` +- [ADR 0125](0117-a-machines-uplink-is-a-seat.md) — `the-uplink`, renamed here to `node-uplink` - [ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md) — the original `mesh-*` seats whose naming this generalises - [to-be 26](../03-DESIGN/01-to-be/26-the-seats.md) — the seat table, updated by this diff --git a/02-DECISIONS/0125-the-bus-is-the-only-broker.md b/02-DECISIONS/0125-the-bus-is-the-only-broker.md new file mode 100644 index 0000000..be960e8 --- /dev/null +++ b/02-DECISIONS/0125-the-bus-is-the-only-broker.md @@ -0,0 +1,133 @@ +--- +topic: the mesh +status: superseded +superseded-by: 02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md +date: 2026-09-26 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0106-the-bus-is-nats.md +--- + +# 125. 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/0126-a-module-declares-its-own-seats.md b/02-DECISIONS/0126-a-module-declares-its-own-seats.md new file mode 100644 index 0000000..8429294 --- /dev/null +++ b/02-DECISIONS/0126-a-module-declares-its-own-seats.md @@ -0,0 +1,145 @@ +--- +topic: the tiers +status: accepted +date: 2026-09-26 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md +--- + +# 126. 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 0125](0125-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. + +## 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 + requirement is kept and only its mechanism replaced. +- [ADR 0125](0125-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/0127-amqp-is-a-provision-not-the-bus.md b/02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md new file mode 100644 index 0000000..cdd1a08 --- /dev/null +++ b/02-DECISIONS/0127-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/0125-the-bus-is-the-only-broker.md +--- + +# 127. AMQP is a provision, not the bus + +## Context + +[ADR 0125](0125-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 0125](0125-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 0126](0126-a-module-declares-its-own-seats.md) — seats, including the one the NATS server + now holds alone. diff --git a/02-DECISIONS/0128-the-mesh-bus-is-required-not-ambient.md b/02-DECISIONS/0128-the-mesh-bus-is-required-not-ambient.md new file mode 100644 index 0000000..0d2902d --- /dev/null +++ b/02-DECISIONS/0128-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/0127-amqp-is-a-provision-not-the-bus.md +--- + +# 128. The mesh bus is required, not ambient + +## Context + +[Design 29](../03-DESIGN/01-to-be/32-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 0125](0125-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 0125'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 0127](0127-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 0125 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 0125](0125-the-bus-is-the-only-broker.md) — superseded by 0119; its bootstrap argument is + narrowed here to the case it supports. +- [ADR 0127](0127-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/0129-a-seat-carries-the-protocol-of-its-role.md b/02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md new file mode 100644 index 0000000..58ef212 --- /dev/null +++ b/02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md @@ -0,0 +1,102 @@ +--- +topic: the mesh +status: accepted +date: 2026-09-27 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0122-a-seat-is-data-a-rename-is-a-database-update.md +--- + +# 129. A seat carries the protocol of its role + +## Context + +[ADR 0126](0126-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. + +## Reconciled with 0122, which landed in parallel + +*Added 2026-09-27, on merging.* [ADR 0122](0122-a-seat-is-data-a-rename-is-a-database-update.md) moved +the seat set out of compiled code and into a table the controller owns. This record was written against +the slice, and says the `mesh-*` set "gains the same three fields a declared seat has". + +**The decision is unaffected and the mechanism is better for it.** What a seat accepts, emits and serves +becomes three columns beside its name and scope, so giving a role a protocol is a write rather than a +rebuild — which is the whole argument of 0122 applied to the thing this record adds. Where this text +says the set gains fields, read: the table gains columns. + +## 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/0130-the-predecessor-is-ending-and-its-broker-goes-with-it.md b/02-DECISIONS/0130-the-predecessor-is-ending-and-its-broker-goes-with-it.md new file mode 100644 index 0000000..7bbdc7f --- /dev/null +++ b/02-DECISIONS/0130-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/0127-amqp-is-a-provision-not-the-bus.md +--- + +# 130. The predecessor is ending, and its broker goes with it + +## Context + +[ADR 0127](0127-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 0127 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 aae6bfc..d7a0619 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -134,6 +134,11 @@ python3 00-META/checks/index.py fail if stale - **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) - **0119** — [A taken tunnel's predecessor is retired once the take is proven](0119-a-taken-tunnels-predecessor-is-retired.md) +- **0125** — [The bus is the only broker](0125-the-bus-is-the-only-broker.md) *(superseded)* +- **0127** — [AMQP is a provision, not the bus](0127-amqp-is-a-provision-not-the-bus.md) +- **0128** — [The mesh bus is required, not ambient](0128-the-mesh-bus-is-required-not-ambient.md) +- **0129** — [A seat carries the protocol of its role](0129-a-seat-carries-the-protocol-of-its-role.md) +- **0130** — [The predecessor is ending, and its broker goes with it](0130-the-predecessor-is-ending-and-its-broker-goes-with-it.md) ### Its tiers, from the bottom up @@ -164,6 +169,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) +- **0126** — [A module declares its own seats; the mesh reserves its own](0126-a-module-declares-its-own-seats.md) ### What runs on them, and how it gets there @@ -202,6 +208,9 @@ python3 00-META/checks/index.py fail if stale - **0115** — [One assignment of a module per node: the module's name is the assignment's identity](0115-one-assignment-of-a-module-per-node.md) *(proposed)* - **0117** — [A machine's uplink is a seat: the mesh configures the manager, never the link](0117-a-machines-uplink-is-a-seat.md) - **0118** — [Undeclaring removes what the mesh made, and gives a unit back the state it was found in](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md) +- **0120** — [A roster fact carries its format as a template: the mesh owns the data, the module owns the format](0120-a-roster-fact-carries-its-format-as-a-template.md) +- **0121** — [A system seat is named for its scope, and a module may define its own](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) +- **0122** — [A seat is data the controller owns, and a rename is a database update](0122-a-seat-is-data-a-rename-is-a-database-update.md) ### How it is built diff --git a/03-DESIGN/01-to-be/08-connectivity.md b/03-DESIGN/01-to-be/08-connectivity.md index 2337889..0743f42 100644 --- a/03-DESIGN/01-to-be/08-connectivity.md +++ b/03-DESIGN/01-to-be/08-connectivity.md @@ -769,7 +769,7 @@ where a found tunnel is left running beside the mesh's; where it is adopted ther The guard admits the mesh's ports from that one interface, and the predecessor's peers arrive on it. -*2026-09-27, [ADR 0119](../../02-DECISIONS/0119-a-taken-tunnels-predecessor-is-retired.md).* The +*2026-09-27, [ADR 0127](../../02-DECISIONS/0119-a-taken-tunnels-predecessor-is-retired.md).* The found configuration is kept only until the take is proven — the found unit down, the mesh's interface up and handshaking with a peer. Then it is removed from where the found unit reads it (its original stays kept), the hold ends, and the predecessor's tunnel cannot be raised again by 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..33f9a45 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/0128-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](32-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. --- 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..fd1acdc 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/0126-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 0126](../../02-DECISIONS/0126-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/25-the-bus-on-nats.md b/03-DESIGN/01-to-be/25-the-bus-on-nats.md index 6b92b3a..7fdd31b 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,15 +1,16 @@ --- layer: to-be -status: proposed +status: in-progress code: - mesh-controller internal/link (to be replaced) - mesh-host internal/link (to be replaced) - 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/0127-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 @@ -17,6 +18,8 @@ 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/0129-a-seat-carries-the-protocol-of-its-role.md + - 02-DECISIONS/0130-the-predecessor-is-ending-and-its-broker-goes-with-it.md --- # 25. The bus on NATS @@ -29,19 +32,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 0126](../../02-DECISIONS/0126-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](32-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 @@ -52,14 +73,30 @@ 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.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-27** ([ADR 0129](../../02-DECISIONS/0129-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](32-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 @@ -69,7 +106,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 @@ -89,10 +133,9 @@ 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.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. @@ -100,6 +143,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 @@ -128,19 +198,56 @@ 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 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 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 @@ -157,9 +264,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 @@ -186,10 +298,33 @@ 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 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 deprecated broker is an ordinary module, not a compatibility layer.** Revision, second review +([ADR 0127](../../02-DECISIONS/0127-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. + +**Revised 2026-09-27** ([ADR 0130](../../02-DECISIONS/0130-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 0127 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 +channel to another module. ## 6. Joining: the enrolment handshake @@ -272,7 +407,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 @@ -333,7 +468,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; @@ -398,8 +533,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 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) (superseding [ADR 0125](../../02-DECISIONS/0125-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/26-the-seats.md b/03-DESIGN/01-to-be/26-the-seats.md index bf2317d..f6c1cd1 100644 --- a/03-DESIGN/01-to-be/26-the-seats.md +++ b/03-DESIGN/01-to-be/26-the-seats.md @@ -10,7 +10,8 @@ code: - mesh-catalog modules/gitea/module.json updated: 2026-09-27 decisions: - - 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md + - 02-DECISIONS/0126-a-module-declares-its-own-seats.md + - 02-DECISIONS/0128-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 - 02-DECISIONS/0117-a-machines-uplink-is-a-seat.md @@ -48,28 +49,55 @@ 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-uplink` | node | — | the program that manages the machine's own network ([ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md)) | +**The set is derived, and only the mesh's half is written here.** Revision, 2026-09-26 +([ADR 0126](../../02-DECISIONS/0126-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 +**And the mesh's own half is data, named for its scope.** Revision, 2026-09-27, reconciling two +records made in parallel: [ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) +names a system seat for the scope it is held at — `mesh-*` for one per mesh, `node-*` for one per +machine — and [ADR 0122](../../02-DECISIONS/0122-a-seat-is-data-a-rename-is-a-database-update.md) moves +the set out of compiled code into a table the controller owns, so a rename is one write rather than a +rebuild of everything that names one. + +So the set has two halves and neither is written out here: the mesh's own, which the controller holds +as rows, and every registered module's, which is computed from the catalogue. What this document keeps +is what a seat *is* — the rest would be a third copy, stale the first time somebody renamed one, which +is the fault ADR 0122 exists about. + + +**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 | `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 | +| `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 0126](../../02-DECISIONS/0126-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. @@ -121,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. @@ -157,16 +194,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 0126](../../02-DECISIONS/0126-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. **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. | +| 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..f557ed6 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/0126-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 0126](../../02-DECISIONS/0126-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 0126](../../02-DECISIONS/0126-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 b0352f9..d86472c 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -1,15 +1,21 @@ --- layer: to-be -status: proposed -code: [] -updated: 2026-09-26 +status: in-progress +code: + - mesh-catalog modules/nats + - mesh-controller internal/catalogue + - mesh-lab scenarios +updated: 2026-09-27 decisions: - 02-DECISIONS/0116-the-bus-is-built-in-five-steps.md - 02-DECISIONS/0106-the-bus-is-nats.md + - 02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md + - 02-DECISIONS/0126-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 - 02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md + - 02-DECISIONS/0130-the-predecessor-is-ending-and-its-broker-goes-with-it.md --- # 28. Building the bus @@ -111,25 +117,153 @@ step 5 the rollout ## Step 1 — the module, and genesis raises it +> **Revised 2026-09-26** ([ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md), +> [design 29](32-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 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 — 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.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.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](32-what-a-module-declares.md) §2, plus its own ack subject and its own inbox + prefix (design 25 §4) +- [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 +- [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 deprecated broker where a genesis module set is declared, which is scenario and installer + configuration — carried with 1.6 rather than before it. +- [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. + + 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 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. + + 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. + + **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. + + **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. + + > **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 + 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 @@ -149,11 +283,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 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 +> 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 @@ -166,19 +324,160 @@ 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 -- [ ] 3.2 design 19 rewritten from exchanges, queues and routing keys to the subjects and streams of +- [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` -- [ ] 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 -- [ ] 3.7 the sdk's three stale comments, and nothing else in it + `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. +- [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 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 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. + + **Three things the wiring forced into the open.** + + *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. + + *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. +- [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 + 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. + + `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. + + 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, + 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. +- [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](32-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), 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 + 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. + + **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. +- [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 0126). 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 @@ -196,6 +495,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 @@ -204,13 +514,111 @@ 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 -- [ ] 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.5 reports and catch-up: a node that was unreachable catches up rather than losing them + — **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 +- [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 0129](../../02-DECISIONS/0129-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 — + **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. +- [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. + +- [x] 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 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 @@ -221,16 +629,84 @@ 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 +**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 -- [ ] 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 - period the operator sets +- [~] 5.2 the rollout: accounts composed, then the controller, every host and every runtime + together; every node confirmed heard before AMQP stops. -**Done when.** Every node reports on NATS and the predecessor's clients never noticed. + **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 0127](../../02-DECISIONS/0127-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, 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 0130](../../02-DECISIONS/0130-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 0127 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 0127 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 0127](../../02-DECISIONS/0127-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 @@ -245,8 +721,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 0130](../../02-DECISIONS/0130-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 diff --git a/03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md b/03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md index ce62923..7f5216d 100644 --- a/03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md +++ b/03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md @@ -65,14 +65,14 @@ create `~/.ssh` at `0700`, chown it to the account, and own the files it places **The boundary — and it is the reason this is safe:** `~/.ssh` is the one directory where a wrong declaration locks a person out of their own machine. So the mesh's *found-vs-owned* semantics -([ADR 0118](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md), +([ADR 0126](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md), adoption) apply *inside* the home directory. The mesh **owns** the directory and the files above; it **holds as found — never rewrites, never removes** — the operator's own contents: their **private keys** and their **personal drop-ins** (`config.d/personal`, the personal `Host` aliases a workstation carries, exactly as `hosts.local` is the home the mesh never rewrites for `/etc/hosts`). Reconcile removing an unassigned `config.d/mesh` is fine; the same logic aimed at `id_ed25519` or an operator's own `authorized_keys` entry is a lockout. This is the login-channel cousin of the rule -[ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md) draws for the uplink and the sshd +[ADR 0125](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md) draws for the uplink and the sshd module draws for the firewall: **the mesh must never be able to arrange the one failure that severs its own way back in.** The carve-out is not a convenience; it is that rule, in `~/.ssh`. @@ -108,7 +108,7 @@ found-vs-owned boundary of §3 is exactly what guarantees nothing already there None of this needs a node to discover the mesh, and none of it needs a control-plane module of its own. The ssh files are **roster facts** -([ADR 0120](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md)): once the +([ADR 0128](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md)): once the roster view carries a node's **host key** and its **account** beside its name and address, the `ssh-client` module ships a template for `known_hosts`, `config` and `authorized_keys`, and the controller renders each node's copy from the full roster and pushes it. The mesh owns the data; the @@ -137,7 +137,7 @@ alias and its trust, and a fresh machine has no operator dotfiles at all — the service and leave the human unable to work on the box. **Why not build it reflexively:** it is a real addition to the node model, the resource model, and -the seat set, and must be gotten right. The mechanism half is now settled — ADR 0120 is what lets +the seat set, and must be gotten right. The mechanism half is now settled — ADR 0128 is what lets the ssh files be templates with no control-plane format — so what remains to decide here is the model: @@ -164,7 +164,7 @@ right time to build it, once the account and CA model are decided here. - The gap was found generating `~/.ssh/config` from the *HAL* registry (`hal/terminal`'s postConfigure hook), which the nox mesh has no equivalent for. -- [ADR 0120](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster +- [ADR 0128](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster fact mechanism that renders the ssh files, format owned by the module. - [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) — the system-path placement this mirrors for home paths. @@ -173,6 +173,6 @@ right time to build it, once the account and CA model are decided here. - [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md) — the CA key is a secret the vault makes; [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md) — short-lived certs as rotation. -- [ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md), - [ADR 0118](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md) — +- [ADR 0125](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md), + [ADR 0126](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md) — the never-sever-the-channel rule and the found-vs-owned semantics, applied here to `~/.ssh`. diff --git a/03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md b/03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md index 76d96d3..949fd41 100644 --- a/03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md +++ b/03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md @@ -129,7 +129,7 @@ designed, not bolted on beside a freeze. The manual process above is the interim - [ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) — the seat rename whose migration and builder deadlock this record is drawn from -- [ADR 0120](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md) — the +- [ADR 0128](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md) — the fact-shape change that first showed the breaking-change freeze - `hal-gitea-tools.service` (`~/.hal/modules/hal/gitea/tools/server.js`) — the predecessor webhook receiver on `:9877` the mesh currently rides on diff --git a/03-DESIGN/01-to-be/32-what-a-module-declares.md b/03-DESIGN/01-to-be/32-what-a-module-declares.md new file mode 100644 index 0000000..cb43c6a --- /dev/null +++ b/03-DESIGN/01-to-be/32-what-a-module-declares.md @@ -0,0 +1,461 @@ +--- +layer: to-be +status: proposed +code: [] +updated: 2026-09-27 +decisions: + - 02-DECISIONS/0126-a-module-declares-its-own-seats.md + - 02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md + - 02-DECISIONS/0128-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 + - 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md + - 02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md +--- + +# 32. What a module declares, and what the bus makes of it + +**A module that speaks to the mesh requires the bus, and receives what it needs to connect** +([ADR 0128](../../02-DECISIONS/0128-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`, `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 — +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..event.order.placed` | +| `consumes: billing.order.placed` | durable consumer on `mesh.mod.billing.event.order.placed` | +| `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 | + +**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 +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 +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 +[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, +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). + +**The mesh's own seats carry protocol too.** *Added 2026-09-27, +[ADR 0129](../../02-DECISIONS/0129-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: + +``` +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. + +**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 +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 0126](../../02-DECISIONS/0126-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. Versioning a protocol + +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. + +### `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 deprecated broker provides `amqp` +([ADR 0127](../../02-DECISIONS/0127-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 +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 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) (superseding [ADR 0125](../../02-DECISIONS/0125-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. + +**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. + +## 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. +- **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 bae51da..d69c9e7 100644 --- a/03-DESIGN/01-to-be/README.md +++ b/03-DESIGN/01-to-be/README.md @@ -35,11 +35,13 @@ 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 0126](../../02-DECISIONS/0126-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 0126](../../02-DECISIONS/0126-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-a-node-has-operator-accounts.md`](29-a-node-has-operator-accounts.md) | **Proposed.** The mesh models machines but not the humans on them: a node gains operator accounts, and a resource may live under a home owned by its account — what would own ~/.ssh, dotfiles and ~/.config when HAL retires | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md) | +| [`32-what-a-module-declares.md`](32-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 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md), [ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) (superseding [ADR 0125](../../02-DECISIONS/0125-the-bus-is-the-only-broker.md)), [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) | + ## Not yet written - **The remaining six contexts.** 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] 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..1b6fbea --- /dev/null +++ b/04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md @@ -0,0 +1,93 @@ +--- +status: resolved +opened: 2026-09-27 +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/32-what-a-module-declares.md +--- + +# 127 — A module's event derives a subject nothing publishes + +## What was observed + +[Design 29](../../03-DESIGN/01-to-be/32-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 32 §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 32 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. 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..b85bd4a --- /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/32-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 32 §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. diff --git a/04-ISSUES/130-undeclaring-a-service-stops-it/00-report.md b/04-ISSUES/130-undeclaring-a-service-stops-it/00-report.md index 4824dd2..57fb3de 100644 --- a/04-ISSUES/130-undeclaring-a-service-stops-it/00-report.md +++ b/04-ISSUES/130-undeclaring-a-service-stops-it/00-report.md @@ -9,7 +9,7 @@ amended-design: 02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was ## What was observed -Reviewing the uplink modules ([ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md)) +Reviewing the uplink modules ([ADR 0125](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md)) found that the host's `remove` path stops every `service` resource that is no longer declared: `SetServiceState(..., "stopped")`, reported as "stopped; the unit file is not the host's to delete". `store.Orphans` matches by id alone. So any of these stops the unit: @@ -45,7 +45,7 @@ reaches it by. ## Resolution -[ADR 0118](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md): +[ADR 0126](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md): undeclaring removes what the mesh made and gives back what it changed. The host records the state it first found a unit in, and undeclaring returns the unit to it — a unit found running (the container runtime, sshd, a network manager) is left running; one the mesh started (the packet