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 51: 2026-10-09 - Phase 34: Nodes

Written for the next LLM agent. It is a changelog and a decision record: every file, why, what was rejected, the dead ends, the exact verification, what is not done, and the traps. Version bumped to 0.34.0 (rule: whole phase N is 0.N.0). Nothing is committed; the commit script is ~/Code/envvault-commit-phase34.ps1.

0. Starting state and what was asked

  • Tree at start: main at c05396f, version 0.33.1 uncommitted in the six version files and Cargo.lock (a bump left over from phase log 50's CI fixes), HANDOFF.md (stale Phase 26 note, untracked, deliberately left alone).
  • User request: "Do Phase 34", then (mid-turn) "then do phase 35, then 36, then all phases until tokens run out", research 1.0.0 viability, "overnight coding", every phase gets a detailed handoff and a ps1 commit script (one commit per file, no co-author trailer), and read the MCP memory.
  • MCP memory was read first (rule): How he wants AI agents to work, Nodes, commit. Relevant standing rules applied: pwsh only for anything handed over; never git commit/git push; no em dashes or emoji; handoffs super detailed; sub-phases (34.1) for follow-ups, never new queue numbers; narrow a design rule only with the deviation written down.
  • The design is the session records/23-2026-08-31-review-01-defect-fixes-the-key-pools-sidebar.md "Design - Nodes" (hub/node, dial/listen, push/pull, node-local reload, one-time enrollment, mutual pinning, TLS 1.3, nodes.json, locked hub = observe continues). AGENTS.md had only a one-line row for Phase 34.

1. What was built, file by file

New files

FileLinesWhy
vault-core/src/nodes.rs1125 (incl. 12 tests)Everything both ends must agree on, and the hub's registry. Ed25519 identity (generate_identity, sign_request, verify_request, key_fingerprint); wire types (TargetReport, Beat, BeatReply, Action::{Push,Upload}, ApplyResult, DriftEvent, HostInfo); the node's own config (NodeConfig/Target, deny_unknown_fields, valid_id, EXPORTERS); the hub registry (NodeStore: mint_token, enroll, authenticate, record_beat, accept, revoke; file nodes.json 0600, atomic write); derive_status (the one place a status word is decided); LOCKED sentinel.
vault-core/src/nodes_apply.rs479 (11 tests)The filesystem half of a node: sha256_hex, hash_file, apply (hash check, keep previous, temp+fsync+rename, validate in place, restore on failure, reload, restore on failure), kept_versions (last 3, 0600), run (shell command with a timeout, kills and reaps), scrub (elides any output line that is also a line of the file).
envv-server/src/nodes.rs1459 (18 tests)Hub routes: owner POST /api/nodes/tokens, GET /api/nodes, DELETE /api/nodes/{id}, POST /api/nodes/{id}/pull, GET /api/nodes/{id}/content/{target}, POST /api/nodes/{id}/accept; node POST /api/nodes/enroll (token-authenticated), POST /api/nodes/beat, POST /api/nodes/upload (signed). process() = audit results, load vault, plan() (render + config-check gate per project/exporter, cached per beat), record_beat, choose actions. Long-poll beat: the request is held up to 25 s and re-planned when nodes_wake fires (vault save, new pull request).
envv-server/tests/nodes_e2e.rs446 (5 tests)A real Agent (from envv-cli) against a real axum::serve socket: enroll, apply, report, stay in sync, vault change, kept version, revoke; failing validate reaches the audit log; apply-off and locked-hub observe-only; pull request/upload/collect; results survive a locked hub and are audited exactly once.
envv-cli/src/node_cmd.rs387envv node glue: hub side (token_new, ls, show, revoke, pull, accept, name-or-id lookup refusing ambiguity) and agent side (enroll, check, run), parse_ttl. token new follows the Phase 14 rule: written 0600 with --out, printed only with --reveal, otherwise redacted (exit 9). pull has no stdout form.
src/ts/nodes-pane.ts213Tools → Nodes: mint token (shown once, kept nowhere), refresh list, revoke (confirm), fetch pull file (saved through saveFile, never rendered in the page), accept. Markup goes through the escaping html template (Phase 28).
tests/nodes-pane.test.ts188 (8 tests)Local-vault message, list rendering, hostile name escaped, token request payload, validation before calling, 404/403/429 wording, revoke asks then refreshes, a pulled file goes to a file not the page.
packaging/envv-node.service40systemd unit for a host: dedicated user, ProtectSystem=strict, NoNewPrivileges, RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX (AF_UNIX for systemctl over dbus). The header says why sudo is not the answer (NoNewPrivileges) and to use a polkit rule for the one reload action.
book/src/adr/0140-nodes-sign-every-request-and-the-hub-never-stores-rendered-content.md-The decision record; cited by source comments (tests/adr-refs.test.ts fails on a citation with no record, which is how this one was found: I cited it before writing it).
the session records/51-2026-10-09-phase-34-nodes.mdthis-

Modified files

FileChange and why
vault-core/src/lib.rspub mod nodes; pub mod nodes_apply;
vault-core/src/tls.rsclient_config_tls13(policy); client_config now delegates to a shared client_config_with(policy, tls13_only) so the three policies exist once.
envv-cli/src/tls.rsbuild_node_client(timeout): pinned policy, TLS 1.3 only, no redirects (a redirect would carry a signed request somewhere the pin never saw). The existing test that forbids HTTP clients outside this module still passes, which is why the agent goes through here.
envv-cli/src/chunks.rsrender_project(vault, project, exporter) -> String: the hub's render path. Always Resolver::materialising (its caller hands the bytes to a node's disk, never to stdout). compose is the YAML, compose-env is the .env beside it (two targets, because a node writes one file per target).
envv-cli/src/lib.rspub mod node_agent; pub mod node_cmd;
envv-cli/src/main.rsCommands::Node, NodeCmd, NodeTokenCmd; run_node_agent_side() dispatched before any vault or session is opened (like catalogue); hub-side commands dispatched through Access. The enrollment token is accepted only as --token-file or --token-stdin (never argv: it would show in ps).
envv-cli/Cargo.toml, envv-server/Cargo.toml, vault-core/Cargo.toml, package.json, package-lock.json, src-tauri/Cargo.toml, src-tauri/tauri.conf.json, Cargo.lockVersion 0.34.0 (npm run version 0.34.0; package-lock.json lines 3 and 9 by hand, the script does not touch it). envv-server/Cargo.toml additionally gains envv-cli (path) and base64 = "0.22".
envv-server/src/lib.rsmod nodes; and AppState fields (nodes_enabled, nodes_path, nodes, nodes_wake, node_wants, node_uploads behind a NodeUploads alias for clippy's type-complexity), AppState::with_nodes, the nine routes, and nodes_wake.notify_waiters() after a successful put_vault (both the owner and the merged sub-user branch).
envv-server/src/main.rs--nodes, --nodes-file.
DockerfileStub envv-cli/src/lib.rs in the dependency-cache layer and COPY envv-cli/src envv-cli/src, because the server now depends on the CLI library. Not built: no Docker daemon was used.
src/ts/state.tsRemoteVaultStore.nodesRequest (same shape as uidRequest, plus DELETE).
src/ts/tools-markup.ts, index.html, src/ts/tools.tsThe tool-nodes pane, its nav button (data-tool="nodes"), initNodesPane().
tests/fixtures/parity/capabilities.jsonTwo capabilities: nodes-hub (both: six CLI leaves, tool:nodes) and nodes-agent (exempt, with the reason: those commands run on the managed host, which has no vault and no app). envv-cli/tests/capabilities.rs passes.
tests/phase23.test.tsFixed a time-of-day flake found on the way: the test built "today" as a UTC date but timeUntil reads a bare date as that day's end in local time, so it failed in the first hours after local midnight east of Greenwich (it failed at 00:18 here). Now builds the local date. Not related to Nodes.
book/src/SUMMARY.mdADR-0140 entry.
README.mdVersion line, contents entry, a "Nodes" section, the command and route tables, the phase table row. Prettier re-aligned the tables (60+/17- lines).
AGENTS.md (gitignored)Phase 34 row ✅, "Phase 34 - Nodes (landed)" block, ADR-0140 in the decisions table, three new test pitfalls, next handoff = 52.

2. Decisions, with the alternatives rejected

  1. Node identity = Ed25519 request signing, not client certificates. The design said "mutual fingerprint pinning". A custom ClientCertVerifier plus extracting the peer certificate inside handlers behind axum-server's rustls acceptor is the largest, least testable part of the phase. Signing gives the same property (the hub pins the node's key; the node pins the hub's certificate) and is testable with oneshot. Cost: the hub's listener still accepts TLS 1.2 (it also serves the app and the CLI); only the node client refuses to negotiate down. Written in ADR-0140.
  2. Replay protection = strictly increasing timestamp, persisted, plus a ±60 s window. Rejected: a nonce cache (memory, restart-sensitive). The mark lives in nodes.json, so a restart does not reopen the window (test the_replay_mark_survives_a_restart).
  3. Signature checked first. Skew, replay and revocation are only reported to someone holding the key. Every failure is the same 401 text for an unknown node and a bad signature.
  4. Enrollment token binds the node's name and its projects. The node cannot name itself or widen its scope by declaring a target; the hub intersects. Rejected: letting the node declare projects (a compromised node could ask for payroll).
  5. The hub stores no rendered content. The design's own invariant would be hollow if nodes.json (unencrypted) held a wg0.conf. Hashes and status only; a pull passes through memory ≤120 s, once (tests assert the registry file contains neither PrivateKey nor the base64 of the content).
  6. Pull delivers a file to the owner; it does not parse it into chunks. The parsers are TypeScript (chunks/parsers.ts); a Rust twin is a separate design. accept records the hash so the target reads in_sync until it changes. Recorded as not built.
  7. listen dialing deferred to 34.1. It needs a hub-side scheduler and a hub signing key the node pins. The protocol is symmetric enough to add it without changing the wire types.
  8. The hub renders with the CLI's exporters, so envv-server depends on the envv-cli library. Rejected: moving exporters.rs+refs.rs+envfile.rs+out.rs into vault-core (3k+ lines and a redaction global, for no behavioural gain). Cost: a bigger server build and the Dockerfile change.
  9. Config-check gate on the hub. render_gated refuses a project whose config_check has an error finding (e.g. two WireGuard peers with the same AllowedIPs); warnings do not block. This is the hook Phase 29.1 described (config_check::gate), done at the call site instead of as a new function; 29.1 can lift it into vault-core later.
  10. Heartbeats never touch the audit chain; applies, tokens, enrollments, revocations, pulls and accepts do. Apply results ride the next beat and are cleared by the node only on results_ack (a locked hub cannot append to the chain, so it says false).
  11. Plain HTTP is refused by the agent except to the loopback. A signature authenticates a request, it does not hide it, and a push carries secrets. Tests use loopback.
  12. apply on a push target is read only from the node's own config. The hub learns it from the beat (to decide whether to send content) but cannot change it; the node re-checks inside nodes_apply::apply and refuses.
  13. Opt-in --nodes. Off means 404 on every route and no nodes.json (tested).

3. Dead ends and incidents (read these)

  • I destroyed envv-server/src/nodes.rs with my own fault-injection script. The script backed up each file to bk/<basename>; vault-core/src/nodes.rs and envv-server/src/nodes.rs share a basename, so the second backup was refused and the restore step copied the vault-core file over the server's. The server file was untracked, so git held nothing. I rewrote it from the code in my own context (about 1,450 lines with tests). Lesson recorded in AGENTS.md pitfalls: key backups by full path and verify restores by diffing. The rewritten file passes everything and was re-mutation-tested.
  • The fault-injection run was first wrong in two more ways: cargo test nodes filters on test names, so tests/nodes_e2e.rs never ran and a real mutant ("agent clears results although the hub did not ack") looked like it survived; and replacing an if cond { with if false { made the only user of a variable unused, which is a build error under warnings = "deny", not a result. Both fixed in the harness (--test nodes_e2e added; if false && cond).
  • The first draft of the "config check" server test was a tautology (it asserted the gate agrees with the checker, which holds even if the gate is off). Replaced by a real firing case: two WireGuard peers with the same AllowedIPs, which config_check reports as an error, plus the same project with distinct addresses rendering. Mutation "gate off" now fails two tests.
  • A paste about tools-markup.ts and setHtml arrived mid-turn claiming the XSS fix was missing. html.ts already exists; mountToolsPanes() uses insertAdjacentHTML('afterbegin', TOOLS_PANES_HTML) with a documented lint suppression (a build-time constant). The pasted fix (setHtml(host, raw(...))) would replace #tools-workspace's existing children instead of prepending, a behaviour change, so it was not applied.
  • A Copilot summary of the 15 Dependabot PRs arrived mid-turn. No action: it contradicts phase log 50 on aes-gcm (the .vaultbak envelope must stay byte-compatible; run the backup tests alone first) and on the Rust image (separate decision). Nothing merged or closed.
  • ui-lab: first full run 165/166; the failing test (sidebar collapse › collapses when a custom width has been persisted) failed twice, then passed three times in a row, including with a debug print that showed the width at 300 px. Suspected cause: a cold Vite dependency cache after file changes against that spec's fixed 250 ms wait. It is unrelated to Nodes (git stash of index.html changed nothing about the pass/fail pattern beyond timing). Re-run before trusting it.

4. Verification (commands and counts)

  • cargo fmt --all -- --check: clean. cargo clippy --workspace --all-targets -- -D warnings: clean (one fix: type_complexity on the uploads map, now a type alias).
  • cargo test --workspace: 532 passed, 0 failed (vault-core 342 incl. 25 new in nodes/nodes_apply; envv-server 52 lib + 5 e2e; envv-cli 47+26+20+…; capabilities test passes with the two new entries).
  • npm run check (version, prettier, eslint --max-warnings 0, tsc, vitest): 1396 tests in 77 files, all pass (8 new in tests/nodes-pane.test.ts).
  • npm run ui-lab: 165 passed, 1 flaky failure as described above; the single test passes alone.
  • Fault injection (13 mutations, harness in the session scratchpad, recreate from the list): every one fails at least one test - no signature check, no replay check, no skew check, no revocation check, reusable token, no project scope in record_beat, no project scope in plan, push ignoring apply, config gate off, unsolicited upload accepted, ack while locked, no hash check in apply, agent clearing unacknowledged results. The 14th is an intentional no-op control and survives.
  • Not run: a Docker build (the Dockerfile change is untested), the Windows CI job, a real TLS hub with a pinned fingerprint (the e2e uses loopback HTTP; probe/--tofu and FingerprintVerifier are existing, separately tested code), the systemd unit on a real host, the app in a Tauri window.

5. Not done (named, so nobody assumes)

  • listen mode; pull-into-chunks; per-beat render cache (every beat loads the vault and renders; fine at tens of nodes, measure before hundreds; the ceiling is O(vault) per beat per node).
  • node.apply rows carry the node's reported error text (cut to 400 characters, scrubbed on the node). A hostile node can still write misleading text into its own audit rows; it cannot forge another node's actor.
  • The hub trusts a node's HostInfo/TargetReport for display only; none of it feeds a decision except apply (which only controls whether content is sent, and the node re-checks).
  • No rate limit on a signed node's beats beyond the per-IP failure limiter (a valid node can beat as fast as it likes). Add one if nodes are ever less trusted than "the operator's own machines".
  • Phase 39 (pentest) should cover: enrollment token guessing and the 12-attempt limiter, replay across restart, the signature input format, the config-check gate bypass by project-name tricks, command injection through reload (only the node's own file feeds it), and the long-poll as a resource-holding route.

6. Traps for the next agent

  • envv-server now depends on envv-cli. A new include_str! in envv-cli outside an already-copied path breaks the Docker image and nothing local notices (see phase log 50).
  • Anything added to the beat payload must be added to both ends and to the Beat defaults, or an older node's beat fails to parse (events, results, wait_secs use #[serde(default)] for exactly this).
  • NodeStore::authenticate persists last_ts_ms on every accepted request; do not call it twice for one request.
  • record_beat replaces the node's target list wholesale each beat but preserves accepted_sha by target id.
  • nodes_wake.notify_waiters() wakes only waiters already registered; the beat handler registers (enable()) before planning for that reason.
  • The agent's tls::configure writes a process-global policy. One process, one hub; do not enroll two hubs from one process.
  • Prettier is off for embedded languages; do not run it over html template files you did not write.

7. Next steps

  1. Run pwsh ~/Code/envvault-commit-phase34.ps1 (or review it first). Left out deliberately: HANDOFF.md (stale, untracked), the session records/ and AGENTS.md (gitignored).
  2. Phase 35 (config time machine) is next in the queue; it needs Phase 34's nodes (it versions a config with its secrets) and Phase 30's storage.
  3. Human-only: a real two-host run (hub with --tls, node with --hub-fingerprint), docker build, and the Windows CI job.

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