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
| File | Why |
|---|---|
book/src/adr/0143-a-held-push-needs-a-human-yes-bound-to-the-bytes-and-signed.md | The decision record (cited from three source files). |
the session records/54-2026-10-09-phase-37-approval.md | this file |
Modified files (all shared with earlier phases; one commit each, see above)
| File | Change and why |
|---|---|
vault-core/src/nodes.rs | Target.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.rs | Test fixture gains require_approval: false. |
envv-server/src/nodes.rs | process(): 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.rs | NodeState.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.rs | policy, 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.rs | NodeCmd::{Policy, Approvals, Approve, Reject} and dispatch. |
src/ts/nodes-pane.ts | Per-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.json | node-approval: both (four CLI leaves, tool:nodes). |
README.md, book/src/SUMMARY.md, version files, locks, AGENTS.md | Docs, ADR link, 0.37.0, Phase 37 row and block. |
3. Decisions and rejected alternatives
- 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_approvalfinds nothing for the new hash, and a new request opens (testan_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). - The hub withholds the content, it does not send it and ask the node to hold it. A pending request produces a
pendingnote and noPushaction, so the bytes are not on the wire or on the host until a yes exists. - The yes is signed, and the node can demand it independently. Policy on the hub (
approval = required) stops the hub from sending.require_approval = truein 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). - The signing seed lives in the vault's encrypted
vault_meta, like the unique-ID registry's secrets, not innodes.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. - 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). - 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".
- One request audit row per request (
firstpass 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). - 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. - 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, productionrequired; 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 theif false && <condition>/|| trueidiom (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 thestr.replacesilently matched nothing; the failures (confirmAnswer is not defined) and agrepof the file were how it was noticed. Alwaysassert old in sbefore 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,tscclean.- End to end (real agent, real socket): with the node's policy
requiredand its targetrequire_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 intoAgent::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 ignoringrequire_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_approvaland the routes it calls are tested separately, the diff text is the history dispatcher's), Docker, Windows.
5. Traps
record_beattakesunix_now()for approval lifetimes, not the caller's clock; tests that need a lapsed approval use the explicitnow_secsarguments ofusable_approval/decide.- The approval request is created from
process()beforerecord_beat; if you reorder them the first beat after a change will reportpendinginstead ofawaiting_approval. usable_approvalalso 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.jsonis 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.