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 52: 2026-10-09 - Phase 35: the config time machine

Written for the next LLM agent: a changelog and decision record. Every file, why, the alternatives rejected, the dead ends, exact verification, what is not done, and traps. Version bumped to 0.35.0 (npm run version 0.35.0, package-lock.json lines 3 and 9 by hand). Nothing is committed. Read 51-2026-10-09-phase-34-nodes.md first: this phase is built on Nodes and on the same working tree, which still holds Phase 34 uncommitted.

0. How the commit scripts work now (read before touching them)

The working tree holds Phase 34 and Phase 35 (and will hold later phases) uncommitted, so a file edited by both is committed once, in its final state. The scripts are therefore generated from a per-file registry, not per phase:

  • Registry: <session scratchpad>/registry.json = {file: {"34": sentence, "35": sentence}}; generator <scratchpad>/mkps1.py. It writes ~/Code/envvault-commit-phase<N>.ps1 for every phase N, putting each file in the script of the earliest phase that touched it, with the sentences of every phase joined in order (so the commit message is true of what the commit contains). It refuses if a changed file has no entry or an entry names an unchanged file.
  • Run the scripts in phase order: pwsh -File ~/Code/envvault-commit-phase34.ps1, then 35, and so on. Phase 35's script holds 11 commits (files first touched in 35); the files Phase 35 also edited but Phase 34 introduced are in the 34 script with a joined message.
  • If the scratchpad is gone, regenerate the registry from git status and this handoff's file table. The generated .ps1 files in ~/Code/ are the artefacts; HANDOFF.md (stale, untracked), the session records/ and AGENTS.md (gitignored) are never in them.

1. What was asked and designed

