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:
- Nothing recorded which secrets a rendered file contained.
- Local
envv execand--outare deliberately outside the audit chain, so there was no record of what reached this machine. - 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
| File | Why |
|---|---|
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-…md | The decision record. |
the session records/53-2026-10-09-phase-36-blast-radius.md | this file |
Modified files
| File | Change and why |
|---|---|
vault-core/src/lib.rs | pub mod blast; |
vault-core/src/config_history.rs | New 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.rs | secret_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.rs | snapshot_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.rs | invoke 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.rs | load_vault wraps the old body (load_vault_raw) and calls matlog::remember. |
envv-cli/src/fmt.rs, envv-cli/src/exec.rs | write_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.rs | pub fn hostname(). |
envv-cli/src/lib.rs, envv-cli/src/main.rs | modules; Commands::BlastRadius {host, since}. |
envv-server/src/nodes.rs | process() 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.ts | A 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.json | blast-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, locks | Section "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
- 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 ofenvv shield) catches all of those and needs no change to the resolver. Cost: a coincidental match; handled by theshortflag (under 8 characters), never by dropping the match. - 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. - "Still current" by fingerprint comparison, against present values only (
version_historyexcluded). Rejected: comparinglast_rotated_atwith the deployment time, wrong whenever an entry was edited without being marked rotated or marked rotated without a change. - Hub clock, not node clock.
node.applyaudit rows carry the node's ownatinsidedetails; the report uses the audit row'stimestamp. 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'sat" is caught.) - 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_streamruns inprocess()before thePush; it deduplicates against a save's snapshot. The e2e testa_push_is_recorded_before_it_is_sent…fails without it. - 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.dbwas 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. - 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'sconsole_url(http/https only) is printed beside it. - 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.
- 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,tscclean.npm run ui-lab: 167 passed.- End to end (real agent, real socket): after the agent applies a pushed file,
blaston that node names Stripe asstill_currentwith the console link and the commandenvv entry rotate 'Stripe' --generate, and no value appears in the report; after the vault's key is rotated (old value moved toversion_history) the same query saysstill_current: falseand 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;sinceignored (inreportand 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_filenot logged;emit --outnot logged;execnot logged. All caught.
5. Incidents and dead ends
- A mutation run gave false assurance and was caught by re-reading it.
mut3.pyreported mutants 11–13 ("write_secret_file / emit / exec not logged") as CAUGHT, but the test I had edited had not actually been changed (cargo fmthad reflowed the file, so mystr.replacematched 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 thehistory showuse 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 neverstr.replaceinto a filecargo fmtmay have rewritten without asserting the replacement happened. history show --outwas not logged because history operations never callAccess::load_vault, so no vault had been remembered andnotesilently did nothing.show/diffwith--outnow load the vault first. This is a pattern to watch:matlog::noteis a no-op without a remembered vault, so any new command that writes real values to a file must load the vault.ENVV_SERVER_URLin 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 IMMEDIATEand proven with a 4-thread test; the "deferred" mutant is caught by it. - Pre-existing, not changed:
fmt::emit(used byproject export --outand every exporter) writes withstd::fs::write, so the file gets the default umask (typically 0644), not 0600, although it holds real secrets.write_secret_fileis the 0600 writer. Left alone because deploy files often need to be readable by a service user, but it deserves a decision (an--outmode 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::notebuilds aMatcherper call (O(secrets)); fine for a handful of materialisations per command, wasteful in a loop. Cache the matcher inrememberif 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.
exposedlists 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.