Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Phase log 54: 2026-10-09 - Phase 37: approval

Written for the next LLM agent: a changelog and decision record. Version bumped to 0.37.0. Nothing is committed. Read 51-2026-10-09-phase-34-nodes.md (Nodes), 52-2026-10-09-phase-35-the-config-time-machine.md (config history; §0 explains the commit-script registry) and 53-2026-10-09-phase-36-blast-radius.md (blast radius) first. Commit scripts: because every file this phase touched except the new ADR was already first touched by 34, 35 or 36, ~/Code/envvault-commit-phase37.ps1 holds one commit (the ADR); the rest of Phase 37's work is committed by the earlier scripts, whose per-file messages were extended with a "Phase 37 also …" clause. Run 34, 35, 36, 37 in order.

1. The problem

Nodes (phase log 51) let automation change production: edit the vault and a node writes the file on a live host within a beat. phase log 23 T5: put a human between the render and the apply, bind the yes to the exact bytes (approving "a change to nginx" rather than "these bytes" is a time-of-check/time-of-use hole), make the diff readable (approval fatigue is the real failure mode), and let staging apply freely while production requires a tap.

2. What was built

New files

FileWhy
book/src/adr/0143-a-held-push-needs-a-human-yes-bound-to-the-bytes-and-signed.mdThe decision record (cited from three source files).
the session records/54-2026-10-09-phase-37-approval.mdthis file

Modified files (all shared with earlier phases; one commit each, see above)