"Do phase 35 then 36 …" with the 1.0.0-viability brief. Phase 35 is phase log 23 "T4": version control that can hold the whole file, including its secrets. Scope kept: snapshot every rendered config on change, diff any two points, envv history, retention with a checkpoint, and the link to nodes ("diff intent against what is on the host"). The two hard parts from the design were storage (satisfied by Phase 30's tables and SQLCipher) and retention (below).

2. What was built, file by file

New files

FileWhy
vault-core/src/config_history.rs (18 tests)Tables config_snapshots (seq, project id, project name, exporter, at, sha256 of the real text, bytes, content, masked, cause, actor, chain) and config_checkpoints, created by init_schema. record (dedupe against the stream's newest hash, 2 MB cap, chain link), list, get, latest, find_by_sha, resolve_pair (default diff = two newest of a stream; asks for --exporter when a project has two streams), diff, prune, verify, stats, policy/set_policy (in vault_meta: history_enabled, history_keep, history_days; defaults on / 50 / 90).
vault-core/src/textdiff.rs (7 tests)The line diff, once, in Rust: common head/tail stripped, LCS table over the middle, above MAX_CELLS = 4,000,000 the middle is one delete plus one insert. diff_ops, unified, stat.
envv-cli/src/history.rs (6 tests)The one dispatcher call(conn, op, args, actor) (ops: list, show, diff, snapshot, prune, verify, stats, policy, where), snapshot_all (renders each project with its type's exporter, twice: real and masked; Compose is two streams, compose and compose-env; projects that render to nothing are skipped), after_save (the hook; logs, never fails a save).
envv-cli/src/history_cmd.rs`envv history ls
envv-cli/tests/history.rs (4 tests, ~40 s: each CLI call pays an Argon2id)The real binary: a save is snapshotted; a rotation diffs as -STRIPE_KEY= / +STRIPE_KEY= with neither secret in the default output; --reveal and --out show them (0600); an unrelated save adds no snapshot; policy --disable skips exactly one save; editing a stored snapshot behind the binary's back makes verify exit 10.
envv-server/src/history.rs (6 tests)Owner-only POST /api/history {op, args} through spawn_blocking; snapshot_after_save (background task after put_vault); snapshot_for_hash for the node link. Tests: 401 without a token, 403 for a signed-in sub-user, background snapshot appears and an identical save adds none, default diff carries no secret and reveal does, history off records nothing, an unknown op is a 400.
src/ts/history-pane.tsTools → Config history. Backend chosen by the store: remote → st.store.nodesRequest('POST','/api/history',{op,args}); local in the desktop app → invokeTauri('history_call'); plain browser → a message. Reveal is a checkbox plus a showConfirm; declining sends reveal: false. "Save file" writes the real text through saveFile and never renders it. Markup via the escaping html template.
tests/history-pane.test.ts (11 tests)Browser message, remote list, desktop history_call, hostile project name stays text, masked by default and a confirmed Reveal (declining sends reveal:false), "Diff with previous" asks for that stream with to=seq, Save file never renders, prune counts → asks → deletes (and cancels), verify problems shown as an error, diff with no project refused before any call, non-owner 403 wording.
book/src/adr/0141-config-history-keeps-a-masked-and-a-real-copy-of-every-rendered-config.mdThe decision record (cited from three source files; tests/adr-refs.test.ts enforces it).
the session records/52-2026-10-09-phase-35-the-config-time-machine.mdthis file

Modified files

FileChange and why
vault-core/src/lib.rspub mod config_history; pub mod textdiff; and config_history::init_schema(conn)? next to ics_feeds.
envv-cli/src/chunks.rsrender_project now delegates to render_project_as(vault, project, exporter, redact), which builds the Resolver with from_parts(..., redact). redact = true is the fingerprinted text the history stores beside the deployable one.
envv-cli/src/access.rsAccess::save (local) calls history::after_save after a successful save (skipped by --dry-run, which returns earlier); Access::local_conn().
envv-cli/src/lib.rs, envv-cli/src/main.rsmodules; Commands::History, HistoryCmd, dispatch.
envv-server/src/lib.rsmod history;, the /api/history route, snapshot_after_save after both successful put_vault branches (owner and the merged sub-user branch).
envv-server/src/nodes.rsGET /api/nodes adds snapshot: {seq, at, cause} to a target whose reported_sha is in the history; new test a_drifted_target_says_which_snapshot_of_the_history_it_still_matches.
src-tauri/src/lib.rshistory_call command (local vault only) registered in generate_handler!; save_vault clones the document before saving so it can snapshot afterwards, and returns the version unchanged.
src/ts/nodes-pane.ts, tests/nodes-pane.test.tsShows "file matches history #N (date)" on a target; one test.
src/ts/tools-markup.ts, index.html, src/ts/tools.tsThe tool-history pane, its nav button, initHistoryPane().
tests/fixtures/parity/capabilities.jsonconfig-history: status both, nine CLI leaves, tool:history, tauri history_call.
README.md, book/src/SUMMARY.md, version files, Cargo.lock, package-lock.jsonSection "Config history", command/route/phase rows, ADR link, 0.35.0.
AGENTS.md (gitignored)Phase 35 row ✅, "Phase 35 - Config time machine (landed)" block, ADR-0141 row, next handoff = 53.

3. Decisions (and what was rejected)

  1. Two stored texts per snapshot (real and masked). The first idea was store the real text and mask on read with the shield engine (envv shield matches the vault's current secret values). It cannot work for history: a secret that was rotated away is no longer in the vault, so nothing knows to mask it in the old snapshot, and an old snapshot is where it sits. The exporters already have a redacting mode (Resolver with redact = true, what envv project export prints), so the masked text is rendered at snapshot time, when the secrets are still known. Cost: 2× storage. The masked hash is inside the chain so editing it is detected.
  2. One dispatcher, three surfaces (history::call). Rejected: a Tauri command and an HTTP route per operation (nine of each to keep in step). The server has one route, the app one command, the CLI calls it directly on a local vault.
  3. Snapshot where the save happens, not in vault-core::save_vault. Rendering needs the exporters, which are in envv-cli. Three hooks (local CLI save, server put_vault, Tauri save_vault). The server hook is a background task so a save does not wait on rendering. Consequence (documented and tested): saves in quick succession coalesce, because a snapshot renders the vault as it is when it runs. A test that made two puts back to back failed for exactly this reason and was changed to wait for the first snapshot; that is the behaviour, not a bug.
  4. Retention = keep ∧ days, never empties a stream, always checkpointed. Deleted rows are those both beyond the newest keep and older than days; that set is always a prefix of the stream, so one checkpoint (the chain value of the last deleted row) lets the rest verify. A prune also appends config.prune to the vault audit chain naming that value, and verify requires a matching audit row for every checkpoint. An attacker with write access to the table could delete a prefix and forge a checkpoint, but then verify reports "no matching row in the audit chain" unless they also forge the audit chain (tested: a_forged_checkpoint_with_no_audit_row_is_caught).
  5. Default on, with a default policy (50 / 90 days). Rejected: opt-in. The whole point is that it is there when you need "what was deployed on the 3rd"; the liability ("old secrets live forever") is answered by the policy and by --disable, and the data is as protected as the vault (SQLCipher).
  6. Owner-only. Snapshots hold every project in the clear. A sub-user is refused (tested), the pane says so on 403.
  7. Diff in Rust only. A TypeScript diff would be a twin pair needing a golden fixture; the UI only displays the result.
  8. Not built: restore into chunks (parsers are TypeScript; show --out writes the old file), diff against a node's live file (the hub only knows a hash by ADR-0140), history for sub-users, and a history diff … --to now.

4. Incidents and dead ends

  • Fixture bug in my first masked-diff test. The stand-in "masked" text was MASKED:<body>, i.e. contained the secret, so the assertion "a masked diff carries no secret" could not hold. A real masked render does not contain the value; the helper now derives m:<hash8> from the body.
  • rechain() in a test ignored checkpoints, so the second prune in prune_keeps_the_newest… rewrote the first row's link from genesis and failed verification. It now starts from genesis_for (the latest checkpoint), which is also the function record uses.
  • genesis_for was covered only by that test helper, so the mutation "a new snapshot ignores the checkpoint" survived (it is unreachable through prune, which keeps the newest row). Added a_stream_that_lost_every_row_after_a_prune_restarts_from_its_checkpoint (an operator deleting rows by hand) and the mutant is now caught.
  • The sub-user test was missing: mutation "route not owner-only" survived because the only auth test sent no token. Added a_signed_in_sub_user_is_refused (a non-owner Session inserted into the state's map).
  • Two harness bugs again (mut2.py): cargo test -p x a b takes one filter (use -- a b), and if false { over a block's only user of a function is a dead-code build error under warnings = "deny". Backups are keyed by full path this time (see phase log 51).
  • Lint: Promise<unknown | null> is flagged by no-redundant-type-constituents; it is Promise<unknown>.
  • ui-lab: 167/167 on the final run. The failing "persisted sidebar width" test of phase log 51 did not recur; treat it as a cold-cache flake.

5. Verification

  • cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings: clean.
  • cargo test --workspace: 573 passed, 0 failed (+41 over phase log 51: 18 config_history, 7 textdiff, 6 + 1 history dispatcher/server, 4 CLI binary, 1 node link, minus rounding from earlier counts).
  • npm run check: 1408 tests in 78 files, all pass; prettier, eslint --max-warnings 0, tsc clean.
  • npm run ui-lab: 167 passed.
  • Fault injection (harness mut2.py; all caught): real text in the masked column; no dedupe; a new stream ignoring its checkpoint; prune deleting the kept rows instead of the old ones; prune without its audit row; checkpoint not checked against the audit chain; chain not verified; content hash not verified; show ignoring reveal; diff always real; /api/history not owner-only; the local save not snapshotting.
  • Not run: the app in a Tauri window (history_call and the save_vault hook are compiled and covered by the dispatcher tests and the contract test, not driven by a real window), a 5,000-entry vault timing for snapshot_all (every save renders every project; the ceiling is O(projects × chunks) per save, run in the background on the server but inline in the desktop save_vault and the CLI, so measure it before a vault with hundreds of projects).

6. Traps

  • snapshot_all renders with the machine's own envCopyField setting and the process's naming flags. Two machines with different settings can record different text for the same vault and show it as a change. Project exporters are not affected by --env-case/--env-prefix (they apply to vault-level exports), but envCopyField does affect ${Provider} resolution.
  • after_save in the Tauri command runs on the command thread while holding the vault-state mutex; a very large vault makes saves feel slower. Move it to a spawned thread if that is measured.
  • prune takes keep >= 1 through the policy but call("prune", {keep: 0}) clamps with .max(1); do not rely on keeping nothing.
  • The history is created in init_schema; a vault last opened by an older build lacks the tables until the next open by this one, which is fine because every opener runs init_schema.
  • verify cross-checks config.prune audit rows by substring of the chain value in details; if the audit details format changes, keep the chain value in it.

7. Next

Phase 36 (blast radius) uses this history and Phase 34's node reports: the hub knows what it rendered (snapshots with at), the node knows what it wrote (apply results), the audit chain knows what changed. envv blast-radius --host … --since … needs a bounded materialisation log outside the chain for local exec/render.