UnENVerse
UnENVerse is a local-first secrets manager: the desktop app, unv CLI, and
optional server share a SQLCipher-backed vault. This guide describes public,
shipping behaviour. It intentionally excludes development handoffs, private
paths, and operational notes.
Start with the security model, then use the CLI guide or browse the roadmap.
Security model
The vault is encrypted at rest with SQLCipher. A master password is processed locally with Argon2id; the derived key is not written to disk and is cleared when the vault is locked. The database salt is required to recover a vault, so backups must keep it with the encrypted database.
unv defaults to redacted output. Commands that would expose stored values on
stdout require an explicit reveal option; use file output or unv exec when a
consumer needs a real secret. The optional server supports TLS pinning, scoped
users, and compare-and-swap writes to avoid silent overwrite conflicts.
No security control removes the need to protect an unlocked desktop session or the machine on which it runs. Report vulnerabilities through the repository's security policy.
CLI guide
Use unv describe --json to obtain the machine-readable command contract for
the installed version. The common workflow is:
unv status
unv entry add GitHub --type password --username alice --key-stdin
unv get GitHub --field api_key --reveal --out token.txt
unv doctor
Use stdin for secret input where possible. Avoid --reveal on interactive
shells, transcripts, or CI logs. unv backup archive creates a recoverable
backup containing the vault database and its salt.
The CLI can connect to a remote vault only after a certificate pin is recorded; this prevents a self-signed or local server from becoming an unauthenticated trust exception.
Bundles
A bundle is one card for several entries that belong together (a service's password, its web session and its API app). Members stay ordinary entries.
unv bundle new "Discord bot" --member bot=DiscordBot --member web=DiscordWeb
unv bundle new "Discord bot" --import config.py # a Python config module, read as data
unv bundle add "Discord bot" Spotify --slot api
unv bundle remove "Discord bot" api # detach; the entry is kept
unv bundle dissolve "Discord bot" # members return to the grid
unv bundle delete "Discord bot" --yes # deletes the members too
unv get bundle:Spotify/api --field ID # the bundle: selector
References use the same selector: ${bundle:Spotify}, ${bundle:Spotify/api},
${bundle:Spotify/api/ID} and ${bundle:Spotify/prefix} for a bundle-local
variable. An ambiguous bundle name resolves to nothing rather than to a guess.
--import never executes the file. It reads string and f-string literals,
numbers, booleans and None; anything else is kept as source text with a
warning that names the line. A name assigned twice keeps the first value and
imports the second as name_2, and later f-strings are rewritten to match.
Files a tool reads
unv emit Npm # lists the formats this entry's type offers
unv emit Npm --as npmrc --out ~/.npmrc
unv emit Prod-DB --as dsn --out db.env
unv emit Home-WiFi --as wifi-uri --reveal
emit writes the credential, so it follows export: refused to stdout without
--reveal, and written 0600 with --out. Formats: npmrc, pypirc,
cargo-credentials, docker-config, netrc (registry tokens); dsn, libpq,
jdbc (databases); wifi-uri (Wi-Fi).
Web sessions
unv cookie import capture.txt # preview, writes nothing
unv cookie import capture.txt --entry Site --create
unv cookie import export.har --origin https://www.example.com --entry Site
unv cookie import cookies.sqlite --from firefox --host example.com --entry Site
Accepts DevTools Copy as cURL (bash, cmd or PowerShell), HAR, Set-Cookie
lines and a Firefox profile's cookies.sqlite. Only the chosen origin's cookies
and headers are kept; an Authorization header and other hosts' cookies are
dropped and listed by name, never by value. Chrome is refused by name: its
cookies are not readable from outside the browser.
OAuth and recovery codes
unv oauth refresh Slack # ONLINE: sends the refresh token to its token_url
unv codes status GitHub-codes
unv codes next GitHub-codes --reveal # reading does not spend a code
unv codes use GitHub-codes # mark the next one used
oauth refresh stores a rotated refresh token in the vault before it reports
anything, because the issuer has already invalidated the old one.
Roadmap
Phases 1–23 established encrypted storage, desktop and CLI clients, sharing, accessibility, stored authenticators, and a richer credential model. Phase 24 focuses on shipped-feature reliability, CI hygiene, and this public guide.
The next planned work is composite and bundled secrets, a more legible secrets grid, calendar feeds, an opt-in unique-ID registry, a larger credential type registry, and one row-per-entry storage migration. Later milestones cover behavioural audits, UI/CLI parity, redaction tooling, config validation, nodes, history, approvals, integrations, a provider catalogue, and an authorised pre-1.0 security assessment.
Roadmap ordering may change as defects are found. Compatibility and security work takes priority over feature expansion.
Phase 21: reference correctness
Reference aliases now resolve identity fields such as ID consistently in the
desktop app and CLI. Metadata fields are rejected from rendered configuration
references, preventing an entry UUID from being silently substituted where a
credential value was expected.
Phase 22: stored authenticators
An entry can hold a third-party authenticator seed alongside its credential and
produce a live code in the desktop app or CLI. Seeds are redacted stored values;
the short-lived derived code is intentionally copyable. URI parsing accepts
common otpauth:// exports and preserves non-default algorithm, digits, and
period settings.
Phase 23: credential shape
Credentials can name the role of their primary value, include a label and version-aware environment-variable name, represent cookie and user-agent secrets, and choose a copy profile. These changes make exported configuration more precise without replacing the underlying encrypted vault format.
Phase 24: reliability and delivery
Phase 24 fixes defects in previously shipped behaviour: remote authenticator codes, desktop exports, form generation, credential-card grouping, sidebar layout, and forward-compatible edits. CI adds dependency auditing, CodeQL, Dependabot, and immutable action pins. This guide is built with mdBook and published alongside the TypeScript and Rust API references.
Sub-phases
- 24.1 Bundles and composites. A composite is one value with named secret
parts; a bundle is one card over several entries plus its own typed variables,
addressed as
${bundle:Name/slot/field}. Python config modules import as bundle variables and export back with templates as f-strings. - 24.2 The secrets grid. Type chips, pools as one card, a health scan that names type, field and state.
- 24.3 Calendar feeds. A revocable, token-addressed
.icsURL served byunv-server; it never carries a value, and a locked server answers 503 rather than an empty calendar. - 24.4 Unique-ID registry. Keyed hashes only, token-bucket rate limits that
count per value (
--uid-rateto override), chunked pruning. - 24.5 Credential model. A type registry, web-session capture import, files emitted for the tools that read them, OAuth refresh with store-first ordering, and BIP39 checksum validation against the bundled wordlist.
Decision records
Decision records preserve a small number of durable public architectural choices. They are append-only: a changed decision gets a new record that names the record it supersedes.
ADR-0001: Local-first storage
Status: accepted
Context
Credentials should remain usable without a hosted account or network service.
Decision
Store the vault locally in SQLCipher; make the server optional.
Consequences
The user controls storage and backups. Lost master passwords cannot be reset.
ADR-0002: Redacted CLI defaults
Status: accepted
Context
CLI output is commonly retained in logs and automation transcripts.
Decision
Redact stored secret values by default and require an explicit reveal action.
Consequences
Automation must opt into exposure or materialise values directly to files.
ADR-0003: Native export writes
Status: accepted
Context
WebKitGTK can ignore browser-style download links without reporting a failure.
Decision
Desktop exports are written through the native backend into the download directory with collision-safe filenames and private Unix permissions.
Consequences
The success message follows the completed write rather than an attempted link click. A future save-as flow can replace the target selection without changing the write safety rules.
ADR-0005: Aria-labelledby on a settings row points at a span around the title, not the whole label
Status: accepted
Context
The block contains the hint paragraph, so pointing at it announces sixty words as the checkbox's name
Decision
aria-labelledby on a settings row points at a span around the title, not the whole label block
Evidence
index.html, src/css/settings.css
ADR-0006: Skipping the onboarding wizard marks it complete and commits nothing
Status: accepted
Context
Re-showing something dismissed on purpose is how a welcome screen becomes an obstacle; committing settings the user only looked at is worse. Settings has an explicit "Run setup again"
Decision
Skipping the onboarding wizard marks it complete and commits nothing
Evidence
src/ts/onboarding.ts
ADR-0007: The onboarding wizard is built and destroyed per showing
Status: accepted
Context
Every handler is then bound to nodes that live for exactly one run, so invariant 9 holds by construction — and static hidden markup is what produces WebKitGTK ghost widgets
Decision
The onboarding wizard is built and destroyed per showing
Evidence
src/ts/onboarding.ts
ADR-0008: Focus trapping is driven by a MutationObserver on classes, not by hooking each overlay
Status: accepted
Context
Thirteen overlays, at least four modules that open one. A rule that has to be remembered at every open site is a rule that gets missed at one
Decision
Focus trapping is driven by a MutationObserver on classes, not by hooking each overlay
Evidence
src/ts/ui-qol.ts
ADR-0009: Caps Lock is derived from the typed character, never from getModifierState
Status: superseded by ADR-0004
Context
The platform lies about it (see Pitfalls). A cased letter is unambiguous evidence and is the same answer everywhere. The cost — the hint cannot appear before the first letter — beats a warning that is confidently wrong all session
Decision
Caps Lock is derived from the typed character, never from getModifierState
Evidence
src/ts/ui-qol.ts
ADR-0010: GetModifierState('CapsLock') is read only after two typed characters agreed with it, and
Status: superseded by ADR-0004
Context
The derived reading is right and free, but says nothing until a letter is typed — which is exactly when a master password is most likely to be wrong. Checking the platform against observed reality buys back the click-to-focus case on platforms that do not lie, while WebKitGTK's always-true mask disqualifies itself on the first lowercase letter. There is deliberately no "consistently inverted" state: a stuck mask is indistinguishable from an inverted one until the lock changes, and believing an inversion is the original bug in mirror image
Decision
getModifierState('CapsLock') is read only after two typed characters agreed with it, and never again after one disagreed
Evidence
src/ts/ui-qol.ts
0011: Envv user totp enroll refuses to print without --out/--reveal, and ref
ADR-0012: TOTP gates the password path only; token auth skips it
Status: accepted
Context
There is no human at a CI runner to read a phone. This exact regression already shipped once in Phase 5.1 and is in the bug history
Decision
TOTP gates the password path only; token auth skips it
Evidence
unv-server/src/lib.rs
ADR-0013: TOTP is refused for the owner, in vault-core rather than at each caller
Status: accepted
Context
The owner authenticates by deriving the SQLCipher key: no stored hash, no login form, nothing for a factor to gate. Enforcing it in the shared crate is what stops the app and the CLI disagreeing about who may have one
Decision
TOTP is refused for the owner, in vault-core rather than at each caller
Evidence
vault-core/src/users.rs
ADR-0014: TOTP enrollment is two-phase: enroll mints, confirm enables
Status: accepted
Context
There is no QR code (the CSP allows no external script, and relaxing it to draw a secret is a poor trade), so the base32 is typed by hand — manual entry that did not take is routine, not an edge case. Enabling on enrollment locks those users out of their own account
Decision
TOTP enrollment is two-phase: enroll mints, confirm enables
Evidence
vault-core/src/users.rs
ADR-0015: Two tokio workers instead of one per core
Status: accepted
Context
The work is IO-bound; the CPU-heavy step (Argon2id, 64 MB) is rare and self-limiting. Threads cost stacks and glibc arenas, which is what shows up as container RSS
Decision
Two tokio workers instead of one per core
Evidence
unv-server/src/main.rs
ADR-0016: UI and CLI must reach capability parity, with written exemptions
Status: accepted
Context
A capability in one half only is a bug. The redaction policy is the mapping between them (reveal ↔ --reveal), not an exception to them. Exemptions are recorded because an unwritten one looks exactly like an oversight
Decision
UI and CLI must reach capability parity, with written exemptions
Evidence
invariant 10, handoff/Handoff-23.md
ADR-0017: Linux display variables are defaults, not overrides
Status: accepted
Context
The old code forced XWayland on Wayland sessions and could not be turned off on hardware it had never seen
Decision
Linux display variables are defaults, not overrides
Evidence
src-tauri/src/lib.rs
ADR-0018: Unix links system SQLCipher, Windows vendors it
Status: accepted
Context
Portability where the platform needs it, build speed where it does not; --features bundled covers portable Linux artefacts
Decision
Unix links system SQLCipher, Windows vendors it
Evidence
vault-core/Cargo.toml
ADR-0019: Is_blank special-cases secretType == "api_key"
Status: accepted
Context
Every writer stamps that default, so treating it as "set" left every imported postgres:// URL misclassified forever
Decision
is_blank special-cases secretType == "api_key"
Evidence
unv-cli/src/enrich.rs
ADR-0020: --online enrichment is opt-in
Status: accepted
Context
It transmits a credential — only to its issuer, but a vault reader should not make network calls by default
Decision
--online enrichment is opt-in
Evidence
unv-cli/src/enrich.rs
ADR-0021: Enrich fills gaps only unless --force
Status: accepted
Context
A wrong guess that silently replaces a deliberate choice is worse than no guess
Decision
enrich fills gaps only unless --force
Evidence
unv-cli/src/enrich.rs
ADR-0022: Icon type from magic bytes, not the file extension
Status: accepted
Context
A file that claims to be a PNG and is not just renders as nothing, with no error explaining why
Decision
Icon type from magic bytes, not the file extension
Evidence
unv-cli/src/entries.rs
ADR-0023: SVG rejected as an icon format
Status: accepted
Context
It is script-bearing; a stored image that is also a program is the thing worth avoiding
Decision
SVG rejected as an icon format
Evidence
src/ts/icons.ts
ADR-0024: A slug and an uploaded icon share the custom_icon field
Status: accepted
Context
Two fields would eventually disagree, and every consumer would have to learn which one wins
Decision
A slug and an uploaded icon share the custom_icon field
Evidence
src/ts/icons.ts, unv-cli/src/entries.rs
ADR-0025: A rejected cached session is cleared, not just reported
Status: accepted
Context
Otherwise every subsequent command fails identically with a 401 that never mentions the cache
Decision
A rejected cached session is cleared, not just reported
Evidence
unv-cli/src/main.rs
ADR-0026: User token new checks the output policy before minting
Status: accepted
Context
Checking afterwards left a live credential nobody could read — an orphan token that still authenticates
Decision
user token new checks the output policy before minting
Evidence
unv-cli/src/users_cmd.rs
ADR-0027: Vault-wide export to stdout refused rather than masked
Status: accepted
Context
A masked .env looks deployable and is not; project exports mask instead because their structure is worth reading
Decision
Vault-wide export to stdout refused rather than masked
Evidence
unv-cli/src/envfile.rs
ADR-0028: ${ref} left visible under redaction
Status: accepted
Context
A reference is a pointer, not a secret; visible wiring is what lets an agent compose configs blind
Decision
${ref} left visible under redaction
Evidence
unv-cli/src/out.rs
ADR-0029: Env_file chunk values masked whole, ignoring per-field secret flags
Status: accepted
Context
chunk set defaults to field_type: var, so a password added that way carries no flag — trusting the flag means the first unflagged password leaks
Decision
env_file chunk values masked whole, ignoring per-field secret flags
Evidence
unv-cli/src/out.rs
ADR-0030: --dry-run enforced inside Access::save
Status: accepted
Context
The single write point; a command that forgets to check the flag still cannot write
Decision
--dry-run enforced inside Access::save
Evidence
unv-cli/src/access.rs
ADR-0031: Redaction decided by which Resolver you hold, not by a flag check
Status: accepted
Context
An exporter cannot print a real value without being handed a materialising resolver, so a new exporter is safe by default
Decision
Redaction decided by which Resolver you hold, not by a flag check
Evidence
unv-cli/src/refs.rs, exporters.rs
ADR-0032: --db-path infers vault.salt beside it
Status: accepted
Context
A vault paired with the wrong salt derives the wrong key and reports "wrong password" for a correct password
Decision
--db-path infers vault.salt beside it
Evidence
unv-cli/src/access.rs
ADR-0033: .vaultbak written by the CLI matches the app's WebCrypto envelope byte for byte
Status: accepted
Context
A backup format that only one half of the product can read is not a backup; verified in both directions against Node's WebCrypto
Decision
.vaultbak written by the CLI matches the app's WebCrypto envelope byte for byte
Evidence
unv-cli/src/backup.rs
ADR-0034: Confirm() errors on a non-tty rather than assuming yes
Status: accepted
Context
A script that forgot --yes must fail loudly, not delete quietly
Decision
confirm() errors on a non-tty rather than assuming yes
Evidence
unv-cli/src/fmt.rs
ADR-0035: CLI lookups refuse ambiguity instead of taking the first match
Status: accepted
Context
envv entry rm git against GitHub + GitLab would delete whichever sorted earlier — the array-index bug class wearing a different hat
Decision
CLI lookups refuse ambiguity instead of taking the first match
Evidence
unv-cli/src/data.rs
ADR-0036: CLI exporters cover only STABLE_PROJECT_TYPES
Status: accepted
Context
Porting all ten doubles the Rust and creates ten pairs of implementations with nothing checking they agree
Decision
CLI exporters cover only STABLE_PROJECT_TYPES
Evidence
unv-cli/src/exporters.rs
ADR-0037: One golden fixture asserted from both TypeScript and Rust
Status: accepted
Context
A config format implemented twice drifts silently; reviewing the two for agreement does not work. The fixture found two live export bugs on its first run
Decision
One golden fixture asserted from both TypeScript and Rust
Evidence
tests/fixtures/parity/, tests/cli-parity.test.ts, unv-cli/tests/parity.rs
ADR-0038: Docker_service keeps ${VAR} on copy/export
Status: accepted
Context
Compose substitutes from the .env written beside it — the placeholder is correct output there, unlike wg/nginx/ssh
Decision
docker_service keeps ${VAR} on copy/export
Evidence
chunk-ops.ts
ADR-0039: Exports tested by round trip through the matching parser
Status: accepted
Context
A copied config a parser cannot read back is one the server will not read either; catches wrong directives and unresolved refs that string assertions miss
Decision
Exports tested by round trip through the matching parser
Evidence
tests/exports.test.ts
ADR-0040: Tests load the real index.html
Status: accepted
Context
A mock fixture cannot catch element-id drift — the Phase 3 #new-category-form failure would pass against a mock
Decision
Tests load the real index.html
Evidence
tests/helpers.ts
ADR-0041: Probe_cert_fingerprint as a separate command
Status: accepted
Context
Confines the unverified handshake to one unauthenticated call that carries no credentials, so TOFU can bootstrap without weakening remote_request
Decision
probe_cert_fingerprint as a separate command
Evidence
src-tauri/src/lib.rs
ADR-0042: RenameProviderRefs() on entry rename
Status: accepted
Context
Chunk fields address entries by provider name; the security audit could detect the resulting stale refs but nothing prevented them
Decision
renameProviderRefs() on entry rename
Evidence
chunk-ops.ts, modals.ts
ADR-0043: SwitchToLocalVault() shared by Disconnect and the vault switcher
Status: accepted
Context
The two paths had already diverged — one cleared projects, the other didn't, and neither prompted for the master password when local was still locked
Decision
switchToLocalVault() shared by Disconnect and the vault switcher
Evidence
remote-panel.ts
ADR-0044: St.bulkSelected keyed by entry id, held in st
Status: accepted
Context
Array positions retargeted bulk delete onto the wrong secrets; living in st lets resetViewState() clear it and buildCard re-apply the tick across a re-render
Decision
st.bulkSelected keyed by entry id, held in st
Evidence
state.ts, tools.ts
ADR-0045: ResetViewState() as the single reset hook
Status: accepted
Context
Import, backup restore, vault switch and lock had each drifted apart; a stale project selection made a successful import render an empty grid
Decision
resetViewState() as the single reset hook
Evidence
state.ts
ADR-0046: Db_path.parent() for directory creation in Docker
Status: accepted
Context
Computed data_dir path fails for non-root users in Docker; parent of actual db file always accessible
Decision
db_path.parent() for directory creation in Docker
Evidence
unv-server/main.rs
ADR-0047: Require_owner on TOTP and user-management endpoints
Status: accepted
Context
Any authenticated user could otherwise manage other users' 2FA — TOTP management is owner-only
Decision
require_owner on TOTP and user-management endpoints
Evidence
unv-server/main.rs
ADR-0048: Rate counter only on auth failures
Status: accepted
Context
Incrementing on all requests would block legitimate probing (e.g., checking if vault exists)
Decision
Rate counter only on auth failures
Evidence
unv-server/main.rs
ADR-0049: ConnectInfo for rate limiter IP
Status: accepted
Context
X-Forwarded-For is trivially spoofable; real socket address is not
Decision
ConnectInfo<SocketAddr> for rate limiter IP
Evidence
unv-server/main.rs
ADR-0050: St.vaultOpen flag
Status: accepted
Context
Prevents visibilitychange from stacking relock overlay before vault is ever opened
Decision
st.vaultOpen flag
Evidence
state.ts
ADR-0051: Description === 'env' convention for docker env fields
Status: accepted
Context
Set during docker-compose YAML import; field_type === 'env_var' for manually-added fields; always check both
Decision
description === 'env' convention for docker env fields
Evidence
chunk-ops.ts
ADR-0052: Confidence-scored ENV link matching
Status: accepted
Context
Real-world env vars have prefixes (ND_LASTFM_APIKEY → LASTFM) that prevent exact name matches
Decision
Confidence-scored ENV link matching
Evidence
chunk-ops.ts
ADR-0053: Get_vault() returns 404 (not 200+empty)
Status: accepted
Context
Allows client to distinguish "vault not initialized" from "empty vault" — prevents silent data wipe on reconnect
Decision
get_vault() returns 404 (not 200+empty)
Evidence
unv-server/main.rs
ADR-0054: Remote tab removed from Settings
Status: accepted
Context
It held no settings, only a signpost to the Remote panel on the activity bar
Decision
Remote tab removed from Settings
Evidence
index.html
ADR-0055: LAN card stays visible while a server is running
Status: accepted
Context
Hiding the only "Stop serving" control while the vault is still published is worse than showing it in the wrong panel
Decision
LAN card stays visible while a server is running
Evidence
lan.ts
ADR-0056: StateFlags::VISIBLE excluded from window-state
Status: accepted
Context
The tray handler hides the window; saving visibility restores an invisible window after hide-to-tray + quit
Decision
StateFlags::VISIBLE excluded from window-state
Evidence
src-tauri/src/lib.rs
ADR-0057: LastConnectedAt stamped only after auth succeeds
Status: accepted
Context
Saving a server is not evidence you can get into it; stamping on save would let a mistyped URL outrank the server used daily
Decision
lastConnectedAt stamped only after auth succeeds
Evidence
remote-panel.ts
ADR-0058: Experimental project types gate creation only
Status: accepted
Context
Gating the render path would strand an existing project's chunks in the vault with no way to reach them
Decision
Experimental project types gate creation only
Evidence
projects.ts, types.ts
ADR-0059: ClearAllFilters() separate from resetViewState()
Status: accepted
Context
"Show me everything" must not also drop expanded cards and bulk ticks; resetViewState is for when the data is replaced
Decision
clearAllFilters() separate from resetViewState()
Evidence
state.ts
ADR-0060: RestoreViewState() validates every persisted id
Status: accepted
Context
A filter restored from localStorage can point at a project/tag the vault no longer has; unvalidated it matches nothing and the app opens to an empty grid under a filter the user never set
Decision
restoreViewState() validates every persisted id
Evidence
state.ts
ADR-0061: Sessions ephemeral (restart = re-auth)
Status: accepted
Context
Intentional design; ENVV_PASSWORD env var covers Docker auto-unlock use case
Decision
Sessions ephemeral (restart = re-auth)
Evidence
unv-server/main.rs
ADR-0062: BEGIN IMMEDIATE transaction for vault save
Status: accepted
Context
Prevents crash between data+hash writes from leaving mismatched integrity state
Decision
BEGIN IMMEDIATE transaction for vault save
Evidence
vault-core/src/lib.rs
ADR-0063: _ddCleanup module-level ref
Status: accepted
Context
showDropdown creates new closure each call; need stable ref to remove previous listener on re-open
Decision
_ddCleanup module-level ref
Evidence
modals.ts
ADR-0064: _draftBound flag + stable _saveDraft function
Status: accepted
Context
openAdd() called on every modal open; listeners must be bound once to permanent DOM nodes
Decision
_draftBound flag + stable _saveDraft function
Evidence
modals.ts
ADR-0065: _toolsInited, _remotePanelListenersAdded, _usersPanelInited flags
Status: accepted
Context
Panels re-initialize on every unlock cycle; guards prevent listener accumulation
Decision
_toolsInited, _remotePanelListenersAdded, _usersPanelInited flags
Evidence
tools.ts, remote-panel.ts, users.ts
ADR-0066: FingerprintVerifier custom rustls verifier
Status: accepted
Context
danger_accept_invalid_certs(true) doesn't verify — replaced with actual SHA-256 comparison before handshake completes
Decision
FingerprintVerifier custom rustls verifier
Evidence
src-tauri/src/lib.rs
ADR-0067: TOFU cert pinning (not CA validation)
Status: accepted
Context
Server uses self-signed certs; CA chain meaningless for local-only server
Decision
TOFU cert pinning (not CA validation)
Evidence
src-tauri/src/lib.rs
ADR-0068: ClipboardWrite() with execCommand fallback
Status: accepted
Context
navigator.clipboard silently fails in Tauri WebView on Linux without HTTPS
Decision
clipboardWrite() with execCommand fallback
Evidence
utils.ts
ADR-0069: Data-action delegation everywhere
Status: accepted
Context
WebKitGTK suppresses onclick="..." in innerHTML regardless of CSP
Decision
data-action delegation everywhere
Evidence
all render functions
ADR-0070: VaultStore abstraction
Status: accepted
Context
Swap LocalVaultStore ↔ TauriVaultStore ↔ RemoteVaultStore with zero UI changes
Decision
VaultStore abstraction
Evidence
state.ts
ADR-0071: All Tauri commands in mod commands
Status: accepted
Context
Avoids E0255 proc-macro namespace collision
Decision
All Tauri commands in mod commands {}
Evidence
src-tauri/src/lib.rs
ADR-0072: REFERENCE_DENY is checked before the extra_vars lookup, not after
Status: accepted
Context
${X/id} has to mean one thing everywhere. Letting an entry's own vars decide whether it resolves reintroduces the divergence the deny-list closes
Decision
REFERENCE_DENY is checked before the extra_vars lookup, not after
Evidence
unv-cli/src/refs.rs, src/ts/chunk-ops.ts
ADR-0073: An entry's TOTP parameter fields are read only by Params::from_fields
Status: accepted
Context
They were read three ways in Rust alone, so one entry listed as a 99-digit credential, produced six digits, and handed the app an error instead of a code. Falling back to the otpauth:// default rather than refusing is what keeps one mistyped number from making a whole entry unreadable. Twin: totpParamsOf, pinned by the fixture's params table
Decision
An entry's TOTP parameter fields are read only by Params::from_fields
Evidence
vault-core/src/totp.rs
ADR-0074: There is one list of secret entry fields, out::SECRET_FIELDS, and it is public
Status: accepted
Context
Two lists is one list and one leak: the copy goes stale exactly when a phase adds a field, which is what let a stored TOTP seed print in clear from envv get --field totp_secret while the whole-entry dump masked it. The test pins the property the printing path evaluates, not the membership
Decision
There is one list of secret entry fields, out::SECRET_FIELDS, and it is public
Evidence
unv-cli/src/out.rs
ADR-0075: The app watches vault_meta.data_hash, not the file's mtime
Status: accepted
Context
It is written in the same transaction as the data, so it is by construction the hash of the bytes on disk, and it is already what the compare-and-swap compares. An mtime heuristic or a Rust-side file watcher would be a second definition of "changed" to keep in step with the first
Decision
The app watches vault_meta.data_hash, not the file's mtime
Evidence
src/ts/vault-watch.ts
ADR-0076: The Authenticator became a fifth activity-bar panel, reversing Phase 22
Status: accepted
Context
Phase 22 argued it was a filter over the Secrets panel rather than a place to be, and that a fifth entry adds a second navigation idiom. What changed is the surface: three kinds, a hand-advanced counter and a next-code view do not fit in a one-line sidebar row. The sidebar section stays — still the fastest path to one code. Recorded as a reversal, because an unwritten one is indistinguishable from having forgotten the reasoning
Decision
The Authenticator became a fifth activity-bar panel, reversing Phase 22
Evidence
src/ts/auth-panel.ts
ADR-0077: The next code is opt-in everywhere
Status: accepted
Context
It is a second working credential with a longer life than the one on screen, so a panel that always painted one would make every screenshot good for two periods instead of one. --next in the CLI, a toggle in the panel, and a separate cache key so turning it on is never served an answer that lacks it
Decision
The next code is opt-in everywhere
Evidence
vault-core/src/totp.rs, src/ts/auth-panel.ts
ADR-0078: Steam is a Kind, not an Algorithm, and its shape is forced rather than validated
Status: accepted
Context
The HMAC is RFC 6238's, unchanged; only the rendering is base 26 over Steam's alphabet. Forcing SHA-1/5/30 on read means a generic exporter's digits: 6 beside a Steam seed cannot produce six characters no Steam login accepts
Decision
Steam is a Kind, not an Algorithm, and its shape is forced rather than validated
Evidence
vault-core/src/totp.rs
ADR-0079: Reading a counter-based code never advances it; envv totp advance and a card button do
Status: accepted
Context
Such a code stands until it is used, and the service moves on only when it accepts one. Advancing on every read walks the vault past the service the first time somebody looks at a card twice, and the failure — a second factor that stops working with no error anywhere — is indistinguishable from a wrong seed
Decision
Reading a counter-based code never advances it; envv totp advance and a card button do
Evidence
unv-cli/src/totp_cmd.rs, src/ts/auth-panel.ts
ADR-0080: A stored OTP seed's kind is a field, totp_kind, not a second SecretType
Status: accepted
Context
It is the same credential in the same place; only where the counter comes from differs. A type would split the model for a difference three lines of match cover, and every consumer — masking, history, import, export, ${ref} — would need a second case
Decision
A stored OTP seed's kind is a field, totp_kind, not a second SecretType
Evidence
vault-core/src/totp.rs
ADR-0081: Redaction is fail-closed: a field prints because it is known safe, not because nobody
Status: accepted
Context
The allow-list-of-secrets shape means any field the running build has not heard of prints verbatim, and a binary older than the vault it reads is ordinary rather than exotic — which is exactly how a stored TOTP seed reached a transcript from envv list --json. Inverting costs an old binary the visibility of a new metadata field, recoverable with --reveal; the other direction costs a credential, which is not recoverable at all
Decision
Redaction is fail-closed: a field prints because it is known safe, not because nobody marked it secret
Evidence
unv-cli/src/out.rs
ADR-0082: Copy reads the code the ticker painted, never re-derives it
Status: accepted
Context
Re-deriving hands the user the next code when the click crosses a step boundary — invisible, and guaranteed to be blamed on the website
Decision
Copy reads the code the ticker painted, never re-derives it
Evidence
src/ts/vault.ts
ADR-0083: The TOTP ticker holds no references between ticks
Status: accepted
Context
An element or an index captured across a render points at whatever took its place (invariant 1). Re-querying [data-totp-for] and re-looking-up the entry by id each second is what makes a deleted entry's slot go blank rather than paint its neighbour's code
Decision
The TOTP ticker holds no references between ticks
Evidence
src/ts/totp.ts
ADR-0084: The Authenticator surface is a sidebar section, not a fifth activity-bar panel
Status: superseded by ADR-0076
Context
It is a filter over the secrets already in that panel, not a new place to be. As a sidebar-section it inherits collapse, reorder, hide and the settings editor, and adds no second navigation idiom
Decision
The Authenticator surface is a sidebar section, not a fifth activity-bar panel
Evidence
index.html, src/ts/render.ts
ADR-0085: New_uuid() was promoted from users.rs to vault-core's root
Status: accepted
Context
The TOTP importer needed one and src-tauri has no uuid crate. A second generator is a second thing to get the version and variant bits wrong in, and an id-less entry falls back to entry_ck's legacy tuple
Decision
new_uuid() was promoted from users.rs to vault-core's root
Evidence
vault-core/src/lib.rs
ADR-0086: The Authenticator section is not data-gated, unlike Tags and Key Pools
Status: accepted
Context
Those are pure filters with nothing to offer when empty. This one carries the Import button, and an empty authenticator list is exactly when somebody is looking for it
Decision
The Authenticator section is not data-gated, unlike Tags and Key Pools
Evidence
src/ts/state.ts, src/ts/render.ts
ADR-0087: An entry carrying a seed may have an empty primary value
Status: accepted
Context
That is what an import produces — the password may never be stored here at all. Demanding one would make every imported entry unsaveable the first time somebody opened it to fix its name. A carve-out for seeds, not a general relaxation
Decision
An entry carrying a seed may have an empty primary value
Evidence
src/ts/modals.ts
ADR-0088: Bitwarden and Google are import-only
Status: accepted
Context
A Bitwarden export is a whole password vault; one holding nothing but seeds imports as a set of empty logins. Google's payload is a QR code this app cannot draw, so the URI behind it is a format nothing reads
Decision
Bitwarden and Google are import-only
Evidence
vault-core/src/totp_import.rs
ADR-0089: Google's migration protobuf is decoded by hand
Status: accepted
Context
One message with seven scalar fields, against prost plus a build-time generator. Same trade as base32 — the sixty lines are what a reader has to check. Every length in the payload is attacker-controlled, so the varint shift is capped and each prefix bounds-checked
Decision
Google's migration protobuf is decoded by hand
Evidence
vault-core/src/totp_import.rs
ADR-0090: HOTP entries are refused on import, and named in the report
Status: accepted
Context
Counter-based means no clock, so "the current code" does not exist; importing one gives a card whose code never changes. Dropping them silently is how somebody learns six months later that an account never came across
Decision
HOTP entries are refused on import, and named in the report
Evidence
vault-core/src/totp_import.rs
ADR-0091: The merge rules live in vault-core, not the CLI
Status: accepted
Context
Whether a working second factor survives an import must not be able to differ between the app and the terminal, so plan/write_fields/new_entry are shared and the desktop Import button calls all three
Decision
The merge rules live in vault-core, not the CLI
Evidence
vault-core/src/totp_import.rs
ADR-0092: An import never replaces an existing seed with a different one without --force
Status: accepted
Context
An import is exactly when a stale export gets pointed at a vault that has since been re-enrolled. Taking the incoming value silently destroys a working second factor at the moment the user believes they are backing one up. Same-seed is a no-op, which is what makes a re-run idempotent
Decision
An import never replaces an existing seed with a different one without --force
Evidence
vault-core/src/totp_import.rs (plan)
ADR-0093: An encrypted export from another app is refused by name, never decrypted
Status: accepted
Context
Six apps, six KDFs and envelopes; implementing them means six password-guessing paths whose failures look exactly like a corrupt file. detect() recognises each encrypted shape and says which app it is and what to do instead
Decision
An encrypted export from another app is refused by name, never decrypted
Evidence
vault-core/src/totp_import.rs
ADR-0094: Six authenticator import formats are parsed in one place, vault-core
Status: accepted
Context
Parsed twice is six chances for the app and the CLI to disagree about what a file meant, and the disagreement is a seed that imports with the wrong period and produces codes the issuer rejects. The app calls it over IPC, as pools.ts already does — no twin, no parity fixture
Decision
Six authenticator import formats are parsed in one place, vault-core
Evidence
vault-core/src/totp_import.rs
ADR-0095: Version_history snapshots every secret-carrying field, with api_key writing no
Status: accepted
Context
A re-enrolled seed is as unrecoverable as a replaced key, and leaving it unversioned is the one option E8 calls unacceptable. Absent field has always meant api_key, and every vault written before this relies on it. The 50-cap stays per entry so a chatty seed cannot evict a key's history
Decision
version_history snapshots every secret-carrying field, with api_key writing no discriminator
Evidence
vault-core/src/lib.rs
ADR-0096: ${…} cannot reach a TOTP seed
Status: accepted
Context
A reference resolves into a config file, and no config file wants an authenticator seed; a code cannot be rendered either, being dead before the file deploys. The seed stays reachable by its literal field name (--field totp_secret, redacted), which is the parity that matters
Decision
${…} cannot reach a TOTP seed
Evidence
unv-cli/src/refs.rs
ADR-0097: TOTP code generation exists once, in Rust; the app asks over IPC
Status: accepted
Context
A second HMAC is a second thing to get wrong, and getting it wrong yields six digits that look right and are rejected with no explanation. Parsing is the twin that had to exist twice — the form splits a pasted URI as it is typed — so it is pinned by parity/totp-seeds.json from both sides
Decision
TOTP code generation exists once, in Rust; the app asks over IPC
Evidence
vault-core/src/totp.rs, src-tauri/src/lib.rs
0098: Envv totp ls lists names and parameters, never codes
ADR-0099: The seed is redacted; the code prints
Status: accepted
Context
Phase 14's rule governs stored values. A code is derived, six digits, and dead in thirty seconds; a command that exists to hand you one and then refuses has no purpose. The otpauth:// URI follows the seed's rule instead, because it contains the seed. Written down because an unwritten exemption is indistinguishable from an oversight (invariant 10)
Decision
The seed is redacted; the code prints
Evidence
unv-cli/src/totp_cmd.rs
ADR-0100: TOTP parameters equal to the default are not written
Status: accepted
Context
totp_algorithm: "SHA1" on every entry cannot be told apart from a defaulted one, so nothing downstream can say whether the issuer chose it or we did. Absent means what an omitted otpauth:// parameter means
Decision
TOTP parameters equal to the default are not written
Evidence
unv-cli/src/entries.rs, src/ts/modals.ts
ADR-0101: A pasted otpauth:// URI is split into seed + three fields, never stored whole
Status: accepted
Context
The URI is a container holding the secret plus three numbers. Storing it whole means a second field that also holds the secret — needing the same masking in SECRET_FIELDS, the card, version_history and every export — and two copies of one value drift the first time either is edited. Unlike custom_icon's slug-or-data-URI, these are not one value in two spellings
Decision
A pasted otpauth:// URI is split into seed + three fields, never stored whole
Evidence
vault-core/src/totp.rs, src/ts/totp.ts
ADR-0102: A stored TOTP seed is a field on any entry, not a SecretType
Status: accepted
Context
A seed sits beside a credential rather than being one. A 'totp' type would split a GitHub login and its second factor across two entries, making ${GitHub/…} ambiguous, envv entry rm GitHub refuse, and the password's card unable to show the code. Bitwarden and 1Password model it the same way, for the same reason
Decision
A stored TOTP seed is a field on any entry, not a SecretType
Evidence
src/ts/types.ts
ADR-0103: The Settings copy preview uses an invented entry, not one of the user's
Status: accepted
Context
The panel must show the same thing on an empty vault, and a real credential's name in a settings screenshot is a small leak for no gain
Decision
The Settings copy preview uses an invented entry, not one of the user's
Evidence
src/ts/settings-panel.ts
ADR-0104: Extra_vars are masked by default, with public as a per-value opt-out
Status: accepted
Context
The old rule masked only when secret: true, a flag that defaults to unset — so an entry whose payload is named variables printed all of them. Opt-out per value and never per type: a client id, a region and an account SID are each safe to print and the secret beside them is not. Without it the basic profile is either useless (everything masked) or unsafe (nothing)
Decision
extra_vars are masked by default, with public as a per-value opt-out
Evidence
unv-cli/src/out.rs
ADR-0105: --profile full is refused vault-wide
Status: accepted
Context
One entry's metadata is a convenience; every entry's — purposes, projects, tags, rotation dates — is a map of what matters in the vault, and it lands in whatever the user pastes into next. Same rule and same reasoning as Phase 14's refusal of a vault-wide export to stdout
Decision
--profile full is refused vault-wide
Evidence
unv-cli/src/envfile.rs, src/ts/import-export.ts
ADR-0106: CopyProfile is a default, and the caret menu is why
Status: accepted
Context
A setting that cannot be overridden per copy becomes a wall the moment somebody needs the other answer once. The one-off choice is deliberately not persisted: a "give me everything" press must not silently change what the next fifty copies contain
Decision
copyProfile is a default, and the caret menu is why
Evidence
src/ts/modals.ts
ADR-0107: Profile metadata is # comments by default
Status: accepted
Context
An .env is loaded into a process. Injecting six non-functional variables per credential into every container is a cost the user did not ask for by pressing Copy. One # name: value line per field rather than one joined line, because full adds nine and a .env comment does not wrap
Decision
Profile metadata is # comments by default
Evidence
src/ts/copy-profile.ts
ADR-0108: The copy-profile builder emits real values and has no masker
Status: accepted
Context
Redaction is the caller's job and is already decided by the Phase 14 rule that governs every artefact — refuse to stdout unless --reveal, write the real thing with --out. A second redaction policy inside the builder is the shape that produced the Phase 22 seed leak
Decision
The copy-profile builder emits real values and has no masker
Evidence
src/ts/copy-profile.ts, unv-cli/src/profile.rs
ADR-0109: A role declared on the entry beats the ${…} alias table
Status: accepted
Context
primary_role: "id" says the primary value is a client id, so ${X/ID} must answer with it rather than with the key_id beside it — while an entry declaring no role resolves exactly as Phase 21 left it. It is also what makes the no-primary shapes addressable: ${Twilio/ACCOUNT_SID} and ${Twilio/AUTH_TOKEN} name the halves by the issuer's own words
Decision
A role declared on the entry beats the ${…} alias table
Evidence
unv-cli/src/refs.rs, src/ts/chunk-ops.ts
ADR-0110: The .env link scorer scores a template match and a PROVIDER_KEYID match the same, and
Status: accepted
Context
They make the same claim about the same syntax. Scoring one above the other would pick a winner where the data does not; scoring them equal makes the collision visible, and a tie now yields no link rather than whichever entry came first in the array (invariant 1)
Decision
The .env link scorer scores a template match and a PROVIDER_KEYID match the same, and refuses ties
Evidence
src/ts/chunks/env-link.ts
ADR-0111: An ambiguous ${NAME} is refused, not resolved
Status: accepted
Context
The legacy Provider_keyid split and the name template occupy the same syntactic position, so a vault holding both kinds of match has two honest answers. Returning either writes a plausible-looking wrong value into a rendered config, which is the Phase 21 defect class; "unresolved" is what every exporter already reports
Decision
An ambiguous ${NAME} is refused, not resolved
Evidence
unv-cli/src/refs.rs, src/ts/chunk-ops.ts
ADR-0112: FormToEntry(base?) spreads the entry being edited
Status: accepted
Context
A field with no form input has to survive by construction, not because the save path remembered it by name — it remembered five, and every field a later phase adds would have been erased on the first edit. A cleared input still clears its field, because spread copies an undefined value
Decision
formToEntry(base?) spreads the entry being edited
Evidence
src/ts/modals.ts
ADR-0113: An unresolved ${ref} is written into a .env raw, not quoted
Status: accepted
Context
Quoting turns a recognisable broken placeholder into an escaped literal, and the caller is told about the unresolved reference either way. A visibly wrong line beats an invisibly wrong one
Decision
An unresolved ${ref} is written into a .env raw, not quoted
Evidence
unv-cli/src/exporters.rs, src/ts/render.ts
ADR-0114: A .env value is quoted on write and unescaped on read, and the fixture asserts the round
Status: accepted
Context
parse(write(v)) == v is the property a .env has to have; the bytes in between are an implementation detail. Pinning the bytes alone would pass for an escaping scheme the parser cannot read back, which is precisely the state this replaced
Decision
A .env value is quoted on write and unescaped on read, and the fixture asserts the round trip
Evidence
parity/env-names.json
ADR-0115: One environment-variable name template, and key_id stays a segment in it
Status: accepted
Context
The design lists six segments and excludes key_id on the grounds that it is identity rather than a value. True of what it means, false of what it already does: dotenvKey has put it in the name since Phase 3, env-link.ts scores PROVIDER_KEYID as a tier-1 match, and find_entry parses a bare ${NAME} by splitting into exactly that pair. Dropping it silently renames every variable a keyed entry generates — the failure the design's own "must not break" section is about — for nothing
Decision
One environment-variable name template, and key_id stays a segment in it
Evidence
src/ts/state.ts, unv-cli/src/envfile.rs
ADR-0116: --blob-file refuses above 128 KB rather than truncating
Status: accepted
Context
A truncated credential fails at deploy time with an error about malformed JSON, which names the consumer and not the vault that broke it. A bundle that large belongs on disk with --mount-path pointing at it
Decision
--blob-file refuses above 128 KB rather than truncating
Evidence
unv-cli/src/entries.rs
0117: Envv exec's scratch directory cleans up in Drop
0118: Envv file write has no stdout form, and never will
ADR-0119: The rename confirmation fires only for an entry that has been copied
Status: accepted
Context
last_copied_name is the evidence that something out there may be reading the old name. Asking on every rename of an entry nobody has ever deployed is the kind of confirmation people learn to click through, and then click through on the one that mattered
Decision
The rename confirmation fires only for an entry that has been copied
Evidence
src/ts/modals.ts
ADR-0120: A collision's suffix is an ordinal, and the first occurrence keeps its name
Status: accepted
Context
The design's "pool members get _1, everything else gets the key_id" was written when key_id was not expected to be in the generated name; here it always is, so appending it cannot disambiguate anything. And renaming both halves would change a variable that was never ambiguous for whoever reads it first
Decision
A collision's suffix is an ordinal, and the first occurrence keeps its name
Evidence
src/ts/state.ts, unv-cli/src/envfile.rs
ADR-0121: Below one day, the expiry warning is unconditional
Status: accepted
Context
expiryWarningDays exists to tune how far ahead a long-lived credential warns. A credential dying within the hour is not a matter of taste
Decision
Below one day, the expiry warning is unconditional
Evidence
src/ts/render.ts
ADR-0122: Cookies.txt is refused when the attributes are missing
Status: accepted
Context
The format needs a domain, an include-subdomains flag, a path, a secure flag and an expiry per cookie, and a bare document.cookie paste has none. A file yt-dlp reads and silently ignores is worse than no button: the user discovers it as "the download is not logged in", with nothing pointing at the file
Decision
cookies.txt is refused when the attributes are missing
Evidence
src/ts/cookies.ts, unv-cli/src/cookies.rs
ADR-0123: A cookie gets no rotation nag; it gets last_verified_at
Status: accepted
Context
Rotating a session means logging in again in a browser, which nothing here can do — so the entry would be flagged overdue forever with no available fix, and a nag with no fix is how a health scan trains people to ignore it. "Never verified" has a fix and it takes ten seconds
Decision
A cookie gets no rotation nag; it gets last_verified_at
Evidence
unv-cli/src/scan.rs, src/ts/tools.ts
ADR-0124: Enrich --online never probes a cookie, and says so by name
Status: accepted
Context
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, and it can trip fraud detection on an account the user still needs. Named in the report rather than silently skipped, so the counts add up
Decision
enrich --online never probes a cookie, and says so by name
Evidence
unv-cli/src/enrich.rs
ADR-0125: User_agent is masked although it is not a secret
Status: accepted
Context
It is a browser fingerprint: it identifies the machine and build a session was minted in, and a listing full of them says which of the user's machines holds which account
Decision
user_agent is masked although it is not a secret
Evidence
unv-cli/src/out.rs
ADR-0126: A cookie entry's extra_vars are masked whole, public ignored
Status: accepted
Context
A jar split one cookie per var is N session credentials and any one of them is enough to be the account. There is no "public half" here the way there is beside a client secret, so the per-value opt-out does not apply — the same rule env_file chunks already follow
Decision
A cookie entry's extra_vars are masked whole, public ignored
Evidence
unv-cli/src/out.rs
ADR-0127: An empty primary is omitted from a .env, not written as NAME=
Status: accepted
Context
NAME= means "set to the empty string" in a file about to be loaded, which is a different claim from saying nothing — and it is exactly the distinction out.rs keeps by fingerprinting an empty value as empty rather than as a hash
Decision
An empty primary is omitted from a .env, not written as NAME=
Evidence
src/ts/state.ts, unv-cli/src/profile.rs
ADR-0128: A public variable is not versioned
Status: accepted
Context
It is a region or a client id by declaration. Filling a 50-record per-entry history with them evicts the values that cannot be recovered any other way
Decision
A public variable is not versioned
Evidence
vault-core/src/lib.rs
ADR-0129: A deleted variable still leaves its last value in history
Status: accepted
Context
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
Decision
A deleted variable still leaves its last value in history
Evidence
vault-core/src/lib.rs
ADR-0130: Version_history matches extra_vars by name, never by index
Status: accepted
Context
The array is rebuilt by the form on every save, so a position 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 — and a history that quietly attributes value A to variable B is worse than no history
Decision
version_history matches extra_vars by name, never by index
Evidence
vault-core/src/lib.rs
ADR-0131: A1's fix made four Tauri commands pure over their arguments rather than gating on
Status: accepted
Context
The gate was checking whether the local vault was unlocked, which is orthogonal to whether the renderer holds a decrypted vault at all (local or remote) — st.vaultOpen is the caller-side gate that actually answers the right question
Decision
A1's fix made four Tauri commands pure over their arguments rather than gating on VaultState
Evidence
src-tauri/src/lib.rs, src/ts/totp.ts
ADR-0132: Bundle landed as a SecretType and field set (Phase 24.1 step 2) with no card, operations
Status: accepted
Context
Record<SecretType, TypeConfig> would not typecheck with an un-exhaustive union the moment anything else touched it — the same call E4 made about formToEntry's spread: land the shape now, the behaviour later
Decision
bundle landed as a SecretType and field set (Phase 24.1 step 2) with no card, operations or scope resolution built on it yet
Evidence
src/ts/types.ts, src/ts/modals.ts
ADR-0133: A3's export write has no save dialog
Status: accepted
Context
tauri-plugin-dialog is a real dependency and capability-file addition for a defect fix; the downloads directory plus filename disambiguation (never overwrite, append (2)) gives correct behaviour without it. Revisit if a "Save As…" UX is explicitly requested
Decision
A3's export write has no save dialog
Evidence
src-tauri/src/lib.rs, src/ts/utils.ts
ADR-0134: The composite/CLI percent-encoder is hand-rolled to exactly RFC 3986's unreserved set,
Status: accepted
Context
JS's built-in additionally leaves ! ~ * ' ( ) unescaped. Using it on one side while Rust used a strict encoder would make the twin pair disagree on precisely the inputs a parity fixture would think to test
Decision
The composite/CLI percent-encoder is hand-rolled to exactly RFC 3986's unreserved set, not encodeURIComponent
Evidence
vault-core/src/composite.rs, src/ts/composite.ts
ADR-0135: A composite's zone classifier reads the template's text, never the rendered output
Status: accepted
Context
Substituting real values first and then parsing the result means the parser's input already contains whatever ambiguity (an @ or / in a secret part) the classification exists to resolve. The template's structure is fixed before any part is filled in
Decision
A composite's zone classifier reads the template's text, never the rendered output
Evidence
vault-core/src/composite.rs, src/ts/composite.ts
ADR-0136: Markup reaches the DOM only through an escaping tagged template
Status: accepted
Context
render.ts and chunk-ops.ts built markup with plain template literals and relied on the author remembering esc() or escAttr() at each interpolation (review-01 section 2.3: 51 calls against 299 interpolations). Invariant 4 says vault data is untrusted, and the enforcement was memory. The same class had already shipped as stored XSS (javascript: in api_url) and as unescaped environment, secretType and project_type. Phase 28 found more of it while migrating: pid and cid (project and chunk ids) were interpolated raw into data-project-id and data-chunk-id on every chunk and config-view button.
Decision
src/ts/html.ts provides the html tagged template, raw() and setHtml(). html escapes every interpolated value unless it is already a SafeHtml, which only html and raw() can produce. setHtml(el, markup) accepts nothing but SafeHtml (or ''). ESLint forbids assigning innerHTML/outerHTML and calling insertAdjacentHTML outside html.ts. tests/html.test.ts pins the exact set of raw() call sites per file, so adding one is a reviewed change to that test.
Plain strings are never trusted, even when they look like markup. A function that returns markup returns SafeHtml; a function that takes a fragment takes HtmlValue. Helpers that used to return pre-escaped strings (esc() results held in variables) were changed to return plain values, because a pre-escaped string interpolated into html is escaped twice.
Escaping is the attribute-strength set (& < > " '), so one rule is right in text and in a quoted attribute. It does not make a URL safe: href and src still go through the http(s) check.
Consequences
Forgetting to escape is now the safe default and the opt-out is a visible raw(. Anything that is not HTML (an SVG data URI, an exporter's config text, an ICS body) must not use html: it would be escaped. Those stay plain template literals, and the type system catches the usual mistake because SafeHtml is not assignable to string.
Prettier's embedded HTML formatting is turned off (embeddedLanguageFormatting: "off"). With it on, Prettier re-indented html templates and put whitespace inside elements such as .key-value; harmless in normal flow, wrong the moment the content is read back with textContent or sits in a white-space: pre* container.
Evidence
src/ts/html.ts, tests/html.test.ts, the no-restricted-syntax block in eslint.config.js, .prettierrc.json
ADR-0137: The config compiler ships six hand-written checks that fire only on positive evidence
Status: accepted
Context
Phase 18's validation matrix checks each generated file against its own tool (nginx -t, wg-quick strip), so every check sees one format. The mistakes that bite are between chunks and formats. A validator that cries wolf gets switched off and then protects nothing, so a false positive costs far more than a missing check.
Decision
vault-core/src/config_check.rs holds six rules, pure over the project JSON, called by envv check and by the desktop app over IPC (config_check_project). No rule language. Each rule is silent unless the project shows it means to define the thing: the proxy_pass rule needs a docker_service chunk, the Ingress rule needs a k8s_service chunk, and a pg host is only compared against services that actually mention it. References that could be satisfied elsewhere (name@provider, ${bundle:…}, a dotted hostname, an IP, localhost, a variable) are skipped, never guessed at. Findings carry chunk and field names and never field values.
The six designed checks are implemented as written. Two of them split into a certain case and a softer one, so there are eight rule ids:
- WireGuard AllowedIPs overlap. The same network on two peers is an
error(the kernel silently gives it to the later peer). One peer's network strictly containing another's is awarning, because WireGuard routes by longest prefix and nesting is how a split route is written. A default route (/0) beside host routes, the standard full-tunnel pattern, is not reported. - Kubernetes. A Deployment naming a Secret no
k8s_secretchunk creates is anerror. This needed model fields the Deployment did not have:secretEnv(Secret names, exported asenvFrom) andsecretMounts(secret:/pathpairs, exported as read-only volume mounts and avolumesblock). Both exporters (exportK8s,export_k8s) emit them andparity/k8s.yamlpins the bytes. An Ingress whose Service nok8s_servicechunk defines is a separatewarningthat the model already supported. - Compose. Every
${NAME}in any service value (image tag, command, ports, an environment value) must be a key someenv_filechunk sets or a vault entry;${NAME:-x}and the other operator forms and$${NAME}are the author's own fallback. - Postgres. A service is a consumer of a
pg_connectionif it lists the database service independs_on, spells its name in an environment value, or reads the connection through${chunk:<name>/…}.
Consequences
Adding a seventh rule is a deliberate edit to RULES and a new unit test with its silent case. The Traefik rule is a warning because a middleware may be defined in another file of the same provider; the message says to write name@file.
Evidence
vault-core/src/config_check.rs, tests/fixtures/parity/k8s.yaml, unv-cli/src/check_cmd.rs, unv-cli/tests/check.rs, src/ts/config-check.ts, tests/config-check.test.ts
ADR-0138: The vault is stored row-per-entry, and a stale writer is merged, not refused
Status: accepted
Context
Schema v1 kept the whole vault as one JSON string in one row (review-01 section 2.1). Every write parsed and re-serialised all of it; the compare-and-swap token was a hash of the blob, so two people editing different entries conflicted, and both branches of the conflict prompt discarded someone's changeset; version_history (up to 50 revisions of secret material per entry) lived inside the thing written most often; nothing could be indexed.
Decision
vault-core/src/storage.rs stores one row per entry and per project, a row for the category list, and a row for every other top-level key (vault_rows). version_history lives in vault_history, one row per entry, attached on load. The document API is unchanged: load_vault returns the same JSON and save_vault takes it, so the app, unv-server and envv call the same functions. VAULT_SCHEMA_VERSION is 2; a v1 build refuses the file with VAULT_SCHEMA_TOO_NEW.
The version token is "<seq>.<state hash>". The state hash is the XOR of a hash of every row's (kind, key, position, content hash), maintained incrementally, so a save costs O(changed rows) of hashing; verify_vault_integrity recomputes it from the rows. The sequence number indexes vault_saves and vault_changes, which record which rows each of the last 2000 saves changed.
A stale writer is merged per row. Given the version the writer read, the changes since then say what each row looked like when the writer read it (the prev_rev of the first later change). A row the writer did not touch keeps whatever the other writer did; a row both changed to the same content is not a conflict; a row both changed differently, or one changed and the other deleted, is a conflict, named in the error. A row the writer does not have, created after it read, is kept. A token the history no longer holds (older than 2000 saves, or in the old bare-hash form) is a whole-vault conflict, as it always was.
A merged save is marked. The version it returns ends in +merged: the writer's copy is behind what was stored, so a client that holds a document reloads it (the app's persist(), the remote store through X-Vault-Merged), and a one-shot client (the CLI) never looks. Returning the new bare token would let a stale copy overwrite the merge on its next save; returning the old base would make that save conflict with the writer's own previous one.
Migration is one-way and backed up. The first open of a v1 vault copies vault.db to vault.db.v1.bak (owner-only), converts the blob inside one transaction without writing audit rows (nothing about the data changed), and deletes the blob so no second copy of every secret is left. envv doctor notes the backup so it is removed deliberately.
The selective read (GET /api/vault/entries) loads the document without any entry's history. The local CLI now saves conditionally on the version it loaded, so a long enrich --online merges with, or is refused by, a concurrent edit instead of overwriting it silently.
Consequences
Writing is O(changed rows) in database work. It is not O(changed rows) in CPU: a client still sends the whole document, which the server must serialise to find what changed. Measured at 5,000 entries with 3 history records each (release build): one edit saves in about 90 ms against about 165 ms for the v1 sequence (read blob, parse, index, serialise, hash, rewrite), and writes one row instead of 3.6 MB; a full load is slower than v1's single parse (about 88 ms against about 35 ms) because it parses 10,000 rows, and the load without history is about 22 ms. A delta API from the client would remove the rest and is a separate change.
Anything that adds a top-level key to the document needs no change: it rides in the doc row. A new per-entity table would need a kind in storage.rs.
Evidence
vault-core/src/storage.rs (22 tests, including the merge matrix and the v1 conversion), vault-core/src/lib.rs (save_vault_txn), unv-server/src/lib.rs (saved_response), src/ts/state.ts (takeMerged, reloadFromStore), tests/vault-merge.test.ts
ADR-0139: The provider catalogue is signed, fetched whole, and verified on every load
Status: accepted
Context
envv enrich recognises issuers by the public prefix of a stored secret (ghp_, sk-ant-, AKIA). That table was compiled into the binary, so a new issuer needed a release. Phase 31 publishes it as a static file. Enrichment writes into the vault, so the file is an input an attacker would like to control: it can mislabel a secret type, point a card at a hostile URL, or make every secret match.
Decision
vault-core/src/catalogue.rs defines the format and every rule; envv catalogue update|show|diff|export|sign and the catalogue_status / catalogue_update Tauri commands are thin I/O around it.
- Signed, key pinned in the binary. The wire form is
{payload, signature}wherepayloadis a JSON string, so the Ed25519 signature covers the exact bytes signed and no re-serialisation can disturb it. The public key isPINNED_KEY_HEX; the private half is theCATALOGUE_SIGNING_KEYActions secret. Rotating the key is a release. - Verified on every load, not only on download. The cache file is re-verified each time
enrichstarts; a missing or invalid cache silently means "use the compiled table". - Shape checks after the signature. A correctly signed mistake is still a mistake: prefixes under 3 characters (they would match nearly every secret), non-graphic prefixes, unknown
secret_type, non-httpsURLs and oversize lists are refused. - No rollback.
generated_atmay not be older than the cached catalogue's, so an old, valid, signed file cannot be replayed. - Fetched whole, never per provider. One request tells the host nothing about which issuers a vault holds (the icon-CDN argument). https only, CA validation, 4 MiB cap.
- The compiled table stays. Lookup takes the longest matching prefix across catalogue and compiled table; a tie goes to the catalogue.
enrichbehaves identically on an air-gapped machine.catalogue update --fileinstalls a catalogue from disk through the same checks. - Reference URLs (
docs_url,rotate_url,revoke_url) are carried and shown, not applied to entries.
Consequences
The signing key is the one thing that must not leak or be lost. If it leaks, a forged catalogue verifies until the next release replaces the pin. If it is lost, new catalogues cannot be published until a release pins a new key.
docs.yml signs catalogue/providers.json on every push and daily, and serves it at /catalogue/catalogue.json on Pages. Without the secret (a fork) nothing is published and clients use the compiled table.
Evidence
cargo test -p vault-core catalogue (tamper, wrong key, overbroad prefix, http URL, rollback). End to end: envv catalogue sign with the real seed, update --file, then enrich proposing a prefix that exists only in the catalogue; a one-letter edit of the signed file exits 10.
ADR-0140: Nodes sign every request, are configured only by their own file, and the hub never stores rendered content
Status: accepted
Context
Phase 34 adds agents on other hosts (nodes) that observe config files and, where allowed, write what the vault renders (a hub is an unv-server). The design (Handoff-23) decided: apply is per target and default off; each target is push (vault to file) or pull (file to vault); the reload command is node-local; enrollment is a one-time token; transport is TLS 1.3 with the pins going both ways; state lives outside the vault; a locked hub idles push but not observe. Three of those needed a concrete mechanism, and two could not be built as written.
Decision
Identity is a signing key, not a client certificate. A node generates an Ed25519 key. The hub stores the public half at enrollment and checks, on every request, a signature over envv-node-v1, method, path, a millisecond timestamp and the SHA-256 of the body (vault_core::nodes::sign_request). A timestamp must be within 60 s of the hub's clock and strictly greater than the last one accepted for that node; the high-water mark is persisted, so a restart does not reopen a replay window. Signature is checked first, so skew, replay and revocation are only reported to someone holding the key. The node pins the hub's certificate (the existing FingerprintVerifier) and offers TLS 1.3 only (client_config_tls13); the hub pins the node through the signature. This is "both ends pinned" without a custom ClientCertVerifier and a peer-certificate extractor behind axum-server, which would be the largest and least testable part of the phase. Deviation, written down: the hub's own listener still accepts TLS 1.2 (it serves the app and the CLI too); only the node client refuses to negotiate down. A plain-HTTP hub is refused by the agent unless it is the loopback, because a signature authenticates a request without hiding it and a push carries secrets.
What a node does is decided by its own file. [[target]] in the node config names the path, project, exporter, mode, apply, and the only two commands it will run (validate, reload). Unknown keys are an error; a pull target may not carry apply, validate or reload. A Push the hub sends for a target the node does not declare, or declares with apply = false, is refused by the node and reported as a failed result. The hub additionally restricts each node to the projects named when the enrollment token was minted: a node cannot widen that by declaring a target.
Apply is transactional and hash-bound. nodes_apply::apply recomputes the SHA-256 of the received bytes against the hash the hub announced before touching anything; copies the previous file aside (0600, last 3 kept); writes a temp file in the same directory, fsync, renames; runs validate against the file in place; on failure restores the previous file (or removes a file that did not exist) and does not reload; runs reload; on failure restores and does not retry. Output of the commands is cut to three lines and any line that is also a line of the file is elided, because a validator that echoes the offending line would otherwise carry a secret to the hub and into its audit log. The announced hash is what Phase 37's approval token will bind to.
The hub renders on demand and stores no content. nodes.json (0600, beside the vault) holds enrollment-token hashes, node records (public key, fingerprint, projects, last host info) and per-target status words with hashes. It never holds file content: a rendered wg0.conf contains a private key and the file is not encrypted. A pull target's file passes through hub memory for at most 120 s, only when the owner asked, and is handed over once. The hub renders with the CLI's exporters (chunks::render_project, so unv-server now depends on the unv-cli library) and runs config_check first: an error finding refuses the push (refused, with the finding) because a node is about to write that file to a live host.
State words are decided in one place. derive_status yields in_sync, drift, pending, missing, unknown, refused for push targets and unreviewed, in_sync, changed, missing for pull targets (a human accepts a hash into the vault with envv node accept).
Heartbeats write nothing to the audit chain. Enrollment tokens, enrollments, revocations, pulls and every reported apply do (node.token, node.enroll, node.revoke, node.pull, node.accept, node.apply, the last attributed to node:<name>). Apply results ride the next beat and are cleared by the node only when the hub says it recorded them (results_ack): a locked hub cannot append to the chain, and clearing on a reply that did not keep them would lose the only record that an apply happened.
Opt-in. --nodes on the server; without it every /api/nodes/* route answers 404 and nodes.json is not created.
Not built (and why)
listendialing (hub dials a node with a public address). The wire protocol is symmetric enough to add it, but it needs a hub-side scheduler and a hub signing key the node pins; it is Phase 34.1.pullinto the vault as chunks. The parsers that turn awg0.confinto chunks are TypeScript (chunks/parsers.ts). A pull delivers the file to the owner (envv node pull --out, or the pane's save), and the existing import flows ingest it;acceptrecords that it was.- A shipped systemd unit is in
packaging/envv-node.service; a container node is observe-only by design (reloading a host's nginx from inside one needs host PID or dbus, a worse hole than the one node-local reload closes).
Consequences
The CLI binary is also the agent, so a node host carries the whole envv binary; the agent half uses none of its vault code. Beats cost one registry write each (a few hundred bytes per node), which is why they are not routed through save_vault.
Evidence
vault-core/src/nodes.rs (signing, registry, config, status; 12 tests), vault-core/src/nodes_apply.rs (apply; 11 tests), unv-server/src/nodes.rs (routes; 18 tests over the real router), unv-server/tests/nodes_e2e.rs (the real agent against a real socket; 5 tests), unv-cli/src/node_agent.rs, unv-cli/src/node_cmd.rs, src/ts/nodes-pane.ts, tests/nodes-pane.test.ts. Fault injection: 13 mutations (no signature check, no replay check, no skew check, no revocation check, reusable token, no project scope in either place, push ignoring apply, gate off, unsolicited upload accepted, ack while locked, no hash check, agent clearing unacknowledged results) each fail at least one test.
ADR-0141: Config history keeps a masked and a real copy of every rendered config, and prunes behind a checkpoint
Status: accepted
Context
Config lives in git and its secrets live elsewhere, so the file that runs is versioned by nobody, and "what was deployed on the 3rd" has no answer. The vault holds both the structure and the values, so it can keep the answer, but two things make that dangerous: old secrets would live forever, and a history that can be pruned is a history whose integrity cannot be checked. The audit chain (Handoff-23, T4) names both problems; Phase 35 had to ship a retention policy and a checkpoint or not ship.
Decision
Every rendered config is snapshotted when a save changes it. vault-core/src/config_history.rs stores one row per change per stream (project, exporter) in vault.db (so SQLCipher encrypts it with the rest). unv_cli::history::snapshot_all renders each project with its type's exporter (Compose is two streams, the YAML and the .env beside it) and record stores the result only when its hash differs from the stream's newest. Reverting to an earlier text is a new snapshot. Every writer calls the same hook: Access::save (local CLI), put_vault (server, in the background so a save does not wait on rendering), and the Tauri save_vault. A failure to snapshot is logged and never fails the save. Saves in quick succession coalesce: a snapshot renders the vault as it is when it runs.
Two texts per snapshot. content is the deployable file; masked is the same render with every resolved secret replaced by its fingerprint, which is what envv project export already prints. Every default view (list, show, diff, the Tools pane) reads masked, so a rotation reads as a changed fingerprint without either key being read. The real text needs --reveal, --out or the pane's confirmed Reveal. Masking the stored text afterwards was rejected: a rotated-away secret is no longer in the vault, so nothing would know to mask it in an old snapshot, and an old snapshot is exactly where it lives.
One dispatcher, three surfaces. unv_cli::history::call(conn, op, args) is the only implementation. The local CLI calls it directly, unv-server exposes it as owner-only POST /api/history {op, args}, and the app reaches it through the history_call command (local vault) or that route (remote vault). Rejected: a route and a Tauri command per operation, which is nine places to keep in step.
The diff is Rust, once. vault-core/src/textdiff.rs: common head and tail stripped, an LCS table over the middle, and above 4 million cells the middle is reported as one delete and one insert (correct, less minimal). A TypeScript diff was rejected: it would be a twin pair needing its own golden fixture, for a function whose output the UI only displays.
Tamper evidence and retention. Each stream is a hash chain over the previous chain value, project, exporter, the hashes of both texts and the timestamp. prune deletes, per stream, rows that are both beyond the newest keep and older than days (always a prefix, never the newest row), and writes a checkpoint holding the chain value at the boundary so what remains still verifies. The same transaction appends a config.prune row to the vault's audit chain naming that value, and verify requires every checkpoint to have a matching audit row: forging a checkpoint means forging the audit chain too. Defaults: history on, newest 50 per stream and everything from the last 90 days. envv history policy --disable turns it off.
The file on a host can be traced to a snapshot. find_by_sha answers "which snapshot rendered this hash", and the hub's node list adds snapshot: {seq, at} to a target whose reported hash is in the history, so drift reads "this host still runs what was rendered on 3 October" (Phase 34's nodes).
Not built (and why)
- Restore into chunks. A rendered file cannot be turned back into chunks without the parsers, which are TypeScript.
envv history show --outwrites the old file; deploying it is the operator's act, or a node's pull and the normal import. - A diff against a node's live file. The hub knows only its hash, not its content, by design (ADR-0140). It can say which snapshot matches, not show how the file differs.
- History for sub-users. Snapshots hold every project in the clear, so the whole feature is owner-only.
Consequences
The vault database grows by the size of each changed rendered config twice (real and masked); a typical config is kilobytes, a snapshot over 2 MB is refused rather than truncated, and the default policy bounds the total. Pruning is the only way secrets leave the history, and it is irreversible; the CLI and the pane count first and ask.
Evidence
vault-core/src/config_history.rs (18 tests: dedupe, per-stream chains, every kind of tampering, prune and checkpoint, forged checkpoint, restart from a checkpoint, policy), vault-core/src/textdiff.rs (7), unv-cli/src/history.rs (6), unv-server/src/history.rs (6 over the real router, including a sub-user refused), unv-cli/tests/history.rs (4, the real binary: a rotation diffs as a changed fingerprint, --reveal/--out, tampering exits 10), src/ts/history-pane.ts and tests/history-pane.test.ts (11). Fault injection: 13 mutations (real text in the masked column, no dedupe, a stream restarting without its checkpoint, prune deleting the kept rows, prune without its audit row, checkpoint not checked against the audit chain, chain not verified, content hash not verified, show ignoring reveal, diff always real, route not owner-only, the local save not snapshotting) each fail at least one test.
ADR-0142: Blast radius is answered from recorded exposures, not reconstructed after the fact
Status: accepted
Context
After a compromise the question is "which credentials were on that machine, and when?". It is never answerable, so the answer becomes "rotate everything", so nobody does. The hub knows what it rendered, the node knows what it wrote, and the audit chain knows what changed (Handoff-23, T6), but three things stood in the way: nothing recorded which secrets a rendered file contained, local envv exec and --out are deliberately outside the audit chain, and by the time of a compromise an entry may have been rotated, renamed or deleted.
Decision
Record, at the time, which secrets a file contained. unv_cli::exposure::Matcher is the exact-value matcher of envv shield (Phase 26), built from the vault's secret values, but keeping which entry and which field matched and the fingerprint of the matched value. Every config-history snapshot stores that list (exposed, covered by the stream's hash chain, so editing it to hide an entry is detected). It is exact, so it also catches a secret pasted literally into a field and one that arrived through a composite or a bundle, which reading the ${…} references would miss. A value under 8 characters is still reported, marked short, because a missed exposure is the failure that matters and a coincidence costs one extra rotation.
The hub's answer comes from its own records. node.apply audit rows (Phase 34) say which file hash a node applied and when, using the audit row's timestamp, the hub's clock, not the time the node put in its own report (a node under investigation is not the source for when things happened). The hash is looked up in the history (exposed_by_sha, the union over every snapshot with that hash). A failed apply counts when the file reached the disk first (a failed validate or reload writes, then restores), and does not when it was refused before any write. To make "every file a host was ever sent is in the history" true even when no save passed through the server's hook, the hub records the exact render it is about to push (snapshot_stream, cause push:<node>) before sending it.
"Still current" is a fingerprint comparison. The recorded fingerprint is compared with the entry's present values (not its version_history). Equal means the value that was on the host is the live one: rotate it. A recorded value that is now only in history means it was already rotated away. An entry that no longer exists is reported and never put in the rotate command. Rejected: comparing last_rotated_at with the deployment time, which is wrong whenever an entry was edited without being marked rotated.
One command, for the vault's copy only. The report ends with envv entry rotate '<name>' --generate; … for exactly the still-current entries, sorted first, quoted only when the name can be quoted safely (a name containing a quote is printed as a comment for a human instead of a guess). It says plainly that this replaces the vault's copy and the old credential must also be revoked at its issuer, and prints the entry's console link when it has one.
What the hub cannot know is reported, not dropped. A deployment whose file hash has no snapshot (history off, or pruned) is listed as unaccounted; a pull target's file was never rendered by the hub and is not covered.
Local materialisations go to a bounded log outside the chain. envv exec, anything written with --out (write_secret_file, emit) and --reveal exports append to materialisations.jsonl beside sessions.json: 0600, newest 5,000 records (compacted at 6,000), each holding when, how, which vault and the entries, fields and fingerprints that were in what was written, never a value. A write containing no vault secret is not recorded. This is the shape AGENTS.md already prescribes for read auditing (separate, bounded, outside the hash chain) so growth cannot compromise tamper evidence. Not covered: get --reveal, the app's copy buttons, and anything that opened the vault directly; the log is evidence of what the CLI did on this machine, not of what a compromised user could have done.
Consequences
Every snapshot now builds the matcher once (O(secrets)) when it records something. Concurrent snapshotters (a save's background task and a push) are serialised with BEGIN IMMEDIATE, because both extending the same chain tail forks it and the stream never verifies again.
Evidence
vault-core/src/blast.rs (9 tests: live vs rotated, deleted, unaccounted, since, collapse, failed applies, unquotable names, renames, short), vault-core/src/config_history.rs (exposed union, tamper detection, concurrent writers), unv-cli/src/exposure.rs (5), unv-cli/src/matlog.rs (4), unv-server/tests/nodes_e2e.rs (2 end to end: a pushed file names Stripe and stops naming it after a rotation; a push is recorded before it is sent, and a missing history is reported as unaccounted), unv-cli/tests/blast.rs (3, the real binary), src/ts/nodes-pane.ts (formatBlast, tested).
ADR-0143: A held push needs a human's yes, bound to the exact bytes and signed by the hub
Status: accepted
Context
Nodes (ADR-0140) make an automation able to change production: edit the vault and a node writes the file on a live host within a beat. That is the point, and it is why nobody sensible would switch apply on for a production target. Handoff-23 T5 asks for a human between the render and the apply, bound to the content so that approving "a change to nginx" cannot become approving different bytes (a time-of-check/time-of-use hole), with staging applying freely and production requiring a tap.
Decision
Policy is per node, set by the owner on the hub (envv node policy NODE --approval required|none, the Tools, Nodes toggle). With required, every push to that node is held. A separate, host-side setting makes the refusal independent of the hub: require_approval = true on a [[target]] in the node's own config.
A request is for exact bytes. When a push is ready (the node's apply is on, the rendered hash differs from what it reports), the hub records the proposed file in the config history (cause proposed:<node>), finds the snapshot of the file the node has, and opens one request per (node, target, sha256) with both snapshot numbers. It never opens a second for the same bytes while one is pending, approved or rejected. If the vault changes before the node picks the push up, the rendered hash changes, the old request no longer matches, and a new one opens: the old yes is not for the new bytes. A consumed or expired request for the same bytes (a revert) does not cover a later push either.
The human reads a diff. The request holds snapshot numbers, not content. envv node approve ID prints the node, target and the proposed file's hash, then the diff against what the node has, secrets as fingerprints unless --reveal (Phase 35's diff and masked text), and asks; --yes is for someone who has looked. The pane's Review button shows the same diff and its Approve question names the hash. A request answered after seven days has lapsed. Rejection is final for those bytes: the same request is not asked again.
The hub signs what was approved. An approved push carries approval = {token, sig}: an Ed25519 signature over envv-approval-v1 and a token naming the node, target, sha256, approval id, approver, time, expiry (the approval time plus one hour) and a fresh nonce. The hub's approval seed is created on first use in the vault's own encrypted vault_meta, so a copy of nodes.json cannot forge one and the key exists only while the vault is open. The hub withholds the content entirely until there is a usable approval: a pending request produces no Push action, only a pending note.
The node checks it before it writes. For a target with require_approval, the agent verifies the signature against the hub key it has pinned, that the token names this node, this target and the hash of these bytes, that it has not expired, and that its nonce has not been used; any failure is a failed apply with the reason, which reaches the hub's audit log. The nonce is spent by a write, not an attempt, so a failed validate leaves the approval usable for the retry. The node pins the hub's approval key the first time a beat shows it, over the already-pinned TLS channel, and refuses a different key later (trust on first use over a pinned channel; re-enroll to change it). A node that has not yet seen the key refuses approval-requiring pushes.
Audit. node.policy, node.approval.request (one per request, however many beats ask), node.approve and node.reject are chain rows; a node's applies are node.apply as before.
Consumption. When a node reports the approved hash, the approval is consumed; an approved request the node did not act on within the hour lapses.
Not built (and why)
- Approval signed on the human's own device. The hub signs on the owner's authenticated request, so a compromised hub can mint approvals; what the scheme guarantees is that a hub bug, a sub-user write path or a replayed action cannot push to a node that requires approval, and that the owner's yes is for exact bytes. A key held by the app would close the compromised-hub case and needs a key-management design of its own.
- A per-target hub policy. The policy is per node (staging nodes
none, production nodesrequired); a target-level setting is the node's ownrequire_approval. - Notifying the owner. A held push appears in
envv node approvals, the Nodes pane andenvv node run --once; a push notification is not part of this.
Evidence
vault-core/src/nodes.rs (token sign/verify; request, decide, usable, consume, expire; policy; 7 tests), unv-server/src/nodes.rs (6 tests over the real router: held until a yes, a changed vault asks again, a rejection is final, owner-only decisions including a node's own signed headers, audit rows and a single request row per request, policy off and a consumed yes), unv-server/tests/nodes_e2e.rs (4 with the real agent: a held push waits and a changed vault waits again; a node that requires approval refuses no approval, another hub's key, other bytes, another target, another node, an expired token, and a replay; a changed hub key is refused; a node that has not seen the key refuses), src/ts/nodes-pane.ts (6 tests). Fault injection: 16 mutations (each token check removed, a request opened every time, a decided request decided again, pending treated as approved or the lifetime ignored, an approval not bound to the bytes, never consumed, the policy ignored on push, decisions open to any session, a token outliving its approval, the node ignoring require_approval, accepting a replay, accepting a changed hub key) each fail at least one test.
ADR-0144: Stack integrations are data descriptors, interpreted once in Rust and once in TypeScript
Status: accepted
Context
Phase 38 asks for Prometheus, Grafana, Homarr/Homepage, Nextcloud and VS Code integrations "and the adapter shape they share". Every existing project type is hand-written in four places (Rust exporter, TypeScript exporter, starter chunks, config-check rules), which is why only four types are stable. Adding three more that way is twelve more code sites and twelve chances for the two halves to drift.
Decision
An integration is a JSON descriptor, vault-core/data/stack-adapters.json, read by Rust (vault-core/src/stack.rs) and by TypeScript (src/ts/stack.ts). A descriptor lists the chunk types (fields, secret flags, defaults), the output document as a small node grammar (literal, field, map, list, each, singleton, group, entry, when), and the semantic rules (unique, required, exclusive, together, choices, needs_one_of). Two small interpreters render the document and run the rules. One file is read by both halves, so the twin pair needs a golden fixture per adapter (tests/fixtures/parity/stack-*.yml|yaml) rather than a hand-kept pair of exporters.
YAML scalars are written bare only if they match ^[A-Za-z_][A-Za-z0-9_.-]*$ and are not a YAML reserved word (on, no, null, ...); everything else is JSON-quoted. This is the rule that decides whether a hostile value can change the document structure.
Secrets go through the resolver with the descriptor's per-field secret flag, so a redacting resolver masks exactly the fields the descriptor says are secret and nothing is masked by guess.
Rules run from config_check::check_project, so envv check, the app panel and the node push gate (ADR-0140) all apply them with no extra wiring, plus a generic rule that flags a ${ref} naming nothing in the vault.
Nodes accept an adapter id as an exporter, so a Prometheus file can be pushed, held for approval (ADR-0143) and recorded in history like any other.
The three new project types are experimental (behind experimentalProjectTypes) until CI validates the output against the real software. Outputs were validated locally with Docker: promtool check config accepts the Prometheus golden and rejects a basic_auth plus authorization control; Grafana provisioned both datasources with its secure fields set; Homepage loaded groups, services and widgets, and an invalid file gave an error and an empty list. .github/workflows/exporters.yml repeats this with images pinned by digest.
A finding from that check: Grafana does not reject two datasources with the same name, it provisions only the last one and the first vanishes without a warning. The unique rule on name states that.
VS Code: vscode-extension/ completes and hovers ${Provider/FIELD} references and runs envv scan --exposed. Its pure logic (src/refs.ts) is tested in the main suite; the rest is thin glue over the CLI and has not been run in a real editor.
Not built
- Nextcloud importer and a Grafana API probe. Both need a live service and a format that has not been verified against one; deferred as 38.1.
- Marking the types stable. Not until the CI jobs have run green.
Evidence
vault-core/src/stack.rs (15 tests), unv-cli/tests/stack_parity.rs (8) and stack_cli.rs (3), tests/stack.test.ts (17), tests/stack-ui.test.ts, tests/vscode-refs.test.ts (12), two node push tests in unv-server/src/nodes.rs. Fault injection: 16 mutations (quoting, reserved words, group default, disabled chunks, when, secret flag, exclusive and together rules, duplicate detection, rules not run by check, CLI render ignoring adapters, nodes refusing adapter exporters, and the TypeScript counterparts) each fail at least one test.
ADR-0145: A hub can dial a node that listens, and everything it sends is signed
Status: accepted
Context
ADR-0140 made every node dial its hub: outbound only, NAT-safe, nothing listening on the managed host. That does not suit a node with a public address (a VPS) whose operator would rather open one port than let the host initiate sessions, and it leaves a locked hub unable to look at such a node at all. The design always allowed a second direction ("per node: dial or listen"); the wire types were written to be symmetric enough for it.
Decision
The direction of the connection changes; what a node does does not. A node enrolled with --listen ADDR --advertise https://host:port runs a small TLS listener instead of a heartbeat loop. The hub makes two requests to it per round:
POST /node/v1/poll(empty body). The node observes its targets and answers with the sameBeata dialling node would send.POST /node/v1/act, body aBeatReply. The hub computes it with the sameprocess()it uses for a dialled beat. The node takes it through the sameaccept_reply, which includes every check from ADR-0140 and ADR-0143: its own config decides what is written, an approval is verified against the pinned approval key, and a held push carries nothing.
Both ends are authenticated. The hub pins the node's TLS certificate (a self-signed certificate the node generated, its SHA-256 sent in the token-authenticated enrollment request), TLS 1.3 only, no redirects. The node checks an Ed25519 signature on every request, under a hub transport key delivered in the enrollment reply, over envv-hub-v1, method, path, a millisecond timestamp and the body hash. The timestamp must be within 60 seconds and strictly newer than the last accepted, and the mark is written to disk before the request is acted on. Every failure is the same bare 401. The node signs its answer to a poll exactly as it signs a dialled beat, so the hub verifies it with the same NodeStore::authenticate (signature, revocation, skew, replay).
The two directions have different signing domains (envv-node-v1 and envv-hub-v1), so a signature made for one cannot be replayed as the other; a test pins that.
The hub transport key lives in nodes.json, not in the vault. The approval key stays in the vault's vault_meta so a locked hub cannot push; the transport key is in a plain 0600 file so a locked hub can still poll (observation continues, as it does for dialling nodes). Cost, stated: a copy of nodes.json lets its holder speak as the hub to listening nodes, within what those nodes' own configs allow, and cannot mint an approval. The same is true today of the hub's server.key for dialling nodes.
Scheduling. A task started with the server polls every listening node every 30 seconds and at once whenever the vault is saved or a decision is made (the same wake signal held beats use). A node that does not answer is logged and retried; it is not marked failed anywhere, because last_seen and last_polled already say when it last spoke.
A deliberately small HTTP reader on the node. Two routes, 16 KiB of headers, 8 MiB of body, no chunked bodies, a 5 second read timeout and a 10 second total deadline per connection. A framework would add attack surface to a host that exists to be boring.
A stalled stranger must not lock the hub out. The first version served one connection at a time, so a client that connected and said nothing held the listener for the whole timeout. Each connection is now read on its own thread, and only a whole, signed request takes the lock on the agent. The cap on concurrent connections went through two values: 16 let twenty idle sockets starve the hub (an end-to-end test caught it once under load), so it is 128. An attacker who can hold 128 sockets open can still delay a poll; on a public address the port should be reachable from the hub's address only, as for SSH. Nothing a stranger sends is acted on.
Consequences
A listening node opens one inbound port, reachable by anyone, that does nothing without a valid hub signature and a pinned TLS handshake. The hub holds an outbound client per poll. Revoking a node removes it from the poll list at once.
Not built
- Rotating a node's certificate or the hub transport key. Re-enroll.
- Concurrent polls. One node at a time per hub; fine at tens of nodes.
- Polling through a locked hub's pushes. A locked hub polls and records, and sends nothing, exactly as for a dialling node.
Evidence
vault-core/src/nodes.rs (ListenInfo, hub request signing, NodeStore::{hub_node_key, listening, mark_polled}, 4 tests), vault-core/src/tls.rs (self_signed, server_config_tls13, a pinned-handshake test), unv-cli/src/node_listen.rs, unv-cli/src/node_agent.rs (enroll with --listen), unv-server/src/nodes.rs (poll_node, spawn_poller), and unv-server/tests/nodes_e2e.rs: the hub dials a real listening agent and a save reaches it; a listener refuses replay, skew, another key, a node-domain signature and a wrong path, and a wrong pin never reaches the handler; the hub ignores a beat that is unsigned, signed by another key or for another id, with a control signed correctly. Fault injection on the node's replay mark, signature check and skew check.
ADR-0146: The app saves deltas, and a project's chunks are rows of their own
Status: accepted
Context
Phase 30 made saves cost O(changed rows) on the server, but the client still sent the whole document for one edit, the server still built one project row per project (so two people editing different chunks of one config conflicted), the change log kept only the newest 2,000 saves (a script saving thousands of times a day turned an hour-old token into a whole-vault conflict), and a full load parsed every row on one thread.
Decision
Delta saves. PATCH /api/vault and the Tauri command save_vault_rows take {put, delete, projects_put, projects_delete, categories}. vault_core::apply_row_patch applies it to the stored document by id; the result goes through the same save_vault, so the compare-and-swap, the per-row merge, the audit rows, the history snapshots and X-Vault-Merged are identical to a whole-document save. Owner only: a sub-user's write is filtered against the document it was served, which a delta cannot reconstruct. The server still loads the whole document to apply the delta; what shrinks is what crosses the wire and what the client must build.
The client diffs, it does not track. Everything in the renderer mutates st.vault in place, so there is no dirty list to read. persist() remembers the JSON of each entry and project as of the last save that reached the store and sends the difference. The snapshot is trusted only while the document and its arrays are the very objects it was taken from; anything that replaced one (a reload, an import, a filter that reassigned api_keys) or any change of order falls back to the whole-document save. A save that did not reach the store leaves the old snapshot, so the same changes go out again. A store that refuses deltas (403/404/405) is saved whole.
Chunk rows, schema v3. A project's chunks are rows of kind chunk, keyed by the project row's key and the chunk's id. The project row keeps an empty chunks array to say they live elsewhere. Two writers editing different chunks of one project merge; the same chunk is a conflict naming it. A chunk whose project was deleted by the other writer is dropped rather than left as an orphan row. A v2 vault loads unchanged (a project row with inline chunks is understood) and is rewritten by its first save. The schema version is raised to 3 so a v2 build refuses the file: it would read a project with no chunks and delete the chunk rows.
Retention by age. The change log keeps the newest 2,000 saves and anything newer than 30 days, bounded by a hard ceiling of 200,000, so a busy script cannot burn the window in hours or grow it without limit. envv doctor reports how far back a stale writer can still be merged.
Load speed. Row and history JSON is parsed on worker threads once there are enough rows to pay for them. At 5,000 entries with history a full load went from 73 ms to 36 ms (release build), below the old single-parse figure. load_entries_where, which nothing called, is deleted; entry_history and GET /api/vault/entries/{id}/history read one entry's history alone.
Over the pinned HTTPS proxy remote_request now returns the ETag and X-Vault-Merged it used to drop, so a remote store reached that way writes with an If-Match like any other.
Consequences
The first save after upgrading rewrites every project row once. A stale writer whose token predates that save sees a conflict on any project it touched. Lazy loading of history in the app was not done: the rotation and health code read version_history from the loaded entry, and a client that loaded entries without it would have to be taught that "absent" means "unchanged" in the save path first.
Evidence
vault-core/src/storage.rs (chunk split/join, five chunk tests, retention test, entry_history, par_parse), vault-core/src/lib.rs (apply_row_patch), unv-server/src/lib.rs (patch_vault, entry_history_handler), src-tauri/src/lib.rs (save_vault_rows, headers on remote_request), src/ts/state.ts (currentPatch, saveWholeOrDelta), tests/delta-save.test.ts, tests/remote-save-rows.test.ts. Fault injection: delete not applied, orphan filter, chunk split disabled, the age condition of the prune, the order check, the array-identity check and the failed-save snapshot rule each fail a test.
ADR-0147: An approval can be signed on the owner's device, and then the hub cannot make one
Status: accepted
Context
ADR-0143 put a human between a render and a write, bound the yes to the exact bytes, and had the hub sign it. That holds against bugs, replays and a sub-user write path. It does not hold against a hub someone else controls: the hub holds the approval key (in the vault's encrypted vault_meta), so whoever runs it can mint approvals for any push. The decision recorded the gap ("approval signed on the owner's own device: not built").
Decision
A third policy, device, and a key that never leaves the owner's machine. approver.key is a 32-byte Ed25519 seed made on first use under the data directory (0600), shared by envv and the desktop app. Its public key is registered with the hub (envv node approver register, or the pane's "Use this device to approve") and written into each node's own config as approver = "<hex>".
The device signs; the hub only checks and forwards. For a node set to device, the owner's client takes the held request (node, target, bytes, request id) from the hub, builds the token itself (lifetime = the hub's own, fresh nonce, device:<fingerprint> as approver), signs it and posts {signed}. The hub accepts it only if it verifies under a registered device, names exactly this request, and does not outlive the hub's own limit (decide_signed). It stores the signed token on the request and sends exactly that. The hub's own click on a device node is refused (409), and process never signs for a device node whatever the record holds, so switching a node to device after a hub-made yes voids that yes.
The node trusts only its own config. With approver in its config, a node checks an approval against that key alone. The hub's key, pinned or not, is not consulted, so a hub that signs its own approvals (policy required) produces pushes the node refuses and reports.
The desktop signs through two commands, not a seed. approver_public returns the public key and fingerprint; approver_sign takes the request's ids and builds and signs the token in Rust, so a compromised page cannot choose a lifetime, a nonce or an approver, and the seed never crosses the IPC boundary.
Removing the last device returns device nodes to required, rather than leaving a node that nothing can approve or one that quietly becomes approvable by the hub alone.
Consequences
A node that names an approver is only as good as the secrecy of that device's approver.key. Losing the device means re-registering another and editing each node's config, deliberately: the node config is the only authority a node has. Approving needs the owner's machine, not just a session on the hub.
Not built
- A hardware key or passkey as the approver. The seed is a file; a hardware-backed signer is a different
approver_sign. - Several approvers needed for one push (two-person rule). The registry holds several devices; any one suffices.
Evidence
vault-core/src/nodes.rs (Approver, add_approver, remove_approver, decide_signed, approver_seed, sign_device_approval, NodeConfig.approver; 4 tests), unv-server/src/nodes.rs (decision route, approver routes, process), unv-cli/src/node_agent.rs (check_approval), unv-cli/src/node_cmd.rs, src-tauri/src/lib.rs (approver_public, approver_sign), src/ts/nodes-pane.ts. End to end with a real agent (unv-server/tests/nodes_e2e.rs): a device node holds a push, refuses the hub's click, refuses a signature from an unregistered key, accepts the registered device's and writes; a node naming an approver ignores an approval the hub made itself; switching to device voids an earlier hub-made yes (the guard in process was removed to see it fail). Writing the first e2e test also found that a device node was not held at all, because two places compared the policy to "required" only.
ADR-0148: A Grafana token is probed only where it is safe to send it, and a PHP config is read, not run
Status: accepted
Context
Phase 38 deferred two integrations because both needed a live service: a Nextcloud importer and a Grafana API probe (38.1). Both were checked against the real software this time (Grafana 11.2 and a Nextcloud 29 container's installer).
Decision
enrich --online can ask a self-hosted Grafana who a service-account token is (glsa_): GET /api/user for the login, GET /api/org for the organisation. Every other probe sends the token to the issuer's own public host; this one sends it to the entry's api_url, which in an imported or shared vault is text an attacker can choose. So the URL must be a plain origin with no userinfo, query or fragment; https is allowed anywhere, and plain http only to a name that cannot be reached from the public internet (loopback, private and link-local addresses, single-label names, .local, .lan, .home.arpa, .internal). The consent screen names the host (issuer_for returns Grafana at host:port), the client does public-CA validation and follows no redirect, and a cookie entry is never probed.
Checked against the real server, the plan changed. /api/user/orgs, the obvious source of a role, answers a service-account token with "Endpoint only available for users", and /api/serviceaccounts/{id} needs a permission an ordinary token lacks. The role is therefore not available and is not guessed; the organisation name from /api/org is. The exporters workflow gained a job that mints a token on a pinned Grafana image and runs the probe, with a control token the server refuses.
A Nextcloud config.php is read, never executed. vault_core::php_config parses the $CONFIG = array(...) literal (or the [...] form): strings with PHP's two escape rules, numbers, true/false/null, nested arrays, the three comment styles. A value that needs PHP to evaluate (getenv(), a constant, a concatenation) becomes null and a warning naming the line; nesting depth and node count are bounded. A first draft recomputed line numbers per value and took 104 seconds on a 150,000-item file; line numbers are now computed only when a warning is written.
What it imports. passwordsalt and secret, the database password (with user and host in the note), the SMTP password, the Redis password, the licence key and an object store's key and secret, one entry each, named with the instance id so two instances do not overwrite each other and a re-import changes nothing. The preview shows fingerprints. The file is the whole of what is read: the Docker image's *.config.php fragments are PHP scripts, not arrays, and importing one reports that rather than guessing.
Consequences
The Grafana probe is the first that can send a secret to a host the user typed. The rules above are the control, and each is pinned by a test, including that a plain-http public address and a URL with userinfo are refused.
Not built
- A Nextcloud rotation adapter (app passwords revoked per device). The importer reads a config file; rotation needs the admin API.
- Grafana token expiry.
GET /api/serviceaccounts/{id}/tokenscarries it but needs the service account id and a permission an ordinary token lacks.
Evidence
unv-cli/src/enrich.rs (grafana_base, probe_grafana, issuer_for; 3 tests including a fake Grafana that records the Authorization header), run by hand against Grafana 11.2 (accepted and rejected tokens), vault-core/src/php_config.rs (5 tests), unv-cli/src/import_vaults.rs (source_value, read_nextcloud), unv-cli/tests/import_vaults.rs (a real SQLite installer's config.php and a synthetic full one, idempotence, no value in the preview), .github/workflows/exporters.yml (grafana-probe).