Watch
0
0
Fork
You've already forked hyperhive
0

docs: fix 329 Microsoft.Contractions hits with proper safety checks

Apply contraction fixes across ~40 doc files (setup, integrations, lifecycle,
networking, scheduler, swarm, tools, trust-boundary, UI, etc.). Skipped 14 hits:
- 10 where words appear in ALL CAPS for deliberate emphasis (is NOT, do NOT, etc.)
- 4 where text could not be safely located due to markdown formatting or column position

Applied via systematic scan with checks for fenced code blocks, inline code spans,
and intentional caps. Preserves sentence-initial capitalization throughout.
This commit is contained in:
iris 2026-09-07 14:28:23 +02:00 • committed by mara
commit 78021ce982
38 changed files with 320 additions and 320 deletions

View file

@ -54,7 +54,7 @@ declarations.
agent?** → [`integrations/matrix.md`](integrations/matrix.md) (the homeserver); agent?** → [`integrations/matrix.md`](integrations/matrix.md) (the homeserver);
[`tools/matrix.md`](tools/matrix.md) for the MCP tool surface and [`tools/matrix.md`](tools/matrix.md) for the MCP tool surface and
`hyperhive.matrixAccounts`. `hyperhive.matrixAccounts`.
- **How do I give an agent a GitHub account (`gh` + `git push`)? How is - **How do I give an agent a GitHub account (`gh` + `git push`)? how's
the PAT injected?** → [`integrations/github.md`](integrations/github.md) the PAT injected?** → [`integrations/github.md`](integrations/github.md)
(operator content up top; the `gh`/git-push + notification-poller (operator content up top; the `gh`/git-push + notification-poller
mechanics are in a collapsed "Implementation" section at the bottom). mechanics are in a collapsed "Implementation" section at the bottom).
@ -79,7 +79,7 @@ declarations.
## Scheduler, CI, observability ## Scheduler, CI, observability
- **What is the job queue, as a general idea (not hive-c0re specifics)?** → - **what's the job queue, as a general idea (not hive-c0re specifics)?** →
[`scheduler/jobq.md`](scheduler/jobq.md) — operator-facing, no implementation detail. [`scheduler/jobq.md`](scheduler/jobq.md) — operator-facing, no implementation detail.
- **How does the rebuild queue work? What are the concrete step kinds, - **How does the rebuild queue work? What are the concrete step kinds,
queue sources, scheduler internals?** → [`scheduler/coordinator.md`](scheduler/coordinator.md). queue sources, scheduler internals?** → [`scheduler/coordinator.md`](scheduler/coordinator.md).

View file

@ -127,7 +127,7 @@ other agents don't:
equivalent on their own socket. equivalent on their own socket.
- **Storage/mounts** — only the manager container gets - **Storage/mounts** — only the manager container gets
`/var/lib/hyperhive/agents` bind-mounted RW at `/agents` (so it can `/var/lib/hyperhive/agents` bind-mounted RW at `/agents` (so it can
manage any agent's state dir — config is not authored there, since a manage any agent's state dir — config isn't authored there, since a
real config change is a PR from a clone), plus RO mounts for real config change is a PR from a clone), plus RO mounts for
`/applied` (diff against what's deployed) and `/meta` (system-wide `/applied` (diff against what's deployed) and `/meta` (system-wide
deploy log). Planned: each agent gets RW to `/agents/<descendant>/` deploy log). Planned: each agent gets RW to `/agents/<descendant>/`

View file

@ -27,7 +27,7 @@ CLI) before it takes effect. What you'll see, and what to do with it:
- **New agent** (`InitConfig` then `Spawn`) — creating a brand-new - **New agent** (`InitConfig` then `Spawn`) — creating a brand-new
agent is two approvals. `InitConfig` creates the config repo and agent is two approvals. `InitConfig` creates the config repo and
seeds it from a template; `Spawn` creates the container from that seeds it from a template; `Spawn` creates the container from that
config. Tailoring the template first is not a separate mechanism — config. Tailoring the template first isn't a separate mechanism —
it's the config-change flow above, a PR you review like any other. Every later change goes through the config-change flow it's the config-change flow above, a PR you review like any other. Every later change goes through the config-change flow
above — there's no repeat "spawn" for an existing agent. above — there's no repeat "spawn" for an existing agent.
- **Meta/flake update** (`UpdateMetaInputs`) — an agent asked to bump - **Meta/flake update** (`UpdateMetaInputs`) — an agent asked to bump
@ -66,11 +66,11 @@ request.
`hive-forge` — the same way it would change any other repo. `hive-forge` — the same way it would change any other repo.
The bind-mounted `/agents/<name>/config/` is a **copy for reading** a The bind-mounted `/agents/<name>/config/` is a **copy for reading** a
config, not the tree to edit: authoring in place there produces no PR config, not the tree to edit: authoring in place there produces no PR
and no approval. (It is currently mounted read-write, which is a and no approval. (it's currently mounted read-write, which is a
defect tracked separately, not an authoring path.) defect tracked separately, not an authoring path.)
Branch protection (push/merge allowlist = `core`, approvals allowlist Branch protection (push/merge allowlist = `core`, approvals allowlist
= operator team; see "Forge mirror" and #1787) makes the agent a = operator team; see "Forge mirror" and #1787) makes the agent a
write collaborator that **cannot merge its own config PR**. write collaborator that **can't merge its own config PR**.
2. hive-c0re's `/webhook/config-pr` endpoint receives the Forgejo 2. hive-c0re's `/webhook/config-pr` endpoint receives the Forgejo
`pull_request` event (opened / synchronized / reopened) and queues a `pull_request` event (opened / synchronized / reopened) and queues a
`MergeConfigPr` approval; a poll fallback catches any missed webhook. `MergeConfigPr` approval; a poll fallback catches any missed webhook.
@ -138,7 +138,7 @@ in-container store. The operator then **spawns** the agent (the
`Spawn` approval / `◆ R3QU3ST SP4WN` button), which creates the `Spawn` approval / `◆ R3QU3ST SP4WN` button), which creates the
container from that config. container from that config.
Changing what the template seeded is not a special case: like every Changing what the template seeded isn't a special case: like every
later change, it's a PR on that config repo (`MergeConfigPr`), made later change, it's a PR on that config repo (`MergeConfigPr`), made
from a clone, reviewed and approved by the operator. The PR flow is from a clone, reviewed and approved by the operator. The PR flow is
the one path — an operator can equally drive both steps herself the one path — an operator can equally drive both steps herself
@ -240,7 +240,7 @@ Transient broker errors (sqlite lock contention, etc.) get the same
- **Recurring rows** re-arm to the next interval slot — the retry - **Recurring rows** re-arm to the next interval slot — the retry
self-heals on the next worker pass. self-heals on the next worker pass.
- **One-shot rows** are deleted unconditionally after their single - **One-shot rows** are deleted unconditionally after their single
fan-out pass; a broker error on a one-shot is not retried (the fan-out pass; a broker error on a one-shot isn't retried (the
operator advisory and `last_result` are the only audit trail). operator advisory and `last_result` are the only audit trail).
### Reminder delivery: file-path semantics ### Reminder delivery: file-path semantics
@ -259,7 +259,7 @@ container, e.g. `/agents/<name>/state/foo.md`). On delivery hive-c0re:
dir is canonicalized and re-verified to live under the agent's host dir is canonicalized and re-verified to live under the agent's host
state root. The final file is opened with state root. The final file is opened with
`O_NOFOLLOW | O_CREAT | O_TRUNC` so an existing symlink at the `O_NOFOLLOW | O_CREAT | O_TRUNC` so an existing symlink at the
basename cannot redirect the write to an arbitrary host path. basename can't redirect the write to an arbitrary host path.
4. **Writes the body to disk** and delivers a short pointer message in its 4. **Writes the body to disk** and delivers a short pointer message in its
place, keeping the agent's inbox / wake-prompt small while the bulky place, keeping the agent's inbox / wake-prompt small while the bulky
payload is read out of band. payload is read out of band.
@ -279,7 +279,7 @@ reuses prior config + login. With `purge = true` the agent's
`/var/lib/hyperhive/{agents,applied}/<name>/` trees are also `/var/lib/hyperhive/{agents,applied}/<name>/` trees are also
wiped (config history + creds + notes gone forever). The wiped (config history + creds + notes gone forever). The
root/bootstrap container is destroyable like any other — hive-c0re root/bootstrap container is destroyable like any other — hive-c0re
recreates it on the next startup if it's absent, so destroying it is recreates it on the next startup if it's absent, so destroying it's
transient. transient.
## Meta flake ## Meta flake
@ -390,7 +390,7 @@ Why two physical repos: the submitting agent's `/agents/<n>/config/` is
RW — a buggy or hostile agent can `git clean -fdx` its own RW — a buggy or hostile agent can `git clean -fdx` its own
proposed tree. The applied repo is never bind-mounted (except proposed tree. The applied repo is never bind-mounted (except
the read-only `.git` exposure described below) so a destructive the read-only `.git` exposure described below) so a destructive
move inside the container cannot reach it. move inside the container can't reach it.
The container's `--flake` ref is `/var/lib/hyperhive/meta#<name>` The container's `--flake` ref is `/var/lib/hyperhive/meta#<name>`
(see "Meta flake" above). The agent's own `applied/<n>/flake.nix` (see "Meta flake" above). The agent's own `applied/<n>/flake.nix`
@ -493,7 +493,7 @@ Every parent agent's container has its **direct children's** config
repos bind-mounted **read-only** (topology-driven: repos bind-mounted **read-only** (topology-driven:
`hive-c0re/src/lifecycle/host_config.rs` calls `bind_child_agent_dirs` `hive-c0re/src/lifecycle/host_config.rs` calls `bind_child_agent_dirs`
for each entry in for each entry in
`topology::children_of(agent_name)`). It is a copy to *read* a child's `topology::children_of(agent_name)`). it's a copy to *read* a child's
current config — not an editing surface. current config — not an editing surface.
An agent with the `approvals` tool group submits a change the same way An agent with the `approvals` tool group submits a change the same way
@ -514,7 +514,7 @@ bind mounts via `set_nspawn_flags`:
deploy flake. deploy flake.
The root agent holds this role; a sub-manager that only manages a The root agent holds this role; a sub-manager that only manages a
subtree does not, and only has its direct children's config dirs. subtree doesn't, and only has its direct children's config dirs.
Each proposed repo (`/agents/<n>/config/`) is pre-configured Each proposed repo (`/agents/<n>/config/`) is pre-configured
with `applied` as a git remote pointing at with `applied` as a git remote pointing at
@ -534,7 +534,7 @@ cat /meta/flake.lock | jq '.nodes | with_entries(select(.key | startswith("agent
``` ```
The RO binds block push at the kernel level — git plumbing inside the The RO binds block push at the kernel level — git plumbing inside the
container cannot corrupt either authoritative repo. container can't corrupt either authoritative repo.
## Startup migrations (older hosts) ## Startup migrations (older hosts)

View file

@ -340,7 +340,7 @@ A parent agent gets each direct child's `state` dir bind-mounted
which includes writing into a child's state for recovery (e.g. seeding which includes writing into a child's state for recovery (e.g. seeding
notes, clearing a stuck sentinel) as well as reading it. notes, clearing a stuck sentinel) as well as reading it.
**`harness` is not mounted at all.** It holds the child's own runtime **`harness` isn't mounted at all.** It holds the child's own runtime
material — `bash-tasks/`, the turn-stats and event sqlite dbs — and material — `bash-tasks/`, the turn-stats and event sqlite dbs — and
nothing argues for a parent reading it, let alone writing it. hive-c0re nothing argues for a parent reading it, let alone writing it. hive-c0re
reads a child's harness dir **directly on the host** when it wants reads a child's harness dir **directly on the host** when it wants
@ -404,7 +404,7 @@ Contents:
hive-wide `services.hyperhive.c0re.agentCpuQuota` / `agentMemoryMax`, hive-wide `services.hyperhive.c0re.agentCpuQuota` / `agentMemoryMax`,
so an agent can override only its memory and still track the hive so an agent can override only its memory and still track the hive
default for CPU. The `CPUWeight=` / `IOWeight=` shares in the same default for CPU. The `CPUWeight=` / `IOWeight=` shares in the same
drop-in have **no** per-agent override — they are hive-wide only and drop-in have **no** per-agent override — they're hive-wide only and
come straight off `HiveEnv`, so this file has no field for them. come straight off `HiveEnv`, so this file has no field for them.
The root agent has the meta dir RO-mounted at `/meta/`. The root agent has the meta dir RO-mounted at `/meta/`.
@ -476,14 +476,14 @@ Only present on the one host running
so it survives restarts and redeploys. so it survives restarts and redeploys.
- `webhook-secret` — the HMAC key the swarm's forge webhooks are signed - `webhook-secret` — the HMAC key the swarm's forge webhooks are signed
with. **Keep it.** It is handed to Forgejo when a hook is registered, with. **Keep it.** it's handed to Forgejo when a hook is registered,
so replacing the file means every subsequent delivery fails so replacing the file means every subsequent delivery fails
verification until the hook is re-registered with the new value. It is verification until the hook is re-registered with the new value. it's
generated automatically on first start; there is nothing to configure. generated automatically on first start; there is nothing to configure.
If the file is unreadable at startup the daemon still starts and logs If the file is unreadable at startup the daemon still starts and logs
`webhook secret unavailable`; the webhook endpoint then answers 503 `webhook secret unavailable`; the webhook endpoint then answers 503
rather than accepting deliveries it cannot verify. Everything else the rather than accepting deliveries it can't verify. Everything else the
controller serves is unaffected. controller serves is unaffected.
## Run-time dirs ## Run-time dirs

View file

@ -10,9 +10,9 @@ sequence. All `hivectl` commands below run as **root on the host** (not
inside an agent container); the `request_*` steps run from ruth's own inside an agent container); the `request_*` steps run from ruth's own
turn via the MCP tools. turn via the MCP tools.
**Bringing up a hive that does not host its own swarm services?** Read **Bringing up a hive that doesn't host its own swarm services?** Read
[`swarm/secrets.md`](../swarm/secrets.md) first. Everything below assumes [`swarm/secrets.md`](../swarm/secrets.md) first. Everything below assumes
each credential is generated where it is read, which is true on an each credential is generated where it's read, which is true on an
all-local deploy and not otherwise — that page says which files an all-local deploy and not otherwise — that page says which files an
operator has to place, and where. operator has to place, and where.
@ -73,8 +73,8 @@ half, `.clientCertFile`, `.clientKeyFile` and `.serverCaFile` at the reader's.
Those are `mkDefault`s, so naming your own paths wins. Do that when your Those are `mkDefault`s, so naming your own paths wins. Do that when your
certificates come from a real internal CA; the store has no opinion about certificates come from a real internal CA; the store has no opinion about
which. A hive that does **not** deploy the store names the reader's three which. A hive that does **not** deploy the store names the reader's three
itself: that leaf is issued out of band, and it is the one credential the store itself: that leaf is issued out of band, and it's the one credential the store
cannot hand you, being what opens it. ⚠️ Not the gateway's HTTPS certificates and not the hive CA — this is can't hand you, being what opens it. ⚠️ Not the gateway's HTTPS certificates and not the hive CA — this is
**mTLS between services and the store**, a separate trust domain, because a **mTLS between services and the store**, a separate trust domain, because a
store that took its identity from an authority it will itself distribute could store that took its identity from an authority it will itself distribute could
never come up before that authority. never come up before that authority.
@ -95,7 +95,7 @@ start. Skipping this step looks like a broken proxy.
swarmctl user add mara --display-name Mara --email mara@example.com --group admins swarmctl user add mara --display-name Mara --email mara@example.com --group admins
``` ```
⚠️ **Keep `--group admins`.** It is not decoration: operator-only ⚠️ **Keep `--group admins`.** it's not decoration: operator-only
surfaces (the swarm UI below) are gated on that group, and an account surfaces (the swarm UI below) are gated on that group, and an account
without it authenticates successfully and is then refused — which reads without it authenticates successfully and is then refused — which reads
like a broken login rather than a missing group. like a broken login rather than a missing group.
@ -109,14 +109,14 @@ Detail, including what the password is and why this stays manual:
### 5 · Swarm UI (only when `deploy.swarm-ui`, on by default with the controller) ### 5 · Swarm UI (only when `deploy.swarm-ui`, on by default with the controller)
Nothing to run — it is served on the swarm apex Nothing to run — it's served on the swarm apex
(`https://<swarm.domain>/`) as soon as the host rebuilds. Two things (`https://<swarm.domain>/`) as soon as the host rebuilds. Two things
decide whether you can actually open it: decide whether you can actually open it:
- **You are in `admins`** (step 3). The gateway asks authelia whether - **You are in `admins`** (step 3). The gateway asks authelia whether
you have a session; the rule that makes it mean _operator_ wants the you have a session; the rule that makes it mean _operator_ wants the
group. Without it you log in and still get bounced. group. Without it you log in and still get bounced.
- **The name resolves to this host.** It is published to the hive's own - **The name resolves to this host.** it's published to the hive's own
resolver and to `/etc/hosts` when `gateway.localHostsEntry` is on; from resolver and to `/etc/hosts` when `gateway.localHostsEntry` is on; from
anywhere else it needs a real DNS record like any other public name. anywhere else it needs a real DNS record like any other public name.

