Staging — blog preview only.
Skip to content

Vault profile is section KV, not one mega-row

Scheduled

8 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:

ColumnRole
Section keyStable id for one concern (identity, did, sync, prefs.*, …)
PayloadVersioned JSON object (v plus fields)
Updated atUnix 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:

ClassExamplesBackup / file exportReplica sync
Portable / syncableIdentity (salt, display name), Key DID public material, wrapped vault-key material, most prefsIncludedIncluded after enrollment
Device-localSync cursor and flags, WebAuthn bindings, backup-UI prefs for this machineOmittedOmitted
Enrollment bootstrapFirst salt / threshold factors on a cold deviceNot a substitute for enrollEnroll 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

Related stories