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.
5.2 KiB
topic, status, date, deciders, reconstructed, extends
| topic | status | date | deciders | reconstructed | extends |
|---|---|---|---|---|---|
| the mesh | accepted | 2026-09-27 | jochen | false | 02-DECISIONS/0118-a-module-declares-its-own-seats.md |
121. A seat carries the protocol of its role
Context
ADR 0118 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). 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 §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.