ADR 0228: a value given by hand lives only until its module's first good start

The controller read 0113 as refusing to replace any value given to it, so
two leaked secrets only their own modules read could not be rotated without
a person handling a new value. Amends to-be 13 and points 0113 at it.
This commit is contained in:
jochen
2026-10-06 12:15:29 +02:00
parent 95c024b7c6
commit 9c7ec6ba75
4 changed files with 185 additions and 2 deletions
@@ -293,3 +293,12 @@ travel, which is the other half and was never in question.
> request/reply over the bus, never through the vault and never as a file the host writes. ADR 0183
> states that as a bounded exception — one vendor, tokens that live hours, one recipient per message —
> and a second such channel is a decision of its own.
> **The mechanism changed — 2026-10-06, by [ADR 0228](0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md).**
> What stands: a delivered value the vault cannot replace, such as an external API key, is not rotated
> by the vault, and rotating it means an operator delivering a new one. What moved: the controller had
> read that as covering **every** value given to it, and refused to rotate any of them. 0228 says what
> cannot be replaced is a value an outside party issues, which a module's definition now marks
> (`"issued-by": "outside"`); a given value for a secret the module reads at start is rotated like a
> made one, and one given through `secret accept` is replaced on its own after the module's first good
> start under the mesh.
@@ -0,0 +1,143 @@
---
topic: what runs on it
status: accepted
date: 2026-10-06
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0113-the-vault-makes-every-secret.md
---
# 228. A value given by hand lives only until its module's first good start
## Context
**Two of a module's own secrets leaked into logs on 2026-10-06**, and the operator approved replacing
both. Each module's definition says it reads the secret when it starts (`taken: at-start`), which is
the form the mesh rotates by making a new value and starting the module again
([ADR 0114](0114-a-shared-credential-rotates-over-two-credentials.md),
[to-be 13](../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md)). The controller refused both:
> not rotated: … holds "…" as a value given to the mesh, not made by it, and the mesh will not replace
> what it cannot read (ADR 0113). Change it where it lives, then `secret accept …` with the new value
Both values had been given by hand long before, when the modules were moved from an older setup, and
nothing outside the mesh uses either. The way out the refusal names has a person or an agent make a
value and feed it to `secret accept` — a secret passing through hands, which is the thing the mesh
exists to avoid.
**The refusal reads [ADR 0113](0113-the-vault-makes-every-secret.md) wider than it decides.** 0113 says
*"A delivered value the vault cannot replace, such as an external API key, is not rotated by the vault:
rotating it means an operator delivering a new one"*. What the vault cannot replace is a value only an
outside party can issue — a vendor's key, a bot's token, a licence — because no value of the mesh's
would work in its place. A secret the module reads at start and nobody else holds is not that: the old
value is not needed to replace it, because the module is the only reader and it reads the new one when
it starts. The controller applied the rule to **every** value it had been given, because the store
records only *made* or *accepted*, and nothing in a module's definition says who issued a value.
The operator, the same day: *"Our mesh should most definitely be able to rotate 'custom provided'
passwords — in fact, ideally we immediately rotate them after assigning and running the module for the
first time so the 'custom pwd' is gone"*, and *"a custom pwd is only useful in case we're adopting an
existing running container into our mesh."*
Counted in the catalogue on 2026-10-06: 48 own secrets across 32 modules; 6 say `taken: at-start`, none
says `applied` and 42 say neither. Of the 6 at-start ones, one is an outside party's key (an OpenAI
key). Of the rest, at least ten are keys or tokens an outside party issues.
## Considered Options
1. **Keep the refusal; add a separate verb that turns a given value into a made one** (`secret mint`,
with a reason). Rejected: it keeps a step whose only effect is to say "yes, really" to a rotation
the definition already permits, and it leaves every given value in force until a person remembers
to take it. The operator asked for the opposite default.
2. **Rotate a given at-start value like a made one, and stop there.** Better, and still leaves the
given value in force indefinitely after an adoption — the value a person handled stays the live one
until somebody asks.
3. **Rotate it like a made one, and replace a newly given value on its own once the module has started
on it**, with the exception said where it belongs: in the module's definition, for a value an
outside party issues. Chosen.
4. **Refuse `secret accept` for a secret the mesh may make, except on an adopted machine.** Rejected:
the mesh cannot tell a fresh install from one carrying data in from elsewhere on a converged machine
— a restored volume holds the password it was made with — and the replacement after the first good
start already bounds what a needless given value costs. It is accepted and said.
## Decision
**A module's own secret says who may make its value.** By default the mesh may. An entry says
`"issued-by": "outside"` when only a party outside the mesh can issue the value — a vendor's API key, a
bot's token, a licence. The parser refuses any other word.
**A secret the mesh may make** — read at start, and not issued outside — **is the mesh's to replace,
whoever gave the value it holds:**
- **`secret rotate` replaces a given value as it replaces a made one**: made anew, sealed to the
machine and the operator, recorded as made, and the machine sent so the module starts on it. A
rotation may say why, and the why is recorded in the hand-act log.
- **A value given by hand lives only until the module's first good start under the mesh.** Given
through `secret accept`, it is marked; when the machine's clean report arrives — every resource
applied, nothing failed or refused — **for the declaration it was last sent, sent after the value
was given**, the controller replaces the value with one it makes, sends the machine, says so in its
log and states `secret-replaced` as the controller seat's fact, never the value. On an adopted
machine the module must also be taken: until then the mesh runs nothing of it. The mark is cleared
as the value is replaced, so it happens once.
- **A given value exists to adopt something already running that holds it.** A module the mesh
installs fresh needs none; `secret accept` for such a secret is accepted, says that the value lives
until the first good start, and says on a converged machine that a fresh install needs no value.
**What stays as given, refused with the reason:**
- a value issued outside the mesh — never replaced; `secret rotate` names the issuer as the one to ask
and says how old the given value is, and `secret accept` delivers the new one;
- a value the module **applies** to a backend that takes it once, until the staged rotation of
[ADR 0114](0114-a-shared-credential-rotates-over-two-credentials.md) exists;
- a value the mesh's own code accepted — a bus account it issued is its word to a broker, and is never
marked;
- a value given **before** this record, which is not marked: the mesh does not decide for a person
that something given long ago is used nowhere else. `secret rotate` replaces it when asked.
**What this does not change.** A pair credential an operator delivers is still never replaced by a
made one ([ADR 0092](0092-an-operator-delivers-a-pair-credential.md)): it is held at both ends of a
provision, and this record is about a secret one module holds. A secret whose definition says neither
`at-start` nor `applied` is still not rotated. That the vault makes every shared secret, and its custody,
stand as 0113 decided.
## Consequences
- Rotating a leaked secret a module reads at start is one call, whoever gave it, with no value in
anybody's hands.
- An adoption leaves no hand-given value in force once the module runs under the mesh. A person who
needs the new value — a password typed at a login page — recovers it with the operator key, as for
any value the mesh makes.
- **The catalogue must mark every secret an outside party issues.** An at-start secret left unmarked
is one the mesh will replace with a random value. The two OpenAI keys in the catalogue are marked
with this change; the other outside keys say neither `at-start` nor `applied`, so they are not
rotated either way, and marking them is tidying, not a prerequisite.
- **The controller ships before the catalogue marks anything.** The parser refuses a field it does not
know, so a definition saying `issued-by` is refused by a controller older than this record.
- What got harder: the controller now acts on a machine's report by itself, sending it once more after
the first good start of a module given a value. A replacement that cannot be sent is kept sealed and
carried by the next push, and said.
## How it is checked
| Rule | Checked by |
|---|---|
| A given value read at start rotates like a made one | Controller inventory test: a value accepted for an at-start secret rotates, and is recorded as made. |
| A given value is replaced once, after the first good start on it | Controller inventory test: a report of a declaration sent before the value was given replaces nothing; a report of an older declaration replaces nothing; the clean report of the declaration sent after it replaces it; the same report again, and the next declaration's report, replace nothing. |
| An outside party's value is never replaced | Controller inventory tests: a value accepted for a secret marked `issued-by: outside` is not marked and not replaced after a start; `rotate` refuses it naming the issuer and the value's age; a definition changed to say `outside` after the value was given keeps it. |
| An applied value, and one the mesh's own code accepted, stay as given | Controller inventory test: neither is marked or replaced after a start; `rotate` of an applied value is refused as not stageable. |
| An adopted machine waits for the take | Controller inventory test: a module held as found keeps its given value through a clean report; after the take, the next clean report replaces it. A module no longer assigned is never replaced. |
| A good start is a clean account of a declaration | Controller test: a refusal, a failure, or a bare word that the machine is there is not one. |
| The definition says who issues a value | Catalogue test: `issued-by` is read, written back as read, and any word but `outside` is refused. |
| The fact is stated, and permitted | Broker test: the controller's grant and its seat's emits both name `secret-replaced`, and nothing else is added. |
| A rotation through the console carries why | Controller test: the `rotate` verb passes why and cause to `secret rotate`; why beside a provision is refused as passed over. |
## References
- [ADR 0113](0113-the-vault-makes-every-secret.md): the decision this extends — what the vault cannot
replace is what an outside party issued
- [ADR 0114](0114-a-shared-credential-rotates-over-two-credentials.md): read at start and applied, and
the staged rotation an applied secret waits for
- [ADR 0092](0092-an-operator-delivers-a-pair-credential.md): a delivered pair credential, unchanged
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md): an adopted machine, and the take
- [To-be 13](../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md): the design this amends
- [To-be 45](../03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md) §7: the hand-act log a rotation's why is recorded in
+1
View File
@@ -325,6 +325,7 @@ python3 00-META/checks/index.py fail if stale
- **0216** — [The agent's configuration is registered through its module, at three scopes, and served as one plugin](0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md)
- **0220** — [What a machine asks needs its uplink held, and the retired resolver pieces go](0220-what-a-machine-asks-needs-its-uplink-held-and-the-retired-resolver-pieces-go.md)
- **0225** — [A consumer's identity is bounded by the provision it requires, judged before merge, and never refuses its provider](0225-a-consumers-identity-is-bounded-by-the-provision-it-requires.md)
- **0228** — [A value given by hand lives only until its module's first good start](0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md)
### How it is built
@@ -5,12 +5,15 @@ code:
- mesh-controller internal/inventory/secrets.go
- mesh-controller cmd/mesh-controller/rotate.go
- mesh-controller examples/postgres-provisioner
updated: 2026-10-01
- mesh-controller internal/inventory/given.go
- mesh-controller cmd/mesh-controller/given.go
updated: 2026-10-06
decisions:
- 02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md
- 02-DECISIONS/0009-modules-and-the-graph.md
- 02-DECISIONS/0085-a-secret-is-a-provision.md
- 02-DECISIONS/0086-a-secret-reaches-a-process-as-a-file.md
- 02-DECISIONS/0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md
---
# 13 — Credentials, and moving them
@@ -144,8 +147,35 @@ rotates only the first: `secret rotate <node> <module> <name>` makes it anew, se
and the operator, and sends the machine, so the module starts again on it. A secret that says neither
is refused with the word to write, because a credential rotated under software that never reads it
again is the fault of issue 179 made deliberately; an applied one is refused until the staged form is
built; an accepted one is refused as ADR 0113 says. `rotate` is a verb on the controller's seat with
built. `rotate` is a verb on the controller's seat with
both shapes, so the console asks for either. A provider that shares its one credential with every
consumer ([ADR 0158](../../02-DECISIONS/0158-a-provider-with-one-credential-shares-it-with-every-consumer.md))
rotates the same way, with every holder's copy remade and every holding machine sent together. *How it is checked:* the tests named in issue 180, and a
live rotation through the console of a secret a module reads at start.
### A value given by hand
*Amended 2026-10-06 by [ADR 0228](../../02-DECISIONS/0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md).*
Until then a value given to the mesh for an own secret was refused rotation outright, read as ADR 0113's
*"the mesh will not replace what it cannot read"*. What the mesh cannot replace is a value only an
outside party can issue; a secret the module reads at start and nobody else holds is replaced without
reading the old value. So an own secret also says who may make its value: by default the mesh, and
`"issued-by": "outside"` for a vendor's key, a bot's token or a licence.
For a secret the mesh may make — read at start, not issued outside:
- `secret rotate` replaces a given value as it replaces a made one, and records it as made. It may say
why, through the console's `rotate` as well, and the why goes to the hand-act log.
- **A value given through `secret accept` lives only until the module's first good start under the
mesh.** The signal is the machine's clean report — everything applied, nothing failed or refused — of
the declaration it was last sent, when that declaration was sent after the value was given; on an
adopted machine, after the module is taken. The controller then makes a value, seals it, sends the
machine, logs it and states the seat fact `secret-replaced`, once. A given value exists to adopt
something already running that holds it; for a fresh install `secret accept` says none is needed.
An outside party's value, a value the module applies, a value the mesh's own code accepted (a bus
account it issued), and a value given before 0228 stay as given. The first is refused with the issuer
named and its age; the last is replaced when a person asks `secret rotate`. A pair credential an operator
delivers is unchanged ([ADR 0092](../../02-DECISIONS/0092-an-operator-delivers-a-pair-credential.md)).
*How it is checked:* the tests ADR 0228 names, and a live `rotate` of a given at-start secret through
the console.