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 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 lint now exits 0 (501 warnings, no errors, no stale suppressions). Verified: npm run lint exit code checked directly, not inferred from the problem count.
  • A1 - remote 2FA codes. entry_totp_code, totp_import_parse, totp_import_merge and totp_export_build no longer take State<VaultState> or check the local SQLCipher key - all four are pure over their arguments. liveCodeFor (src/ts/totp.ts) gates on st.vaultOpen instead, which is true for a local or a remote session. A refusal is no longer swallowed silently: tickTotp now sets the slot's title to name it. 3 new Rust unit tests, 3 new TS tests (tests/totp-ipc.test.ts).
  • A3 - exports. New Tauri command write_export_file writes a real file (resolves the downloads directory, falls back to home then temp, refuses to overwrite by appending (2), (3) …, 0600 on Unix). New saveFile() in src/ts/utils.ts is the one call site; every createObjectURL + <a download> site in render.ts (5), tools.ts (1), vault.ts (1) and import-export.ts's downloadText/exportAs now routes through it, and the success toast fires only after the write returns. 4 new Rust unit tests (filename disambiguation, path-traversal safety via Path::file_name, 0600), plus a source-scanning test asserting createObjectURL appears nowhere else in src/ts (the ipc-contract.test.ts shape).
  • A2 - the Authenticator sidebar section removed. renderAuthenticatorSection, its markup (#sidebar-section-authenticator), and the totp-import-btn/ totp-export-btn half of the wiring loop in vault.ts are gone. sidebarSections drops authenticator on 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 to renderGrid.
  • A5 - the */"optional" marker. dynamicSecretFields now recomputes it from primaryIsOptional on 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-required on the field tracks the same predicate.
  • A11 - forward-compatibility guard, landed ahead of 24.1's first new type as the design requires. fillForm detects a secretType the <select> has no <option> for (reads back as ''), shows a banner ("made by a newer EnvVault - update to edit"), disables Save, and formToEntry's fallback chain preserves the real value verbatim instead of defaulting to api_key. Checked the CLI side too: envv entry set was already safe by construction - --type is clap-validated against a closed list, so an unrecognised type can never be written through it, and the field is left untouched when --type is 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 (the book/ docs site and ADRs).

Phase 24.1, steps 1–2 of 6:

  • Step 1 - value kinds (data model only). extra_vars gains role, tier and kind: 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.rs and src/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. custom and any unrecognised kind string render raw. Both sides implement RFC 3986's exact unreserved set by hand rather than encodeURIComponent (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 by tests/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 from tests/composite.test.ts (19 tests) and vault-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. SecretType gained 'composite' and 'bundle' (the latter is data-model only this session - see below); VaultEntry gained composite_template, composite_kind, bundle_id, bundle_slot, bundle_order, bundle_primary. composite_template/composite_kind/bundle_id/ bundle_slot/bundle_primary added to envv-cli's PUBLIC_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 new canonical_field alias) returns the raw shape; a part is an ordinary extra_vars lookup and needed no new code. 4 new Rust tests for this specifically. Form support: a composite option 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 like api_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> demanded composite/bundle entries the moment the union grew, which caught the gap at typecheck time rather than at runtime). primaryIsOptional and entryHasPayload (src/ts/state.ts) both learned composite (payload = has a template) and bundle (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).
  • 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). bundle exists as a SecretType and 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's composite and composite_parity, envv-cli's refs::composite_ref_tests and the full envv-cli suite) all green; a full cargo test --workspace re-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.

  1. A1 - every 2FA code was blank on every remote vault. The gate checked whether the local SQLCipher key was present, which is None on 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.
  2. 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 bulk export.env tool - nine call sites, all fixed by the same helper.
  3. A11 - an entry of an unrecognised type would have been silently downgraded to api_key on the next Save, once 24.1 or 24.5 ships a type this build predates. Not yet reachable in the wild (no build has shipped composite before 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.
  4. 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-dialog is 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.
  • bundle was added as a SecretType and field set this session, with nothing built on top of it, rather than left out of the union entirely. The alternative was leaving TYPE_CONFIG's Record<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 about formToEntry'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 _unknownSecretType test-state leak, the totp-ipc test suite's missing st.vaultOpen, the composite masking test's wrong assumption about extra_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 run reported "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's composite/bundle additions legitimately grew to 10 - not flakiness, a real assertion this session needed to update and initially missed because a targeted run of tests/modals.test.ts alone (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 particular it. 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 -N as 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 and grep/tail the file afterward instead of baking | tail into the backgrounded command.

Next Steps

  1. Chase the one flaky test from the final full-suite run before trusting green in this suite again.
  2. 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.
  3. 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.
  4. 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.
  5. 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.yml can deploy.

Notes for whoever picks this up

  • src/ts/composite.ts and vault-core/src/composite.rs are 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 in types.ts with full doc comments describing the eventual design, but nothing reads or writes them yet except that a bundle-typed entry can be created and saved (as an otherwise-inert env_var-shaped entry, per the design's own B11 "older builds" fallback - which is currently every build, including this one's UI).
  • refreshCompositePreview and refreshRequiredMarker (A5) are both wired through dynamicSecretFields, 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.