mara: "make clickable badges a proper pill not a roundrect" (#4276).
.ui-badge's base border-radius: 1em is a genuine pill at its own
compact display-only height, but .ui-badge-interactive's min-height:
2.75em (the WCAG 2.5.5 touch-target floor) makes an interactive badge
tall enough that the fixed 1em radius no longer reaches half the box's
height -- the corners round without the sides ever meeting the same
curve, i.e. a rounded rectangle. border-radius: 999px is the standard
stadium-shape trick (the browser clamps it to exactly half the box's
height whenever it exceeds it), same value .hive-pill/.hive-pill-sm
already use. Shared component -- fixes every interactive Badge caller
(swarm-ui's WantedMenu/AgentCard/model-effort pickers, the per-agent
page's StatusChips/MetaNav/HeaderPill) in one place.
Verified with a real headless-chromium screenshot of the /components
Badge section before/after: the interactive "model sonnet"/"pause"
badges were visibly rectangular with rounded corners before this,
matching the non-interactive display badges' pill shape after.
Both places that warned about a hive named after the controller's cert-auth
subject still told the reader it was unmitigated, and the option's description
recommended a migration — "change this to something outside the hive-name
grammar, at the cost of a role rename in any store that has already run the
granting unit" — that is no longer the answer and is not cheap.
swarm.nix now feeds the subject into the guard on `swarm.hives`, so a
colliding roster fails evaluation. Both paragraphs get SHORTER saying so: a
guarded hazard needs the consequence ("reserved as a hive name") rather than
the threat model, and the write-site keeps only the sentence the next person
needs — a role added beside this one must join that list.
Found by sweeping for the claim rather than for the symbol: the change that
made these stale touched swarm.nix, swarm-otel.nix and module-eval.nix, so no
diff-context or doc-comment rule covers a paragraph two files away. Grepping
the tree for "would satisfy" and for prose about a hive named after a subject
turned up exactly these two and nothing in docs/.
mara, PR review: "make agentspage a subdir now that its split into
sub components". AgentsPage.tsx/.css, AgentCard.tsx/.css,
AgentTypes.ts, and WantedMenu.tsx move as a family into their own
pages/agents/ directory; CreateAgentForm and LinkMatrixAccountForm
stay in pages/ since they aren't part of this split (CreateAgentForm
is still rendered inside AgentsPage's own dialog but is a standalone,
independently-named form, not one of the pieces carved out of the
page itself).
Pure move: relative imports within the new pages/agents/ family are
unchanged (they were always siblings), only the ones reaching back
out to ui/ and the two forms above gained one more '../', plus
App.tsx's route import.
Three more asks from the same review thread:
- "agentspage is now giant and deserves a split" - AgentsPage.tsx was
1047 lines. Split into AgentTypes.ts (AgentRow and friends),
WantedMenu.tsx, AgentCard.tsx (+ its own CSS), leaving AgentsPage.tsx
as state/actions/columns/the render tree - 649 lines, and every piece
it composes is now independently readable.
- "what about the component that represents filtered data ... that the
card view and table can both use?" - extracted FilterableView
(ui/filterable-view/): takes columns/rows/rowKey/storageKey/view/
renderCard, builds its filter bar from *every* filterable column (not
a hand-picked subset - the old AgentFilterBar only showed 4 of the
agent columns' 6 filterable fields, an accidental gap the table's own
popovers didn't have), and renders either the card list or Table.
AgentsPage now just tells it which view to show; the view toggle
itself stays page-side since it's Panel-header chrome, not filtering.
Disclosed side effect: card view's filter bar now also covers
message/config-PR (text filters), matching table view exactly instead
of a narrower subset.
- CSS audit: AgentsPage.css now holds only what's genuinely page-specific
(the view toggle, the detail-panel field grid) - everything else moved
to its owning component's own colocated CSS.
FilterableView gets a /components demo (view toggle + filter bar + both
render modes, same day per the design guide). Verified: AgentsPage
still renders the same (real screenshot), and the demo's own table
toggle produces a real Table with the same rows.
argus, PR review: detailTarget stored the whole AgentRow object at
selection time, so it never picked up a later refresh() or a
declareState patch - the card list updated live, the panel next to it
kept showing whatever was true the moment it was opened. Concrete
repro: open an agent's detail panel, wait for the next refresh or
destroy it from inside the panel itself, watch the panel not update.
Fix: store only the selected agent's name (detailTargetName) and
re-derive the actual row from rows on every render
(rows?.find(r => r.name === detailTargetName)). The panel can't drift
from what the list is showing since it's reading the same array.
Verified with a real refresh cycle against a mock server that returns
different data on the second call: before, both card and panel show
"idle"; after a live refresh, both show the new value in sync.
Mara: "result looks like the shape i am looking for, but the code does
not. you did not follow component first principle" - the card's
clickable/selectable mechanics, the card-view filter trigger+popover,
and the list+detail split layout were all one-off page-local JSX in
AgentsPage.tsx instead of docs/web-ui/design-guide.md's "Component-first
design" primitives. Three new ui/ components, each with a same-day
/components demo section per that doc's own rule:
- ui/card/Card.tsx - the role=button/keyboard-activation/selected
mechanics AgentCard now wraps agent-specific content around, instead
of owning them itself.
- ui/multiselect-filter/MultiselectFilter.tsx - the checkbox-list
trigger+popover control. This was also a straight duplicate of
Table's own inline popover content once AgentsPage's filter toolbar
needed the identical thing; Table now renders the same
MultiselectFilterOptions piece too (keeping its own th-anchored
trigger and fixed+portal positioning, which are genuinely
table-specific), not a second copy.
- ui/split-view/SplitView.tsx - the list+detail flex-wrap layout, no
opinion on what's inside either pane.
AgentsPage.tsx's own CSS shrinks to just the agent-specific content
inside these primitives (card line/message layout, detail-panel field
grid, the name-search input) - the container/positioning rules moved
to each component's own colocated CSS.
No behavior change for any other Table caller (HivesPage,
IssueReportPage, the components demo's own Table samples) - the
popover's visual output is identical, just sourced from the shared
component instead of inline JSX.
Verified: typecheck/build clean, real screenshots of both AgentsPage
(pixel-identical to before) and the three new /components sections,
plus a live click confirming MultiselectFilter's popover opens
correctly on the demo page too.
Two more asks from mara's live review:
- "i want the same filters for the cards tho, thats why i suggested
separating data and filter from view" - extracted Table's filter
*state* (not its popover UI, which stays table-shaped) into a new
exported useTableFilters hook. Table calls it internally, unchanged
behavior for every existing caller. AgentsPage now calls the same hook
with the same storageKey, so card view and table view read/write one
shared filter state instead of each having their own (or cards having
none at all). Card view gets its own toolbar (AgentFilterBar) - a
name search input plus one FilterMultiselect per multiselect column,
same checkbox-list markup Table's own popover uses, driving the same
state. Switching the view toggle no longer loses or hides whatever's
filtered.
- "why no separate panel? i mean a second panel on agent page" - replaced
the modal Dialog with a real second Panel, always mounted (empty state
when nothing's selected, so selecting an agent never shifts the page's
own layout). Panel gained an optional `class` prop so the two panels
can flex-size themselves in a row. List/detail panels sit side by side
in a flex-wrap row that stacks on a narrow viewport - content-driven,
same approach the shell's own nav uses, not a second hardcoded
breakpoint. Selected card gets a highlight so it's clear which one the
detail panel is showing.
Verified with real CDP clicks: split layout with nothing selected,
selecting a card highlights it and populates the detail panel, opening
a card-view filter and checking a value narrows both the card list AND
(after switching the toggle) the table to the identical row set.
Three things from mara's PR review + argus's:
- "the info from main list should be included in the agent view" - detail
panel now repeats status/message/wanted alongside the panel-only fields
(hive, config PR, matrix link), not just the leftovers.
- "destroy is already available via wanted state" - dropped the standalone
"destroy agent" button; the detail panel's wanted field is a real
WantedMenu (default showDestroy) instead, same control as the card/table,
just with the fourth option back. Also resolves argus's stale-doc-comment
finding (the comment described a second WantedMenu call site that didn't
exist yet - now it does).
- Rebased onto main to pick up the just-merged dropdown-portal-clip fix -
this branch was cut before that merged, so it had silently regressed
back to the pre-fix Dropdown the whole time.
That rebase surfaced a real bug of its own, likely "the third screenshot
shows a layout bug": WantedMenu always passed `portal` to Dropdown, and a
portaled (position: fixed, body-appended) dropdown renders *behind* an
open native <dialog> - the dialog is promoted to the browser's top layer,
which composites above ordinary body content regardless of z-index. Only
the sliver of the dropdown extending past the dialog's own edge was
visible. WantedMenu's `portal` is now its own prop, opt-in, true only at
the table's call site (the one with an actual clipping ancestor to escape)
- card and detail-panel call sites render it as a plain child instead,
which is both correct inside the dialog and one fewer moving part where
it isn't needed.
Verified with real CDP clicks: detail panel shows all fields, and its
wanted dropdown now renders in the right place with all four options
visible instead of mostly hidden behind the dialog.
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.