Commit graph

4,231 commits

Author SHA1 Message Date
damocles
162b646e5b swarm-controller: KV-store + serve per-agent status (#3341 item 2) 2026-09-02 10:20:29 +02:00
iris
340aa5448f frontend: finish the dashboard comment-density sweep
Continues #3901 (dashboard round 1 was #3906, tabs.js/swarm.js).
Scanned the rest of packages/dashboard/src for the same 'moved to X'/
'now lives in Y' pattern: five more hits in schedules.js, common.js,
logs.js (two, one duplicating the other), core.js, and swarm.js.
All trimmed to state the current location as a fact rather than
narrating the move; one inline duplicate (logs.js's tab-default
comment restating the file-header pointer) dropped outright.
2026-09-02 09:06:15 +02:00
atlas
fa9b8ba73a hive-priv: three doc comments that disagree with their own validators
Follow-up to the same defect class as the `validate_container_name`
fix: prose on a privsep validator that does not match what the body
does. Found by enumerating all 33 fns in this file whose doc claims a
check or guarantee and reading body against doc. Three disagreed.

`validate_forge_admin_arg` overclaimed, which is the dangerous
direction. Its doc ended "Shell metacharacters are harmless since the
command is spawned directly (no shell), but we reject them
defensively" -- and no metacharacter is rejected anywhere: the body
takes null bytes, newlines and carriage returns only. The first clause
is true and is the actual safety argument, so it stays; the claim of a
defence that does not exist goes. The same doc also said "null bytes
and newlines" while the body rejects `\r` too.

`validate_bind_path` listed "no newlines, null bytes, or
double-quotes" and omitted the colon its body rejects -- the one that
matters most. The other three would corrupt the
`EXTRA_NSPAWN_FLAGS="..."` conf line; a colon corrupts nothing,
because `--bind=SRC:DST` is colon-separated, so a path carrying one
silently produces a different mount than the caller asked for. The
function's own bail! string already named colons.

Checked and left alone because they are accurate: `check_fd_agreement`
(both arms really do bail, so "in either direction" is earned),
`exec_forge_admin`, `validate_credential_name`.

Comments only, no behaviour change. Gated: fmt, clippy -D warnings, 21
tests, rustdoc under CI's docs-rustdoc lints, push-lints.
2026-09-02 09:05:24 +02:00
atlas
181a82e5a6 gateway: $connection_upgrade does not come from recommendedProxySettings
The doc comment on PROXY_HEADER_BLOCK attributed the variable to
`recommendedProxySettings = true`. It does not: nixpkgs' nginx module
emits `map $http_upgrade $connection_upgrade` into commonHttpConfig
outside that conditional, after four other independently-gated blocks.

The distinction is load-bearing rather than pedantic, because several
hyperhive vhosts set `recommendedProxySettings = false` per location
(hive-forge's auth_request endpoint, swarm-ui, swarm-victorialogs,
swarm-victoriametrics). Under the old comment, moving the per-agent
include near any of them reads as breaking the websocket headers, and
the fix someone would reach for is a second, redundant map in our own
http config.

Verified against the nixpkgs module rather than inferred: the
recommendedProxySettings block opens and closes ten lines before the
map, and `git grep connection_upgrade -- nix/` returns nothing, so
nothing on our side defines it either.
2026-09-02 09:04:24 +02:00
atlas
83bbd7a5ea swarm-ca: state the store-is-world-readable rule once, not three times
`docs/swarm/ca.md` said it under *Constraints on the material* and again
under *Distributing the root*; this module's header said it a third time,
directly above a line already pointing at that doc.

Measured: 6-gram intersection between the module's comments and the doc,
32 -> 6. The sentence now appears once in the tree.

Module header keeps what a reader editing this file cannot see from the
config: that one root makes cross-hive trust O(1), that the two
provisioning modes differ only in who writes the artifacts, and the ⚠️
that both root key and root cert are runtime files — the cert as a
consequence, and that one costs something real, since nothing whose trust
store is built at build time can name it. Everything behind those is a
`docs/x.md::Section` pointer with the heading checked to exist.

Dropped the O(n²)-per-peer-pinning story: the live reason is that one root
is O(1), and how it used to be configured is history.

In the doc, *Distributing the root* now links to *Constraints on the
material* rather than restating it. Intra-doc `#anchor` links are already
the convention there — 85 of them across `docs/`, 8 in `gateway.md` alone.

Comments + one doc paragraph; no behaviour change. Refs #3901.
2026-09-02 09:03:35 +02:00
atlas
55145ff8e8 hive-ci: keep the two load-bearing properties, drop the retelling
The module header restated `docs/scheduler/ci.md`'s credential-isolation
paragraph nearly word for word, and re-told the nix-sandbox gotcha that
`docs/process/gotchas.md` owns — both immediately above lines that already
pointed at those docs.

Measured, not eyeballed: 6-gram intersection between this file's comments
and `docs/scheduler/ci.md`, 74 -> 24.

Kept, because neither is visible from the config that implements it and a
reader changing this file needs both:

  * the container has its own network namespace, not the host's, so a PR's
    nix build cannot reach host-loopback services — a prompt-injection
    boundary rather than a preference;
  * the forge ADMIN token never enters this container; only a registration
    token is handed in.

Each is now one sentence naming the property and its consequence, with the
mechanism behind a `docs/x.md::Section` pointer whose heading was checked
to exist (`Container isolation`, `Container design`).

Also restated in the present: the container is non-ephemeral because the
runner writes its registered credentials on first registration and reuses
them, which is why an ephemeral one would re-register every restart. That
was previously a description of what gitea-actions-runner does, with the
reason left implicit.

Comments only; no behaviour change. Refs #3901.
2026-09-02 09:03:15 +02:00
atlas
0ec8a7d2f9 hive-priv: point at the socket-dir rationale instead of restating it
`docs/trust-boundary/boundary.md` § *the per-agent socket dir* already
explains why write permission on a directory confers the right to unlink
its entries and why the sticky bit does not restrain that. This comment
said the same thing in the same words, which is the "restating facts all
over" case rather than two independent explanations.

Measured rather than eyeballed, since "these look similar" is not a
finding: 6-gram intersection between this file's comments and that doc
went 36 -> 14. What remains is short shared phrasing — the
socket-activation sentence, which already carries its own pointer, and
the principals table, which is call-site material.

Kept at the call site, because a reader editing this line needs it and a
pointer alone would not stop the change:

  * that 0751 is deliberate and security-relevant, with the consequence
    named in one line;
  * that `d` re-applies on every sync, so an external chown does not
    survive the next agent's spawn.

That second one used to be written as history — a tug-of-war with a
`ChownSocketDir` unit that no longer exists. The constraint it carried is
still live, so it is stated in the present tense instead of as a story
about a removed mechanism.

Comments only; no behaviour change. Refs #3901.
2026-09-02 09:02:55 +02:00
atlas
e78a9d3034 hive-gateway: stop restating what gateway.md already says
Two comment blocks explained things `docs/networking/gateway.md` already
explains, one of them directly above a line pointing at that very doc. A
pointer under a duplicate is not a pointer; it is a duplicate with a
citation.

Measured, not eyeballed: 6-gram intersection between this file's comments
and that doc, 73 -> 31. The residual is mostly the one-line summaries kept
at the call sites, which necessarily share phrasing with the doc they
summarise — that is the floor of the metric, not remaining duplication.

  * the self-signed-cert import unit: kept the 🚨 that stops someone
    collapsing it into "point nginx at the CA dir", both jobs at one line
    each, and the blast radius (whole gateway, not one vhost). Dropped the
    `BIO_new_file()` mechanism and the production-outage narrative — the
    doc carries both, and the second is history.
  * the nginx-reload note: rewritten to say the one thing this file needs
    to say, that nothing here reloads nginx and hive-c0re drives it from
    the host through hive-priv. The per-unit-state dispatch table is the
    doc's, and only the doc has it in full.

Both pointers use the `docs/x.md::Section` form and name headings verified
to exist; the first replaces a parenthetical `("Self-signed TLS")` spelling
that no check can resolve.

Comments only; no behaviour change. Refs #3901.
2026-09-02 09:02:33 +02:00
damocles
4383760807 swarm-queue-client: fix broken intra-doc link in agent_status key() 2026-09-02 09:01:38 +02:00
damocles
e6d5e2da28 hive-c0re: publish each agent's status upward to the swarm queue (#3341 item 1) 2026-09-02 09:01:38 +02:00
atlas
5b27aa18c2 docs: repoint eighteen pointers whose section no longer exists
`check-doc-refs.sh` resolves the PATH half of a `docs/x.md::Section`
pointer and stops there. The section half rots the same way, and more
quietly: the file still exists, so every path-shaped check stays green
while the pointer names nothing.

Eighteen sites, five distinct pointers, each retargeted at a heading
verified to exist rather than at the nearest plausible one:

  docs/web-ui.md::Container row
    -> docs/web-ui/dashboard.md::Container row
  docs/web-ui.md::Shared terminal pane
    -> docs/web-ui/shape.md::Shared terminal pane
      Both sections moved out when docs/web-ui.md became a two-heading
      index. The path still resolves, which is exactly why nothing
      caught them.

  approvals.md::Helper events to the manager
    -> approvals.md::Helper events to the submitting agent
      Renamed with the manager special-casing removal; the pointer kept
      the old vocabulary.

  approvals.md::Migration from the pre-tag
    -> approvals.md::Startup migrations (older hosts)
      Same content, including the HIVE_SKIP_META_MIGRATION kill switch
      the citing comment names.

  agent-hierarchy.md::Current state
    -> ::Where the tree lives   (topology.rs, container_view.rs)
    -> ::Reparenting            (topology.rs's set_parent, host-sock)
      Split by what each site actually asks for rather than repointed
      uniformly: two want the format and the source-of-truth rule, two
      want the reparenting validation.

Three known-dead pointers are deliberately left alone:

  * `docs/integrations/forge.md::Sources` sits on a line PR #3927
    rewrites; fixing it here would conflict for no gain.
  * `docs/web-ui/shape.md::One unified channel` names real text that is
    bold inline rather than a heading — which of those counts as
    resolvable is the open question on #3922.
  * `persistence.md::Harness state files` should point at a heading
    whose own text contains backticks, and the backticked-pointer form
    cannot nest them. That is a limit of the convention, not a typo.

Comments only; no behaviour change. Refs #3922.
2026-09-02 09:00:23 +02:00
atlas
a6acf58b4f docs: stop writing repo-doc pointers as relative links rustdoc cannot resolve
Eleven doc comments pointed at `docs/` files as markdown links. Ten of
them render as broken hyperlinks in the docs rustdoc CI builds, and
nothing in the tree can tell.

Rustdoc renders a page at `target/doc/<crate>/<module…>/`, so a relative
link resolves against that directory and not against the source file it
was typed in. Every one of these except the single crate-root `//!` was
written for a reader resolving from the source tree, which is one `../`
short at module level and two short one directory deeper.

Two measurements on a throwaway crate, same build and same
`RUSTDOCFLAGS="-D rustdoc::all"`:

  * a bogus intra-doc link `[`no_such_item`]` is a hard error, so the
    `docs-rustdoc` check in nix/checks.nix works for its class;
  * a relative link to a nonexistent file in the same comment produces
    no diagnostic at all and lands in the html verbatim as
    href="../../../docs/does-not-exist.md".

So the class is invisible to the one gate whose stated purpose is to
stop a doc pointer dangling — and it is worse than the plain-text
failure that gate's comment describes, because a broken href still
looks clickable.

Fixing the depths was the other option and is rejected: the correct
depth is a function of how deeply the module is nested, so any module
move silently breaks it again, and no check we have would notice.

The link text was already the canonical pointer — `docs/x.md::Section`,
the same repo-root-relative form used everywhere else in the tree and
the form scripts/check-doc-refs.sh gates. Dropping the `[…](…)` wrapper
keeps every byte of information a reader uses and removes the only part
that was ever wrong.

Refs #3926.
2026-09-02 08:59:48 +02:00
atlas
d2747c7b77 refs: repoint seven comments that name files which have moved
Comments cite nix modules, scripts and crate source files constantly,
and nothing evaluates a comment — so when a file moves, the reference
rots silently and `nix flake check` stays green. A reader following one
finds nothing and cannot tell whether the file was renamed, deleted, or
never existed.

Seven such references, each repointed at the file that actually holds
the thing the sentence is about rather than at the directory the old
name became:

  hive-c0re/src/agent_config/limits.rs   hive-agent/src/mcp.rs
                                       -> hive-agent-mcp/src/mcp/mod.rs
  hive-agent-mcp/src/mcp/mod.rs          hive-c0re/src/limits.rs
                                       -> hive-c0re/src/agent_config/limits.rs
                                         (and the module path in the doc
                                          comment above it, which was stale
                                          in the same way)
  hive-c0re/src/forge/mod.rs             hive-c0re/src/knowledge.rs
                                       -> hive-c0re/src/workers/knowledge.rs
  nix/host-modules/hive-c0re/options.nix hive-c0re/src/hive_stats.rs
                                       -> hive-c0re/src/stats/hive_stats.rs
  nix/packages/default.nix               nix/host-modules/hive-c0re.nix
                                       -> .../hive-c0re/options.nix
  nix/agent-modules/network.nix          nix/host-modules/hive-gateway.nix
                                       -> .../hive-gateway/dnsmasq.nix
  frontend/README.md                     nix/modules/frontend.nix
                                       -> nix/packages/frontend.nix

The two `limits.rs` comments are a matched pair: each names the other's
old path, so the "keep in sync" instruction they exist to carry pointed
both ways at nothing.

Where a flat module became a directory the target is the file that
declares the named thing, not `default.nix` by reflex — the
`preBuildAgentTemplates` option is declared in `options.nix`, and the
DHCP pool that sentence is about lives in `dnsmasq.nix`.

Comments only; no behaviour change. Refs #3923, which is about whether a
gate should cover this class at all — that question is unanswered and
this does not close it.
2026-09-02 08:58:31 +02:00
atlas
9b14014077 docs(sso): document the machine surface, and stop restating it in nix
`docs/swarm/sso.md` described a person in a browser. The swarm's other
callers — the telemetry collector, the queue's auth-callout responder, each
hive's agents — hold no session and follow no redirect, and nothing operator-
facing said how they authenticate. Its relying-party table is forge and
matrix, both browser surfaces.

The new section carries what `swarm-authelia.nix` was holding in comments:
one client per hive because identity belongs to the directory, the audience
being that client id rather than a parallel naming scheme, and signed rather
than opaque tokens because the collector verifies offline against
`/jwks.json` while the queue introspects.

It also states the fail-closed rule once, in the place a reader looks before
touching a vhost: an error page answers 200, and `auth_request` reads any 2xx
as access granted. That shape has now appeared three times — this module's
`/api/` prefix and both of victorialogs' routes — which is what makes it
documentation rather than a comment.

The two comment blocks those replace shrink to the part that is genuinely
local: the submodule-typing reason these clients are a definition rather than
an append, and a loud warning against folding the machine prefix back into
`/`. The security warning stays at the site; only its consequence list moves.

Comments 495 -> 465 lines. Option `description` strings are untouched: they
are the source `pkgs.nixosOptionsDoc` renders into the operator's options
reference, so trimming one would delete published documentation rather than a
duplicate.
2026-09-02 08:57:23 +02:00
iris
f625151556 frontend: trim narrated-history comment in packages/shared/Badge.tsx
Continues #3901 (dashboard/#3906, agent+swarm-ui/#3917, this closes out
the packages/shared slice — jobq-graph was already done separately).
Scanned the rest of shared/src for the same 'used to be X, now Y'
pattern: one real hit. The other two matches (hive-btn.js's is=
attribute history, hive-side-panel.js's one-word /* legacy */ comment)
are load-bearing or too trivial to touch.
2026-09-02 08:41:07 +02:00
damocles
223ac257e0 job_queue: trim NodeKind/Resource doc comments now that coordinator.md covers them 2026-09-02 08:39:52 +02:00
iris
c2733d2edf frontend: trim narrated-history comments in packages/agent, packages/swarm-ui
Continues #3901. Same pattern as tabs.js/swarm.js: cut 'used to be
X, now Y'/'moved to'/'no longer' change-history narration down to
the current design fact, keep every load-bearing rationale intact
(the ResizeObserver feedback-loop note in Header.tsx, the Dropdown-
vs-real-<a> semantics in MetaNav.tsx, etc.).
2026-09-02 08:38:46 +02:00
damocles
56c0602c2f hive-forge: add ci-runs listing verb, fix ci-log's ambiguous no-log message 2026-09-02 08:36:44 +02:00
atlas
55eaebc9cf coverage: run nightly and keep the report as an artifact
The operator never received a coverage report. The job existed but was
`workflow_dispatch` only, so nothing had ever run it — "there is a CI job
for it" was true and produced nothing.

Its header argued against a schedule: an instrumented build roughly doubles
a test job, and a nightly number nobody reads is farm time for nothing. That
was a cost judgement, and it has been made differently. The comment changes
with the trigger rather than staying to contradict it.

The hour is deliberate: the runner's job capacity defaults to 1
(`services.hyperhive.deploy.forgejo.ci.concurrency`, applied as
`runner.capacity` in nix/host-modules/hive-ci.nix), so at that setting this
job holds the runner for its whole timeout and everything else queues.

`--summary-only` wrote into the run log, which is not a place anyone
receives anything. The summary is now teed to a file and uploaded, so a
scheduled run leaves a report to fetch. `if: always()` keeps the partial
output from a failed run.

`set -o pipefail` is load-bearing: without it the step's status is `tee`'s,
so a failed run would report success and upload an empty report. Verified
that shape rather than assuming it — without the guard a failing pipeline
exits 0, with it 1, and a succeeding one still exits 0.
2026-09-02 08:35:34 +02:00
iris
5dce46830d reference-docs: virtualize the options bundle under docs/options/, like crates/
mara on hyperhive/website#59 ("is this slice 1? i expected the options
to fold into the main docs sidebar as well so that you have one all
docs tree" / "the same thing already happens: crate readmes get
included as well. add the virtual options dir"): apply the exact
technique already used for docs/crates/<crate>.md to the nixosOptionsDoc
bundle (packages.docs) — project it into the reference-docs tree at
docs/options/*.md rather than leaving it a wholly separate flake output
options.nix has to fetch and render on its own pipeline.

Simpler than the crates/ case: the options bundle is already
self-contained CommonMark with no relative doc-links needing rewriting
(nixosOptionsDoc's transformOptions already points every option
declaration at an absolute forge URL), so this is a straight
recursive copy, not a per-file sed pass.

website's docs.nix needs no changes to pick this up — its sidebar walk
is already generic over subdirectories, same reason the earlier
crates/ virtualization needed none. The website-side follow-up (has
docs.nix render these instead of options.nix's separate pipeline, and
what that means for the current /options/ URL) is a separate change on
that repo, not touched here.
2026-09-02 08:35:30 +02:00
iris
03d1552746 jobq-graph: animate new-node mount and state-change flash
Two of the three motion gaps mara flagged on the swarm-ui jobs graph
(the third, node status changes as a tree of nesting divs rather than
a node-link diagram, has no edges to animate today — see the issue
thread for that scoping correction).

- New node mount: .jg-node gets a fade+slide-in keyframe. No JS change
  needed — Preact only creates a new .jg-node DOM node when its key
  (the node id) is genuinely new, so this only plays on first
  appearance, not every fetch re-render.
- State change flash: NodeView tracks each node's previous state via
  a ref; on a real change it adds .jg-state-flash to the glyph span
  (removed on animationend), driving a scale pulse.

Both follow the same three-rule motion-guard shape as Shell.css's
shell-page-enter / LinksMenu.css's links-menu-popover-enter.
2026-09-02 03:09:46 +02:00
atlas
8dbccd5578 fix(#3124): adapt hive-c0re's converge loop to a Copy AgentState
Making `AgentState` `Copy` in the preceding commit is a change to every
consumer of the type, not to the crate that declares it. `hive-c0re` grew its
own consumer while this branch was in review, and under `-D pedantic` a
one-byte enum taken by reference is `trivially_copy_pass_by_ref` and a
`.clone()` on it is `clone_on_copy`. Neither crate is wrong alone; the merge
is.

`decide` now takes the state by value and the call sites drop the `&`. No
behaviour change — the function only matches on the value.
2026-09-02 02:48:39 +02:00
atlas
76d5871d20 feat(#3124): publish the agent set the swarm declares for each hive
The hive-side loop landed without anything to converge to: nothing wrote
`$KV.hive-wanted.<hive>`, so in production only the "no key" branch ran.
This is the writer.

`WantedWriter` mirrors `StatusReader` — that module reads what hives report,
this one writes what they are told, so it holds a client rather than a bucket
handle and resolves the store on first use. It shares the status reader's
connection: the controller has exactly one by design, and a second connect
would double the auth-callout traffic and give the two paths independent
reconnect state.

The value under a hive's key is the map of every agent on that hive, so a
plain `put` of a single-agent change would drop a concurrent change to a
different agent, with only one revision of history to not recover from.
Writes are read-modify-write against the entry revision, and only
`WrongLastRevision` / `AlreadyExists` count as a lost race — every other
error returns immediately rather than spinning the retry loop and then
blaming a concurrent writer that never existed.

`apply` is split out and tested because it holds the invariant: declaring
one agent preserves the rest, and a current value that will not decode is an
error rather than a fresh start. Overwriting a document nobody can read
discards every other agent's declaration.

Two routes, no swarmctl verb and no jobq node: `create_agent` needs a graph
because it is multi-step, and one CAS'd write is not.

`build_app` is extracted from `main` in the same change because `main` sat at
exactly the `too_many_lines` limit, so adding an endpoint tripped a lint
about the startup sequence. The route list is the part that grows.
2026-09-02 02:32:36 +02:00
damocles
066f31b58b docs/coordinator: add the remaining 15 NodeKind variants to the node inventory 2026-09-02 02:13:02 +02:00
damocles
d68d0e809e docs/coordinator: fix Create/Provision node-inventory staleness 2026-09-02 02:13:02 +02:00
iris
9a854a160b docs: fix ci.md's now-two-levels-deep relative link to .forgejo/workflows
argus caught this on review: ci.md's own content never changed (0 diff on the
move), but it went from one level under docs/ to two (docs/scheduler/ci.md),
so its `../.forgejo/workflows/ci.yml` link now needs `../../`.

Also fixes the verification script itself — it only checked link targets
ending in .md or / (a systematic blind spot for a link to any other file
type, which is exactly how this one slipped through both the grep sweep and
the link-resolution check). Re-ran the corrected version repo-wide (93
tracked .md files, matches argus's own count) — zero broken links.
2026-09-02 01:55:37 +02:00
iris
07b62612b0 docs: restructure into topic subdirectories, collapse duplicated index
Per mara's go-ahead on hyperhive#3902 ("getting started is good, but
terminal rendering does not go in there i think"):

Moved 21 top-level docs/*.md files into 7 new topic subdirectories
(existing web-ui/, turn-loop/, swarm/, tools/, crates/ untouched):
  getting-started/  setup.md
  agent-lifecycle/  agent-hierarchy.md, approvals.md, persistence.md
  trust-boundary/   boundary.md, security.md
  integrations/     forge.md, matrix.md, github.md, knowledge.md
  networking/       gateway.md, network.md, snapshot-store.md
  scheduler/        jobq.md, coordinator.md, ci.md, observability.md
  process/          conventions.md, gotchas.md, pr-review-gate.md
  web-ui/           terminal-rendering.md (moved into the EXISTING dir,
                    per mara's correction to the original getting-started
                    guess -- it's UI implementation detail, not onboarding)

The physical layout now matches docs/README.md's own topical headers,
which already amounted to this taxonomy -- see the scoping comment on
the issue for the two findings that motivated this (a genuine
duplication between CLAUDE.md's old "Reading paths" list and
docs/README.md's grouped one, since drifted out of sync with each
other; and the flat layout not matching the grouping we already had).

Fixed every cross-reference this moved across the whole repo (~120
files: docs/ internal links at every depth, Rust doc comments, nix
module option docs, crate READMEs) -- verified two ways: a grep sweep
confirming zero remaining references to any old path, and a script
that resolves every markdown link in docs/**/*.md + CLAUDE.md +
README.md against the filesystem and reports anything that doesn't
exist (zero broken links).

Collapsed CLAUDE.md's "Reading paths" section (the duplicate) down to
a pointer at docs/README.md, now the single index. Rewrote
docs/README.md itself to use the new subdirectory paths and added the
one doc it was missing that CLAUDE.md's old copy had (pr-review-gate.md).

Classified all 22 docs/*.md files first via a haiku subagent (mara's
suggestion) on two axes -- proposed grouping and operator-vs-
implementation focus -- before finalizing the taxonomy; spot-checked
the report and found internal inconsistencies (its classification
table disagreed with its own summary section for a few files), so this
taxonomy is my original proposal + the one correction mara gave
directly, not a blind application of the subagent's table. The
operator-focus data it gathered is still useful for a follow-up
content pass (docs skewing 'mixed' rather than pure operator-facing),
not addressed in this PR -- structure only.

nix fmt clean, both pre-push lints clean.
2026-09-02 01:55:37 +02:00
atlas
e4a22b4190 module-eval: cut the header to the new comment budget, drop the verdict note
mara set a 20-line comment maximum and scoped this file for the first pass.
Measured rather than eyeballed: this file had exactly one block over 20 — the
28-line header. Every other block in it is 8 lines or fewer, so the pass is
that header plus one removal.

The header keeps the four things a reader can act on — why this check exists,
assertion versus the absence class, name a case for the property it defends,
and evaluates-rather-than-executes with its stubbing rule — and loses the
connective prose around them.

The verdict-reading paragraph goes back out. It landed by a misclick on a PR
she meant to close, and the judgement is the one she gave its sibling: the
technique works, it just does not earn source space.

The lint still enforces 30. 208 blocks repo-wide exceed 20, so flipping MAX
belongs to the overhaul rather than to this file's pass.
2026-09-02 01:53:09 +02:00
damocles
b8d3b95641 hive-agent: trim narrative comment bloat in doc comments (#3901) 2026-09-02 01:52:53 +02:00
atlas
1f0b3cf0cc swarm-authelia: one fact in three places, kept in two
`docs/swarm/sso.md` already argues that the OIDC provider is derived from
the client list rather than carrying its own `enable` — authelia refuses to
start without clients, so a separate flag would be a second fact free to
disagree. The module header said it again, and the definition site said it a
third time.

The doc keeps the argument. The definition site keeps a short version,
because someone changing `oidcEnabled` needs the reason there and not two
files away, along with the part no doc carries: that it is unconditionally
true today and stays derived so the OIDC-gated code below is self-documenting
about why it is conditional.

The header also restated the users-store rationale the doc's "does not
provision users" bullet owns. What survives there is the one clause the doc
does not have — why a file backend is right rather than a placeholder for
LDAP, the subject set being bounded by one swarm.

503 comment lines to 495. Comment-only: still 36 module-eval properties.
2026-09-02 01:49:35 +02:00
atlas
e3121c1c81 swarm-otel: stop restating what docs/observability.md already says
Two named anti-patterns from the comment-budget issue, applied to the file
that carries the most comment lines in nix/host-modules.

The header re-explained the two-tier model and the co-location argument that
`docs/observability.md` already owns — "a boundary that disappears locally is
one the local deployment stops testing" appears in both, nearly word for word.
The doc is the right home for it; what stays here is the one fact the doc does
not carry, that this is a container because `services.opentelemetry-collector`
is a singleton option already taken by the hive tier.

The `logs_endpoint` block keeps every instruction and loses the narration:
use `logs_endpoint` not `endpoint`, both spellings pass `otelcol validate`
and every wrong path answers 400, `_msg_field` is required or text search
finds nothing while ingest still answers 200, `_stream_fields` sets stream
cardinality.

Six smaller trims of "measured"/"verified"/"once". One pointed at a probe
script under a single agent's state directory — a path no other reader can
open, in source every reader loads.

What stays: `validateConfigFile`'s "a parser, not a wiring check", the
`issuer_ca_path` warning, the swarm-tier stamp. Non-obvious, in no doc, and
the target is a share of the whole tree rather than a per-file quota.

604 comment lines to 574. Comment-only: the module still evaluates to the
same 36 module-eval properties.
2026-09-02 01:39:31 +02:00
iris
08f4f60106 dashboard: trim comment bloat in tabs.js + swarm.js
Per mara's guidance on hyperhive#3901 (target ~15% comment density
overall, less where obvious, more where not; prefer docs for
abstract/general topics; don't restate facts in multiple places;
don't document history).

tabs.js: removed 4 pure "X now lives in Y" / "moved to Z" historical
asides (the underlying facts are already documented in
docs/web-ui/dashboard.md, not lost by removing the floating in-code
aside) and merged one comment block that had drifted into restating
the same fact twice (one ticker feeding two live displays, documented
as if it were two separate tickers).

swarm.js: cut a comment narrating the removal history of two features
that no longer exist in the code (a per-agent queued-badge and a
client-side jobq tally), keeping only the design constraint still in
force (why the jobq-derived state here is deliberately narrow); cut a
comment documenting a removed CSS class's history down to a statement
of the current class's purpose; cut a comment restating the
jobq-rollup rendering rationale already stated once, above, down to a
one-line pointer; trimmed a "legacy flat layout is bit-identical"
history clause down to what the depth-0 case actually renders.
2026-09-02 01:32:47 +02:00
atlas
9749e9324d fix(#3894): an unreadable power intent must reach the reconcile queue
The boot sweep's error arm substituted `Wanted::from_running(running)`,
which is the one value for which `reconcile_action` returns `Noop` — both
ways. An agent whose `agent_power` row could not be read therefore could
never enter `drifted`, so on a fresh rev marker a corrupt row produced one
`warn!` per boot and no other signal, indefinitely.

Classify the unreadable case as its own outcome instead: the agent gets a
boot `Reconcile`, whose `get_or_seed` fails as a per-agent node — a surface
the dashboard already renders — and the failure stops at that one agent.

The classification moved into `boot_action`, a pure function, because the
loop had no tests at all. The first of the five asserts the unreadable case
across every (fresh × running) combination, which is exactly the matrix the
fabricated value made unreachable.
2026-09-02 00:51:16 +02:00
atlas
c90b999285 module-eval: say how to read the verdict without a build
The check's results are already embedded in its builder text — the comment
above `runCommand` says so — but nothing said that this makes the verdict
readable straight out of the derivation. So mutation-testing a case, which
is the documented way to prove a case can fail, has been costing a build on
the shared farm per arm when it costs none.

Command verified against both arms: on an unmutated tree it prints the
`N properties hold` line, and with a case deliberately broken it prints that
case's `FAILED:` line. The jq shape is envelope-agnostic, since `derivation
show` grew a `{"derivations": …}` wrapper.
2026-09-01 17:21:52 +02:00
atlas
978e66790c fix(#3892): a corrupt power-intent row is an error, not an absent one
`PowerStore::get` ended `Ok(row.and_then(|s| Wanted::parse(&s)))`, so a
row holding an unparseable value collapsed into the same `Ok(None)` as
no row at all. Its doc comment stated the narrower case as the whole one
("`None` when the agent has no row yet"), which is what kept the gap
invisible.

The damage is not that a caller reads the wrong value. `get_or_seed`
takes `None` to mean "never seeded" and writes
`Wanted::from_running(running)` — so a corrupted row silently
**overwrote itself** with whatever the container happened to be doing at
that moment, which is precisely the inference an authoritative intent
store exists to prevent. Its own comment says the DB is authoritative
once seeded.

The error arm was already in the signature; this makes it reachable.
Every caller already handles `Result`: the two `get_or_seed` paths
propagate, so a corrupt row now fails that agent's boot reconcile
instead of erasing itself, and `workers::wanted` already skips an agent
whose intent could not be read.

The second test is the one that matters — it asserts the row still holds
its original bytes afterwards, because the overwrite, not the read, was
the bug. Both tests carry a valid and an absent row alongside, so
neither can pass by breaking `get` for everything.

Found by argus reviewing #3891, which reads this store but does not
write it.
2026-09-01 17:11:32 +02:00
atlas
a4924aee4d fix(#3124): close AgentState — an unknown value is not a partial instruction
mara's call on the PR: "dont make the enum open, we will just add
entries later". The catch-all variant is gone, and with it the per-agent
inert path.

What changes is where version skew lands, not whether it is handled. An
unrecognised value used to be one agent this hive left alone; it is now a
decode failure for the whole declaration, so a hive running older code
converges *nothing* rather than obeying the agents it happened to
understand. That fails closed instead of dangerous, and it is the right
trade when both ends ship together — which is what "add entries later"
assumes.

The test moved with the property rather than being rewritten in place:
`an_unknown_state_fails_the_whole_declaration` lives in
swarm-queue-client, where the decode is, with a valid entry beside it as
the control. `hive-c0re` keeps a coverage check that every state this
build knows produces an action somewhere — asserting inertness there
would be asserting something the type system no longer lets me build.
2026-09-01 14:05:30 +02:00
atlas
37f3c63eeb feat(#3124): converge the hive onto the agent set the swarm declares
The deploy event is a nudge with no second path: core NATS is
at-most-once, so a hive that was down when the controller published
simply never learns that an agent is meant to exist here. This adds the
repair path — one boot-time DAG node that reads this hive's own key in
the `hive-wanted` bucket and converges the agents it names.

Two semantics settled on the issue thread, and both are places where a
plausible implementation is the wrong one:

- **Absence is not a deletion order.** No bucket, no key, or an agent
  the value does not name all mean the controller has said nothing.
  Swarm-side lifecycle does not yet cover agents that predate it, so
  "converge to exactly this set" would tear down every agent the swarm
  has not adopted. `plan` only ever inspects the agents a declaration
  names.
- **An unrecognised state is inert.** `AgentState` is an open enum: a
  value this build cannot read deserialises into `Unrecognised` and is
  left alone. A closed enum would force "not `Up`" onto a state like
  `paused`, so a controller that learned a new value would take agents
  down on every hive not yet updated.

Divergence is measured against the hive's **stored power intent**, not
the container's observed running state — an agent that is down while its
intent says `Up` is already the boot reconcile's work, and a loop reading
`is_running` would insert a start DAG behind that reconcile's back on
every boot. A hive that already agrees with its declaration queues
nothing at all.

`queue_first_deploy` is extracted from the deploy-event path rather than
open-coded here, for the power-intent seed: without it `first_deploy`'s
tail `Reconcile` seeds `Wanted` from a container that exists but has not
started yet, which locks the agent to `Offline` on its first reconcile.

The read is authorised as-is: `store.get` takes async-nats' direct-get
arm (the KV bucket is created with `allow_direct`), which is exactly the
`$JS.API.DIRECT.GET.KV_hive-wanted.$KV.hive-wanted.<hive>` subject
`swarm-nats-auth` grants a hive. The fallback subject is not granted, and
a refused NATS request surfaces as a timeout rather than an error.

Nothing writes the bucket yet — the controller-side writer is the other
half of #3124, so this does not close it.
2026-09-01 13:07:54 +02:00
atlas
8cba57e01c docs/swarm: the swarm collector need not share a host with authelia
This PR makes a swarm collector on a host with neither store — and no
authelia — an expressible deployment; `module-eval`'s `otelRemoteAuthelia`
case is exactly that. `secrets.md` still asserted the opposite in two
places, and one of them was the "hive elsewhere" column reading `n/a`.

The delivery unit is the convenience for the co-located case, not the
definition of it: whether the collector authenticates follows the
credential, never another service's placement.
2026-09-01 12:38:43 +02:00
atlas
5478e0bf67 fix(#3554): push to the swarm's stores by domain, authenticated
The collector's store exporters were gated on deploy.victoriametrics.enable /
deploy.victorialogs.enable — options that say "this host RUNS the store", not
"the swarm has one". A collector that did not share a host with the stores
rendered no exporter at all and dropped everything it received, from every
hive, silently: an absent exporter is not an error.

mara ruled the premise rather than the mechanism ("the swarm always has a
store"), so there is no gate and no new option for that. Both exporters are
unconditional and address the stores by domain, per the cross-host rule from
the OIDC client and secret-delivery unit #3517 already built. The logs exporter
had the identical bug and is fixed here too.

Both stores gained a machine ingest route, required in the same change: the
exporter now targets https://logs.<domain>/insert/..., and that vhost is
browser-shaped, so shipping the collector half alone would have regressed logs
ingestion that works today. Neither ingest location carries
`error_page 401 =302` — a pusher handed a redirect follows it and POSTs at a
login page, which answers 200.

Whether the collector authenticates follows the CREDENTIAL, never another
service's placement: `clientSecretFile` is a nullable option, and the delivery
unit — the one thing here that may know where authelia runs, since it copies
out of its container — sets it by mkDefault. An earlier revision gated this on
deploy.authelia.enable directly, which put a different service's co-location in
the collector's own config.

Also removed rather than relaxed: the assertion that this collector has
"somewhere to send". It read the store's per-host enable, so it rejected at
eval exactly the deployment reaching the stores by domain exists for.
Deliberately not replaced with an authentication assertion — a collector on a
host of its own is a supported shape, and refusing to build it would make this
fix illegal where the bug bites hardest.

Knock-on worth review: collectLogs is now always satisfied, so journald
collection is unconditional.

Config shape validated against otelcol-contrib 0.151.0 `validate`, with a
bogus-key control confirming the validator checks the extension schema.
module-eval: 31 properties.
2026-09-01 11:11:37 +02:00
atlas
a3612f5168 feat(#3849): a grafana board for the swarm's secret store
Nine panels against the metric names openbao actually emits — the prefix is
`vault_`, the fork kept upstream's names, and a board built on `openbao_`
would render perfectly and show nothing.

The top row works on a SEALED store, which is what a hive has until an
operator runs `operator init`: seal state, uptime, goroutines and a request
count that reads 0 rather than going blank. Everything below is empty until
then, and each panel's description says so rather than leaving an operator to
wonder which of the two it is looking at.

The seal panel reduces with `max()`. Once unsealed there are two
`vault_core_unsealed` series and the `cluster=""` one stays 0 forever, so a
bare selector reads SEALED on a healthy store — invisible while sealed, which
is why the metric names were sampled in both states.
2026-09-01 11:10:30 +02:00
atlas
7eb5c92d15 fix(#3849): scrape the path openbao actually serves
The store's scrape target rendered as `host:port` alone, so the collector
requested `/metrics` — a path openbao does not serve on any listener. Every
scrape since the target landed has 404'd, which reads as a dead exporter
rather than a wrong address, and no bao sample has ever reached the store.

`scrapeTargets` values may now carry a path and query. Both are optional and
omitted when absent, so every existing target renders the config it rendered
before. The query cannot ride in `metrics_path`: prometheus percent-encodes
the `?`, so it has to become `params`.

Measured against openbao 2.6.2 and otelcol-contrib 0.151.0, the pinned
versions: `/metrics` 404s ("this listener only serves the metrics endpoint"),
`/v1/sys/metrics` answers JSON, and only `?format=prometheus` — or an Accept
header the collector happens to send today — returns exposition text. The
query param is the half that does not depend on content negotiation.
2026-09-01 11:10:30 +02:00
atlas
fe81dcaf59 fix(#3813): seal with an RSA-OAEP key, the only mechanism the TPM has
`bao operator init` fails at the seal with CKR_MECHANISM_INVALID. The
pkcs11 seal asks for AES-GCM by default and the TPM does not offer it: a
TPM 2.0's symmetric modes are CBC/CFB/CTR/OFB/ECB, and openbao accepts
only AEAD mechanisms — AES-GCM or RSA-OAEP — so RSA-OAEP is the single
mechanism both sides implement.

Measured on the deployed token (`pkcs11-tool --list-mechanisms`, #3860):
no AES-GCM, and `RSA-PKCS-OAEP, keySize={1024,2048}, hw, encrypt,
decrypt` present. CBC is not a fallback — openbao's
`MechanismFromString` rejects `CKM_AES_CBC_PAD` as deprecated and its
encrypt path implements exactly the two AEAD mechanisms.

The key gets a new label so a store provisioned by the earlier module
keeps its unusable AES key without the two resolving to one label, and
the addkey step is now keyed on the label rather than on the store not
existing — otherwise an existing deployment never gains the RSA key.
2026-09-01 09:16:04 +02:00
atlas
c044a33be9 fix(#3882): pin the gid that owns the TPM node, on both sides of the boundary
#3880 bound /dev/tpmrm0 into the store's container, which was necessary and
not sufficient: a bind mount preserves the host's ownership, openbao runs as a
DynamicUser with an empty CapabilityBoundingSet (so no CAP_DAC_OVERRIDE), and
the host applies no tpm udev rule at all — measured, 0 hits across all 41 host
closures against 62 rule files as a control. The node therefore keeps the
kernel default and the seal cannot open it.

A name cannot fix this. NixOS allocates system groups at activation, per
machine, so `tss` — or any group declared on both sides — gets two different
ids, and the device node carries the number. mara picked pinning a gid with an
overridable default (deploy.bao.tpmGid).

The default sits above the range NixOS auto-assigns system groups from
(400-999, measured in update-users-groups.pl) and above the normal-user range,
and below systemd's DynamicUser range (61184-65519), so it collides with
nothing any of those allocate.

The module-eval case compares the two sides rather than checking each against
a literal: the property is that they AGREE, not what they agree on. Its
absence arm is a shamir store, which never opens a TPM and must not claim a
device node's group — without it, pinning unconditionally would look identical.
2026-08-31 23:03:19 +02:00
iris
e395737a25 swarm-ui: widen the shell body for the issue report route
mara (#3877): issue report too narrow — the 8-column table was
clipped by .shell-body's 60em readable-line-length cap, which is right
for the rest of the UI's cards/forms but too tight for a wide table.

damocles diagnosed the root cause and scoped the shape (route
allowlist + an additive .shell-body-wide modifier, no prop plumbing
through App.tsx) before asking for a nod; mara then routed the actual
build to me. Implemented that shape as scoped: /issues opts into a
90em cap via WIDE_BODY_ROUTES, every other route keeps 60em untouched.

Verified with real headless-chromium screenshots at 1280px: the issue
report's full 8-column row (through 'transitively blocks') now fits
with no horizontal scroll, and a second screenshot of /agents confirms
every other route is unaffected.
2026-08-31 22:51:07 +02:00
atlas
a58c7af3bd fix(#3860): provision the bao pkcs11 token inside its container
openbao runs as a DynamicUser, so the uid that has to open the token
store is allocated by the container's PID 1 and cannot be named by a
host unit — the store was created root-owned 0700 and the seal could
never read it. The provisioning unit moves inside the container and
hands the sqlite store over by group; the host keeps only the mkdir the
bind mount needs, create-only so a reboot does not re-impose 0700.

Two further layers blocked the same start, both measured while fixing
this one:

  - DynamicUser implies ProtectSystem=strict (systemd.exec(5)), so the
    bind mount was read-only to openbao however it was owned, and the
    pkcs11 library opens its store read-write. ReadWritePaths= is
    required and was absent.

  - allowedDevices renders DeviceAllow= and nothing else, and nspawn
    builds its own /dev as a fixed tmpfs and cannot create device nodes
    — verified against a live container, whose /dev holds no host
    devices at all. /dev/tpmrm0 was therefore absent inside swarm-bao,
    not merely unpermitted. It is now bound in.

Whether openbao's dynamic uid may *open* that node is a third question:
the tss gid is dynamically allocated, so no name or number means the
same thing on both sides of the boundary. Filed separately rather than
guessed at here.

The two module-eval cases that asserted the unit on the host now assert
it in the container and absent from the host, and two new cases pin the
device bind and the write access — each was individually valid and
collectively required, which is the state no assertion catches. Both
new cases select with `or [ ]`: mutation-testing them showed that a bare
select aborts the run with a nix trace instead of failing the case by
name, which also hid the second failure behind the first.
2026-08-31 22:50:40 +02:00
damocles
8a3766c60b issue-report: persist sort/filter state to localStorage 2026-08-31 22:40:13 +02:00
damocles
361121c7f6 issue-report: add transitively-blocks count alongside direct depended-on-by count 2026-08-31 21:51:42 +02:00
atlas
aef42b64eb module-eval: pin the collector off in the listener-count fixture
The case counting that fixture's listeners is about the addresses
declared on it. A collector on the same host adds a metrics listener,
so the expected count depended on a flag the case never mentions —
found by mutating the gate it sits next to, which failed both.
2026-08-31 21:25:00 +02:00
atlas
dc9adce444 swarm-bao: ship the store's logs and metrics
The store declared no journald units and served no metrics: nothing in
`swarm-bao.nix` mentioned either, while every sibling swarm service
declares both.

Metrics get their own loopback listener rather than a flag on the API
one, and that follows from what a scraper can express rather than from
taste: `swarm.otel.scrapeTargets` is `host:port`, plaintext and with no
credential, while the API listener is TLS and demands a client
certificate once a client CA is set. `metrics_only` narrows the new
listener to the metrics path; `prometheus_retention_time` is what serves
the endpoint at all.

Measured against openbao 2.6.2 before writing any of it: the metrics
path answers 200 on such a listener *while the node is sealed and
uninitialised*, 503 on the API listener, and 404 for a non-metrics path
on the metrics listener.

The listener exists only where a collector does — it is unauthenticated
by design for now, and an endpoint with no reader would be exposure
bought for nothing.

The port cannot be the API port + 1: openbao derives every listener's
cluster address as its own port plus one, so that number is already
taken. An assertion says so, since the failure is otherwise a race with
no log line.

Journald units are declared by the module that defines each unit, not
gathered here, matching the option's own rule — a name nothing defines
is silently ignored, so a central list would read as coverage on hives
that have neither glue module.

Refs #3849
2026-08-31 21:25:00 +02:00
atlas
75a6101f66 swarm-bao: set the cluster address raft refuses to start without
openbao has never started on a hive that deploys it. With raft storage
and no `cluster_addr`, `bao server` exits 1 before serving anything:

    cluster address must be set when using raft storage

Reproduced against openbao 2.6.2 with the module's own config shape:
the same JSON minus `cluster_addr` exits 1 with that line, and with it
the server comes up and configures every listener.

Both addresses are built from `swarm.bao.domain` rather than a bind
address — that is the URL a reader already dials (`BAO_ADDR` in
glue-matrix-bao-token) and the name the server certificate carries.
Cluster traffic sits one port up, upstream's convention.

The `unknown or unsupported field loopback` warning in the same startup
log is unrelated and cosmetic: openbao's unknown-field check does not
know about named listener blocks, but the parser honours `type` and
configures each one (measured, two named listeners, both served).
Noted in the module so the next reader does not chase it; the JSON
array form that avoids the warning does not typecheck against nixpkgs'
`settings.listener` (`attrsOf`).

module-eval gains the regression gate plus a control that the settings
it reads vary per deployment.

Refs #3860
2026-08-31 21:23:45 +02:00