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:
mainatc05396f, version 0.33.1 uncommitted in the six version files andCargo.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; nevergit 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
| File | Lines | Why |
|---|---|---|
vault-core/src/nodes.rs | 1125 (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.rs | 479 (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.rs | 1459 (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.rs | 446 (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.rs | 387 | envv 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.ts | 213 | Tools → 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.ts | 188 (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.service | 40 | systemd 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.md | this | - |
Modified files
| File | Change and why |
|---|---|
vault-core/src/lib.rs | pub mod nodes; pub mod nodes_apply; |
vault-core/src/tls.rs | client_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.rs | build_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.rs | render_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.rs | pub mod node_agent; pub mod node_cmd; |
envv-cli/src/main.rs | Commands::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.lock | Version 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.rs | mod 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. |
Dockerfile | Stub 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.ts | RemoteVaultStore.nodesRequest (same shape as uidRequest, plus DELETE). |
src/ts/tools-markup.ts, index.html, src/ts/tools.ts | The tool-nodes pane, its nav button (data-tool="nodes"), initNodesPane(). |
tests/fixtures/parity/capabilities.json | Two 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.ts | Fixed 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.md | ADR-0140 entry. |
README.md | Version 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
- Node identity = Ed25519 request signing, not client certificates. The design said "mutual fingerprint pinning". A custom
ClientCertVerifierplus extracting the peer certificate inside handlers behindaxum-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 withoneshot. 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. - 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 (testthe_replay_mark_survives_a_restart). - 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.
- 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).
- The hub stores no rendered content. The design's own invariant would be hollow if
nodes.json(unencrypted) held awg0.conf. Hashes and status only; a pull passes through memory ≤120 s, once (tests assert the registry file contains neitherPrivateKeynor the base64 of the content). - 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.acceptrecords the hash so the target readsin_syncuntil it changes. Recorded as not built. listendialing 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.- The hub renders with the CLI's exporters, so
envv-serverdepends on theenvv-clilibrary. Rejected: movingexporters.rs+refs.rs+envfile.rs+out.rsintovault-core(3k+ lines and a redaction global, for no behavioural gain). Cost: a bigger server build and the Dockerfile change. - Config-check gate on the hub.
render_gatedrefuses a project whoseconfig_checkhas 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 intovault-corelater. - 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 saysfalse). - 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.
applyon 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 insidenodes_apply::applyand refuses.- Opt-in
--nodes. Off means 404 on every route and nonodes.json(tested).
3. Dead ends and incidents (read these)
- I destroyed
envv-server/src/nodes.rswith my own fault-injection script. The script backed up each file tobk/<basename>;vault-core/src/nodes.rsandenvv-server/src/nodes.rsshare 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 nodesfilters on test names, sotests/nodes_e2e.rsnever ran and a real mutant ("agent clears results although the hub did not ack") looked like it survived; and replacing anif cond {withif false {made the only user of a variable unused, which is a build error underwarnings = "deny", not a result. Both fixed in the harness (--test nodes_e2eadded;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_checkreports as an error, plus the same project with distinct addresses rendering. Mutation "gate off" now fails two tests. - A paste about
tools-markup.tsandsetHtmlarrived mid-turn claiming the XSS fix was missing.html.tsalready exists;mountToolsPanes()usesinsertAdjacentHTML('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.vaultbakenvelope 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 stashofindex.htmlchanged 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_complexityon the uploads map, now a type alias).cargo test --workspace: 532 passed, 0 failed (vault-core 342 incl. 25 new innodes/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 intests/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 inplan, push ignoringapply, config gate off, unsolicited upload accepted, ack while locked, no hash check inapply, 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/--tofuandFingerprintVerifierare existing, separately tested code), the systemd unit on a real host, the app in a Tauri window.
5. Not done (named, so nobody assumes)
listenmode; 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.applyrows 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/TargetReportfor display only; none of it feeds a decision exceptapply(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-servernow depends onenvv-cli. A newinclude_str!inenvv-clioutside 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
Beatdefaults, or an older node's beat fails to parse (events,results,wait_secsuse#[serde(default)]for exactly this). NodeStore::authenticatepersistslast_ts_mson every accepted request; do not call it twice for one request.record_beatreplaces the node's target list wholesale each beat but preservesaccepted_shaby 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::configurewrites 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
htmltemplate files you did not write.
7. Next steps
- Run
pwsh ~/Code/envvault-commit-phase34.ps1(or review it first). Left out deliberately:HANDOFF.md(stale, untracked),the session records/andAGENTS.md(gitignored). - 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.
- 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.