tiers
Record tiers, not session tiers. This page covers per-record data tiers —
elevate/demote, per-tier DEKs, tier-gated reads. For the session authentication ladder (Tier 1 secret / Tier 2 multi-slot / Tier 3 quick-unlock), see session-tiers.
| Subpath | @noy-db/hub/tiers |
| Factory | withTiers() |
| Layer | with-audit |
| Cluster | G — Collaboration & Auth |
Mental model
Every record in a tiered collection lives at a numeric access tier. Tier 0 is the
default — the floor everyone with the collection DEK can read. A collection opts into higher
tiers by declaring them: vault.collection(name, { tiers: [0, 1, 2] }). Higher tiers see
lower tiers; a lower tier cannot see a record parked above it.
Each tier wraps its own DEK. A record's body is encrypted under a content-encryption key (CEK, see per-record keys); the CEK is AES-KW-wrapped under the tier's DEK, not one flat collection key. So "which tier a record is at" is a fact about which DEK can unwrap its body — a key-holder for tier 0 simply cannot decrypt a record whose CEK is wrapped under the tier-2 DEK.
Tiers compose with per-record keys. Moving a record between tiers (elevate/demote)
re-wraps the same CEK from the source-tier DEK to the target-tier DEK — the body key is
unchanged, so the ciphertext body is not rewritten, only its wrapped-CEK header. This is the
property that makes a tier move cheap and reversible (source of truth:
core/noydb).
Prerequisites
tiersStrategy: withTiers()passed tocreateNoydb— without it every record-tier operation throwsTiersNotEnabledError, and the tier cores stay out of the bundle. Thevault.elevate()handle mint is the one exception: it is always-on core, but writing through the handle still needswithTiers()and a tier-declaring collection.- The collection must declare its tiers —
vault.collection(name, { tiers: [0, 1, 2] }). A collection that never declarestiersis a plain tier-0 collection and pays nothing.
API
Record-tier operations are gated behind tiersStrategy: withTiers(). Without it every
tier-aware call throws TiersNotEnabledError — the machinery stays out of the bundle.
import { createNoydb } from '@noy-db/hub'
import { withTiers } from '@noy-db/hub/tiers'
const db = await createNoydb({
store: /* your store adapter */,
user: 'alice',
tiersStrategy: withTiers(),
})
const vault = await db.openVault('acme')
const docs = vault.collection<Doc>('docs', { tiers: [0, 1, 2] })
Scoped session elevation — vault.elevate()
When a workflow needs a lower-tier session to briefly act at a higher tier — approving a gated
write, running a plaintext export — vault.elevate(tier, { ttlMs, reason }) returns an
ElevatedHandle whose writes land at the elevated tier. Reads on the original vault
stay at the caller's inherent tier; only the returned handle is privileged.
const elevated = await vault.elevate(2, {
ttlMs: 15 * 60_000,
reason: 'plaintext export',
})
// → ElevatedHandle { tier: 2, reason: 'plaintext export', expiresAt: <now + ttlMs> }
await elevated.collection<Doc>('docs').put('d1', record)
await elevated.release() // or let ttlMs elapse
vault.elevate(tier, { ttlMs, reason }): Promise<ElevatedHandle>— the handle mint itself is always-on core; writing through the handle requires the target collection to declare the tier andwithTiers()to be wired.ElevatedHandlecarriestier,reason,expiresAt, exposescollection<T>(name).put(id, record), andrelease(): Promise<void>(idempotent — reverts the elevation and frees the vault's one active-elevation slot).- TTL is enforced lazily. The handle checks
expiresAton every operation, so a forgottenrelease()cannot keep elevated writes alive — the next write after expiry throwsElevationExpiredError. Reads on the original vault are unaffected. - One elevation per vault. A second
vault.elevate(...)while one is live throwsAlreadyElevatedError.release()the current handle first. - Clearance is required. The keyring must already carry a wrap for the target tier on at
least one collection, or the caller must be
owner/admin/custodian(who auto-mint the tier DEK at write time). OtherwiseelevatethrowsTierNotGrantedError. - Not a privilege escalation. The export gates (
vault.assertCanExport('plaintext', fmt)andvault.assertCanExport('bundle')) are not bypassed by elevation — it projects a tier, not a role.
Each elevation writes one audit envelope to the reserved _elevation_audit collection
({ id, actor, tier, reason, ttlMs, startedAt, expiresAt }), and every write through the
handle fires a CrossTierAccessEvent with authorization: 'elevation', stamped with reason.
Subscribe with vault.onCrossTierAccess(listener).
Per-record moves — collection.elevate() / demote()
For moving an individual record's home tier (rather than opening a session handle), the
collection exposes the tier-move primitives — all gated behind withTiers():
collection.elevate(id, toTier): Promise<TierMoveResult>— re-encrypts the record under the target tier's DEK and stamps_elevatedBywith the caller id. The caller must hold DEKs for both the current tier (to decrypt) and the target tier (to re-encrypt).collection.demote(id, toTier): Promise<TierMoveResult>— the reverse. Allowed only for the user who performed the last elevation, or an owner; anyone else getsTierDemoteDeniedError.TierMoveResultcarriessearchResidue: boolean— the record move (indexes, cache, ledger, derived outputs, history, blobs) always completes;searchResidue: truesignals only that the search-index side (_vec/_ftindex) was left needing a retry, mirroringforget()'s residue posture rather than aborting the move.
Cleared reads — getAtTier() / listAtTier()
Tier-0 read surfaces treat an elevated record as absent (see gating). The sanctioned way for a cleared caller to read above their inherent tier:
collection.getAtTier(id): Promise<T | GhostRecord | null>— a fully-cleared read returns the plaintext record and fires a cross-tier event; an uncleared read returnsnull('invisibility'mode, the default) or aGhostRecord { _ghost, _tier, canElevateFrom? }('ghost'mode, selected with the collection'stierModeoption).collection.listAtTier(): Promise<Array<{ id; tier; readable }>>— ids grouped by the caller's readability; above-tier ids are omitted under'invisibility'and included with tier metadata under'ghost'.collection.putAtTier(id, record, tier, opts?): Promise<TierMoveResult>— a direct tiered write. It asserts access to the record's existing tier as well as the target, so a caller cleared only for the target cannot move a record parked at a tier they were never granted (TierNotGrantedError).
Cross-tier delegation — vault.delegate()
Delegation hands another user time-boxed access to a tier without a full grant:
vault.delegate(opts: IssueDelegationOptions): Promise<DelegationToken>— the caller must hold the tier DEK for the requested{ tier, collection? }; it wraps that DEK into the reserved_delegationscollection.IssueDelegationOptionsis{ toUser, tier, collection?, record?, until: Date | string }. A target with no keyring in the vault raisesDelegationTargetMissingError.vault.refreshDelegations(now?: Date): Promise<DelegationToken[]>— the read half. Merges every live delegation addressed to the calling user into their keyring and returns the ones it merged.vault.revokeDelegation(id: string): Promise<void>— revoke by id; safe to call for an id that does not exist. Revocation is the deletion of the_delegations/<id>envelope.
The returned DelegationToken records { id, toUser, fromUser, tier, collection, record?, until, wrappedDek?, wrappedDeks?, sealedCek?, createdAt }. A read or write via an active
delegation fires a CrossTierAccessEvent with authorization: 'delegation'.
refreshDelegations() must be called; opening the vault does not merge anything, and neither
does a read. As of hub 0.9.0 that is deliberate rather than pending: an automatic scan would
call getDEK('_delegations'), which mints a DEK when absent and persists the keyring — so
merely opening a vault would write to it, and on a vault whose shared _delegations DEK the
caller does not hold it would mint a wrong one and then fail to decrypt every token. Call it
after open, and on whatever interval your app wants; it lists first and returns [] when
nothing is written.Per-collection or collection-wide
collection is optional on the way in and string | null on the way out — omit it and the
token covers every collection the grantor held at that tier. The two forms carry different
payloads, which is why both fields are optional:
collection | payload | meaning |
|---|---|---|
| a name | wrappedDek | one wrapped tier DEK |
omitted → null | wrappedDeks | a map, <collection>#<tier> → wrapped DEK, one entry per collection |
⛔ A collection-wide token is a MAP, not a wildcard key. Every collection has its own tier
DEK — docs#1 and ledger#1 are different keys — so one key cannot decrypt many collections.
Each entry merges under its real <collection>#<tier> slot, and the access gate needs no
wildcard case and gains no new privilege surface. (An earlier design tried a single
__any#<tier> slot; it was removed before release because the gate would have said yes and
decryption would then have failed.)
⚠️ "Every collection" means every collection AS OF ISSUE TIME. The map is a snapshot taken
when delegate() ran: a collection created afterwards is not in it and is not granted by it,
because the grantor had no DEK to wrap. If a grantee needs access to collections that do not
exist yet, re-issue — there is no form of this token that widens itself later.
What the grantor needs from the target
Since hub 0.9.0 (core#65): only the target's public half. The tier DEKs are AES-KW-wrapped
under a per-token content key, and that key is RSA-OAEP-sealed to the target's inbox public
half in sealedCek, so a grantor can delegate to a member whose secret it does not know.
Opening one needs the target's inbox private half, which is sealed in their own keyring under
a DEK only their KEK unwraps.
⚠️ A token written before 0.9.0 has no sealedCek and is wrapped under the grantor's own
KEK. Those still load for a target that shares that KEK — the only case that ever worked — and
are skipped for anyone else, exactly as before. So sealedCek being absent identifies an older
token, never a broken one.
⭐ The slot names stay readable to any member holding the _delegations DEK while the keys
do not: an operator can enumerate active delegations for audit without being able to use them.
Read/write gating
Tiers are enforced at two choke points — a read gate that makes above-tier records invisible, and a write ring that refuses tier-0 writes onto elevated records.
Reads are gated at runtime, before any decrypt. On a tiered collection, get(), list(),
query(), and blob(id) treat an elevated record as nonexistent for a lower-tier caller —
the check runs off the record's live _tier envelope header before any key material is
resolved. The tierMode option chooses the shape: 'invisibility' (default) returns
null / omits the id; 'ghost' returns a GhostRecord placeholder that reveals the record
exists and its tier without its contents. The only sanctioned cleared paths are getAtTier(),
listAtTier(), and — for attachments — blob(id).atTier();
an ungranted cleared read throws TierNotGrantedError and mints no key.
The write ring refuses tier-0 writes onto elevated records. On a collection that declares
tiers, a tier-0 put() or delete() targeting a record whose live envelope is elevated
(_tier > 0) throws TierWriteRefusedError — even for a caller who holds the tier's DEK.
The refusal is not about clearance; put()/delete() are simply the wrong API for an elevated
record: a tier-0 put() over one would silently demote it (an elevated record reads as absent,
so put() treats the id as a create), and a tier-0 delete() would write a tier-less marker
that erases the elevation signal. Use putAtTier() / elevate() / demote() — the tier-aware
paths — which are unaffected, as are tier-0 records and any collection that never declares
tiers. onBeforeWrite hooks still fire for a refused put, receiving a null prior for the
elevated record (never its plaintext); beforePut gate handlers do not fire at all — the
refusal precedes the gate bus.
What tiers do and don't protect
Tiers are a confidentiality boundary keyed by DEK, not an access-control policy layer. Honestly, that means:
- What they protect. An elevated record's body, its per-tier CEK, and — on a tiered blob collection — its attachment content, chunk addresses, and slot metadata are all encrypted under the tier DEK. A tier-0 key holder, including one with raw read access to the storage backend, cannot decrypt them at rest. Above-tier records are invisible on tier-0 read surfaces before any decrypt, and the write ring stops a tier-0 caller from silently demoting or erasing them.
- What they don't. Tiers protect record bodies, not the envelope protocol header. The
fields a store, the sync engine, and history read without keys — including
_tierand_elevatedBy— are not encrypted (see forking the enclave): an observer with backend access learns which records are elevated and to what tier, just not their contents. Tier membership therefore leaks record count and tier distribution. Owners, admins, and custodians auto-mint tier DEKs, so tiers are not a defense against a compromised admin keyring. And two documented at-rest residues survive: ablobTierPolicy: 'dedup'shared blob stays decryptable under the flat_blobDEK, and a demoted record's prior history versions remain decryptable at their original tier until the history keys are rewrapped. Tiers are a graded confidentiality tool for one vault's own key-holders — not a substitute for revoking a keyring you no longer trust.
Tiers across the system
Tier behavior is documented in each feature's own home; this table is the map.
| Area | Where | One-line behavior |
|---|---|---|
| Crypto composition | core/noydb | elevate/demote re-wrap the same CEK from source-tier DEK to target-tier DEK; body key unchanged. |
| Tiered blobs | blobs → Tiered blobs | A blob's storage tier follows its owning record; blobTierPolicy: 'isolate' | 'dedup' governs shared content; cleared read via blob(id).atTier(). |
| Search & embeddings | embeddings → rebuildEmbeddings | Elevated records are skipped, not refused — a re-derive counts them in skipped rather than writing a searchable sidecar above tier 0. |
| Erasure residue | glossary/deletion → Erasure residue | forget() of an elevated owner shreds under its pre-tombstone tier; an undecodable MV output row surfaces as derivedResidueUndecodable. |
| Right-to-erasure | forget | Crypto-shred honors tier: an elevated blob-owner shreds under the record's live tier. |
| Policy errors | session-tiers | An insufficient-tier PolicyDeniedError (e.g. from user-envelope's view-team-profiles gate) belongs to the session authentication ladder, not this page — the caller is expected to authenticate further, not elevate a record. |
| Wire fields | architecture → forking the enclave | _tier and _elevatedBy are family-owned protocol-header fields, readable without keys. |
See also
- team — grants and tiers compose: a delegate's tier DEK is scoped by the keyring the team service manages
- Enclave — the per-record CEK and tier-DEK re-wrap this page builds on
- blobs — tiered attachments and
blobTierPolicy - session-tiers — the unrelated session authentication ladder
- Services overview · Orientation map