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 59: 2026-10-09 - everything that was left: 30.1/30.2, 34.1, 37.1, 38.1, 29.1, 28.1, 31.3 and the leftovers of 24.5, 35 and 36

Written for the next LLM agent: a changelog and decision record. Nothing is committed; version unchanged (0.38.0, sub-phases take no bump). Read phase log 57 and 58 first. Commit scripts: ~/Code/envvault-commit-phase34.ps1 to 41.ps1, run in order. Every file is committed once, in the earliest script that touched it, with a message joining every phase's sentence; this round's new files are in 41, and files first touched in 34 to 40 got a "Phase 41 also ..." clause.

1. What was asked and what was done

The user said "I asked you to do everything. this isnt everything. DO everything." After phase log 58 the open list was: the app half of 30.1, 30.2, 34.1, 38.1, the rest of 29.1, the rest of 28.1, more of 31.3, and the "not built" lines of 24.5 (JWKS, GPG expiry, Jellyfin, passkeys), 35 (diff against a node's file, restore into chunks), 36 (coverage), 34 (pull into chunks) and 37 (approval signed on the owner's device). All are now built except the items in section 5, which need the maintainer or a real deployment.

2. Changes by topic (every file, and why)

30.1 / 30.2 - delta saves, chunk rows, retention, load speed (ADR-0146)

FileChange
vault-core/src/lib.rsapply_row_patch(doc, patch): {put, delete, projects_put, projects_delete, categories} applied by id, shared by the server and the desktop so they cannot differ; VAULT_SCHEMA_VERSION is 3 (a v2 build now refuses the file instead of deleting chunk rows).
vault-core/src/storage.rsKind chunk: split moves a project's chunks to rows keyed <project key>\u{3}<chunk key> leaving chunks: [] on the project row; join re-attaches them and still understands a v2 inline array; merge drops a chunk row whose project the other writer deleted. Change history is kept for the newest 2,000 saves and 30 days, hard ceiling 200,000 (prune_saves); merge_window feeds envv doctor. par_parse parses rows and history on worker threads (full load of 5,000 entries with history: 73 ms to 36 ms, release). entry_history(id) reads one entry's history; load_entries_where (no caller) deleted. 8 new tests.
envv-server/src/lib.rsPATCH /api/vault (owner only, same compare-and-swap as PUT), GET /api/vault/entries/{id}/history.
src-tauri/src/lib.rssave_vault_rows; remote_request now returns etag and merged (the pinned proxy used to drop them, so a remote store over it wrote with no If-Match).
src/ts/state.tspersist() remembers the JSON of each entry and project as of the last save that reached the store and sends the difference (currentPatch); trusted only while the document and its arrays are the same objects, order unchanged, last save not failed; otherwise the whole document. RemoteVaultStore.saveRows, TauriVaultStore.saveRows, lastSaveFailed.
envv-cli/src/doctor.rsSays how far back a stale writer can be merged.
tests/delta-save.test.ts, tests/remote-save-rows.test.tsThe client's decision; the proxy headers.

Not done in 30.2 and why: the app still loads version_history with every entry (rotation and the health scan read it from the loaded entry; a client that loaded entries without it would first need the save path taught that "absent" means "unchanged").

34.1 - the hub dials a node that listens (ADR-0145)

vault-core/src/nodes.rs (ListenInfo, sign_hub_request/verify_hub_request under the envv-hub-v1 domain, the hub transport key in nodes.json, listening(), mark_polled), vault-core/src/tls.rs (self_signed, server_config_tls13, fingerprint_of_pem), envv-cli/src/node_listen.rs (new: the listener), envv-cli/src/node_agent.rs (enroll with --listen/--advertise, build_beat/accept_reply/sign split out of beat_once), envv-cli/src/tls.rs (build_pinned_node_client, hub_post), envv-server/src/nodes.rs (poll_node, spawn_poller, enrollment takes a listen address and returns the hub transport key), envv-server/src/lib.rs/main.rs (start_node_poller), src/ts/nodes-pane.ts (shows the endpoint and last poll). Tests: 4 in vault-core, a pinned-handshake test, and in nodes_e2e.rs the hub dials a real agent and a save wakes it; a listener refuses replay, skew, another key, a node-domain signature, a wrong path, and a wrong pin never reaches the handler; TLS 1.2 is refused; the hub ignores an unsigned beat, one signed by another key or for another id (with a control signed correctly); twenty stalled sockets do not delay a legitimate poll (pentest finding below).

