hyperhive/docs/agent-lifecycle/agent-hierarchy.md
iris 0ad0376fe3 docs: fix genuine passive-voice hits in docs/agent-lifecycle
Ninth batch of the ongoing write-good.Passive pass (hyperhive#4042):
read all 75 hits across agent-hierarchy.md/persistence.md/approvals.md
in context and rewrote 46 with a clearly nameable actor -- mostly
hive-c0re, a specific fn/type named right there or a sentence or two
earlier (approvals.md's numbered "Reminder delivery" list all share
hive-c0re as the established actor across three consecutive bullets),
or "the worker"/"the daemon" reused from the section's own established
subject.

Left 29 alone. Recurring buckets: predicate-adjective state pairs
("is wedged"/"is stopped", "is unaffected", "are unrelated", "is
overloaded" -- matches the established "is trusted"/"is privileged"
family), negative-capability/invariant idioms ("no X is needed",
"can't be removed/started", "no such transient was cleared"),
forward-looking design-intent statements about not-yet-built
enforcement (agent-hierarchy.md's whole "Planned topology semantics"
section leans on this: "are meant to run", "once enforcement is
finished", "will be gated"), the "audit trail, not cache" policy
invariant restated twice verbatim ("Approvals are kept forever" /
"indefinitely" -- left both alone consistently), and a config-
conditional state idiom ("when interval_seconds is set"). One more
false-positive tokenization, same bug as knowledge.md's earlier case:
persistence.md's "`config` is read-only" trips vale's "is read" match
inside the compound adjective -- not a real passive at all.

One deliberately-conservative leave: agent-hierarchy.md's "Reminder
cancellation is handled fully in-agent" had an ambiguous actor (the
in-agent socket vs. the hive-c0re source file the doc points readers
to for detail) -- left alone rather than risk a wrong attribution,
unlike the higher-confidence rewrites elsewhere in the same batch.

Verified via vale before/after: 75 -> 29 write-good.Passive hits,
exactly the 29 left alone above; error count and other warning
categories unchanged. Re-read every changed line in full surrounding
context after editing before running the final vale check.
2026-09-08 13:30:26 +02:00

12 KiB

Agent hierarchy & privileges

Every agent has a place in an operator-editable parent/child tree, used to scope which agents can manage which others. This doc covers how hive-c0re stores and edits the tree today, the rules that are meant to run on top of it once enforcement is finished, and where the manager still gets special-cased in the meantime. Tracking issue: hyperhive#361 ($HIVE_FORGE_URL/hyperhive/hyperhive/issues/361).

Where the tree lives

Topology lives in the hive-c0re-owned meta repo, alongside flake.nix, at /var/lib/hyperhive/meta/topology.json:

{
  "ruth": null,
  "alice": null,
  "bob": "alice"
}

null = root-level agent. New agents default to root — there is no structural manager that everything hangs under. Hierarchy is built explicitly: an agent that requests a sub-agent gets a requester-as-parent edge written at its init_config approval (so alice spawned bob above), and the operator can reparent any agent, including the bootstrap container (ruth) — it's just another root. The manager is reparentable like any other agent; there's no "structurally root" carve-out. Its privileges live on its MCP socket, not its tree position (see Manager special-casing today below).

Reparenting

  • CLI: hivectl agent <child> set-parent --parent <new> (or --root to promote). Exactly one of --parent / --root is required.
  • Dashboard: POST /api/topology/set-parent (form fields child, optional new_parent — absent / empty ⇒ promote to root).
  • Wire: HostRequest::SetParent { child, new_parent: Option<String> }.

All three go through the same validation, which refuses:

  • unknown child / new_parent (typo guard),
  • self-parenting,
  • cycles (a bounded ancestor walk — moving the manager under one of its own descendants is the only real safety concern here, and it's caught the same way as any other agent).

Setting a parent to its current value is a no-op (no disk write). A successful change triggers an immediate rescan, so connected dashboard viewers see the tree repaint without polling.

Why meta, not per-agent agent.nix

An agent shouldn't be able to claim a parent without that parent's consent, and operator-driven re-parenting shouldn't require touching the moved agent's config. Topology IS a system-level concern; meta is where system-level facts live.

How topology.json gets updated

  • Read — parsed into an agent→parent map; a missing or unparsable file degrades safely to "every agent is root" (covers a fresh install that hasn't synced yet).
  • Reconcile — runs alongside the periodic meta/flake regeneration. New agents default to root unless they already carry an explicit parent edge from an init_config approval; Reconcile preserves existing entries (including operator overrides); removed agents drop. Agents that are approved but not yet spawned keep their edge too, so it survives the gap until the container actually appears.
  • Inject — hive-c0re exposes each container's parent (if any) to its own environment as HIVE_PARENT, so the harness / system-prompt renderer can see it.
  • Surface — every rescan re-reads topology.json and populates ContainerView.parent, which the dashboard renders as a tree.

See hive-c0re/src/agent_config/topology.rs and hive-c0re/src/meta.rs's module docs for the exact call chain.

Current limitation: state-dir visibility lags topology

Reparenting today is purely a JSON edit. Only the top-level manager (root) gets /var/lib/hyperhive/agents bind-mounted at /agents in its container, so sub-agents don't yet see their would-be children's state dirs. Once sub-manager bind mounts land alongside capability enforcement, reparenting will grow a companion umount-old / mount-new / restart-cascade step.

Planned topology semantics (once ancestor-based enforcement lands)

operation who can do it
kill / start / restart / update (any descendant) any ancestor
request_init_config (spawn a new child) any agent, child added under self
config change via forge PR (any descendant's config) any ancestor
get_logs (any descendant) any ancestor
moderate reminders (cancel any open thread of a descendant) any ancestor
send / recv routing parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else
request_update_meta_inputs (bump meta lock) root agents only (today: just manager)

"Ancestor" walks ContainerView.parent chains; a visited-set guards against cycles at dispatch time (a malformed topology.json can't lock the dispatcher into a loop).

Manager special-casing today

Enforcement of the ancestor rules above isn't fully wired yet, so the manager (ruth) still gets some hard-coded special treatment other agents don't:

  • Naming/bootstrap — the manager's broker recipient name, state-dir key, and nixos-container name are all ruth (container h-ruth). hive-c0re spawns it directly at boot if missing, with no operator approval step — every other agent goes through request_init_config → approval. Topology-wise, ruth is still just another root agent.
  • Wire-protocol — the privileged Request variants (RequestInitConfig; Kill / Start / Restart / Update; GetLogs; RequestUpdateMetaInputs) — marked *(privileged)* in hive-core-agent-sock's unified Request enum — are reachable only from the manager's socket flavour today. Planned rule for each is in the table above ("any agent, child added under self" for init-config, "any ancestor" for lifecycle/logs); RequestUpdateMetaInputs stays a root-only capability even post-milestone, not a topology rule. One exception: Wake (inject a from: <X> message into the caller's own inbox) isn't really privileged — every per-agent daemon (for example hive-forge-notify) needs it, and sub-agents already have the equivalent on their own socket.
  • Storage/mounts — only the manager container gets /var/lib/hyperhive/agents bind-mounted RW at /agents (so it can manage any agent's state dir — config isn't authored there, since a real config change is a PR from a clone), plus RO mounts for /applied (diff against what's deployed) and /meta (system-wide deploy log). Planned: each agent gets RW to /agents/<descendant>/ for just its own subtree — the manager's full-forest RW becomes the "root's subtree is everything" case of that same rule. RO /meta access will be gated on a "meta read" capability; only request_update_meta_inputs writes flake.lock, gated by its own capability.
  • Prompt/tools — the system prompt uses <!-- role:agent --> / <!-- role:manager --> marker blocks, and a Flavor::{Agent, Manager} switch picks the MCP tool allow-list claude sees. Both are already parametrised on a single flavour value, so the planned per-capability-group version (cap:<group> prompt blocks + a matching tool allow-list) is additive rather than a rewrite.
  • State dirsnot special-cased: HYPERHIVE_STATE_DIR is injected uniformly via systemd.globalEnvironment for every container including the manager, so all token/state paths resolve through it the same way everywhere.
  • Scattered ownership checks — a handful of independent manager-only overrides exist across hive-c0re today: loose-ends visibility (manager sees hive-wide, sub-agents only their own), destroy refusing to act on the manager, and crash-watch skipping the manager (it autorestarts via systemd instead of going through the crash-watch loop). Each is planned to become an ancestor/descendant check instead of a manager-name check — see the module docs for loose_ends.rs, stores/broker.rs, actions.rs, and workers/crash_watch.rs for the current owner-check logic in each. (Reminder cancellation is handled fully in-agent — see the note on CancelLooseEndKind::Reminder in hive-c0re/src/socket_server/mod.rs.)

None of the above is a stable interface — treat the module doc comments as the source of truth for exactly which checks exist today.

Future work: sub-agents inside the same container

When enabled for an agent, it will be able to spawn temporary "sub-agents" that run inside its own container — lighter than a full nspawn agent. Open questions, not yet wired:

  • Inherit caps from parent, or take an explicit narrower set?
  • Survive container restart, or always ephemeral?
  • Inbox: separate from parent, or shared?
  • Filesystem: share parent's /state RW, or a sub-dir?
  • Identity: distinct broker recipient name, or address the parent?

Harness systemd unit shape

One harness serve binary (hive-agent, with its hive-agent-mcp sibling), one shared nix/agent-modules/ tree, one service unit (systemd.services.hive-agent) for all agents. No separate manager service name or role distinction exists in the harness — privilege differences live server-side in the broker socket (which tool groups and manager-surface calls each agent receives).

agent.nix and ruth.nix both import the shared nix/agent-modules/. ruth.nix additionally sets forge defaults to suppress the subscription/participation firehose so ruth's inbox stays focused on direct mentions, reviews, and assignments.

Environment variables set on the unit

  • HOME = /home/<userName> — systemd defaults HOME to / for services without User= set; with the per-agent user the harness needs the right home so claude finds its bind-mounted ~/.claude/ session dir.
  • HIVE_STATIC_DIR = <mergedDist>tower_http::ServeDir root for the per-agent web UI; merged dist = agent default + every hyperhive.frontend.extraFiles overlay.
  • HIVE_ASSETS_DIR = pkgs.hyperhive-assets/share/hyperhive — set directly on the unit, not via environment.variables, because the latter only populates /etc/profile which systemd services don't inherit.

PATH setup (the wrapper-dir trick)

path = [ "/run/wrappers" "/run/current-system/sw" ];

/run/wrappers (not /run/wrappers/bin) comes first so setuid wrappers — notably sudo — resolve before bare nix-store binaries; see docs/process/gotchas.md ("systemd.services.*.path appends /bin to every entry") for why the trailing /bin matters in general. It's load-bearing here because the harness runs as the per-agent user: without the wrapper dir on PATH, sudo resolves to the non-setuid nix-store binary and every hyperhive.user.passwordlessSudo grant fails with "must be owned by uid 0 and have the setuid bit set."

serviceConfig highlights

  • ExecStart = pkgs.hyperhive/bin/hive-agent — same binary for every agent.
  • Restart = on-failure, RestartSec = 2 — keeps the harness resilient across transient crashes without thundering retries.
  • RuntimeDirectory = "hive-config"/run/hive-config/ owned by User=, autocleared on stop. The harness writes regenerated claude-{mcp-config,settings,system-prompt} files there (paths::config_dir). Deliberately separate from /run/hive, which the host bind-mounts in root-owned and which holds hive-c0re's mcp.sock.
  • User = Group = userName — drops root inside the container; sudo is the explicit escalation surface (hyperhive.user.passwordlessSudo).

Cross-references

  • Milestone: "Agent privileges and sub-agents" ($HIVE_FORGE_URL/hyperhive/hyperhive/issues/361)
  • Dashboard render: "show agent topology in container list" ($HIVE_FORGE_URL/hyperhive/hyperhive/issues/363)
  • Audit table source: milestone comment ($HIVE_FORGE_URL/hyperhive/hyperhive/issues/361#issuecomment-3335)
  • Operator/agent trust boundary (orthogonal axis): boundary.md