Vault profile is section KV, not one mega-row
Scheduled8 min read By NT²
Salt, Key DID public material, sync cursors, and onboarding prefs are not one fat SQL row. They live as named vault profile sections—so backup, sync, and unlock can each touch only what they need.
Vault profile is section KV, not one mega-row
Claim: the vault profile is a section store
Every NT² Vault has more than encrypted items. It has a vault profile: the display name you pick on this device, the KDF salt that never leaves the machine, Key DID public material for cloud identity, wrapped key material for unlock, Premium sync cursors, WebAuthn bindings, and a growing set of prefs— health report toggles, onboarding state, travel-mode settings, and whatever the product adds next year.
That profile lives in the same per-vault SQLite database as the asset catalog—
under OPFS in the browser, or a native file in a desktop shell. The table is
still called vault_meta. The important claim is about shape, not name:
Vault profile is a section key-value store: one row per logical section, not one mega-row with dozens of columns.
Each section has a stable key (identity, did, sync, prefs.health, …)
and a versioned JSON payload. Unlock reads the identity section. Backup exports
portable sections. Replica sync ships syncable sections. Device-bound material
never pretends it belongs in a file you carry to another laptop.
If you only remember one sentence: we group vault-level state by concern, so migration, backup, and sync stop fighting over the same fat row.
Constraint: the mega-row eats the product
The failure mode is familiar. Start with a single vault_meta row—id = 'meta', columns for salt, display name, a few flags. Every new feature adds a
column. Every column needs an ALTER TABLE. Every unlock path learns to
SELECT *. Every backup exporter has to decide, field by field, what is safe
to copy. Every sync engineer inherits a blob of mixed concerns: bootstrap crypto
next to “last sync cursor on this laptop” next to “did the user dismiss the
first-run tip.”
That shape fails three ways at once.
Schema churn becomes the feature tax. Preferences and device bindings are product surface, not relational schema. Forcing an SQLite column migration for “show vault health report” turns a prefs toggle into a schema event. Incremental migration after launch is already a serious promise; burning version stamps on prefs columns is the wrong currency.
Portable and device-local get tangled. Salt and Key DID public material must travel with a portable backup or an enrolled replica. Sync cursors must not. WebAuthn credentials are bound to a platform authenticator. If everything sits in one row, “export the profile” becomes a hand-curated allowlist of columns— easy to get wrong, hard to review, tempting to over-export “just in case.”
Unlock pays for the attic. Cold open should read salt and verifier, derive keys, and decide locked vs unlocked. It should not load travel prefs, onboarding flags, and sync bookkeeping to answer that question. A mega-row invites all-or-nothing reads. A section store lets unlock stay narrow.
Local-first products feel this pressure early. The vault file is authoritative. Zero-knowledge means the cloud cannot reconstruct missing local profile. So the profile layout is not an implementation detail—it is part of the security and durability story. A single wide row papers over that story until backup, sync, and unlock disagree about what “the profile” means.
Design: named sections, versioned payloads, three travel classes
The table is small on purpose:
| Column | Role |
|---|---|
| Section key | Stable id for one concern (identity, did, sync, prefs.*, …) |
| Payload | Versioned JSON object (v plus fields) |
| Updated at | Unix ms for that section’s last write |
One vault database holds many section rows. There is no “only one meta row” check. New product surface usually means a new section key and a TypeScript parser—not a new SQL column.
Payloads are plaintext JSON at rest, in the same trust model as item titles: readable only if you already have the vault file on the device. They do not hold decrypted asset fields or the master password. Sensitive crypto material that does live in sections (wrapped keys, verifier ciphertext) stays in the sections designed for it; the layout does not become a dumping ground for plaintext secrets.
Domain code can still present a flat “vault meta” view for callers that want the whole picture. Under the hood, writes route to the correct section. That facade is convenience. The storage truth is sectional.
Travel behavior is the second half of the design. Sections fall into classes:
| Class | Examples | Backup / file export | Replica sync |
|---|---|---|---|
| Portable / syncable | Identity (salt, display name), Key DID public material, wrapped vault-key material, most prefs | Included | Included after enrollment |
| Device-local | Sync cursor and flags, WebAuthn bindings, backup-UI prefs for this machine | Omitted | Omitted |
| Enrollment bootstrap | First salt / threshold factors on a cold device | Not a substitute for enroll | Enroll via recovery kit / setup QR first |
Portable sections are what make a .nt2backup feel like sovereignty: you leave
with the profile pieces another device needs after a proper restore path—not
with another laptop’s sync watermark. Device-local sections stay honest about
physics: a cursor is “how far this replica has pulled,” not a property of the
logical vault in the abstract. WebAuthn material that only makes sense on one
authenticator must not round-trip through a USB stick as if it were portable
identity.
Unlock stays deliberately boring. It reads the identity section—salt and the local password verifier—and only then proceeds into threshold combine, Key DID unwrap, and session state. Prefs and sync bookkeeping wait until the vault is actually open. Wrong password still fails closed; the section layout does not change that rule, it just keeps the gate small.
For replica sync, syncable sections ride in the same blind batch format as encrypted item rows: the edge stores ciphertext and non-sensitive metadata, not a readable biography of your life. Last-write-wins merge is per section key. Device-local keys that somehow appear on the wire are dropped, not “helpfully” applied. Backup import does not invent a new enrolled replica by itself— enrollment and portable profile are separate doors on purpose.
flowchart LR
subgraph vaultDb [Per-vault SQLite]
idSec[identity]
didSec[did]
syncSec[sync]
prefsSec[prefs.*]
deviceSec[device.*]
end
unlock[Unlock path] --> idSec
backup[.nt2backup portable] --> idSec
backup --> didSec
backup --> prefsSec
replica[Replica batch] --> idSec
replica --> didSec
replica --> prefsSec
syncSec -.->|stays on device| localOnly[This machine only]
deviceSec -.->|stays on device| localOnly
The diagram is the product rule in picture form: many sections, one database, different travelers.
Trade-off: parsers and discipline instead of SQL convenience
Section KV is not free.
We own parsers in application code. Each section has a versioned payload and
a named parse path. Unknown keys are ignored for forward compatibility; wrong
v fails closed when a breaking change ships. We do not lean on SQLite
json_extract as the query language for prefs. That keeps SQL boring and puts
schema evolution where TypeScript already lives—but it means every new section
needs a registry entry, defaults, and tests, not only a UI toggle.
We maintain a facade and the truth. Call sites that want a flat profile get
an aggregated view. Call sites that must not touch sync state use section APIs.
Two ways to read the same store can drift if writers bypass the router. The
discipline is: partial updates go to the correct section; do not reinvent a
wide-row UPDATE in domain code.
We accept more rows for clearer boundaries. One prefs namespace could have
been thirty columns. Dot-namespaced keys (prefs.health, prefs.onboarding)
keep related fields together without returning to mega-row gravity. We refuse
the opposite extreme too—one key per scalar field would explode row count and
transactions for no clarity gain.
We couple backup and sync allowlists to the registry. Portable and syncable sets must stay aligned with what enrollment and restore actually need. A new section without a travel class is a bug waiting for a support ticket. The upside is reviewability: security readers can ask “does sync export this key?” instead of auditing forty columns.
What we gain is the property the mega-row never delivers cleanly: additive product prefs without additive SQL migrations, narrow unlock, and a honest split between what leaves the device and what must not.
What we refuse
We refuse a single mega-row as the long-term vault profile. Column sprawl is not a storage strategy.
We refuse ALTER TABLE as the default path for new prefs. Prefs are section
payloads. Schema version stamps are for relational shape that unlock, list, and
FTS actually depend on.
We refuse exporting device-local state as if it were portable identity. Sync cursors, WebAuthn bindings, and machine-only prefs do not belong in a backup file that claims to move a vault.
We refuse treating backup import as enrollment. Portable sections restore profile after a vault exists; they do not mint threshold factors and pretend a cold laptop is already a replica.
We refuse unlock that loads the entire profile attic. Identity first. The rest after the gate opens.
We refuse stuffing decrypted asset payloads into profile sections. The section store is vault-level metadata and crypto bookkeeping—not a second item table.
We refuse a cloud oracle for salt or verifier. Those live in local identity sections on the device. The edge never becomes the place you fetch “enough to guess the password.”
Close: profile shape is a local-first feature
Section KV sounds like an internal refactor. It is how local-first stays honest when the product grows.
Named sections keep salt on the device, Key DID material auditable, prefs portable when they should be, and sync bookkeeping local when it must be. The same per-vault SQLite file that holds your encrypted catalog holds a profile that can evolve without a wipe—and travel without dragging another machine’s state along for the ride.
For how that SQLite file upgrades after launch without recreate, read Schema v60 and incremental migration. For why salt never becomes a cloud fetch, read KDF salt stays on the device. For where the vault database lives in the browser, read Why our vault SQLite database lives in OPFS, not IndexedDB. For how enrolled replicas move ciphertext without teaching the edge to read you, read Blind replica sync on the edge.
If you want a vault whose profile is sectional by design—not one mega-row waiting to become a migration tax—try NT² Vault or read more at nt2.me.
Last updated 2026-10-03