37.1 - approval signed on the owner's device (ADR-0147)

Policy device, approver.key under the data directory (shared by CLI and app), Approver registry on the hub, NodeConfig.approver, decide_signed, envv node approver show|register|ls|rm, Tauri approver_public/approver_sign, Tools, Nodes buttons. Bug found by the first e2e test: a device node was not held at all, because two places compared the policy to "required" only (vault-core/src/nodes.rs status and envv-server/src/nodes.rs::process); both now accept required or device.

38.1 - Nextcloud and Grafana (ADR-0148)

vault-core/src/php_config.rs (parse a PHP array literal without running it; bounded), envv-cli/src/import_vaults.rs (source_value, read_nextcloud; envv import-vault nextcloud FILE, the app's import pane accepts .php), envv-cli/src/enrich.rs (grafana_base, probe_grafana; consent names the host). Verified against Grafana 11.2 (accepted and refused tokens) and a Nextcloud 29 container's installer: /api/user/orgs does not work for a service-account token, so the role is not offered; the organisation comes from /api/org. .github/workflows/exporters.yml gained grafana-probe.

29.1 / 28.1 - the rest

vault-core/src/config_check.rs: rules 13 and 14 (compose-network-undeclared, k8s-service-selector-unmatched by name, since the model has no label fields), services_of + check_project_scoped (the wide scope), gate() (error findings only; envv-server/src/nodes.rs::render_gated now calls it). envv check --all-projects, the config-check panel's checkbox, config_check_project's optional elsewhere. 28.1: tests/escape-panes.test.ts (Authenticator, remotes, LAN, users and classes, feeds, copy menu, forms, settings, health scan, UID pane) and two ui-lab screens (panel-auth, project-config-findings, the latter answering its one IPC command from a stub).

31.3 - catalogue

Added with the page that states each: Sentry sntryu_/sntrys_ (token-formats table), Supabase sb_publishable_/sb_secret_, HashiCorp Vault hvs./hvb./hvr., Doppler dp.st. (examples only), age AGE-SECRET-KEY-1 (README), Heroku HRKU- (example output). Fetched and refused (page does not state a prefix): Postman, New Relic, Pulumi, Render, Perplexity, Airtable, OpenRouter, PlanetScale, Brevo; Neon shows napi_ once beside a different placeholder; Mapbox gives pk/sk without the dot.

24.5 leftovers

vault-core/src/pgp.rs (fingerprint, key id, user ids and expiry from the self-signatures; checked against real gpg output; --pgp-public, the form's Read key, pgp_inspect); vault-core/src/jwks.rs (+ jwks emitter on signing_key, public so it prints without --reveal; vectors made by Python's cryptography, thumbprint equals RFC 8037); Jellyfin preset through a new auth_template field (MediaBrowser Token="{key}"; twin pair + fixture cases incl. a line-break injection). Jellyfin's primary documentation is a script-rendered page that could not be read; the form is from independent client libraries and a mirror of the API docs (stated in the code comment). Passkey workflows: CXF import/export already exists; EnvVault is not an authenticator, so nothing further was built.

35 / 36 / 34 leftovers

envv history diff-file + diff_text + the pane's Compare with a file (without --reveal only the names of the differing lines, never what follows, because the snapshot's masking was made from the vault as it stood then); the materialisation log now records every --reveal output (out::ok) and every app clipboard copy (matlog_note), and node pull loads the vault so its file is matched; envv node pull --into-chunk (+ app "Read into a chunk", env_import_plan) for .env-style targets only, never flattening a ${reference}.

