Research 016: survey how each provider can rotate a credential

Overlap as drafted in 0113 would have deleted consumer data: seven of
eight providers name the resource after the login and five drop it on
remove. Rotation is now undecided in 0113 and to-be 27, pending the
survey. Also: a requirement naming a seat resolves to its holder, a
person chooses among remaining candidates at assignment, the controller's
secrets are requirements of its definition, genesis seals to the
control-node key, and moving the vault or broker is break-glass.
This commit is contained in:
jochen
2026-09-26 00:14:23 +02:00
parent 805df3f81e
commit 942ebe350f
9 changed files with 422 additions and 199 deletions
+17 -17
View File
@@ -8,7 +8,7 @@ code:
- 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-25
updated: 2026-09-26
decisions:
- 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md
- 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md
@@ -78,7 +78,8 @@ merges.
controller, the store holding its records, the broker carrying its bus. **They route no consumer.** The
store and broker modules may run on other nodes too. A database or `amqp` consumer is served by
co-location, from whichever runs on its own node, the seat's holder included
([23 — Choosing a provider](23-choosing-a-provider.md)).
([23 — Choosing a provider](23-choosing-a-provider.md)). A requirement cannot name one of them,
because they deliver nothing.
## A seat that delivers a provision
@@ -86,19 +87,18 @@ co-location, from whichever runs on its own node, the seat's holder included
the npm registry, git and the vault are each one per mesh by decision. A seat that delivers a
provision may only be held by an assignment of a module that provides it, at the seat's scope.
**Its holder answers for that provision.** A requirement for it resolves, in order, to:
**A requirement may name the seat, and then its holder answers.** Naming the seat asks for *the
mesh's* one, so the holder answers **even when another provider runs on the consumer's own machine**,
and nobody is asked anything. With the seat unheld, the requirement is refused, naming the seat. A
second provider can run beside the holder and harm nothing. A forge assignment holds
`npm-package-registry`, and an npm proxy may provide the same provision on another machine. A builder
that names the seat is still served by the forge, without anybody pinning it.
1. the provider the consumer's node was pinned to, because a consumer coupled to one provider's
contents has said so ([23 — Choosing a provider](23-choosing-a-provider.md));
2. the holder of the seat, **even when another provider runs on the consumer's own machine**;
3. otherwise nothing, and the requirement is refused, naming the unheld seat.
Co-location, which answers first for every other provision, does not apply here: a seat says which
one is the mesh's, and co-location answering first would let any second provider on a consumer's
machine take over for that consumer, silently. So a second provider can run beside the holder and
harm nothing. A forge assignment holds `npm-package-registry`. An npm proxy may provide the same
provision on another machine, and a module requiring an npm registry is still served by the forge,
without anybody pinning it.
**A requirement that names no seat resolves as any other**: a pin, the provider on the consumer's own
machine, the only provider. If several remain and none is local, a person chooses when the module is
assigned. The candidates are listed with the seat's holder suggested first, and the answer is recorded
as the assignment's pin ([27](27-a-module-requires-the-mesh-resolves.md)). Nothing is guessed, and
nothing changes silently because a second provider happened to appear nearby.
**Moving the role is changing which assignment holds the seat.** No definition changes and nothing is
unassigned: the forge keeps running, and keeps holding `git`, when its npm role moves. A module can
@@ -106,9 +106,9 @@ take the role only if its definition says it can hold the seat.
**The vault's provision is reserved.** Only an assignment holding `mesh-vault` may provide `secret` at
all: a definition providing it that cannot hold the seat is refused, an assignment providing it without
holding the seat is refused, and a pin cannot choose another provider, because there is none. A second
provider of secrets would be a second place secrets live, which is what the vault being one per mesh
exists to prevent.
holding the seat is refused, and a `secret` requirement always names the seat, because there is no
other provider. A second provider of secrets would be a second place secrets live, which is what the
vault being one per mesh exists to prevent.
**What a consumer receives is what it required**, the same as for any provision: where the provider
answers, what it serves, and a credential. A consumer never reads the seat directly. The one exception
@@ -2,7 +2,7 @@
layer: to-be
status: proposed
code: []
updated: 2026-09-25
updated: 2026-09-26
decisions:
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
- 02-DECISIONS/0113-the-vault-makes-every-secret.md
@@ -59,15 +59,20 @@ can come from and a reviewer has to know every one.
Which module answers, in order:
1. **a pin**: the assignment names a provider, because this consumer is coupled to that provider's
1. **the holder of the seat the requirement names.** A requirement may name a seat instead of leaving
the provider open. It asks for *the mesh's* one, and the mesh answers with whichever assignment
holds that seat, with nothing asked of anyone. Unheld, the requirement is refused, naming the seat
([ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md),
[26 — The seats](26-the-seats.md)). A `secret` requirement always names `mesh-vault`, because
that provision is reserved;
2. **a pin**: the assignment names a provider, because this consumer is coupled to that provider's
contents ([ADR 0084](../../02-DECISIONS/0084-which-provider-serves-a-consumer.md));
2. **the holder of a seat** that delivers the provision, where one does. Co-location does not apply
to these: the seat is the mesh's one answer for everyone ([ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md),
[26 — The seats](26-the-seats.md));
3. for a provision no seat delivers, **the provider on the consumer's own node**;
4. for a provision no seat delivers, **the only provider** in the mesh;
5. otherwise **refused**: naming the unheld seat, for a provision a seat delivers, even when exactly
one provider exists; naming the candidates otherwise.
3. **the provider on the consumer's own node**;
4. **the only provider** in the mesh;
5. otherwise **a person chooses, at assignment**. Assigning the module lists the candidates, with the
holder of a seat that delivers the provision suggested first, and the answer is recorded on the
assignment as its pin. Without an answer the module is not assigned, and the refusal names the
candidates. Nothing is ever guessed ([ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md)).
### Secrets: provisioning all the way down
@@ -103,10 +108,10 @@ Every other shared secret takes the same path:
- a secret a backend issues itself, such as a forge's API token, which the module that received it
delivers to the vault.
**Parties that are not modules take the same path too.** The controller's own store login and bus
account, and each node agent's bus account, have no definition to require them, because the controller
and a node agent are the mesh itself. The controller asks the vault on its own behalf or a node's, and
the answer is made, sealed and carried exactly as for a module.
**The controller takes the same path, because it is a module.** Its store logins and bus accounts are
own secrets of its definition today, and become requirements of that definition. **A node's host is the
one party with no definition**, because it is what runs definitions. Its bus account is a requirement
the mesh makes for each enrolled node, answered and carried exactly as for a module.
**A provider makes resources and data.** Beyond secrets, a provider's adapter may answer with its
contract's non-secret fields: an analytics site id, a registered public name. The mesh carries them
@@ -206,14 +211,14 @@ runtime base, which the installation makes only after the store, the broker and
the controller over the bus. So the vault is installed **as soon as that base exists**, before any other
module built on it, and genesis generates what is needed until then:
- the store's superuser, and the broker's admin in the hashed form the broker needs;
- the bus accounts of the temporary and permanent controller, the control-node's agent, the builder,
the broker's own provisioner and the vault;
- the controller's store login, and the first enrolment token.
- the bus accounts of the temporary and permanent controller (its account and the broker-management
login), the control-node's host, the builder, the broker's own provisioner and the vault;
- the controller's three store logins, and the first enrolment token.
Until the broker's provisioner runs, genesis creates those bus accounts with the broker's admin, as the
controller does today; the provisioner adopts them when it starts. Genesis seals everything to the
operator key as today ([ADR 0085](../../02-DECISIONS/0085-a-secret-is-a-provision.md)), and when the
vault is installed it **delivers the values to it, recorded as the mesh's own**. That distinction keeps
control-node's key, and when the vault is installed the controller **delivers the values to it,
recorded as the mesh's own**, with nobody present. That distinction keeps
them rotatable: an operator's value is never replaced, and these are, because the vault can make their
replacements.
@@ -221,48 +226,34 @@ That is the one time anything but the vault generates a shared secret, and it en
over. It is also the answer to the objection ADR 0085 had to the vault being the only maker: the
vault cannot make what exists before it, so what exists before it is delivered to it.
**Raising the vault or the broker again is a genesis act.** Moving either seat to a new assignment, or
recovering either after it is lost, delivers the values it needs the way genesis did. It is a stated
break-glass procedure, and an ordinary assignment attempting it is refused.
## Rotation
Rotating a secret is asked of the vault, by an operator or by the vault's own policy, such as a
maximum age in the secret's contract ([ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md)).
An operator's external key is not rotated by the vault, which cannot make its replacement: an
operator delivers a new one.
maximum age in the requirement's contract, and the vault makes the new value
([ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md)). An operator's external key is
not rotated by the vault, which cannot make its replacement: an operator delivers a new one.
**A secret's contract says how each recipient takes a new value.** A recipient either *applies* it,
through a provisioner (a provider creating the login, the broker's provisioner updating an account,
the store's own provisioner changing its superuser), or *reads it at start*. A secret a service reads
only when it first initialises is marked applied, because a restart would change nothing.
**Each requirement says how its recipient takes a new value.** It either *applies* it, through a
provisioner (a provider setting a login's password, the broker's provisioner updating an account, a
store's provisioner changing its own superuser), or *reads it at start*. Every module in the catalogue
reads its secrets at start, and none watches them. The host already recreates a container when a file
it read at creation changes, its env-files and files mounted into it directly
([issue 103](../../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md)).
So a reader's restart is derived, and a definition declares `restart-on` only for a secret reaching a
process, or a file in a mounted directory. A secret a service takes only at first initialisation is
applied by its provisioner or marked not rotatable by the mesh, and a rotation of it is refused rather
than reported done.
**Old and new overlap, so nobody is ever without a credential that works.** A credential is never
changed in place. Each consumer has two logins, both derived by the mesh, and uses one at a time:
1. **The vault makes the new value.**
2. **Each applier adds it beside the old**, as the consumer's other login, through the adapter's
existing create. It verifies that the new login works and the old one still does, and confirms,
repeating that confirmation on every reconcile pass until the vault acknowledges it.
3. **Only then is the new login released to the readers**, such as gitea, login and value together.
4. **The host restarts every such reader**, and recreates a container whose env-file carries the
secret. It knows which, because a definition reads a secret only through its requirement, so no
definition declares a restart for a secret. An applier is never restarted for it. This needs
[issue 103](../../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md)
fixed, or a container fed by an env-file keeps the old value.
5. **Each reader confirms** by passing its health check with the new login, where its definition
declares one.
6. **Only when every reader has confirmed is the old login retired**, through the adapter's existing
remove, and verified to no longer authenticate.
So a reader whose machine is offline keeps working on the old login until it returns, a bus account's
owner keeps its bus until it has moved, and a provisioner restarted mid-rotation is still delivered
both values. The rotation shows as waiting on whichever applier or reader has not moved, and is done
only when the old login is gone.
**No consumer module changes.** A provider's adapter gains one duty: both of a consumer's logins get the
same rights over its data, which in postgres means both belong to one role that owns it. The
alternation itself is the provider loop's.
A secret some service reads only when it first initialises cannot be rotated by restarting it. It is
applied by a provisioner, or, where none exists, marked not rotatable by the mesh, and a rotation is
refused rather than reported done.
**How old and new change over is not settled.** Until it is, rotation stays as the controller does it
today: in place, both ends sent in one push, with a stated window in which a consumer cannot
authenticate. [Research 016](../../01-RESEARCH/016-how-a-credential-can-be-rotated/00-overview.md)
measured three mechanisms against every provider. One constraint holds whichever is chosen: **retiring a
credential must never remove a consumer's resource.** Today's adapters remove both in one call, and in
five providers that deletes the consumer's data.
## Refusing
@@ -271,6 +262,7 @@ requirement it names what is missing and what would answer it:
- an unheld seat, and which modules could hold it;
- no provider, and which modules could provide it;
- several candidates and no choice made, and which they are;
- an operator value with no default, and that the assignment must give it;
- a provider, or the vault, that has not answered yet, and which one.
@@ -309,9 +301,9 @@ Each phase ends at a check that holds, so none of them leaves a mechanism half-r
foundation's first secrets to the vault; the broker's provisioner creates every bus account; the
mesh carries providers' data back.
[Issue 103](../../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md)
is fixed first. *Ends when* nothing outside the vault generates a shared secret after genesis, a lab
consumer of analytics receives its site id, and a database credential rotates with old and new
overlapping, the consumer restarted by derivation and the rotation confirmed.
is fixed in the host already. *Ends when* nothing outside the vault generates a shared secret after
genesis, a lab consumer of analytics receives its site id, and a database credential rotates, the
vault making the value, the consumer recreated by derivation, and its data intact.
3. **Definitions move, and seats move to assignments.** Every catalogue definition is rewritten, adopted
and running assignments placed where their data already is, and each claim becomes a seat the
module can hold, held by the assignment that holds it today. *Ends when* the list of definitions
@@ -324,7 +316,7 @@ Each phase ends at a check that holds, so none of them leaves a mechanism half-r
|---|---|
| Every requirement has one of the four provider kinds | The parser refuses any other. |
| A definition names no host path, node or mesh | The catalogue tests of [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md). |
| A module provider is chosen by pin, seat, co-location, only one, refusal | Resolution tests for each step; for a second provider on a consumer's own machine when a seat delivers the provision; and for an unheld seat with exactly one provider, refused. |
| A module provider is chosen by named seat, pin, co-location, only one, a person's choice | Resolution tests for each step: a requirement naming a seat served by its holder even with another provider on the consumer's node, and refused when the seat is unheld; several candidates and none local, where assignment lists them with the seat's holder first and records the choice as a pin, and refuses without one. |
| A provider answers within its contract | A controller test: an answer carrying a field its contract does not name, or missing one it does, is refused and not delivered. |
| A module narrows a contract and never widens it | The parser refuses a module specification that loosens a contract's field. |
| A host requirement is answered on its own node | A resolution test placing one elsewhere: refused. |
@@ -337,12 +329,16 @@ Each phase ends at a check that holds, so none of them leaves a mechanism half-r
| Only the vault generates a shared secret after genesis | A controller test: no code path generates one. An installer test: genesis generates exactly the foundation's first secrets and delivers them to the vault. |
| Only the vault provides `secret` | The parser refuses another provider of it, and resolution refuses a pin on a `secret` requirement. |
| A provider's per-consumer secret comes from the vault | A resolution test: requiring a database expands to a secret requirement named for the consumer, answered by the vault and delivered to both recipients. |
| Rotation overlaps old and new | The rotation tests of [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md): both logins authenticate while readers move; an offline reader keeps working on the old login; the old login is retired only after every reader confirms; an applied secret restarts nothing, and one read at start restarts its reader without a declared restart. |
| Restarts are derived, and rotation keeps data | The tests of [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md): an applied secret restarts nothing, one read at start recreates its reader without a declared restart, and rotating a consumer's credential leaves its resource and data intact. |
| Refusal names everything at once | A resolution test with three unresolved requirements of different kinds: one refusal naming all three. |
| The old forms retire | The catalogue test listing definitions still using one. It must be empty before a form is removed. |
## Not settled here
- How old and new credentials change over on rotation: in place, as today, or two logins over one
resource, as [research 016](../../01-RESEARCH/016-how-a-credential-can-be-rotated/03-the-options.md)
recommends for credentials with two parties. It is decided in its own record.
- The exact spelling of the one form. It must name a requirement and a field and nothing else.
- The layout a node's default root uses beneath it, beyond one directory per assignment.
- Whether a module provider's answer can change without the provider being asked, for example a