Phase log 33: 2026-09-14
Goal
Implement Phase 24 (shipped-feature defects, lint to zero, CI hygiene) and
Phase 24.1 (composite secrets and bundles), per the session records/32-2026-09-14-planning-phase-24-and-24-1-24-4.md's plan.
Both phases are large - Phase 24 alone has ~20 sub-items across UI defects, lint, CI and docs; 24.1 is a six-step design with a real-config acceptance test. This session worked the order of work CLAUDE.md itself lays out for each phase, front to back, and stopped when the well-specified, verifiable core was solid rather than spreading thin across everything. What is and is not done is listed explicitly below and in CLAUDE.md - the project's own rule is that an unwritten gap is indistinguishable from an oversight.
Current Progress
Phase 24, in the plan's own order:
- B.1 -
npx eslint --prune-suppressions.npm run lintnow exits 0 (501 warnings, no errors, no stale suppressions). Verified:npm run lintexit code checked directly, not inferred from the problem count. - A1 - remote 2FA codes.
entry_totp_code,totp_import_parse,totp_import_mergeandtotp_export_buildno longer takeState<VaultState>or check the local SQLCipher key - all four are pure over their arguments.liveCodeFor(src/ts/totp.ts) gates onst.vaultOpeninstead, which is true for a local or a remote session. A refusal is no longer swallowed silently:tickTotpnow sets the slot'stitleto name it. 3 new Rust unit tests, 3 new TS tests (tests/totp-ipc.test.ts). - A3 - exports. New Tauri command
write_export_filewrites a real file (resolves the downloads directory, falls back to home then temp, refuses to overwrite by appending(2),(3)…,0600on Unix). NewsaveFile()insrc/ts/utils.tsis the one call site; everycreateObjectURL+<a download>site inrender.ts(5),tools.ts(1),vault.ts(1) andimport-export.ts'sdownloadText/exportAsnow routes through it, and the success toast fires only after the write returns. 4 new Rust unit tests (filename disambiguation, path-traversal safety viaPath::file_name, 0600), plus a source-scanning test assertingcreateObjectURLappears nowhere else insrc/ts(theipc-contract.test.tsshape). - A2 - the Authenticator sidebar section removed.
renderAuthenticatorSection, its markup (#sidebar-section-authenticator), and thetotp-import-btn/totp-export-btnhalf of the wiring loop invault.tsare gone.sidebarSectionsdropsauthenticatoron every read, not via a one-time migration, and the old migration flag is left set so nothing tries to re-add the key. The Phase 22.2 test asserting the section "stays and is unchanged" is inverted to assert it is gone. The per-card 2FA ticker's start/stop, which the section used to own, moved torenderGrid. - A5 - the
*/"optional" marker.dynamicSecretFieldsnow recomputes it fromprimaryIsOptionalon every type change, extra-var add/remove/edit, cookie split and TOTP-seed edit, instead of writing it once from a static per-type label.aria-requiredon the field tracks the same predicate. - A11 - forward-compatibility guard, landed ahead of 24.1's first new
type as the design requires.
fillFormdetects asecretTypethe<select>has no<option>for (reads back as''), shows a banner ("made by a newer EnvVault - update to edit"), disables Save, andformToEntry's fallback chain preserves the real value verbatim instead of defaulting toapi_key. Checked the CLI side too:envv entry setwas already safe by construction ---typeis clap-validated against a closed list, so an unrecognised type can never be written through it, and the field is left untouched when--typeis omitted. No Rust change needed there. - Not started: A4 (Caps Lock via a real OS query), A6 (nested 2FA
variable display), A7 (uneven sidebar row heights), A8 (in-form generator
popover), A9 (emoji input), A10 (settings drag-reorder) - A4 and A9
explicitly need real-window diagnosis this session could not do. B.2–B.5
(the remaining 493 warnings, promoting six rules to
error). C (Dependabot, CodeQL,cargo-deny). D (thebook/docs site and ADRs).
Phase 24.1, steps 1–2 of 6:
- Step 1 - value kinds (data model only).
extra_varsgainsrole,tierandkind: ValueKind(a 26-member union: core, colour, template, large-id/bitfield groups - the dev-format group is explicitly deferred to 24.5 per the design). No editor/validator UI was built for these beyond what composites needed; storage never changes, which was the whole point. - Step 2 - composite renderer twin + parity fixture. New
vault-core/src/composite.rsandsrc/ts/composite.ts:{name}placeholder tokenising,{{/}}literal escapes, an unfilled placeholder refuses to render, one placeholder used twice is one part filled twice (C3), an unused part is reported not refused, and zone-aware percent-encoding (userinfo/host/path/query-name/query-value/fragment) classified from the template's own structure - never from a URL built out of real values, which would already contain the thing being classified.customand any unrecognised kind string render raw. Both sides implement RFC 3986's exact unreserved set by hand rather thanencodeURIComponent(which leaves a larger set unescaped in JS) - this was the one place a "just use the built-in" shortcut would have silently broken parity. Pinned bytests/fixtures/parity/composite.json(14 cases: the calendar URL, a Discord webhook, connection-string userinfo encoding, query name+value encoding, fragment encoding, literal braces, C3, unused-part reporting, custom-never-encodes, unknown-kind-is-custom, three refusal shapes, and control-character handling per kind), asserted fromtests/composite.test.ts(19 tests) andvault-core/tests/composite_parity.rs(1 test walking all 14 cases). 13 additional Rust unit tests and 4 additional TS unit tests beyond the fixture.SecretTypegained'composite'and'bundle'(the latter is data-model only this session - see below);VaultEntrygainedcomposite_template,composite_kind,bundle_id,bundle_slot,bundle_order,bundle_primary.composite_template/composite_kind/bundle_id/bundle_slot/bundle_primaryadded toenvv-cli'sPUBLIC_FIELDS(a masked template protects nothing and makes the entry unreadable).${…}reference resolution (envv-cli/src/refs.rs) renders a composite's bare reference (${X}), and${X/template}(a newcanonical_fieldalias) returns the raw shape; a part is an ordinaryextra_varslookup and needed no new code. 4 new Rust tests for this specifically. Form support: acompositeoption in the type select, a template textarea- kind select (
#f-template-group, hidden for every other type), a live preview wired to the template, kind and every extra-var row, and a save-time "template is required" check. Card support: the value row shows the rendered text (masked/revealed exactly likeapi_key) with the template underneath in small type; a render error shows as red text instead of breaking the card. 4 new render tests, 4 new modals tests were not needed beyond the type-config completeness the compiler already enforced (Record<SecretType, TypeConfig>demandedcomposite/bundleentries the moment the union grew, which caught the gap at typecheck time rather than at runtime).primaryIsOptionalandentryHasPayload(src/ts/state.ts) both learnedcomposite(payload = has a template) andbundle(payload = true, unconditionally - a bundle's real "empty" check needs its members, which it does not itself record; that is B2 in the design, deferred).
- kind select (
- Not started: step 3 (the bundle entry as a real card, membership
operations, B1–B16), step 4 (the
{slot.field}scope, derived locals, taint, cycles), step 5 (export dialects, import), step 6 (the Discord acceptance fixture).bundleexists as aSecretTypeand a set of fields and nothing else - an entry can be saved as one, but nothing renders it as a bundle card, nothing lets you add a member, and no scope resolution exists. This is the largest remaining piece of 24.1 by a wide margin; the design itself sequences it after composites for exactly the reason this session ran out of scope for it first.
Verification, full workspace:
npm run typecheck- clean throughout.npm run lint- exit 0, 493 warnings (down from 501; no new ones), no errors, no stale suppressions.npx vitest run- 1157 tests across 50 files. One transient failure was seen once in the full run and did not reproduce on a targeted re-run of the same file (see What Didn't Work) - chase this before trusting a red run in this suite without re-running the specific file first.cargo test --workspace- clean baseline before this session's Rust changes, then targeted runs of every new/touched module (vault-core'scompositeandcomposite_parity,envv-cli'srefs::composite_ref_testsand the fullenvv-clisuite) all green; a fullcargo test --workspacere-run after all Rust changes was not repeated at the very end of the session - worth doing before the next one starts.cargo clippy --workspace --all-targets- clean (workspace denies warnings) both mid-session and after the composite additions.
The bugs that mattered
Ranked by consequence to the user, not by size of the fix.
- A1 - every 2FA code was blank on every remote vault. The gate checked
whether the local SQLCipher key was present, which is
Noneon a session connected only to a remote - the same bug shape as the Phase 12 LAN wrong-vault fix, three years and several phases later, in a different command. Nobody was told why; the slot just showed- - -forever. - A3 - every export in the app reported success and wrote nothing.
WebKitGTK's webview has no download handler and the app has no dialog/fs
plugin, so a
<a download>click silently went nowhere while the toast said "Exported ✓". This covered ICS,.env, YAML, JSON, k8s Secrets,.tfvars, encrypted backups, settings export and the bulkexport.envtool - nine call sites, all fixed by the same helper. - A11 - an entry of an unrecognised type would have been silently
downgraded to
api_keyon the next Save, once 24.1 or 24.5 ships a type this build predates. Not yet reachable in the wild (no build has shippedcompositebefore this session), which is exactly why it had to land first - the design's own point about A11 needing to be released before the type that would trigger it. - A2 - two surfaces for one feature, one of them silently broken. The sidebar Authenticator section showed nothing whenever A1's bug applied, and having two places to look for the same six digits is confusing even when both work. Removed rather than fixed twice.
Decisions made
- RFC 3986's unreserved set is hand-rolled in both languages, not
delegated to
encodeURIComponent. JS's built-in additionally leaves! ~ * ' ( )unescaped; using it in TypeScript while Rust used a strict RFC 3986 encoder would have made the twin pair disagree on exactly the inputs a security-relevant fixture would think to test. - Composite zone classification reads the template's own text, never the
rendered output. Parsing a URL built from the actual secret values would
mean the classifier's input already contains whatever ambiguity (an
@in a password, a/in a token) the classification exists to resolve correctly. The template's structure is fixed and known before any part is filled in. - No save-dialog plugin for A3. Adding
tauri-plugin-dialogis a real dependency and capability-file change for a defect fix; the downloads directory plus filename disambiguation gives the correct behaviour (a real file, never silently overwritten) without it. Worth reconsidering if a "Save As…" UX is explicitly requested later. bundlewas added as aSecretTypeand field set this session, with nothing built on top of it, rather than left out of the union entirely. The alternative was leavingTYPE_CONFIG'sRecord<SecretType, TypeConfig>un-exhaustive until step 3 lands, which means the type wouldn't typecheck the moment anything else touches that map. Landing the shape now and the behaviour later is the same call E4 made aboutformToEntry's spread.
What Worked / What Didn't Work
Worked:
- Following each phase's own "Order of work" list literally, front to back, rather than picking items by perceived interest - it is already priority- ordered with the dependencies worked out.
- Writing the Rust and TypeScript composite renderers side by side, function by function, rather than one and then the other - the zone classifier in particular would have been easy to get subtly wrong in the second language if it were translated from a finished first draft instead of built in parallel with the same test cases in mind.
- Running
cargo check/clippy/targeted tests after every discrete change rather than batching - every regression this session (the_unknownSecretTypetest-state leak, the totp-ipc test suite's missingst.vaultOpen, the composite masking test's wrong assumption aboutextra_vars' pre-existing opt-in secret flag) was caught within one tool call of being introduced.
Didn't work / watch for:
- The full
npx vitest runreported "1 failed | 1156 passed (1157)" with exit code 0 - the background wrapper's own exit code is not a substitute for reading the summary line. Chased it:tests/modals.test.ts's "TYPE_CONFIG covers every secret type" asserted the exact 8-member key set, which this session'scomposite/bundleadditions legitimately grew to 10 - not flakiness, a real assertion this session needed to update and initially missed because a targeted run oftests/modals.test.tsalone (run several times earlier in the session, before the composite work) had passed, and the composite-specific tests were checked with their own targeted runs that never happened to include this particularit. Fixed; full suite green after. Lesson: a targeted re-run after a change is not the same guarantee as the specific test file that would have caught it - run the full suite, not just the file you think you touched. - Piping a long-running command straight through
tail -Nas part of the backgrounded command itself throws away everything before the last N lines permanently - when a failure needs its stack trace, redirect to a file andgrep/tailthe file afterward instead of baking| tailinto the backgrounded command.
Next Steps
- Chase the one flaky test from the final full-suite run before trusting green in this suite again.
- Phase 24, order of work items 5–9: A6/A7/A10 (small, no real-window
need), A4/A9 (need X11 and Wayland diagnosis in a real Tauri window -
cannot be done in this environment), A8 (the in-form generator popover,
moderate size), then B.2–B.5 (lint to zero - 493 warnings across mostly
tools.ts,state.ts,users.ts,import-export.ts- and the 236 originally-suppressed errors already folded into B.1's prune), then C and D. - Phase 24.1 step 3: the bundle entry as a real card (member sections,
slot tabs,
bundle_primary's Copy button), create/add-member/remove-member/ dissolve operations, and B1–B16's edge cases (dangling membership, empty bundle, RBAC reads/writes, one-save atomicity). This is the largest single remaining piece of either phase. - Phase 24.1 steps 4–6: the
{slot.field}scope with taint and cycle detection, export dialects (.env/Python/JS/JSON/YAML/TOML/shell) and import (.env/JSON/TOML/the Python assignment subset), and finally the Discord acceptance fixture - the phase is not done until a real bot config round-trips. - Only the human can do: enable Dependabot alerts, private vulnerability
reporting and code-scanning default setup in repository settings (C);
enable GitHub Pages via Settings → Pages → Source: GitHub Actions, if not
already done from an earlier phase, before D's
docs.ymlcan deploy.
Notes for whoever picks this up
src/ts/composite.tsandvault-core/src/composite.rsare meant to be read side by side - every function has a same-named counterpart, and the module doc comments cross-reference each other. If you change one, open both.- The bundle fields (
bundle_id,bundle_slot,bundle_order,bundle_primary) exist intypes.tswith full doc comments describing the eventual design, but nothing reads or writes them yet except that abundle-typed entry can be created and saved (as an otherwise-inertenv_var-shaped entry, per the design's own B11 "older builds" fallback - which is currently every build, including this one's UI). refreshCompositePreviewandrefreshRequiredMarker(A5) are both wired throughdynamicSecretFields, which now does more than its name suggests (type-driven field visibility and two live previews). If it grows a fourth concern, consider splitting it - the function was already long before this session.