3. Pentest-style findings from this round (phase 39 follow-up)

  1. The listener served one connection at a time: a client that connected and sent nothing held it for the I/O timeout, so one stalled socket every ten seconds kept the hub from ever polling. Fixed: a thread per connection, a hard 20 s total deadline, at most 16 at once, and only a whole, signed request takes the agent lock. Pinned by an e2e test with twenty stalled sockets.
  2. A device node was not held (above).
  3. Dynamic probe of a running envv-server --nodes --tls: unauthenticated beat, malformed headers, a 10 MB body (413), enrollment brute force (429 after ten), every owner route (401) and PATCH /api/vault unauthenticated (401) all behaved; the hub's own listener still accepts TLS 1.2 (the documented deviation of ADR-0140). Running the server with --tls and a temp --db-path still writes server.crt/server.key into ~/.local/share/envv-server unless XDG_DATA_HOME is set; this run created two files there by mistake and removed them (they did not exist before: the directory held only the June vault.db and vault.salt).

4. Verification

  • cargo fmt --check, cargo clippy --workspace --all-targets -D warnings: clean. cargo test --workspace: 707 passed, 0 failed. npm run check: 1487 tests in 87 files, lint/format/typecheck clean. npm run ui-lab: 185 passed (before the final listener-constant change, which touches no UI). docker build .: succeeds. The end-to-end node suite was run three times in a row after the last change (18 passed each).
  • The stalled-sockets test failed once under full-suite load with the first cap (16): twenty idle sockets filled it and the legitimate poll was reset. The cap is 128, the read timeout 5 s and the total deadline 10 s; ADR-0145 and the listener header say what that does and does not protect against (an attacker who can hold 128 sockets can still delay a poll, so firewall the port to the hub's address).
  • Fault injection (all caught unless noted): delta order check, array-identity check, failed-save snapshot rule; chunk split disabled; orphan-chunk filter; retention age condition; compose-network-undeclared and k8s-service-selector-unmatched conditions; node listener replay mark, signature and skew checks; device guard in process (needed a dedicated test: the first mutation survived because every device record carries a signed token; the survivor is the case "policy switched after a hub-made yes"); renderers made to use raw() in the remotes list, the Authenticator side list, the LAN card, the users list and the feed list. Survivor, accepted: removing if id != &node.id in poll_node changes nothing, because authenticate already looks the node up by the header id; it is defence in depth.

5. Not done, and why

  • Bundle acceptance (24.1): needs your own sanitised Discord config.
  • 24.4 container benchmark, the real-window smoke tests (A4/A9/A10), GitHub repository settings, Pages, the catalogue signing secret, tagging 1.0.0, the envv name decision, the phase log 56 checklist against a real deployment: yours (see review/review-02.md).
  • Restore a history snapshot into chunks (35): only .env-style files have a Rust parser; the other formats' parsers are TypeScript, and a second implementation would be a new twin pair. The pulled-file path (--into-chunk) covers the .env case.
  • Lazy version_history in the app (30.2), concurrent polls and certificate rotation for listening nodes (ADR-0145), a hardware key as approver (ADR-0147), a Nextcloud rotation adapter and Grafana token expiry (ADR-0148).

6. Traps for the next agent

  • pkill -f "<pattern>" kills the shell that contains the pattern (it did, twice). Use pgrep and kill.
  • A backgrounded ( ... ) & makes the task notification fire at once; poll the output file.
  • cargo fmt between an edit script and a second str.replace silently breaks the replace; assert old in s.
  • A line-number counter that rescans from the start per value is quadratic (the PHP parser took 104 s on 150,000 items); compute lazily.
  • Prettier checks vault-core/tests/fixtures/ unless ignored (it is now); fixtures are the assertion.
  • A new Tauri command, CLI leaf or flag must be classified in tests/fixtures/parity/capabilities.json or envv-cli/tests/capabilities.rs fails.
  • Fixed-size arrays of public fields (PUBLIC_FIELDS_P23 in out.rs) need their length bumped when a field is added.
  • Never edit src/ or tests/ while npm run check is running; the ADR/source citation tests read them as text.