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:
@@ -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.
|
||||||
@@ -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)*
|
- **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)
|
- **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)
|
- **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
|
### Its tiers, from the bottom up
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ decisions:
|
|||||||
- 02-DECISIONS/0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md
|
- 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/0083-one-push-leaves-the-mesh-consistent.md
|
||||||
- 02-DECISIONS/0039-what-the-sdk-holds-and-refuses.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
|
# 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>.report a node's report (JetStream: CONTROL)
|
||||||
mesh.control.<node>.alive heartbeat (core, no persistence)
|
mesh.control.<node>.alive heartbeat (core, no persistence)
|
||||||
mesh.control.enrol an enrolment request (JetStream: CONTROL)
|
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.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>.event.<event> an event (JetStream: EVENTS)
|
||||||
mesh.mod.<module>.tool.<tool> a tool invocation (core request/reply)
|
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)
|
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)
|
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
|
**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>.>`,
|
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
|
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 |
|
| 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 |
|
| 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) |
|
| 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
|
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.
|
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
|
Nothing else. A module that tries to publish outside its emits is refused by the server, not by
|
||||||
convention.
|
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
|
**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.
|
`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
|
- **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/0041-events-are-a-relationship.md
|
||||||
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.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/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
|
# 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
|
and the controller stays the only writer of stream and consumer definitions
|
||||||
([design 25](25-the-bus-on-nats.md) §3).
|
([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
|
**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:
|
its inbound backlog survives, because that is a property of the service:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user