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 31: 2026-09-14

Phase 23 steps 4–7. The phase is complete: every step, every edge case E0–E17.


Goal

Finish Phase 23. Steps 1–3 landed the day before (the session records/30-2026-09-13.md); this session did 4, 5, 6 and 7, plus E16, which lost its step in 3 and was written down at the time rather than dropped quietly.


What landed

Step 4 - env_var entries with no mandatory primary

The design demanded the audit list be walked before the form validation was relaxed, not after. It was:

SiteFinding
Exporter.dotenv / .yamlEmitted NAME= unconditionally. Fixed: an empty primary is omitted. NAME= means "set to the empty string" in a file about to be loaded, which is a different claim from saying nothing - and out.rs deliberately keeps empty distinguishable from unset for exactly that reason. Both also now emit extra_vars, which they never did.
Health scan (tools.ts)Flagged every empty primary as "Short or weak secret value (< 12 chars)". Skipped when the primary is empty: a nag with no available fix is how a health scan trains people to ignore it.
out.rsThe empty fingerprint still means "unset" and still must. No change, checked.
enrich.rs is_blankAbout metadata fields, not the primary. No change, checked.
version_historyE8 - see below.

primaryIsOptional() and entryHasPayload() are the single predicates every one of those places now asks. "The primary value is never empty" was assumed in more places than it was stated, and each of them re-deriving the rule is how they drift.

E8 - version_history snapshotted api_key only. A refresh-token swap, a replaced client secret and every extra_vars edit left no history at all - and for an env_var entry, whose entire payload lives in named variables, nothing was versioned. E8 itself calls leaving a secret silently unversioned the one unacceptable option.

Now every secret-carrying value is snapshotted: api_key, api_secret, and every extra_vars value not marked public. Four decisions:

  • api_key still writes no field discriminator. Absent has always meant api_key and every vault written before Phase 22 relies on it.
  • Variables are matched by name, never by position. extra_vars is an array the form rebuilds on every save, so an index captured across an edit points at whatever took its place - invariant 1, in the one place where getting it wrong writes the wrong secret into history.
  • A deleted variable leaves its last value behind. Deleting a row makes its value exactly as unrecoverable as overwriting one, and the row's absence is not evidence the user meant to lose it.
  • A public var is skipped. It is a region or a client id by declaration, and filling a 50-record history with them evicts the values that cannot be recovered any other way.

envv entry restore restores to the field the record names, not to api_key - every record used to be an api_key snapshot, so writing there was right; now a record can be a re-enrolled seed, a replaced secret or a named variable, and putting any of those back into the primary slot would overwrite a live credential with an unrelated one.

E16 - how a credential is sent

auth_scheme (bearer | header | basic | query | cookie) and auth_param. How to send it is part of a credential and was nowhere in the model: two entries holding the same-looking string go one in Authorization: Bearer, one in X-Api-Key, one in ?api_key=, one as the password half of HTTP basic. The vault knew the secret and not the one other thing you need to use it.

envv curl <entry> [-- URL], a "Request header" and a "curl command" row in the copy caret menu, and a seventh twin pair (src/ts/auth-request.ts / envv-cli/src/authreq.rs) pinned by parity/auth-request.json.

Shell quoting is pinned in that fixture too. Every interpolated value is vault data (invariant 4) and one of them is routinely a cookie jar full of semicolons; a value that can end the quoted string can start a command. POSIX single-quoting, with cases for $(rm -rf /), a backtick, a semicolon and an embedded '.

Step 5 - cookies

New SecretType 'cookie', user_agent, last_verified_at, per-cookie attrs, and an eighth twin pair (src/ts/cookies.ts / envv-cli/src/cookies.rs) pinned by parity/cookies.json - three parsers (document.cookie, Netscape cookies.txt, browser-extension JSON) and three writers.

