# Approvals + helper events The approval queue is where an agent asks the operator for something it can't do itself. Nothing submits a new approval today: the one kind left, a meta-input bump, only reads back from existing rows. Config changes don't go through this queue: they're pull requests on the agent's config repo, which an operator merges on the forge, and that merge deploys them. The submitting agent — any agent with the `approvals` tool group, which manages the config of its **direct children** (the root agent for top-level agents; a sub-manager for its own subtree) — is the policy gate in front of both; helper events are how it stays informed about what happens after a decision lands. ## For operators - **Config change** — an agent proposed a change to another agent's config (or its own, via a sub-manager) as a pull request on `agent-configs/`. Review it on the forge like any other PR, and merge it there to deploy it. See [Config changes](#config-changes). - **Meta/flake update** (`UpdateMetaInputs`) — an existing row reads back and you can approve it. Approving runs the update and commits the lock change; it doesn't rebuild anything by itself. Don't want to approve something? **Deny it** (`DENY` on the dashboard card, or `hivectl approvals deny `) — nothing runs. Either way the submitting agent is always notified that the operator denied their request; what's optional is only the reason text, which you can add on the dashboard's prompt (cancelling that prompt aborts the whole deny, not just the reason) but not from the CLI. Denying is final: a denied approval can't be re-approved later, the agent has to submit a fresh request. Everything below this point is the implementation detail behind that flow. ## Config changes Config changes flow through a **forge pull request** on the agent's `agent-configs/` repo — the same surface agents use for code PRs. No MCP tool exists for config changes: opening the PR IS the request. 1. The submitting agent (the child's parent, holding the `approvals` tool group) **clones** `agent-configs/`, edits it there (any tracked path, but `agent.nix` is the contract entry point), commits with its own git identity, and pushes a branch + opens a PR with `hive-forge` — the same way it would change any other repo. The bind-mounted `/agents//config/` is a **copy for reading** a config, not the tree to edit: authoring in place there produces no PR. (it's currently mounted read-write, which is a defect tracked separately, not an authoring path.) Branch protection (the agent isn't on the `main` push allowlist; merge and approvals allowlist = `operators` team; see "Forge mirror" below) makes the agent a write collaborator that **can't merge its own config PR**. 2. An operator reviews the PR **on the forge** (native diff, threaded comments, CI status) and merges it there. Merging needs membership of the `operators` team in `agent-configs`. The team starts empty: add each operator by hand in the forge UI. 3. swarm-controller gets the `agent-configs` org's `pull_request` delivery. A `closed` event with `merged: true` and base branch `main` names the commit in `merge_commit_sha`. 4. The controller looks up which hive's wanted state places the agent and publishes a deploy request carrying that commit on that hive's deploy subject. With no such hive, or more than one, it deploys nothing and logs a warning naming them. 5. If the hive's `applied/main` already is that commit, it does nothing. Otherwise it fetches the config repo's `main`, requires the commit to descend from `applied/main`, fast-forwards `applied/main` to it, and queues a rebuild. A failure before the rebuild (the fetch, or a commit that doesn't descend) deploys nothing and posts the error as a comment on the merged PR. An agent with no container on that hive yet ignores the commit; its first deploy builds what it seeds. This path runs **no eval-verify**. A merged config that doesn't evaluate or build fails the rebuild, and `applied/main` stays at that commit, so the agent's rebuilds keep failing until a fix merges. A failed rebuild shows on the hive like any other and isn't commented on the PR. A merge whose webhook delivery never reaches the controller deploys nothing. ## Approval queue ### Withdrawing a pending approval The submitting agent can call `cancel_loose_end(kind: "approval", id)` to withdraw an approval the operator hasn't acted on yet. The row transitions to `ApprovalStatus::Cancelled` (distinct from `Denied`/`Failed`), the dashboard pulls the card out of the pending pane, and `ApprovalResolved { status: "cancelled" }` fires on the root agent + dashboard channels. Approvals that the operator (or a lifecycle failure) has already approved, denied, or failed return an error — the resolution is final once acted on. The socket refuses the `approval` kind with a clear error for any agent that lacks the `approvals` tool group: only an agent with that group submits approvals (for its direct children), so an agent without it has nothing of its own to withdraw. Creating a brand-new agent isn't an approval: it's swarm-level (`swarmctl agent create`, `POST /api/agents` on the swarm controller), and no hive can originate an agent. The swarm controller's `InitAgentConfigRepo` job seeds the agent's config repo with a default `agent.nix` template, then asks the target hive to deploy it. Changing what the template seeded isn't a special case: like every later change, it's a PR on that config repo, made from a clone and merged by an operator on the forge (see [Config changes](#config-changes)). ### Approval kinds (wire shapes) `ApprovalKind` carries one variant, and it overloads `commit_ref` as its payload carrier. - `UpdateMetaInputs` — `commit_ref` stores the JSON-encoded inputs array (`"[]"` = all inputs, `"[\"nixpkgs\"]"` = just nixpkgs, etc.). hive-c0re sets the `agent` field to the requesting root agent. On approve hive-c0re runs `nix flake update [inputs...]` on the meta flake and commits the resulting lock changes. ### Reminder delivery: file-path semantics A reminder may carry a `file_path` (the agent-visible path inside its container, for example `/agents//state/foo.md`). On delivery hive-c0re: 1. **Translates** the container path to the host path (`/var/lib/hyperhive/agents//state/foo.md`) so c0re can write from outside the container. 2. **Validates** the path: rejects anything outside the agent's own state subtree, containing `..` (path traversal), or with an empty relative tail. On rejection hive-c0re skips the write and delivers the original message inline with a warning — the reminder still fires. 3. **Defends against symlink escape**: after `create_dir_all`, hive-c0re canonicalizes the parent dir and re-verifies it lives under the agent's host state root. hive-c0re opens the final file with `O_NOFOLLOW | O_CREAT | O_TRUNC` so an existing symlink at the basename can't redirect the write to an arbitrary host path. 4. **Writes the body to disk** and delivers a short pointer message in its place, keeping the agent's inbox / wake-prompt small while the agent reads the bulky payload out of band. `Broker::deliver_reminders_batch` handles atomicity of the inbox INSERT + `reminders.sent_at` UPDATE; the scheduler only computes the body strings before calling it. ### Destroy semantics `HostRequest::Destroy { name, purge }` is the lifecycle tear-down, not an approval. Stops + removes the nspawn container, drops the systemd drop-in, fails any pending approvals. Persistent state (proposed/applied repos, claude credentials, `/state/` notes) is **kept by default** — recreating the agent with the same name reuses prior config + login. With `purge = true` the agent's `/var/lib/hyperhive/{agents,applied}//` trees are also wiped (config history + creds + notes gone forever). The root/bootstrap container is destroyable like any other — hive-c0re recreates it on the next startup if it's absent, so destroying it's transient. ## Meta flake The hive-c0re-owned repo at `/var/lib/hyperhive/meta/` declares one flake input per agent (`agent-.url = "git+http:///agent-configs/.git"`) and one `nixosConfigurations.` output per agent. Each output wraps `inputs.agent-.nixosModules.default` with the identity + `HIVE_PORT` / `HIVE_LABEL` / `HIVE_DASHBOARD_PORT` injection module. Containers run against `--flake /var/lib/hyperhive/meta#`. The declared input url is the agent's **forge config repo** (the same `agent-configs/` config PRs merge into), so the meta flake references a reviewable, reproducible source rather than a local checkout. hive-c0re authenticates that `git+http` fetch via a git credential helper that reads the live forge-core token — no token in the url or the lock. The rebuild paths, however, do **not** re-lock from the forge: they `--override-input agent- git+file:///var/lib/hyperhive/applied/`, locking the exact config `applied//main` was fast-forwarded to. That keeps a rebuild reproducible and independent of forge reachability — rebuilds fire on crash-restart and meta bumps, not just config merges — while the declared url stays the forge. `sync_agents` re-renders + re-locks the persistent input; a plain `nix flake lock` leaves an existing applied override in place (it only re-locks when the declared url itself changes), so the forge-declared / applied-deployed split is stable. Lock updates are single-phase and commit when the lock changed: `meta::lock_update_for_rebuild(name)` relocks one agent's input for a relocking rebuild (the manual `↻ R3BU1LD` button, and the rebuild a merged config commit queues), and `meta::lock_update_hyperhive()` is the autoupdate flake-rev bump (one shot before per-agent rebuilds). `meta::sync_agents(hive: &HiveEnv, agents: &[AgentSpec])` — `hive` carries `hyperhive_flake`, `dashboard_port`, and the rest of the per-hive config — is the idempotent reconciler called by `spawn`, `destroy`, `rebuild`, and the startup migration. Renders `flake.nix` from the agent list; if it differs from disk, runs `nix flake lock` + commits as `regenerate meta flake` (or `seed meta from N agent(s)` on the first call). The root agent has `/meta` RO-bound inside its container: `git -C /meta log --oneline` is the swarm-wide deploy log, `cat /meta/flake.lock | jq '.nodes["agent-"].locked'` resolves which sha the flake pins each agent at right now. Dashboard surfaces the same info as a `deployed:` chip per container row. ## Two repos per agent ``` /var/lib/hyperhive/agents//config/ proposed — parent mount is RO └── # any files the submitting # agent wants in the commit. # agent.nix is the # convention entry # point; flake.nix is # tracked boilerplate # (submitting agent doesn't # edit it). /var/lib/hyperhive/applied// applied — core-only ├── .git/ # tag-rich history ├── flake.nix # tracked, fixed │ # boilerplate exporting │ # nixosModules.default ├── agent.nix # working tree of main └── # also tracked /var/lib/hyperhive/meta/ swarm-wide flake — core ├── .git/ # one commit per lock │ # change ├── flake.nix # generated from agent set └── flake.lock # pins each agent's sha ``` Why two physical repos: the submitting agent's `/agents//config/` is RW — a buggy or hostile agent can `git clean -fdx` its own proposed tree. The applied repo is never bind-mounted (except the read-only `.git` exposure described below) so a destructive move inside the container can't reach it. The container's `--flake` ref is `/var/lib/hyperhive/meta#` (see "Meta flake" above). The agent's own `applied//flake.nix` is a fixed boilerplate that exports `nixosModules.default = import ./agent.nix`; the meta flake imports that module and wraps it with identity + `HIVE_PORT` / `HIVE_LABEL` / `HIVE_DASHBOARD_PORT`. ### Tag state machine hive-c0re plants `deployed/0` on the seed commit at first spawn. A merged config commit that deploys plants no tag: the merged PR on the forge and meta's lock commits record it. A config PR nobody merges carries no extra state on the forge side: the PR stays open, and the submitter pushes again (or closes it) to retry. ### Dispatch via the job queue Long-running approval work — `UpdateMetaInputs` — runs as a DAG on the global job queue (`docs/scheduler/coordinator.md::Job queue`), submitted by the approval handler rather than run inline: | `ApprovalKind` | DAG submitted | source | |---|---|---| | `UpdateMetaInputs` | `meta_update` (`MetaLock` + rebuild fan-out) | `approval` | The DAG carries the originating `approval_id`. **Every** queued kind resolves through `actions::resolve_approval_dag` when its DAG settles terminal: the DAG's own terminal state is the authoritative outcome. That hook fires the matching `HelperEvent::*` via `finish_approval`. Two visible consequences: - **Operator dashboard**: after selecting APPR0VE the work-in-progress shows up on the *rebuild queue* card (`GET /api/jobq/graph`, refetched on every `rebuild_queue_changed` tick), not on the approvals panel (which already moved the row to "approved"). A long meta-update cascade renders as a parent DAG with one child rebuild per affected agent — see `docs/web-ui/dashboard.md` for the layout. - **Cancellation**: the dashboard's *× cancel* button on a still-queued DAG calls `POST /api/rebuild-queue/{id}/cancel`, which flips it to `Cancelled` before any node runs (and fails the approval row instead of leaving it dangling). Returns `{"cancelled": true}` on success, `{"cancelled": false}` once any node started — terminal states can't be retroactively rewritten. The `approval` source + `approval_id` mean a tail-end build failure surfaces back as a failed approval row, not just a silent queue entry. `manual` (dashboard ↻ R3BU1LD) and `auto_update` (boot reconcile) DAGs use the same queue but skip the approval plumbing. ### Forge mirror The bundled `hive-forge` container runs on the swarm's forge host (`deploy.forgejo.enable`, see [`../swarm/services.md`](../swarm/services.md)), and hive-c0re mirrors every agent's applied repo into a private `agent-configs` Forgejo org. `forge::push_config()` pushes every tag, then `applied/main`, to `agent-configs/` on every startup sweep and every rebuild. Forge `main` is branch-protected, so the forge routinely refuses a push of an established `main`, which hive-c0re expects. Pushes are best-effort — a missing or stopped forge never blocks a deploy. Each agent is a **write collaborator on its own** `agent-configs/` repo — so it can push a branch and open a config PR — but not a member of any other agent's, so it can't reach another agent's config through the forge. Branch protection keeps the agent off the `main` push allowlist and allowlists merging to the `operators` team only, so an agent can't fast-forward its own config or self-merge its PR (see [Config changes](#config-changes) above). hive-c0re passes the tokenised push URL inline to `git push`, never writing it into `applied//.git/config`; that repo is RO-bind-mounted into the root agent, and a stored token would leak core's admin credential to an agent. The dashboard deep-links into this org — a `config repo` link per container row. See `docs/web-ui/dashboard.md`. ### Submitting agent's view of config repos An agent holding `ManageRootAgent` has every other agent's config repo bind-mounted **read-only** (`hive-c0re/src/lifecycle/host_config.rs` calls `bind_child_agent_dirs` for each entry in `topology::all_agents()`). it's a copy to *read* another agent's current config — not an editing surface. An agent without the capability sees no other agent's config at all. An agent with the `approvals` tool group submits a change the same way it makes any other change: **clone the target agent's config repo from the forge into its own state dir, commit on a branch, open a PR**, and let the operator review and approve it. By design, no second, mount-shaped path reaches the same file without the review. Agents holding the `manage_root_agent` capability (granted per agent in `capabilities.json`; see `hive-sh4re/src/permissions.rs`) get additional host-side bind mounts via `set_nspawn_flags`: - `/var/lib/hyperhive/agents/` → `/agents/` (RW) — **every** agent's proposed repo, not just direct children. The capability means "may manage any agent", so the mounted set is every agent. - `/var/lib/hyperhive/applied/` → `/applied/` (RO) — every agent's authoritative applied repo, including `.git`. - `/var/lib/hyperhive/meta/` → `/meta/` (RO) — the swarm-wide deploy flake. An agent without the capability only has its direct children's config dirs. ⚠️ nspawn bakes bind flags at container start, so granting or revoking this capability doesn't change any mount until that agent's container is rebuilt/restarted. The root agent gets the capability by default, seeded on its autodeploy path (`workers::auto_update::ensure_root_agent`) so the recovery mounts are there from its first container. That seed only fires while `capabilities.json` doesn't exist yet: any grant or revoke through the dashboard creates the file, so a revoked root-agent grant stays revoked and isn't re-applied on the next hive-c0re restart. Each proposed repo (`/agents//config/`) is pre-configured with `applied` as a git remote pointing at `/applied//.git`. Useful incantations from inside an agent with the full `/applied` mount: ```sh git -C /agents//config fetch applied git -C /agents//config log applied/main --oneline git -C /agents//config show applied/refs/tags/deployed/0 # the seed commit git -C /agents//config show applied/refs/tags/denied/ # body = operator note git -C /agents//config rebase applied/main # base in-flight work on what's deployed git -C /meta log --oneline # swarm-wide deploy history cat /meta/flake.lock | jq '.nodes | with_entries(select(.key | startswith("agent-")))' ``` The RO binds block push at the kernel level — git plumbing inside the container can't corrupt either authoritative repo. ## Startup migrations (older hosts) hive-c0re runs a couple of idempotent migrations on every startup so a host set up before the tag-driven-deploy + meta-flake scheme (both described above) converges to it automatically. Each phase is a no-op once already applied: - **Tags**: hive-c0re tags agents from before the tag-driven scheme `deployed/0` on `main` once. Non-destructive — it doesn't touch live containers, state dirs, or claude creds. - **Meta flake**: rewrites each `applied//flake.nix` to the module-only boilerplate, wires the `applied` remote in each proposed repo, and bootstraps the meta repo from the current agent list. Set `HIVE_SKIP_META_MIGRATION=1` on the service to defer this phase. No state loss in either migration: claude creds, `/state/` notes, the events DB, and both proposed + applied history all survive. The root agent keeps its session; sub-agents stay logged in. ## The root/bootstrap container is hive-c0re-managed The root agent container runs through the **same lifecycle as sub-agents**. On `hive-c0re serve` startup, if `ruth` is missing, hive-c0re creates it. The root agent's flake lives at `/var/lib/hyperhive/applied/ruth/`; its proposed config at `/var/lib/hyperhive/agents/ruth/config/`. The root agent can edit its own `agent.nix` (visible inside the container at `/agents/ruth/config/`) and open a config PR on `agent-configs/ruth` for operator approval, same as any other agent. Differences from sub-agents: - `flake.nix` extends `hyperhive.nixosConfigurations.ruth` (vs `agent-base`). - Web UI port via `lifecycle::agent_web_port("ruth")` — same FNV-1a hash as every other agent (8100..8999 range). - `set_nspawn_flags` adds two extra binds: `/var/lib/hyperhive/agents` → `/agents` (RW) so the root agent can edit per-agent proposed repos, and `/var/lib/hyperhive/applied` → `/applied` (RO) so the root agent can `git fetch` deployed/failed/denied tags from any agent's authoritative applied repo (see "Root-agent view of applied" below). - First-deploy spawn bypasses the approval queue (the root agent is required infrastructure). - `socket_server::start_manager` binds the root agent's socket, pure transport with no dedicated helpers — it uses the same per-agent runtime dir as any other agent (`/run/hyperhive/agents/ruth/`), not a special manager-only path. **Migration note** (for older hosts): drop any `containers.root = { ... }` block from your host NixOS config. hyperhive creates and updates the root agent itself. ## Root-agent policy The system prompt (`hive-agent/prompts/system.md`, rendered by `hive-agent/src/prompt.rs`) is the **same for every agent**; what varies is which MCP tools it surfaces (gated by tool groups and capabilities in `agent.nix`). No `role:manager` block renders only for the root agent. The root agent's approval-gating behaviour comes from its CLAUDE.md / agent-specific instructions, not the system prompt template. ## Helper events to the submitting agent `Coordinator::notify_submitter(approval_id, &HelperEvent)` routes the event to the agent that originally submitted the approval (looked up from the `submitter` column on the `approvals` table). The harness delivers it as a regular `system` inbox message so it drives a normal claude turn. `finish_approval` fires an `ApprovalResolved` HelperEvent this way for **every** approval kind's terminal state. A "FYI, check when convenient" event doesn't need a message — hive-c0re publishes those as swarm lifecycle notices instead (`swarm_notices::notify`). Legacy approval rows that predate the submitter column fall back to the root agent. Variants (`hive_sh4re::manager::HelperEvent`): - `ApprovalResolved { id, agent, commit_ref, status, note }` — fired by `actions::approve` + `actions::deny` whenever an approval transitions to its terminal state. - `ContainerCrash { agent, note }` — `crash_watch`: a previously- running container went away with no operator-initiated transient state (Stopping / Restarting / Destroying / Rebuilding) AND nothing cleared that transient in the last 30s (`RECENT_TRANSIENT_GRACE` tombstone, three `POLL_INTERVAL`s — closes the race where a lifecycle op finishes between two crash-watch polls and the container shows briefly as "stopped without transient" before the next start). The root agent escalates to the operator, who starts it again from the dashboard. - `NeedsUpdate { agent }` — sub-agent's recorded flake rev is stale. The operator rebuilds it from the dashboard. The remaining lower-urgency lifecycle notices — `Rebuilt`, `Killed`, `Destroyed`, `NeedsLogin`, `LoggedIn` — are "FYI, check when convenient" events with no reason to drive an immediate turn, so they go out via `swarm_notices::notify` (see above) instead of a `HelperEvent`: `subsystem = "core"`, `key = ":"`, and a single free-text `summary` (`rebuilt_todo_summary` renders `Rebuilt`'s `ok`/`note`/`sha`/`tag` fields into that string). To add a new lifecycle notice: if it needs to drive an immediate turn (something genuinely urgent, like `ContainerCrash`), add a `HelperEvent` variant + call sites + update `prompts/system.md`'s message-event list. If it's "FYI, check when convenient," call `swarm_notices::notify` instead — no new wire type needed. ## Autoupdate on startup `hive-c0re serve` runs `auto_update::run` in a background task right after opening the coordinator. It enumerates managed containers and rebuilds any whose recorded hyperhive rev differs from the current one — sub-agents and the root agent go through the same `job_queue::templates::rebuild` DAG. "Rev" = canonical filesystem path of `services.hyperhive.c0re.hyperhiveFlake`. Marker file: `/var/lib/hyperhive/applied/..hyperhive-rev`. If the flake input has no canonical path (for example a `github:` URL), autoupdate is a no-op — rebuild manually. The dashboard surfaces pending updates per agent: a clickable "needs update ↻" badge appears whenever the marker differs from current rev. The badge POSTs `/api/rebuild/`, which inserts the same `job_queue::templates::rebuild` DAG so manual triggers and the startup scan can't drift. When at least one container is stale, a top-level `↻ UPD4TE 4LL` button appears that loops over every stale container.