The mesh's own roles carry a protocol, and the build branch retires

ADR 0121, first half. The `mesh-*` seats said who does a job and nothing about what
may be said to them or by them, so the mesh had roles it could not describe. They
take the same three fields a module's seat has now, and the machinery that already
derives a work queue, a holder's worker and a permission set from a declared seat
does it for these too.

The build-machine role accepts a build and emits an outcome, so `mesh.build.request`,
`mesh.control.built` and the BUILDS stream are gone. A work queue shared by several
build machines is what a seat's `accepts` already is, and keeping a second mechanism
for it was two places a permission could be wrong.

The controller's own side of a seat is a named list rather than something derived: it
is not a module and declares no `uses`, so which roles the mesh itself submits work to
has to be stated — and stating it makes that question answerable.

Two things this caught:

**The followed event subjects were hard-coded and had just gone stale.** They were
written out while the catalogue still spelled its events as the old bus's routing keys,
so converting those (issue 127) turned the pair into a controller listening to a
subject nothing publishes — the same fault as the issue, from the other side. They
derive from the emitter and the event name now, through the same function the
permission uses, so the two cannot drift apart.

**A role's queue exists before its holder**, checked against a real server, and
asserting twice changes nothing. Work queues until somebody arrives to do it, so
assigning a build machine later flushes the backlog instead of having lost it.
This commit is contained in:
2026-09-27 15:34:44 +02:00
parent 05ff6065d0
commit 0c83ecf1b5
9 changed files with 144 additions and 27 deletions
+6
View File
@@ -713,6 +713,12 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
if err := broker.Raise(js, names); err != nil { if err := broker.Raise(js, names); err != nil {
return err return err
} }
// The work queues of the mesh's own roles (novox/hq ADR 0121). The queue before the holder,
// deliberately: work queues until somebody arrives to do it, so assigning a build machine a week
// after something started asking for builds flushes the backlog instead of having lost it.
if err := broker.RaiseSeats(js, inventory.MeshSeats(), nil); err != nil {
return err
}
fmt.Printf("the bus at %s has its streams, and %d machine(s) can hear a declaration\n", fmt.Printf("the bus at %s has its streams, and %d machine(s) can hear a declaration\n",
address, len(names)) address, len(names))
return nil return nil
+4 -2
View File
@@ -89,8 +89,10 @@ type DeclaredSeat struct {
Accepts []string Accepts []string
// Emits are the verbs the seat's holder publishes under the seat's own name. An event about a // Emits are the verbs the seat's holder publishes under the seat's own name. An event about a
// role belongs here rather than in the holder's namespace, because the name then outlives // role belongs here rather than in the holder's namespace, because the name then outlives
// whoever fills it (novox/hq 04-ISSUES/127). // whoever fills it (novox/hq ADR 0121, 04-ISSUES/127).
Emits []string Emits []string
// Serves are the verbs the holder answers, request and reply.
Serves []string
RetainSeconds int RetainSeconds int
} }
+15 -2
View File
@@ -76,6 +76,11 @@ type Principal struct {
PasswordHash string PasswordHash string
} }
// meshSeatsTheControllerUses are the roles the mesh's own flows submit work to. Named rather than
// derived from the seat set: the controller is not a module and declares no `uses`, so its side of a
// seat has to be stated, and a list is what makes "which roles does the mesh itself talk to" answerable.
var meshSeatsTheControllerUses = []string{"mesh-build-machine"}
// enrolmentPrefix is the space every enrolling node's user and inbox live under, so the one place the // enrolmentPrefix is the space every enrolling node's user and inbox live under, so the one place the
// controller may answer an enrolment is derived from the same constant the user is named from. // controller may answer an enrolment is derived from the same constant the user is named from.
const enrolmentPrefix = "enrol" const enrolmentPrefix = "enrol"
@@ -154,8 +159,16 @@ func PermissionsFor(p Principal) (Permissions, error) {
case KindController: case KindController:
// The controller owns the mesh's own traffic and the streams. It is the only writer of // The controller owns the mesh's own traffic and the streams. It is the only writer of
// stream definitions (design 25 §3), so it alone reaches the JetStream API. // stream definitions (design 25 §3), so it alone reaches the JetStream API.
pub = []string{"mesh.control.>", "mesh.node.>", "mesh.build.>", "$JS.API.>"} pub = []string{"mesh.control.>", "mesh.node.>", "$JS.API.>"}
sub = []string{"mesh.control.>", "mesh.build.>", "$JS.API.>"} sub = []string{"mesh.control.>", "$JS.API.>"}
// Work the mesh's own flows submit to a role, and the outcomes they wait on (ADR 0121). A
// build is the one today: the controller asks, and reads the answer from the seat's event
// like the catalogue does — which is why no holder needs to publish into anybody's inbox.
for _, seat := range meshSeatsTheControllerUses {
pub = append(pub, "mesh.seat."+seat+".accept.>")
sub = append(sub, "mesh.seat."+seat+".event.>")
}
// The two events it reacts to, and its ack subject on the stream they arrive from // The two events it reacts to, and its ack subject on the stream they arrive from
// (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the // (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the
+44
View File
@@ -99,3 +99,47 @@ func TestTheControlConsumerDoesNotDeadLetterBeforeTheControllerGivesUp(t *testin
"before the controller finished deciding about it", info.Config.MaxDeliver) "before the controller finished deciding about it", info.Config.MaxDeliver)
} }
} }
// A role's work queue exists before anybody holds it, against a real server.
//
// **The queue before the holder is the point** (novox/hq ADR 0121): work queues until somebody arrives
// to do it, so assigning a build machine a week after something started asking for builds flushes the
// backlog instead of having lost it. A stream created at assignment would make "the holder is not here
// yet" mean "your requests are gone".
func TestRaisingAMeshRolesWorkQueue(t *testing.T) {
js := aLiveBus(t)
seats := []DeclaredSeat{{Name: "mesh-build-machine", Accepts: []string{"build"},
Emits: []string{"built"}}}
t.Cleanup(func() { _ = js.Context().DeleteStream("SEAT_MESH_BUILD_MACHINE") })
if err := RaiseSeats(js, seats, nil); err != nil {
t.Fatalf("a real server refused a role's work queue: %v", err)
}
info, err := js.Context().StreamInfo("SEAT_MESH_BUILD_MACHINE")
if err != nil {
t.Fatalf("the role has no work queue: %v", err)
}
if info.Config.Retention != nats.WorkQueuePolicy {
t.Errorf("the queue retains as %v: work a holder took must leave it, or the next holder does "+
"it again", info.Config.Retention)
}
if len(info.Config.Subjects) != 1 || info.Config.Subjects[0] != "mesh.seat.mesh-build-machine.accept.>" {
t.Errorf("it carries %v rather than the role's own inbound subjects", info.Config.Subjects)
}
// Nobody holds it, so there is no worker — and asserting again changes nothing, because this runs
// on every start.
if err := RaiseSeats(js, seats, nil); err != nil {
t.Fatalf("asserting a role's queue a second time failed, so a restart would: %v", err)
}
// And once somebody holds it, the worker appears on that same queue.
if err := RaiseSeats(js, seats, map[string]Holder{
"mesh-build-machine": {Node: "anchor", Module: "builder"},
}); err != nil {
t.Fatal(err)
}
if _, err := js.Context().ConsumerInfo("SEAT_MESH_BUILD_MACHINE",
"SEAT_MESH_BUILD_MACHINE_worker"); err != nil {
t.Fatalf("the holder got no worker on the role's queue: %v", err)
}
}
+17 -16
View File
@@ -63,8 +63,10 @@ type Stream struct {
func MeshStreams() []Stream { func MeshStreams() []Stream {
return []Stream{ return []Stream{
{ {
Name: "CONTROL", Name: "CONTROL",
Subjects: []string{"mesh.control.*.report", "mesh.control.enrol", "mesh.control.built"}, // A build's outcome is no longer here: it is the build-machine seat's own event, so one
// publish reaches whoever asked, the controller and the catalogue (novox/hq ADR 0121).
Subjects: []string{"mesh.control.*.report", "mesh.control.enrol"},
Retention: RetentionWorkQueue, Retention: RetentionWorkQueue,
Why: "the store-window guarantee (ADR 0083): the controller naks with a delay while its " + Why: "the store-window guarantee (ADR 0083): the controller naks with a delay while its " +
"store is away and the message is redelivered; nothing is dropped", "store is away and the message is redelivered; nothing is dropped",
@@ -76,12 +78,6 @@ func MeshStreams() []Stream {
Why: "one declaration per node, always the newest; a node that sees sequence n refuses " + Why: "one declaration per node, always the newest; a node that sees sequence n refuses " +
"n-1 by construction (issue 107)", "n-1 by construction (issue 107)",
}, },
{
Name: "BUILDS",
Subjects: []string{"mesh.build.request"},
Retention: RetentionWorkQueue,
Why: "at least once, one builder at a time; a builder that dies mid-build has its message redelivered",
},
{ {
Name: "EVENTS", Name: "EVENTS",
// A seat's own events ride here too: they are 1:many like any event, and the // A seat's own events ride here too: they are 1:many like any event, and the
@@ -167,15 +163,20 @@ const ControllerName = "controller"
// ControllerFollows are the events the controller reacts to: the catalogue saying a module's // ControllerFollows are the events the controller reacts to: the catalogue saying a module's
// current version moved, and a catalogue that has just started saying it may have missed builds. // current version moved, and a catalogue that has just started saying it may have missed builds.
// //
// **These carry the local names the manifests hold today**, which still spell an event the way a // **Derived the same way a module's subscription is**, from the emitter and the bare local event
// routing key on the bus the mesh has does — `module.<module>.<verb>` rather than design 29's bare // name, rather than written out. They were written out while the catalogue still spelled its events
// verb — so the derived subject names the module twice. It is consistent, and it is what the // as the old bus's routing keys, and the moment those were converted (novox/hq 04-ISSUES/127) a
// catalogue actually publishes, so it is what the controller must listen to. It changes when those // hard-coded pair became a controller listening to a subject nothing publishes — the same fault, from
// names are converted, and not before: a subscription written against the name design 29 specifies // the other side. Deriving them means the conversion could not leave these behind.
// would be a controller listening to a subject nothing publishes.
var ControllerFollows = []string{ var ControllerFollows = []string{
"mesh.mod.mesh-catalog.event.module.mesh-catalog.upgraded", moduleEventSubject("mesh-catalog", "upgraded"),
"mesh.mod.mesh-catalog.event.module.mesh-catalog.catching-up", moduleEventSubject("mesh-catalog", "catching-up"),
}
// moduleEventSubject is where one module's event lands. The same derivation PermissionsFor uses, so
// what the controller subscribes and what the emitter is permitted to publish cannot drift apart.
func moduleEventSubject(module, event string) string {
return "mesh.mod." + module + ".event." + event
} }
// MeshConsumers is what the controller consumes, in the order a person reads it. // MeshConsumers is what the controller consumes, in the order a person reads it.
-1
View File
@@ -125,7 +125,6 @@ func TestEachStreamCarriesTheRetentionItsShapeNeeds(t *testing.T) {
want := map[string]Retention{ want := map[string]Retention{
"CONTROL": RetentionWorkQueue, "CONTROL": RetentionWorkQueue,
"NODES": RetentionLastPerSubject, "NODES": RetentionLastPerSubject,
"BUILDS": RetentionWorkQueue,
"EVENTS": RetentionLimits, "EVENTS": RetentionLimits,
} }
got := map[string]Retention{} got := map[string]Retention{}
+2 -2
View File
@@ -23,8 +23,8 @@ accounts {
MESH { MESH {
users = [ users = [
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.build.>", "mesh.control.>", "mesh.node.>"] } publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.control.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>"] }
subscribe: { allow: ["$JS.API.>", "_INBOX.controller.>", "mesh.build.>", "mesh.control.>", "mesh.mod.mesh-catalog.event.module.mesh-catalog.catching-up", "mesh.mod.mesh-catalog.event.module.mesh-catalog.upgraded"] } subscribe: { allow: ["$JS.API.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.>"] }
allow_responses: { max: 1, ttl: "1m" } allow_responses: { max: 1, ttl: "1m" }
} } } }
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: { { user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
+32 -1
View File
@@ -27,6 +27,17 @@ type Seat struct {
// provision may only be held by a module providing it at the seat's scope, and its holder is // provision may only be held by a module providing it at the seat's scope, and its holder is
// what a requirement for that provision resolves to when several modules provide it. // what a requirement for that provision resolves to when several modules provide it.
Delivers string Delivers string
// Accepts, Emits and Serves are the protocol of the role, as local verbs — the same three a
// module declares for a seat of its own (novox/hq ADR 0118), and empty for most of these: a seat
// is usually about who does a job and not about what may be said to them.
//
// **Named here so the mesh has no role it cannot describe** (ADR 0121). Without them a build
// machine had three audiences for one outcome and nothing derived a grant for any of them, and an
// event about a role had nowhere to live but the namespace of whichever module held that role
// today — which the bus refuses, because a namespace belongs to who it is named for.
Accepts []string
Emits []string
Serves []string
// Decision is the record that made it a seat. // Decision is the record that made it a seat.
Decision string Decision string
} }
@@ -53,7 +64,12 @@ var seats = []Seat{
{Name: "mesh-catalog", Scope: ScopeMesh, Decision: "novox/hq ADR 0110"}, {Name: "mesh-catalog", Scope: ScopeMesh, Decision: "novox/hq ADR 0110"},
{Name: "mesh-npm-package-registry", Scope: ScopeMesh, Delivers: "npm-package-registry", Decision: "novox/hq ADR 0109"}, {Name: "mesh-npm-package-registry", Scope: ScopeMesh, Delivers: "npm-package-registry", Decision: "novox/hq ADR 0109"},
{Name: "mesh-git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0111"}, {Name: "mesh-git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0111"},
{Name: "mesh-build-machine", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, // A build is work submitted to this role, and its outcome is the role's own event (ADR 0121).
// One publish reaches whoever asked, the controller that records it, and the catalogue that
// places it in the module graph — which is what the old bus's shared exchange did for free, and
// what a dedicated build branch was doing a second way.
{Name: "mesh-build-machine", Scope: ScopeNode,
Accepts: []string{"build"}, Emits: []string{"built"}, Decision: "novox/hq ADR 0110"},
{Name: "mesh-dns-port", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, {Name: "mesh-dns-port", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
{Name: "mesh-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, {Name: "mesh-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
{Name: "mesh-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, {Name: "mesh-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
@@ -197,3 +213,18 @@ var renamedSeats = map[string]string{
"the-resolver-configuration": "mesh-resolver-configuration", "the-resolver-configuration": "mesh-resolver-configuration",
"the-showcase": "mesh-showcase", "the-showcase": "mesh-showcase",
} }
// SeatsWithAProtocol are the mesh's own seats that say something about what may be said to them or by
// them, which is the set the bus derives streams, consumers and permissions from.
//
// Most of the set is not here, and that is the ordinary case: a seat saying only who does a job grants
// nothing on the bus and needs no queue.
func SeatsWithAProtocol() []Seat {
var out []Seat
for _, s := range seats {
if len(s.Accepts) > 0 || len(s.Emits) > 0 || len(s.Serves) > 0 {
out = append(out, s)
}
}
return out
}
+24 -3
View File
@@ -38,6 +38,15 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
seats[s.Name] = s seats[s.Name] = s
} }
} }
// And the mesh's own, which carry protocol too (novox/hq ADR 0121). Added after the modules'
// rather than before, because a `mesh-*` name is the mesh's and registration refuses a module
// declaring one — so this cannot be shadowed, and if it ever were, the mesh's own would win.
for _, own := range catalogue.SeatsWithAProtocol() {
seats[own.Name] = catalogue.SeatDeclaration{
Name: own.Name, Scope: own.Scope,
Accepts: own.Accepts, Emits: own.Emits, Serves: own.Serves,
}
}
out := broker.Records{Assigned: map[string][]broker.Declared{}, People: map[string][]string{}} out := broker.Records{Assigned: map[string][]broker.Declared{}, People: map[string][]string{}}
for _, n := range nodes { for _, n := range nodes {
@@ -88,9 +97,9 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
Serves: m.Tools, Serves: m.Tools,
} }
for _, c := range m.Claims { for _, c := range m.Claims {
// A seat the mesh defines for itself carries no protocol, so holding one grants nothing here: // Every seat with a protocol, the mesh's own included. One that says only who does a job is
// those seats say who does a job, not who may say what. // not here and grants nothing, which is most of them.
if s, declaredSomewhere := seats[c.Name]; declaredSomewhere { if s, hasAProtocol := seats[c.Name]; hasAProtocol {
d.Holds = append(d.Holds, asSeat(s)) d.Holds = append(d.Holds, asSeat(s))
} }
} }
@@ -106,6 +115,18 @@ func asSeat(s catalogue.SeatDeclaration) broker.Seat {
return broker.Seat{Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: s.Serves} return broker.Seat{Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: s.Serves}
} }
// MeshSeats are the mesh's own seats that carry a protocol, as the bus needs them: what to make a work
// queue for, and whose holder gets a worker on it (novox/hq ADR 0121).
func MeshSeats() []broker.DeclaredSeat {
var out []broker.DeclaredSeat
for _, s := range catalogue.SeatsWithAProtocol() {
out = append(out, broker.DeclaredSeat{
Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: s.Serves,
})
}
return out
}
// NodesWithALiveToken is every machine holding a token that could still be presented — issued, not // NodesWithALiveToken is every machine holding a token that could still be presented — issued, not
// expired, not redeemed. // expired, not redeemed.
// //