Every edge case in the design:

  • E5, the mask-whole rule. A cookie entry's extra_vars are masked whole and public is ignored for them: a jar split one cookie per var is N session credentials and any one of them is enough to be the account, so there is no "public half" here the way there is beside a client secret.
  • E6. A jar is 2–8 KB and dominated every listing an agent read. Collapsed to {cookies, names, bytes, fingerprint} - the same treatment the embedded icon already gets, reusing that shape rather than inventing a second one. user_agent is masked too: it is a browser fingerprint, and a listing full of them says which of the user's machines holds which account.
  • E7. expires_at was compared in whole days, so an AWS STS token (one hour), an OAuth access token and every scraped cookie read "expires today" for their entire life and the badge stopped meaning anything. timeUntil() counts in minutes and hours below a day, and below one day the warning is unconditional - expiryWarningDays tunes how far ahead a long-lived credential warns; a credential dying within the hour is not a matter of taste. A bare date is read as the end of that day, or every long-lived credential expires a day early.
  • E11. enrich --online never probes a cookie. It exists to ask an issuer about its own credential; replaying a session cookie from a desktop app is indistinguishable, at the far end, from the session hijack the cookie exists to prevent. Skipped with a named reason in the report, so the counts add up. (The signature table already matched with starts_with rather than a substring, so the second half of E11 was already true - checked, and written down beside the first.)
  • E13. Rotation is meaningless for a session - "rotate" means "log in again in a browser", which nothing here can do - so a cookie flagged never-rotated stays flagged forever. The rotation scan, the card badge and the form's cadence input all skip it, and last_verified_at + envv entry verify + a ✓ button on the card are the check that is actionable.
  • E14. cookies.txt needs a domain, an include-subdomains flag, a path, a secure flag and an expiry per cookie. A jar pasted as a bare document.cookie string has none, so the export is refused by name rather than writing a file yt-dlp silently ignores - which the user would discover as "the download is not logged in", with nothing pointing at the file. The form's split helper says so before the user gets there: "3 cookies - no domain or path, so cookies.txt cannot be written".
  • E15. Covered by E16's shellQuote.

Step 6 - collisions, rename confirmation, last_copied_name

E2. Two entries generating one variable name is a silent overwrite: the dotenv writers emit duplicate lines and every parser takes the last, and Exporter.yaml keys a map. Two entirely routine setups hit it - a key pool is by definition several entries for one provider, and one entry with primary_role: 'id' plus a var keyed ID does it alone.

Now: detected live in the form (naming the other entry), reported by the health scan with both sides named, and disambiguated by appending rather than dropping on every copy and export. Compared case-insensitively, because Windows environment variables are case-insensitive and envCopyCase: 'preserve' can produce a pair that collides there and not on Linux.

Two decisions the design did not anticipate:

  • The suffix is an ordinal, not the key_id. The design says pool members get _1/_2 and everything else gets the key_id - written when key_id was not expected to be part of the generated name. In this implementation it always is (it is a template segment, see phase log 30), so appending it again can never disambiguate anything: the two colliding names already contain it.
  • The first occurrence keeps its name. Renaming both halves of a collision would change a variable that was never ambiguous for the consumer reading it first - which is the rename this phase's "one thing this must not break" section is about.

last_copied_name is the only new persisted state in the phase. Renaming a provider, version or label silently renames every variable the entry generates, and the .env already on a server keeps the old name - renameProviderRefs()'s problem with a wider blast radius, because the stale reference is not in the vault at all. So a rename shows the before/after and asks, and only when the entry has actually been copied under the old name: asking on every rename of an entry nobody has ever deployed is the kind of confirmation people learn to click through. The health scan reports the drift too.

Step 7 - file-shaped credentials

E17. A GCP service-account JSON, an Apple .p8, an mTLS bundle and a kubeconfig are consumed by pointing at them. Copying the contents produces something no consumer wants, and pasting a 2 KB JSON blob into a .env produces a variable the library tries to open() as a path.

Two fields had each solved half of it: blob_ref held a path and not the file, so a fresh machine had the reference and not the credential; certificate_data held the PEM and no path, so exporting it for a consumer that wants a file had nowhere to write. blob_data (what is stored) plus mount_path (how it is delivered) lets one entry do both.

  • envv file write <entry> [--out], 0600, printing the .env line that names the file. There is no stdout form and deliberately never will be: the point is that the consumer wants a path, so this is materialising by construction rather than guarded by --reveal.
  • envv exec writes file-shaped entries into a 0700 scratch directory and puts the paths in the child's environment. Cleanup is Drop, not an explicit delete after status() - an explicit one is skipped on a panic and on every early return above it, and what would be left is a decrypted credential in /tmp with nothing to say it is there.
  • ${GCP/path} (and MOUNT, MOUNT_PATH, FILE) resolve to the mount path on both sides, pinned in parity/field-aliases.json.
  • --blob-file is capped at 128 KB and refuses above it. A truncated credential fails at deploy time with an error about malformed JSON, which names the consumer and not the vault that broke it.
  • In the app: a "Write to file…" action and "Copy the .env line (path, not contents)".

