Compare commits

..
Author SHA1 Message Date
jschoubben d7d6f2eda0 Design 28: the move needs a credential and a membership for machines already enrolled
The first live attempt at 5.2 found the half nobody had built. With the seat
handed over on record and the new bus's module assigned beside the old one, the
push was refused: not one user has a credential for the new bus. The check is
right. A credential is minted only at enrolment, at `module issue` and for a
person; nothing mints one for a machine already enrolled or for the control plane
itself, and on the host the membership is written once at enrolment and never
rewritten. So "move each machine" had no mechanism under it on either side.

Written into 5.2 as the mechanism to build before anything moves: the control
plane mints what is missing and delivers each plaintext where its owner reads it —
a machine's as a sealed membership in its declaration, a module's as its broker
secret, its own as its module secret — and the host saves a delivered membership
and re-dials on it through the reconnect path it already has. 5.3 is ticked as
built; 5.4's catalogue half is done and its live half waits on 5.2.
2026-09-27 23:47:45 +02:00
jschoubben 694555214a Merge pull request 'Design 26: which assignment holds a seat is on record, and changes as one act' (#153) from design/26-a-seat-is-held-on-record into main 2026-09-27 21:22:56 +00:00
jschoubben a7249541df Design 26: which assignment holds a seat is on record, and changes as one act
Until now the holder was derived — assigned and claiming — and a second eligible
assignment was refused, so a seat could not pass from one holder to the next
without a moment where nobody held it. The controller finds its own bus through
one of these seats, and that moment took the control plane down on 2026-09-27.

The holder is now a row the controller keeps, written by `seat <name> --to
<node>/<module>` in the same write that removes the previous one. No row means the
old rule, so nothing changes for a mesh that never hands a seat over; with a row,
another eligible assignment is silent rather than refused, which is what lets the
next holder run beside the current one until the switch. A holding is the
assignment's and goes when it does. Each rule names the test that checks it.

Under ADR 0131; design 28 task 5.3 is the work.
2026-09-27 23:20:36 +02:00
2 changed files with 66 additions and 5 deletions
+31 -1
View File
@@ -4,12 +4,15 @@ status: implemented
code:
- mesh-controller internal/catalogue/seats.go
- mesh-controller internal/catalogue/resolve.go
- mesh-controller internal/inventory/seats.go
- mesh-controller internal/inventory/migrations/0039-a-seat-is-held-by-one-assignment-on-record.sql
- mesh-controller cmd/mesh-controller/seats.go
- mesh-controller cmd/mesh-controller/source.go
- mesh-controller internal/inventory/migrations/0032-a-source-may-live-on-a-seat.sql
- mesh-catalog modules/gitea/module.json
updated: 2026-09-27
decisions:
- 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
- 02-DECISIONS/0128-the-mesh-bus-is-required-not-ambient.md
- 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md
@@ -42,6 +45,31 @@ is refused. A seat makes a role singular, never a module.
**The seat points at the assignment.** Everything the mesh knows about the holder is what it knows
about that assignment: the node, the node's settings for the module, and what the module serves.
**Which assignment holds a seat is a fact on record, and changes as one act.** Revision, 2026-09-27
([ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)). Until
then the holder was derived — the module that is assigned and claims the seat holds it, and a second
eligible assignment was refused. That has no way to pass a seat from one holder to the next without a
moment in which nobody holds it, and the controller finds its own bus through one of these seats: that
moment took the control plane down for an evening. So the holder is now one row the controller keeps,
written by a handover — `seat <name> --to <node>/<module>` — that names the seat and the assignment
taking it over and replaces the previous holder in the same write. Between two handovers the seat has
exactly one holder, and it is never none.
Three consequences follow. **A seat with no row is held as it always was**: the sole eligible
assignment holds it, and two eligible ones are refused — so a mesh that has never handed a seat over
behaves exactly as before, and the row appears the first time somebody does. **With a row, any other
assignment whose module could hold the seat is eligible and silent**: neither refused nor holding.
That is what lets the next holder run beside the current one until the handover, which the bus's move
needs ([28](28-building-the-bus.md), task 5.3). **And a holding is the assignment's**: unassigning the
holder takes the row with it, so a seat never points at something that is not running anywhere, and
the seat falls back to derivation rather than to nothing.
The handover refuses what would make the new holder wrong before anything is written: the seat must
exist, the assignment must exist, and the module must be able to hold the seat — claim it at its scope
and provide what it delivers, judged against the store's row and not against anything compiled into a
binary. It does not check that the module is running yet; `push` confirms that afterwards, and a
handover that could only be recorded after the new holder was up could not be the switch.
**The set is closed.** A seat the mesh does not define is refused wherever it is named, and so is one
named at the wrong scope. Adding a seat is a decision, recorded, for the reason every addition to the
host's vocabulary is one: the set is what a person reads to learn what a mesh can have, and an entry
@@ -207,7 +235,9 @@ checked as their tables say:
| `mesh-*` is the mesh's, and a module may not declare one | 0118: a registration test refusing a manifest that declares any `mesh-*` seat, naming the prefix. |
| Two modules cannot declare the same seat | 0118: a registration test; the second is refused and the first untouched. |
| A holder satisfies the seat's protocol | 0118: a claim whose module does not serve what the seat declares is refused at assignment. |
| A seat is held by one assignment, and only by one whose module can hold it | 0118: resolution tests for a second holder and for a seat the definition does not name. |
| A seat is held by one assignment, and only by one whose module can hold it | 0118: resolution tests for a second holder and for a seat the definition does not name. 0131: `CanHold` is the one judgement, shared by registration and the handover, and its test follows the store's row. |
| A holder on record settles the seat; another eligible assignment is silent, not refused | 0131: resolution tests with a recorded holder on the same machine, on another machine, and under a seat's former name; without a record, the old rule's tests still pass unchanged. |
| A handover replaces the holder as one write, needs an assignment to point at, and goes with it | 0131: store tests — a second handover leaves one row; a handover to a module not assigned where named is refused; unassigning the holder removes the row. |
| A requirement naming a seat is answered by its holder; a foundation seat cannot be named | 0118: resolution tests with a second provider on the consumer's node, with the seat unheld, and naming `mesh-store`. |
| Several providers and none local is a person's choice | 0118: an assignment test listing candidates with the seat's holder first and recording the pin. |
| `secret` is reserved | 0118: the parser and resolution refusals for another provider and a pin. |
+35 -4
View File
@@ -683,12 +683,43 @@ healthy while reacting to nothing.
> crash-looped for two hours and nothing could be deployed until it was repaired by hand.
> An earlier version of this note said the old broker stays as an ordinary provider of
> `amqp` ([ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md)); that is withdrawn by [ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) — see 5.4.
- [ ] 5.3 **the seat changes hands as one act.** A command takes a seat and the assignment taking it
**What the first live attempt found, 2026-09-27.** With the seat handed over on record and the
new bus's module registered and assigned beside the old one, `push` refused the control node:
*not one user has a credential for the new bus*. The check is right — a bus whose user list is
empty refuses every connection in the mesh — and it exposed the half of this task nobody had
built. A credential is minted at three moments only: a machine's at enrolment, a module's at
`module issue`, a person's at `operator`. **Nothing mints one for a machine already enrolled, or
for the control plane itself.** And on the host, the membership — bus address, fingerprint,
password, transport — is written once, at enrolment, and nothing ever rewrites it. So "move
each machine and confirm it reports" had no mechanism under it on either side.
The mechanism, to build before anything moves:
- **the control plane mints what is missing** — every user the records derive with no hash —
and delivers each plaintext where its owner reads it: a machine's inside its declaration, as a
sealed *membership* for the new bus (address, fingerprint, password, transport); a module's as
its broker secret, the path `module issue` already uses; the control plane's own as its module
secret, so it reads it the way any module does;
- **the host saves a delivered membership and re-dials on it** — the same file enrolment wrote,
the same reconnect path a lost connection takes, so a machine moved this way is a machine
that came back, and nothing new has to be right for it to work;
- **the switch is then two acts in one push**: `MESH_BUS_NATS` on the control plane, and
`seat mesh-broker --to <node>/<the new bus's module>` — the seat never empty, every machine
already holding a credential that works on the other side.
Until the first bullet exists the check keeps refusing, and it should: a machine moved without
a credential cannot come back, and afterwards there is no bus to tell it anything over.
- [x] 5.3 **the seat changes hands as one act.** A command takes a seat and the assignment taking it
over, and the seat is never empty in between — the emptiness is the outage of 2026-09-27, when
the control plane, which finds its own bus through this seat, lost the address and looped.
Today only `seat rename` exists. This is what 5.2 uses to move `mesh-broker` from the old
broker's assignment to the new one's, and it is built first ([ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)).
- [ ] 5.4 **the old broker and everything that named AMQP leave the mesh** ([ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md),
**Built 2026-09-27** (mesh-controller `seat_holding`, migration 0039; design 26 says how it is
checked). Its first live use recorded the standing holder — which the row moving under it had
made unable to satisfy what the seat delivers, so the first handover on a mesh that predates the
record writes down who holds without re-judging them. This is what 5.2 uses to move
`mesh-broker` from the old broker's assignment to the new one's ([ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)).
- [~] 5.4 **the old broker and everything that named AMQP leave the mesh** — the catalogue half done
2026-09-27 (three modules removed; registration refuses the word; the seat's row delivers
`mesh-bus`, migration 0040); the live half — unassigning the old broker — waits on 5.2 ([ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md),
superseding [ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md)): the two modules that
required `amqp` are removed, the broker's module is unassigned and removed, registration refuses
a manifest that provides or requires `amqp`, and a whole-catalogue check asserts none does. Not