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 53: 2026-10-09 - Phase 36: blast radius

Written for the next LLM agent: a changelog and decision record. Version bumped to 0.36.0. Nothing is committed. Read 51-2026-10-09-phase-34-nodes.md (Nodes) and 52-2026-10-09-phase-35-the-config-time-machine.md (config history) first: this phase is a consumer of both, and the commit-script mechanism (a per-file registry, scripts run in phase order) is described in phase log 52 §0 and has been extended here (~/Code/envvault-commit-phase36.ps1, 9 commits; files first touched by 34 or 35 stay in those scripts with a joined message).

1. The problem and the shape of the answer

After a compromise the question is "which credentials were on that machine, and when?". It was never answerable, so the answer was "rotate everything", so nobody did. phase log 23 T6 says the hub knows what it rendered, the node knows what it wrote and the audit chain knows what changed, and that the real work is the log, not the query. Three gaps stood between those facts and an answer:

  1. Nothing recorded which secrets a rendered file contained.
  2. Local envv exec and --out are deliberately outside the audit chain, so there was no record of what reached this machine.
  3. By the time of a compromise an entry may have been rotated, renamed or deleted, so "what is in the vault now" is the wrong thing to look up.

2. What was built

New files

FileWhy
vault-core/src/blast.rs (9 tests)Pure logic. Exposed {entry_id, provider, key_id, field, fp, short}, Deployment, CurrentEntry, report(host, since, deployments, lookup, current). Groups per entry (first/last seen, count, fields, via), decides still_current by fingerprint, removed, builds rotate and one command. A failed apply counts only when the new file reached the disk (validate failed / reload failed); a refusal or hash mismatch does not. Names containing a quote are printed as a comment, not guessed at.
envv-cli/src/exposure.rs (5 tests)Matcher::from_vault (Aho-Corasick over out::secret_pairs, several owners per shared value, short below 8 characters) and current_map (the present, non-historical fingerprints and a vetted console_url). Same exact-value idea as envv shield.
envv-cli/src/matlog.rs (4 tests)The local ring: ~/.local/state/envv/materialisations.jsonl (session::state_file), 0600, newest 5,000 records, compacted at 6,000 (counted by newlines, not by parsing); remember(vault) is called by Access::load_vault; note(kind, note, text) matches the text against the remembered vault and writes {at, kind, host, vault, note, exposed[]} only when something matched. MAX_RECORDS is 50 under cfg(test).
envv-cli/src/blast_cmd.rs`envv blast-radius --host NODE
envv-cli/tests/blast.rs (3 tests, ~30 s)The real binary: exec, project export --out and history show N --out are logged by name and never by value (the log is 0600), a rotation moves the entry from ROTATE to rotated and prints "Nothing in the vault needs rotating", a command that handed out no secret leaves no record and only the entry it did hand out is named, --since narrows.
book/src/adr/0142-…mdThe decision record.
the session records/53-2026-10-09-phase-36-blast-radius.mdthis file

Modified files

FileChange and why
vault-core/src/lib.rspub mod blast;
vault-core/src/config_history.rsNew column exposed (in CREATE TABLE, plus an idempotent ALTER for a history created earlier); record(…, masked, exposed, cause, actor); the chain formula is now envv-history-v2 and includes sha(exposed) so hiding an entry from the list breaks the chain; Snapshot.exposed; exposed_by_sha (union over every snapshot with that hash, None when unknown, so "nothing exposed" is distinguishable from "not recorded"); record and prune use BEGIN IMMEDIATE. +3 tests (union/unknown, tamper with exposed, 4 threads × 25 concurrent writers verify clean).
envv-cli/src/out.rssecret_pairs(entry) -> Vec<(field, value)>; secret_values is now defined from it (behaviour unchanged). Labels: the field name, extra_vars/NAME, version_history.
envv-cli/src/history.rssnapshot_all computes and stores exposed (matcher built lazily, once per call); new snapshot_stream(conn, vault, project, exporter, cause, actor); dispatcher op blast {host, since}; node_deployments reads node.apply audit rows and takes the audit row's timestamp, not the node's own at. +2 tests.
envv-cli/src/history_cmd.rsinvoke is pub(crate); show/diff with --out call a.load_vault() first so the log can name what the file holds (history ops never load the vault otherwise).
envv-cli/src/access.rsload_vault wraps the old body (load_vault_raw) and calls matlog::remember.
envv-cli/src/fmt.rs, envv-cli/src/exec.rswrite_secret_file and emit (file, and stdout under --reveal) and exec::run (just before the child is spawned) call matlog::note. envv env (the redacted manifest) deliberately does not: it materialises nothing.
envv-cli/src/node_agent.rspub fn hostname().
envv-cli/src/lib.rs, envv-cli/src/main.rsmodules; Commands::BlastRadius {host, since}.
envv-server/src/nodes.rsprocess() keeps the connection and records the exact (project, exporter) render in the history (snapshot_stream, cause push:<node>, actor node:<name>) before adding a Push action.
envv-server/tests/nodes_e2e.rs+2 end-to-end tests (see §4).
src/ts/nodes-pane.ts, src/ts/tools-markup.ts, tests/nodes-pane.test.tsA Blast radius button on each node and a "since" date; formatBlast(report) renders the same text as the CLI; 1 new test (+ a lint fix of an unused argument).
tests/fixtures/parity/capabilities.jsonblast-radius: both (CLI leaf, tool:nodes); the note says the --host local half has no app counterpart by nature (the app's copy buttons are not logged).
README.md, book/src/SUMMARY.md, version files, locksSection "Blast radius", rows, ADR link, 0.36.0.
AGENTS.md (gitignored)Phase 36 row ✅, "Phase 36 - Blast radius (landed)" block, ADR-0142, next handoff = 54.

3. Decisions and rejected alternatives

  1. Exact-value matching, not reference parsing. The tempting design records which ${Provider/field} references a render resolved. It misses a secret typed literally into a chunk field, one that arrived through a composite part or a bundle, and anything the exporter derived. Matching the vault's actual secret values in the rendered text (the engine of envv shield) catches all of those and needs no change to the resolver. Cost: a coincidental match; handled by the short flag (under 8 characters), never by dropping the match.
  2. Store the exposure list with each snapshot, inside the hash chain. Reconstructing it at report time from the current vault would miss rotated and deleted entries, which are the interesting ones. The list is derived data, but it is what the report is made from, so editing it must be detectable: it is in the chain (envv-history-v2). Phase 35's data was never released, so changing the chain formula needed no migration.
  3. "Still current" by fingerprint comparison, against present values only (version_history excluded). Rejected: comparing last_rotated_at with the deployment time, wrong whenever an entry was edited without being marked rotated or marked rotated without a change.
  4. Hub clock, not node clock. node.apply audit rows carry the node's own at inside details; the report uses the audit row's timestamp. A node under investigation is not the source for when things happened. (Test: a_nodes_deployments_are_timed_by_the_hubs_audit_row…; mutation "trust the node's at" is caught.)
  5. Record what is about to leave the hub, first. Without it a first push of a vault that was written directly (no save through the server's hook) has no snapshot, so blast radius would list a real exposure as "unaccounted". snapshot_stream runs in process() before the Push; it deduplicates against a save's snapshot. The e2e test a_push_is_recorded_before_it_is_sent… fails without it.
  6. A bounded local ring file, outside the chain, for CLI materialisations. It is the shape AGENTS.md already prescribes for read auditing (every read used to write an audit row; pruning would break the chain). A table in vault.db was rejected: a CLI on a workstation using a remote vault has no database to write to, and the machine being investigated is the workstation. The log records entries, fields and fingerprints and never a value; a write with no vault secret in it is not recorded.
  7. One command, for the vault's copy only, and the report says so. envv entry rotate '<name>' --generate; … for exactly the still-current entries. Revoking at the issuer is outside the vault; the entry's console_url (http/https only) is printed beside it.
  8. Unaccounted, not clean. A deployment whose file hash has no snapshot (history off, or pruned) is listed with its time, target and short hash. Reporting "nothing exposed" for it would be the one false statement this feature must never make.
  9. Not covered, written down: a value printed by get --reveal; the app's copy buttons; a node's pull targets (never rendered by the hub); anything that opened the vault directly; a node host's own copy of a secret it already had.

4. Verification

  • cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings: clean.
  • cargo test --workspace: 601 passed, 0 failed (+28 over phase log 52).
  • npm run check: 1409 tests in 78 files, all pass; prettier, eslint --max-warnings 0, tsc clean.
  • npm run ui-lab: 167 passed.
  • End to end (real agent, real socket): after the agent applies a pushed file, blast on that node names Stripe as still_current with the console link and the command envv entry rotate 'Stripe' --generate, and no value appears in the report; after the vault's key is rotated (old value moved to version_history) the same query says still_current: false and an empty command; an unknown host answers empty, not an error; with history disabled the apply is reported as one unaccounted deployment and no entries.
  • Fault injection (harness mut3.py, backups keyed by full path): everything-is-still-current; a refused apply counted as exposure; since ignored (in report and in the dispatcher); a deleted entry counted as still current; no quote safety; history values counted as current; records with no secret written; the node's own timestamp trusted; deferred (not IMMEDIATE) snapshot transactions; the push not recorded before it is sent; write_secret_file not logged; emit --out not logged; exec not logged. All caught.

5. Incidents and dead ends

  • A mutation run gave false assurance and was caught by re-reading it. mut3.py reported mutants 11–13 ("write_secret_file / emit / exec not logged") as CAUGHT, but the test I had edited had not actually been changed (cargo fmt had reflowed the file, so my str.replace matched nothing, silently), so the test expected the old count and failed with or without the mutation. The tell was a debug script showing two log records while the test asserted three. Fix: edit the test for real, make the history show use the newest snapshot (snapshot #1 is the empty chunk from before the reference was set and contains no secret, so logging nothing for it was correct), and re-run those mutations. Lesson: a mutation is only "caught" if the unmutated test passes first; run the clean suite immediately before the mutation, and never str.replace into a file cargo fmt may have rewritten without asserting the replacement happened.
  • history show --out was not logged because history operations never call Access::load_vault, so no vault had been remembered and note silently did nothing. show/diff with --out now load the vault first. This is a pattern to watch: matlog::note is a no-op without a remembered vault, so any new command that writes real values to a file must load the vault.
  • ENVV_SERVER_URL in the shell sent a debug script's CLI at the real server ("Wrong master password"). Known trap (AGENTS.md); the script now unsets it.
  • A concurrency hole found while designing the push-time snapshot: two snapshotters (a save's background task and a push) could both read the same chain tail and each extend it, forking the chain so the stream never verifies. Fixed with BEGIN IMMEDIATE and proven with a 4-thread test; the "deferred" mutant is caught by it.
  • Pre-existing, not changed: fmt::emit (used by project export --out and every exporter) writes with std::fs::write, so the file gets the default umask (typically 0644), not 0600, although it holds real secrets. write_secret_file is the 0600 writer. Left alone because deploy files often need to be readable by a service user, but it deserves a decision (an --out mode flag?). Recorded here so it is not rediscovered.

6. Not done and traps

  • The app has no UI for --host local (by nature: the app's copy actions are not logged). If the app ever logs its own copy/export actions it should write the same ring through a Tauri command.
  • matlog::note builds a Matcher per call (O(secrets)); fine for a handful of materialisations per command, wasteful in a loop. Cache the matcher in remember if a command ever materialises per-item.
  • The materialisation log lives in the user's state directory; two users on one machine have two logs, and a root-run command logs to root's.
  • exposed lists are computed at snapshot time against the vault then: an entry created after the file was written is correctly absent; an entry whose value was identical by coincidence is present (reported, marked short if under 8 characters).
  • Run order of the commit scripts matters (34, 35, 36), see phase log 52 §0.

7. Next

Phase 37 (approval) sits on the same push path: the hub renders, a human approves the exact bytes, the hub releases an approval bound to the SHA-256 of the content, and the node refuses a mismatch. The hash is already announced with every Push and re-checked by nodes_apply::apply; what is missing is the pending state, the diff for the human (Phase 35's diff and snapshots are exactly that), and the approval token.