/** * Hierarchical access — the collection-level tier operations * (`putAtTier` / `getAtTier` / `listAtTier` / `elevate` / `demote`). * * A collection opted into `{ tiers: [...] }` stamps `_tier: N` on each envelope * and encrypts its body under the tier-N DEK. These helpers are the read/write * surface for that scheme: write at a tier, read with tier-aware visibility * (invisibility vs. ghost), and move a record up (`elevate`) or down (`demote`) * the tier ladder by re-wrapping its body key. * * Each function takes a small {@link TiersContext} (the exact `this.*` the * moving methods touched) instead of `this`, mirroring the `record-keys/` * siblings. Behaviour is byte-identical to the inline code it replaced. * * The crux is `cekCache`: `elevate`/`demote`/`getAtTier` write the SAME `Lru` * reference `Collection` owns (never a copy) so a per-record CEK re-wrap stays * synchronous with the cache the kernel's write/read path also mutates. The * cross-tier event sink stays collection-resident and is reached via the * `emitCrossTierEvent` callback. * * Internal service — not exported as a `@noy-db/hub/*` subpath. */ export { withTiers } from './active.js'; export { NO_TIERS, type TiersStrategy } from './strategy.js'; export { TiersNotEnabledError } from '../../kernel/errors.js'; import { type RecordCodec, type EnclaveKey, type SealedShredSlot } from '../../kernel/enclave/index.js'; import type { UnlockedKeyring } from '../../with-party/team/keyring.js'; import type { Lru } from '../../kernel/cache/index.js'; import type { BlobFieldsConfig } from '../../with-shape/blobs/blob-compaction.js'; import { type NoydbStore, type EncryptedEnvelope, type GhostRecord, type TierMode, type CrossTierAccessEvent } from '../../kernel/types.js'; /** Everything the moving tier methods touched on `this.*`, as a flat context. */ export interface TiersContext { /** Collection name — the crypto AAD scope and tier-DEK key prefix. */ readonly name: string; /** Vault namespace the records live under. */ readonly vault: string; /** The ciphertext store. */ readonly adapter: NoydbStore; /** The caller's unlocked keyring (tier-DEK holdings + role + userId). */ readonly keyring: UnlockedKeyring; /** The record codec — decrypts a tier-0 envelope to T. */ readonly codec: RecordCodec; /** * The collection's per-record CEK cache (SHARED reference, not a copy). * `elevate`/`demote`/`getAtTier` re-wrap a record's CEK and `set` it here so * the move stays synchronous with the cache the kernel's read/write path * owns. `null` → no caching. */ readonly cekCache: Lru | null; /** * Sync the collection's decoded-record cache after a tier move rewraps the * envelope (#691). The lazy LRU is evicted on EVERY call — it is never * re-seeded, since a lazy `#getRaw` miss refetches and decodes the fresh * envelope via its adapter fallback (a stale LRU entry after a tier-0 * `putAtTier` overwrite was the #702 lazy-mode gap). `null` → also evict * the eager cache entry; an `entry` → set the eager cache to the given * decoded record/version (used by `demote()` landing back at tier 0 and * `putAtTier`'s tier-0 write, #702 — the record is tier-0, so it must stay * plain-`get()`-readable in-session). */ syncCache(id: string, entry: { record: T; version: number; } | null): void; /** * Sync the collection's indexes after a tier move rewraps the envelope * (#709). Persisted `_idx//` side-cars hold the indexed * field's PLAINTEXT value and are always encrypted under the tier-0 DEK, * whatever the record's own tier — the same leak class `forget()` fixed * via `purgePersistedIndexes` ("forget() crypto-shreds the body but keeps * the collection DEK, under which these side-cars are encrypted — so * without this they leave the indexed field VALUES readable"). `null` → * the record just left tier 0: purge its persisted side-cars and drop its * in-memory index entries. A record → it is tier-0 again: (re)build its * entries from that record. Call this BEFORE {@link syncCache} — the * implementation reads the pre-write cached value as "previous" to clean * up stale index buckets, so it must run while that entry is still live. * `version` stamps a rebuilt side-car's own envelope version — every * caller has one in scope (the envelope it just wrote), including the * purge (`null`) branches, which pass it along even though it's ignored * there (#720: keeps the parameter honestly required, no dead default). * `priorEnvelope` (#720) — the RAW envelope read BEFORE this call's own * overwrite (already in scope as `existing`/`envelope` at every record- * branch call site), so a lazy same-tier value change (a `putAtTier(0)` * dropping an indexed field, a `demote(0)` off an elevated record) can * still be tier-gate-decoded as "previous" once the live envelope itself * has already moved past it. The `null`-record branches never pass one — * see the implementation's doc comment for why they don't need to. */ syncIndexes(id: string, record: T | null, version: number, priorEnvelope?: EncryptedEnvelope): Promise; /** Sync the collection's SEARCH artifacts after a tier move (#721). Both the * lexical `_ftindex` blob and the `_vec/` embedding are encrypted under * the tier-0 DEK and hold the record's derived plaintext (full field text / * a text-invertible vector), so leaving them means elevation never hid what * the record was searchable by — the `forget()` precedent, unapplied to * elevate. `null` → the record left tier 0: purge its `_vec`, and * invalidate the `_ftindex` blob (the cache-driven rebuild then excludes * it). A record → it is tier-0 again: re-embed it, and invalidate * `_ftindex` (rebuild includes it). No-op fast when the collection has no * search. */ syncSearch(id: string, record: T | null, version?: number): Promise; /** * Sync a record's `_history` snapshots after a tier move rewraps the live * envelope (#712, at-rest hardening). Each snapshot carries its own key * material (a direct DEK wrap, or a per-record `_cek` itself wrapped under * the tier DEK) — `elevate()`'s live-body rewrap moves only the live * envelope, leaving prior versions decryptable at rest under the tier the * record has moved away from (the same leak class `forget()`'s * `tombstoneHistory` closed for erasure). `fromDek`/`toDek` are the SAME * DEKs the caller already resolved for the live rewrap — reuse them, don't * recompute. No-op when the strategy is `NO_HISTORY`. */ syncHistory(id: string, fromDek: EnclaveKey, toDek: EnclaveKey): Promise; /** * Save a raw pre-move envelope as a `_history` snapshot (#728). Tier moves * (`putAtTier`/`elevate`/`demote`) bump `_v` and overwrite the live * envelope without ever snapshotting the version that existed just before * the move — so `history()` silently lost it. The caller (`tiers/index.ts`) * builds `envelope` by reusing the SAME `rewrapBodyToDek(envelope, fromDek, * toDek)` result it already computed for the live write, so the snapshot * lands wrapped under the DESTINATION tier's DEK — never `ctx.codec. * encryptRecord`, which always resolves the tier-0 DEK and would leak the * pre-move body whenever `fromTier > 0`. No-op when history is disabled * (folds the `historyConfig.enabled` gate so `tiers/index.ts` stays simple). */ saveHistorySnapshot(id: string, envelope: EncryptedEnvelope): Promise; /** * `true` iff a real history strategy is wired (not `NO_HISTORY`) AND not * explicitly disabled via `historyConfig.enabled`. `putAtTier` checks this * BEFORE decrypting `existing` to build a snapshot (#728, #737 regression * fix) — unlike elevate/demote, which reuse a `body` rewrap their live * write needs unconditionally, putAtTier's live write never decrypts the * prior envelope, so building a snapshot is the ONLY reason it would * touch (and potentially throw decrypting) `existing`'s ciphertext. A * derivation-free, history-disabled collection must not pay that decode — * or risk it, on a corrupted/inaccessible prior body — same law * `hasDerivedOutputs` already enforces for the derived-outputs decode. */ readonly historyEnabled: boolean; /** * Rehome a record's blob attachments after a tier move (#724 Arc 10 Task * 2, at-rest hardening). A blob's home tier is its owning record's tier — * the `_blob` DEK is tier-scoped (`dekKey('_blob', tier)`), so a solo- * owned blob's content CEK must move with the record or it stays * tier-0-unwrappable at rest even once the live body has moved (the same * leak class `syncHistory` closes for `_history` snapshots). Delegates to * `BlobSet.rehomeForTier`; no-op fast when the collection has no blob * fields. Order-independent with the other sync hooks — touches only * blob side structures, not this collection's own cache/indexes. */ syncBlobs(id: string, fromTier: number, toTier: number): Promise; /** * Purge a record's tier-0-era plaintext ledger deltas after a tier move * lands it above tier 0 (#729). The audit ledger is a flat, vault-wide, * hash-chained log — a record's reverse-JSON-Patch deltas in * `_ledger_deltas` are encrypted under one collection-wide `_ledger` DEK * (not the record's own tier DEK), so `elevate()`/`putAtTier()`'s live-body * rewrap never moves them: they stay readable at rest to any tier-0 * caller, the same leak class `syncHistory` closed for `_history` * snapshots. Chain-safe: `verify()` reads only `_ledger` entry fields * (including `deltaHash`, which lives on the entry, not the deleted delta * row), never `_ledger_deltas`, so a purge cannot break it. * **Irreversible** — the deleted plaintext cannot be restored by * `demote()`. **Metadata-retained** — the `_ledger` entries (that the * record was mutated, at which version/timestamp/actor) are untouched; * only the delta *content* is purged. No-op when the collection has no * ledger (`withHistory()` not enabled). */ syncLedger(id: string): Promise; /** * Sync a record's derived outputs (materialized-view rows, rollup * contributions, `withDerivation` outputs) after a tier move (#722). * Those outputs are computed from this record and written to OTHER * (output) collections via a plain `put` at tier 0 — `elevate()`'s live- * body rewrap moves only the source envelope, so the output rows keep * holding the source's tier-0-era plaintext, the same leak class * `syncSearch`/`syncIndexes` closed for THIS collection's own artifacts. * `elevated` (landing tier > 0) reuses the SAME onDelete fanout * `forgetDerivedFanout` drives for `forget()` (`kernel/via/dispatch.ts`) * — minus its `'ref'` cascade edge (elevate/demote never erase a * *different* record) — to recompute this record's derived outputs as * REMOVED. Recompute is tier-safe: the fanout's own source scan reads * the elevated-excluding cache (#701/#709/#712), so it naturally omits * the now-elevated record instead of re-embedding its plaintext. Landing * back at tier 0 (`elevated === false`) is the reverse (Task 2, #722): * `syncDerivedOutputs` instead runs the ordinary local-write add- * dispatchers (`dispatchDerivations`/`dispatchMaterializedViews`, the * same ones a plain `put()` fires) to restore the contribution — * reversible, since the source's plaintext survives the elevate/demote * rewrap round-trip. `record` is the decoded source record (PRE-move on * the remove side, POST-move on the add side); only the rollup edge * consumes it on remove, both add-dispatchers require it on add; `null` * (tombstone/delete-marker) skips both. `version` stamps the add- * dispatchers' `_derivedFrom.sourceVersion` metadata — reuses the version * the tier op already has, no re-read; unused on the remove side. No-op * fast when the collection has no MV/derivation source — each dispatcher * below checks its own source before doing any work. */ syncDerived(id: string, record: T | null, elevated: boolean, version?: number): Promise; /** * `true` iff the collection has a materialized-view or derivation source * attached. Gates the {@link syncDerived} pre-move decode on the remove * direction (`elevate()`, `putAtTier(tier>0)`, `demote()`'s intermediate- * tier branch): those sites decode the record SOLELY to feed * `syncDerived(..., true)`, whose dispatchers no-op immediately when the * collection has no MV/derivation source — so a no-derivation collection * would otherwise pay a full record-body decrypt on every tier move for * nothing (#722 perf regression). `false` short-circuits the decode to * `null`, which `syncDerivedOutputs` already treats safely. */ readonly hasDerivedOutputs: boolean; /** Emit `_source`/`_sourceTs` provenance fields when a source is supplied. */ readonly provenance: boolean; /** Declared tiers, or null when the feature is off. */ readonly tiers: ReadonlySet | null; /** Above-tier read visibility mode (`'invisibility'` | `'ghost'`). */ readonly tierMode: TierMode; /** Resolve a tier DEK by its `dekKey(name, tier)`. */ getDEK(key: string): Promise; /** Fire a cross-tier access event (sink stays collection-resident). */ emitCrossTierEvent(event: CrossTierAccessEvent): void; /** * Register this record's ref in the encrypted `_subject_index` (#766). * `putAtTier`'s raw `ctx.adapter.put` bypasses `Collection.put()`'s write- * hook pipeline — the `onAfterWrite` hook that normally does this — so a * record whose FIRST persistence is `putAtTier` (sensitive from birth, a * documented legitimate use) would otherwise never enter the index and * stay unreachable by `vault.forget()`. Idempotent (safe on every call, * including a repeat write of an already-registered record) and a no-op * when the collection declares no forget-subject field. */ addSubjectRef(id: string, record: T): Promise; } export declare function assertTiersEnabled(ctx: TiersContext): void; export declare function assertDeclaredTier(ctx: TiersContext, tier: number): void; /** * Tier-composition guard (#724 / Arc 7 of the tier-invisibility campaign). * * Refuses `tiers` declared together with a derived-artifact feature whose * crypto has not yet been made tier-aware — i.e. a feature `elevate()` / * `demote()` does not re-key when a record moves tiers, so an elevated * record's data for that feature would stay readable at tier 0. * * **Status:** the original call site (`Collection` constructor, beside * `buildUniqueConstraintSet`) was removed by Arc 10 Task 1 (#724) — the * `tiers + blobFields` refusal below was superseded by a runtime read gate * on `collection.blob(id)` (`with-shape/blobs/blob-set.ts`), so this * function currently has no caller. Relocated here from * `with-lookup/indexing/unique-constraints.ts` (#733) so the tier-domain * guard lives in the tier domain, rather than an indexing file it only * borrowed for a grandfathered import specifier. * * ## #724 verified: `tiers + blobFields` leaks * * `collection.blob(id)` (`Collection.blob()`) never checks the live * record's tier before returning a `BlobSet` handle, and `BlobSet`'s crypto * is entirely orthogonal to the tier ladder: * - the slot map (`_blob_slots_{collection}/{id}`) is encrypted under the * collection's TIER-0 DEK (`getDEK(name)` ≡ `dekKey(name, 0)` — see * `dekKey` in `with-party/team/tiers.ts`), never a tier-N DEK; * - chunk content (`_blob_index` / `_blob_chunks`) is encrypted under the * vault-shared `_blob` DEK (`BLOB_COLLECTION`), which has no tier * dimension at all. * `elevate()`/`demote()` (this file) rewrap only the live record body, * `_history` snapshots (#712), and index side-cars — blob slots and chunks * are untouched. A caller whose keyring never held the record's elevated * tier DEK can still open `collection.blob(id).get(slot)` and read full * plaintext, even though `collection.get(id)` correctly reports the same * record as invisible. See `__tests__/tier-composition-guard.test.ts` for * the reproduction. * * ## Safe today — no check needed here * * Field indexes, search (`textIndexes`/`embeddings`), `withHistory` * (snapshot keys are rewrapped by `elevate()`/`demote()`, #712), and the * decoded-record cache (evicted on tier moves) are already tier-safe. They * are not enumerated below; only known-unsafe features are refused, so any * combination not named here passes silently by default. * * ## Deliberately NOT handled here * * - `ledger` — `withHistory` threads a ledger writer onto every tiered * collection by default in shipped usage; refusing `tiers + ledger` would * break that default. A ledger-specific tier-rekey handler (rewrap ledger * entries, or scope ledger visibility to tier) is a separate arc. * - materialized-view (MV) output — an MV's fanout spec lives on the * SOURCE collection, not on the MV collection's own config, so it is not * visible at `vault.collection()` registration time and this guard cannot * detect it structurally. */ export declare function assertTierComposition(collectionName: string, cfg: { readonly tiers: boolean; readonly blobFields: BlobFieldsConfig | undefined; }): void; /** * Structural surface {@link syncDerivedOutputs} needs from the owning * collection — the SAME onDelete dispatchers `forgetDerivedFanout` already * drives (`kernel/via/dispatch.ts`), reused as-is (#722). Narrower than * `Collection` (avoids an import cycle: collection.ts constructs * `TiersContext` FROM `this`, which already satisfies this shape * structurally — no cast needed at the `tiersContext()` call site). */ export interface DerivedOutputsHost { dispatchMaterializedViewsOnDelete(id: string): Promise<{ deleted: number; residueUndecodable: string[]; residueDeclined: string[]; }>; dispatchArrayDerivationsOnDelete(id: string, eraseRecordShapeToo?: boolean): Promise; dispatchRollupsOnDelete(id: string, deleted: T): Promise; /** Task 2 (#722) add-direction: the same local-write dispatchers an ordinary `put()` fires. */ dispatchMaterializedViews(id: string, record: T): Promise; dispatchDerivations(id: string, record: T, version: number): Promise; } /** * The {@link TiersContext.syncDerived} implementation collection.ts's * `tiersContext()` binds `this` into (#722). See that field's doc comment * for the design; `host` is `this` — every dispatcher below is a public * Collection method, already self-guarding (no-op) when the collection has * no materialized-view/derivation source, so no separate fast-path check is * needed here. */ export declare function syncDerivedOutputs(host: DerivedOutputsHost, id: string, record: T | null, elevated: boolean, version?: number): Promise; /** * tier-aware put. Encrypts the record with the collection's tier-N DEK and * stamps `_tier: N` on the envelope. The caller's keyring must hold the tier-N * DEK (directly, by delegation, or by virtue of being the grantor); otherwise * throws `TierNotGrantedError`. * * accepts an optional `elevation` context. When present, the emitted cross-tier * event is stamped with `authorization: 'elevation'`, the elevation's reason, * and the caller's pre-elevation tier. `vault.elevate(...).collection().put` * threads this through; direct `putAtTier` calls leave it undefined and fall * back to the inherent-write event shape. */ export declare function putAtTier(ctx: TiersContext, id: string, record: T, tier: number, opts?: { elevation?: { reason: string; fromTier: number; }; source?: string; sourceTs?: string; }): Promise; /** * tier-aware get. When the stored record is at a tier the caller cannot * decrypt: * - `'invisibility'` mode (default) → returns `null`. * - `'ghost'` mode → returns a `GhostRecord` placeholder with the tier and * the record id (the record exists but contents are withheld). * * Fully-cleared reads return the plaintext record and fire a cross-tier event * when `_tier > 0`. */ export declare function getAtTier(ctx: TiersContext, id: string): Promise; /** * list ids grouped by the caller's readability. Returns only ids whose tier the * caller can read. Above-tier ids are omitted in `'invisibility'` mode and * included (with tier metadata) in `'ghost'` mode. */ export declare function listAtTier(ctx: TiersContext): Promise>; /** * Result of a completed `elevate()`/`demote()` (#764). The record move * (put + indexes/cache/ledger/derived/history/blobs sync) always completes — * `searchResidue: true` means only the search-index side (`_vec`/`_ftindex`) * was left in a needs-retry state (a stuck `PersistedIndexCompensationError`), * mirroring `forget()`'s `indexResidue` posture rather than aborting the move. */ export interface TierMoveResult { readonly searchResidue: boolean; } /** * elevate a record to a higher tier. Re-encrypts with the target tier's DEK. * The caller must hold DEKs for both the current tier (to decrypt) and the * target tier (to re-encrypt). Stamps `_elevatedBy` with the caller id so * `demote()` can check the reverse operation. */ export declare function elevate(ctx: TiersContext, id: string, toTier: number): Promise; /** * demote a record to a lower tier. Allowed only for the user who performed the * last elevation or an owner. */ export declare function demote(ctx: TiersContext, id: string, toTier: number): Promise; /** * Classify a live envelope's `_sealed` slots for crypto-shred completeness * (#M-1). Thin delegate to {@link RecordCodec.classifySealedShred}; surfaced * here because `vault.ts` forget() reaches in via `collection._classifySealedShred`. */ export declare function classifySealedShred(ctx: TiersContext, live: EncryptedEnvelope): Promise<{ readonly slots: readonly SealedShredSlot[]; }>;