stablesince 0.1.0-pre.3

tiers

Record tiers — scoped elevation with TTL + reason, per-tier DEKs, tier-gated reads, and how blobs, search, and forget behave across 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
FactorywithTiers()
Layerwith-audit
ClusterG — 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 to createNoydb — without it every record-tier operation throws TiersNotEnabledError, and the tier cores stay out of the bundle. The vault.elevate() handle mint is the one exception: it is always-on core, but writing through the handle still needs withTiers() and a tier-declaring collection.
  • The collection must declare its tiers — vault.collection(name, { tiers: [0, 1, 2] }). A collection that never declares tiers is 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 and withTiers() to be wired.
  • ElevatedHandle carries tier, reason, expiresAt, exposes collection<T>(name).put(id, record), and release(): Promise<void> (idempotent — reverts the elevation and frees the vault's one active-elevation slot).
  • TTL is enforced lazily. The handle checks expiresAt on every operation, so a forgotten release() cannot keep elevated writes alive — the next write after expiry throws ElevationExpiredError. Reads on the original vault are unaffected.
  • One elevation per vault. A second vault.elevate(...) while one is live throws AlreadyElevatedError. 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). Otherwise elevate throws TierNotGrantedError.
  • Not a privilege escalation. The export gates (vault.assertCanExport('plaintext', fmt) and vault.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 _elevatedBy with 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 gets TierDemoteDeniedError.
  • TierMoveResult carries searchResidue: boolean — the record move (indexes, cache, ledger, derived outputs, history, blobs) always completes; searchResidue: true signals only that the search-index side (_vec/_ftindex) was left needing a retry, mirroring forget()'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 returns null ('invisibility' mode, the default) or a GhostRecord { _ghost, _tier, canElevateFrom? } ('ghost' mode, selected with the collection's tierMode option).
  • 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 _delegations collection. IssueDelegationOptions is { toUser, tier, collection?, record?, until: Date | string }. A target with no keyring in the vault raises DelegationTargetMissingError.
  • 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'.

Merging is EXPLICIT — it does not happen on open.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:

collectionpayloadmeaning
a namewrappedDekone wrapped tier DEK
omitted → nullwrappedDeksa 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 _tier and _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: a blobTierPolicy: 'dedup' shared blob stays decryptable under the flat _blob DEK, 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.

AreaWhereOne-line behavior
Crypto compositioncore/noydbelevate/demote re-wrap the same CEK from source-tier DEK to target-tier DEK; body key unchanged.
Tiered blobsblobs → Tiered blobsA blob's storage tier follows its owning record; blobTierPolicy: 'isolate' | 'dedup' governs shared content; cleared read via blob(id).atTier().
Search & embeddingsembeddings → rebuildEmbeddingsElevated records are skipped, not refused — a re-derive counts them in skipped rather than writing a searchable sidecar above tier 0.
Erasure residueglossary/deletion → Erasure residueforget() of an elevated owner shreds under its pre-tombstone tier; an undecodable MV output row surfaces as derivedResidueUndecodable.
Right-to-erasureforgetCrypto-shred honors tier: an elevated blob-owner shreds under the record's live tier.
Policy errorssession-tiersAn 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 fieldsarchitecture → 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