View file

@ -134,7 +134,7 @@ agent's entire still-unread backlog as a flood of fresh wakes. Forge's
own read-state is the only durable record this design needs. own read-state is the only durable record this design needs.
**Read-before-comment guard doesn't block a fresh wake.** hive-forge's **Read-before-comment guard doesn't block a fresh wake.** hive-forge's
read-before-comment guard (which keys off forge unread-state) does not read-before-comment guard (which keys off forge unread-state) doesn't
force the agent to view a thread before commenting on it: the broker force the agent to view a thread before commenting on it: the broker
wake already carries the notification body, so _delivery is the read_. wake already carries the notification body, so _delivery is the read_.
An agent that wants the full thread still runs `hive-forge comments` / An agent that wants the full thread still runs `hive-forge comments` /
@ -144,7 +144,7 @@ An agent that wants the full thread still runs `hive-forge comments` /
id → last-delivered `updated_at`) guards the narrow window where a id → last-delivered `updated_at`) guards the narrow window where a
mark-read call _transiently fails_ and the thread reappears unread in mark-read call _transiently fails_ and the thread reappears unread in
the next poll before its `updated_at` bumps — so a flaky PATCH doesn't the next poll before its `updated_at` bumps — so a flaky PATCH doesn't
re-fire the wake. It is **not persisted** and resets on restart (forge re-fire the wake. it's **not persisted** and resets on restart (forge
read-state covers the durable case). Each poll prunes it to the ids in read-state covers the durable case). Each poll prunes it to the ids in
the single `limit=UNREAD_FETCH_LIMIT` (50) fetch page, so it can never the single `limit=UNREAD_FETCH_LIMIT` (50) fetch page, so it can never
exceed that many entries (a debug assertion pins the invariant; the exceed that many entries (a debug assertion pins the invariant; the
@ -222,7 +222,7 @@ pipeline steps in order:
The strict ATX rule is deliberate: `\#tag` and `#tag` render The strict ATX rule is deliberate: `\#tag` and `#tag` render
identically, so an over-eager escape just adds visual clutter identically, so an over-eager escape just adds visual clutter
without changing behavior. Setext-style headings (`title\n====`) without changing behavior. Setext-style headings (`title\n====`)
are not handled — rarer in practice, would need multi-line aren't handled — rarer in practice, would need multi-line
lookahead. lookahead.
### Wrapper format ### Wrapper format
@ -240,7 +240,7 @@ Five shapes, distinguished by the notification's classification:
The issue/PR's own description is deliberately never embedded here (only a The issue/PR's own description is deliberately never embedded here (only a
comment/review body is — that's the actual new content a notification comment/review body is — that's the actual new content a notification
exists to surface): repeating a subject's own description on every "new" exists to surface): repeating a subject's own description on every "new"
or "activity on" wake for it is noise the agent already has via the URL, or "activity on" wake for it's noise the agent already has via the URL,
not context (mara, triage: "just the issue title should be enough"). not context (mara, triage: "just the issue title should be enough").
Review labels come from the Forgejo `state` field: `APPROVED` → Review labels come from the Forgejo `state` field: `APPROVED` →

View file

@ -28,7 +28,7 @@ into every agent, so no agent ships the `gh` wrapper or credential helper.
(`hyperhive.github.enable` also exists per-agent for completeness, but the (`hyperhive.github.enable` also exists per-agent for completeness, but the
hive-wide host switch is the intended control.) hive-wide host switch is the intended control.)
github.com only. The token **value** never touches nix — it is written to github.com only. The token **value** never touches nix — it's written to
`<state>/github-token` separately (see [Provisioning](#provisioning)). `<state>/github-token` separately (see [Provisioning](#provisioning)).
## Provisioning ## Provisioning
@ -88,7 +88,7 @@ minimal environment that wouldn't carry one.
### Notifications ### Notifications
`hive-github-notify` polls github.com for the agent, turning each unread `hive-github-notify` polls github.com for the agent, turning each unread
notification thread into a todo. It is a **separate binary and a notification thread into a todo. it's a **separate binary and a
separate systemd unit** from the internal forge's poller separate systemd unit** from the internal forge's poller
(`hive-forge-notify`, see [forge.md](forge.md#notification-poller-hive-forge-notifysrcnotifyrs)), (`hive-forge-notify`, see [forge.md](forge.md#notification-poller-hive-forge-notifysrcnotifyrs)),
installed by `nix/agent-modules/github.nix` under installed by `nix/agent-modules/github.nix` under
@ -99,12 +99,12 @@ path, not a new package.
Two units rather than one daemon with two loops, because it puts the Two units rather than one daemon with two loops, because it puts the
decision in nix: a hive built without this module has **no github poller decision in nix: a hive built without this module has **no github poller
in its closure at all**, which is what makes GitHub access separable in its closure at all**, which is what makes GitHub access separable
rather than merely switched off. It is also why this is not a cargo rather than merely switched off. it's also why this isn't a cargo
feature — a feature would unify across the workspace and cost every feature — a feature would unify across the workspace and cost every
crate its build cache. crate its build cache.
At runtime the poller needs the PAT above. No PAT, no polling: the unit At runtime the poller needs the PAT above. No PAT, no polling: the unit
logs why and exits 0, which is why it is `Restart = on-failure` and logs why and exits 0, which is why it's `Restart = on-failure` and
never `always` — a clean exit on a PAT-less agent must not become a never `always` — a clean exit on a PAT-less agent must not become a
restart loop. restart loop.
@ -114,7 +114,7 @@ parse serves both: `id`, `repository.full_name`,
for field. The two real differences are absorbed by lenient for field. The two real differences are absorbed by lenient
deserializers — GitHub sends the thread id as a _string_ where Forgejo deserializers — GitHub sends the thread id as a _string_ where Forgejo
sends a number, and says `PullRequest` where Forgejo says `Pull`. Todo sends a number, and says `PullRequest` where Forgejo says `Pull`. Todo
keys are prefixed `gh:<id>` so a github thread id cannot collide with a keys are prefixed `gh:<id>` so a github thread id can't collide with a
forge one. forge one.
Two host differences worth knowing before touching this code: Two host differences worth knowing before touching this code:
@ -122,15 +122,15 @@ Two host differences worth knowing before touching this code:
- **Auth scheme, not just value.** Forgejo takes - **Auth scheme, not just value.** Forgejo takes
`Authorization: token <t>`; GitHub wants `Bearer <t>` plus `Accept: `Authorization: token <t>`; GitHub wants `Bearer <t>` plus `Accept:
application/vnd.github+json`, `X-GitHub-Api-Version` and a application/vnd.github+json`, `X-GitHub-Api-Version` and a
`User-Agent`. Sending Forgejo's form to GitHub does not error — it `User-Agent`. Sending Forgejo's form to GitHub doesn't error — it
authenticates as _nobody_ and silently drops to the unauthenticated authenticates as _nobody_ and silently drops to the unauthenticated
rate limit. The cheap way to tell the two apart is the rate-limit rate limit. The cheap way to tell the two apart is the rate-limit
header: `x-ratelimit-remaining` near 5000 is an authenticated user, header: `x-ratelimit-remaining` near 5000 is an authenticated user,
near 60 is anonymous. near 60 is anonymous.
- **GitHub sets the cadence.** It returns `X-Poll-Interval` (60s in - **GitHub sets the cadence.** It returns `X-Poll-Interval` (60s in
practice, slower than our own tick) and rate-limits callers who ignore practice, slower than our own tick) and rate-limits callers who ignore
it, so the loop re-arms to the server's interval whenever that is it, so the loop re-arms to the server's interval whenever that's
_slower_ than ours. A hint faster than our own tick is not a reason to _slower_ than ours. A hint faster than our own tick isn't a reason to
poll harder. poll harder.
⚠️ **This needs the `notifications` scope on the PAT.** A token minted ⚠️ **This needs the `notifications` scope on the PAT.** A token minted

View file

@ -44,13 +44,13 @@ hive-c0re maintains the local clone at
event to every hive over the swarm queue and each hive runs `git event to every hive over the swarm queue and each hive runs `git
pull`, so agents see the new content on their next turn. pull`, so agents see the new content on their next turn.
A hive that is offline when the event is sent does not get it on A hive that's offline when the event is sent doesn't get it on
reconnect — the periodic pull below is what closes that gap. So one reconnect — the periodic pull below is what closes that gap. So one
hive briefly showing older `/knowledge` content than another is hive briefly showing older `/knowledge` content than another is
expected, and resolves by itself within the fallback interval. expected, and resolves by itself within the fallback interval.
**Do not add a per-hive hook.** A webhook has exactly one target **don't add a per-hive hook.** A webhook has exactly one target
URL, so a second registration against the same repo does not add a URL, so a second registration against the same repo doesn't add a
recipient — it takes delivery away from whoever registered first. recipient — it takes delivery away from whoever registered first.
Earlier versions had each hive register its own; hive-c0re now Earlier versions had each hive register its own; hive-c0re now
removes its own leftover at startup, so no operator step is needed removes its own leftover at startup, so no operator step is needed
@ -59,7 +59,7 @@ pull`, so agents see the new content on their next turn.
2. **Periodic pull** — a background task in `hive-c0re::main` 2. **Periodic pull** — a background task in `hive-c0re::main`
pulls on a fixed cadence as a fallback (webhook missed, c0re pulls on a fixed cadence as a fallback (webhook missed, c0re
restarted between pushes). The pull is best-effort — a failure restarted between pushes). The pull is best-effort — a failure
logs a warning and does not affect the rest of the daemon. logs a warning and doesn't affect the rest of the daemon.
Both paths share the same `knowledge::pull()` function, which also Both paths share the same `knowledge::pull()` function, which also
handles the change notice below — neither path can forget to wire it handles the change notice below — neither path can forget to wire it

View file

@ -31,7 +31,7 @@ Two distinct hostnames:
- **`serverName`** — matrix-spec `server_name`, embedded - **`serverName`** — matrix-spec `server_name`, embedded
*irrevocably* in every `@user:<server_name>` and `!room:<server_name>` *irrevocably* in every `@user:<server_name>` and `!room:<server_name>`
identifier minted on this homeserver. Cannot be changed later identifier minted on this homeserver. can't be changed later
without abandoning every account and chat history. Defaults to the without abandoning every account and chat history. Defaults to the
bare `services.hyperhive.swarm.domain`; clients auto-discover the bare `services.hyperhive.swarm.domain`; clients auto-discover the
actual API endpoint via the `.well-known/matrix/{client,server}` actual API endpoint via the `.well-known/matrix/{client,server}`
@ -46,9 +46,9 @@ homeserver: tying its identity to a single hive's domain would make
relocating the container between hives look like a different relocating the container between hives look like a different
homeserver. homeserver.
⚠️ **They are still not interchangeable, and the difference is the ⚠️ **they're still not interchangeable, and the difference is the
cost of changing one.** `gatewayHost` is a routing detail clients cost of changing one.** `gatewayHost` is a routing detail clients
rediscover through `.well-known`, so it is safe to move on a running rediscover through `.well-known`, so it's safe to move on a running
deployment. `serverName` is baked into every user and room id, so deployment. `serverName` is baked into every user and room id, so
adopting a new one does **not** rename the existing users and rooms — adopting a new one does **not** rename the existing users and rooms —
it strands them, because their ids still name a homeserver that no it strands them, because their ids still name a homeserver that no
@ -71,8 +71,8 @@ services.hyperhive.swarm.matrix = {
A rebuild on a host that already has a homeserver prints a A rebuild on a host that already has a homeserver prints a
`hive-matrix: WARNING — … serverName is unset` line when this is missing, `hive-matrix: WARNING — … serverName is unset` line when this is missing,
naming the value it is about to default to. That warning is why this naming the value it's about to default to. That warning is why this
section exists; it never fails the rebuild, so it is on you to act on it section exists; it never fails the rebuild, so it's on you to act on it
before the ids are minted. before the ids are minted.
## Default-closed firewall ## Default-closed firewall
@ -147,13 +147,13 @@ Initial rollout settings:
external / federated homeservers or to keep contents opaque to the external / federated homeservers or to keep contents opaque to the
homeserver admin. **The agent matrix client always supports decryption homeserver admin. **The agent matrix client always supports decryption
regardless of this flag** — it uses the `e2e-encryption` feature of regardless of this flag** — it uses the `e2e-encryption` feature of
`matrix-sdk` so it can read encrypted rooms it is invited to even when `matrix-sdk` so it can read encrypted rooms it's invited to even when
this homeserver doesn't permit room encryption. Crypto keys are stored this homeserver doesn't permit room encryption. Crypto keys are stored
in the per-agent sqlite store under the state dir and persist across in the per-agent sqlite store under the state dir and persist across
restarts (lost on `--purge`). `read_room` decrypts via restarts (lost on `--purge`). `read_room` decrypts via
`room.messages()` — UTD events surface as `event_type = `room.messages()` — UTD events surface as `event_type =
"m.room.encrypted"` with `body = "[unable to decrypt]"`. "m.room.encrypted"` with `body = "[unable to decrypt]"`.
Cross-signing and automatic key backup are not enabled for the first Cross-signing and automatic key backup aren't enabled for the first
pass: static bearer-token bot accounts can't bootstrap cross-signing pass: static bearer-token bot accounts can't bootstrap cross-signing
without MSC3967. without MSC3967.
@ -227,7 +227,7 @@ homeserver at all rather than of a setting:
tuwunel reads its identity providers from the config file, so a tuwunel reads its identity providers from the config file, so a
half-configured one can stop the homeserver from starting outright half-configured one can stop the homeserver from starting outright
rather than merely hiding a login button. On a host that also runs rather than merely hiding a login button. On a host that also runs
the swarm's authelia it is wired up for you. the swarm's authelia it's wired up for you.
- **`swarm.authelia.url` is required** — without a provider URL there - **`swarm.authelia.url` is required** — without a provider URL there
is nothing to discover against. is nothing to discover against.
- **`gatewayHost != null` is required** — the SSO callback URL is - **`gatewayHost != null` is required** — the SSO callback URL is

View file

@ -107,7 +107,7 @@ now set unconditionally for every agent. The mechanism:
The dir is `0751`, owned by the agent's container uid/gid, so The dir is `0751`, owned by the agent's container uid/gid, so
nginx reaches `web.sock` through `o=--x` (traverse) and the socket's nginx reaches `web.sock` through `o=--x` (traverse) and the socket's
own `0666`. The gateway is one of three principals sharing that dir own `0666`. The gateway is one of three principals sharing that dir
and does not own its ownership rules — see and doesn't own its ownership rules — see
[`docs/trust-boundary/boundary.md`](../trust-boundary/boundary.md#the-per-agent-socket-dir). [`docs/trust-boundary/boundary.md`](../trust-boundary/boundary.md#the-per-agent-socket-dir).
3. **Marker gate**. After successful `bind_unix`, the harness drops 3. **Marker gate**. After successful `bind_unix`, the harness drops
`<dir>/hyperhive-socket-bound` next to the socket. c0re's `<dir>/hyperhive-socket-bound` next to the socket. c0re's
@ -118,13 +118,13 @@ now set unconditionally for every agent. The mechanism:
`/var/lib/hive-gateway/conf/agents.conf` — a plain nginx include `/var/lib/hive-gateway/conf/agents.conf` — a plain nginx include
file with one `location /agent/<name>/` block per agent. Always file with one `location /agent/<name>/` block per agent. Always
a UDS upstream (`http://unix:/run/hive-agent/<name>/web.sock:/`); a UDS upstream (`http://unix:/run/hive-agent/<name>/web.sock:/`);
if the socket is not yet bound, nginx returns 502 caught by the if the socket isn't yet bound, nginx returns 502 caught by the
`error_page 502 503 504 = /__hive_agent_unreachable` directive. `error_page 502 503 504 = /__hive_agent_unreachable` directive.
nginx includes `/var/lib/hive-gateway/conf/agents.conf` — the same nginx includes `/var/lib/hive-gateway/conf/agents.conf` — the same
path c0re writes, since both run on the host. path c0re writes, since both run on the host.
After each write, c0re triggers the appropriate nginx action via After each write, c0re triggers the appropriate nginx action via
`hive-priv` (which is root; hive-c0re runs as the unprivileged `hive-priv` (which is root; hive-c0re runs as the unprivileged
`hive-core` user and cannot act on a system unit). `hive-core` user and can't act on a system unit).
`hive-priv` queries `ActiveState` and dispatches: `hive-priv` queries `ActiveState` and dispatches:
- active → `systemctl reload nginx` (SIGHUP, zero-downtime) - active → `systemctl reload nginx` (SIGHUP, zero-downtime)
- failed → `systemctl reset-failed nginx` + `systemctl start nginx` - failed → `systemctl reset-failed nginx` + `systemctl start nginx`
@ -206,7 +206,7 @@ On by default, and listens on `httpsPort` (default 443) on every vhost beside th
The issuer is a **host-held hive CA**, not a bare self-signed leaf. A host service (`hive-tls-ca.service`, from the `hive-tls` module) generates a long-lived CA (`services.hyperhive.deploy.hive-controller.tls.caValidityDays`, default ~20y) under `services.hyperhive.deploy.hive-controller.tls.stateDir` (default `/var/lib/hive-tls`), then signs a gateway **leaf** (`leafValidityDays`, default 30d) with it. `hive-gateway-self-signed-cert` then imports the leaf into nginx's state dir (`/var/lib/hive-gateway/tls/{cert,key}.pem`). The issuer is a **host-held hive CA**, not a bare self-signed leaf. A host service (`hive-tls-ca.service`, from the `hive-tls` module) generates a long-lived CA (`services.hyperhive.deploy.hive-controller.tls.caValidityDays`, default ~20y) under `services.hyperhive.deploy.hive-controller.tls.stateDir` (default `/var/lib/hive-tls`), then signs a gateway **leaf** (`leafValidityDays`, default 30d) with it. `hive-gateway-self-signed-cert` then imports the leaf into nginx's state dir (`/var/lib/hive-gateway/tls/{cert,key}.pem`).
⚠️ **Do not collapse that import unit into pointing nginx at the CA dir.** ⚠️ **don't collapse that import unit into pointing nginx at the CA dir.**
It does two jobs, and skipping it has taken the gateway down in production It does two jobs, and skipping it has taken the gateway down in production
before. It re-modes the leaf (`hive-tls-ca` writes the key `0600 before. It re-modes the leaf (`hive-tls-ca` writes the key `0600
root:root`; nginx's pre-start `nginx -t` runs as the *nginx user*, so a root:root`; nginx's pre-start `nginx -t` runs as the *nginx user*, so a
@ -216,13 +216,13 @@ swarm-services fallback below is for.
**Why a CA, not a bare leaf**: a bare self-signed leaf is its own trust anchor, so every regeneration is a new anchor every consumer must re-trust — and a runtime-generated leaf can't be wired into an agent's build-time trust store at all. With a stable CA, agents and federation peers trust it *once*; leaf rotation never re-breaks them. **Why a CA, not a bare leaf**: a bare self-signed leaf is its own trust anchor, so every regeneration is a new anchor every consumer must re-trust — and a runtime-generated leaf can't be wired into an agent's build-time trust store at all. With a stable CA, agents and federation peers trust it *once*; leaf rotation never re-breaks them.
**What consumers trust**: `trust-bundle.pem` in the same state dir, not `ca.pem`. The hive CA is itself issued under the swarm root ([`swarm/ca.md`](../swarm/ca.md) has the hierarchy), and an intermediate is not a chain a verifier can terminate at — so the bundle carries the hive CA plus whatever it is rooted at. nginx is handed the leaf with the hive CA appended for the same reason. Everything that trusts the hive's TLS reads the bundle: agents (via `security.pki.certificateFiles`), the CI and forge containers, and a federating peer. **What consumers trust**: `trust-bundle.pem` in the same state dir, not `ca.pem`. The hive CA is itself issued under the swarm root ([`swarm/ca.md`](../swarm/ca.md) has the hierarchy), and an intermediate isn't a chain a verifier can terminate at — so the bundle carries the hive CA plus whatever it's rooted at. nginx is handed the leaf with the hive CA appended for the same reason. Everything that trusts the hive's TLS reads the bundle: agents (via `security.pki.certificateFiles`), the CI and forge containers, and a federating peer.
**Why on by default**: matrix-dart-sdk (FluffyChat's SDK) hardcodes `https://<host>/.well-known/matrix/client` for homeserver discovery and refuses to fall back to plain http. Without TLS the browser client cannot bootstrap. **Why on by default**: matrix-dart-sdk (FluffyChat's SDK) hardcodes `https://<host>/.well-known/matrix/client` for homeserver discovery and refuses to fall back to plain http. Without TLS the browser client can't bootstrap.
**Cert shape**: leaf subject CN = bare hive domain; subjectAltName is `<hive>` plus wildcard `*.<hive>`, so all current and future sub-domain vhosts validate under the same leaf + the hive CA. A swarm service whose name is *not* under this hive's domain cannot be added here — the hive CA is name-constrained to `<hive>`, and a violating SAN invalidates the whole leaf, not just that name. Those names get the swarm-services leaf instead ([`swarm/ca.md`](../swarm/ca.md)). **Cert shape**: leaf subject CN = bare hive domain; subjectAltName is `<hive>` plus wildcard `*.<hive>`, so all current and future sub-domain vhosts validate under the same leaf + the hive CA. A swarm service whose name is *not* under this hive's domain can't be added here — the hive CA is name-constrained to `<hive>`, and a violating SAN invalidates the whole leaf, not just that name. Those names get the swarm-services leaf instead ([`swarm/ca.md`](../swarm/ca.md)).
**Rotation**: `hive-tls-ca.service` is idempotent — it re-signs the leaf when it is missing or within 30 days of expiry, always under the same CA (so consumer trust is undisturbed). The CA itself is regenerated only if missing or already expired. To force a leaf rotation, delete `gateway.pem` under the state dir and restart the unit, then reload `nginx`. **Rotation**: `hive-tls-ca.service` is idempotent — it re-signs the leaf when it's missing or within 30 days of expiry, always under the same CA (so consumer trust is undisturbed). The CA itself is regenerated only if missing or already expired. To force a leaf rotation, delete `gateway.pem` under the state dir and restart the unit, then reload `nginx`.
**Cert prompts**: browsers still warn once per host until the hive's `trust-bundle.pem` is added to the browser/OS trust store (an anchor, not the leaf, is the thing to trust). Agent trust is wired separately (see the agent-trust work for `/run/hive-ca`). **Cert prompts**: browsers still warn once per host until the hive's `trust-bundle.pem` is added to the browser/OS trust store (an anchor, not the leaf, is the thing to trust). Agent trust is wired separately (see the agent-trust work for `/run/hive-ca`).
@ -242,7 +242,7 @@ nginx reads the directory directly and uses `cert.pem` + `key.pem` (override `tl
`tls.certDir` and `tls.acme.enable` set together is an assertion error. `tls.certDir` and `tls.acme.enable` set together is an assertion error.
**Key file permissions**: nixpkgs's `security.acme` outputs private keys as `0640 root:acme` by default. nginx runs as the `nginx` user and cannot read a key with that ownership. Fix with: **Key file permissions**: nixpkgs's `security.acme` outputs private keys as `0640 root:acme` by default. nginx runs as the `nginx` user and can't read a key with that ownership. Fix with:
```nix ```nix
security.acme.certs."example.com".group = "nginx"; security.acme.certs."example.com".group = "nginx";
@ -258,7 +258,7 @@ for an operator who wants their own TLS terminator:
- give the gateway the real cert via `tls.certDir` (or `tls.acme`) so it - give the gateway the real cert via `tls.certDir` (or `tls.acme`) so it
serves proper TLS directly — no separate proxy needed; or serves proper TLS directly — no separate proxy needed; or
- front it over a **unix socket** rather than a plain-http TCP port (the - front it over a **unix socket** rather than a plain-http TCP port (the
intended direction for "bring your own proxy" — the gateway is not meant intended direction for "bring your own proxy" — the gateway isn't meant
to expose an unencrypted TCP upstream). to expose an unencrypted TCP upstream).
Because of this, `.well-known/matrix/{client,server}` discovery responses Because of this, `.well-known/matrix/{client,server}` discovery responses
@ -369,7 +369,7 @@ that path.
services.hyperhive.swarm.forge.rootUrl = "https://forge.example.com/"; services.hyperhive.swarm.forge.rootUrl = "https://forge.example.com/";
``` ```
`rootUrl` (default **null**) overrides the Forgejo `ROOT_URL` that is `rootUrl` (default **null**) overrides the Forgejo `ROOT_URL` that's
auto-derived from `forge.domain` + gateway state. The auto-derivation auto-derived from `forge.domain` + gateway state. The auto-derivation
covers most cases: covers most cases:
@ -455,7 +455,7 @@ legacy single-proxy block and all traffic is forwarded to the agent
daemon as before. daemon as before.
**`extraFiles`**: per-agent `hyperhive.frontend.extraFiles` are in **`extraFiles`**: per-agent `hyperhive.frontend.extraFiles` are in
`mergedDist`, not in the base `cfg.frontend` dist. They are not under `mergedDist`, not in the base `cfg.frontend` dist. they're not under
the nix-store `alias` path, so requests for them fall through the nix-store `alias` path, so requests for them fall through
`try_files` to `@<name>_dynamic` and are served by the agent daemon `try_files` to `@<name>_dynamic` and are served by the agent daemon
as before. as before.
@ -493,7 +493,7 @@ default status code would point at the wrong component. The per-agent
routes qualify (a 502 there means the harness is restarting, not that routes qualify (a 502 there means the harness is restarting, not that
the gateway is broken), and so does `auth.<swarm>` — a dead authelia the gateway is broken), and so does `auth.<swarm>` — a dead authelia
upstream almost always means the user store was never bootstrapped, and upstream almost always means the user store was never bootstrapped, and
a bare 502 blames the proxy, which is the one part that is working. a bare 502 blames the proxy, which is the one part that's working.
Forge / matrix / fluffychat still get nginx defaults: their upstreams Forge / matrix / fluffychat still get nginx defaults: their upstreams
being down means what the status code says, so a themed page would add being down means what the status code says, so a themed page would add
@ -542,7 +542,7 @@ The daemon hashes passwords with BCrypt (cost 12) and writes
`$2y$`-prefixed hashes that nginx accepts natively. No external `$2y$`-prefixed hashes that nginx accepts natively. No external
`htpasswd` binary is required. `htpasswd` binary is required.
**What is not gated:** per-agent UI routes emitted into `agents.conf` **what's not gated:** per-agent UI routes emitted into `agents.conf`
(served under `/agent/<name>/`) inherit no auth from `/` — nginx (served under `/agent/<name>/`) inherit no auth from `/` — nginx
applies `auth_basic` per-location. Full per-agent coverage is a applies `auth_basic` per-location. Full per-agent coverage is a
follow-up. follow-up.
@ -618,18 +618,18 @@ the opposite arm run as a control:
- `verify` + `depth` — the chain is leaf -> intermediate -> root. - `verify` + `depth` — the chain is leaf -> intermediate -> root.
- `trusted_cert` — the bundle; nginx reads ALL certs in the file, which - `trusted_cert` — the bundle; nginx reads ALL certs in the file, which
the bundle's own doc warns is not true of every consumer. the bundle's own doc warns isn't true of every consumer.
- `ssl_name` — checks the HOSTNAME too. Without it a chain-only check - `ssl_name` — checks the HOSTNAME too. Without it a chain-only check
accepts any certificate this CA ever signed, and for an internal CA accepts any certificate this CA ever signed, and for an internal CA
that is every service on the hive. that's every service on the hive.
- `server_name on` — sends SNI, or the far end cannot pick a cert. - `server_name on` — sends SNI, or the far end can't pick a cert.
**⚠️ Session-cache footgun**: `proxy_ssl_session_reuse` is left at its **⚠️ Session-cache footgun**: `proxy_ssl_session_reuse` is left at its
default (on), deliberately — this is used on per-request auth default (on), deliberately — this is used on per-request auth
subrequests, so the handshake it avoids is paid on every request. Worth subrequests, so the handshake it avoids is paid on every request. Worth
knowing when testing though: the session cache is keyed by upstream knowing when testing though: the session cache is keyed by upstream
address and NOT by trust config, so two locations pointing at one address and NOT by trust config, so two locations pointing at one
upstream with different trust do not verify independently. upstream with different trust don't verify independently.
**⚠️ Host-header clobber footgun**: `verifiedProxyTo` also pins `Host` **⚠️ Host-header clobber footgun**: `verifiedProxyTo` also pins `Host`
(and reinstates the rest of nginx's `recommendedProxySettings` header (and reinstates the rest of nginx's `recommendedProxySettings` header

View file

@ -75,7 +75,7 @@ The flows, end to end:
`/run/hive/mcp.sock`, privileged helper `/run/hive/priv.sock`, `/run/hive/mcp.sock`, privileged helper `/run/hive/priv.sock`,
operator admin `/run/hyperhive/host.sock`, and the per-agent UI operator admin `/run/hyperhive/host.sock`, and the per-agent UI
sockets under `/run/hive-agent/` are unix domain sockets sockets under `/run/hive-agent/` are unix domain sockets
bind-mounted through the VFS; private netns does not affect them. bind-mounted through the VFS; private netns doesn't affect them.
## Container shape (where dnsmasq lives) ## Container shape (where dnsmasq lives)
@ -100,8 +100,8 @@ listener on `bridgeIp` is on the host's bridge interface.
``` ```
Requires `services.hyperhive.domain` to be set — the dnsmasq resolver Requires `services.hyperhive.domain` to be set — the dnsmasq resolver
is authoritative for `<hive-domain>` and its sub-domains. You do not is authoritative for `<hive-domain>` and its sub-domains. You don't
write it: it is read from this hive's entry in the swarm directory write it: it's read from this hive's entry in the swarm directory
(`docs/swarm/README.md` § Hive identity config). (`docs/swarm/README.md` § Hive identity config).
## Bridge addressing ## Bridge addressing
@ -159,7 +159,7 @@ agent containers.
interfaces stay closed. The hive resolver isn't an external-facing interfaces stay closed. The hive resolver isn't an external-facing
service. service.
- Port 67 (UDP) admits DHCP requests to the dnsmasq pool. dnsmasq - Port 67 (UDP) admits DHCP requests to the dnsmasq pool. dnsmasq
receives DHCP via a regular UDP socket (it does not use a receives DHCP via a regular UDP socket (it doesn't use a
netfilter-bypassing raw socket), so the hole is mandatory — without netfilter-bypassing raw socket), so the hole is mandatory — without
it containers never get a lease and fall back to 169.254.x.x. it containers never get a lease and fall back to 169.254.x.x.
- Ports 80 and 443 let isolated agents reach nginx (gateway - Ports 80 and 443 let isolated agents reach nginx (gateway
@ -174,7 +174,7 @@ boot its `firewall-start` flushes the `nixos-fw` chains, rebuilds them
from the container's (empty) port list, and deletes the host's from the container's (empty) port list, and deletes the host's
`nixos-nat-*` chains without recreating them, silently wiping the `nixos-nat-*` chains without recreating them, silently wiping the
bridge holes above plus the agents' NAT. Private-netns containers bridge holes above plus the agents' NAT. Private-netns containers
(agents, hive-ci) may keep their own firewall — it is scoped to their (agents, hive-ci) may keep their own firewall — it's scoped to their
namespace. namespace.
### Reaching host services (`exposeHostPorts`) ### Reaching host services (`exposeHostPorts`)
@ -225,7 +225,7 @@ address arithmetic.
`PRIVATE_NETWORK=1`, `LOCAL_ADDRESS=` (empty), `HOST_ADDRESS=<bridge-ip>`, `PRIVATE_NETWORK=1`, `LOCAL_ADDRESS=` (empty), `HOST_ADDRESS=<bridge-ip>`,
and `HOST_BRIDGE=<bridgeName>` via `lifecycle::set_nspawn_flags` when and `HOST_BRIDGE=<bridgeName>` via `lifecycle::set_nspawn_flags` when
creating or updating containers. Both variables are validated **once at creating or updating containers. Both variables are validated **once at
daemon startup**, not per container: they are process-global, so a daemon startup**, not per container: they're process-global, so a
missing or malformed value is a misconfigured daemon rather than one bad missing or malformed value is a misconfigured daemon rather than one bad
container, and failing at boot gives a single diagnostic instead of one container, and failing at boot gives a single diagnostic instead of one
per agent. There is no non-isolated mode to fall back to. `LOCAL_ADDRESS` is left empty so the per agent. There is no non-isolated mode to fall back to. `LOCAL_ADDRESS` is left empty so the
@ -236,7 +236,7 @@ to all containers — agents and service containers alike.
`HOST_ADDRESS` is the bridge gateway IP (the address part of `HOST_ADDRESS` is the bridge gateway IP (the address part of
`HIVE_NETWORK_SUBNET`, via `lifecycle::bridge_gateway_ip` — taken verbatim `HIVE_NETWORK_SUBNET`, via `lifecycle::bridge_gateway_ip` — taken verbatim
so a non-`.1` operator override still resolves to wherever the bridge so a non-`.1` operator override still resolves to wherever the bridge
actually lives). It is **load-bearing**: nixos-container's container-side actually lives). it's **load-bearing**: nixos-container's container-side
network setup only installs a default route (`ip route add default via network setup only installs a default route (`ip route add default via
$HOST_ADDRESS`) when `HOST_ADDRESS` is non-empty. In bridge mode the $HOST_ADDRESS`) when `HOST_ADDRESS` is non-empty. In bridge mode the
host-side address/route setup is skipped, so writing it only affects the host-side address/route setup is skipped, so writing it only affects the
@ -248,7 +248,7 @@ but no path off the bridge subnet (no internet, no `api.anthropic.com`).
nixos-container copies the **host's** `/etc/resolv.conf` into the container nixos-container copies the **host's** `/etc/resolv.conf` into the container
at every start. The host resolver (e.g. `127.0.0.53` from systemd-resolved, at every start. The host resolver (e.g. `127.0.0.53` from systemd-resolved,
or a LAN router) is unreachable from a private netns and isn't or a LAN router) is unreachable from a private netns and isn't
authoritative for the hive's own zones, so it is replaced with the authoritative for the hive's own zones, so it's replaced with the
bridge dnsmasq at boot. Because the copy happens on every start, a bridge dnsmasq at boot. Because the copy happens on every start, a
declarative `environment.etc."resolv.conf"` would be clobbered — so the declarative `environment.etc."resolv.conf"` would be clobbered — so the
wiring is runtime: wiring is runtime:
@ -257,7 +257,7 @@ wiring is runtime:
gateway IP) into each container's `/etc`. gateway IP) into each container's `/etc`.
- the `hyperhive-isolated-dns` oneshot (`nix/agent-modules/network.nix`), gated on that - the `hyperhive-isolated-dns` oneshot (`nix/agent-modules/network.nix`), gated on that
marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot. marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot.
It is ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and it's ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and
`tea-login` so the resolver is correct before the first DNS lookup. `tea-login` so the resolver is correct before the first DNS lookup.
**Why isolation is safe**: hive-c0re's control-plane sockets are unix **Why isolation is safe**: hive-c0re's control-plane sockets are unix

View file

@ -4,14 +4,14 @@ The swarm's `btrfs receive` endpoint. Hives push agent snapshots to it
over the WireGuard mesh; a destination hive later pulls one back to over the WireGuard mesh; a destination hive later pulls one back to
complete a migration. complete a migration.
Two things it is not, both worth stating because both are common Two things it's not, both worth stating because both are common
assumptions: assumptions:
- **It is not the swarm controller**, and does not depend on one. It is - **it's not the swarm controller**, and doesn't depend on one. it's
a NixOS host role: a btrfs subvolume tree, a socket-activated a NixOS host role: a btrfs subvolume tree, a socket-activated
receiver, and the `wg-hive` interface the swarm module already brings receiver, and the `wg-hive` interface the swarm module already brings
up. That is why it can be deployed before any controller exists. up. that's why it can be deployed before any controller exists.
- **It is not a backup product.** It happens to hold the data a backup - **it's not a backup product.** It happens to hold the data a backup
would hold, and it should be operated accordingly (see would hold, and it should be operated accordingly (see
[Operating it](#operating-it)) --- but nothing in it does scheduling, [Operating it](#operating-it)) --- but nothing in it does scheduling,
verification, or restore orchestration. verification, or restore orchestration.
@ -56,15 +56,15 @@ services.hyperhive.swarm.snapshotStore = {
Two deliberate asymmetries in that pair, both liable to be misread as Two deliberate asymmetries in that pair, both liable to be misread as
inconsistency: inconsistency:
- **`address` has no default.** It is a deployment fact a pushing hive - **`address` has no default.** it's a deployment fact a pushing hive
cannot derive, and a wrong guess means streaming an agent's state at can't derive, and a wrong guess means streaming an agent's state at
whatever happens to answer. Unset, a push fails naming this option. whatever happens to answer. Unset, a push fails naming this option.
- **`port` does default** (`51821`), because it is a convention both - **`port` does default** (`51821`), because it's a convention both
ends read from the same option docs --- a default there is ends read from the same option docs --- a default there is
coordination, not a guess. coordination, not a guess.
Note the option lives under `swarm.*` while the receiving host's lives Note the option lives under `swarm.*` while the receiving host's lives
under `services.hyperhive.snapshotStore`. That is the distinction the under `services.hyperhive.snapshotStore`. that's the distinction the
two namespaces carry throughout: `swarm.*` describes _the swarm_ as seen two namespaces carry throughout: `swarm.*` describes _the swarm_ as seen
from this host, and a bare `services.hyperhive.<service>` describes _a from this host, and a bare `services.hyperhive.<service>` describes _a
role this host performs_. A store host sets both --- one to run the role this host performs_. A store host sets both --- one to run the
@ -78,7 +78,7 @@ and no credential argument, because the mesh is the authentication.
## The mesh is the authentication ## The mesh is the authentication
There are no certificates here, and no key material of its own. That is There are no certificates here, and no key material of its own. that's
deliberate rather than an omission. deliberate rather than an omission.
WireGuard's cryptokey routing already binds a peer's source address to WireGuard's cryptokey routing already binds a peer's source address to
@ -97,16 +97,16 @@ you need to move an agent.
The destination is keyed by **agent**. The destination is keyed by **agent**.
This is not cosmetic. After a migration, an agent's next incremental This isn't cosmetic. After a migration, an agent's next incremental
send arrives from a _different_ hive than the previous one. Keying by send arrives from a _different_ hive than the previous one. Keying by
hive would split that agent's snapshot chain across two directories, hive would split that agent's snapshot chain across two directories,
and `btrfs send -p` would fail to find its parent --- breaking exactly and `btrfs send -p` would fail to find its parent --- breaking exactly
the case the store exists to serve. the case the store exists to serve.
## What the sender can and cannot choose ## What the sender can and can't choose
A `btrfs send` stream carries no notion of _which agent_ it belongs to, A `btrfs send` stream carries no notion of _which agent_ it belongs to,
and the subvolume name inside it is chosen by the sender. So the and the subvolume name inside it's chosen by the sender. So the
protocol is one `agent <name>` header line, then the raw stream. protocol is one `agent <name>` header line, then the raw stream.
The rule that matters: The rule that matters:
@ -116,7 +116,7 @@ The rule that matters:
Validation is an allowlist --- `[A-Za-z0-9_-]+` and nothing else. No Validation is an allowlist --- `[A-Za-z0-9_-]+` and nothing else. No
slash and no dot means neither directory traversal nor an absolute path slash and no dot means neither directory traversal nor an absolute path
can survive it. It is deliberately an allowlist and not a list of can survive it. it's deliberately an allowlist and not a list of
forbidden characters: a blocklist only ever excludes the attacks forbidden characters: a blocklist only ever excludes the attacks
somebody already thought of. somebody already thought of.
@ -128,7 +128,7 @@ address being set are assertions, not documentation --- bound to
`0.0.0.0` this socket is an unauthenticated remote write into agent `0.0.0.0` this socket is an unauthenticated remote write into agent
state. state.
Binding is not sufficient on its own. NixOS's firewall is default-deny Binding isn't sufficient on its own. NixOS's firewall is default-deny
and filters in netfilter, _before_ a packet reaches a bound socket, so and filters in netfilter, _before_ a packet reaches a bound socket, so
the port is opened explicitly --- and scoped to the mesh interface: the port is opened explicitly --- and scoped to the mesh interface:
@ -165,7 +165,7 @@ nobody runs it day to day, so it rots and is discovered broken in
production. production.
⚠️ **The assumption to keep true over time:** the store host runs ⚠️ **The assumption to keep true over time:** the store host runs
nothing else. That is true on day one and quietly false the day someone nothing else. that's true on day one and quietly false the day someone
notices the box has spare disk. Nothing in the config objects when it notices the box has spare disk. Nothing in the config objects when it
stops being true. stops being true.
@ -207,7 +207,7 @@ periodically), not here. Count rather than age is deliberate: a count
is bounded by construction, whereas an age policy silently scales disk is bounded by construction, whereas an age policy silently scales disk
usage with how hot a hive runs. usage with how hot a hive runs.
Per-agent or per-hive `btrfs qgroup` quotas are not configured yet. Per-agent or per-hive `btrfs qgroup` quotas aren't configured yet.
Without them one runaway hive can fill the store and take out every Without them one runaway hive can fill the store and take out every
other hive's snapshots. other hive's snapshots.

View file

@ -126,7 +126,7 @@ request still carries an optional `wait_seconds` (long-poll the first
message, once one arrives — or one is already pending — the call message, once one arrives — or one is already pending — the call
drains up to `max` in total): the harness's own turn-driving loop drains up to `max` in total): the harness's own turn-driving loop
uses it internally (`hive-agent`'s `recv_next`, 180s). The uses it internally (`hive-agent`'s `recv_next`, 180s). The
agent-facing MCP `recv` tool does not expose this parameter — it agent-facing MCP `recv` tool doesn't expose this parameter — it
always passes `wait_seconds: None`, an immediate peek. always passes `wait_seconds: None`, an immediate peek.
Per-row bookkeeping inside the broker: Per-row bookkeeping inside the broker:
@ -484,7 +484,7 @@ must be *fixed*, not silenced. Common patterns:
If a lint seems wrong for a specific call site, file an issue and ask If a lint seems wrong for a specific call site, file an issue and ask
mara — don't add `#[allow]` speculatively. The gate is intentional. mara — don't add `#[allow]` speculatively. The gate is intentional.
**The devshell checks are not the full `nix flake check`.** Clippy / **The devshell checks aren't the full `nix flake check`.** Clippy /
fmt / `cargo test` cover most gates, but `nix flake check` runs extra fmt / `cargo test` cover most gates, but `nix flake check` runs extra
check derivations they don't: check derivations they don't:
@ -535,7 +535,7 @@ Shape contract — every one of these:
1. **Always `exit 0`**, even on internal failure. A non-zero exit 1. **Always `exit 0`**, even on internal failure. A non-zero exit
would mark the unit `failed`, which in turn aborts would mark the unit `failed`, which in turn aborts
`nixos-container update` and blocks rebuilds. The agent's `nixos-container update` and blocks rebuilds. The agent's
capability surface is not allowed to gate the container capability surface isn't allowed to gate the container
build. build.
2. **No `set -e`** in the script body. Subshell failures must not 2. **No `set -e`** in the script body. Subshell failures must not
propagate. Use `... || true` on every external call that can propagate. Use `... || true` on every external call that can

View file

@ -138,7 +138,7 @@ reference-less `-source` (so the runtime closure never arrives), and
pure evaluation rejects. `hyperhive.docs.source` gets away with being pure evaluation rejects. `hyperhive.docs.source` gets away with being
an input only because a docs tree has no runtime dependencies. an input only because a docs tree has no runtime dependencies.
The `storePath` trap is worth spelling out, because it is not confined The `storePath` trap is worth spelling out, because it's not confined
to options the operator writes: **any** option of type `package` fed a to options the operator writes: **any** option of type `package` fed a
store-path _string_ coerces through `lib.toDerivation`, i.e. store-path _string_ coerces through `lib.toDerivation`, i.e.
`builtins.storePath`. `environment.systemPackages` and `builtins.storePath`. `environment.systemPackages` and
@ -150,7 +150,7 @@ farm built from the interpolated string — before it can go anywhere a
package is expected. package is expected.
The catch is that a path written into a generated flake is text, not a The catch is that a path written into a generated flake is text, not a
reference — the container's closure does not keep the binary alive. reference — the container's closure doesn't keep the binary alive.
The **host** does: the package is interpolated into The **host** does: the package is interpolated into
`/etc/hyperhive/serve.json`, so it lands in the host's system closure `/etc/hyperhive/serve.json`, so it lands in the host's system closure
and is gc-rooted by the running generation. `builtins.toJSON` preserves and is gc-rooted by the running generation. `builtins.toJSON` preserves
@ -257,7 +257,7 @@ store files. Any subsequent write into the copied tree (adding new
files in subdirectories) fails with `EPERM`. Fix: pass files in subdirectories) fails with `EPERM`. Fix: pass
`--no-preserve=mode,ownership` so the output tree is writable. `--no-preserve=mode,ownership` so the output tree is writable.
### `nix build flake#name` does not walk into `nixosConfigurations` ### `nix build flake#name` doesn't walk into `nixosConfigurations`
`nix build` resolves the fragment (`#name`) against the flake's `nix build` resolves the fragment (`#name`) against the flake's
**top-level output attrs** — not against `nixosConfigurations` **top-level output attrs** — not against `nixosConfigurations`
@ -339,7 +339,7 @@ error: getting Git object '<hash>': object not found (libgit2 error code = 9)
when a commit that was reachable at some earlier evaluation is now gone when a commit that was reachable at some earlier evaluation is now gone
(GC'd, rebased away, or pruned). The failure is persistent: clearing (GC'd, rebased away, or pruned). The failure is persistent: clearing
`~/.cache/nix/{eval-cache-v6,gitv3,fetcher-cache-v4.sqlite}` does not `~/.cache/nix/{eval-cache-v6,gitv3,fetcher-cache-v4.sqlite}` doesn't
help because the missing object is a structural gap in the git object help because the missing object is a structural gap in the git object
graph itself, not in nix's caches. graph itself, not in nix's caches.
@ -410,7 +410,7 @@ connects to the compositor at `127.0.0.1:<vnc_port>`.
and `XDG_RUNTIME_DIR=/run/gui` as global system environment and `XDG_RUNTIME_DIR=/run/gui` as global system environment
variables (gated on `hyperhive.gui.enable`) so every systemd service variables (gated on `hyperhive.gui.enable`) so every systemd service
in the container inherits them. Without this, services starting in the container inherits them. Without this, services starting
Wayland clients could not find the compositor — libwayland falls Wayland clients couldn't find the compositor — libwayland falls
back to a headless display or errors out, the app "works" on a back to a headless display or errors out, the app "works" on a
second invisible display, and the VNC session shows a blank weston second invisible display, and the VNC session shows a blank weston
desktop (#540 double-screen). desktop (#540 double-screen).
@ -432,7 +432,7 @@ connects to the compositor at `127.0.0.1:<vnc_port>`.
- **`Type = "simple"` (not `notify`)**: `switch-to-configuration` - **`Type = "simple"` (not `notify`)**: `switch-to-configuration`
must never block on weston signalling readiness. A misconfigured must never block on weston signalling readiness. A misconfigured
weston degrades to a `Restart=on-failure` loop visible in weston degrades to a `Restart=on-failure` loop visible in
`journalctl`, it does not abort the `nixos-container update`. `journalctl`, it doesn't abort the `nixos-container update`.
Same reasoning as the `tea-login` unit in `nix/agent-modules/forge.nix`. Same reasoning as the `tea-login` unit in `nix/agent-modules/forge.nix`.
- **`[core] idle-time=0`**: disables weston's 300-second idle - **`[core] idle-time=0`**: disables weston's 300-second idle
timeout. Without it the VNC desktop fades to black and timeout. Without it the VNC desktop fades to black and

View file

@ -5,7 +5,7 @@ executing CI jobs from `.forgejo/workflows/ci.yml` on every PR.
## For operators ## For operators
**Enabling it is one line**: `services.hyperhive.deploy.forgejo.ci.enable = true` **Enabling it's one line**: `services.hyperhive.deploy.forgejo.ci.enable = true`
in the host NixOS config. No manual token provisioning — hive-c0re in the host NixOS config. No manual token provisioning — hive-c0re
registers the runner with the forge automatically. registers the runner with the forge automatically.
@ -45,7 +45,7 @@ blocks merge.
`hive-forge ci-rerun --pr N` dispatches a `workflow_dispatch` retrigger `hive-forge ci-rerun --pr N` dispatches a `workflow_dispatch` retrigger
without an empty commit. without an empty commit.
**`ci-rerun --pr` verifies the code but does not reliably move the PR's own **`ci-rerun --pr` verifies the code but doesn't reliably move the PR's own
status checks.** Measured directly (raw `GET /repos/.../actions/tasks` status checks.** Measured directly (raw `GET /repos/.../actions/tasks`
JSON): a genuine PR-triggered run carries `event: pull_request` with the JSON): a genuine PR-triggered run carries `event: pull_request` with the
`#<n>` pseudo-ref as its `head_branch`; a `ci-rerun --pr`-dispatched run `#<n>` pseudo-ref as its `head_branch`; a `ci-rerun --pr`-dispatched run
@ -95,12 +95,12 @@ hive-forge instance to register against — nothing extra to enable beyond
Optional tuning: `services.hyperhive.deploy.forgejo.ci.name` (runner name in forge Optional tuning: `services.hyperhive.deploy.forgejo.ci.name` (runner name in forge
admin panel), `concurrency` (parallel job capacity), `labels` (workflow admin panel), `concurrency` (parallel job capacity), `labels` (workflow
targeting), `jobTimeout` (per-job wall-clock cap, default `"1h"`, Go duration targeting), `jobTimeout` (per-job wall-clock cap, default `"1h"`, Go duration
string e.g. `"3h"` — a job that exceeds it is killed so a hung or runaway string e.g. `"3h"` — a job that exceeds it's killed so a hung or runaway
build can't hold the runner's single slot indefinitely). build can't hold the runner's single slot indefinitely).
## Container design ## Container design
- **Private netns, bridge-attached**: the container runs in its own network namespace (`privateNetwork = true`, `hostBridge`) and reaches hive-forge through the gateway at `http://<forge.domain>` (resolved to the bridge IP via `networking.extraHosts`). It cannot reach host-loopback services — the core dashboard at `127.0.0.1:7000` and the raw forge port are unreachable from CI. Requires `deploy.forgejo.behindGateway = true`. - **Private netns, bridge-attached**: the container runs in its own network namespace (`privateNetwork = true`, `hostBridge`) and reaches hive-forge through the gateway at `http://<forge.domain>` (resolved to the bridge IP via `networking.extraHosts`). It can't reach host-loopback services — the core dashboard at `127.0.0.1:7000` and the raw forge port are unreachable from CI. Requires `deploy.forgejo.behindGateway = true`.
- **Non-ephemeral**: runner credentials persist across restarts (written to container's stateDir on first registration, reused thereafter). - **Non-ephemeral**: runner credentials persist across restarts (written to container's stateDir on first registration, reused thereafter).
- **Sandbox fallback**: nspawn containers can't create user-namespaces, so nix's sandboxing would always fail. Module sets `nix.settings.sandbox-fallback = true` in the container — nix builds run unsandboxed (safe because the container is already isolated). See `docs/process/gotchas.md`. - **Sandbox fallback**: nspawn containers can't create user-namespaces, so nix's sandboxing would always fail. Module sets `nix.settings.sandbox-fallback = true` in the container — nix builds run unsandboxed (safe because the container is already isolated). See `docs/process/gotchas.md`.
- **Credential isolation**: the forge admin token (`forge-core-token`) never enters the container. hive-c0re holds it and performs all forge API calls (runner validation + registration-token mint, in `forge/ci_runner.rs`); via hive-priv it writes only the runner registration token to the host env-file `/run/hive-ci/runner-token`, which the container bind-mounts read-only. - **Credential isolation**: the forge admin token (`forge-core-token`) never enters the container. hive-c0re holds it and performs all forge API calls (runner validation + registration-token mint, in `forge/ci_runner.rs`); via hive-priv it writes only the runner registration token to the host env-file `/run/hive-ci/runner-token`, which the container bind-mounts read-only.
@ -130,7 +130,7 @@ Gated on `HYPERHIVE_FORGE_CI_ENABLED` (the nix module sets it on `hive-c0re.serv
- The container boots immediately — nothing gates its start on registration. - The container boots immediately — nothing gates its start on registration.
- tmpfiles seeds `/run/hive-ci/runner-token` with `TOKEN=placeholder` so the runner's `EnvironmentFile` always exists. - tmpfiles seeds `/run/hive-ci/runner-token` with `TOKEN=placeholder` so the runner's `EnvironmentFile` always exists.
- `gitea-runner-hive.service` has an `ExecStartPre` precond (ahead of the nix-daemon wait) that **fails fast** unless it is already registered (`.runner` present) or a real, non-placeholder token is in place. `Restart=on-failure` (no start-limit cap) self-heals it: a runner that precond-fails at boot keeps retrying until hive-c0re writes the token (c0re's explicit restart is the primary path; the retry is the safety net). - `gitea-runner-hive.service` has an `ExecStartPre` precond (ahead of the nix-daemon wait) that **fails fast** unless it's already registered (`.runner` present) or a real, non-placeholder token is in place. `Restart=on-failure` (no start-limit cap) self-heals it: a runner that precond-fails at boot keeps retrying until hive-c0re writes the token (c0re's explicit restart is the primary path; the retry is the safety net).
- **Convergence**: because the token write targets the _host_ file, even if c0re's restart races the container being down, the container later starts, reads the now-real token, passes the precond, and registers on its own. - **Convergence**: because the token write targets the _host_ file, even if c0re's restart races the container being down, the container later starts, reads the now-real token, passes the precond, and registers on its own.
## Actions checkout mirror ## Actions checkout mirror
@ -173,16 +173,16 @@ to avoid provisioning collisions.
### What unsandboxed builds mean ### What unsandboxed builds mean
nspawn containers cannot create user-namespaces, so `nix.settings.sandbox-fallback = true` is set in the container. This means every `nix build` (and `nix flake check`) runs **without a build sandbox** — the build process has full access to the container filesystem, network, and any bind-mounts during the build phase. nspawn containers can't create user-namespaces, so `nix.settings.sandbox-fallback = true` is set in the container. This means every `nix build` (and `nix flake check`) runs **without a build sandbox** — the build process has full access to the container filesystem, network, and any bind-mounts during the build phase.
A malicious `default.nix` or build script in a PR can therefore: A malicious `default.nix` or build script in a PR can therefore:
- **Make arbitrary network requests** to any address reachable from the container. The container runs in its own netns behind the hive bridge, so it reaches the forge only through the gateway (`http://<forge.domain>`, public/read endpoints — no admin credentials) and **cannot** reach host-loopback services: the unauthenticated core dashboard at `127.0.0.1:7000` and the raw forge port are off-limits (bridge→127.0.0.0/8 is dropped). - **Make arbitrary network requests** to any address reachable from the container. The container runs in its own netns behind the hive bridge, so it reaches the forge only through the gateway (`http://<forge.domain>`, public/read endpoints — no admin credentials) and **can't** reach host-loopback services: the unauthenticated core dashboard at `127.0.0.1:7000` and the raw forge port are off-limits (bridge→127.0.0.0/8 is dropped).
- **Write to the container filesystem**, including corrupting the runner's state dir or `.runner` credentials. - **Write to the container filesystem**, including corrupting the runner's state dir or `.runner` credentials.
The core admin token (`forge-core-token`) is **not** bind-mounted into the container. It is held and used only by hive-c0re on the host (`forge/ci_runner.rs`), which mints per-runner registration tokens; only that registration token reaches the container's env-file. A build process can still reach forge over the network, but cannot use the admin token to issue privileged API calls. The core admin token (`forge-core-token`) is **not** bind-mounted into the container. it's held and used only by hive-c0re on the host (`forge/ci_runner.rs`), which mints per-runner registration tokens; only that registration token reaches the container's env-file. A build process can still reach forge over the network, but can't use the admin token to issue privileged API calls.
Note: `nix flake check --no-build` (eval-only) reduces the attack surface but does not eliminate it — `builtins.fetchGit`, `builtins.fetchurl`, and import-from-derivation can reach the network and filesystem during evaluation. The default CI workflow runs full `nix flake check` (builds derivations), which is the higher-risk path. Note: `nix flake check --no-build` (eval-only) reduces the attack surface but doesn't eliminate it — `builtins.fetchGit`, `builtins.fetchurl`, and import-from-derivation can reach the network and filesystem during evaluation. The default CI workflow runs full `nix flake check` (builds derivations), which is the higher-risk path.
### Mitigation ### Mitigation

View file

@ -35,7 +35,7 @@ A DAG is **declared, not described**: a template builds it through
`hive_jobq::JobBuilder`, naming each node it depends on via the handle `hive_jobq::JobBuilder`, naming each node it depends on via the handle
`b.node(kind)` handed back, and the builder inserts the nodes itself. A handle `b.node(kind)` handed back, and the builder inserts the nodes itself. A handle
only exists for a node already declared, so every edge points backwards and a only exists for a node already declared, so every edge points backwards and a
cycle cannot be written down — there is no submit-time validation pass, because cycle can't be written down — there is no submit-time validation pass, because
there is no malformed spec to reject. there is no malformed spec to reject.
### Node inventory (primitives) ### Node inventory (primitives)
@ -100,9 +100,9 @@ container build:
`DeployWindow` — the deploy subtree's root, which holds it across every `DeployWindow` — the deploy subtree's root, which holds it across every
phase below it (it declares `Resource::MetaWindow`). Two meta phase below it (it declares `Resource::MetaWindow`). Two meta
mutations can therefore never interleave, so no commit lands inside another mutations can therefore never interleave, so no commit lands inside another
node's staged window. It is a queue resource rather than a runtime mutex node's staged window. it's a queue resource rather than a runtime mutex
because a resource is held by a subtree root across its whole subtree, which because a resource is held by a subtree root across its whole subtree, which
a `MutexGuard` (bounded by one executor fn) cannot — that is what lets a a `MutexGuard` (bounded by one executor fn) can't — that's what lets a
multi-node deploy own one window. For the same reason the window must stay multi-node deploy own one window. For the same reason the window must stay
_off_ long store-only work: the rebuild's meta preamble is its own _off_ long store-only work: the rebuild's meta preamble is its own
`MetaSync` node, a sibling of (never a parent of) `Prebuild`, so the `MetaSync` node, a sibling of (never a parent of) `Prebuild`, so the
@ -121,7 +121,7 @@ The `stop` / `start` power ops write the durable `wanted` intent via a head
so intent-write + reconcile is atomic per-agent. `restart` is the exception: so intent-write + reconcile is atomic per-agent. `restart` is the exception:
it writes _no_ intent (no `SetWanted` head) — it bounces the container and it writes _no_ intent (no `SetWanted` head) — it bounces the container and
lets the tail `Reconcile` converge to the agent's existing `wanted`, so a lets the tail `Reconcile` converge to the agent's existing `wanted`, so a
deliberately-stopped agent is not forced back up by a hive-wide restart. The deliberately-stopped agent isn't forced back up by a hive-wide restart. The
hive-wide power ops — `restart`, `stop`, and `start` — take an agent _list_: hive-wide power ops — `restart`, `stop`, and `start` — take an agent _list_:
a hive-wide `hivectl restart` / `stop` / `start` is ONE DAG with a per-agent a hive-wide `hivectl restart` / `stop` / `start` is ONE DAG with a per-agent
subgraph each (independent roots, run concurrently on their own leases), not subgraph each (independent roots, run concurrently on their own leases), not
@ -130,7 +130,7 @@ N separate DAGs.
**These are built dynamically from each agent's live running state** (an **These are built dynamically from each agent's live running state** (an
async `lifecycle::is_running` read), so they live in `job_queue/power.rs`, async `lifecycle::is_running` read), so they live in `job_queue/power.rs`,
not the pure/sync `templates.rs`. Per-agent shape rule: `stop`/`start` carry not the pure/sync `templates.rs`. Per-agent shape rule: `stop`/`start` carry
a head `SetWanted` (intent) — `restart` does not; the tail `Reconcile` a head `SetWanted` (intent) — `restart` doesn't; the tail `Reconcile`
(convergence guarantee — cheap, noops when already converged) is ALWAYS (convergence guarantee — cheap, noops when already converged) is ALWAYS
present; only the _mechanical_ nodes (`Signal`/`Drain`/`StopForUpdate`) are present; only the _mechanical_ nodes (`Signal`/`Drain`/`StopForUpdate`) are
state-conditional — skipped for a _down_ agent (nothing to quiesce/stop). Keeping `Reconcile` in every shape state-conditional — skipped for a _down_ agent (nothing to quiesce/stop). Keeping `Reconcile` in every shape
@ -237,7 +237,7 @@ than to one DAG shape it happens to appear in. A **brace** is the one sanctioned
exception: a pure-resource-holder root that declares on behalf of a subtree exception: a pure-resource-holder root that declares on behalf of a subtree
coordinated with itself, whose members then declare nothing. coordinated with itself, whose members then declare nothing.
It is forced rather than stylistic. Declaring a resource means _"I need this it's forced rather than stylistic. Declaring a resource means _"I need this
exclusively"_, and the agent lease is single-unit — so **two siblings that both exclusively"_, and the agent lease is single-unit — so **two siblings that both
declared it could never run concurrently.** For a subtree whose whole point is declared it could never run concurrently.** For a subtree whose whole point is
concurrency (`Prebuild` beside the `Signal` → `Drain` quiesce window), declaring concurrency (`Prebuild` beside the `Signal` → `Drain` quiesce window), declaring
@ -246,7 +246,7 @@ mutually exclusive. One holder above them speaks for the subtree.
This is the opposite of the failure the declare-your-own rule exists to prevent, This is the opposite of the failure the declare-your-own rule exists to prevent,
not a relapse into it: there the requirement was _implicit_, inferred from a not a relapse into it: there the requirement was _implicit_, inferred from a
node's kind and true only by accident of placement. Here it is explicit, on one node's kind and true only by accident of placement. Here it's explicit, on one
node, with the omission below it documented on the brace itself. node, with the omission below it documented on the brace itself.
Two consequences worth knowing: Two consequences worth knowing:
@ -260,7 +260,7 @@ Two consequences worth knowing:
_payload_ agent, not off a declared lease edge, so every child still lights its _payload_ agent, not off a declared lease edge, so every child still lights its
own dashboard pill and still reports its own `takes_container_down` to the own dashboard pill and still reports its own `takes_container_down` to the
crash watcher. A brace itself reports `false`: it parents the stopping nodes crash watcher. A brace itself reports `false`: it parents the stopping nodes
but does not stop anything, and claiming otherwise would widen crash but doesn't stop anything, and claiming otherwise would widen crash
suppression across the build and tail, where a vanished container is still a suppression across the build and tail, where a vanished container is still a
real crash. real crash.
@ -349,7 +349,7 @@ the event's whole job.
## Container view ## Container view
`container_view.rs` maintains an in-memory snapshot of every nixos-container's `container_view.rs` maintains an in-memory snapshot of every nixos-container's
systemd service state. It is polled on coordinator startup and re-scanned after systemd service state. it's polled on coordinator startup and re-scanned after
every lifecycle operation (spawn, rebuild, kill) so the dashboard always reflects every lifecycle operation (spawn, rebuild, kill) so the dashboard always reflects
the actual container status without a live `nixos-container list` call on each the actual container status without a live `nixos-container list` call on each
render. render.
@ -429,7 +429,7 @@ Sequence for a rebuild DAG (each step is its own queue node):
1. `MetaSync` — rebuild-dir prep, meta `sync_agents`, and (unless this is a 1. `MetaSync` — rebuild-dir prep, meta `sync_agents`, and (unless this is a
meta-update cascade child) the per-agent relock. Short, and the only step meta-update cascade child) the per-agent relock. Short, and the only step
that mutates the meta repo, so it is the only one holding the global deploy that mutates the meta repo, so it's the only one holding the global deploy
window. window.
2. `Prebuild` — build the new `system.build.toplevel` **before** stopping. 2. `Prebuild` — build the new `system.build.toplevel` **before** stopping.
The container keeps serving the previous generation while eval + fetch + The container keeps serving the previous generation while eval + fetch +
@ -446,11 +446,11 @@ Sequence for a rebuild DAG (each step is its own queue node):
slot, so the next DAG's `Prebuild` overlaps the container boot. slot, so the next DAG's `Prebuild` overlaps the container boot.
The approval deploy uses this same chain rather than a rebuild path of its The approval deploy uses this same chain rather than a rebuild path of its
own. Its `DeployApply` node does not build: it merges, opens the two-phase own. Its `DeployApply` node doesn't build: it merges, opens the two-phase
meta deploy, and returns the chain above as a subgraph the scheduler grafts meta deploy, and returns the chain above as a subgraph the scheduler grafts
into the live DAG under that node. A `FinalizeDeploy` node gated on the into the live DAG under that node. A `FinalizeDeploy` node gated on the
graft's completion then plants the deploy tag — so "did the agent come back graft's completion then plants the deploy tag — so "did the agent come back
up?" is answered by `Reconcile` succeeding, the same way it is for every up?" is answered by `Reconcile` succeeding, the same way it's for every
other rebuild, instead of by a fused inline start. other rebuild, instead of by a fused inline start.
The grafted nodes land _inside_ `DeployWindow`'s subtree, so they re-enter The grafted nodes land _inside_ `DeployWindow`'s subtree, so they re-enter
@ -479,7 +479,7 @@ flags → `systemctl daemon-reload` → `start`.
### Prebuild attr path ### Prebuild attr path
`nix build` does not auto-resolve `meta#<name>` against `nixosConfigurations` the `nix build` doesn't auto-resolve `meta#<name>` against `nixosConfigurations` the
way `nixos-container` does internally. The explicit attr path way `nixos-container` does internally. The explicit attr path
`<flake-root>#nixosConfigurations.<name>.config.system.build.toplevel` is required; `<flake-root>#nixosConfigurations.<name>.config.system.build.toplevel` is required;
using the bare `meta#<name>` ref would make nix look in `packages`, `legacyPackages`, using the bare `meta#<name>` ref would make nix look in `packages`, `legacyPackages`,
@ -523,11 +523,11 @@ wants it.
| `services.hyperhive.c0re.agentCpuQuota` | `"200%"` | CPU cap per agent, as a percentage of one core (`"200%"` = 2 cores). Raise if agents hit CPU limits during builds or heavy tool use. | | `services.hyperhive.c0re.agentCpuQuota` | `"200%"` | CPU cap per agent, as a percentage of one core (`"200%"` = 2 cores). Raise if agents hit CPU limits during builds or heavy tool use. |
| `services.hyperhive.c0re.agentMemoryMax` | `"4G"` | Memory cap per agent. Raise for agents that run large nix builds or hold big in-memory data. | | `services.hyperhive.c0re.agentMemoryMax` | `"4G"` | Memory cap per agent. Raise for agents that run large nix builds or hold big in-memory data. |
| `services.hyperhive.c0re.agentCpuWeight` | `80` | `cpu.weight` share per agent, `1`–`10000` or `null` to omit the setting. Kernel default is `100`, so `80` makes agents yield. | | `services.hyperhive.c0re.agentCpuWeight` | `80` | `cpu.weight` share per agent, `1`–`10000` or `null` to omit the setting. Kernel default is `100`, so `80` makes agents yield. |
| `services.hyperhive.c0re.agentIoWeight` | `80` | `io.weight` share per agent, same range and `null` handling. See the caveat below — it is a no-op on many hosts. | | `services.hyperhive.c0re.agentIoWeight` | `80` | `io.weight` share per agent, same range and `null` handling. See the caveat below — it's a no-op on many hosts. |
Two things to know about the weights: Two things to know about the weights:
- They are **hive-wide** — unlike the caps there is no per-agent - they're **hive-wide** — unlike the caps there is no per-agent
override in `meta/resource-limits.json`, so every agent carries the override in `meta/resource-limits.json`, so every agent carries the
same value and the weight does _not_ rank agents against each other. same value and the weight does _not_ rank agents against each other.
What `80` buys is that agents yield to everything **not** on this What `80` buys is that agents yield to everything **not** on this

View file

@ -33,33 +33,33 @@ swarm's store. No agent holds a copy, and neither does this hive.
The hive collector reaches the swarm collector by its gateway name The hive collector reaches the swarm collector by its gateway name
(`swarm.otel.domain`, default `otel.<swarm domain>`) — the same DNS-and-CA-trust (`swarm.otel.domain`, default `otel.<swarm domain>`) — the same DNS-and-CA-trust
shape every hive-to-swarm-service hop uses, not a URL an operator has to point shape every hive-to-swarm-service hop uses, not a URL an operator has to point
anywhere. A hive that does not run the swarm's services still resolves that anywhere. A hive that doesn't run the swarm's services still resolves that
name through the gateway; nothing here needs setting for the split-host case. name through the gateway; nothing here needs setting for the split-host case.
⚠️ **The collector is therefore in the path of all telemetry.** It runs on the ⚠️ **The collector is therefore in the path of all telemetry.** It runs on the
same host as the agents and restarts on failure, and telemetry is not the same host as the agents and restarts on failure, and telemetry isn't the
control plane — degraded telemetry is not degraded operation — but the export control plane — degraded telemetry isn't degraded operation — but the export
no longer survives independently of anything host-side. no longer survives independently of anything host-side.
### what the agent→collector hop is and isn't ### what the agent→collector hop is and isn't
**It has no application-level auth.** The receiver takes any OTLP that reaches **It has no application-level auth.** The receiver takes any OTLP that reaches
it; what bounds who can reach it is the firewall — `exposeHostPorts` opens the it; what bounds who can reach it's the firewall — `exposeHostPorts` opens the
port on the bridge interface only. So "unauthenticated to a bridge address" port on the bridge interface only. So "unauthenticated to a bridge address"
means _reachable from an agent container_, not _presents a credential_. means _reachable from an agent container_, not _presents a credential_.
The consequence, stated because it is a choice rather than an oversight: **any The consequence, stated because it's a choice rather than an oversight: **any
agent can push arbitrary OTLP, and it is forwarded on under the operator's agent can push arbitrary OTLP, and it's forwarded on under the operator's
credential.** Neither tier can tell a container's genuine Claude Code stats credential.** Neither tier can tell a container's genuine Claude Code stats
from anything else shaped like OTLP arriving on that port — including data from anything else shaped like OTLP arriving on that port — including data
smuggled out in resource attributes on an otherwise-legitimate export. smuggled out in resource attributes on an otherwise-legitimate export.
That is a **different risk from the one the collector fixes**, and strictly that's a **different risk from the one the collector fixes**, and strictly
smaller than what preceded it: before, every agent held the upstream credential smaller than what preceded it: before, every agent held the upstream credential
itself, so it could do all of the above _and_ use the token anywhere else. The itself, so it could do all of the above _and_ use the token anywhere else. The
collector removes the token and keeps the pipe. Agents are inside the trust collector removes the token and keeps the pipe. Agents are inside the trust
boundary (`docs/trust-boundary/security.md`: capability = accepted risk), so an agent being boundary (`docs/trust-boundary/security.md`: capability = accepted risk), so an agent being
able to _send_ is an accepted extension of that boundary — but it is not able to _send_ is an accepted extension of that boundary — but it's not
closed by this design, and nothing here should be read as closing it. closed by this design, and nothing here should be read as closing it.
**The `agent` label is self-reported, and no planned authentication changes **The `agent` label is self-reported, and no planned authentication changes
@ -76,7 +76,7 @@ is getting a credential:
`hive=` from the connection it authenticated, so _that_ label becomes `hive=` from the connection it authenticated, so _that_ label becomes
unforgeable. unforgeable.
So a verified `hive` is reachable and a verified `agent` is not — and that falls So a verified `hive` is reachable and a verified `agent` isn't — and that falls
out of the topology rather than being a gap someone forgot to close. The swarm out of the topology rather than being a gap someone forgot to close. The swarm
runs one collector, and the mechanism gives it no finer grain: a bearer-token runs one collector, and the mechanism gives it no finer grain: a bearer-token
check never reveals _which_ token matched, and a receiver reads request metadata check never reveals _which_ token matched, and a receiver reads request metadata
@ -91,7 +91,7 @@ Every `services.hyperhive.otel.*` option's full type/default/description/
example is generated straight from the nix module (`nix/host-modules/ example is generated straight from the nix module (`nix/host-modules/
otel.nix`) into [`/options/`](/options/) (host options — `nix build otel.nix`) into [`/options/`](/options/) (host options — `nix build
.#docs-host` for a local render). That page is kept honest by the build in a .#docs-host` for a local render). That page is kept honest by the build in a
way a hand-copied version here cannot be, so it is the reference, not this way a hand-copied version here can't be, so it's the reference, not this
doc. What follows is what a flat per-option listing can't express: the doc. What follows is what a flat per-option listing can't express: the
two-tier architecture, the security model, and how the options interact. two-tier architecture, the security model, and how the options interact.
@ -120,9 +120,9 @@ every agent needs the credential to authenticate — and the harness delivers
that token into the agent's own `~/.claude/settings.json`, a file the agent can that token into the agent's own `~/.claude/settings.json`, a file the agent can
read. `0600` protects it from other containers, not from the agent itself. As read. `0600` protects it from other containers, not from the agent itself. As
long as the direct path stays _selectable_, that hole stays selectable; an long as the direct path stays _selectable_, that hole stays selectable; an
option that can reintroduce it is a hole with extra steps. option that can reintroduce it's a hole with extra steps.
**Why the tiers stay separate on one box.** They are not collapsed when **Why the tiers stay separate on one box.** they're not collapsed when
co-located: an all-local hive is a statement about _where_ processes run, not co-located: an all-local hive is a statement about _where_ processes run, not
about the shape of the deployment. A boundary that disappears locally is one about the shape of the deployment. A boundary that disappears locally is one
the local deployment stops testing. the local deployment stops testing.
@ -136,14 +136,14 @@ automatically; there is nothing to open by hand.
### Authenticated ingest ### Authenticated ingest
The swarm tier gives **each hive its own receiver**, and stamps the `hive` label The swarm tier gives **each hive its own receiver**, and stamps the `hive` label
from whichever receiver accepted a sample. A hive therefore cannot report from whichever receiver accepted a sample. A hive therefore can't report
metrics as another hive, and cannot relabel its own by editing what it sends — metrics as another hive, and can't relabel its own by editing what it sends —
the label is not taken from the payload at all. the label isn't taken from the payload at all.
**On an all-local swarm there is nothing to set.** Each hive already has an **On an all-local swarm there is nothing to set.** Each hive already has an
identity, and its collector reads the secret that host's own authelia minted. identity, and its collector reads the secret that host's own authelia minted.
**On a hive that does not host the swarm's services**, the secret has to arrive **On a hive that doesn't host the swarm's services**, the secret has to arrive
somehow — copy it across and name it: somehow — copy it across and name it:
```nix ```nix
@ -286,7 +286,7 @@ distinguishable from "this hive is gone". Select them with
Both levels are reported every cycle, `0` included, so a healthy hive is Both levels are reported every cycle, `0` included, so a healthy hive is
visible as zeros rather than as missing series. visible as zeros rather than as missing series.
`hyperhive.hive.degraded` is what a dashboard should alert on: it is `hyperhive.hive.degraded` is what a dashboard should alert on: it's
`hive-c0re`'s own readiness verdict, so it stays in step with `/health/ready` `hive-c0re`'s own readiness verdict, so it stays in step with `/health/ready`
and with what the swarm controller sees. `hyperhive.hive.warnings` is the and with what the swarm controller sees. `hyperhive.hive.warnings` is the
detail behind it — `warn`-level entries mean "an operator should look" and do detail behind it — `warn`-level entries mean "an operator should look" and do
@ -341,7 +341,7 @@ hive-metric <name> <value> [--type counter|gauge] [--temporality delta|cumulativ
itself). itself).
- `--labels key=value` — extra per-data-point labels. May be repeated. - `--labels key=value` — extra per-data-point labels. May be repeated.
The resource labels (agent, hive, swarm, service.name) are inherited The resource labels (agent, hive, swarm, service.name) are inherited
automatically from `OTEL_RESOURCE_ATTRIBUTES` — do not re-specify them. automatically from `OTEL_RESOURCE_ATTRIBUTES` — don't re-specify them.
### Examples ### Examples
@ -356,10 +356,10 @@ hive-metric queue_depth 17 --type gauge
hive-metric api_latency_ms 142.5 --type gauge --labels model=sonnet --labels tier=api hive-metric api_latency_ms 142.5 --type gauge --labels model=sonnet --labels tier=api
``` ```
### Error when OTEL is not configured ### Error when OTEL isn't configured
When `services.hyperhive.otel.enable = false` (the default), the When `services.hyperhive.otel.enable = false` (the default), the
`OTEL_EXPORTER_OTLP_ENDPOINT` env var is not set and `hive-metric` exits `OTEL_EXPORTER_OTLP_ENDPOINT` env var isn't set and `hive-metric` exits
with an informative error message. No silently-dropped metrics. with an informative error message. No silently-dropped metrics.
### Wire format ### Wire format

View file

@ -47,14 +47,14 @@ and deploys — an eval failure asking the operator to write the address
down is the cheaper outcome. **Upgrading past this release means setting down is the cheaper outcome. **Upgrading past this release means setting
both once.** both once.**
`domain` is required too, but you no longer _write_ it: it is read from `domain` is required too, but you no longer _write_ it: it's read from
this hive's own entry in the directory, whose `domain` defaults to this hive's own entry in the directory, whose `domain` defaults to
`<name>.<swarm.domain>`. So a conventional swarm states no addresses at `<name>.<swarm.domain>`. So a conventional swarm states no addresses at
all, and a hive addressed by something else states it in the one place all, and a hive addressed by something else states it in the one place
the other hives read — `swarm.hives.edge.domain = "edge.elsewhere.example";`. the other hives read — `swarm.hives.edge.domain = "edge.elsewhere.example";`.
Setting `services.hyperhive.domain` directly still works and still wins, Setting `services.hyperhive.domain` directly still works and still wins,
with a **deprecation warning**. The reason it's deprecated is not tidiness: with a **deprecation warning**. The reason it's deprecated isn't tidiness:
that option is local to one host, the directory is copied to every host, that option is local to one host, the directory is copied to every host,
so a value written only there leaves every peer pointing somewhere else so a value written only there leaves every peer pointing somewhere else
with nothing detecting the disagreement. with nothing detecting the disagreement.
@ -70,8 +70,8 @@ form qualified labels (`iris@pr1ma.example.com`).
`swarm.name` is purely display — it surfaces in the dashboard chrome `swarm.name` is purely display — it surfaces in the dashboard chrome
header and per-agent system prompts, and federated hives at different header and per-agent system prompts, and federated hives at different
domains can share one. `hiveName` surfaces in the same places but is domains can share one. `hiveName` surfaces in the same places but is
_not_ only display: it is the leftmost label of the hive's domain. That _not_ only display: it's the leftmost label of the hive's domain. That
`swarm.name` sits under `swarm` and `hiveName` does not is the whole `swarm.name` sits under `swarm` and `hiveName` doesn't is the whole
distinction — one names this hive, the other names the group it belongs distinction — one names this hive, the other names the group it belongs
to. to.
@ -120,7 +120,7 @@ services.hyperhive.swarm.hives = {
``` ```
One attrset describing **every** hive in the swarm, **including this One attrset describing **every** hive in the swarm, **including this
one**, keyed by that hive's `hiveName`. It is meant to be _identical on one**, keyed by that hive's `hiveName`. it's meant to be _identical on
every host_ — write it once, share it, and each host reads it correctly every host_ — write it once, share it, and each host reads it correctly
because `services.hyperhive.hiveName` says which entry is itself. because `services.hyperhive.hiveName` says which entry is itself.
@ -130,7 +130,7 @@ hive. That assertion is load-bearing rather than pedantic — "my peers"
is derived as _everything that isn't me_, so a directory that doesn't is derived as _everything that isn't me_, so a directory that doesn't
contain you derives every hive as a peer and you peer with yourself. contain you derives every hive as a peer and you peer with yourself.
`domain` is required per entry and deliberately undefaulted: it is `domain` is required per entry and deliberately undefaulted: it's
conventionally `<name>.<swarm.domain>`, but a wrong domain that conventionally `<name>.<swarm.domain>`, but a wrong domain that
evaluates cleanly points at a real machine that isn't the one you meant. evaluates cleanly points at a real machine that isn't the one you meant.
@ -138,7 +138,7 @@ evaluates cleanly points at a real machine that isn't the one you meant.
> inside a swarm comes from the swarm root ([`ca.md`](ca.md)): every > inside a swarm comes from the swarm root ([`ca.md`](ca.md)): every
> hive chains to it, so one anchor replaces per-hive pinning entirely. > hive chains to it, so one anchor replaces per-hive pinning entirely.
> What that genuinely drops is trusting a hive whose root this swarm > What that genuinely drops is trusting a hive whose root this swarm
> does _not_ own — another swarm's, or one keeping its own CA. That is > does _not_ own — another swarm's, or one keeping its own CA. that's
> a cross-swarm problem and wants a mechanism designed for it. (An > a cross-swarm problem and wants a mechanism designed for it. (An
> earlier `certFingerprint` field existed for exactly that gap, pinning > earlier `certFingerprint` field existed for exactly that gap, pinning
> a peer's TLS leaf for hive-c0re's own peer HTTPS checks — removed > a peer's TLS leaf for hive-c0re's own peer HTTPS checks — removed
@ -160,7 +160,7 @@ evaluates cleanly points at a real machine that isn't the one you meant.
**container's** trust bundle, independent of this directory. **container's** trust bundle, independent of this directory.
⚠️ **That container currently trusts no swarm-internal CA**, so a ⚠️ **That container currently trusts no swarm-internal CA**, so a
self-signed gateway certificate does not federate. The swarm root self-signed gateway certificate doesn't federate. The swarm root
can't be listed there: `security.pki.certificateFiles` is can't be listed there: `security.pki.certificateFiles` is
read when the system is _built_, and the root is a runtime file (its read when the system is _built_, and the root is a runtime file (its
key must never enter the store), so there is no build-time name for key must never enter the store), so there is no build-time name for
@ -183,7 +183,7 @@ hiveName = "pr1ma"; hiveName = "edge";
swarm.hives = { … }; swarm.hives = { … }; # byte-identical swarm.hives = { … }; swarm.hives = { … }; # byte-identical
``` ```
That is the point of the shape, and it removes a class of bug rather that's the point of the shape, and it removes a class of bug rather
than saving typing: a per-host peer list let two hosts hold _different_ than saving typing: a per-host peer list let two hosts hold _different_
facts about the same third hive — a stale endpoint, a rotated facts about the same third hive — a stale endpoint, a rotated
fingerprint — with nothing to detect the disagreement. One entry per fingerprint — with nothing to detect the disagreement. One entry per
@ -276,7 +276,7 @@ service it points at: `services.hyperhive.swarm.snapshotStore.{address,
port}` tells this hive where the swarm's `btrfs receive` endpoint is, so port}` tells this hive where the swarm's `btrfs receive` endpoint is, so
`hivectl agent <name> subvol snapshot push` has somewhere to stream to. `hivectl agent <name> subvol snapshot push` has somewhere to stream to.
It is genuinely swarm-scoped rather than per-peer — a swarm has exactly it's genuinely swarm-scoped rather than per-peer — a swarm has exactly
one store, because the receiver keys destinations by _agent_ so a one store, because the receiver keys destinations by _agent_ so a
migrating agent keeps one unbroken incremental chain. See migrating agent keeps one unbroken incremental chain. See
[snapshot-store.md](../networking/snapshot-store.md). [snapshot-store.md](../networking/snapshot-store.md).
@ -288,9 +288,9 @@ daemon on this host. **Off by default and deliberately not derived from
`services.hyperhive.enable`**: a swarm has one controller, so enabling it `services.hyperhive.enable`**: a swarm has one controller, so enabling it
is a statement about swarm topology, not about whether hyperhive is is a statement about swarm topology, not about whether hyperhive is
installed. Every hive runs `hive-c0re` (the agents on that host); one installed. Every hive runs `hive-c0re` (the agents on that host); one
hive additionally runs this (what is true across hives). hive additionally runs this (what's true across hives).
What it serves, why it is a unix socket rather than a port, and the What it serves, why it's a unix socket rather than a port, and the
socket-directory constraint that governs where `socketPath` may point: socket-directory constraint that governs where `socketPath` may point:
[`swarm-controller/README.md`](../../swarm-controller/README.md). [`swarm-controller/README.md`](../../swarm-controller/README.md).
@ -298,19 +298,19 @@ socket-directory constraint that governs where `socketPath` may point:
One row per hive in `swarm.hives`, saying when it last reported and what One row per hive in `swarm.hives`, saying when it last reported and what
it said. Hives publish upward through the swarm queue; the controller it said. Hives publish upward through the swarm queue; the controller
never reaches down to collect, so a hive that cannot reach the swarm never reaches down to collect, so a hive that can't reach the swarm
still knows its own state — you just cannot see it from here. still knows its own state — you just can't see it from here.
A hive publishes only once it has been given the three A hive publishes only once it has been given the three
`swarm.statusPublish` coordinates below. A hive that has not reads `swarm.statusPublish` coordinates below. A hive that has not reads
`never_reported` — it is not broken, it just has nothing to say upward. `never_reported` — it's not broken, it just hasn'thing to say upward.
| freshness | what to do about it | | freshness | what to do about it |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `fresh` | nothing — reported within `staleAfterSeconds` | | `fresh` | nothing — reported within `staleAfterSeconds` |
| `stale` | the hive stopped reporting. Its last payload is still shown, so check `age_seconds` and the payload for what it managed to say | | `stale` | the hive stopped reporting. Its last payload is still shown, so check `age_seconds` and the payload for what it managed to say |
| `never_reported` | this hive has never reported at all — normally a deployment that hasn't happened, not an outage | | `never_reported` | this hive has never reported at all — normally a deployment that hasn't happened, not an outage |
| `unknown` | something is publishing under a name that is not in `swarm.hives` — a typo in the roster, or a hive that was removed and is still running | | `unknown` | something is publishing under a name that's not in `swarm.hives` — a typo in the roster, or a hive that was removed and is still running |
Every row also carries `last_seen_unix` and `age_seconds` if you want to Every row also carries `last_seen_unix` and `age_seconds` if you want to
apply your own threshold. The timestamp is the one the queue recorded on apply your own threshold. The timestamp is the one the queue recorded on
@ -341,20 +341,20 @@ not distribute it. Copy `hive-<hiveName>.secret` out of the swarm host's
`deploy.authelia.hostClientSecretDir` with whatever secret management the `deploy.authelia.hostClientSecretDir` with whatever secret management the
deployment already uses. deployment already uses.
The identity is not a choice — a hive authenticates as `hive-<hiveName>` The identity isn't a choice — a hive authenticates as `hive-<hiveName>`
and publishes under `hiveName`, the same name that keys `swarm.hives`. and publishes under `hiveName`, the same name that keys `swarm.hives`.
If a hive stops reporting, its own dashboard is the place to look: a If a hive stops reporting, its own dashboard is the place to look: a
failure to publish raises a warning banner there after three consecutive failure to publish raises a warning banner there after three consecutive
misses. It stays `warn` rather than `crit` on purpose — a hive that misses. It stays `warn` rather than `crit` on purpose — a hive that
cannot reach the queue is not itself unhealthy, so it does not start can't reach the queue isn't itself unhealthy, so it doesn't start
calling itself degraded for being unable to say it is fine. calling itself degraded for being unable to say it's fine.
The endpoint answers **503** when this host has no swarm queue The endpoint answers **503** when this host has no swarm queue
configured, or has one and cannot read it — deliberately not an empty configured, or has one and can't read it — deliberately not an empty
list, which would look like a silent swarm rather than a controller that list, which would look like a silent swarm rather than a controller that
cannot see. The body says which. Status survives a controller restart: can't see. The body says which. Status survives a controller restart:
it is stored in the queue, not in the daemon. it's stored in the queue, not in the daemon.
### Swarm-wide forge webhooks ### Swarm-wide forge webhooks
@ -375,19 +375,19 @@ leftover at startup. A webhook has exactly one target URL, so per-hive
registration never added a recipient — it took delivery away from registration never added a recipient — it took delivery away from
whichever hive registered before it. whichever hive registered before it.
**The `agent-configs` org is not yet.** Each hive still registers its own **The `agent-configs` org isn't yet.** Each hive still registers its own
`pull_request` hook there, so that repo has two — the hive's and the `pull_request` hook there, so that repo has two — the hive's and the
controller's — and **both are expected; do not delete either.** Removing controller's — and **both are expected; don't delete either.** Removing
a hive's stops it acting on config PRs; removing the controller's just a hive's stops it acting on config PRs; removing the controller's just
gets recreated on its next start. gets recreated on its next start.
Nothing to configure. The hooks are registered only when this host also Nothing to configure. The hooks are registered only when this host also
serves the swarm UI vhost — that is what publishes the endpoint, and a serves the swarm UI vhost — that's what publishes the endpoint, and a
hook the forge cannot reach would collect failed deliveries while hook the forge can't reach would collect failed deliveries while
looking healthy. The HMAC secret is generated on first start and kept looking healthy. The HMAC secret is generated on first start and kept
(see [`docs/agent-lifecycle/persistence.md`](../agent-lifecycle/persistence.md)). (see [`docs/agent-lifecycle/persistence.md`](../agent-lifecycle/persistence.md)).
To check it is working, push to `internal/knowledge` and look for To check it's working, push to `internal/knowledge` and look for
`webhook: verified delivery` in `journalctl -u swarm-controller`. A `webhook: verified delivery` in `journalctl -u swarm-controller`. A
refused delivery logs `webhook: refused delivery` with the reason. refused delivery logs `webhook: refused delivery` with the reason.

View file

@ -5,7 +5,7 @@ hive's own CA, and that hive CA signs the gateway leaf. A peer that
trusts the root once validates every hive in the swarm, present and trusts the root once validates every hive in the swarm, present and
future, instead of being pinned to each one by hand. future, instead of being pinned to each one by hand.
That is the whole point of the hierarchy — it turns per-peer trust from that's the whole point of the hierarchy — it turns per-peer trust from
O(n²) hand-pinning into one anchor per swarm. O(n²) hand-pinning into one anchor per swarm.
## Two provisioning modes, one structure ## Two provisioning modes, one structure
@ -19,7 +19,7 @@ What differs is who puts the artifacts on disk:
`services.hyperhive.swarm.ca.autoConfigure` selects between them, and is `services.hyperhive.swarm.ca.autoConfigure` selects between them, and is
**off by default**: a swarm's services and its hives can live on **off by default**: a swarm's services and its hives can live on
different hosts, and a host cannot tell whether it is the one holding different hosts, and a host can't tell whether it's the one holding
the root, so setting the swarm CA up is an operator action rather than the root, so setting the swarm CA up is an operator action rather than
something a host assumes. Turn it on for an all-on-one-host deployment something a host assumes. Turn it on for an all-on-one-host deployment
and the hierarchy costs no configuration. and the hierarchy costs no configuration.
@ -43,13 +43,13 @@ The swarm's service names (`swarm.forge.domain`,
`swarm.matrix.gatewayHost`, `swarm.authelia.domain`) default to `swarm.matrix.gatewayHost`, `swarm.authelia.domain`) default to
siblings of the hive domain — `forge.<swarm.domain>`, not siblings of the hive domain — `forge.<swarm.domain>`, not
`forge.<hive domain>`. The hive CA's leaf is a **single-label** wildcard `forge.<hive domain>`. The hive CA's leaf is a **single-label** wildcard
over the hive's own domain, so it cannot cover them; only the over the hive's own domain, so it can't cover them; only the
swarm-services leaf can, and this host signs that one only under swarm-services leaf can, and this host signs that one only under
`autoConfigure`. A hive with neither serves the hive leaf on those names `autoConfigure`. A hive with neither serves the hive leaf on those names
and every client sees a name mismatch. and every client sees a name mismatch.
So `hive-tls` emits an eval-time **warning** naming the uncovered names. So `hive-tls` emits an eval-time **warning** naming the uncovered names.
It is deliberately not an assertion: this module can see what _it_ is it's deliberately not an assertion: this module can see what _it_ is
able to issue, but not an operator-installed sub-CA in able to issue, but not an operator-installed sub-CA in
`swarm.ca.stateDir`, an external ACME setup, or a certificate delivered `swarm.ca.stateDir`, an external ACME setup, or a certificate delivered
by any other means. If you have arranged one, the warning is expected by any other means. If you have arranged one, the warning is expected
@ -89,16 +89,16 @@ host:
scp /var/lib/swarm-ca/root.pem <peer-host>:/var/lib/swarm-ca/root.pem scp /var/lib/swarm-ca/root.pem <peer-host>:/var/lib/swarm-ca/root.pem
``` ```
That is the point of the hierarchy: the root is installed **once per that's the point of the hierarchy: the root is installed **once per
swarm host**, not once per peer, so a hive joining later needs no edit on swarm host**, not once per peer, so a hive joining later needs no edit on
the hives already running. A hive whose cert already chains to a public the hives already running. A hive whose cert already chains to a public
CA has nothing to install — `peer-config` says so instead. CA has nothing to install — `peer-config` says so instead.
Handing a peer this hive's own `ca.pem` would not work even as a Handing a peer this hive's own `ca.pem` would not work even as a
one-off: once a hive CA is an intermediate under the swarm root, it is one-off: once a hive CA is an intermediate under the swarm root, it's
no longer something a verifier can build a chain _to_ — OpenSSL will not no longer something a verifier can build a chain _to_ — OpenSSL won't
terminate a chain at a trusted non-self-signed certificate without terminate a chain at a trusted non-self-signed certificate without
`-partial_chain`. That is why the tool distributes the root, not a `-partial_chain`. that's why the tool distributes the root, not a
per-hive file. per-hive file.
## Adopting the hierarchy on an existing hive ## Adopting the hierarchy on an existing hive
@ -107,23 +107,23 @@ A hive that predates the swarm root carries a self-signed `ca.pem`, and
adopting the hierarchy means replacing it. That invalidates an anchor adopting the hierarchy means replacing it. That invalidates an anchor
consumers already trust, and they refresh on their own schedule — agents consumers already trust, and they refresh on their own schedule — agents
only pick up new trust when their container restarts, peers only on only pick up new trust when their container restarts, peers only on
their own rebuild. **Who is allowed to decide that is what splits the their own rebuild. **Who is allowed to decide that's what splits the
two cases.** two cases.**
### Where this host owns the root (`autoConfigure`) ### Where this host owns the root (`autoConfigure`)
Adoption happens by itself, once. `hive-tls-ca.service` notices that Adoption happens by itself, once. `hive-tls-ca.service` notices that
`ca.pem` does not chain to the root, keeps the old certificate as `ca.pem` doesn't chain to the root, keeps the old certificate as
`ca-previous.pem`, and re-issues under the root; the next leaf is signed `ca-previous.pem`, and re-issues under the root; the next leaf is signed
by the new CA. by the new CA.
It is safe to automate here precisely because this is the it's safe to automate here precisely because this is the
all-on-one-host shape: every consumer is on this box, so "when will they all-on-one-host shape: every consumer is on this box, so "when will they
have refreshed" is knowable rather than guessed. have refreshed" is knowable rather than guessed.
The old CA stays in `trust-bundle.pem` afterwards, so adoption is The old CA stays in `trust-bundle.pem` afterwards, so adoption is
**additive to the anchor set before it is subtractive** — a container **additive to the anchor set before it's subtractive** — a container
that has not restarted yet still validates. Removing `ca-previous.pem` that hasn't restarted yet still validates. Removing `ca-previous.pem`
is a deliberate later step: how long is long enough is a property of the is a deliberate later step: how long is long enough is a property of the
deployment, not something the unit can know. deployment, not something the unit can know.
@ -141,15 +141,15 @@ rm <deploy.hive-controller.tls.stateDir>/ca.pem <deploy.hive-controller.tls.stat
systemctl restart hive-tls-ca.service systemctl restart hive-tls-ca.service
``` ```
Failing rather than warning is deliberate: a hive whose CA does not Failing rather than warning is deliberate: a hive whose CA doesn't
chain to the root it has been given is misconfigured, and a warning in a chain to the root it has been given is misconfigured, and a warning in a
build log is not something anyone reads twice. build log isn't something anyone reads twice.
To keep the current CA on purpose — a hive that deliberately stays To keep the current CA on purpose — a hive that deliberately stays
outside the hierarchy, or one mid-migration — `touch` the marker file outside the hierarchy, or one mid-migration — `touch` the marker file
named in the message. That is a decision, and it is recorded as one. named in the message. that's a decision, and it's recorded as one.
A hive with **no** root configured at all is not affected by any of A hive with **no** root configured at all isn't affected by any of
this: it self-signs exactly as it always has. this: it self-signs exactly as it always has.
## Distributing the root ## Distributing the root
@ -159,11 +159,11 @@ The root **key** is a runtime file for the reason in
The root **certificate** is a runtime file as a _consequence_ — it lives The root **certificate** is a runtime file as a _consequence_ — it lives
beside the key under `swarm.ca.stateDir` — and that has a cost worth beside the key under `swarm.ca.stateDir` — and that has a cost worth
naming, because it is subtle and it bites at a distance: naming, because it's subtle and it bites at a distance:
> **Nothing whose trust store is assembled at build time can reference > **Nothing whose trust store is assembled at build time can reference
> the swarm root.** `security.pki.certificateFiles` is read inside the > the swarm root.** `security.pki.certificateFiles` is read inside the
> derivation; the root does not exist there. > derivation; the root doesn't exist there.
Two consumers, and only one of them is fine: Two consumers, and only one of them is fine:

View file

@ -19,9 +19,9 @@ so the store is a perfectly good place for it.
**The generator and the reader are usually in different containers.** They share **The generator and the reader are usually in different containers.** They share
the host's network namespace, which makes them feel co-located, but their the host's network namespace, which makes them feel co-located, but their
filesystem roots are separate. That is why delivery is a **host-side copy rather filesystem roots are separate. that's why delivery is a **host-side copy rather
than a bind mount**: `nixos-container` refuses to start when a bind source is than a bind mount**: `nixos-container` refuses to start when a bind source is
missing, and a secret minted on another container's first boot does not exist missing, and a secret minted on another container's first boot doesn't exist
yet. Binding it would make one container wait on a file that waits on a yet. Binding it would make one container wait on a file that waits on a
container that starts after it. container that starts after it.
@ -30,20 +30,20 @@ container that starts after it.
Every row below is read against one of these. This is a different cut Every row below is read against one of these. This is a different cut
from the [deployment shapes](services.md#deployment-shapes) --- those from the [deployment shapes](services.md#deployment-shapes) --- those
say _where services run_, these say _who is responsible for a secret say _where services run_, these say _who is responsible for a secret
file being there_ --- so the two lists do not line up one-to-one, and file being there_ --- so the two lists don't line up one-to-one, and
neither is a renaming of the other. neither is a renaming of the other.
| topology | what it means | who places secrets | | topology | what it means | who places secrets |
| ------------------ | ---------------------------------------------------------- | ---------------------------------------------------------------------------- | | ------------------ | ---------------------------------------------------------- | --------------------------------------------------------------------------- |
| **all-local** | one host runs the swarm's shared services and its own hive | nobody — each secret is generated where it is read, or copied by a host unit | | **all-local** | one host runs the swarm's shared services and its own hive | nobody — each secret is generated where it's read, or copied by a host unit |
| **swarm-managed** | the swarm's services run on a host with `swarmctl` | `swarmctl` writes what it owns; the rest is still generated in place | | **swarm-managed** | the swarm's services run on a host with `swarmctl` | `swarmctl` writes what it owns; the rest is still generated in place |
| **hive elsewhere** | a hive that federates with a swarm it does not host | the operator provides the file and names it in config | | **hive elsewhere** | a hive that federates with a swarm it doesn't host | the operator provides the file and names it in config |
## Swarm-level — one of each per swarm ## Swarm-level — one of each per swarm
| secret | generated by | lives at | hive elsewhere | | secret | generated by | lives at | hive elsewhere |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| swarm root CA cert | `swarm-ca.nix` first-boot unit, when `autoConfigure` is set | `/var/lib/swarm-ca/root.pem` | operator copies the **cert** in; it is public | | swarm root CA cert | `swarm-ca.nix` first-boot unit, when `autoConfigure` is set | `/var/lib/swarm-ca/root.pem` | operator copies the **cert** in; it's public |
| swarm root CA key | same unit | `/var/lib/swarm-ca/root-key.pem`, `0600` | stays on whichever host holds it — see the constraint below | | swarm root CA key | same unit | `/var/lib/swarm-ca/root-key.pem`, `0600` | stays on whichever host holds it — see the constraint below |
| swarm-services sub-CA (cert + key) | `swarm-ca.nix`, signed by the root | `/var/lib/swarm-ca/services-ca{,-key}.pem` | issued where the root lives | | swarm-services sub-CA (cert + key) | `swarm-ca.nix`, signed by the root | `/var/lib/swarm-ca/services-ca{,-key}.pem` | issued where the root lives |
| authelia session, JWT and storage-encryption keys | authelia's first-boot unit, in-container | `/var/lib/authelia-swarm/{session,jwt,storage-encryption}.key` | generated in place; nothing outside that container reads them | | authelia session, JWT and storage-encryption keys | authelia's first-boot unit, in-container | `/var/lib/authelia-swarm/{session,jwt,storage-encryption}.key` | generated in place; nothing outside that container reads them |
@ -56,31 +56,31 @@ neither is a renaming of the other.
| wireguard private key | **the operator** — `wg genkey` | whatever `deploy.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you | | wireguard private key | **the operator** — `wg genkey` | whatever `deploy.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you |
| queue auth-callout nkeys (user seed + account seed) | `swarm-nats-callout-keys` first-boot unit, when `deploy.nats.autoGenerateCallout` is set | `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed`, `0600` | operator mints both with `nk` and names them in `deploy.nats.calloutUserSeedFile` / `deploy.nats.calloutIssuerSeedFile` | | queue auth-callout nkeys (user seed + account seed) | `swarm-nats-callout-keys` first-boot unit, when `deploy.nats.autoGenerateCallout` is set | `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed`, `0600` | operator mints both with `nk` and names them in `deploy.nats.calloutUserSeedFile` / `deploy.nats.calloutIssuerSeedFile` |
| the secret store's own contents | openbao, on first `bao operator init` — **an operator action, not a unit** | inside the `swarm-bao` container, at its own `/var/lib/openbao`, kept across rebuilds by `ephemeral = false`. ⚠️ **Not a host path**: `nixos-container destroy swarm-bao` takes the raft data with it, so back up the container's tree, not `/var/lib/`. Only the store's TLS material (`/var/lib/swarm-bao-tls`) and its PKCS11 token (`/var/lib/swarm-bao-token`) are host-level | n/a — there is one store; a hive elsewhere is a _client_ of it and holds none of this | | the secret store's own contents | openbao, on first `bao operator init` — **an operator action, not a unit** | inside the `swarm-bao` container, at its own `/var/lib/openbao`, kept across rebuilds by `ephemeral = false`. ⚠️ **Not a host path**: `nixos-container destroy swarm-bao` takes the raft data with it, so back up the container's tree, not `/var/lib/`. Only the store's TLS material (`/var/lib/swarm-bao-tls`) and its PKCS11 token (`/var/lib/swarm-bao-token`) are host-level | n/a — there is one store; a hive elsewhere is a _client_ of it and holds none of this |
| the secret store's unseal material | the HSM/TPM under `deploy.bao.seal = "pkcs11"`; openbao itself under `"shamir"` | in the token; or held by whoever ran `bao operator init`, which is what `"shamir"` means and why it is stated rather than inferred | n/a — only the host running the store seals anything | | the secret store's unseal material | the HSM/TPM under `deploy.bao.seal = "pkcs11"`; openbao itself under `"shamir"` | in the token; or held by whoever ran `bao operator init`, which is what `"shamir"` means and why it's stated rather than inferred | n/a — only the host running the store seals anything |
The three keys authelia mints for itself are generated in-container precisely The three keys authelia mints for itself are generated in-container precisely
because nothing outside that container ever reads them. **That is the test worth because nothing outside that container ever reads them. **that's the test worth
applying to any secret added here** — and the client secret's plaintext half is applying to any secret added here** — and the client secret's plaintext half is
the one row that fails it, which is the entire reason a delivery step exists. the one row that fails it, which is the entire reason a delivery step exists.
There are two telemetry collectors and they land on opposite sides of that test. There are two telemetry collectors and they land on opposite sides of that test.
The **hive's** collector needs no delivery step. It authenticates to the swarm's The **hive's** collector needs no delivery step. It authenticates to the swarm's
collector as its own hive, and it is a host unit rather than a container, so on collector as its own hive, and it's a host unit rather than a container, so on
an all-local swarm it reads authelia's file where it lies and no second copy is an all-local swarm it reads authelia's file where it lies and no second copy is
made. On any other topology it is an ordinary "operator provides the file" made. On any other topology it's an ordinary "operator provides the file"
case — see `services.hyperhive.otel.clientSecretFile`. case — see `services.hyperhive.otel.clientSecretFile`.
The **swarm's** collector does need one. It runs in a container, so its copy is The **swarm's** collector does need one. It runs in a container, so its copy is
placed by `swarm-otel-oidc-secret.service` and lands at placed by `swarm-otel-oidc-secret.service` and lands at
`/var/lib/swarm-otel-oidc/<client-id>.secret` — the same shape as the forge and `/var/lib/swarm-otel-oidc/<client-id>.secret` — the same shape as the forge and
homeserver rows below, and for the same reason: the container that mints the homeserver rows below, and for the same reason: the container that mints the
secret is not the container that reads it. secret isn't the container that reads it.
The copy is only made when authelia is enabled **on this host** and something The copy is only made when authelia is enabled **on this host** and something
published is being scraped; otherwise no secret is needed and none is placed. published is being scraped; otherwise no secret is needed and none is placed.
⚠️ **Do not read that delivery unit as the only way this collector is fed.** ⚠️ **don't read that delivery unit as the only way this collector is fed.**
Whether it authenticates follows the _credential_, never another service's Whether it authenticates follows the _credential_, never another service's
placement: a swarm collector may run on a host that holds neither store and no placement: a swarm collector may run on a host that holds neither store and no
authelia, and then the secret is an ordinary operator-provided file named in authelia, and then the secret is an ordinary operator-provided file named in
@ -91,7 +91,7 @@ not the definition of the case.
### Minting the queue's callout nkeys ### Minting the queue's callout nkeys
`deploy.nats.autoGenerateCallout` mints both keypairs on the host before the queue `deploy.nats.autoGenerateCallout` mints both keypairs on the host before the queue
starts. It is on by default only under `singleHostSwarm` — the one starts. It's on by default only under `singleHostSwarm` — the one
topology where the queue, its responder and the operator are the same person. On topology where the queue, its responder and the operator are the same person. On
every other topology, mint them yourself: every other topology, mint them yourself:
@ -108,12 +108,12 @@ admit anyone to the queue, so it belongs wherever the responder runs and nowhere
else. else.
A hive that sets neither the public keys nor `autoGenerateCallout` fails at A hive that sets neither the public keys nor `autoGenerateCallout` fails at
eval, naming the option it wants. That is deliberate: a queue that started eval, naming the option it wants. that's deliberate: a queue that started
without them would accept `CONNECT {"user":"auth"}` from anyone sharing the without them would accept `CONNECT {"user":"auth"}` from anyone sharing the
host's network namespace, and nothing would look wrong until somebody connected. host's network namespace, and nothing would look wrong until somebody connected.
**All four or none** — the seed paths are required too, not just the public **All four or none** — the seed paths are required too, not just the public
keys. They are two halves of the same pair: the server verifies with the public keys. they're two halves of the same pair: the server verifies with the public
half, the responder signs with the private one. Supplying only the public keys half, the responder signs with the private one. Supplying only the public keys
used to pass eval and leave the queue with an auth-callout nobody answers, which used to pass eval and leave the queue with an auth-callout nobody answers, which
refuses every client rather than degrading — and a refusal reaches the client as refuses every client rather than degrading — and a refusal reaches the client as
@ -139,8 +139,8 @@ bounded wait, 120s — and then **fail loudly** rather than skipping. A silent s
produces a service whose login button always fails, which is a symptom several produces a service whose login button always fails, which is a symptom several
layers from its cause. layers from its cause.
The store's **first reader** is the matrix registration token, and it is worth The store's **first reader** is the matrix registration token, and it's worth
saying why that one: it is an opaque 32-byte value with no second file and no saying why that one: it's an opaque 32-byte value with no second file and no
format. Authelia's OIDC secret needs a `.secret` _and_ a matching `.digest`, so format. Authelia's OIDC secret needs a `.secret` _and_ a matching `.digest`, so
starting there would have meant debugging "can a reader authenticate and get starting there would have meant debugging "can a reader authenticate and get
bytes back" and "did we write authelia's file format right" at once, with an bytes back" and "did we write authelia's file format right" at once, with an
@ -153,8 +153,8 @@ locally minted token in place, so a hive with no store behaves exactly as it
did before. did before.
⚠️ **Service↔store mTLS is its own trust domain.** A credential you must ⚠️ **Service↔store mTLS is its own trust domain.** A credential you must
already hold to authenticate cannot be fetched from the thing it authenticates already hold to authenticate can't be fetched from the thing it authenticates
you to, so the store's identity cannot come from an authority the store you to, so the store's identity can't come from an authority the store
distributes — which excludes the hive CA and the swarm CA both, and has nothing distributes — which excludes the hive CA and the swarm CA both, and has nothing
to do with the gateway's HTTPS certificates either way. `glue-bao-tls.nix` to do with the gateway's HTTPS certificates either way. `glue-bao-tls.nix`
mints a CA that signs exactly two things, the store's server certificate and a mints a CA that signs exactly two things, the store's server certificate and a
@ -163,14 +163,14 @@ internal CA deletes that file and names its own paths in
`deploy.bao.serverCertFile` / `clientCaFile`; the store itself has no opinion. `deploy.bao.serverCertFile` / `clientCaFile`; the store itself has no opinion.
A hive that reads from a store on **another** machine names the reader's half — A hive that reads from a store on **another** machine names the reader's half —
`clientCertFile`, `clientKeyFile`, `serverCaFile` — and places that leaf by hand. `clientCertFile`, `clientKeyFile`, `serverCaFile` — and places that leaf by hand.
It is the one credential that cannot come out of the store, being what opens it; it's the one credential that can't come out of the store, being what opens it;
everything else a hive needs does. everything else a hive needs does.
## The constraint that decides where the root lives ## The constraint that decides where the root lives
A hive CA carries `nameConstraints=permitted;DNS:<hive domain>`, and **a swarm A hive CA carries `nameConstraints=permitted;DNS:<hive domain>`, and **a swarm
service name is a sibling of the hive domain rather than a child** — `forge.<swarm>` service name is a sibling of the hive domain rather than a child** — `forge.<swarm>`
next to `<hive>.<swarm>`. So a hive CA cannot issue a certificate for a swarm next to `<hive>.<swarm>`. So a hive CA can't issue a certificate for a swarm
service. Not by policy: by construction, and openssl enforces it. service. Not by policy: by construction, and openssl enforces it.
Whatever holds the swarm root is therefore what makes swarm-service certificates Whatever holds the swarm root is therefore what makes swarm-service certificates
@ -188,4 +188,4 @@ possible at all. Two things follow:
State three things, in the row you add above: **who mints it**, **which State three things, in the row you add above: **who mints it**, **which
container reads it**, and **what happens when they differ**. If they differ, it container reads it**, and **what happens when they differ**. If they differ, it
needs a delivery unit, and the unit copies — it does not bind. needs a delivery unit, and the unit copies — it doesn't bind.

Some files were not shown because too many files have changed in this diff Show more