Merge pull request 'ADR 0228: a value given by hand lives only until its module's first good start' (#133) from feat/a-given-secret-lives-until-the-first-good-start into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
This commit was merged in pull request #133.
This commit is contained in:
@@ -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.
|
||||
|
||||
+143
@@ -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
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user