Verification

npm run check           version + prettier + eslint (0 errors) + tsc + 1120 tests, 49 files
cargo test --workspace   17 suites green, 194 in vault-core
npm run lint:rust        clippy --workspace --all-targets -D warnings, clean

Five parity fixtures now, each asserted from both implementations: env-names.json, copy-profiles.json, auth-request.json, cookies.json, and the role-aware and mount_path sections of field-aliases.json.

New unit coverage in tests/phase23.test.ts (20 tests) for the rules that live on one side only: which entries may have no primary, sub-day expiry, collision detection and disambiguation, and what a file-shaped entry emits. Three new vault-core tests pin E8, including the by-name matching and the deleted-variable case.

Real-browser checks (jsdom has no layout and no cascade): a cookie card shows expires in 38 min and no rotation badge, carries the ✓ verify button, and its caret menu offers the three cookie forms; a file-shaped card offers "Write to file…" and the path line; the form's split helper reports "3 cookies - no domain or path, so cookies.txt cannot be written" and produces three rows.


The bugs that mattered

  1. version_history versioned nothing for an env_var entry. Its whole payload is named variables, and none of them were snapshotted - so the one shape whose secrets live entirely in extra_vars was the one shape with no history at all.
  2. envv entry restore wrote every restored value into api_key. Harmless while every record was an api_key snapshot; the moment Phase 22 added seeds it would have overwritten a live credential with an unrelated one.
  3. Every empty primary was reported as a weak secret, which would have made the health scan useless the day step 4 shipped.
  4. expires_at compared in whole days, so every short-lived credential read "expires today" forever.

What worked

  • Walking the audit list before relaxing the validation. Two of the five sites needed changes and two did not; finding that out first is the difference between a step and a rollback.
  • Writing the deviations down as they happened. The ordinal suffix and the first-occurrence rule are both departures from the design, and both are in the source and here.
  • Reading the fixture diff instead of reasoning about it. Three expectation errors (the TRUE/FALSE subdomain column, 100/minute bare vs quoted, the disambiguation suffix) were all faster to find by running than by thinking.

What didn't

  • Guessing that the suppressions ratchet counts per rule per file. Adding one persist() - which returns a promise - pushed modals.ts over its recorded count and made ESLint report all eight floating promises in the file as errors, none of them near the change. void on the two new call sites put it back; the backlog did not grow.
  • Driving #f-secret-type with Playwright's selectOption. The native select is hidden behind a CustomSelect, so it is never "visible" - set .value and dispatch change instead. Worth knowing before the UI lab grows a form screen.

Next steps

  1. Commit. The pwsh one-liner was handed over in the session. Steps 1–3 are in the same working tree, uncommitted - this is one commit's worth of Phase 23 or two, the user's call.
  2. Phase 23 is done. The next phase is 24 - storage, row-per-entry, schema v2. It was scheduled adjacent to 23 precisely so the vault migrates once, and 23 has now added ten fields to VaultEntry, so that argument is stronger than when it was written.
  3. ui-lab still has no Authenticator screen, no cookie card and no seeded TOTP entry. Three cheap additions, and the first is what would have caught phase log 30's dead 2FA panel before a user did.
  4. Still outstanding from phase log 29: re-enrol the two exposed seeds, and delete or rebuild the stale 0.20.0 envv binary in target/debug.

Notes for whoever picks this up

Every one of E0–E17 has landed, and the two that changed shape on contact with the code said so in writing - the KEYID segment in step 1, the ordinal disambiguation suffix in step 6. That is the rule the phase set for itself: an unwritten exemption is indistinguishable from an oversight.

The one thing to carry forward: a jar, a file and a session are all credentials whose delivery is the hard part, not their storage. Everything in steps 5 and 7 follows from that - cookies.txt refuses rather than approximates, envv file write has no stdout form, and the scratch directory cleans up in Drop.