ADR 0121: a seat carries the protocol of its role

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.
This commit is contained in:
2026-09-27 15:24:17 +02:00
parent c4a8455e2e
commit c8f430290e
4 changed files with 125 additions and 5 deletions
@@ -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.
+1
View File
@@ -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
+13 -5
View File
@@ -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.<node>.report a node's report (JetStream: CONTROL)
mesh.control.<node>.alive heartbeat (core, no persistence)
mesh.control.enrol an enrolment request (JetStream: CONTROL)
mesh.control.built a build's outcome (JetStream: CONTROL)
mesh.node.<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.<module>.event.<event> an event (JetStream: EVENTS)
mesh.mod.<module>.tool.<tool> a tool invocation (core request/reply)
mesh.seat.<seat>.accept.<verb> work submitted to a role (JetStream: per-seat work queue)
@@ -82,6 +81,15 @@ mesh.seat.<seat>.tool.<verb> a role's tool (core request/rep
mesh.ask.<node>.<command> 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.<module>.>`,
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.<node>.>` and subscribe its own
`mesh.node.<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
@@ -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: