Confidential HR records
Audience: teams holding co-located records of mixed sensitivity in one collection — an employee's public basics beside their salary — who need the sensitive fields cryptographically gated, not just hidden by a UI check. Bundle: core +
withTiers()(@noy-db/hub/tiers) +withTeam()(@noy-db/hub/team) +withBlobs()(@noy-db/hub/blobs) +withSearch()(@noy-db/hub) +withHistory()(@noy-db/hub/history) +withForget()(@noy-db/hub/forget).
Verified by: showcases/src/recipe-confidential-hr.recipe.test.ts — the code below is the same code that runs there.
An HR vault stores each employee's public basics — name, role, start date — right next to their salary, performance notes, and signed contract PDFs. Those two halves need different confidentiality. Record tiers give the sensitive half its own per-tier data-encryption key (DEK): a caller at the public floor cannot decrypt a record parked above it, cannot see it on a plain read, and cannot search or overwrite it — while the public basics stay readable to everyone with the collection DEK. This recipe walks the whole tier surface: gated reads, a TTL-bound elevation for a privileged write, a delegation to a payroll reviewer, tier-aware search, a contract blob that follows its record's tier, and a right-to-erasure that shreds across tiers.
Prerequisites
- The Bundle line's six strategies, passed to
createNoydb()together (see Setup). - Every tiered collection must declare its tiers —
vault.collection(name, { tiers: [0, 1] }). A collection that never declarestiersis a plain tier-0 collection. - The blob-owning collection also needs
perRecordKeys: true— a blob can only be tier-isolated when its owning record carries a per-record key;blobTierPolicy: 'isolate'then keeps the content unshared so it re-keys with the record. - The vault owner (
hr-adminhere) auto-mints tier DEKs at write time. A lower-tier session or a delegate is what makes the gate observable.
What this gets you
| Capability | Provided by |
|---|---|
| Salary parked above the public floor, invisible to a below-tier read | withTiers() + putAtTier() / the collection's tiers: [0, 1] |
| A cleared read that decrypts above your inherent tier | getAtTier() |
| A time-boxed, audited session that writes at an elevated tier | vault.elevate(tier, { ttlMs, reason }) |
| Handing a reviewer scoped, expiring access without a full grant | vault.delegate() (needs withTeam() for the reviewer's keyring) |
| Semantic search that skips — never refuses — elevated records | withSearch() + a collection embeddings encoder |
| A contract PDF whose storage tier follows its owning record | withBlobs() + blobTierPolicy + blob(id).atTier() |
| Right-to-erasure that crypto-shreds across every tier | withForget() (+ withHistory() for the ledger proof) |
Setup
Every beat below shares one wiring — the vault owner's console:
import { createNoydb, withSearch } from '@noy-db/hub'
import { withTiers } from '@noy-db/hub/tiers'
import { withTeam } from '@noy-db/hub/team'
import { withBlobs } from '@noy-db/hub/blobs'
import { withHistory } from '@noy-db/hub/history'
import { withForget } from '@noy-db/hub/forget'
import { toMemory } from '@noy-db/to-memory'
interface Employee { employeeId: string; name: string; role: string; startDate: string }
interface Salary { employeeId: string; amount: string; notes: string }
interface Contract { employeeId: string; title: string }
interface Review { employeeId: string; notes: string }
const PUBLIC = 0
const CONFIDENTIAL = 1
const db = await createNoydb({
store: toMemory(),
user: 'hr-admin',
secret: 'hr-console-secret-2026',
tiersStrategy: withTiers(),
teamStrategy: withTeam(),
blobsStrategy: withBlobs(),
searchStrategy: withSearch(),
historyStrategy: withHistory(),
forgetStrategy: withForget({ subjects: { employees: 'employeeId', salaries: 'employeeId' } }),
})
const vault = await db.openVault('acme-hr')
Gated reads — public basics stay readable, salary does not
The employee's basics live at tier 0; the salary is born confidential via a direct tiered write (putAtTier), never a plain put().
const employees = vault.collection<Employee>('employees', { tiers: [PUBLIC, CONFIDENTIAL] })
const salaries = vault.collection<Salary>('salaries', { tiers: [PUBLIC, CONFIDENTIAL] })
await employees.put('e1', { employeeId: 'e1', name: 'Dana Lopez', role: 'Engineer', startDate: '2024-02-01' })
await salaries.putAtTier('e1', { employeeId: 'e1', amount: '145000', notes: 'strong Q1' }, CONFIDENTIAL)
await employees.get('e1') // → { …, role: 'Engineer' } — tier-0 basics, readable
await salaries.get('e1') // → null — the read gate runs off the live `_tier` header before any decrypt
A plain get() on a confidential record returns null for a below-tier caller — it is treated as absent, not refused. The sanctioned way to read above your inherent tier is getAtTier(), which succeeds for a caller who holds the tier DEK (here, the owner) and fires a cross-tier access event:
await salaries.getAtTier('e1') // → { employeeId: 'e1', amount: '145000', notes: 'strong Q1' }
Scoped elevation — a TTL-bound privileged write
To update a salary, a session briefly elevates. vault.elevate(tier, { ttlMs, reason }) returns a handle whose writes land at the elevated tier; reads on the original vault stay at the caller's inherent tier.
const elevated = await vault.elevate(CONFIDENTIAL, {
ttlMs: 15 * 60_000,
reason: 'annual review adjustment',
})
// → ElevatedHandle { tier: 1, reason: 'annual review adjustment', expiresAt: <now + ttlMs> }
await elevated.collection<Salary>('salaries').put('e1', {
employeeId: 'e1', amount: '152000', notes: 'raise approved',
})
await salaries.getAtTier('e1') // → { …, amount: '152000' }
Only one elevation may be live per vault — a second vault.elevate(...) throws AlreadyElevatedError. The TTL is enforced lazily: the handle checks expiresAt on every operation, so a forgotten release() cannot keep elevated writes alive. Calling release() (or letting ttlMs elapse) makes the handle inert, and the next write through it throws:
await elevated.release()
await elevated.collection<Salary>('salaries').put('e1', { /* … */ })
// → throws ElevationExpiredError
Delegation — scoped access for a payroll reviewer
A reviewer who needs the confidential tier without a standing grant gets a time-boxed delegation. The reviewer must already have a keyring in the vault, so grant them in first (withTeam()), then delegate:
await db.grant('acme-hr', {
userId: 'payroll', displayName: 'Payroll Reviewer', role: 'operator', secret: 'payroll-pass-2026',
})
const token = await vault.delegate({
toUser: 'payroll',
tier: CONFIDENTIAL,
collection: 'salaries',
until: new Date(Date.now() + 60 * 60_000),
})
// → DelegationToken { id, toUser: 'payroll', fromUser: 'hr-admin', tier: 1,
// collection: 'salaries', wrappedDek, sealedCek, until, createdAt }
Naming collection makes this the per-collection form, so the token carries a single
wrappedDek. Omit collection and you get the collection-wide form instead — a wrappedDeks
map covering every collection the grantor held at that tier when the token was issued. For a
reviewer who only needs salaries, name the collection: it is the narrower grant.
The tier DEK is wrapped under a per-token content key, and that key is sealed to the reviewer's
inbox public half (sealedCek) — so hr-admin delegates without knowing anything of the
reviewer's secret. The reviewer's side then calls vault.refreshDelegations() to merge it,
which grants a cleared read until until passes; vault.revokeDelegation(token.id) cancels it
early. Merging is explicit — opening the vault does not do it, and tiers says
why. The runnable test asserts issuance and the token's shape; the cross-session merge is a
second-session concern beyond one in-memory process.
Tier-aware search — elevated records are skipped, not refused
A collection with an embeddings encoder derives a _vec sidecar per record. Elevating a record purges that sidecar: it is no longer searchable above tier 0, and a below-tier search simply omits it — no error.
const reviews = vault.collection<Review>('reviews', { tiers: [PUBLIC, CONFIDENTIAL], embeddings: enc() })
await reviews.put('r1', { employeeId: 'e1', notes: 'strong performer bonus recommended' })
await reviews.put('r2', { employeeId: 'e2', notes: 'strong performer bonus recommended' })
await reviews.elevate('r1', CONFIDENTIAL)
const hits = await reviews.retrieve('strong performer bonus', { mode: 'semantic' })
hits.map((h) => h.id) // → ['r2'] — the elevated r1 is absent, not an error
A bulk re-derive counts the elevated record in skipped rather than writing a searchable sidecar above tier 0:
await reviews.rebuildEmbeddings() // → { rebuilt: 1, skipped: 1 }
(enc() is any encoder returning a Float32Array; the test uses a deterministic stub — see search — AI for real encoders.)
Tiered blobs — a contract that follows its record
A signed contract PDF is a blob owned by an employee record. On a perRecordKeys collection with blobTierPolicy: 'isolate', the blob's storage tier follows its owning record.
const contracts = vault.collection<Contract>('contracts', {
tiers: [PUBLIC, CONFIDENTIAL],
perRecordKeys: true,
blobTierPolicy: 'isolate',
})
await contracts.put('c1', { employeeId: 'e1', title: 'Signed offer letter' })
const pdf = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37]) // "%PDF-1.7"
await contracts.blob('c1').put('contract', pdf, { mimeType: 'application/pdf' })
await contracts.elevate('c1', CONFIDENTIAL)
await contracts.get('c1') // → null — record gated
await contracts.blob('c1').get('contract') // → null — the tier-0 blob surface is gated too
The cleared blob(id).atTier() path — the getAtTier() analogue for attachments — sees through the gate; a demote() re-homes the blob back to tier 0, where plain reads work again:
const cleared = await contracts.blob('c1').atTier()
await cleared.get('contract') // → the PDF bytes
await contracts.demote('c1', PUBLIC)
await contracts.blob('c1').get('contract') // → the PDF bytes, plain read restored
Right-to-erasure across tiers
When an employee leaves, vault.forget(subject) crypto-shreds every record of that subject — honoring tier, so the tier-0 record and the elevated salary both go.
await employees.put('e9', { employeeId: 'e9', name: 'Sam Rivera', role: 'Analyst', startDate: '2020-01-01' })
await salaries.putAtTier('e9', { employeeId: 'e9', amount: '99000', notes: 'departing' }, CONFIDENTIAL)
const result = await vault.forget('e9')
result.recordsShredded // → 2 (across both tiers)
result.collections // → includes 'employees' and 'salaries'
await employees.get('e9') // → null
await salaries.getAtTier('e9') // → null
The ForgetResult also carries the tier-specific residue channels — derivedResidueUndecodable and derivedResidueDeclined. They are empty here and populate only when a materialized view spans an elevated record (see erasure residue).
See also
- tiers — the record-tier system this recipe exercises end to end: elevate/demote, tier DEKs, gating semantics, and the threat model
- blobs — tiered attachments and
blobTierPolicy - forget — crypto-shred and how it honors tier
- search — AI — the semantic encoder whose elevated records are skipped, not refused