From c8f430290e851f3b9133e100a10e9307ac058bd9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 15:24:17 +0200 Subject: [PATCH] ADR 0121: a seat carries the protocol of its role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh's own seats said who does a job and nothing about what may be said to them or by them, and that gap showed up three times in one day looking like three different problems: a build machine with three audiences for one outcome and no way to derive a grant for any of them; an event genuinely about a role with nowhere to live but the namespace of whichever module holds that role today; and a catalogue catching up on builds, where every option needed a grant the design refuses. One cause — the mesh has roles it cannot describe. So the `mesh-*` seats take the same three fields a module's seat has, and the machinery that already derives authority, queues and consumers from a declared seat does it for these too. Builds become work submitted to a role, and `mesh.build.request`, `mesh.control.built` and the BUILDS stream retire. A work queue shared by several build machines is exactly what a seat's `accepts` is, so a second mechanism for it was two places a permission could be wrong. The outcome is the seat's own event, which means one publish still reaches whoever asked, the controller that records it and the catalogue that places it — the fan-out a shared exchange gave for free, written as a subject the mesh derived rather than a topology somebody configured. That also avoids the grant that ruled out the alternatives: no holder needs permission to publish into an asker's inbox. The blocking gap is now named rather than incidental: the shared library has no way for a module to publish on a seat. The build machine is Go and reaches the bus directly, so it is unaffected; the artifact-store event waits. --- ...a-seat-carries-the-protocol-of-its-role.md | 91 +++++++++++++++++++ 02-DECISIONS/README.md | 1 + 03-DESIGN/01-to-be/25-the-bus-on-nats.md | 18 +++- .../01-to-be/29-what-a-module-declares.md | 20 ++++ 4 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md diff --git a/02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md b/02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md new file mode 100644 index 0000000..3ee5fce --- /dev/null +++ b/02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md @@ -0,0 +1,91 @@ +--- +topic: the mesh +status: accepted +date: 2026-09-27 +deciders: jochen +reconstructed: false +extends: 02-DECISIONS/0118-a-module-declares-its-own-seats.md +--- + +# 121. A seat carries the protocol of its role + +## Context + +[ADR 0118](0118-a-module-declares-its-own-seats.md) let a module declare a seat with its protocol: +what work the role accepts, what it emits, what it serves. A module's own seats work that way today. +**The mesh's own seats — the `mesh-*` set — carry no protocol at all**, only a name, a scope and the +provision they deliver. They say who does a job and nothing about what may be said to them or by +them. + +That gap surfaced three times in one day, each time as a different-looking problem. + +**A build machine.** On the bus the mesh runs on today a builder has its own account kind, created by +its own command, with permissions written by hand: read the build queue, write to two exchanges. One +publish to a shared exchange reached all three audiences a finished build has — whoever asked, the +controller that records it, and the catalogue that places it in the module graph. On a bus where +permissions are per subject those are three separate grants, and nothing derives them, because a +builder is not a module and holds a seat that promises nothing. + +**An event about a role rather than about a module.** The module holding the artifact-store seat +declared an event named after a *different* module +([issue 127](../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md)). The +bus refuses that, because a namespace belongs to who it is named for. The event is genuinely about the +role — "the artifact store accepted an image" — and a consumer written against whichever module holds +that role today breaks when the holder changes. There was nowhere else to put it. + +**A catalogue catching up.** The controller answers a request for builds it may have missed by +re-publishing them under its own name, which no consumer of the builder's subject hears. Publishing +them under the builder's name would be the controller signing an event as another module. Answering +into the asker's inbox needs a grant over every inbox in the mesh, which +[design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §4 refuses. + +Three symptoms, one cause: **the mesh has roles it cannot describe.** + +## Decision + +**A seat carries the protocol of its role, whether the seat is a module's or the mesh's own.** The +`mesh-*` set gains the same three fields a declared seat has — what it accepts, what it emits, what it +serves — and the holder's authority, its work queue and its consumers are derived from them by the +machinery that already does this for a module's seats. + +**Builds become work submitted to a role.** The build machine seat accepts a build and emits an +outcome. The dedicated `mesh.build.*` branch and the stream behind it retire: a work queue shared by +several build machines is exactly what a seat's `accepts` already is, and keeping a second mechanism +for it means two things to reason about and two places for a permission to be wrong. + +**One publish still reaches three audiences, and now the mesh derived the subject.** A build's outcome +is the seat's own event. Whoever asked matches it by the id their request carried; the controller +records it; the catalogue places it. That is the fan-out the shared exchange gave for free, expressed +as a subject rather than as a topology, and it means no holder needs permission to publish into +anybody's inbox. + +## Alternatives considered + +**A dedicated principal kind for a builder**, mirroring the account the old bus issues it. Smaller: one +addition to the composer, no change to seats, and it matches how a builder is treated today. Not taken +because it answers one of the three symptoms and leaves the other two, and because "the builder is +special" is a claim nobody could justify from the design — a build machine is a role the mesh has, and +the mesh has a word for a role. + +**Leaving the outcome as a reply to the asker's inbox.** Rejected on authority: a holder able to answer +any asker needs a grant across the whole inbox space, which is the one grant design 25 §4 refuses by +name. The seat's event costs the asker a filter and costs the mesh nothing. + +## Consequences + +**A seat is now the mesh's unit of "a role that talks".** A role that accepts work, announces outcomes +or answers questions says so where it is defined, and everything about permissions, queues and +consumers follows. Nothing hand-writes a grant for a role again. + +**The shared library cannot yet publish on a seat, and that is now the blocking gap rather than a +curiosity.** A module holding a seat has the authority and no way to use it; the build machine is +written in Go and reaches the bus directly, so it is unaffected, but the artifact-store event stays +under its module's own name until the library has a surface for this. That is a task, and this record +is what makes it one. + +**A second mechanism disappears.** `mesh.build.*`, the BUILDS stream and the builder's hand-written +account all retire. Fewer things, and the ones left are derived. + +**The catch-up question is not settled by this**, only made answerable: a seat that serves something +gives the controller a way to be asked, which the mesh did not have. Whether catch-up should be a +question at all remains open. diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index 740bca0..7c325c6 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -136,6 +136,7 @@ python3 00-META/checks/index.py fail if stale - **0117** — [The bus is the only broker](0117-the-bus-is-the-only-broker.md) *(superseded)* - **0119** — [AMQP is a provision, not the bus](0119-amqp-is-a-provision-not-the-bus.md) - **0120** — [The mesh bus is required, not ambient](0120-the-mesh-bus-is-required-not-ambient.md) +- **0121** — [A seat carries the protocol of its role](0121-a-seat-carries-the-protocol-of-its-role.md) ### Its tiers, from the bottom up diff --git a/03-DESIGN/01-to-be/25-the-bus-on-nats.md b/03-DESIGN/01-to-be/25-the-bus-on-nats.md index 4338f1a..8613129 100644 --- a/03-DESIGN/01-to-be/25-the-bus-on-nats.md +++ b/03-DESIGN/01-to-be/25-the-bus-on-nats.md @@ -18,6 +18,7 @@ decisions: - 02-DECISIONS/0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md - 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md - 02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md + - 02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md --- # 25. The bus on NATS @@ -71,9 +72,7 @@ permissions are expressed as which branches of it that account may publish to an mesh.control..report a node's report (JetStream: CONTROL) mesh.control..alive heartbeat (core, no persistence) mesh.control.enrol an enrolment request (JetStream: CONTROL) -mesh.control.built a build's outcome (JetStream: CONTROL) mesh.node..declare a declaration for a node (JetStream: NODES, last-per-subject) -mesh.build.request work for the build machine (JetStream: BUILDS, work queue) mesh.mod..event. an event (JetStream: EVENTS) mesh.mod..tool. a tool invocation (core request/reply) mesh.seat..accept. work submitted to a role (JetStream: per-seat work queue) @@ -82,6 +81,15 @@ mesh.seat..tool. a role's tool (core request/rep mesh.ask.. the controller's command api (core request/reply) ``` +**Revised 2026-09-27** ([ADR 0121](../../02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md)): +**`mesh.build.request`, `mesh.control.built` and the BUILDS stream are gone.** A build is work submitted to a role, and the +mesh already has a shape for that — a seat's `accept` subjects, on a work queue with a queue group of +holders, which is what a build queue shared by several machines *is*. Keeping a second mechanism for it +meant two things to reason about and two places for a permission to be wrong. A build's outcome is the +seat's own event — which is why the control branch loses its copy too: one publish reaches whoever +asked, the controller that records it and the catalogue that places it — the fan-out a shared exchange gave for free, as a derived subject rather than a +configured topology. + **Revised 2026-09-26** ([design 29](29-what-a-module-declares.md)): a module's events and tools moved from `mesh.events.*` / `mesh.tools.*` into one namespace per module, `mesh.mod..>`, so a module's authority over its own name is a single subject pattern the server enforces — and @@ -124,9 +132,8 @@ Core NATS is at-most-once. Everything the mesh must not lose lives in a JetStrea | Stream | Subjects | Retention | Why | |---|---|---|---| -| CONTROL | `mesh.control.>` except `alive` | work queue, one consumer (the controller), explicit ack | the store-window guarantee ([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)): the controller `nak`s with a delay while its store is away and the message is redelivered; nothing is dropped | +| CONTROL | `mesh.control.>` except `alive` (a build's outcome moved to its seat, ADR 0121) | work queue, one consumer (the controller), explicit ack | the store-window guarantee ([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)): the controller `nak`s with a delay while its store is away and the message is redelivered; nothing is dropped | | NODES | `mesh.node.>` | last per subject | one declaration per node, always the newest | -| BUILDS | `mesh.build.>` | work queue, explicit ack | at least once; a builder that dies mid-build has its message redelivered | | EVENTS | `mesh.mod.*.event.>` | limits (age, size), durable consumer per subscribing module | a subscriber that was down catches up; after `max-deliver` attempts the advisory feeds `mesh.events.dead` (its own small stream) | Tool calls and heartbeats stay on core NATS: a lost heartbeat is the next heartbeat; a lost tool @@ -207,7 +214,8 @@ expresses this exactly, per subject, and better than a vhost could: permissions for each consumed event's subject, its tool subjects, and that same inbox prefix. Nothing else. A module that tries to publish outside its emits is refused by the server, not by convention. -- **The controller's user** owns `mesh.control.>`, `mesh.node.>`, `mesh.build.>` and the streams. +- **The controller's user** owns `mesh.control.>`, `mesh.node.>` and the streams, and may submit work + to the seats the mesh's own flows use — a build, for one (ADR 0121). **A host's user** may publish its own `mesh.control..>` and subscribe its own `mesh.node..declare` — and nothing of any other node's. - **A person's user** (§7) is a module-shaped user with permissions on the tool subjects it may diff --git a/03-DESIGN/01-to-be/29-what-a-module-declares.md b/03-DESIGN/01-to-be/29-what-a-module-declares.md index 2fa0186..0184627 100644 --- a/03-DESIGN/01-to-be/29-what-a-module-declares.md +++ b/03-DESIGN/01-to-be/29-what-a-module-declares.md @@ -11,6 +11,7 @@ decisions: - 02-DECISIONS/0041-events-are-a-relationship.md - 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md - 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md + - 02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md --- # 29. What a module declares, and what the bus makes of it @@ -130,6 +131,25 @@ seat. The module does not name them, does not know their names, and cannot misco and the controller stays the only writer of stream and consumer definitions ([design 25](25-the-bus-on-nats.md) §3). +**The mesh's own seats carry protocol too.** *Added 2026-09-27, +[ADR 0121](../../02-DECISIONS/0121-a-seat-carries-the-protocol-of-its-role.md).* A seat declared by a +module says what it accepts, emits and serves; the `mesh-*` set said only who does a job. So the mesh +had roles it could not describe — a build machine with three audiences for one outcome and no way to +derive a grant for any of them, and an event genuinely about a role with nowhere to live but the +namespace of whichever module happens to hold it. The mesh's seats now take the same three fields, and +the same machinery derives the holder's authority, its work queue and its consumers. + +**So a build is work submitted to a role, like any other.** The build machine seat accepts a build and +emits an outcome, and the dedicated branch that carried builds retires: a work queue shared by several +machines is exactly what `accepts` already is, and a second mechanism for it is two places a permission +can be wrong. + +**And one publish reaches three audiences without anybody's inbox being opened.** A build's outcome is +the seat's own event: whoever asked matches it by the id their request carried, the controller records +it, the catalogue places it in the graph. That is the fan-out a shared exchange gave for free, written +as a subject the mesh derived instead of a topology somebody configured — and it is why a holder needs +no permission to publish into an asker's inbox, which is the one grant design 25 §4 refuses by name. + **Retention belongs to whoever owns the namespace, not to a consumer.** A seat declares how long its inbound backlog survives, because that is a property of the service: