From 937289383c97ca11fa93a893424160368e3024d2 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 16 Sep 2026 18:23:34 +0200 Subject: [PATCH 01/10] Add a credential docs page: current state, target state, the rule between MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The swarm's credential docs say where every file lives. They do not say whether it should be a file at all, so a discussion about direction has had nothing to point at and each one re-derived the same table. This page carries that table with the three columns the target contract is written in — minter, reader, renewal — plus the column the target is really about: whether the value is persisted outside the store. Stating it flatly is the point. All four stored families are plaintext files on disk, the appservice token twice; every renewal cell reads NONE; no agent container holds a store identity at all, so the hive reads on its behalf and writes a file in; and the appservice token has a second, uncoordinated local mint that can diverge from the published one. The target section is marked as a target throughout, because its first line is the one most easily misread as fact: every host needing a store mTLS certificate is where this is going, while today only the store's own host auto-mints and swarm-bao.nix calls it the credential an operator places by hand everywhere else. The progressive-enhancement rule is stated as a table of questions a reviewer applies to a pull request rather than as prose, since a rule nobody can check is a preference. New functionality matches the target immediately; existing functionality moves stepwise, and the questions distinguish a step from churn. Indexed from the docs root and the swarm README. It supersedes secrets.md when the migration completes — at which point that file is deleted and this one moves into its place. --- docs/README.md | 4 ++ docs/swarm/README.md | 6 ++ docs/swarm/credentials.md | 137 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 docs/swarm/credentials.md diff --git a/docs/README.md b/docs/README.md index 3483f42a..298a78c3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -76,6 +76,10 @@ declarations. - **Where do agent snapshots go? How does the swarm's `btrfs receive` endpoint authenticate a pushing hive?** → [`networking/snapshot-store.md`](networking/snapshot-store.md). +- **Who mints each credential, who reads it, and how does it rotate — and + where is that shape headed?** → [`swarm/credentials.md`](swarm/credentials.md) + (current state, target state, and the progressive-enhancement rule); + [`swarm/secrets.md`](swarm/secrets.md) for where each file lives today. ## Scheduler, CI, observability diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 3d085e0f..feea18bd 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -104,6 +104,12 @@ Every credential the swarm holds, who mints it, where it must live, and which of the three topologies makes it the operator's job to place: [`secrets.md`](secrets.md). +Where that shape is **going** — the per-secret minter/reader/renewal +contract, the target of one mTLS identity per host and everything else +through the store, and the test a change has to pass to count as movement +toward it: [`credentials.md`](credentials.md). It supersedes `secrets.md` +when the migration completes. + ## Swarm UI The operator-only web surface on the swarm apex, why reaching it needs diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md new file mode 100644 index 00000000..fcae088d --- /dev/null +++ b/docs/swarm/credentials.md @@ -0,0 +1,137 @@ +# Credentials: current state, target state, and the rule in between + +Where [`secrets.md`](secrets.md) is a map of every file a swarm holds, this +page is the **direction of travel**: what the credential shape is today, what +it is meant to become, and the test a change has to pass to count as movement +toward it rather than away. + +It is written to be the thing a discussion points at. **When the migration +below is complete, this page supersedes the old credential docs** — `secrets.md` +gets deleted and this file moves into its place. Until then the two coexist and +neither is wrong: that one says where a file is, this one says whether it should +be a file at all. + +Two conventions carry over unchanged, because the target does not relax them. + +**A secret is a path, never a value.** Every option carrying key material or a +token takes a file path, because a literal in a nix expression lands in the nix +store — world-readable and permanent. That rule survives the migration; what +changes is how many paths there are. + +**Public material is a value.** A certificate or a public nkey is handed to +every client that connects, so the store is a fine place for it. Nothing below +is about those. + +## Current state + +Four credential families live in the swarm's secret store, plus the mTLS +identity that opens it. That fifth row is not a stored secret — it is the +credential that cannot be, being what authenticates the reader to the store. + +| store path | what it is | minter | writer | reader | renewal | persisted outside the store | +| -------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `swarm/agents//matrix/` | one agent's matrix account credential | `swarm-controller`, via a caller-supplied bearer token or its own `m.login.password` (`swarm-controller/src/matrix_account.rs:14-21`) | same unit (`swarm-controller/src/matrix_account.rs:163`) | `hive-c0re`'s credential worker, under the **hive's** identity (`hive-c0re/src/workers/credential.rs:33-64`) | `NONE` | **yes** — `hive-priv` writes `/matrix-token`, `0600`, chowned to the agent (`hive-priv/src/main.rs:535-556`) | +| `swarm/hives//matrix/appservice-token` | the appservice token authenticating one hive's homeserver | `swarm-secret-publisher.nix` itself, `head -c 32 /dev/urandom` on the authelia host (`nix/host-modules/swarm-secret-publisher.nix:280-286`) | same unit (`nix/host-modules/swarm-secret-publisher.nix:288-289`) | `swarm-bao-matrix-token.service` on the hive (`nix/host-modules/glue-matrix-bao-token.nix:159-167`) | `NONE` | **yes, twice** — the publisher's own state copy (`nix/host-modules/swarm-secret-publisher.nix:185`) and the hive's token file, `0600` (`nix/host-modules/glue-matrix-bao-token.nix:186-188`) | +| `swarm/hives//queue/agent` | the OIDC client id + secret agent containers present to the swarm queue | authelia, on first boot, into its own client-secret dir | `swarm-secret-publish.service` (`nix/host-modules/swarm-secret-publisher.nix:222-225`) | `swarm-bao-queue-agent.service` on the hive (`nix/host-modules/glue-queue-agent-credential.nix:187-197`) | `NONE` | **yes** — `secret` `0600` and `client_id` `0644` under the hive's agent-credential dir (`nix/host-modules/glue-queue-agent-credential.nix:224-231`) | +| `swarm/services//oidc/client` | a swarm service's own OIDC client secret, one per service | authelia, on first boot | `swarm-secret-publish.service` (`nix/host-modules/swarm-secret-publisher.nix:245-248`) | whichever host runs that service — `swarm-bao-grafana-oidc.service` (`nix/host-modules/swarm-grafana.nix:565-684`), the collector's twin (`nix/host-modules/swarm-otel.nix:685-767`) | `NONE` | **yes** — Grafana's `0400` host file (`nix/host-modules/swarm-grafana.nix:678-682`), the collector's `0400` file handed in by `LoadCredential` (`nix/host-modules/swarm-otel.nix:763-767`) | +| _(not stored)_ the store's own mTLS leaves | the certificate a host presents to reach the store at all | `swarm-bao-pki.service`, self-signed, 10-year (`nix/host-modules/glue-bao-tls.nix:114-149`) | n/a | every client reads its own leaf off disk via `BAO_CLIENT_CERT` (`swarm-secret-client/src/client.rs:12-14`) | `NONE` — signed only when absent (`nix/host-modules/glue-bao-tls.nix:128-149`) | **always, by construction** — it has no other form | + +Four things in that table are uncomfortable, and they are the reason this page +exists rather than a note in the margin of the old one. + +**Every stored secret is persisted as a plaintext file outside the store.** All +four, without exception. The appservice token is persisted twice: once in the +publisher's own state directory on the authelia host, and again in the token +file on the hive that reads it. + +**Nothing rotates.** Every renewal cell reads `NONE`. There is no rotation path +for any of the four, and none for the mTLS leaves either — those are signed once +if absent and carry ten years of validity, and deleting the CA to reissue +invalidates every client certificate already trusting it. + +**No agent container has a store identity at all.** There is not one +`BAO_ADDR` or `BAO_CLIENT_CERT` anywhere under `nix/agent-modules/`. An agent +never talks to the store; the hive host reads on the agent's behalf and writes +a file into the container. That is the exact inverse of the target below. + +**The appservice token has a second, uncoordinated minter.** `hive-matrix.nix` +keeps a local first-boot mint as a fallback for an unreachable store. Two +minters exist for one logical credential, on different hosts, with no agreement +between them — they can diverge, and the only thing that reconciles them is the +reader overwriting the local value on its next successful start. + +One half of the target is already real: all four families are minted once and +read by an identity that logs in with a certificate, with no ambient trust +anywhere in the path. The cleanest examples are `swarm-bao-queue-agent.service` +and `swarm-bao-grafana-oidc.service`. What each of them then does is write the +fetched bytes to disk. + +Two per-agent credential files — the forge token and the github token +(`hive-priv/src/main.rs:368,413`) — are deliberately absent from the table. +They are operator-supplied and never pass through the store at all, so they are +not credentials this page governs. Their absence is not an oversight. + +## Target state + +⚠️ **This section describes what the swarm is moving toward. None of it is a +description of what exists today.** Read the current-state table above for +that. In particular, the first line below is the one most easily misread as a +statement of fact, so it is stated as a target twice. + +**Every host needs a store mTLS certificate. Everything else is done through +the store.** That is the target. Today only the host running the store itself +auto-mints leaves — for itself, and pre-emptively for a controller and a +publisher that might be co-located. For every other host, `swarm-bao.nix:527-528` +says it outright: this is _"the credential an operator places by hand."_ There +is no automated issuance path for an ordinary hive. The target is one +out-of-band credential per host and zero out-of-band credentials anywhere else; +the current state is one out-of-band credential per host **plus** every file in +the last column of the table above. + +**Agent containers get an mTLS certificate passed in from the hive, and pull +their secrets themselves.** The hive's job becomes handing the agent an +identity, not handing it secrets. Today no agent has an identity of any kind +and every agent secret arrives as a file the host wrote. + +**No store-provided secret is persisted.** A value read from the store lives in +the memory of the process that needs it and nowhere else — not in a state +directory, not in a bind-mounted file, not in a systemd credential. Today every +one of the four is persisted, and one of them twice. + +**Per secret, the docs specify minter, reader, and renewal strategy.** Those +three columns are the contract. A secret whose renewal strategy is `NONE` is +allowed to exist only as an honest record of where the swarm is — it is not a +strategy, and a new secret does not get to write it. + +## Progressive enhancement + +The rule has two halves, and the second is the one that makes the first +survivable. + +**New functionality matches the target shape immediately.** There is no +grandfather clause for code that does not exist yet. + +**Existing functionality moves step by step** — each individual step moving +toward the target shape, never sideways and never away. A step does not have to +finish the migration. It has to not undo it. + +That is checkable, so check it. Apply these to a pull request that touches a +credential: + +| question | a `yes` means | +| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Does it add a minter outside the swarm's existing mint path? | **block** for new functionality. For existing functionality, only if it removes one too. | +| Does it persist a store-provided secret to disk — a state directory, a bind mount, a rendered config? | **block** for new functionality. For existing functionality, only if the change removes a persisted copy elsewhere and nets out fewer. | +| Does it add a credential whose renewal strategy is `NONE`? | **block** for new functionality. State the strategy, even if it is "operator reissues and restarts the reader." | +| Does it read a secret on another principal's behalf and hand it over? | **block** for new functionality. The principal that needs the value authenticates for it. | +| Does it give a host or container an out-of-band credential that is not the store mTLS leaf? | **block**, both. One out-of-band credential per principal is the whole point of the store. | +| Does it change the minter, reader, or renewal of an existing credential? | The current-state table above is **stale until updated in the same change.** | + +For existing functionality the questions are not pass/fail — they are a +direction check. A change that persists one fewer copy, or moves one reader from +a host-written file to its own store read, is a step and lands. A change that +leaves the count the same while moving the file somewhere tidier is not a step; +it is churn with a migration story attached. + +When the last `yes` in the current-state table's final column becomes a `no`, +this page replaces `secrets.md` and the current-state section goes with it. From 52fabe7096f2a020be5267069ae883cbc371b848 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 16 Sep 2026 18:33:57 +0200 Subject: [PATCH 02/10] docs/swarm/credentials.md: fix 7 drifted/wrong file:line citations Audited all 22 file:line citations against source on this head, per argus's PR review. Fixed: - matrix_account.rs writer cell pointed at path-computation line 163; the actual store.write() call is at 189-197 (now anchored to the put_matrix_account function name too). - swarm-secret-publisher.nix appservice-token minter cited the "put on every run" comment (280-286) instead of the mint itself (271-278). - glue-matrix-bao-token.nix persisted-token cell cited the registration-restamp comment (186-188) instead of the printf+chmod (180-181). - glue-queue-agent-credential.nix persisted-files cell missed the secret's chmod 0600 at 219, citing 224-231; corrected to 218-224. - swarm-otel.nix persisted-file cell cited 763-767, one line short of the chmod 0400 at 768; corrected to 764-768. - hive-priv/src/main.rs:368,413 (forge/github token cell) landed in unrelated DestroyContainer/ListContainers/WriteAgentGithubToken match arms; replaced with the write_forge_token/write_github_token function names as a greppable, edit-resistant anchor. - swarm-bao.nix:527-528 (the "operator places by hand" quote) missed the quoted text, which starts at 529; corrected to 529-531. 15 of 22 already resolved exactly; no claim lacked supporting code, so nothing was removed. 22 of 22 citations checked. --- docs/swarm/credentials.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index fcae088d..692a7bba 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -28,13 +28,13 @@ Four credential families live in the swarm's secret store, plus the mTLS identity that opens it. That fifth row is not a stored secret — it is the credential that cannot be, being what authenticates the reader to the store. -| store path | what it is | minter | writer | reader | renewal | persisted outside the store | -| -------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `swarm/agents//matrix/` | one agent's matrix account credential | `swarm-controller`, via a caller-supplied bearer token or its own `m.login.password` (`swarm-controller/src/matrix_account.rs:14-21`) | same unit (`swarm-controller/src/matrix_account.rs:163`) | `hive-c0re`'s credential worker, under the **hive's** identity (`hive-c0re/src/workers/credential.rs:33-64`) | `NONE` | **yes** — `hive-priv` writes `/matrix-token`, `0600`, chowned to the agent (`hive-priv/src/main.rs:535-556`) | -| `swarm/hives//matrix/appservice-token` | the appservice token authenticating one hive's homeserver | `swarm-secret-publisher.nix` itself, `head -c 32 /dev/urandom` on the authelia host (`nix/host-modules/swarm-secret-publisher.nix:280-286`) | same unit (`nix/host-modules/swarm-secret-publisher.nix:288-289`) | `swarm-bao-matrix-token.service` on the hive (`nix/host-modules/glue-matrix-bao-token.nix:159-167`) | `NONE` | **yes, twice** — the publisher's own state copy (`nix/host-modules/swarm-secret-publisher.nix:185`) and the hive's token file, `0600` (`nix/host-modules/glue-matrix-bao-token.nix:186-188`) | -| `swarm/hives//queue/agent` | the OIDC client id + secret agent containers present to the swarm queue | authelia, on first boot, into its own client-secret dir | `swarm-secret-publish.service` (`nix/host-modules/swarm-secret-publisher.nix:222-225`) | `swarm-bao-queue-agent.service` on the hive (`nix/host-modules/glue-queue-agent-credential.nix:187-197`) | `NONE` | **yes** — `secret` `0600` and `client_id` `0644` under the hive's agent-credential dir (`nix/host-modules/glue-queue-agent-credential.nix:224-231`) | -| `swarm/services//oidc/client` | a swarm service's own OIDC client secret, one per service | authelia, on first boot | `swarm-secret-publish.service` (`nix/host-modules/swarm-secret-publisher.nix:245-248`) | whichever host runs that service — `swarm-bao-grafana-oidc.service` (`nix/host-modules/swarm-grafana.nix:565-684`), the collector's twin (`nix/host-modules/swarm-otel.nix:685-767`) | `NONE` | **yes** — Grafana's `0400` host file (`nix/host-modules/swarm-grafana.nix:678-682`), the collector's `0400` file handed in by `LoadCredential` (`nix/host-modules/swarm-otel.nix:763-767`) | -| _(not stored)_ the store's own mTLS leaves | the certificate a host presents to reach the store at all | `swarm-bao-pki.service`, self-signed, 10-year (`nix/host-modules/glue-bao-tls.nix:114-149`) | n/a | every client reads its own leaf off disk via `BAO_CLIENT_CERT` (`swarm-secret-client/src/client.rs:12-14`) | `NONE` — signed only when absent (`nix/host-modules/glue-bao-tls.nix:128-149`) | **always, by construction** — it has no other form | +| store path | what it is | minter | writer | reader | renewal | persisted outside the store | +| -------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `swarm/agents//matrix/` | one agent's matrix account credential | `swarm-controller`, via a caller-supplied bearer token or its own `m.login.password` (`swarm-controller/src/matrix_account.rs:14-21`) | same unit, `put_matrix_account`'s `store.write` call (`swarm-controller/src/matrix_account.rs:189-197`) | `hive-c0re`'s credential worker, under the **hive's** identity (`hive-c0re/src/workers/credential.rs:33-64`) | `NONE` | **yes** — `hive-priv` writes `/matrix-token`, `0600`, chowned to the agent (`hive-priv/src/main.rs:535-556`) | +| `swarm/hives//matrix/appservice-token` | the appservice token authenticating one hive's homeserver | `swarm-secret-publisher.nix` itself, `head -c 32 /dev/urandom` on the authelia host (`nix/host-modules/swarm-secret-publisher.nix:271-278`) | same unit (`nix/host-modules/swarm-secret-publisher.nix:288-289`) | `swarm-bao-matrix-token.service` on the hive (`nix/host-modules/glue-matrix-bao-token.nix:159-167`) | `NONE` | **yes, twice** — the publisher's own state copy (`nix/host-modules/swarm-secret-publisher.nix:185`) and the hive's token file, `0600` (`nix/host-modules/glue-matrix-bao-token.nix:180-181`) | +| `swarm/hives//queue/agent` | the OIDC client id + secret agent containers present to the swarm queue | authelia, on first boot, into its own client-secret dir | `swarm-secret-publish.service` (`nix/host-modules/swarm-secret-publisher.nix:222-225`) | `swarm-bao-queue-agent.service` on the hive (`nix/host-modules/glue-queue-agent-credential.nix:187-197`) | `NONE` | **yes** — `secret` `0600` and `client_id` `0644` under the hive's agent-credential dir (`nix/host-modules/glue-queue-agent-credential.nix:218-224`) | +| `swarm/services//oidc/client` | a swarm service's own OIDC client secret, one per service | authelia, on first boot | `swarm-secret-publish.service` (`nix/host-modules/swarm-secret-publisher.nix:245-248`) | whichever host runs that service — `swarm-bao-grafana-oidc.service` (`nix/host-modules/swarm-grafana.nix:565-684`), the collector's twin (`nix/host-modules/swarm-otel.nix:685-767`) | `NONE` | **yes** — Grafana's `0400` host file (`nix/host-modules/swarm-grafana.nix:678-682`), the collector's `0400` file handed in by `LoadCredential` (`nix/host-modules/swarm-otel.nix:764-768`) | +| _(not stored)_ the store's own mTLS leaves | the certificate a host presents to reach the store at all | `swarm-bao-pki.service`, self-signed, 10-year (`nix/host-modules/glue-bao-tls.nix:114-149`) | n/a | every client reads its own leaf off disk via `BAO_CLIENT_CERT` (`swarm-secret-client/src/client.rs:12-14`) | `NONE` — signed only when absent (`nix/host-modules/glue-bao-tls.nix:128-149`) | **always, by construction** — it has no other form | Four things in that table are uncomfortable, and they are the reason this page exists rather than a note in the margin of the old one. @@ -67,7 +67,7 @@ and `swarm-bao-grafana-oidc.service`. What each of them then does is write the fetched bytes to disk. Two per-agent credential files — the forge token and the github token -(`hive-priv/src/main.rs:368,413`) — are deliberately absent from the table. +(`hive-priv/src/main.rs`'s `write_forge_token`/`write_github_token`) — are deliberately absent from the table. They are operator-supplied and never pass through the store at all, so they are not credentials this page governs. Their absence is not an oversight. @@ -81,7 +81,7 @@ statement of fact, so it is stated as a target twice. **Every host needs a store mTLS certificate. Everything else is done through the store.** That is the target. Today only the host running the store itself auto-mints leaves — for itself, and pre-emptively for a controller and a -publisher that might be co-located. For every other host, `swarm-bao.nix:527-528` +publisher that might be co-located. For every other host, `swarm-bao.nix:529-531` says it outright: this is _"the credential an operator places by hand."_ There is no automated issuance path for an ordinary hive. The target is one out-of-band credential per host and zero out-of-band credentials anywhere else; From b2c1378fd3e73a32ca2733d96047f7b19e6c1f0b Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 16 Sep 2026 18:51:42 +0200 Subject: [PATCH 03/10] docs/swarm/credentials.md: reword prose to pass vale Fixes the Microsoft.Contractions/write-good.ThereIs/alex.Condescending/ Microsoft.Auto errors vale flagged in CI (prose-lint-errors, required): contractions throughout, no sentences opening with 'There is', dropped the 'auto-mints' hyphenation and the 'easily' condescension flag, and renamed the credentials table's 'what it is' header to 'kind' (also flagged once styles were resynced). No technical claim or file:line citation changed. Same fix applied to the one new line in docs/README.md's pointer to this page. --- docs/README.md | 2 +- docs/swarm/credentials.md | 66 +++++++++++++++++++-------------------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/docs/README.md b/docs/README.md index 298a78c3..1ae0fda8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -77,7 +77,7 @@ declarations. endpoint authenticate a pushing hive?** → [`networking/snapshot-store.md`](networking/snapshot-store.md). - **Who mints each credential, who reads it, and how does it rotate — and - where is that shape headed?** → [`swarm/credentials.md`](swarm/credentials.md) + where's that shape headed?** → [`swarm/credentials.md`](swarm/credentials.md) (current state, target state, and the progressive-enhancement rule); [`swarm/secrets.md`](swarm/secrets.md) for where each file lives today. diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 692a7bba..7e14abea 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -1,17 +1,17 @@ -# Credentials: current state, target state, and the rule in between +# Credentials: current state, target state, and the rule between the two Where [`secrets.md`](secrets.md) is a map of every file a swarm holds, this page is the **direction of travel**: what the credential shape is today, what -it is meant to become, and the test a change has to pass to count as movement +it's on track to become, and the test a change has to pass to count as movement toward it rather than away. -It is written to be the thing a discussion points at. **When the migration +It's the thing a discussion should point at. **When the migration below is complete, this page supersedes the old credential docs** — `secrets.md` gets deleted and this file moves into its place. Until then the two coexist and neither is wrong: that one says where a file is, this one says whether it should be a file at all. -Two conventions carry over unchanged, because the target does not relax them. +Two conventions carry over unchanged, because the target doesn't relax them. **A secret is a path, never a value.** Every option carrying key material or a token takes a file path, because a literal in a nix expression lands in the nix @@ -25,10 +25,10 @@ is about those. ## Current state Four credential families live in the swarm's secret store, plus the mTLS -identity that opens it. That fifth row is not a stored secret — it is the -credential that cannot be, being what authenticates the reader to the store. +identity that opens it. That fifth row isn't a stored secret — it's the +credential that can't be, being what authenticates the reader to the store. -| store path | what it is | minter | writer | reader | renewal | persisted outside the store | +| store path | kind | minter | writer | reader | renewal | persisted outside the store | | -------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `swarm/agents//matrix/` | one agent's matrix account credential | `swarm-controller`, via a caller-supplied bearer token or its own `m.login.password` (`swarm-controller/src/matrix_account.rs:14-21`) | same unit, `put_matrix_account`'s `store.write` call (`swarm-controller/src/matrix_account.rs:189-197`) | `hive-c0re`'s credential worker, under the **hive's** identity (`hive-c0re/src/workers/credential.rs:33-64`) | `NONE` | **yes** — `hive-priv` writes `/matrix-token`, `0600`, chowned to the agent (`hive-priv/src/main.rs:535-556`) | | `swarm/hives//matrix/appservice-token` | the appservice token authenticating one hive's homeserver | `swarm-secret-publisher.nix` itself, `head -c 32 /dev/urandom` on the authelia host (`nix/host-modules/swarm-secret-publisher.nix:271-278`) | same unit (`nix/host-modules/swarm-secret-publisher.nix:288-289`) | `swarm-bao-matrix-token.service` on the hive (`nix/host-modules/glue-matrix-bao-token.nix:159-167`) | `NONE` | **yes, twice** — the publisher's own state copy (`nix/host-modules/swarm-secret-publisher.nix:185`) and the hive's token file, `0600` (`nix/host-modules/glue-matrix-bao-token.nix:180-181`) | @@ -36,7 +36,7 @@ credential that cannot be, being what authenticates the reader to the store. | `swarm/services//oidc/client` | a swarm service's own OIDC client secret, one per service | authelia, on first boot | `swarm-secret-publish.service` (`nix/host-modules/swarm-secret-publisher.nix:245-248`) | whichever host runs that service — `swarm-bao-grafana-oidc.service` (`nix/host-modules/swarm-grafana.nix:565-684`), the collector's twin (`nix/host-modules/swarm-otel.nix:685-767`) | `NONE` | **yes** — Grafana's `0400` host file (`nix/host-modules/swarm-grafana.nix:678-682`), the collector's `0400` file handed in by `LoadCredential` (`nix/host-modules/swarm-otel.nix:764-768`) | | _(not stored)_ the store's own mTLS leaves | the certificate a host presents to reach the store at all | `swarm-bao-pki.service`, self-signed, 10-year (`nix/host-modules/glue-bao-tls.nix:114-149`) | n/a | every client reads its own leaf off disk via `BAO_CLIENT_CERT` (`swarm-secret-client/src/client.rs:12-14`) | `NONE` — signed only when absent (`nix/host-modules/glue-bao-tls.nix:128-149`) | **always, by construction** — it has no other form | -Four things in that table are uncomfortable, and they are the reason this page +Four things in that table are uncomfortable, and they're the reason this page exists rather than a note in the margin of the old one. **Every stored secret is persisted as a plaintext file outside the store.** All @@ -44,15 +44,15 @@ four, without exception. The appservice token is persisted twice: once in the publisher's own state directory on the authelia host, and again in the token file on the hive that reads it. -**Nothing rotates.** Every renewal cell reads `NONE`. There is no rotation path +**Nothing rotates.** Every renewal cell reads `NONE`. No rotation path exists for any of the four, and none for the mTLS leaves either — those are signed once if absent and carry ten years of validity, and deleting the CA to reissue invalidates every client certificate already trusting it. -**No agent container has a store identity at all.** There is not one -`BAO_ADDR` or `BAO_CLIENT_CERT` anywhere under `nix/agent-modules/`. An agent +**No agent container has a store identity at all.** Not one +`BAO_ADDR` or `BAO_CLIENT_CERT` exists anywhere under `nix/agent-modules/`. An agent never talks to the store; the hive host reads on the agent's behalf and writes -a file into the container. That is the exact inverse of the target below. +a file into the container. That's the exact inverse of the target below. **The appservice token has a second, uncoordinated minter.** `hive-matrix.nix` keeps a local first-boot mint as a fallback for an unreachable store. Two @@ -68,22 +68,22 @@ fetched bytes to disk. Two per-agent credential files — the forge token and the github token (`hive-priv/src/main.rs`'s `write_forge_token`/`write_github_token`) — are deliberately absent from the table. -They are operator-supplied and never pass through the store at all, so they are -not credentials this page governs. Their absence is not an oversight. +They're operator-supplied and never pass through the store at all, so they're +not credentials this page governs. Their absence isn't an oversight. ## Target state -⚠️ **This section describes what the swarm is moving toward. None of it is a -description of what exists today.** Read the current-state table above for -that. In particular, the first line below is the one most easily misread as a -statement of fact, so it is stated as a target twice. +⚠️ **This section describes what the swarm is moving toward — not what exists +today.** Read the current-state table above for that. In particular, the first +line below is the one most often misread as a statement of fact, so it's +stated as a target twice. **Every host needs a store mTLS certificate. Everything else is done through -the store.** That is the target. Today only the host running the store itself -auto-mints leaves — for itself, and pre-emptively for a controller and a +the store.** That's the target. Today only the host running the store itself +mints its own leaves automatically — for itself, and pre-emptively for a controller and a publisher that might be co-located. For every other host, `swarm-bao.nix:529-531` -says it outright: this is _"the credential an operator places by hand."_ There -is no automated issuance path for an ordinary hive. The target is one +says it outright: this is _"the credential an operator places by hand."_ No +automated issuance path exists for an ordinary hive. The target is one out-of-band credential per host and zero out-of-band credentials anywhere else; the current state is one out-of-band credential per host **plus** every file in the last column of the table above. @@ -100,38 +100,38 @@ one of the four is persisted, and one of them twice. **Per secret, the docs specify minter, reader, and renewal strategy.** Those three columns are the contract. A secret whose renewal strategy is `NONE` is -allowed to exist only as an honest record of where the swarm is — it is not a -strategy, and a new secret does not get to write it. +allowed to exist only as an honest record of where the swarm is — it's not a +strategy, and a new secret doesn't get to write it. ## Progressive enhancement The rule has two halves, and the second is the one that makes the first survivable. -**New functionality matches the target shape immediately.** There is no -grandfather clause for code that does not exist yet. +**New functionality matches the target shape immediately.** No legacy clause +covers code that doesn't exist yet. **Existing functionality moves step by step** — each individual step moving -toward the target shape, never sideways and never away. A step does not have to +toward the target shape, never sideways and never away. A step doesn't have to finish the migration. It has to not undo it. -That is checkable, so check it. Apply these to a pull request that touches a +That's checkable, so check it. Apply these to a pull request that touches a credential: | question | a `yes` means | | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | Does it add a minter outside the swarm's existing mint path? | **block** for new functionality. For existing functionality, only if it removes one too. | | Does it persist a store-provided secret to disk — a state directory, a bind mount, a rendered config? | **block** for new functionality. For existing functionality, only if the change removes a persisted copy elsewhere and nets out fewer. | -| Does it add a credential whose renewal strategy is `NONE`? | **block** for new functionality. State the strategy, even if it is "operator reissues and restarts the reader." | +| Does it add a credential whose renewal strategy is `NONE`? | **block** for new functionality. State the strategy, even if it's "operator reissues and restarts the reader." | | Does it read a secret on another principal's behalf and hand it over? | **block** for new functionality. The principal that needs the value authenticates for it. | -| Does it give a host or container an out-of-band credential that is not the store mTLS leaf? | **block**, both. One out-of-band credential per principal is the whole point of the store. | +| Does it give a host or container an out-of-band credential that's not the store mTLS leaf? | **block**, both. One out-of-band credential per principal is the whole point of the store. | | Does it change the minter, reader, or renewal of an existing credential? | The current-state table above is **stale until updated in the same change.** | -For existing functionality the questions are not pass/fail — they are a +For existing functionality the questions aren't pass/fail — they're a direction check. A change that persists one fewer copy, or moves one reader from a host-written file to its own store read, is a step and lands. A change that -leaves the count the same while moving the file somewhere tidier is not a step; -it is churn with a migration story attached. +leaves the count the same while moving the file somewhere tidier isn't a step; +it's churn with a migration story attached. When the last `yes` in the current-state table's final column becomes a `no`, this page replaces `secrets.md` and the current-state section goes with it. From ae388c48a71fa8e4df54fc3ec235ecb061297dd7 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 16 Sep 2026 18:57:31 +0200 Subject: [PATCH 04/10] docs/swarm/credentials.md: restore claims changed by vale rewrite Two vale rewrites in c33cc441 changed meaning, not just wording: - "on track to become" asserted a trajectory the page's own inventory contradicts (no bao identity anywhere, all renewals NONE, all secrets plaintext). Restored intent: "what it should become". - "most often misread" claimed an observed reader frequency nobody measured. Dropped the claim about readers entirely; states the fact directly instead. Both stay vale-clean; no suppressions. --- docs/swarm/credentials.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 7e14abea..e1ecfbcf 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -2,7 +2,7 @@ Where [`secrets.md`](secrets.md) is a map of every file a swarm holds, this page is the **direction of travel**: what the credential shape is today, what -it's on track to become, and the test a change has to pass to count as movement +it should become, and the test a change has to pass to count as movement toward it rather than away. It's the thing a discussion should point at. **When the migration @@ -75,8 +75,7 @@ not credentials this page governs. Their absence isn't an oversight. ⚠️ **This section describes what the swarm is moving toward — not what exists today.** Read the current-state table above for that. In particular, the first -line below is the one most often misread as a statement of fact, so it's -stated as a target twice. +line below states intent, not current state, so it's stated as a target twice. **Every host needs a store mTLS certificate. Everything else is done through the store.** That's the target. Today only the host running the store itself From f1e5fd55927d34351e0b82800289bb346593a981 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 16 Sep 2026 19:53:24 +0200 Subject: [PATCH 05/10] docs/swarm/credentials.md: make the target section bao-shaped, not file-shaped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The target section described the target as a shorter list of secret files — the intro promised "what changes is how many paths there are", and the per-secret contract pointed at the current-state table's file-shaped columns. That is today's shape with fewer rows, not the target. Rewrite it around the invariant: no secret the store holds is ever written to disk. The mTLS client certificate is the sole credential on disk, and the section now says why — it authenticates the puller to the store, so it is the one credential that cannot be fetched from it. Per secret the target still states minter, reader and renewal, but the reader is now a process pulling a store path at runtime under its own certificate, not a path being read off disk. The current-state section and all of its citations are untouched: the current state genuinely is files, and that contrast is what the page is for. --- docs/swarm/credentials.md | 81 +++++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 29 deletions(-) diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index e1ecfbcf..75a092ca 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -11,12 +11,13 @@ gets deleted and this file moves into its place. Until then the two coexist and neither is wrong: that one says where a file is, this one says whether it should be a file at all. -Two conventions carry over unchanged, because the target doesn't relax them. +Two conventions frame everything below. -**A secret is a path, never a value.** Every option carrying key material or a -token takes a file path, because a literal in a nix expression lands in the nix -store — world-readable and permanent. That rule survives the migration; what -changes is how many paths there are. +**Where a nix option does carry a secret, it takes a path, never a value.** A +literal in a nix expression lands in the nix store — world-readable and +permanent. The target doesn't relax that rule; it removes almost every option +the rule applies to, because in the target shape there is almost nothing on +disk for an option to point at. **Public material is a value.** A certificate or a public nkey is handed to every client that connects, so the store is a fine place for it. Nothing below @@ -73,34 +74,56 @@ not credentials this page governs. Their absence isn't an oversight. ## Target state -⚠️ **This section describes what the swarm is moving toward — not what exists -today.** Read the current-state table above for that. In particular, the first -line below states intent, not current state, so it's stated as a target twice. +⚠️ **This section describes the shape the swarm is meant to have — not what +exists today, and not a claim of movement toward it.** Read the current-state +table above for what exists. Nothing here is partly done. -**Every host needs a store mTLS certificate. Everything else is done through -the store.** That's the target. Today only the host running the store itself -mints its own leaves automatically — for itself, and pre-emptively for a controller and a -publisher that might be co-located. For every other host, `swarm-bao.nix:529-531` -says it outright: this is _"the credential an operator places by hand."_ No -automated issuance path exists for an ordinary hive. The target is one -out-of-band credential per host and zero out-of-band credentials anywhere else; -the current state is one out-of-band credential per host **plus** every file in -the last column of the table above. +**No secret the store holds is ever written to disk.** That's the invariant, +and everything else in this section follows from it. A value pulled from the +store — bao — lives in the memory of the process that asked for it and nowhere +else: not in a state directory, not in a bind-mounted file, not in a systemd +credential, not in a rendered config, not for a moment before a unit deletes +it. The target isn't a shorter list of secret files. It's the store, plus +exactly one file. -**Agent containers get an mTLS certificate passed in from the hive, and pull -their secrets themselves.** The hive's job becomes handing the agent an -identity, not handing it secrets. Today no agent has an identity of any kind -and every agent secret arrives as a file the host wrote. +**That one file is the mTLS client certificate, and it's the only credential on +disk.** It has to be a file, and the reason is the whole asymmetry: the +certificate is what authenticates a principal to the store, so it's the one +credential that can't be fetched from the store. Something has to exist on disk +before the first request, or there's nothing to make the request with. Every +host has one, and its only power is to ask the store for the rest. Today only +the host running the store mints its own leaves automatically — for itself, and +pre-emptively for a controller and a publisher that might be co-located. For +every other host, `swarm-bao.nix:529-531` says it outright: this is _"the +credential an operator places by hand."_ -**No store-provided secret is persisted.** A value read from the store lives in -the memory of the process that needs it and nowhere else — not in a state -directory, not in a bind-mounted file, not in a systemd credential. Today every -one of the four is persisted, and one of them twice. +**The hive hands an agent an identity, never a secret.** An agent container is +passed an mTLS certificate from its hive and from then on authenticates to the +store under its own name, pulling what it needs when it needs it. No process +reads a secret on another principal's behalf: the principal that needs a value +is the principal that authenticates for it. -**Per secret, the docs specify minter, reader, and renewal strategy.** Those -three columns are the contract. A secret whose renewal strategy is `NONE` is -allowed to exist only as an honest record of where the swarm is — it's not a -strategy, and a new secret doesn't get to write it. +**Per secret, the target specifies minter, reader, and renewal strategy.** +Those three are the contract, and in this shape the **reader is a process +pulling a store path at runtime** — not a path on disk, and not a unit whose +job is to turn a store value into a file. + +| store path | minter | reader — pulls at runtime, holds in memory | renewal | +| -------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | **must be stated** — no strategy is chosen for this credential, and `NONE` isn't one | +| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | **must be stated** — as above | +| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | **must be stated** — as above | +| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | **must be stated** — as above | +| _(not in the store)_ the mTLS client leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — **the sole exception above**, because it's what makes every other row's pull possible | **must be stated** — as above | + +A renewal cell reading `NONE` is allowed only in the current-state table above, +as an honest record of where the swarm is. It isn't a strategy, and a new +secret doesn't get to write it. + +The appservice token's second, uncoordinated minter isn't a row here for a +reason: in the target each credential has exactly one minter, so a fallback +mint on the reading host is a thing the target deletes, not a thing it +describes. ## Progressive enhancement From 940e11ecda28d64c5a6ae072e6c030452fc35cce Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 16 Sep 2026 22:41:22 +0200 Subject: [PATCH 06/10] docs/swarm/credentials.md: cut the page down to the target shape only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the current-state table and the prose describing how the swarm currently breaks the target (unrotated secrets persisted to disk outside the store, no agent store identity, the appservice token's second minter). Scope the path-never-value rule to the mTLS client certificate — the one credential a nix option can carry, now that everything else goes through bao. Trim the migration checklist to unconditional rules for any credential-touching change, since there's no current-state table left for the old existing-functionality carve-outs to reference. Refs #4445 --- docs/swarm/credentials.md | 201 +++++++++++--------------------------- 1 file changed, 56 insertions(+), 145 deletions(-) diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 75a092ca..a57b4ba6 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -1,159 +1,70 @@ -# Credentials: current state, target state, and the rule between the two +# Credentials: the target shape -Where [`secrets.md`](secrets.md) is a map of every file a swarm holds, this -page is the **direction of travel**: what the credential shape is today, what -it should become, and the test a change has to pass to count as movement -toward it rather than away. - -It's the thing a discussion should point at. **When the migration -below is complete, this page supersedes the old credential docs** — `secrets.md` -gets deleted and this file moves into its place. Until then the two coexist and -neither is wrong: that one says where a file is, this one says whether it should -be a file at all. - -Two conventions frame everything below. - -**Where a nix option does carry a secret, it takes a path, never a value.** A -literal in a nix expression lands in the nix store — world-readable and -permanent. The target doesn't relax that rule; it removes almost every option -the rule applies to, because in the target shape there is almost nothing on -disk for an option to point at. +The swarm's credential store is bao. This page describes the shape every +credential is meant to have — who mints it, who reads it, and how it renews +— not what's on disk today. [`secrets.md`](secrets.md) remains the map of +the files that exist right now; this page replaces it, and `secrets.md` gets +deleted, once the swarm's credential path matches what's described below. **Public material is a value.** A certificate or a public nkey is handed to -every client that connects, so the store is a fine place for it. Nothing below -is about those. - -## Current state - -Four credential families live in the swarm's secret store, plus the mTLS -identity that opens it. That fifth row isn't a stored secret — it's the -credential that can't be, being what authenticates the reader to the store. - -| store path | kind | minter | writer | reader | renewal | persisted outside the store | -| -------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `swarm/agents//matrix/` | one agent's matrix account credential | `swarm-controller`, via a caller-supplied bearer token or its own `m.login.password` (`swarm-controller/src/matrix_account.rs:14-21`) | same unit, `put_matrix_account`'s `store.write` call (`swarm-controller/src/matrix_account.rs:189-197`) | `hive-c0re`'s credential worker, under the **hive's** identity (`hive-c0re/src/workers/credential.rs:33-64`) | `NONE` | **yes** — `hive-priv` writes `/matrix-token`, `0600`, chowned to the agent (`hive-priv/src/main.rs:535-556`) | -| `swarm/hives//matrix/appservice-token` | the appservice token authenticating one hive's homeserver | `swarm-secret-publisher.nix` itself, `head -c 32 /dev/urandom` on the authelia host (`nix/host-modules/swarm-secret-publisher.nix:271-278`) | same unit (`nix/host-modules/swarm-secret-publisher.nix:288-289`) | `swarm-bao-matrix-token.service` on the hive (`nix/host-modules/glue-matrix-bao-token.nix:159-167`) | `NONE` | **yes, twice** — the publisher's own state copy (`nix/host-modules/swarm-secret-publisher.nix:185`) and the hive's token file, `0600` (`nix/host-modules/glue-matrix-bao-token.nix:180-181`) | -| `swarm/hives//queue/agent` | the OIDC client id + secret agent containers present to the swarm queue | authelia, on first boot, into its own client-secret dir | `swarm-secret-publish.service` (`nix/host-modules/swarm-secret-publisher.nix:222-225`) | `swarm-bao-queue-agent.service` on the hive (`nix/host-modules/glue-queue-agent-credential.nix:187-197`) | `NONE` | **yes** — `secret` `0600` and `client_id` `0644` under the hive's agent-credential dir (`nix/host-modules/glue-queue-agent-credential.nix:218-224`) | -| `swarm/services//oidc/client` | a swarm service's own OIDC client secret, one per service | authelia, on first boot | `swarm-secret-publish.service` (`nix/host-modules/swarm-secret-publisher.nix:245-248`) | whichever host runs that service — `swarm-bao-grafana-oidc.service` (`nix/host-modules/swarm-grafana.nix:565-684`), the collector's twin (`nix/host-modules/swarm-otel.nix:685-767`) | `NONE` | **yes** — Grafana's `0400` host file (`nix/host-modules/swarm-grafana.nix:678-682`), the collector's `0400` file handed in by `LoadCredential` (`nix/host-modules/swarm-otel.nix:764-768`) | -| _(not stored)_ the store's own mTLS leaves | the certificate a host presents to reach the store at all | `swarm-bao-pki.service`, self-signed, 10-year (`nix/host-modules/glue-bao-tls.nix:114-149`) | n/a | every client reads its own leaf off disk via `BAO_CLIENT_CERT` (`swarm-secret-client/src/client.rs:12-14`) | `NONE` — signed only when absent (`nix/host-modules/glue-bao-tls.nix:128-149`) | **always, by construction** — it has no other form | - -Four things in that table are uncomfortable, and they're the reason this page -exists rather than a note in the margin of the old one. - -**Every stored secret is persisted as a plaintext file outside the store.** All -four, without exception. The appservice token is persisted twice: once in the -publisher's own state directory on the authelia host, and again in the token -file on the hive that reads it. - -**Nothing rotates.** Every renewal cell reads `NONE`. No rotation path exists -for any of the four, and none for the mTLS leaves either — those are signed once -if absent and carry ten years of validity, and deleting the CA to reissue -invalidates every client certificate already trusting it. - -**No agent container has a store identity at all.** Not one -`BAO_ADDR` or `BAO_CLIENT_CERT` exists anywhere under `nix/agent-modules/`. An agent -never talks to the store; the hive host reads on the agent's behalf and writes -a file into the container. That's the exact inverse of the target below. - -**The appservice token has a second, uncoordinated minter.** `hive-matrix.nix` -keeps a local first-boot mint as a fallback for an unreachable store. Two -minters exist for one logical credential, on different hosts, with no agreement -between them — they can diverge, and the only thing that reconciles them is the -reader overwriting the local value on its next successful start. - -One half of the target is already real: all four families are minted once and -read by an identity that logs in with a certificate, with no ambient trust -anywhere in the path. The cleanest examples are `swarm-bao-queue-agent.service` -and `swarm-bao-grafana-oidc.service`. What each of them then does is write the -fetched bytes to disk. - -Two per-agent credential files — the forge token and the github token -(`hive-priv/src/main.rs`'s `write_forge_token`/`write_github_token`) — are deliberately absent from the table. -They're operator-supplied and never pass through the store at all, so they're -not credentials this page governs. Their absence isn't an oversight. - -## Target state - -⚠️ **This section describes the shape the swarm is meant to have — not what -exists today, and not a claim of movement toward it.** Read the current-state -table above for what exists. Nothing here is partly done. +every client that connects, so the store is a fine place for it. Nothing +below is about those. **No secret the store holds is ever written to disk.** That's the invariant, -and everything else in this section follows from it. A value pulled from the -store — bao — lives in the memory of the process that asked for it and nowhere -else: not in a state directory, not in a bind-mounted file, not in a systemd -credential, not in a rendered config, not for a moment before a unit deletes -it. The target isn't a shorter list of secret files. It's the store, plus -exactly one file. +and everything else in this page follows from it. A value pulled from the +store — bao — lives in the memory of the process that asked for it and +nowhere else: not in a state directory, not in a bind-mounted file, not in a +systemd credential, not in a rendered config, not for a moment before a unit +deletes it. The target isn't a shorter list of secret files. It's the store, +plus exactly one file. -**That one file is the mTLS client certificate, and it's the only credential on -disk.** It has to be a file, and the reason is the whole asymmetry: the +**That one file is the mTLS client certificate, and it's the only credential +on disk.** It has to be a file, and the reason is the whole asymmetry: the certificate is what authenticates a principal to the store, so it's the one -credential that can't be fetched from the store. Something has to exist on disk -before the first request, or there's nothing to make the request with. Every -host has one, and its only power is to ask the store for the rest. Today only -the host running the store mints its own leaves automatically — for itself, and -pre-emptively for a controller and a publisher that might be co-located. For -every other host, `swarm-bao.nix:529-531` says it outright: this is _"the -credential an operator places by hand."_ +credential that can't be fetched from the store. Something has to exist on +disk before the first request, or there's nothing to make the request with. +Every host has one; its only power is to ask the store for the rest. +`swarm-bao.nix:529-531` states the rule for the nix option that carries it: +this is _"the credential an operator places by hand"_, and _"a path, never a +value."_ A literal in a nix expression lands in the nix store — +world-readable and permanent — so that option takes a path to the +certificate on disk, never the certificate's bytes. -**The hive hands an agent an identity, never a secret.** An agent container is -passed an mTLS certificate from its hive and from then on authenticates to the -store under its own name, pulling what it needs when it needs it. No process -reads a secret on another principal's behalf: the principal that needs a value -is the principal that authenticates for it. +**The hive hands an agent an identity, never a secret.** An agent container +is passed an mTLS certificate from its hive and from then on authenticates +to the store under its own name, pulling what it needs when it needs it. No +process reads a secret on another principal's behalf: the principal that +needs a value is the principal that authenticates for it. + +Two per-agent credential files — the forge token and the github token — sit +outside this page: they're operator-supplied and never pass through the +store, so the table below doesn't govern them. **Per secret, the target specifies minter, reader, and renewal strategy.** -Those three are the contract, and in this shape the **reader is a process -pulling a store path at runtime** — not a path on disk, and not a unit whose -job is to turn a store value into a file. +Those three are the contract, and the reader is a process pulling a store +path at runtime — not a path on disk, and not a unit whose job is to turn a +store value into a file. A renewal cell may never read `NONE`: state the +strategy for every credential, including the mTLS leaf. -| store path | minter | reader — pulls at runtime, holds in memory | renewal | -| -------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | **must be stated** — no strategy is chosen for this credential, and `NONE` isn't one | -| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | **must be stated** — as above | -| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | **must be stated** — as above | -| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | **must be stated** — as above | -| _(not in the store)_ the mTLS client leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — **the sole exception above**, because it's what makes every other row's pull possible | **must be stated** — as above | +| store path | minter | reader — pulls at runtime, holds in memory | renewal | +| --- | --- | --- | --- | +| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | +| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | +| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | +| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | +| _(not in the store)_ the mTLS client leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the sole exception above, because it's what makes every other row's pull possible | must be stated | -A renewal cell reading `NONE` is allowed only in the current-state table above, -as an honest record of where the swarm is. It isn't a strategy, and a new -secret doesn't get to write it. +## Touching a credential -The appservice token's second, uncoordinated minter isn't a row here for a -reason: in the target each credential has exactly one minter, so a fallback -mint on the reading host is a thing the target deletes, not a thing it -describes. +A pull request that touches a credential can't: -## Progressive enhancement - -The rule has two halves, and the second is the one that makes the first -survivable. - -**New functionality matches the target shape immediately.** No legacy clause -covers code that doesn't exist yet. - -**Existing functionality moves step by step** — each individual step moving -toward the target shape, never sideways and never away. A step doesn't have to -finish the migration. It has to not undo it. - -That's checkable, so check it. Apply these to a pull request that touches a -credential: - -| question | a `yes` means | -| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| Does it add a minter outside the swarm's existing mint path? | **block** for new functionality. For existing functionality, only if it removes one too. | -| Does it persist a store-provided secret to disk — a state directory, a bind mount, a rendered config? | **block** for new functionality. For existing functionality, only if the change removes a persisted copy elsewhere and nets out fewer. | -| Does it add a credential whose renewal strategy is `NONE`? | **block** for new functionality. State the strategy, even if it's "operator reissues and restarts the reader." | -| Does it read a secret on another principal's behalf and hand it over? | **block** for new functionality. The principal that needs the value authenticates for it. | -| Does it give a host or container an out-of-band credential that's not the store mTLS leaf? | **block**, both. One out-of-band credential per principal is the whole point of the store. | -| Does it change the minter, reader, or renewal of an existing credential? | The current-state table above is **stale until updated in the same change.** | - -For existing functionality the questions aren't pass/fail — they're a -direction check. A change that persists one fewer copy, or moves one reader from -a host-written file to its own store read, is a step and lands. A change that -leaves the count the same while moving the file somewhere tidier isn't a step; -it's churn with a migration story attached. - -When the last `yes` in the current-state table's final column becomes a `no`, -this page replaces `secrets.md` and the current-state section goes with it. +- add a minter outside the swarm's existing mint path +- persist a store-provided secret to disk — a state directory, a bind mount, + a rendered config +- add a credential whose renewal strategy is `NONE` — state the strategy, + even if it's "operator reissues and restarts the reader" +- read a secret on another principal's behalf and hand it over — the + principal that needs the value authenticates for it +- give a host or container an out-of-band credential that isn't the store + mTLS leaf — one out-of-band credential per principal is the whole point of + the store From cc77db0e7e97c76a91027c04178fc4ce89ace3df Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 16 Sep 2026 22:41:53 +0200 Subject: [PATCH 07/10] docs/swarm/credentials.md: nix fmt table alignment --- docs/swarm/credentials.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index a57b4ba6..b66670f4 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -46,13 +46,13 @@ path at runtime — not a path on disk, and not a unit whose job is to turn a store value into a file. A renewal cell may never read `NONE`: state the strategy for every credential, including the mTLS leaf. -| store path | minter | reader — pulls at runtime, holds in memory | renewal | -| --- | --- | --- | --- | -| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | -| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | -| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | -| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | -| _(not in the store)_ the mTLS client leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the sole exception above, because it's what makes every other row's pull possible | must be stated | +| store path | minter | reader — pulls at runtime, holds in memory | renewal | +| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | -------------- | +| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | +| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | +| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | +| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | +| _(not in the store)_ the mTLS client leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the sole exception above, because it's what makes every other row's pull possible | must be stated | ## Touching a credential From 972b80d7b88cb6934f0e7a0557cc3419a0338df4 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 16 Sep 2026 22:46:29 +0200 Subject: [PATCH 08/10] docs/swarm/credentials.md: fix citation range to include 'A path, never a value' The citation for the nix option description was 529-531, but the quoted phrase 'A path, never a value.' appears on line 533. The correct range 529-533 includes both the 'operator places by hand' claim and the 'path, never a value' assertion that the doc attributes to that file. Refs #4445 --- docs/swarm/credentials.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index b66670f4..e39bc973 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -24,7 +24,7 @@ certificate is what authenticates a principal to the store, so it's the one credential that can't be fetched from the store. Something has to exist on disk before the first request, or there's nothing to make the request with. Every host has one; its only power is to ask the store for the rest. -`swarm-bao.nix:529-531` states the rule for the nix option that carries it: +`swarm-bao.nix:529-533` states the rule for the nix option that carries it: this is _"the credential an operator places by hand"_, and _"a path, never a value."_ A literal in a nix expression lands in the nix store — world-readable and permanent — so that option takes a path to the From 596e8f006fc8c4655ef79f3ef4da22d53f6d99fd Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 16 Sep 2026 23:24:54 +0200 Subject: [PATCH 09/10] docs/swarm/credentials.md: every host has at least one certificate - Change 'Every host has one' to 'Every host has at least one' to clarify that a host may hold multiple mTLS client certificates. --- docs/swarm/credentials.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index e39bc973..639a16b5 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -23,7 +23,7 @@ on disk.** It has to be a file, and the reason is the whole asymmetry: the certificate is what authenticates a principal to the store, so it's the one credential that can't be fetched from the store. Something has to exist on disk before the first request, or there's nothing to make the request with. -Every host has one; its only power is to ask the store for the rest. +Every host has at least one; its only power is to ask the store for the rest. `swarm-bao.nix:529-533` states the rule for the nix option that carries it: this is _"the credential an operator places by hand"_, and _"a path, never a value."_ A literal in a nix expression lands in the nix store — From 0c50913db929b14afbca94980824772af24b8a78 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 16 Sep 2026 23:29:39 +0200 Subject: [PATCH 10/10] docs/swarm/credentials.md: mTLS cert is per-identity, not per-host Two lines still framed the invariant as one file total: line 19 ('plus exactly one file') and line 21 ('the only credential on disk') implied a single mTLS cert page-wide, contradicting the earlier fix that made line 26 say 'every host has at least one'. Agents and swarm-level services each need their own identity certificate, so a host running several holds several. Restate both as per-identity, keep the target shape (store + certs on disk, nothing else) unchanged. Also relax the touching-a-credential rule: moving a secret into bao without resolving its renewal strategy in the same change is acceptable while the migration to this shape is in progress, provided a follow-up issue is opened to settle renewal. Refs #4445 --- docs/swarm/credentials.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 639a16b5..680848b6 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -16,14 +16,16 @@ store — bao — lives in the memory of the process that asked for it and nowhere else: not in a state directory, not in a bind-mounted file, not in a systemd credential, not in a rendered config, not for a moment before a unit deletes it. The target isn't a shorter list of secret files. It's the store, -plus exactly one file. +plus one file per identity. -**That one file is the mTLS client certificate, and it's the only credential -on disk.** It has to be a file, and the reason is the whole asymmetry: the -certificate is what authenticates a principal to the store, so it's the one -credential that can't be fetched from the store. Something has to exist on -disk before the first request, or there's nothing to make the request with. -Every host has at least one; its only power is to ask the store for the rest. +**Those files are mTLS client certificates, one per identity, and they're +the only credential on disk.** Each has to be a file, and the reason is the +whole asymmetry: the certificate is what authenticates a principal to the +store, so it's the one credential that can't be fetched from the store. +Something has to exist on disk before the first request, or there's nothing +to make the request with. Every identity — an agent, a swarm-level service — +needs one; a host running several holds several, and its only power is to +ask the store for the rest. `swarm-bao.nix:529-533` states the rule for the nix option that carries it: this is _"the credential an operator places by hand"_, and _"a path, never a value."_ A literal in a nix expression lands in the nix store — @@ -68,3 +70,8 @@ A pull request that touches a credential can't: - give a host or container an out-of-band credential that isn't the store mTLS leaf — one out-of-band credential per principal is the whole point of the store + +While the swarm's credential path is still moving to this shape, a PR that +moves a secret into bao is allowed to leave its renewal strategy unresolved, +provided it opens a follow-up issue to settle renewal. That's a migration-era +allowance, not a standing exception to the renewal-strategy rule above.