stable

Confidential HR records

An HR vault where salary, notes, and contracts sit one record tier above the public basics — gated reads, TTL elevation, delegation, and tiered blobs.

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 declares tiers is 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-admin here) auto-mints tier DEKs at write time. A lower-tier session or a delegate is what makes the gate observable.

What this gets you

CapabilityProvided by
Salary parked above the public floor, invisible to a below-tier readwithTiers() + putAtTier() / the collection's tiers: [0, 1]
A cleared read that decrypts above your inherent tiergetAtTier()
A time-boxed, audited session that writes at an elevated tiervault.elevate(tier, { ttlMs, reason })
Handing a reviewer scoped, expiring access without a full grantvault.delegate() (needs withTeam() for the reviewer's keyring)
Semantic search that skips — never refuses — elevated recordswithSearch() + a collection embeddings encoder
A contract PDF whose storage tier follows its owning recordwithBlobs() + blobTierPolicy + blob(id).atTier()
Right-to-erasure that crypto-shreds across every tierwithForget() (+ 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