vault-head
| Subpath | @noy-db/hub/vault-head |
| Factory | withVaultHead() |
| Layer | with-commit |
What it does
A record's envelope authenticates itself. Its identity — collection, id, tier, author — and its version are bound into the AEAD, so an untrusted store cannot relocate a record, re-tier it, re-author it, splice another body into it, or present it at a version it was not sealed at. The sync merge rejects an envelope that fails to authenticate before committing it.
None of that can see absence.
A store serving a genuine, unmodified v1 when v7 exists is serving a real record this client
really wrote — nothing about the bytes is wrong. A store serving nothing is indistinguishable
from a record that never existed. You cannot detect a missing thing by examining the things
present.
withVaultHead() supplies the missing external knowledge: an authenticated {id → version}
manifest the client writes and the store cannot forge.
Why it is opt-in
Identity binding costs nothing to coordinate and closes alteration, so it is unconditional. The head costs a write per commit and needs anti-entropy. On a single-device offline vault it defends against nothing — there is no second party to withhold anything — so making it mandatory would tax every user for a multi-writer property.
That split is what lets the security boundary be stated as a narrower true claim rather than a concession:
by-peer peer — cannot alter, relocate, re-tier, re-author, splice or
re-version any record it serves. Without withVaultHead(), it can still withhold or omit.Prerequisites
- A store that advertises
capabilities.casAtomic— the head is written with a compare-and-set. Without it a sweep reports'unverifiable'rather than a false clean. - A multi-writer or remote deployment for it to be worth enabling — on a single-device offline vault there is no second party to withhold anything.
- Nothing else: the head travels as ordinary records over the same store contract, so no adapter needs new methods to support it.
Using it
import { createNoydb } from '@noy-db/hub'
import { withVaultHead, verifyVaultHead } from '@noy-db/hub/vault-head'
const head = withVaultHead()
const db = await createNoydb({ vaultHeadStrategy: head, store, user, secret })
const vault = await db.openVault('acme')
// …writes proceed normally; the head records each one…
const { adapter, getDEK } = vault._introspectState()
const result = await verifyVaultHead(head, adapter, 'acme', getDEK, 'invoices')
The verdict is three-way, and the middle value is the point
result.verdict // 'verified' | 'unverifiable' | 'tampered'
result.because // why it could not conclude — non-empty only when 'unverifiable'
| verdict | meaning |
|---|---|
'verified' | the head held expectations, every one was met, and the store can serialize head writes |
'unverifiable' | the sweep could not reach a conclusion — see because |
'tampered' | at least one discrepancy. Positive evidence, so it wins over any unverifiable reason present at the same time |
Collapsing the middle value is wrong in a different direction each way. Folded into "clean" it hides withholding, which is the one thing this service exists to catch. Folded into "tampered" it cries wolf on a vault that is merely unexamined.
That is not hypothetical: this service's own first bug was a head that recorded nothing and swept perfectly clean — the degraded state rendered identically to a healthy one. Assert on the count of records checked, not merely on the absence of an error.
Why a sweep may be unverifiable
because | meaning |
|---|---|
'no-expectations' | the head holds nothing for this collection — a fresh vault, a head switched on after the fact, or a restore from a snapshot (_head is _-prefixed, so loadAll excludes it and a restored vault has no head to compare against) |
'store-cannot-cas' | the store does not advertise capabilities.casAtomic, so two writers racing on one bucket can silently lose an entry — and a lost entry is a record the sweep stops expecting, which is a false clean |
casAtomic degrades the very manifest that
exists to detect that store — the attack disables its own detector.Discrepancies
if (result.verdict === 'tampered') {
for (const d of result.discrepancies) {
// d.expected — what the client last committed
// d.actual — what the store serves now, or null when it serves nothing
}
}
kind | meaning |
|---|---|
'withheld' | the store serves nothing for a record we wrote — suppression |
'rolled-back' | it serves an authentic older version in place of the current one — the case no amount of per-envelope authentication can see |
Limits & gotchas
- Verification is client-side and opt-in. A store cannot be made to prove faithful service; the head only lets a client that opted in notice. In a relay or multi-writer deployment, the party that must enable it is the party the relay does not control.
- Keyring writes are not covered.
_keyringfiles are not records under the head, so a replayed genuine roster is outside what this detects. - A restore from a snapshot starts with no expectations and reports
'unverifiable'until the head reaccumulates — correct, not a fault.