Phase log 22: 2026-08-27 - Phase 20 observability, secret creation dates, an ICS calendar feed, and envv use
Continues from 21-2026-08-25-phases-17-and-18-implemented-ci-unblocked-twi.md, which closed Phases 17 and 18. This session
was paused partway through Phase 19 at the user's request (token budget), so
this file has an unusually large Next Steps section - three Phase 19 items are
specified but unwritten.
Goal
The ask was three things at once: implement the staged Phases 19 and 20, add a panel showing when each secret was created, and add an iCalendar feed covering every creation and expiry date in the vault.
The audit at the start found that none of Phase 19 or 20 existed in the
tree - no tracing, no /api/health, no Retry-After, no totp anywhere, no
.envv.json - and, more importantly, that VaultEntry had no creation date
at all. The requested "creation date panel" therefore needed a new field plus
a migration story before it could show anything, and that shaped the whole
session.
Two decisions were put to the user before starting. Both are recorded under Decisions made.
Current Progress
Version 0.8.1 (unchanged - no bump this session; the release pipeline is triggered by a version bump, and this work is not released yet).
Verification at the pause point: 856 frontend tests across 37 files, all
Rust tests passing (envv-cli 19 lib + 8 parity + 6 + 11 + 1, envv-server 21),
tsc --noEmit clean, cargo check --workspace clean.
Phase 20 - Operability (3 of 4 items complete)
| Item | What landed |
|---|---|
20.1 tracing | vault-core/src/telemetry.rs, behind a new telemetry feature, enabled by all three binaries. ENVV_LOG (then RUST_LOG) sets the filter; ENVV_LOG_FORMAT=json switches to one JSON object per line. |
20.2 /api/health | Liveness and lock state with no counts, 503 when the server is bound but cannot read its own vault. |
20.3 Retry-After + request ids | 429 carries Retry-After; every response carries x-request-id; every error body carries request_id. |
20.4 envv name collision | Closed as a decision, no code. See Decisions made. |
Logging rules, written into the module and worth keeping:
- stderr, always. The CLI's stdout is a machine-readable contract. A log
line written there corrupts the JSON envelope an agent is parsing, so
ENVV_LOG=debug envv get X --json | jqhas to keep working - and it does, becausewith_writer(std::io::stderr)is not optional. - Never log a value. Fingerprints, entry ids and provider names only. A log file is not encrypted and is routinely shipped somewhere else.
- Quiet by default.
envv-serverand the desktop app default toinfo; the CLI defaults toerror.
Defaults differ on purpose: a long-lived server's log is read after the fact, and a CLI's stderr is read now, by a human who ran one command.
/api/health is deliberately not /api/stats. Usage data about a secrets
manager is itself sensitive, and this endpoint is the one an unauthenticated
monitor polls every few seconds. It reports status, version, unlocked,
vault_exists, uptime_secs and nothing else. A test asserts the absence of
secrets_stored, users_total and users_connected, so a later "just add a
count" cannot pass silently.
It also had to fail when the process is wedged rather than merely bound.
The old Docker healthcheck opened the TCP port, which a hung process answers
perfectly well - restart: unless-stopped sat on top of a check that could not
go red. When an owner session claims the vault is unlocked, /api/health now
opens the database and runs a query, and answers 503 when it cannot.
Verified live against a running debug server: unlocked → 200 ok; the database
file overwritten with garbage under the running process → 503 degraded,
with the ERROR line in the log carrying the same request id as the response.
Rate limiting now says when to retry. rate_is_allowed returned a bool, so
a client could not tell "slow down" from "denied" - the agent-driven CLI is the
main caller and had to guess. It is now rate_retry_after returning
Option<u64> seconds, floored at 1 (a client told to retry in 0 seconds retries
instantly, is refused again, and reads the server as broken). Verified live:
twelve parallel bad-token requests, then HTTP/1.1 429 with retry-after: 43.
Request ids use a tokio::task_local!, not an axum Extension. err_json
is a free function called from forty places, several frames deep inside
forbidden and guard_manage_user, none of which can reach an extractor.
Scoping the id to the request future gives all of them the same answer without
threading a parameter through every call site. Outside a request - the desktop
app calling a handler directly, unit tests - current_request_id() is None
and the field is omitted rather than emitted as null, because a correlation
handle that leads nowhere is worse than no handle.
Secret creation dates (new feature)
VaultEntry.created_at, optional, ISO-8601, never rewritten on edit. Set by
the desktop form, by envv entry add, and by every import path (.env import
in both halves, and the vendor importers). "When it entered this vault" - an
imported Bitwarden item does not inherit Bitwarden's creation date, because that
is the date another vault first held it.
The migration question is the interesting part. Options were: stamp "now" on every undated entry, infer, or leave blank. The user chose infer-else-unknown, and the implementation is stricter than that sounds:
backfillCreatedAt()writes a date only from anaddrow in the audit log, which is exact evidence. Oldest matching row wins.- An ambiguous provider is skipped entirely. Audit rows identify an entry by
entry_provideralone, so two entries sharing a provider cannot be told apart, and attributing one entry's creation to its namesake is the array-index bug class wearing a different hat. The CLI refuses ambiguous lookups for exactly this reason; so does this. version_historyis not a source. Its oldestsaved_atis when a value was replaced - an upper bound, not a creation date. The panel shows it as "before 4 Mar" and never writes it, because a stored date is indistinguishable from a known one the moment anything else reads it back.
Secret Timeline panel (new feature)
Tools → Secret Timeline. Columns: Secret, Created, Age, Expires, Rotation due.
Sortable four ways, with undated entries pinned last in both directions -
a ?? Infinity would sink them one way and float them the other, putting the
least informative rows at the top of the ascending view.
The Created column carries a certainty: known, bound ("before …"), or
unknown, and .tl-muted is the visual form of "we do not actually know this".
A bound rendered at full contrast reads as a creation date, which is the one
thing this pane must not imply.
iCalendar feed (new feature)
src/ts/calendar.ts and envv-cli/src/calendar.rs - the fourth twin pair,
and it follows the rule the other three established: one golden file
(tests/fixtures/parity/calendar.ics), asserted from both sides. It was
byte-identical on the first run.
Three event kinds per entry, all all-day: Created, Expires (7-day alarm),
Rotate (3-day alarm). Rotation counts from last_rotated_at, falling back to
created_at - a 90-day key that has never been rotated is due 90 days after it
was issued, not never - and an entry with neither anchor gets no event, because
putting a deadline in someone's calendar that no evidence supports is worse than
leaving it out.
UIDs are <entry-id>-<kind>@envvault, stable across exports. That is what makes
a re-import an update rather than a duplicate.
What is deliberately not in the file: values, and fingerprints. An .ics is
the least private artefact this program produces - its whole purpose is to be
handed to Google Calendar or a phone, which stores it unencrypted on hardware
nobody here controls. A fingerprint is stable per value, so a feed carrying them
would be an equality oracle over the vault published to a third party. Both
absences are asserted, in Rust and in TypeScript, against the real fixture
vault rather than a toy entry.
Surfaces: envv calendar [--kind created|expires|rotation] [--project P] [-o FILE]
and an "Export .ics" button in the panel, which confirms first and says plainly
that names travel even though values do not.
envv calendar may write to stdout under the default redacting policy, which is
an exception to the Phase 14 rule and a deliberate one: export is refused
because a masked .env still looks deployable, while an .ics has no values
to mask - it carries names and dates, which envv list already prints.
Validated against a real parser, not just against itself. Python's
icalendar 7.3.0 parses both the golden fixture and live CLI output; every
event's DTEND - DTSTART is exactly one day (an all-day DTEND is exclusive,
and a zero-length event is dropped by several clients); every raw line is ≤ 75
octets; there are no bare LFs. The control case - the same file with
END:VCALENDAR removed - is rejected, so the check proves something about the
good file.
Phase 19 - UX (1 of 4 items complete)
envv use (19.4). Writes .envv.json in the working directory; commands
run there inherit the project and environment. Upward search from the working
directory, so envv get from src/deep/nested/ finds the context at the top of
a checkout. Precedence: explicit flag > ENVV_PROJECT / ENVV_ENV > file.
--show, --clear, and each half settable independently (--env staging in a
directory already pinned to a project must not silently unpin it).
The file names things and never holds a value, so it is written 0644 and is safe to commit. A context file that held credentials would need every consumer to remember to gitignore it, which is a rule that gets forgotten exactly once.
A stale pin is an error, not a shrug. A context naming a project the vault no
longer has exits 3 and says how to fix it. The desktop app's answer to invariant
7 is to drop the reference and repaint; a CLI cannot do that, because "matched
nothing" and "no filter" produce visibly different output for list and
identical output for a vault with one project. Silently widening a scope the
user believes is narrow is the worse failure.
--show and --clear are dispatched before open_access, for the same
reason doctor and restore-archive are: the state they are most needed in is
the state where opening a vault fails.
Applied to every command that already had --project: list, entry ls,
export, calendar, exec, watch, import, import-vault.
19.5 (Settings-panel overflow) was already done before this session, per phase log 21.
The bugs that mattered
Ranked by what they did, not by which line was wrong. Three of these were mine, caught before they shipped.
'\;'in a JavaScript string literal is;. The ICS escaper's semicolon branch was a no-op, soicsEscapewould have emitted a raw;- a parameter separator in RFC 5545, which corrupts the property it appears in. Caught by the parity fixture disagreeing with Rust, then again by the unit test, whose expectation contained the identical mistake and had to be fixed before it could fail correctly. Rust has the same trap and rejects it at compile time (unknown character escape: ;), which is how the Rust half was caught./api/healthreported "Wrong master password" for a corrupt database. SQLCipher cannot tell the two apart - the header simply fails to decrypt - so passing the underlying error through sent an operator hunting a password problem while the file underneath the running server had been replaced. The detail string is now written here, not forwarded, and a test asserts it never contains the word "password".- The first attempt at
/api/healthstolestats's#[utoipa::path]. The new function was inserted between the existing attribute and the function it decorated, so the attribute landed onhealthand the new one onstats-conflicting implementation for __path_health, seven compile errors deep. Inserting before a function is only safe when the function has no attributes; check for them. - The parity fixture's project filter existed twice.
envv calendarneeded the same--projectmatchingenvfile::export_vaulthad inline. Extracted todata::entries_in_projectrather than copied, because two definitions of what--project webmeans agree right up until one of them learns about sub-projects. serde_jsonmap key order is not insertion order without thepreserve_orderfeature. A context-file test asserted the field list in declaration order and failed on a correctly written file. Compare as a set.
Decisions made
- The
envvbinary-name collision is closed with no rename. Put to the user as a Phase 20 item; the answer was that envvault.dev does not work and a project rename may happen anyway. No alias binary, no deprecation shim. - Undated entries stay undated unless there is exact evidence. Stamping
"now" would make every pre-existing secret claim it was created today, and
that lie would then be repeated by the timeline panel and by every exported
.ics. Chosen by the user from three options. - An inferred upper bound is displayed, never stored. Once written to
created_at, a bound is indistinguishable from a known date to the CLI, to an export, and to the calendar feed. - Ambiguity refuses rather than guesses, in the backfill as everywhere else in this codebase.
.icscarries no fingerprints. They are stable per value, and the file is handed to a third party by design.- Logs go to stderr in every binary, and the rule lives in one place. Three
copies of a subscriber setup means three chances for one of them to write to
stdout and silently break
--jsonfor its binary only. /api/healthstays free of counts so it can remain unauthenticated. That is the same constraint/api/statslives under, stated as a test.- The
.envv.jsoncontext is validated on use, not on every read. Writing it checks the project exists; reading it checks again only when the value came from the file or the environment rather than from a flag, so a flag's error is not reported twice in different words.
What Worked
- Auditing before implementing, again. Every Phase 19/20 item was assumed
present by the plan and none of them was;
VaultEntryhaving no creation date at all changed the shape of the requested feature. Ten minutes ofgrep. - Asking two questions and no more. The binary-name collision and the backfill policy genuinely could not be inferred. Everything else was decided from the code.
- Running the real thing. The server was started, corrupted underneath itself, and hammered with parallel bad credentials; the calendar was fed to a real iCalendar parser with a control case. Both found things review would not have.
- The twin-pair convention paid for itself immediately. The golden fixture caught the semicolon bug across implementations on its first run, exactly as it did for the exporters in Phase 13.
- Reverting each guard to check the test. The backfill's ambiguity guard and
the timeline's
esc()were each removed independently, and each produced exactly one failure.
What Didn't Work
- Assuming the shell's working directory persists. It was reset between tool
calls mid-session, so a
printf > hv/vault.dbwrote nothing and a "corrupt the database" check silently tested the healthy path. Use absolute paths in every command that writes. - Testing the rate limiter serially against a debug build. Argon2id at
m=65536 is slow enough unoptimised that eleven sequential failed unlocks took
longer than the 60-second window, so the limiter reset instead of tripping.
Twelve parallel requests to
/api/authreproduced it immediately. - Writing an escape sequence twice and getting it wrong both times. The test expectation was copied from the implementation, so it agreed with the bug. A golden file shared with a second implementation is what broke the tie.
Next Steps
Three Phase 19 items remain, all unwritten. Work stopped at the point of
reading vault-core/src/users.rs for the TOTP schema.
- TOTP for sub-users (19.2). Greenfield - there is no
totpin the tree; the Phase 5.1 implementation was removed in Phase 7 because nothing reached it. The design settled on before the pause:vault-core/src/totp.rs: RFC 6238, HMAC-SHA1, 6 digits, 30-second step. Needs asha1dependency (hmacis already there). Base32 for theotpauth://secret, hand-rolled the way HKDF is inentropy.rsrather than pulling a crate for forty lines.- Schema:
totp_secretandtotp_last_stepcolumns onusers, added by idempotentALTER TABLEininit_users_schemaalongsideclass_idandstrict_write. - Anti-replay is not optional. Reject any code whose counter step is ≤ the last accepted step; a ±1-step skew window without it means a code is reusable for ninety seconds.
- Enrollment is two-phase:
enrollmints a secret and returns the URI,confirmrequires a correct code before the factor is enabled. Enabling on enrollment locks out anyone whose authenticator did not actually take. - Sub-users only. The owner authenticates by deriving the SQLCipher key; there is no hash to gate and nothing for a second factor to protect.
- Server:
/api/authgainstotp, required only when the user has the factor enabled and authenticated by password. Token auth must skip it, or every CI integration breaks - that regression is already in the bug history from Phase 5. - CLI:
envv user totp enable|confirm|disable|status, plus a global--totpfor authenticating to a server. - UI: the Users panel. A QR code needs a library the CSP will not load, so
show the base32 secret in groups of four and the
otpauth://URI; every authenticator accepts manual entry.
- Onboarding wizard (19.1). Nothing exists. Note that the unlock and first-run screens are shown, hidden and shown again - invariant 9 applies, so every handler must be assigned, never added.
- Accessibility pass (19.3). The new Timeline pane was written with a
<table>,<th scope="col">and real<label for>bindings, so it is a reasonable starting point rather than another item on the list. The rest of the app has not been looked at.
Then:
- Fold this session's permanent material into
CLAUDE.md: the twin-pair table gains a fourth row,created_atbelongs in the Data Model, the phase table needs 19 and 20 updated, and three new pitfalls (the'\;'literal, inserting a function between an attribute and its target,serde_jsonkey order). - Bump the version to cut a release.
npm run version <x.y.z>, then push - the tag check inbuild.ymlis what triggers it.
Only a human can decide:
- Whether 237 lint suppressions ship in a 1.0. Unchanged from phase log 21.
- Whether lock-on-switch stays the default.
- Whether the project itself gets renamed, which was raised as the reason not
to bother resolving the
envvcollision now.
Notes for whoever picks this up
- Read
npm run versionin the same command that writes it anywhere. It was 0.8.1 all session, and phase log 21 records shipping a README that said 0.7.1 because the value moved mid-session. - The tree is uncommitted at the pause point, and
README.mdand.gitignorewere already modified before this session started - they are not part of this work. A commit one-liner covering only this session's files was handed to the user. envv doctorremains the fastest way to find out what a vault is missing.- The new
.icsvalidation script is not in the repo; it was a scratch Python file usingicalendar. If the format changes, re-run that check rather than trusting the golden file alone - the golden proves the two implementations agree, not that either is legal RFC 5545. - The next handoff is
23-2026-08-31-review-01-defect-fixes-the-key-pools-sidebar.md.