FileChange and why
vault-core/src/nodes.rsTarget.require_approval (a pull target may not set it); NodeRecord.approval (""/required); Action::Push.approval: Option<SignedApproval>; BeatReply.pending: Vec<PendingNote> and hub_pubkey; ApprovalRecord (node, target, project, exporter, sha256, from_sha, from_seq/to_seq history snapshots, requested/decided times, status pending/approved/rejected/consumed/expired); ApprovalToken/SignedApproval, sign_approval, verify_approval, hub_public, generate_hub_seed, hub_seed(conn) (created on first use in the vault's encrypted vault_meta); NodeStore::{set_approval_policy, request_approval, decide, usable_approval, approval_for, approvals}; record_beat yields awaiting_approval / rejected when the node requires approval, and consumes an approval when the node reports its bytes, expires an unused one after an hour and an unanswered request after seven days. +7 tests.
vault-core/src/nodes_apply.rsTest fixture gains require_approval: false.
envv-server/src/nodes.rsprocess(): reads the policy fresh from the store (a held beat woken by an approval must see what the owner set); for a node that requires approval, before the status is computed, records the proposed render in the history (snapshot_stream, cause proposed:<node>), finds the snapshot of the file the node has, and opens a request (one audit row, node.approval.request, actor node:<name>, only on the first pass); a push is withheld (only a pending note) until usable_approval finds an approved, unexpired yes for those bytes, then it carries a signed token (expiry = approval time + 1 h, fresh nonce); the reply carries the hub's approval public key. New routes POST /api/nodes/{id}/policy and `POST /api/node-approvals/{id}/{approve
envv-server/tests/nodes_e2e.rs+4 end-to-end tests with the real agent (see §4).
envv-cli/src/node_agent.rsNodeState.hub_pubkey (pinned on first sight, a different one later is a denied error that stops the run loop); for a target with require_approval, check_approval verifies signature, node, target, bytes and expiry and the nonce ring (approvals-seen.json, 256 entries); the nonce is spent by a write (Applied::Written/Unchanged after a successful apply), not an attempt, so a failed validate leaves the approval usable for the retry.
envv-cli/src/node_cmd.rspolicy, approvals, decide (approve/reject). Approve prints the node, target and the proposed file's sha256, then the diff against what the node has (secrets as fingerprints unless --reveal; with no earlier snapshot, the whole proposed file as additions), then asks (--yes skips); pick_approval takes a full id or a prefix of at least 8 characters and refuses an ambiguous one. node run --once prints held pushes. +2 tests.
envv-cli/src/main.rsNodeCmd::{Policy, Approvals, Approve, Reject} and dispatch.
src/ts/nodes-pane.tsPer-node "Require approval" toggle; per held push "Review changes" (history diff or show, masked), "Approve" (a confirm that names the first 12 characters of the file's hash) and "Reject"; recent decisions shown small. Markup via the escaping template.
tests/nodes-pane.test.ts+6 tests (toggle, Review diff with reveal:false, whole-file review with no earlier snapshot, Approve asks and names the hash and only a yes sends, Reject needs no confirm, hostile target stays text).
tests/fixtures/parity/capabilities.jsonnode-approval: both (four CLI leaves, tool:nodes).
README.md, book/src/SUMMARY.md, version files, locks, AGENTS.mdDocs, ADR link, 0.37.0, Phase 37 row and block.

3. Decisions and rejected alternatives

  1. A request is for (node, target, sha256), nothing coarser. This is the TOCTOU answer: if the vault changes while a request waits, the rendered hash changes, usable_approval finds nothing for the new hash, and a new request opens (test an_approval_is_for_the_bytes_that_were_shown…). A consumed or expired request for the same bytes (a revert) does not cover a later push (a_spent_request_for_the_same_bytes_does_not_cover_a_later_push).
  2. The hub withholds the content, it does not send it and ask the node to hold it. A pending request produces a pending note and no Push action, so the bytes are not on the wire or on the host until a yes exists.
  3. The yes is signed, and the node can demand it independently. Policy on the hub (approval = required) stops the hub from sending. require_approval = true in the node's own config makes the node refuse anything without a valid signed approval, so a hub bug, a policy change, a sub-user write path or a replayed action cannot push there. The token binds node, target, hash, approver, expiry and nonce. Rejected: an unsigned flag in the push (a hub bug could set it).
  4. The signing seed lives in the vault's encrypted vault_meta, like the unique-ID registry's secrets, not in nodes.json: a copy of the registry file is not enough to forge an approval, and the key exists only while the vault is open, which is also when a push can be made.
  5. Trust on first use for the hub's approval key, over the already-pinned TLS channel, rather than a key delivered at enrollment: enrollment can happen while the hub is locked and has no key to give. A different key later is a hard error that stops the run loop (denied), re-enrolling is the fix. A node that has not yet seen the key refuses approval-requiring pushes (test).
  6. Spend the nonce on a write, not an attempt. A failed validate restores the old file and the hub will push again next beat with the same approval; burning the nonce on the attempt would make every retry fail with "already used".
  7. One request audit row per request (first pass only, and only when the request is new), because a held push is re-evaluated every beat; a row per beat would be the heartbeat-in-the-chain mistake again (test: five beats, one row).
  8. Review is the history's diff. The request stores snapshot numbers, never content; Phase 35's masked diff is what the human reads (fingerprints, so a rotated key shows as a changed fingerprint). --reveal/Reveal is deliberately not the default for approval. The question includes the hash so the thing approved is the thing on screen.
  9. Not built (ADR-0143): an approval signed on the owner's own device (a compromised hub can mint approvals; what holds is that bugs and replays cannot, and that the owner's yes is for exact bytes); a per-target hub policy (per node is enough: staging none, production required; the target-level control is the node's own setting); a push notification when something is held.

4. Incidents and verification

  • First mutation run: 10 of 16 "BUILD ERROR". Removing a check left its argument unused, which is a hard error under warnings = "deny". Rewritten with the if false && <condition> / || true idiom (phase log 51/52 pitfall, now in AGENTS.md). Also: the harness now runs the three clean suites first and aborts if any is red (the phase log 53 lesson).
  • cargo fmt/Prettier reflowed files between my edit script and its replace twice (the nodes-pane test's mock block, node_cmd::run). Both times the str.replace silently matched nothing; the failures (confirmAnswer is not defined) and a grep of the file were how it was noticed. Always assert old in s before replacing (done in the scripts from then on).
  • cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings: clean.
  • cargo test --workspace: 620 passed, 0 failed (+19).
  • npm run check: 1415 tests in 78 files, all pass; prettier, eslint --max-warnings 0, tsc clean.
  • End to end (real agent, real socket): with the node's policy required and its target require_approval = true, the first beat writes nothing and the node is told it is held; after the owner approves, the next beat carries the signed approval, the node verifies it and writes; changing the vault leaves the file as it was until the new bytes are approved. Forged pushes written straight into Agent::act (no approval, another hub's key, other bytes, another target, another node, expired, replayed after a write) all write nothing and every reason reaches the hub's audit log. A changed hub key is refused; a node that has not yet seen the hub's key refuses.
  • Fault injection (harness mut4.py): each token check removed (bytes, node, target, expiry, signature); a request opened every time; a decided request decided again; pending treated as approved and the lifetime ignored; the approval not bound to the bytes; an approval never consumed; the server ignoring the policy when pushing; decisions open to any session; a token outliving its approval; the node ignoring require_approval; the node accepting a replay; the node accepting a changed hub key. All 16 caught.
  • npm run ui-lab: 167 passed (run after the UI changes).
  • Not run: the CLI approve flow against a live hub (it needs Access::Remote, i.e. a real login; pick_approval and the routes it calls are tested separately, the diff text is the history dispatcher's), Docker, Windows.

5. Traps

  • record_beat takes unix_now() for approval lifetimes, not the caller's clock; tests that need a lapsed approval use the explicit now_secs arguments of usable_approval/decide.
  • The approval request is created from process() before record_beat; if you reorder them the first beat after a change will report pending instead of awaiting_approval.
  • usable_approval also requires the node to be active: revoking a node withdraws its outstanding approvals.
  • A hub that is locked has no key and no vault: it sends no pushes at all, approved or not.
  • approvals-seen.json is only the replay ring for tokens that were used; deleting it re-opens replay of tokens that have not yet expired (at most an hour).

6. Next

Phase 38 (stack integrations): Prometheus, Grafana, Homarr/Homepage, Nextcloud, VS Code, and "the adapter shape they share". Its dependencies are all met (nodes, history, blast radius). Then 39 (the pentest checklist) and the sub-phases and the 1.0.0 review.

1 line(s) about the maintainer's own machines were left out of this page.