Issue 068 — secrets have no owning module

Store and broker seats were made ordinary modules; secret-minting is still a
privileged property of the controller that no module owns. Propose the vault
become a module that provides a secret provision (generate/hold/rotate/backup/
audit), node-scoped like every other provider (issue 067), subsuming the three
secret paths and giving local-secret rotation a home.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
2026-09-20 21:24:42 +02:00
parent c4ff0478b7
commit d66579c8f6
@@ -0,0 +1,90 @@
---
status: open
opened: 2026-09-20
located-in: []
fixed-by:
amended-design:
---
# 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?