Design 24 flips to in-progress with mesh-catalog as its owner (playbook 04). Starting the build surfaced two gaps the decision did not settle: a module requiring `secret` receives exactly one value (069), and no command can accept an operator's value into a consumer↔vault pair (070). Both opened as issues rather than improvised around. Also fills fixed-by on 067 and 068, which the cycle check refused as resolved with no reference.
91 lines
5.3 KiB
Markdown
91 lines
5.3 KiB
Markdown
---
|
|
status: resolved
|
|
opened: 2026-09-20
|
|
located-in: []
|
|
fixed-by: graduated — hq fc4ab37 (ADR 0085, to-be design 24)
|
|
amended-design: 03-DESIGN/01-to-be/24-the-secrets-vault.md
|
|
---
|
|
|
|
# Secrets have no owning module
|
|
|
|
## Symptom, as observed
|
|
|
|
Every other piece of shared infrastructure in the mesh has been made an ordinary
|
|
module that claims a seat: the store seat and the broker seat are each filled by a
|
|
module that runs its own code, provisions for its consumers, and is built and
|
|
delivered like any other ([ADR 0047](../../02-DECISIONS/0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md),
|
|
[ADR 0048](../../02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md),
|
|
issue 051). Secrets are the exception. There is **no module that owns a secret.**
|
|
|
|
Secret handling is instead smeared across three built-in parts of the controller
|
|
and node runtime:
|
|
|
|
- the **controller mints** — one password per consumer↔provider pair, sealed to
|
|
both node keys (ADR 0048);
|
|
- the **mesh database holds** — a secret is a database record, never a file in the
|
|
repository (as-is design, `06-configuration-and-secrets.md`);
|
|
- the **synchroniser injects** — a generated secret is written into a node's
|
|
generated env file and kept stable across regenerations.
|
|
|
|
Nothing is the owner of "a secret" the way the store module is the owner of "a
|
|
database." The consequences are the gaps we already have written down separately:
|
|
|
|
- **Generated local secrets exist but cannot be rotated by a mesh operation.** The
|
|
as-is design records the weakness verbatim — *"Rotation is not a mesh operation…
|
|
there is no mechanism that rotates one and informs everything holding it"*. A
|
|
`rotate <provision>` command has since been added and proven for provisioned
|
|
provider↔consumer credentials, but it reaches **only** those pairs; a secret a
|
|
module generates for its own fully-local use (the password of a version-pinned
|
|
embedded database, for instance — see issue 067) is minted by the mesh and then
|
|
has no operation that can remake it.
|
|
- **Three secret paths, no single provider behind them.** Provisioned credentials
|
|
(ADR 0048), generated local secrets (a manifest's generated-secret env var), and
|
|
operator-delivered secrets (`secret accept`, sealed to a node) are three separate
|
|
mechanisms. Nothing unifies "the mesh has a secret and is responsible for its
|
|
whole life."
|
|
|
|
## Why it matters beyond this instance
|
|
|
|
- **It is the same de-specialisation the mesh already committed to, left half
|
|
done.** Making the store and broker seats ordinary modules was a deliberate
|
|
decision precisely so infrastructure would not be a privileged property of the
|
|
controller that no module owns, cannot be reasoned about as a module, and cannot
|
|
be built or replaced like one. Secret-minting is still exactly that privileged
|
|
controller property. Either the store/broker decision was right and this should
|
|
follow it, or it was wrong — but the mesh should not be half one and half the
|
|
other with no record of why.
|
|
- **Rotation, backup, audit and break-glass have nowhere to live.** These are
|
|
provider responsibilities everywhere else (the store provisioner rotates a DB
|
|
credential; a provider is where a resource's lifecycle lives). With no secrets
|
|
provider, each of these is either absent or a one-off in the controller. The mesh
|
|
being migrated onto has a working secrets subsystem to learn from — the source
|
|
mesh exposes locate / backup / verify / break-glass / preflight / generate
|
|
operations — none of which has an owner on the nox side.
|
|
- **It blocks the "assume every old secret leaked" step of the migration.** The
|
|
cutover plan ends with a full rotation of every credential on the assumption the
|
|
old ones are compromised. Today that step is only expressible for provisioned
|
|
pairs; app-internal and generated-local secrets must be rotated by hand, which is
|
|
the exact operation the design says has taken services down when done wrong.
|
|
|
|
## Open questions
|
|
|
|
- Is the vault a **module that claims a seat** (like store and broker), a provider
|
|
that offers a `secret` provision that other modules `require`, or both — a seat
|
|
whose claimant is also the provider of secrets to everyone else?
|
|
- Does a consumer request a secret the way it requests a database (`requires:
|
|
secret`, the vault mints and delivers it), so that a fully-local embedded
|
|
service's password is a provisioned secret rather than a magic generated env var?
|
|
- Does the vault **subsume** the three existing paths (provisioned credentials,
|
|
generated local secrets, operator-delivered secrets), or sit beside them owning
|
|
only rotation/backup/audit? Subsuming is cleaner but is a migration of every
|
|
provider that mints today.
|
|
- Is the vault **node-scoped** like every other provider (per issue 067) — each
|
|
node's own vault — or is there a case for a single mesh vault, and if so how does
|
|
that survive the same objections that made the store node-scoped?
|
|
- What does rotation of a secret mean when the holder is a local-only service the
|
|
mesh cannot reach as a consumer — does the vault restart the holder, or hand the
|
|
new value to the holding module's own runtime to apply?
|
|
- How does break-glass work — recovering a secret when the normal sealed path is
|
|
unavailable — without reintroducing a key some single place holds, which ADR 0048
|
|
went out of its way to avoid?
|