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:
| Site | Finding |
|---|---|
Exporter.dotenv / .yaml | Emitted 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.rs | The empty fingerprint still means "unset" and still must. No change, checked. |
enrich.rs is_blank | About metadata fields, not the primary. No change, checked. |
version_history | E8 - 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_keystill writes nofielddiscriminator. Absent has always meantapi_keyand every vault written before Phase 22 relies on it.- Variables are matched by name, never by position.
extra_varsis 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
publicvar 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_varsare masked whole andpublicis 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_agentis 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_atwas 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 -expiryWarningDaystunes 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 --onlinenever 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 withstarts_withrather 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.txtneeds a domain, an include-subdomains flag, a path, a secure flag and an expiry per cookie. A jar pasted as a baredocument.cookiestring has none, so the export is refused by name rather than writing a fileyt-dlpsilently 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/_2and everything else gets thekey_id- written whenkey_idwas 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.envline 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 execwrites file-shaped entries into a 0700 scratch directory and puts the paths in the child's environment. Cleanup isDrop, not an explicit delete afterstatus()- 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/tmpwith nothing to say it is there.${GCP/path}(andMOUNT,MOUNT_PATH,FILE) resolve to the mount path on both sides, pinned inparity/field-aliases.json.--blob-fileis 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
version_historyversioned nothing for anenv_varentry. Its whole payload is named variables, and none of them were snapshotted - so the one shape whose secrets live entirely inextra_varswas the one shape with no history at all.envv entry restorewrote every restored value intoapi_key. Harmless while every record was anapi_keysnapshot; the moment Phase 22 added seeds it would have overwritten a live credential with an unrelated one.- Every empty primary was reported as a weak secret, which would have made the health scan useless the day step 4 shipped.
expires_atcompared 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/FALSEsubdomain column,100/minutebare 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 - pushedmodals.tsover its recorded count and made ESLint report all eight floating promises in the file as errors, none of them near the change.voidon the two new call sites put it back; the backlog did not grow. - Driving
#f-secret-typewith Playwright'sselectOption. The native select is hidden behind aCustomSelect, so it is never "visible" - set.valueand dispatchchangeinstead. Worth knowing before the UI lab grows a form screen.
Next steps
- 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.
- 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. ui-labstill 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.- Still outstanding from phase log 29: re-enrol the two exposed seeds, and delete
or rebuild the stale 0.20.0
envvbinary intarget/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.