Mara's follow-up on #4257, right after the card-only version went up:
"still want the filters tho, maybe split the data component from the
view" / "if we split data component and view, we can make it switchable
between table and specialized card view."
AgentsPage already had its data/actions (rows, declareState, the
dialog-target state) separated from rendering by this point - the only
missing piece was a second renderer. Restores the original Table +
columns array as the "table" viewMode alongside the new card view,
adds a small two-button toggle in the Panel's actions row, and persists
the choice to localStorage (same pattern as Table's own per-column
filter persistence) so a reload keeps the last choice. Cards stays the
default.
Verified with a real CDP click switching to table view: columns,
sort arrows, and filter icons are all back.
Replaces AgentsPage's Table-rendered roster with one AgentCard per
agent: name/status/wanted on the first line, the free-form status
message on the second (mara, scoping #4257: "main view: name, status,
message, wanted" / "message as second line" / "more like card per
agent").
Everything the old table's other columns carried (hive, matrix
link-account, config-PR link, destroy) moves into a detail panel that
opens on card click, reusing the existing Dialog modal rather than a
new docked/slideover primitive - the shared hive-side-panel drawer is a
shadow-DOM custom element swarm-ui's esbuild config can't consume yet
(same gap Dialog.tsx's own comment already flags for hive-dialog).
WantedMenu gains a showDestroy flag: the card's own menu keeps the
three everyday states, destroy gets its own button in the detail panel
instead of a fourth dropdown entry next to states someone reaches for
often.
Known regression, flagged for follow-up rather than silently dropped:
the old table's per-column sort/filter has no replacement in this view
yet.
Backend list/detail endpoint split (also requested in #4257) is
deliberately left for a follow-up PR - it's an orthogonal optimization,
not required for this interaction to work correctly against the
existing single /api/agents/status response.
The two hive-name guards lived in swarm-otel.nix, inside its
`config = lib.mkIf (… && deployCfg.swarm-otel.enable)`. A swarm running the
secret store and the controller but no collector therefore had no hive-name
check at all, while the names were still composed into OIDC client ids, bao
policies and cert-auth roles exactly the same way. They move to swarm.nix,
which declares `swarm.hives` and is unconditional. swarm-otel keeps the
assertion that its own entry is still in the shared list — that one is about
this module's stake in a file it no longer controls.
The equality guard also takes the store's cert-auth subjects now. Cert auth
trusts the CA, so `allowed_common_names` is the whole of what narrows a role
to one identity, and the same CA signs every hive's leaf with the hive's name
as its CN. A hive named after a role's subject presents a certificate that
role accepts, which for the controller is write access to every hive's
credentials and policies.
A list rather than the one string, because the next role added beside it
widens what a hive name must not collide with, and because the subject is an
option an operator sets — a literal deny entry covers the default and nothing
else.
Four module-eval cases, two of them controls. The fixture overrides the
subject to `ctl` on purpose: the default contains `swarm`, which the substring
guard catches whatever the new arm does, so a fixture using it could not tell
the two apart. The controls are that a legal roster trips neither guard, and
that all three fixtures really do have the collector disabled — without the
second, every case would pass while testing the arrangement they exist to
rule out.
A `WantedMenu` badge dropdown on the agents table clips against
`.ui-table-scroll`s overflow the moment its row is the last (or
near-last) one — the popover extends past the table content the
scroll container bounds itself to. `Table.tsx` already solved the
identical clip for its own column-filter popover with a
position:fixed + portal computed from the anchor rect; `Dropdown` now
takes an optional `portal` prop that opts a caller into that same
recipe instead of a second hand-rolled copy of it. Off by default —
every other current caller (StatusChips x2, the components-page demo)
keeps its existing non-portal behavior unchanged.
The crate had a single path convention and it was per-agent:
`swarm/agents/<agent>/matrix/<account>`. The secrets still to move into the
store do not fit it — one belongs to a hive, one to a swarm service, one to
the controller itself — so each would have picked its own shape, and each
would have been a separate grant to get wrong.
mara ruled the scheme on the epic: `swarm/<kind>/<name>/<secret>`, over
`agents`, `hives`, `services` and `controller`. This lands it.
`Kind` is an enum rather than free strings for one reason: the store's grant
is written in nix and cannot be reached from Rust, so a misspelled kind is a
403 at provision time and not a compile error. `Kind::ALL` lets a test
enumerate the set instead of restating it, which is what makes adding a kind
a deliberate edit rather than an accidental grant.
Note `Kind` sits beside `checked_segment`'s existing `kind` argument, which
means something else entirely — the label of the name being validated. They
are not the same concept and should not be merged.
Nothing about the rendered policy changes. `policy::render` still grants read
on the agent kind alone; the other kinds are absent on purpose, because what a
hive may read of its own kind is a boundary question and not a consequence of
the namespace growing. The controller's write grant likewise stays scoped to
`agents/` — it widens when a path outside it gains a writer, not when the
kinds are declared.
Verified: `cargo test -p swarm-secret-client` 23 passed, 0 failed. The two
tests pinning the rendered strings (`the_document_grants_read_over_the_whole_agent_prefix`
and matrix's path assertion) still assert the same literals they did before,
which is what shows this is a faithful port rather than a reshape. `nix fmt`
710 emitted, 10 formatted, 0 changed; the three scripts/check-*.sh lints pass
with the change staged. No reference to the removed `path::AGENT_PREFIX`
survives in the crate or in nix — checked with a scoped pattern, because the
unqualified name also belongs to hive-host-sock's container prefix and greps
for it are answering a different question.
The mode was declared twice in this file — the service unit's
RuntimeDirectoryMode and the socket unit's DirectoryMode — with only a
prose "must match" note tying them. Whichever unit activates first creates
the directory, so they cannot be allowed to disagree.
Both literals are in one file, so they become a `let`. Deleting a copy
beats checking it, and unlike rendering the mode into hive-priv it costs no
config knob for a value nobody should ever set.
hive-priv's tmpfiles.d entry for the same path is a third declaration that
cannot read this binding, and is left in step by hand. An earlier revision
of this branch added a CI check for exactly that pair; mara pointed out it
was keyed to one path rather than to the class, and looking for the general
case found two more paths declared by more than one mechanism — including
/run/hive-agent, where hive-gateway's tmpfiles rule and hive-priv's
generated one disagreed on the owner and the winner depended on systemd's
read order. That check is being reworked as a general one, tracked
separately, so nothing about it rides in here.
Verified: nix fmt (713 traversed, 5 formatted, 0 changed); the three
scripts/check-*.sh lints all exit 0 with the tree staged; .forgejo/ is now
byte-identical to main and the diff is this one file. checks.module-eval
reported 91 module properties hold on the previous revision of this branch
— the only nix change since is comment text inside the same let block,
which cannot affect evaluation.
The three cross-references this branch rewrote used `*Swarm SSO*`; prettier
emits `_Swarm SSO_`, so checks.formatting went red on an otherwise
content-only change.
Verified: nix fmt is now idempotent on this tree (0 changed); the three
scripts/check-*.sh lints exit 0 with the change staged.
`c1b7be11` inserted `### 3 · Secret store` and renumbered every heading below
it — SSO 3→4, UI 4→5, Matrix 5→6, Spawn 6→7, Host commands 7→8 — without
touching a single cross-reference. Five were left pointing one section short:
line 25 "see step 6" -> §6 Matrix, meant §7 Spawn sub-agents
line 30 "in step 6" -> §6 Matrix, meant §7 Spawn sub-agents
line 35 "see step 3" -> §3 Secret store, meant §4 Swarm SSO
line 217 "(step 3)" -> §3 Secret store, meant §4 Swarm SSO
line 246 "see step 3" -> §3 Secret store, meant §4 Swarm SSO
Both `step 3` targets are account-creation instructions and §3 is the secret
store, which is `only when deploy.bao` — so an operator without a store follows
the pointer into a section that does not apply and finds no `swarmctl` in it.
Renumbering to 4/7 would rot on the next insertion. These name the section
instead, the form `approvals.md:87` and `dashboard.md:906` already use. The
Matrix block's `# 5a.`–`# 5d.` comments lose their prefixes for the same
reason: they numbered themselves against that section's old position, and the
page's other code blocks carry unnumbered comments anyway.
Closes#4212.
Mara's decision on #4179: the default window for comments/timeline (no
flags at all) is now the newest 10, matching an explicit --tail 10 --
agent can still opt into the old oldest-first window via an explicit
--limit. Same restructure in both verbs: limit u64 -> Option<u64>, only
the explicit-limit branch takes the head path now, the tail-or-default
branch covers both --tail N and the no-flags case.
timeline.rs also gets the real fix: TimelineListHeaders.x_total_count is
a genuine field forgejo-api already returns on every timeline call --
verified against the vendored crate source. The old 'no total exists'
premise in the module doc was false, same shape as #3200. Replaced the
over-fetch-by-one boolean 'more' with an exact count via a cheap
page_size=1 fetch_total, mirroring comments.rs's fetch_tail pagination
math for --tail. JSON output reshaped to {events, more_before,
more_after, since_more, since_limit_clamped}, matching comments.rs --
a breaking interface change, intentional.
Also fixed a real pre-existing bug in comments.rs's truncation_note:
the more_before message suggested retrying with --tail, which is
nonsensical since more_before only ever fires from a tail-shaped
window. Now correctly suggests --limit. Added a regression test.
fixes#4179
Two unrelated changes landed correctly and left their prose behind, both
on operator-facing surfaces.
`hivectl agent <name> choom` gained a `hive-admin` path when hive-c0re
started shipping a polkit rule for `machinectl shell`. `choom.rs`
implements exactly that — `euid != 0 && !in_hive_admin_group()` — but
the `Choom` variant's clap doc comment still said "Requires root and a
running container", eight weeks on. That string is what
`choom --help` prints and what renders into the generated CLI
reference, so it is the sentence an operator actually reads, and it
tells a `hive-admin` member the command is not available to them.
`docs/turn-loop/mcp.md` carried the same staleness inside a
trust-boundary argument ("an operator (root) action"), where naming a
narrower reachable set than the real one is the wrong way to be wrong.
Dating it rather than asserting it: the acceptance landed 2026-09-07,
the doc string 2026-07-16.
Separately, `docs/tools/hivectl.md` states the agent-name hoist itself
("the name is hoisted onto the parent command, so none of the verbs
below repeat it") and its own example block obeys it, then twice writes
`set-limits sock --memory-max 8G` — the pre-hoist order. The generated
reference is unambiguous: `hivectl agent <NAME> <COMMAND>` versus
`hivectl agent set-limits [OPTIONS]`, with `quota set <SIZE>` nearby
showing that positionals do render when they exist. The costly one is a
complete command in backticks inside the paragraph explaining the
footgun it illustrates.
`docs/tools/hivectl-cli.md` is regenerated rather than hand-edited; the
diff against the committed copy is exactly one line.
Closes#4236.
mara's call on this PR was "list schedules not being scoped at all is a
bug - dont document it, file the bug and fix it". The bug is fixed in
damocles's separate PR, which also rewrites this page's
`list_schedules()` section.
So both of my paragraphs about scoping go: the "not scoped at all"
sentence in the intro (documenting the bug, which is what she
objected to) and the follow-up in the `list_schedules()` section. That
section is now byte-identical to main again, leaving it entirely to the
PR that changes the behaviour — the two PRs no longer touch a common
hunk in this file.
What stays is the claim this PR was actually filed for: the page said
"All scheduling ops go through the operator approval queue", and only
creating one does. The intro now splits creating from the other four
verbs and states the one authorization rule that covers all of them,
which the scoping fix makes true.
`docs/tools/scheduling.md` said three things about who may do what. Two
were wrong prose; the third was the code.
"All scheduling ops go through the operator approval queue" — one of the
five does. Cancel, edit, list and fire are `require_group("scheduling")`
and nothing else (socket_server/mod.rs:594-643), which is what the MCP
tool descriptions already tell an agent. The page pushed in the cautious
direction: someone watching a runaway recurring schedule would wait for
an operator rather than cancel it themselves.
The authorization sentence covered "read/cancel/edit". Cancel, edit and
fire really do check `cancel_authorized` (self, operator, or subtree) from
three call sites. `handle_list_schedules` takes no requester at all and
returns every row — now stated, along with the part that matters: the
snapshot includes other agents' schedule bodies.
`lines` was documented as "host-capped at 500" and the 500 was in the
agent's own MCP layer, not the host; `handle_get_logs` passed any u32
straight into JournalQuery. A limit in the caller is not a limit, so the
host clamps instead of the sentence changing. That also makes args.rs's
arg doc and the tool description agents read correct, untouched. The
sibling `get_host_journal` already clamps host-side at 100, which is both
the precedent and the control that the missing clamp here was real.
Closes#4230.
sso.md's "What this doesn't do" list opened with "It doesn't disable
local login" without qualification, which was wrong for Grafana before
the previous commit and is wrong in a different way after it: Grafana now
disables the form for every deployment, not just where authelia happens to
be on the same host.
Names the exception, gives the reason a reader can act on (a default
`admin`/`admin` account on a gateway-published vhost), and states the
consequence plainly — SSO is the only door, so a dead provider locks
everyone out, which is why the OIDC role defaults to Admin.
`auth.disable_login_form` was gated on `ssoLocal` — `grafana.enable &&
authelia.enable`, i.e. "both of them run on THIS host". With authelia
elsewhere in the swarm that is false, so a deployment that is very much
using SSO still rendered grafana with its local login form enabled, on a
vhost the gateway publishes, for a product that ships an `admin`/`admin`
account.
The reason that matters was already in the module, three lines up
("Grafana ships an `admin`/`admin` account, and this vhost is on the
public gateway") — it was just attached to a conditional. Whether a
password box sits on a public login page is not a per-host question.
Per mara on the docs PR for this: "grafana requires sso - no local
login". The OIDC block below stays gated on locality; making that follow
the same swarm-wide question is a larger change with its own tracking.
The module-eval suite already had the fixture this needed: `grafanaOldPath`
enables grafana and not authelia, which is exactly the shape the login
form stayed enabled in, so the regression case needs no new hive. 90 -> 91
properties.
Closes#4218.
`matrix.md` documents the exact text of every inbound matrix signal —
three wake-body shapes and the invite loose-end — and none of them
mention that the daemon prefixes `[acct:<name>] ` when it serves more
than one account.
`wake::tag_account` is live on both documented paths (`timeline.rs:75`
for unread wakes, `:139` for invite todos), so an agent with an extra
account receives `[acct:ccc] [matrix] @a:s in #x: hi` where the page
promises a body starting `[matrix]`. The example is not hypothetical:
the matrix module uses `matrix-token-ccc` on dmatrix as its worked
example of an extra account.
It stayed invisible because the `None` arm returns the body unchanged,
so every single-account agent sees the documented format exactly. The
page is right for almost every reader and wrong for precisely the
readers its "Multiple accounts" section is written for.
Two placements rather than one. The prefix itself goes next to the wake
formats it corrects, with the worked example and the reason a leading
`[matrix]` match works until a second account exists. A forward pointer
goes in "Multiple accounts", because that is the section someone
configuring extra accounts actually lands on, and it previously covered
only the outbound `account` parameter — the half you pass, not the half
you parse.
Closes#4243.
The security section said hive→swarm ingest auth was "planned" and that the
`hive` label "becomes" unforgeable. Sixty lines later, `### Authenticated
ingest` in the same file describes it as shipped and mandatory — "No
unauthenticated mode exists" — and the code agrees: swarm-otel.nix:157 derives
one receiver port per entry in `swarm.hives`, :1387 stamps `key = "hive"` from
the receiver that accepted the sample, :751 asserts `swarm.hives != {}`, and
otel.nix:659 makes a hive without an identity a build error.
Not drift. Ordered by position in main rather than by author date, the "planned"
wording is the NEWEST of the three commits: `9dc60061` documented authenticated
ingest and `9bd2b9e9` dropped the unauthenticated mode (both 2026-08-19), while
`5fcd2a93` — authored 00:59 that morning, merged on 08-30 — added the sentence
eleven days later into a tree where the feature already existed. A long-lived
branch's prose is a snapshot of the tree it was written against, and nothing
re-reads it at merge time.
Matters because §"what the agent→collector hop is and isn't" is the page a
reader goes to for "can a hive forge a label". It answered "auth is planned",
which reads as "`hive=` is forgeable today". The truth is the opposite and
stronger.
Closes#4216.
handle_list_schedules took no requester and returned every schedule
unfiltered, unlike cancel_schedule/edit_schedule/fire_schedule_now
which all gate on the shared ownership predicate (self, operator, or
subtree via topology::is_descendant_of). list_schedules now filters
through the same predicate, renamed cancel_authorized ->
schedule_authorized since it backs all four verbs now, not just
cancel.
Fixed five stale 'every schedule' / 'unfiltered' claims found while in
here: filter_ghost_schedule_targets's doc comment, the list_schedules
MCP tool description, docs/tools/scheduling.md's per-verb section
(already self-contradicting its own top-of-file subtree-scoping claim
before this fix), and hive-core-agent-sock's ListSchedules/Schedules
wire-type doc comments (including a stale '(privileged)' marker from
the pre-topology-subtree model).
Credit to atlas: independently found the same fix while finishing
PR #4233 (which documents this bug per mara's 'fix it, don't document
it' ruling) and caught two stale doc spots I'd missed
(hive-core-agent-sock's comments) plus proposed the
schedule_authorized rename. Compared diffs directly before either of
us pushed; he dropped his scheduling.rs changes so we didn't collide.
fixes#4237
`security.md` introduces its hive-priv table as "Narrow interface —
`PrivRequest` variants map 1:1 to specific known operations". The table
named 17 of 34, so the claim it was making was only half-checkable by
the reader it was written for.
Two whole subsystems were absent rather than stragglers: btrfs
subvolume + snapshot management (9 verbs) and per-agent external forge
accounts (2), plus `SendAgentSnapshotToFd`, which passes a file
descriptor across the boundary via SCM_RIGHTS — the kind of verb
someone reads a security page to find.
The table had already been resynced by hand once, in June, and drifted
again within three months. mara's call was to keep it exhaustive rather
than summarise by subsystem: the swarm-level operations are expected to
go away, so the row count is at its peak now and shrinks from here.
Rows for the 17 missing variants, each naming what the helper actually
runs. Three of them carry the constraint that makes them safe rather
than just the command, because that is what the surrounding section is
for: `ControlInfraContainer`'s allowlist is the `InfraContainer` enum
(serde rejects unknown names at the wire boundary, and `hive-c0re` has
no variant); `WriteAgentExtraForgeAccount`'s `label` reaches a filename
and is validated as a plain identifier first; `SendAgentSnapshotToFd`
requires exactly one descriptor and refuses one arriving alongside any
other operation.
Prose is active voice throughout the new rows — "hive-priv validates
`label`" rather than "`label` is validated" — since the question a
reader brings to this table is which component enforces what.
Verified with a variant-vs-page diff carrying its own controls
(extractor count, page-readable, a present variant resolves, an
invented one does not): 34 named, 0 absent. vale is unchanged from
main's baseline for this file, 0 errors and 13 warnings on both sides.
Closes#4222.
#4184 gave 'pr status' a positional PR-number arg (--pr <n> still works, just
no longer the recommended form). docs/tools/forge.md's examples and the
prose section still showed the old --pr form in four places; same one-line
hint baked into every agent's own system prompt (hive-agent/prompts/
system.md). Neither file was touched by #4184 itself -- leftover from my own
#4182 branch that lost the collision to atlas's #4184 (docs/tools/forge.md
and hive-agent/prompts/system.md were the two genuinely non-overlapping bits
of that branch, tracked as a follow-up rather than dropped).
docs/tools/forge-cli.md is clap-generated and doesn't need regenerating --
prose-only doc changes don't touch the clap tree it's derived from.
refs #4182
A run triggered by a pull_request-event workflow carries a #<n> pseudo-ref
in prettyref rather than a real branch name. Dispatching a workflow with
that as the ref 500s. resolve_run now recognizes the #<n> form and falls
back to the same branch_for_pr lookup --pr already uses, so ci-rerun --run
works on PR-triggered runs the same way ci-rerun --pr does.
diagnosis + discriminating control table (real branch vs #<n> pseudo-ref)
from atlas.
fixes#4201
For embedding companions (e.g. trollshell WebKitGTK) that supply their
own header/input chrome around the per-agent page and only want the
raw terminal feed. Comma-separated so both axes can hide in one param:
?hide=header,input. Zeroes --agent-header-h/--agent-composer-h via body
classes rather than conditionally computing padding, so every dependent
calc() (overlay offset, terminal scroll padding) collapses in one place.
`docs/tools/subagent.md` and `docs/tools/bash.md` both described their MCP
server as injected "unconditionally". Both entries are `lib.mkDefault`, and
the module says why one line above each: "so an agent.nix can still
override/disable the entry", "so the operator's own agent.nix can override
the entry".
The word matters for the subagent one in particular. The same comment block
records the framing that it is default-on for now and should become a real
capability gate later, so "can I turn this off today?" is a question an
operator has — and "unconditionally" answers it as "patch nix/" when the
answer is one override in agent.nix.
Both pages now say default, and say what the default yields to.
The other direction on the same page: `subagentHttpPort`'s option
description and the unit comment beside it both listed three tools,
`start`/`continue`/`interrupt`. The daemon serves four. #4101, which
introduced it, is titled with the three-verb phrasing, so `status` landed
afterwards and never reached either description — while `subagent.md` had
the full set all along. The option description renders into the generated
options doc, so it is the one an operator reads.
Closes#4231.
`require_descendant` (`socket_server/mod.rs:666`) authorises
kill/start/restart/update/get_logs with `topology::is_descendant_of` — the
caller's whole subtree, itself included. That has been true since `53b4e752`
(#1865), whose message says "a parent owns its whole subtree; the root covers
every agent as a consequence, no positional privilege", and two tests pin it
(`is_descendant_of_in_grandchild`, `is_descendant_of_in_self_is_true`).
The prose never followed. The four lifecycle tool descriptions, their
`// IMPORTANT:` comments, `docs/tools/lifecycle.md`, the tools README,
hive-agent-mcp's README and the system prompt every agent is rendered from all
still said "direct children only" — while `list_containers`, four tools away in
the same file, said "direct children + their subtrees".
`lifecycle.md` also taught the model #1865 deleted: "Privileged agents (for
example ruth) may operate on any sub-agent — the topology scope applies to all
others." There is no privileged class to belong to; ruth reaches every agent
because the check is transitive and everything sits under it.
Same drift on the state-query side: `resolve_agent_state_target` is
subtree-scoped by the same commit, so `get_loose_ends`' argument doc, the
`QueryAgentState` capability doc and `docs/turn-loop/mcp.md` were all telling a
parent it needs a capability to read a grandchild's threads.
Two smaller corrections found on the way:
* `list_containers` returns the caller itself. `is_descendant_of` is true for
`candidate == ancestor` and `handle_list_descendants` filters the topology
with it; called from a leaf agent it answers one row, that agent.
* `request_init_config` accepts any unused name — the requester becomes its
parent — or an existing agent already in the caller's subtree, not "a direct
child". The editing surface is narrower than the guard, though: only direct
children's config repos are bind-mounted, so re-seeding deeper in the subtree
leaves no local copy to edit. `lifecycle.md` now says so.
The prompt's other stale claim, the dead `request_apply_commit`, is #4226 and
was fixed independently by damocles in #4227 while this was being gated. This
branch keeps only the scope wording on that line.
Closes#4225.
`agent_uid_gid` returns `None` for all 13 agents on every sync, and the
tmpfiles caller answers that `None` by writing `d /run/hive-agent/<name>
0777 root root` instead of `0751 <uid> <gid>` — a world-writable socket
dir, which docs/trust-boundary/boundary.md spells out as letting anything
that can reach the path unlink an agent's socket and bind its own.
Which failure fires could not be determined, because the read used
`.ok()?` and collapsed every io::Error into the same `None` a missing
user produces. None of the three causes the doc comment enumerated (not
built yet, unparseable, missing user) fits 13 long-lived containers whose
agent user demonstrably exists — from inside one:
srw-rw-rw- atlas atlas /run/hive-agent/atlas/agent.sock
So the live cause was outside the documented set and unidentifiable. Both
`None` arms now log, with the path and the error.
Splitting the pure parser out to make it testable surfaced a second,
narrower bug. The old scan used `?` on the field reads, and those are
only reached once the name matches — so an unusable row *for the wanted
user* returned `None` from the whole function instead of skipping, hiding
a usable entry below it. (Rows for other users were always skipped fine:
`split(':')` always yields at least one item, so the first `?` could not
fire.) It now skips unusable rows and keeps looking.
Does not pre-empt #3047, which removes the lookup entirely and stays
blocked on #3998; this only makes the lookup honest about failing while
it exists.
Closes#4197.
request_apply_commit was removed with the non-PR config flow; the
approvals tool group is exactly request_init_config and
request_update_meta_inputs (hive-sh4re/src/permissions.rs). The system
prompt every agent is rendered from still named it three times, so an
agent could read the prompt, call the tool it describes, and get an
unknown-tool failure with nothing pointing at why.
Also fixed the approval-boundary paragraph's description of the config-
change flow itself, not just the tool name: creating an agent is
request_init_config then the operator's own Spawn approval from the
dashboard; changing an agent's config is a forge PR on
agent-configs/<name> that queues a MergeConfigPr approval on open/update
-- no MCP tool call in that path at all. docs/tools/lifecycle.md already
described this correctly; only the prompt was stale.
fixes#4226
README.md described `swarm.hives.<name>.domain` two ways, 100 lines apart. §
*Hive identity config* says it defaults to `<name>.<swarm.domain>` and shows a
names-only directory; § *The swarm's hive directory* called it "required per
entry and deliberately undefaulted" and wrote a domain into every entry.
The module sides with the first (`nix/host-modules/swarm.nix:125`):
default = if swarmCfg.domain == null
then "${name}.invalid"
else "${name}.${swarmCfg.domain}";
and `:110` answers the stale paragraph's objection directly — the default is "a
derivation from two values an operator had to state explicitly (both are
required), not a guess".
Ordinary drift, two hours wide: `433b2940` wrote "deliberately undefaulted" at
20:44 on 08-05, `3b6576fa` added the default at 22:43 the same evening under the
message "a hive's domain comes out of the swarm directory". Both mine.
The example now shows what the convention actually looks like — names only, plus
one off-convention hive carrying the override, which is the case the option
exists for.
Closes#4220.
jobq.md's "Watching it happen" section ended by promising that a step which
isn't needed "shows as `·` rather than dropping out of the tree entirely, so
the same kind of operation keeps a recognizable shape run to run".
In that view it does drop out. `JobqGraph.tsx:94` sets
`DEFAULT_HIDDEN_STATES = {Done, Skipped}` under the comment 'Product call:
"default selection filters out skipped and done"', `:299` seeds the selection as
everything except those, and `:271` puts the selection in the query string — so
the hidden states are never fetched, not merely styled out. That component is
what renders both surfaces the page names, and neither passes an override.
The claim is true of the data and false of the screen, in a section about what
is on the screen. Keeping both: the node stays in the graph, and the default
filter is named, along with the part that would otherwise surprise someone
debugging it — the selection is a request parameter.
Closes#4223.
`5478e0bf` (`fix(#3554)`) made both store exporters unconditional and removed
the "somewhere to send" assertion, and touched no documentation — its diffstat
is four `nix/` files. `services.md` § *Telemetry collector (OTEL)* still
described the old shape in two places.
① "it writes the store above and exports to `otel.endpoint`, doing both when
both are configured" — only the upstream is conditional. `exporterNames`
(swarm-otel.nix:231) always carries the store exporter, with a comment saying
why: `deploy.victoriametrics.enable` means "this host RUNS the store", and a
swarm has one either way.
② "With neither `otel.endpoint` nor the store enabled, this module refuses the
collector at eval" — that assertion is gone. swarm-otel.nix:693 records the
removal at the head of the assertions list, and `5478e0bf`'s message states it
was a ruling rather than an oversight: a collector on a host of its own is a
supported shape, so refusing to build it would have made the fix illegal
exactly where the bug bit hardest. `git grep` finds no surviving assertion of
that shape.
The replacement paragraph states the invariant an operator can act on — the
exporters have no gate, the stores are addressed by swarm name — and keeps the
failure it prevents, since "an absent exporter is not an error" is the part that
made the old bug silent.
The stale claim dates to `80c9118f` (2026-08-18), 13 days before the behaviour
changed under it.
Closes#4214.
secrets.md said the swarm collector's OIDC secret is copied "when authelia is
enabled on this host and something published is being scraped; otherwise no
secret is needed and none is placed". The second condition left the tree in
5478e0bf (#3554): `swarm-otel-oidc-secret` and the client registration it
delivers for are both `lib.mkIf deployCfg.authelia.enable`, and
`publishedScrapeTargets` appears in neither guard. swarm-otel.nix:589 says why
in as many words — authelia refuses a bearer-authz client with no audience,
which is what the old guard was for, and the push audiences are unconditional,
so there is now always one.
The sentence survived because I rewrote the paragraph around it a day later
(8cba57e0) for the collector-elsewhere case and carried the clause through.
What a reader loses: with authelia on and nothing published, the page promises
the delivery is inert. It is not — the unit runs, waits a bounded 120s for
authelia's mint, and fails the collector's container start if the secret never
appears. So the case documented as quiet is one that can fail a boot, and the
operator debugging it has a page saying this path was not taken. The
replacement states the single real condition and that failure mode.
Closes#4210.
security.md's "What's NOT exposed" list said `/home/<name>/.claude/` is mode
`0700`. Measured from inside an agent container — the vantage the claim is
about, since the nixbld users in this threat model run there — it is `0755`.
`ensure_claude_dir` (hive-c0re/src/lifecycle/setup.rs) chmods it to 0755
deliberately, and its comment says why: hive-core is a different user and needs
read+execute to list the directory so `claude_has_session` can detect a valid
session. So the code is right and the doc named a mechanism that was never the
real one.
The conclusion the doc drew still holds — nixbld users can't read anything that
matters. Every sensitive entry is protected on its own: `.credentials.json`,
`history.jsonl` and `settings.json` at 0600, `projects/` and `sessions/` at
0700, and the five `backups/*.backup.*` at 0600. 32 nixbld users exist on this
host (uid 30001+) and none of them can read any of it.
What changes is the invariant a reader should rely on. The directory mode was
the doc's whole stated guarantee and it isn't there; the protection is per-file,
which fails differently — anything landing in `.claude/` at a default mode is
world-readable while the doc says it cannot be. `plugins/` (596 files, 0755) and
`.last-cleanup` (0644) already demonstrate that files do land loose there. Both
are harmless; the point is that nothing stops the next one from being.
Closes#4204.
`cfg` is whatever the reading module bound it to. It does not exist in a
NixOS configuration, so a sentence naming an option as `cfg.<name>` is
correct about behaviour and unusable as an instruction — the reader has
to go find the real path.
The four sites in docs/networking/gateway.md this was filed for:
cfg.sshPort -> services.hyperhive.swarm.forge.sshPort
cfg.dashboardPort -> services.hyperhive.c0re.dashboardPort
cfg.frontend (x2) -> services.hyperhive.c0re.frontend
Sweeping docs/ for the pattern rather than the ticket's line numbers
found five more, in four other files:
approvals.md cfg.hyperhiveFlake -> services.hyperhive.c0re.hyperhiveFlake
matrix.md cfg.registrationTokenFile -> services.hyperhive.deploy.matrix.registrationTokenFile
matrix.md cfg.gatewayHost -> services.hyperhive.swarm.matrix.gatewayHost
conventions.md cfg.dashboardPort -> services.hyperhive.c0re.dashboardPort
gotchas.md cfg.dashboardPort -> services.hyperhive.c0re.dashboardPort
matrix.md is the clearest case for doing this at all: its two `cfg.`
references resolve to *different* option trees — `deploy.matrix` and
`swarm.matrix` — so the shorthand is ambiguous even within one file.
Each path is read off the `mkOption` that declares it plus the
`options.services.hyperhive.*` root it sits under, with the indentation
checked so a nested block cannot have been missed. `cfg.frontend` is
declared in hive-c0re, not the gateway: the gateway module binds
`cfg = config.services.hyperhive.gateway`, which has no `frontend`.
Deliberately unchanged: docs/networking/snapshot-store.md:136, where
`cfg.port` sits inside a ```nix block quoting module source. `cfg` is
correct there, and rewriting it would make the snippet wrong.
Closes#4193.
`nix/host-modules/swarm-peers-removed.nix` exists to turn an "option does
not exist" error into a warning that says where the entries went. That
warning is its whole deliverable, and no fixture set `swarm.peers` — the
string appeared 0 times in module-eval.nix, so the shim was never
evaluated by anything.
It differs from its ten siblings in what a broken shim looks like. The
others re-route a value, so a failure shows up as a wrong rendered
config. This one renders prose nothing reads back, so a `mkIf` that
stops matching or a rename of the `swarm.hives` it points at fails
silently, and lands on the one operator who needed it.
The check already carries an old-path fixture for ten migrated
namespaces (wireguard, forge, matrix, nats, authelia, controller, ui,
stores, grafana, statusPublish). `swarm.peers` was the eleventh and the
only uncovered one.
Two peers, only one carrying `caCert`, because the module emits a second
warning filtered on exactly that attribute — with a single peer the
filtered list and `attrNames` are the same list, so a `withCaCert` that
had collapsed into `attrNames` would still read green. The third case is
the control: a hive that never set `peers` must get neither warning,
without which the other two pass on any config whose warning list
happens to carry the string.
Closes#4188.
#4195 fixed `d /run/hyperhive` to 0751, matching hive-c0re.service's own
RuntimeDirectoryMode and docs/trust-boundary/boundary.md. Nothing stops
it drifting back: the mode is a string literal inside a function that
writes a file and then shells out to systemd-tmpfiles, and a test can do
neither of those things.
`agent_tmpfiles_content` splits the pure content builder out so the modes
can be asserted at all. Two tests:
- /run/hyperhive is 0751, and explicitly NOT 0750 — the regression #4195
fixed. 0750 denies the `o=--x` traversal a `hive-admin` member who is
not in `hive-core` needs to reach host.sock, before the socket's own
0660 gate is ever consulted.
- the per-agent socket dir line, and that the body grows with the roster.
A mode nobody can assert is a mode that drifts. This adds no behaviour
change of its own; it pins the one #4195 landed.
Refs #4195.
7 bail! messages in pr_merge.rs were prefixed pr-merge: -- the flat-alias
verb name that main.rs's renamed-verb list now refuses to run, not the
current pr merge kind-namespaced form. Someone who read the prefix and
tried hive-forge pr-merge got a second, unrelated refusal for a verb that
doesn't exist anymore.
#4157 swept the renamed verbs out of docs, prompts and the deprecation
list; it didn't cover runtime error prefixes, which is where these
survived (same shape as the two pr_status.rs had, fixed in #4182/#4184).
One of the seven also told the reader to check pr status --pr {number};
now pr status {number}, matching #4184's new positional form.
fixes#4183
`deploy.allSwarmServices` derives nine service enables and appeared
nowhere in the check, so the tier a service sits on was prose only. The
tenth, the swarm controller, already had this exact pair of cases — it
rides `singleHostSwarm` instead, and swarm-ui follows the controller.
Four cases: the switch turns its nine on, a hive that does not host them
runs none, an operator placing one elsewhere still wins over the
`mkDefault`, and hosting the shared services does not make a hive the
swarm's control plane.
The roster is counted before it is read: `lib.all` over an empty set
holds vacuously, so a roster that lost a member would otherwise turn the
case green by measuring nothing.
Closes#4186
sync_agent_tmpfiles wrote 'd /run/hyperhive 0750 hive-core hive-core -' into
/etc/tmpfiles.d/hyperhive-agents.conf on every agent spawn/destroy -- five
months after 30a2a2e9 moved the directory to 0751 so hive-admin members
(no hive-core membership) can traverse into it to reach host.sock.
the 0750 write itself always EROFSes (hive-priv's ReadWritePaths doesn't
include /run/hyperhive, tracked separately as #4194 -- do not 'fix' that
by adding it there, since a *succeeding* 0750 write would permanently
break traversal on every host). so the directory only ever stayed at the
correct 0751 by accident, preserved by a failing privileged call, and any
mechanism that ever recreates the dir at the old baked-in RuntimeDirectoryMode
default would un-mask this instantly. matching the tmpfiles line to 0751
removes the wrong write entirely: no mode to change, no fchmod, no EROFS.
root cause found by atlas, digging in from the other end while verifying
boundary.md's permission numbers against the running host. credit is
entirely theirs -- I'd been chasing reboot-vs-restart theories on the
issue thread without finding this.
fixes#4078
The TLS section read as universal — "the gateway always terminates TLS",
"listens on httpsPort on every vhost". Measured against the rendered
config of an all-local host: ten of eleven vhosts carry two listens and
addSSL/forceSSL, and `grafana-metrics` carries one plain-HTTP listen on
127.0.0.1 with a single `= /metrics` location proxying grafana's unix
socket for the local collector.
The exception is right; a doc with no room for it is not. Someone asking
"is anything here serving plain HTTP" was told no, when the useful answer
is "no, except an internal class that is loopback-bound and deliberately
exempt" — which tells them what to look for on the next host.
One sentence where the modes are introduced rather than a patch to each
sentence that inherits the assumption, plus "every vhost" -> "every
routable vhost" where the self-signed default repeats it.
Two neighbouring claims were checked at the same time and hold: /_matrix/
proxies to 127.0.0.1:8008, and .well-known/matrix/client returns the
homeserver base_url with no port suffix on 443.
Closes#4191
the module doc pointed at docs/integrations/forge.md::Sources, a section
that was never written. the actual rationale + host differences it
promises already live in docs/integrations/github.md's Notifications
section (auth scheme, cadence) -- point there instead of duplicating
content across two docs.
fixes#4189
`docs/tools/forge-cli.md` is generated from the clap tree, so changing an
argument or its doc comment changes that file too. The freshness check in
`checks.hive-forge-docs` is what says so, and it is what failed here —
after 8m44s, on a PR that was already approved.
Regenerated, no hand edits. Two hunks, both the `pr status` section, and
`nix fmt` leaves the result byte-identical so the formatter and the
generator are not fighting over it.
Part of #4182.
Every other PR-scoped verb takes the number as a positional — `pr show
42`, `pr comments 42`, `pr assign-reviewer 42 argus`. `pr status` alone
required `--pr 42`, so whichever form you learn first is wrong for the
other, and clap's error for the mistake suggests `-- --pr`, which would
pass the literal string on as the next positional.
The number is positional here too now. `--pr` stays, because it was the
only spelling this verb had; `--sha` keeps its flag because the two are
alternatives rather than one required argument.
Two error strings in this file also named `pr-status`, a form that has
refused to run since the subcommand rename. One of them is the message
you get for passing neither argument — i.e. exactly when you are already
unsure what the verb is called.
The four new cases cover both halves: that clap accepts each spelling,
and that something reads it. `target_pr` is named rather than inlined
for exactly that reason — dropping the positional from the selection
passed every parse-only case while leaving `pr status 42` reporting
"pass a PR number". The conflict case earns its keep the same way: clap
accepting an argument and clap ignoring it are indistinguishable from a
passing parse, so `42 --sha abc` and `42 --pr 42` both have to be
rejected.
Closes#4182.
observability.md scoped the whole feature to Claude Code statistics, so a
reader asking what leaves an agent container would have concluded
counters and nothing else.
That page also documents the trust properties of the agent->collector
hop, and those are what needs restating for a richer payload: same
destination, same credential, same already-accepted ability to push
arbitrary OTLP. What changes is how much detail leaves by default.
docs/swarm/services.md needs no change. It already said each hive ships
the journals of "the harness units inside every agent container" — which
was not true until now.
Part of #3940.
Nothing in this suite evaluated nix/agent-modules/ at all: every fixture
was a host, so a typo in a rendered container config surfaced on a real
deploy and nowhere else. This adds an `agent` constructor beside `hive`,
off the same `nixosModules.agent-base` the meta flake hands a container.
It also adds the suite's first two-hive fixture. Every existing one
declares `swarm.hives.h1` alone, so a per-hive arm written against those
passes on a hardcoded literal — which is exactly what the new per-hive
logs pipeline needed covering.
Eight cases, each paired with the control that makes it mean something:
the absence arms with a presence half, the per-hive arm with a roster
length check, because `lib.all` over an empty roster holds vacuously.
Each was then shown to fail. Eight mutations across the three files —
`directory` back to the runtime default, a pipeline naming no receiver,
an exporter aimed at a loopback literal, an exporter name that stops
reading `protocol`, a missing hive-tier logs pipeline, the metrics-only
processor inside it, a per-hive pipeline hardcoded to one hive, and logs
pipelines exporting to the metrics store — all caught, none survived,
none skipped, each run's baseline green.
Part of #3940.
An agent container writes a complete journal — 991 MB and nine days deep
on this hive — that nothing outside it can read: the host-side
per-container journal directory is an id-mapped bind mount, and journald
writes nothing into it. So the reader has to run inside the container,
and the path it would push to did not exist.
Three tiers, one vertical slice, because any two of them alone are
silent:
- the agent container gains an `opentelemetry-collector` with a
`journald` receiver aimed at its own journal and an exporter aimed at
the same base address every in-process producer already exports to.
- the hive collector gains `service.pipelines.logs`. Without it the
`otlp` receiver answers 404 on `/v1/logs` — measured, and
indistinguishable from a route that was never meant to exist.
- the swarm collector gains a per-hive `logs/<hive>` pipeline beside
`metrics/<hive>`. Without it the push is accepted, answered 200, and
routed nowhere.
The receiver's `directory` is stated rather than inherited, and that is
the load-bearing line: its default is the RUNTIME journal
(`/run/log/journal`), which in an agent container is empty. Left at the
default this whole path validates, starts, reports healthy and forwards
nothing. The assertion beside it covers the same silence from the other
end — a `volatile` or `none` journald storage empties the directory the
receiver reads.
Attribution follows the tier that can prove it. The forwarder stamps
`agent`, which no host-side reader could supply; `hive` is deliberately
left to the swarm tier, which upserts it from whichever receiver
accepted the sample, precisely so the label comes from something the
sender cannot write.
No `units` allowlist, unlike the swarm tier's journald receiver. That
one needs one because the host's journal also holds an operator's own
session; a container's journal is the harness and what the harness
spawns. Measured volume is 20827 entries / 6.3 MB per agent per day,
with nothing logging below `info` — so the receiver's `info` default
filters nothing and there is no bill to justify a knob.
Agent containers only, per the ruling on the issue: swarm services need
one forwarder per service container and get re-measured once this works.
Part of #3940.
The read policy and cert-auth role for each hive were written once, at
startup. On the deploy that surfaced this, the store was still coming
up, the pass logged its warning and moved on, and no hive could log in
until someone restarted the daemon — while cert auth answered "no chain
matching all constraints", which reads like a certificate problem
rather than a role that was never created.
The bootstrap unit in swarm-bao.nix lost the same race and won on its
retry 30s later. A daemon that boots alongside its store loses that race
routinely; on a normal boot it is the ordinary case.
The two passes fold into one `provision()` that logs in once instead of
twice for two loops over the same list, keeping policy before role since
the role names the policy. `ensure_hive_access` still awaits the first
pass, so a store that is already up leaves nothing deferred, and only a
pass that could not reach the store at all spawns the retry.
The retry is `config_pr::spawn`'s idiom from this same crate: an
interval task whose first tick is immediate. Its cadence and bound match
the bootstrap unit's — 30s, ~a day — because the two halves of one race
should not disagree about how long a wait is worth.
`Error::MissingEnv` is what keeps it from spinning forever: no `BAO_*`
set means a deployment that runs no store, where asking again changes
nothing, so it returns Ok. Everything else is retryable, including an
authority file that is not placed yet — the unit that writes it starts
alongside this one. Both cases previously landed in the same "not
managed here" line, so a store that was late looked exactly like one
that was never configured.
Per-hive failures keep their old behaviour: logged, skipped, Ok. A store
that refuses one hive's write refuses it again, so the next start really
is the right retry for those, and the module doc still says so.
Closes#4176.