import type { NoydbStore, BlobObject, SlotInfo, VersionRecord, BlobPutOptions, BlobResponseOptions } from '../../kernel/types.js'; import type { ObjectProjection } from './object-projection.js'; import type { BlobFieldsConfig } from './blob-compaction.js'; import { type EnclaveKey } from '../../kernel/enclave/index.js'; import type { UnlockedKeyring } from '../../with-party/team/keyring.js'; /** * DEK slot name for vault-shared blob data. Calling `getDEK('_blob')` * auto-creates a blob DEK the first time — same lazy-creation mechanism * used for any user-defined collection. */ export declare const BLOB_COLLECTION = "_blob"; /** Stores `BlobObject` metadata envelopes, keyed by eTag. */ export declare const BLOB_INDEX_COLLECTION = "_blob_index"; /** * Stores encrypted chunk envelopes, keyed by `{eTag}/{chunkIndex}`. * NOT loaded into the in-memory query layer. Fetched on demand by * `BlobSet.get()` / `BlobSet.response()`. */ export declare const BLOB_CHUNKS_COLLECTION = "_blob_chunks"; /** Prefix for per-collection slot metadata collections. */ export declare const BLOB_SLOTS_PREFIX = "_blob_slots_"; /** Prefix for per-collection version records. */ export declare const BLOB_VERSIONS_PREFIX = "_blob_versions_"; /** * Default chunk size: 256 KB raw bytes. * After AES-GCM (same size) + base64 (~33% inflation) → ~342 KB per * envelope, safely within DynamoDB's 400 KB item limit. */ export declare const DEFAULT_CHUNK_SIZE: number; /** * Handle for reading, writing, versioning, and deleting binary blobs * on a specific record. * * Obtained via `collection.blob(id)`. No I/O is performed until you * call a method. * * ## Storage layout * * ``` * _blob_index/{eTag} BlobObject metadata (vault-shared DEK) * _blob_chunks/{eTag}/{chunkIndex} Encrypted chunk data (vault-shared DEK + AAD) * _blob_slots_{collection}/{recordId} Slot map (parent collection DEK) * _blob_versions_{collection}/{recordId}/{slot}/{label} Published versions (parent collection DEK) * ``` * * ## Deduplication * * `put()` computes `eTag = HMAC-SHA-256(blobDEK, plaintext)` — keyed so the * store cannot predict eTags for known content. If another record already * uploaded the same bytes, the chunks are reused and `refCount` is incremented. * * ## Chunk integrity * * Each chunk is encrypted with AES-256-GCM using AAD = `{eTag}:{index}:{count}`, * preventing chunk reorder, substitution, and truncation attacks. */ export declare class BlobSet { private readonly store; private readonly vault; private readonly collection; private readonly recordId; private readonly getDEK; private readonly encrypted; private readonly userId; private readonly maxBlobBytes; private readonly erasableBlobs; private readonly tiersActive; private readonly debugPlaintext; private readonly objectStore; private readonly blobFields; private readonly keyring; /** * Set only on the cleared clone `atTier()` returns (#749) — never by * `openSlot`/`Collection.blob()`. Its presence is what makes a `BlobSet` * a cleared view: see `ownerRecordElevated()`/`ownerTier()`. */ private readonly clearedTier; constructor(opts: { store: NoydbStore; vault: string; collection: string; recordId: string; getDEK: (name: string) => Promise; encrypted: boolean; userId?: string; maxBlobBytes?: number; erasableBlobs?: boolean; tiersActive?: boolean; debugPlaintext?: boolean; objectStore?: ObjectProjection; blobFields?: BlobFieldsConfig; keyring?: UnlockedKeyring; clearedTier?: number; }); /** * #724 I1 completion: a legacy (non-`perRecordKeys`) blob has no per-blob * `_cek`, so `rehomeForTier` can never tier-isolate it on elevate/demote — * it stays decryptable under the flat tier-0 `_blob` DEK at rest even * after the owning record is elevated. The construction-time mandate * (`resolveCollectionConfig`) only catches this when `blobFields` is * DECLARED; a collection with an undeclared field (or no `blobFields` at * all) constructs fine and reaches this write path unguarded. Refuse the * write itself instead of broadening the construction mandate, which * would over-refuse blobless tiered collections (`blobStrategy` is * vault-wide, not per-collection). Called from every content-write entry * (`put()`, `publish()`) — never from a read path, so a pre-existing * legacy blob stays readable (back-compat). */ private assertBlobWritable; /** * #748: `adoptExternal()`/`setExternalMeta()` write an `external` slot * reference directly — unlike `put()`, which only takes the external path * when `blobFields[slotName].external` is declared (the construction-time * tiers×blobFields mandate, `collection-config.ts`, then applies). Without * this gate, either method can attach an external reference to a slot the * collection never declared `external` — bypassing that mandate on a tiered * collection. Mirrors `put()`'s own gate. */ private assertExternalDeclared; /** * Resolve the key the blob's CHUNKS are encrypted under. * * - `_cek` present (erasable blob) → unwrap the per-blob content CEK under * `blobDEK`. Deleting the BlobObject (at `refCount → 0`) makes this * key unrecoverable → the chunks are crypto-shredded. * - `_cek` absent (legacy) → `blobDEK` encrypts chunks directly. * - unencrypted vault → `null` (chunks stored as plaintext base64). * * `blobDEK` defaults to this record's own (tier-0) `_blob` DEK — every * pre-#724-T3 call site. `rehomeForTier`'s isolate-fork path (#724 Arc 10 * Task 3) passes the `fromTier`-scoped DEK explicitly to read a shared * blob's plaintext under the key it's CURRENTLY wrapped under, reusing * this one `_cek` unwrap site rather than adding a new raw access. */ private resolveChunkKey; /** * #724 Arc 10 Task 1: the same "elevated ≡ invisible" clearance check * `collection.get()` applies to the owning record (`liveRecordIsElevated`, * #701/#707/#709/#712) — reused here, not reinvented, so a blob content * read on an elevated record is refused exactly like a `get()` on it * would be. Reads only the owning record's `_tier` envelope metadata; * runs before any blob decrypt. * * #749: a cleared clone (`this.clearedTier !== undefined`, produced only by * `atTier()`) short-circuits to `false` — its whole purpose is to see past * this gate, having already paid the `assertTierAccess` cost `atTier()` * charged to get here. No live envelope peek needed on that path. */ private ownerRecordElevated; /** * #724 Arc 10 Task 4: the tier this record's LIVE envelope currently * carries — `loadSlots`/`saveSlots` default to resolving the slot map's * collection DEK here, so the slot map (filenames/eTags/mimeTypes) is * read/written wherever a prior `rehomeForTier` call physically re-keyed * it to. `rehomeForTier` itself passes an EXPLICIT tier instead of * relying on this default (see its own doc comment): by the time it * runs, the owning record's live `_tier` has ALREADY moved to `toTier` * (the collection-level `elevate`/`demote`/`putAtTier` writes the record * before invoking `syncBlobs`), while the slot map is still physically at * `fromTier` until `rehomeForTier`'s own move step lands — so this peek * would resolve the wrong DEK mid-move. * * #749: a cleared clone reports its fixed `clearedTier` instead of * re-peeking — a stable view for the caller's whole session with it. A * concurrent demote/elevate on the underlying record is invisible to an * already-cleared handle; call `atTier()` again to see it. This is not a * silent-corruption risk: a WRITE through a stale clone after a concurrent * move fails loud, not quiet — the slot map/index envelope has already * been physically re-keyed to the record's new tier by `rehomeForTier`, so * the stale clone's `clearedTier`-pinned DEK can no longer open it and the * write throws an AEAD decrypt error rather than silently writing under * the wrong key. */ private ownerTier; /** * The sanctioned cleared-read path to an elevated record's blobs (#749). * * The law: `blob(id)` is the tier-0 surface — `get()`/`list()`/`blobInfo()`/ * etc. all hide an elevated record's blobs from EVERYONE, unconditionally * (`ownerRecordElevated()`, #724 Task 1), with no cleared path of their own. * `atTier()` is that cleared path, the `getAtTier()` analogue for blobs: * live-peeks the owning record's tier, and for `tier > 0` runs * `assertTierAccess` — the SAME gate `putAtTier`/`elevate`/`demote` run * before ever touching a tier DEK — then returns a NEW `BlobSet` bound to * that tier. Every subsequent call on the returned handle (`get()`, * `list()`, `publish()`, …) sees through the gate as if the record were at * tier 0. * * `assertTierAccess` — not a bare `getDEK` call — IS the authorization * check: owner/admin/custodian bypass it (their on-demand tier-DEK mint is * sanctioned), everyone else must already hold the tier DEK (via a prior * grant or delegation) or it throws `TierNotGrantedError`. A bare `getDEK` * would not gate anything here — it silently mints a fresh DEK for ANY * caller when one is missing, which is exactly the "non-cleared caller * creating tier key material inside the trust boundary" hazard * `assertTierAccess`'s own doc comment (`with-party/team/tiers.ts`) warns * against; running it BEFORE any `getDEK` call is what keeps an ungranted * member's failed `atTier()` call from minting junk key material into * their keyring as a side effect. No tier DEK is resolved here at all — * every actual read still resolves its DEK lazily, same as today. * * `this.keyring` is undefined only on a construction path that never * threads one through `openSlot` (`Collection.blob()` always does) — that * can't prove clearance for anyone, so it throws the same * `TierNotGrantedError` an ungranted caller would get. */ atTier(): Promise; /** The internal collection that holds slot metadata for this collection's blobs. */ private get slotsCollection(); /** The internal collection that holds published versions for this collection's blobs. */ private get versionsCollection(); /** * @param tier #724 Arc 10 Task 4: explicit tier to resolve the slot map's * collection DEK at — `getDEK(dekKey(this.collection, tier))`. Omitted → * resolves via `ownerTier()` (the record's CURRENT live tier), correct * for every call site except `rehomeForTier`'s own internal reads, which * must pin `fromTier` explicitly (see `ownerTier()`'s doc comment). */ private loadSlots; /** * Resumable slot-map read for `runRehomeSteps` (#746 spec §7 §2d bullet * 1): try `fromTier` (the map's pre-move physical location) first; on a * decrypt failure (wrong key, not "missing" — mirrors `loadBlobObject`'s * own TamperedError-only fallback trigger) retry `toTier` — the signal * that a prior (crashed) run's own move step already landed. `atTier` * reports which one opened it. A missing envelope reports `atTier: * fromTier` (arbitrary — the `Object.keys(slots).length > 0` gate the * caller applies makes the value moot for an empty map). */ private loadSlotsTolerant; /** @param tier See {@link loadSlots}'s `tier` param — same resolution. */ private saveSlots; /** * CAS retry loop for slot metadata updates. Re-reads slots on conflict * and re-applies the mutation function. * * #724 re-review (High): when the mutation empties the slot map (the * last slot was deleted), the `_blob_slots_{collection}/{recordId}` row * is DELETED rather than saved as an empty-but-present envelope. An * empty envelope left in place would still be tier-keyed at whatever * tier it was last written at; `rehomeForTier`'s `slots exist` guard * (`Object.keys(slots).length > 0`) would then skip re-keying it on a * later elevate/demote, stranding it at the wrong tier's DEK — * `TamperedError` on every later access. Deleting the row instead makes * "no slots" and "no envelope" the SAME state, so a record that once * had a blob and a record that never had one behave identically once * empty. * * @param tier See {@link loadSlots}'s `tier` param — threaded through so * `rehomeForTier`'s isolate-fork writes (via `putUnderDEK`) target the * slot map's CURRENT (fromTier) location during the move. */ private casUpdateSlots; /** * Clear a slot's `pendingRelease` breadcrumb once its release has landed * (#746 spec §7 review, carried finding (b)) — a no-op CAS if the field is * already gone (idempotent, safe to call again on resume) or no longer * matches `expected` (a NEWER put() on this slot already moved past it). */ private clearPendingRelease; /** * `runRehomeSteps`'s FIRST step (#746 spec §7 review, carried finding * (b)) — reconcile any `pendingRelease` breadcrumb left on `slots` by a * PRIOR (crashed) run of THIS SAME marker-governed rehome (`opId` * identifies it). `putUnderDEK`'s slot-CAS (pointing the slot at its new * eTag) and its old-eTag release are two separate writes; a crash between * them strands the release — the slot map has already moved past the old * eTag, so a plain re-derivation of "which eTags need releasing" from the * CURRENT slot map can never find it again (a permanent leak, not merely * a delayed one). Durably recording the old eTag ON the slot record * itself (in the SAME CAS write that moves it) survives exactly the crash * window that loses an in-memory breadcrumb, and this pass is what * consumes it on the next attempt — completing the release (idempotent * via the SAME row-scoped stamp `putUnderDEK` used) and clearing the * field. C10: the release is NOT swallowed — a failure here must keep the * marker alive (propagates), not silently strand it a second way. */ private reconcilePendingReleases; /** * #747: the `_blob_index` envelope follows the eTag's HOME tier — the * same tier the `_cek` payload wrapped inside it has been scoped to since * #724. That fix only tier-scoped the WRAPPED content CEK; the outer * index envelope itself stayed flat, so a tier-0 DEK holder could still * read an elevated record's blob metadata (size/mimeType/compression/ * chunkCount/refCount/createdAt) straight off `_blob_index` at rest. * * `tier` defaults to `ownerTier()`, mirroring `loadSlots`. Two-tier mode * (#753 spec §7 C7 / §2d): try `tier`'s DEK first; on a decrypt failure * (wrong key — AES-GCM auth fails, not "missing") retry under * `alsoTryTier`'s DEK. `atTier` reports which key actually opened it, so a * caller mutating the object (`casUpdateRefCount`) can write it back * under that SAME key — never lifting a flat dedup/legacy object onto the * owner's tier (or vice versa) as a side effect of a refCount change. * * `alsoTryTier` defaults to `0` when `tier` resolves `> 0` and no explicit * value is given — this is #747's original tier-then-flat fallback, * preserved byte-identical for every pre-#753 call site (none of which * pass `alsoTryTier`). It covers the two legitimate classes that stay * flat even for an elevated owner: a `dedup`-policy shared object (#741, * left in place on purpose) and a legacy `_cek`-less object (chunks * direct under the flat DEK, never tier-isolated — #724 I1). When `tier` * resolves to `0` and no `alsoTryTier` is given, there is no fallback * attempt at all (matches pre-#753 behavior exactly) — but a `tier === 0` * caller MAY now pass an elevated `alsoTryTier` explicitly (the rehome * resume path, §2d: "try `fromTier`, fall back to `toTier`", either of * which may be 0). Passing `alsoTryTier === tier` is a no-op fallback (no * second attempt — nothing new to try). * * No migration/compat path: the whole tiers×blobs arc is unpublished, so * there is no previously-published elevated blob whose index envelope is * stuck flat at rest — every elevated write from here on is born * tier-keyed. */ private loadBlobObject; /** * `runRehomeSteps`'s per-eTag resume tolerance (#746 spec §7 §2d bullet 2) * — `loadBlobObject(eTag, fromTier, toTier)`, `alsoTryTier`'s first real * caller: `atTier === toTier` on return means this eTag's object already * opens under the DESTINATION tier — either a prior (crashed) run's own * re-`put()` already landed for it (skip: already moved), or it's a * `dedup`-policy/legacy object that happens to sit flat at tier 0 and * `toTier === 0` (skip either way — see the call site's doc comment). * * When `toTier !== 0`, that flat-at-0 legacy/dedup case is NOT covered by * the two tiers already tried — `loadBlobObject`'s own default fallback * (implicit `alsoTryTier: 0` when the primary tier is `> 0`) is exactly * what closes it for a non-resumable caller, but passing `toTier` * explicitly here overrides that default. So on a `TamperedError` from * both `fromTier` and `toTier`, retry once more at flat `0` — the SAME * fallback `loadBlobObject`'s own default already provides, just chained * behind the resume signal instead of replacing it. */ private loadBlobObjectResumable; /** * @param tier See {@link loadBlobObject}'s `tier` param — same resolution. * A caller mutating an EXISTING object must pass the tier that OPENED it * (`loadBlobObject`'s `atTier`), never its own ask — see * `casUpdateRefCount`. */ private writeBlobObject; /** * CAS retry loop for refCount changes on a BlobObject. * * @param tier Resolves the READ side (see {@link loadBlobObject}). The * WRITE-back always uses `atTier` — the tier that actually opened the * object — never `tier` itself: a dedup-shared or legacy object left flat * on purpose must stay flat even when asked for under an elevated * owner's tier, or a refCount change would silently lift it there (#747). */ private casUpdateRefCount; /** * Stamp-aware CAS retry loop for refCount changes (#753 spec §7 C2/C4) — * the journal primitive `casUpdateRefCount` itself is deliberately left * untouched (every existing call site passes no stamp and must stay * byte-identical). A distinct method rather than an optional param on * `casUpdateRefCount` keeps the return shape type-clean: this one reports * whether ITS delta was newly applied, not just the resulting count. * * C4: the membership check lives INSIDE the retry loop — every (re)read, * including retries after a losing CAS attempt, checks `blob.lastOps` for * `stamp` FIRST, before computing a delta. This is what makes it a true * test-and-set rather than a check-then-act with a TOCTOU window: two * concurrent callers racing the SAME stamp can each land on any attempt, * but only the one that reads `lastOps` without the stamp present ever * applies the delta — the other, whichever attempt it lands on, always * observes the stamp (either already there, or freshly appended by the * winner) and skips. * * On apply: the stamp is appended to the bounded ring (`appendStamp`, * K=8) in the SAME `writeBlobObject` CAS write as the delta — no window * between them (spec C2's atomicity requirement). * * @returns `{ applied: true, refCount }` when this call's delta was freshly * applied (or `{ applied: false, refCount }` when `stamp` was already * present — the delta from a PRIOR call with this exact stamp already * landed; `refCount` reflects the object's current count, unchanged by * this call). * @param tier See {@link loadBlobObject}'s `tier` param — same resolution * and the same atTier write-back rule as `casUpdateRefCount`. */ private casUpdateRefCountStamped; /** * Release `n` references to a blob and reclaim it at refCount 0. * * The single reclaim choke point for every reference-drop path — slot * delete/overwrite, published-version delete, and `forget()` shred — so the * refCount-0 policy is uniform: * - **erasable blob** (`_cek` present) → delete the `BlobObject` (the SOLE * copy of the wrapped content CEK → chunks permanently undecryptable) and * reclaim the chunks. The crypto-shred is EAGER on every path: GDPR erasure * must not wait on orphan retention. * - **legacy blob** (no `_cek`) → reclaimed only when `reclaimLegacy` (the * `forget()` erasure path); otherwise left for deferred GC so the existing * `BlobLifecyclePolicy.orphanRetentionDays` semantics are preserved. * * @returns `'shredded'` (erasable, refCount 0, chunks dead) · `'retainedShared'` * (erasable, still referenced) · `'residue'` (legacy — not a crypto-shred). * * @param tier See {@link loadBlobObject}'s `tier` param — forwarded to both * the read and the refCount write-back (which resolves its own `atTier` * from the read, per `casUpdateRefCount`'s doc comment). * * @param stamp #753 spec §7 C1 journal primitive: when present, this call * is a (possibly-resumed) journaled release and the CAS goes through * {@link casUpdateRefCountStamped} instead, under the two-armed resume * rule: * - `lastOps` already has `stamp` && `refCount > 0` → this stamp's * decrement already landed and the object is still referenced elsewhere * — skip re-decrementing, report the same outcome a fresh call would * (`retainedShared`/`residue`). * - `lastOps` already has `stamp` && `refCount <= 0` → this stamp's * decrement already landed, but the crash window sat BETWEEN the * decrement CAS and the index+chunk deletion below — COMPLETE that * deletion now (idempotent: both `store.delete` calls are void on an * already-absent key). * - not yet stamped → apply via `casUpdateRefCountStamped`, then proceed * exactly like the unstamped path below. * * A crash can also land AFTER the index row is deleted but BEFORE every * chunk is — `loadBlobObject` then returns null (indistinguishable from * "never existed") and the object's `chunkCount` is gone with it. A * stamped caller who knows the true count (the journal marker's captured * `holds`, once that plumbing lands) supplies it via `chunkCountHint` so * this call can still finish the chunk deletion; without it, an * already-index-gone eTag is reported `'shredded'` same as today (best * effort — nothing left to key the cleanup off of). */ private releaseRef; /** * Crypto-shred this record's blob attachments — called by * `vault.forget()`. * * For each distinct eTag the record references (a record may attach the same * content under several slot names → several refCount holds): decrement the * blob's refCount by that many. When it reaches 0: * - **erasable blob** (`_cek` present) → delete the `BlobObject` (the SOLE * recoverable copy of the wrapped content CEK → chunks permanently * undecryptable) and reclaim the chunk bytes. This is the crypto-shred. * - **legacy blob** (no `_cek`) → chunks are under the shared `_blob` DEK and * stay decryptable until byte-deleted; we delete the orphaned chunks + * index but report it as residue, not a cryptographic erasure. * When refCount stays > 0 the content legitimately persists for its other * owner — reported as `retainedShared` (or `residue` if legacy). * * Finally drops the record's slot map, severing the subject's link. * * @param ownerTier #724 Arc 10 C3: `vault.forget()` writes the record's * tombstone (which drops `_tier`) BEFORE calling this method, so the * `ownerTier()` default — a fresh peek at the now-tombstoned envelope — * would resolve tier 0 and decrypt the tier-scoped slot map under the * wrong DEK (`TamperedError`, erasure aborted mid-shred). `forget()` reads * the LIVE record first and passes its pre-tombstone tier here instead. * * #724 re-review (Critical) defense-in-depth: an unreadable slot map must * not abort the whole erasure cascade — `casUpdateSlots` deleting the row * instead of leaving it empty-but-present (rather than a stray mis-keyed * envelope) is what makes an ABSENT row the normal "no blobs" case, and * `loadSlots` already returns `{ slots: {}, version: 0 }` cleanly for * that, never throwing. So the only way this catch fires is a row that IS * present failing to decrypt — genuine corruption/mis-key/tamper — and * that must never be swallowed as "no blobs to shred": it is pushed onto * `residue` so `forget()` surfaces it via `blobResidueCollections` rather * than silently reporting a clean erasure while un-shredded blobs remain. * * #750: published versions (`_blob_versions_{collection}/{recordId}::*`) * take an INDEPENDENT refCount hold on a `BlobObject`, separate from the * slot map (see `publish()`) — the slot loop above never sees them. Left * untouched, that hold (and the version-held content, if the slot was * later overwritten) survives `forget()` — a GDPR-erasure hole. So this * method also enumerates the record's version rows via * `collectVersionHolds`, folds each version's hold into the SAME `holds` * map (a shared eTag reports ONE outcome, not two), and deletes the * readable version rows once their holds are released. * * **#753 spec §7 (crash-idempotent shred):** the holds used for the * actual release loop below come from a `_blob_intent` MARKER, never * re-collected from rows on resume: * - `forget()` mints the marker PRE-tombstone (`mintShredIntent`, * C5) — the normal case, so a marker is ALREADY present by the time * this method runs and `intent.ownerTier` (captured live, before the * tombstone dropped `_tier`) is authoritative even when the caller's * own `ownerTier` argument is stale (a crash-retried `forget()` * re-reads the now-tombstoned record and can only pass `0`). * - Called WITHOUT a pre-existing marker (defensive/non-forget callers, * or the direct-call path this suite exercises) — one is minted here * from the live rows, exactly like `mintShredIntent` does, so the * crash matrix holds regardless of caller (spec §2c). * - A pending `op:'rehome'` marker is resumed to completion FIRST (#746 * spec §7 Q1 — "supersession is resume-then-shred": a half-done rehome * can leave a row-unreferenced destination object that shred's * row-derived holds can never see; replacing the marker instead of * resuming it would leak that object past `forget()` permanently), via * {@link resolveShredIntent} — then a fresh SHRED marker is minted (or * discovered, if the resume's own completion raced with another * minter) exactly as the no-marker branch below does. * * **Whole-branch review (#753):** the marker is a crash-safety * ENHANCEMENT, not a precondition for erasure. If the no-marker branch's * own mint fails (transient store error, `getDEK` failure — possibly the * same failure that already sent `forget()`'s pre-tombstone mint to * residue), this degrades to {@link unmarkedShred} — a best-effort * UNMARKED shred, exactly the pre-#753 behavior — rather than aborting * and leaving the blobs un-shredded. * * **#746 review Critical 1:** that degradation covers ONLY a genuine * no-marker mint failure — nothing was in flight, so a live-row * best-effort re-collection is safe. A REHOME-resume failure is a * DIFFERENT failure mode: a marker WAS present and its holds are * ambiguous mid-move (C10's whole point). `resolveShredIntent` below * therefore never wraps `consumeRehomeIntent` in the mint's degradation * try/catch — a resume failure propagates all the way out of this * method uncaught, leaving the rehome marker alive (never `deleteIntent`d) * for a later resume, instead of silently falling through to * `unmarkedShred`, which would both clobber rows the ambiguous rehome * still needs and abandon the marker forever (the exact permanent-leak * shape the journal exists to prevent). * * `collectShredHolds` is re-run here (idempotent — a corrupt/unreadable * row stays corrupt/unreadable on re-read; an already-deleted row reads * back cleanly empty) purely to derive the slot-map/version-row DELETE * targets and residue notices — never to recompute the release counts, * which are fixed for the operation's lifetime in the marker (C1/C2). */ shredAllForRecord(ownerTier?: number): Promise<{ shredded: string[]; retainedShared: string[]; residue: string[]; }>; /** * Resolve a SHRED `BlobIntent` for this record, or `null` when the entry * mint itself failed and nothing was in flight (the caller degrades to * {@link unmarkedShred}) — `shredAllForRecord`'s entry logic (#746 spec §7 * Q1), factored out so the two failure modes stay structurally separate * (#746 review Critical 1): * * - A pending REHOME marker — found up front OR discovered via a raced * `createIntent` inside `mintFreshShredIntent` — is resumed via * {@link consumeRehomeIntent} OUTSIDE the `try` below. Its failure * PROPAGATES to the caller uncaught: holds are ambiguous mid-move, so * this must surface, never silently degrade. * - Only `mintFreshShredIntent` itself (the create-a-fresh-SHRED-marker * step, reached with no rehome in flight) is wrapped — its failure is * the ONLY thing `null` reports, matching the whole-branch-review * degrade contract exactly. * * The loop re-attempts the mint after resuming a raced rehome discovery, * mirroring `resolveToShredIntent`'s prior recursive shape without ever * routing a `consumeRehomeIntent` call through the mint's own catch. */ private resolveShredIntent; /** * Best-effort UNMARKED shred (#753 whole-branch review) — reached only * when `shredAllForRecord`'s own entry-mint threw, so there is no marker * to journal against. Reproduces the pre-#753 `shredAllForRecord` body: * collect this record's holds from the live rows, release each unstamped * (no journal — a crash here reverts to the pre-#753 stranding-eTag * exposure, exactly like main before this arc), then unconditionally drop * the slot map / readable version rows. The mint failure itself is * reported as `_blob_intent` residue so `forget()` surfaces the * degraded crash-safety posture via `blobResidueCollections`, never * silently. */ private unmarkedShred; /** * Collect this record's CURRENT blob holds (slot map + published * versions) at `tier` — the shred journal's single hold-collection * routine (#753 spec §7), shared by `mintFreshShredIntent` (to seed a * fresh marker's authoritative `holds`) and `shredAllForRecord`/ * `resolvePendingIntent` (to derive delete targets + residue on * consumption — see {@link shredAllForRecord}'s doc comment for why that * second use never feeds the release loop). Mirrors the pre-#753 * inline collection logic byte-for-byte; `chunkCount` per hold (C5) is * best-effort — an unreadable index row just means the marker's hint is * absent, `releaseRef`'s own chunkCountHint fallback still applies. */ private collectShredHolds; /** * Collect this record's current holds and CAS-create a fresh `_blob_intent` * SHRED marker from them (#753 spec §7 C8). Used by `mintShredIntent` * (forget()'s pre-tombstone step) and `shredAllForRecord`'s no-marker * (defensive/direct-call) branch. * * C4: a concurrent minter for this SAME record can win the create race — * `createIntent` throws `BlobIntentPendingError`, and the winner's marker * (whatever op it turns out to be) is returned instead; the caller decides * how to handle a raced rehome marker. */ private mintFreshShredIntent; /** * Release every hold in `intent.holds` (stamped with `intent.opId`), then * delete the slot map / readable version rows / the marker itself — the * shred journal's consume step (#753 spec §7 §2c), shared by * `shredAllForRecord` and `resolvePendingIntent`. * * **C10 (no swallowed releases under a marker):** a release that THROWS * (as opposed to returning `'residue'`, a normal non-error outcome for a * legacy blob) is caught, reported as residue, and marks the whole * consumption incomplete — the slot map, version rows, and the marker * itself are then left IN PLACE (not deleted) so a later resume retries * every hold. Stamped holds that already landed skip re-decrementing * (C1/C4's test-and-set), so redoing the full loop on retry is safe. */ private consumeShredIntent; /** * Resume gate (#753/#746 spec §7 C6): every refCount/slot mutator calls * this FIRST. A pending SHRED marker means a previous `forget()`/ * `shredAllForRecord()` crashed mid-flight — refCounts are ambiguous * until it's resumed, so no new blob write may proceed over them; this * resumes it to completion before the caller's own mutation runs. A * pending REHOME marker means a previous tier move crashed mid-flight — * resumed to completion the SAME way (§2d), via {@link consumeRehomeIntent} * using the marker's OWN captured `fromTier`/`toTier`/`policy`/`opId`, * never the caller's own ask (which may be a totally unrelated write). * * Never called from `shredAllForRecord`/`mintShredIntent`/ * `mintFreshShredIntent`/`collectShredHolds`/`consumeShredIntent`/ * `sweepPendingShredIntents`/`runRehomeSteps`/`consumeRehomeIntent` * themselves — those ARE the resume machinery; routing them back through * this gate would recurse. */ private resolvePendingIntent; /** * Resume a pending REHOME marker to completion: run {@link * runRehomeSteps} (never the public `rehomeForTier`/`syncTierMove` — * both would re-enter `resolvePendingIntent` on the SAME marker and * recurse) using the marker's own captured `fromTier`/`toTier`/`policy`/ * `opId`, then delete it. Shared by `resolvePendingIntent`'s rehome * branch and `resolveToShredIntent` (#746 spec §7 Q1: forget() resumes a * pending rehome before shredding). */ private consumeRehomeIntent; /** * Resume every stranded SHRED marker in THIS collection (#753 spec §7 — * `sweepBlobIntents`'s first real production caller, Task 3 item 5). * Scoped to `this.collection` rather than the whole vault: `forget()` * already iterates per-ref-collection, so calling this from * `mintShredIntent` once per ref opportunistically heals any OTHER * record's marker left behind by a previous crashed operation in the * same collection — "heals untouched ones" (spec C5's retry contract), * without a separate vault-open hook. A pending REHOME marker on a * sibling record is left alone here (out of THIS sweep's scope — it heals * on its OWN record's next write/tier-op via `resolvePendingIntent`, * §2d's "who resumes") rather than surfaced as a resume failure. * `sweepBlobIntents`'s own per-marker * isolation (blob-intent.ts) means one corrupt sibling never blocks * another record's healthy resume. */ private sweepPendingShredIntents; /** * Mint the `_blob_intent` SHRED marker for this record — `vault.forget()`'s * PRE-tombstone step (#753 spec §7 C5). Captures this record's CURRENT * blob holds (slot map + published versions, at `ownerTier` — the LIVE * pre-tombstone tier) into a fresh marker before any destructive write * happens, so a crash between here and `shredAllForRecord`'s completion — * including one straddling the tombstone itself, which drops `_tier` and * would otherwise strand an elevated record's holds unrecoverably on * retry — leaves a resumable, tier-correct record of what must still be * released. * * First sweeps this collection for any stranded marker * (`sweepPendingShredIntents`) — this resumes a PRE-EXISTING marker for * THIS record (a previous forget() attempt crashed mid-shred) to * completion before minting fresh (C8: never overwrite a pending * marker — that would orphan its op-stamps), as a side effect of the * same collection-wide sweep. */ mintShredIntent(ownerTier: number): Promise; /** * #750: enumerate this record's published-version rows (`{recordId}::*` in * `_blob_versions_{collection}` — the same raw prefix scan as * `rehomeVersionRecords`) and fold each version's independent refCount hold * into `holds`. Returns the READABLE version keys — safe to delete once * their holds are released. An unreadable row is pushed onto `residue` and * NOT returned: deleting it blind would orphan its refCount hold and strand * the content undecryptable-but-undeleted forever, so it stays in place for * out-of-band repair (mirrors the unreadable-slot-map posture above). */ private collectVersionHolds; /** * Rehome this record's blobs after a tier move (#724 Arc 10 Task 2, * at-rest isolation) — called by `TiersContext.syncBlobs`. A blob's home * tier is its owning record's tier: the `_blob` DEK is tier-scoped via * `dekKey('_blob', tier)`, so moving a record's tier must move the * wrapping key of any blob it exclusively owns, or a tier-0-cleared * caller could still unwrap the content CEK off the store at rest even * though the runtime read gate (Task 1) hides it through the API. * * Every read/write of the slot map INSIDE this method pins `fromTier` * explicitly (never the `loadSlots()`/`saveSlots()` default) — by the * time this runs, the owning record's live `_tier` envelope has ALREADY * moved to `toTier` (the collection-level `elevate`/`demote`/`putAtTier` * writes the record before invoking `syncBlobs`), but the slot map itself * is still physically encrypted at `fromTier` until this method's own * move step (last) lands. `ownerTier()`'s default would resolve the * wrong DEK for every read until then. * * Enumerates the record's eTags via the slot map — mirrors * `shredAllForRecord`'s `loadSlots()` → `holds` enumeration above. For * each distinct eTag: * - **solo** (`refCount === 1`) and **shared `isolate`** (`refCount > 1`, * default policy) are UNIFIED (#724 Arc 10 correction, closes C1): both * re-`put()` the plaintext under `toBlobDEK` for every one of this * record's slots pointing at the eTag. That mints a fresh, tier-scoped * eTag/`_cek` (HMAC + wrap keyed by `toBlobDEK`) and, via `put()`'s own * existing old-eTag decrement, releases this record's hold on the old * object — a solo blob's old object drops to refCount 0 and is * crypto-shredded; a shared co-owner keeps its unchanged refCount. The * OLD in-place rewrap (`wrapCek(unwrapCek(_cek, fromDEK), toDEK)`, eTag * held stable) is UNSAFE — it leaves the eTag in the FROM tier's HMAC * namespace, so a later same-bytes put computed under that tier's DEK * dedup-*hits* the wrapped object (cross-tier corruption of an * uninvolved writer). Re-`put()`ing makes that structurally * impossible: different tiers hash to different eTags. Landing at * `toTier === 0` naturally REJOINS the tier-0 dedup pool if a co-owner * already holds the content there (`put()`'s own dedup check matches * on the recomputed tier-0-native eTag) — this is how `demote(→0)` * reverses an `elevate()` fork. * - **shared `dedup`** (#741, opt-in, only for `refCount > 1`): NO-OP. * The slot keeps pointing at the shared eTag. The Task-1 runtime read * gate still hides it from a tier-0 caller; the chunks remain * decryptable at rest under the flat `_blob` DEK (documented residue). * - **legacy** (`_cek` absent — chunks direct under the flat `_blob` * DEK): NO-OP. Tiered collections mandate `perRecordKeys`, so new * blob data is always erasable; a legacy blob reaching this method * would need a chunk re-encrypt (not attempted here). * * #724 Arc 10 Task 4 — slot-map metadata move: LAST step, after every * fork/rewrap/reconcile write above (which all operated on the slot map * via the explicit `fromTier`, its still-current physical location). * Re-reads (to pick up any fork/reconcile-produced eTag changes from the * loop) and re-encrypts the SAME `_blob_slots_{collection}/{recordId}` * row under the `toTier` collection DEK — filenames/sizes/mimeTypes/eTags * are no longer tier-0-readable at rest for an elevated record. No * separate delete: it's the same physical row, just re-keyed in place * (mirrors `history.ts`'s `rewrapHistory`). * * @param opId #753/#746 spec §7 C3: the governing `_blob_intent` rehome * marker's op-stamp identity, threaded through by `syncTierMove`. Omitted * (a direct call, e.g. tests) → byte-identical unstamped behavior. When * present, every DESTINATION refCount `+1` this call performs (the slot * loop's dedup-hit re-put below, and `rehomeVersionETag`'s own increment) * carries a ROW-SCOPED stamp — `${opId}:${slotName}` / `${opId}:${versionKey}`, * never the bare opId — so a resumed re-put's `+1` is idempotent per row * without collapsing N legitimate `+1`s onto one destination eTag into one. * * The direct-call entry point: resumes a pending SHRED marker first * (unchanged, #753 C6), or a pending REHOME marker for a DIFFERENT prior * op (#746 Q1 — resumed via its OWN stored fromTier/toTier/opId before * this call's requested move proceeds), then runs {@link runRehomeSteps}. * `syncTierMove` — the real tier-op seam (`syncBlobs`) — calls * `runRehomeSteps` directly instead, after minting/consuming its own * marker (calling back through here would re-enter `resolvePendingIntent` * on the marker `syncTierMove` itself just created). */ rehomeForTier(fromTier: number, toTier: number, policy: 'isolate' | 'dedup', opId?: string): Promise; /** * The tier-op → rehome seam (#746 spec §7 §2d) — called by * `TiersContext.syncBlobs` (`collection.ts`), replacing the pre-#746 * unstamped `rehomeForTier` call. Mints the governing `_blob_intent` * REHOME marker BEFORE the first write (CAS create-if-absent), resuming * any marker already pending for this record first — a stale rehome from * an earlier, different move, or a shred, per the same C6/Q1 resume-first * law `rehomeForTier`'s own entry follows. Deletes the marker as the LAST * step, once every phase of {@link runRehomeSteps} completes without * throwing (a failure — including a C10 unswallowed release — leaves the * marker in place for the next resume). */ syncTierMove(fromTier: number, toTier: number, policy: 'isolate' | 'dedup'): Promise; /** * #746 whole-branch review Hardening 1: `syncTierMove` runs on EVERY tier * move (`elevate`/`demote`/`putAtTier`) — including the common case of a * record that has never attached a blob. Minting-then-deleting a marker * for such a record is pure overhead (~4 extra adapter ops on every * bulk elevate/demote of blob-less records) protecting nothing: an empty * slot map AND no published versions means there is genuinely no content * this op could strand. Checked AFTER `resolvePendingIntent` (a pending * marker, however stale, is always resumed first regardless of current * blob state — it may reference content this check alone wouldn't see) * and BEFORE minting a new one. */ private isBlobFree; /** * The resumable rehome executor (#746 spec §7 §2d) — `rehomeForTier`'s * pre-#746 body, factored out so it can be invoked WITHOUT re-entering * `resolvePendingIntent` (which would recurse: this method IS part of the * resume machinery, called from `resolvePendingIntent`'s own rehome * branch and from `syncTierMove` after its marker is already settled). * * Per-step resume tolerance: * - Slot map: {@link loadSlotsTolerant} tries `fromTier` then `toTier` — * if it opens at `toTier`, a prior run's move step already landed, so * the ENTIRE per-eTag loop (strictly sequenced before the move) is * also already done — skip both, but first reconstruct `rehomedETags` * (see below) so the version pass's same-eTag fast path still works * (carried finding (a)). * - Per-eTag: {@link loadBlobObjectResumable} — an eTag that only opens * under `toTier` means THIS slot's re-put already landed on a prior * run — skip re-processing it. * - Slot-CAS→release gap (carried finding (b)): {@link * reconcilePendingReleases} runs FIRST, completing any old-eTag * release a prior run's `putUnderDEK` left stranded (slot already * moved to the new eTag, release not yet applied) before either the * "already moved" or "still moving" branch below runs. * - Version pass: same per-key tolerance, in `rehomeVersionRecords`. * - Destination `+1`s (#746 whole-branch review, K=8 stamp-ring * blocker): `knownApplied` — this op's marker-backed * `appliedStamps`, read ONCE here — makes every row-scoped * increment idempotent INDEPENDENTLY of `BlobObject.lastOps`'s * bounded ring; see {@link applyStampedIncrement}'s doc comment. */ private runRehomeSteps; /** * Rehome this record's PUBLISHED VERSIONS after a tier move — the second * half of `rehomeForTier` (#724 Arc 10 Task 2, closes C4). Enumerates * every `_blob_versions_{collection}/{recordId}::*` row (mirrors * `listVersions`'s raw prefix scan, but across ALL slots on this record, * not one) and, for each: * - rehomes its held eTag via {@link rehomeVersionETag} (reusing the slot * loop's result when the version happens to hold the SAME eTag a slot * held — see that method's doc comment), and * - re-keys the version RECORD itself onto the `toTier` collection DEK, * regardless of whether the content moved — metadata protection is * orthogonal to the shared-content dedup policy (matches the slot-map * move, which always relocates even for a `dedup`-left-in-place blob). * * Per-key resume tolerance (#746 spec §7 §2d bullet 3): each key's * metadata is read via {@link loadVersionRecordAtKeyTolerant} (try * `fromTier` then `toTier`) — a key whose metadata already opens at * `toTier` had its `resolveRehomedVersionETag` call AND its * `writeVersionRecordAtKey` BOTH already land on a prior run (the * metadata write is sequenced strictly after the eTag rehome, which * itself completes the old-eTag release under C1's two-armed resume rule * — same order as the slot loop's own CAS-then-release), so it's skipped * entirely. * * Known residual gap (documented, not closed by this task — see the T2 * report): if a version's OLD eTag is held ONLY by that version (never * shared with any slot), a crash strictly between * `resolveRehomedVersionETag`'s release (drops it to refCount 0, * crypto-shredding a solo-held erasable object) and THIS method's * metadata write would leave the version pointing at an eTag whose sole * copy is already gone. This mirrors the slot-side "carried finding (b)" * shape but on the version side; closing it durably (a `pendingRelease`- * style breadcrumb on `VersionRecord`, filtered from the public * `listVersions()` surface) is out of scope for this task. */ private rehomeVersionRecords; /** * Resumable per-key version-record metadata read (#746 spec §7 §2d bullet * 3) — mirrors {@link loadSlotsTolerant}: try `fromTier` first, fall back * to `toTier` on a decrypt failure. `atTier === toTier` on return is the * "already fully rehomed" signal `rehomeVersionRecords` skips on. */ private loadVersionRecordAtKeyTolerant; /** * Rehome ONE version's independently-held eTag — both the DESTINATION * `+1`/create AND the OLD eTag's release, in that order (mirrors the slot * loop's own CAS-then-release sequencing, and matches C1's two-armed * resume rule: a crash after the release CAS lands but before the index * row's delete completes is resumable — `already`/`loadBlobObject` still * find the row at `fromTier` on a re-run, since the CALLER's metadata * write hasn't happened yet). Returns the eTag the version should now * point at (unchanged if legacy/missing/left `dedup`-shared) — the caller * (`rehomeVersionRecords`) writes the version's metadata after this * returns. * * If the version happened to hold the SAME eTag a slot held (the common * case: publish right after put, no later overwrite), the slot loop in * `runRehomeSteps` already re-`put()` the content under `toBlobDEK` — * `rehomedETags` (content-addressed, so deterministic) tells us the * resulting destination eTag without a redundant fetch+re-encrypt; we * only need to move THIS hold's refCount onto it. Otherwise (the version * outlived its slot, or was never in the slot map) this re-`put()`s the * plaintext itself via `writeBlobContent` — the same content-write core * `put()`/the slot loop use, no new crypto — mirroring the slot case's * legacy/`dedup`-shared skip conditions. * * @param fromTier / @param toTier #747: same from/to split as the slot * loop above — the read of the version's CURRENTLY-held eTag pins * `fromTier`; the re-put and the refCount bump onto an already-rehomed * (slot-loop-produced) eTag pin `toTier`. * * @param stamp #753/#746 spec §7 C3: when present (a marker-governed * rehome), every destination `+1` this call performs — both the * `already`-rehomed fast path's explicit CAS and the `writeBlobContent` * re-put's own dedup-hit CAS — carries the row-scoped stamp * `${opId}:${versionKey}` the caller computed, never the bare opId (see * `rehomeForTier`'s `opId` doc comment). * @param knownApplied #746 whole-branch review (K=8 stamp-ring blocker): * this op's marker-backed confirmed-stamps set — see * {@link applyStampedIncrement}. Threaded through to the * `writeBlobContent` re-put's own dedup-hit CAS too. */ private resolveRehomedVersionETag; /** * CAS retry loop for an arbitrary BlobObject mutation. Used only by * `migrate()`, which is legacy-only by definition — a legacy object is * always flat-keyed — so the tier is pinned to `0` explicitly rather than * left to `loadBlobObject`/`writeBlobObject`'s independent `ownerTier()` * defaults, which could diverge on an elevated owner and re-key the * envelope under the elevated tier DEK while chunks stay flat (#747 review). */ private casUpdateBlobObject; /** * Migrate this record's LEGACY blobs (no `_cek`, chunks under the shared * `_blob` DEK) to per-blob content CEKs so they become crypto-shreddable. * Returns the eTags migrated vs. already-erasable. * * **Explicit maintenance pass** (mirrors the record-CEK migration posture): * re-encrypts the existing compressed chunks IN PLACE under a fresh content * CEK — preserving the eTag, chunkCount, chunkSize, and compression — then * flips the `_cek` discriminant. Crash-safe + idempotent via `_cekPending`: * 1. persist the wrapped content CEK in `_cekPending` (readers ignore it → * the blob stays readable under the `_blob` DEK; the key survives a crash); * 2. re-encrypt each chunk under the content CEK (a resume reads an * already-migrated chunk under the content CEK, else under the `_blob` DEK); * 3. promote `_cekPending` → `_cek` (atomic flip). Reads now use the CEK. * A re-run after a crash resumes from whichever phase was reached. * * Dedup-safe: migrating a shared blob (refCount > 1) re-keys it for every * referencer at once; a non-erasable collection still reads it (it unwraps * `_cek` under the `_blob` DEK it holds). */ migrate(): Promise<{ migrated: string[]; alreadyErasable: string[]; }>; private writeChunk; private readChunk; /** #752: '::' is the version-key separator (`versionKey`) — a recordId/slotName/label * containing it makes the `{recordId}::` prefix scans (listVersions / rehomeVersionRecords / * collectVersionHolds) match ACROSS records, which escalated from mis-read to destructive * with #750's shred path. Also refused: a part that starts or ends with ':' — otherwise a * boundary colon re-segments the key (recordId `"a:"` + slotName `"slot"` yields * `"a:::slot::label"`, which starts with `"a::"` and prefix-matches record `"a"`'s rows). * With both rules, every '::' in a stored key is exactly a component separator — the grammar * is unambiguous by construction. Refused at the write surface only: legacy '::' data stays * readable/sheddable. */ private assertKeyPartSafe; /** #752: `recordId`/`slotName`/`label` must not contain '::' — see `assertKeyPartSafe`. */ private versionKey; /** * @param tier #724 Arc 10 Task 2: explicit tier to resolve the version * record's collection DEK at — `getDEK(dekKey(this.collection, tier))`, * mirroring `loadSlots`'s `tier` param. Omitted → resolves via * `ownerTier()` (the record's CURRENT live tier); `rehomeForTier`'s own * reads pin `fromTier`/`toTier` explicitly instead (see `ownerTier()`'s * doc comment for why the default would be wrong mid-move). */ private loadVersionRecord; /** @param tier See {@link loadVersionRecord}'s `tier` param — same resolution. */ private writeVersionRecord; /** * `writeVersionRecord`'s body, addressed by an explicit raw store key * instead of `slotName`+`label` — used by `rehomeVersionRecords` (#724 * Arc 10 Task 2), which already has the key from its raw `store.list()` * scan across ALL of this record's version slots. */ private writeVersionRecordAtKey; private deleteVersionRecord; private effectiveChunkSize; /** * #747/#749 whole-branch review I1: when `loadBlobObject`'s tier-scoped open * fell through to the flat retry while a CLEARED higher-tier view (#749) * was reading (`this.clearedTier > 0`, `result.atTier === 0`), the * `_blob_index` row that "won" the read is not necessarily the one this * handle's `assertTierAccess` grant vouches for. Chunk AAD * (`{eTag}:{index}:{count}`) is attacker-computable, and a legacy * (`_cek`-less) object has no wrapped content CEK to unwrap under a key * the attacker doesn't hold — so ANY flat `_blob` DEK holder with store * write access can plant a `_cek`-less forgery at `_blob_index/{eTag}` * (this elevated blob's own address) whose chunks are their own flat * bytes, and it will decrypt cleanly under the flat DEK the fallback * already tries. * * `verifyFlatETag`, when set, is the eTag the CALLER originally asked * for (the slot/version's `.eTag` — never `blob.eTag`, which is * attacker-controlled content pulled from the same forged row and would * make the check tautological). After assembling the plaintext, we * recompute the SAME content address every write path mints * (`hmacSha256Hex(flatBlobDEK, plaintext)` — `writeBlobContent`'s Step 1, * unconditional on `_cek`) and compare it to that requested eTag. A * forged row can produce valid ciphertext under the flat DEK, but can't * produce plaintext that re-hashes to an address it doesn't control. * * Both legitimate flat-fallback classes — a `dedup`-policy shared object * (#741) and a legacy `_cek`-less object (#724 I1) — were minted this * same way, so an honest read's recomputed hash always matches and this * is a no-op for them. */ private fetchAllChunks; /** * #747/#749 review I1: the eTag to pass as `fetchAllChunks`'s `verifyFlatETag` — set * only when `loadBlobObject` fell through to the flat retry * (`resolvedAtTier === 0`) on a CLEARED higher-tier view * (`this.clearedTier > 0`). Every ordinary (non-cleared) call reaching * a content-fetch site already has `ownerTier() === 0` (elevated records * are hidden by `ownerRecordElevated()` before this point), so * `loadBlobObject` never even attempts the tier branch for them — no * fallback occurred, nothing to verify. */ private flatFallbackVerifyETag; /** * Upload bytes and attach them to this record under `slotName`. * * 1. Computes `eTag = HMAC-SHA-256(blobDEK, plaintext)` for keyed content-addressing. * 2. Auto-detects MIME type from magic bytes if not provided. * 3. If a blob with this eTag already exists, skips chunk upload (deduplication) * and CAS-increments refCount. * 4. Otherwise: compresses → splits into chunks → encrypts each chunk with * AAD binding → writes `_blob_chunks` → writes `BlobObject` to `_blob_index`. * 5. CAS-updates the slot metadata in `_blob_slots_{collection}`. * If overwriting an existing slot, decrements the old eTag's refCount. */ put(slotName: string, data: Uint8Array, opts?: BlobPutOptions): Promise; /** * The slot-attachment core of `put()` (Step 7 — CAS the slot metadata), * parameterized by which `_blob` DEK to hash the eTag and wrap the * content CEK under. Steps 1-6 (hash/dedup/compress/chunk/index-write) * are `writeBlobContent` (#724 Arc 10 Task 2 extraction, so `rehomeForTier` * can reuse them for a version-held eTag without touching the slot map). * `put()` always passes this record's own OWNER-TIER blob DEK (#724 Arc * 10 correction) — `dekKey(BLOB_COLLECTION, 0)` for a tier-0 record. * * `rehomeForTier` is the other caller: on every tier move it re-`put()`s * each owned blob's plaintext under the `toTier`-scoped DEK — solo and * shared-isolate alike — so the moved copy gets a private, tier-scoped * eTag and `_cek` wrap from the moment it's written. The move never * routes through public `put()` under the wrong key, and this method * adds no new raw `_cek`/`_iv`/`_data` access (identical bodies, just * parameterized). * * @param slotsTier #724 Arc 10 Task 4: pins the slot-map CAS update * (Step 7) to a specific tier instead of the caller's `ownerTier()` * default — `rehomeForTier` passes `fromTier`, since mid-move the slot * map is still physically there (see `ownerTier()`'s doc comment). The * old eTag being replaced lives wherever the slot map itself is * physically keyed, so its release (#747, below) reuses this SAME value * rather than a separate param. * @param contentTier #747: pins the `_blob_index` read/write inside * `writeBlobContent` to a specific tier — `rehomeForTier` passes `toTier` * (matching `blobDEK`, already resolved at `toTier`), since mid-move * `ownerTier()` already reads `toTier` too but this is more direct. * Omitted → `ownerTier()`, correct for `put()`'s ordinary (non-rehome) call. * @param opId #753/#746 spec §7 C3: `rehomeForTier`'s governing marker * op-stamp identity (omitted → `undefined`, `put()`'s ordinary call and * every pre-existing caller, byte-identical unstamped behavior). When * present, the ROW-SCOPED stamp `${opId}:${slotName}` — never the bare * opId — governs BOTH this call's destination `+1` (via * `writeBlobContent`'s dedup-hit CAS) and its old-eTag release below: two * different `BlobObject`s (destination vs. source), so the shared stamp * string collides with neither, and a resumed re-put of THIS slot can * neither double-apply the `+1` nor double-release the old object. Two * DIFFERENT slots on this record legitimately re-putting the SAME * destination eTag each get their OWN stamp (their own `slotName`) — the * row scope is what lets N slots' `+1`s all land on one destination * object instead of the first silently absorbing the rest. */ private putUnderDEK; /** * C10 (#753/#746 spec §7): under a marker-governed (`stamp` present) * rehome, a failed old-eTag release must not be silently swallowed — it * is the crypto-shred of the FROM-tier object, and swallowing it during a * documented-exactly-once op is the bug C10 closes. Propagating keeps the * governing `_blob_intent` marker alive (never deleted) so a later resume * retries it, rather than completing the op over a residue that never * surfaces. Unstamped (ordinary `put()`/`delete()`) calls keep the * pre-#753 best-effort posture unchanged — a missed decrement there is * reconciled by a later pass, same as always. */ private releaseOldETagAfterMove; /** * Durably confirm a rehome row-stamp's `+1` in the governing marker — * #746 whole-branch review (K=8 stamp-ring blocker). No-op when there is * no marker to record against (a direct `rehomeForTier(..., opId)` call * with no real `_blob_intent` row, e.g. this suite's Task-1 tests — * byte-identical ring-only behavior for that path, unchanged). NOT * swallowed on failure (mirrors C10): a failed confirmation write must * keep the governing marker's op from completing silently over an * unconfirmed increment, not be dropped. */ private recordAppliedRehomeStamp; /** * Apply ONE destination `+1` under a row-scoped rehome stamp, made * idempotent INDEPENDENTLY of `BlobObject.lastOps`'s bounded ring (#746 * whole-branch review — see `BlobIntent.appliedStamps`'s doc comment for * the full hazard). `knownApplied` — this op's OWN marker-backed, * unbounded record of confirmed stamps, read ONCE at the top of * `runRehomeSteps` — is consulted FIRST: a hit means this exact row's * `+1` was already confirmed on a PRIOR run, so the CAS is skipped * entirely regardless of whether the shared destination's ring has since * evicted it (another 8+ rows — from THIS record's own fan-out, or from * unrelated CONCURRENT rehomes converging on the same destination — * could have pushed it out). A miss falls through to the existing * ring-based stamped CAS (correct and sufficient for the overwhelming * majority of resumes — same-session or shortly-after), and on success * (freshly applied OR the ring itself already had it) records the stamp * into the marker so THIS row is never re-examined again by a later * resume, no matter how much unrelated activity lands on the destination * in between. */ private applyStampedIncrement; /** * The chunk/CEK/dedup core of `put()` (former Steps 1-6 of `putUnderDEK`, * extracted #724 Arc 10 Task 2 so `rehomeVersionETag` can write a * version-held blob's content under a target tier's DEK WITHOUT touching * the slot map — `putUnderDEK`'s remaining Step 7 is slot-specific). * Content-addressed and dedup-aware exactly like `put()`: hashing the same * plaintext under the same `blobDEK` twice always lands on the same eTag. * * @param tier #747: the index-envelope tier to read/write at (see * {@link loadBlobObject}'s `tier` param) — `putUnderDEK`'s `contentTier`, * matching whichever tier `blobDEK` itself was resolved at. Omitted → * `ownerTier()`, correct for `put()`'s ordinary (non-rehome) call. * @param incrementStamp #753/#746 spec §7 C3: when present, a dedup hit at * Step 3 applies its `+1` via {@link casUpdateRefCountStamped} under this * stamp instead of the plain {@link casUpdateRefCount} — the row-scoped * identity `putUnderDEK`/`rehomeVersionETag` compute so a resumed re-put's * destination increment is idempotent per row. Omitted (every call site * before this arc) → byte-identical unstamped behavior. * * ALSO seeds `lastOps: [incrementStamp]` on the fresh-object create path * (Step 6, review finding on #746 C3): a solo blob has no pre-existing * destination object for a resumed re-put to dedup-hit against a stamp on * — the FIRST attempt's create is itself the only write, and a crash * after it lands but before the slot/version CAS leaves the object * present at refCount 1 with NO stamp. An unseeded resume then * re-executes Step 3, finds that same object, and — finding no matching * stamp — applies a SECOND, spurious `+1` (1 → 2), the exact over-count * hazard this stamping arc exists to close. Seeding the ring at create * time closes that: the resumed re-put's Step 3 dedup-hit against THIS * object finds its own stamp already present and skips, matching the * dedup-hit branch's behavior exactly. */ private writeBlobContent; /** * Fetch all bytes for the named slot. * Returns `null` if the slot does not exist. * Throws `NotFoundError` if the index entry exists but a chunk is missing. */ get(slotName: string): Promise; /** * A URL to fetch an `external` slot's object directly — presigned * (time-limited) or public, per the projection. Returns `null` if the slot * does not exist. Throws for a non-external slot (use `get()`/`response()`). */ url(slotName: string, opts?: { expiresInSeconds?: number; }): Promise; /** * Build the backlink stamped onto an external object's metadata — the * self-describing "secondary store" reference back to this record. See * {@link BlobFieldPolicy.backlink}. Returns the `userMeta` to attach and, for * `opaque-token`, the `token` to record on the slot. */ private buildBacklink; /** * Adopt an EXISTING object in the projection into this slot **without * re-uploading** — used by import/bootstrap to anchor objects already in the * bucket. Writes the external slot record (the catalog entry). */ adoptExternal(slotName: string, ref: { key: string; size?: number; contentType?: string; public?: boolean; backlink?: string; meta?: Record; }): Promise; /** * Merge derived metadata (video `duration`, image `width`/`height`, arbitrary * metatags) into an external slot's secondary metadata store. Typically called * from an AWS-side processing callback (MediaConvert / ffprobe / Rekognition) * once it has probed the object. No-op for a missing or non-external slot. */ setExternalMeta(slotName: string, meta: Record): Promise; /** * Read an external slot's synced derived metadata (the secondary store). * Returns `null` for a missing or non-external slot, `{}` if none synced yet. */ externalMeta(slotName: string): Promise | null>; /** * List all slot entries for this record. * Returns metadata only — no chunk data is loaded. */ list(): Promise; /** * Delete the named slot from this record. * Decrements refCount on the blob. Chunks are GC'd by `vault.blobGC()`. */ delete(slotName: string): Promise; /** * Return a native `Response` whose body streams the decrypted, * decompressed blob bytes with full HTTP metadata headers. * * Note: implementation is buffered — all chunks are loaded into * memory before being enqueued. True streaming deferred to. * * Returns `null` if the slot does not exist. */ response(slotName: string, opts?: BlobResponseOptions): Promise; /** * Decrypt the slot and wrap the bytes in a browser ObjectURL ready * to feed into ``, ``, etc. The caller MUST call * `revoke()` when the URL is no longer needed — otherwise the URL * (and the underlying decrypted Blob) are pinned for the lifetime * of the document, which leaks memory in long-lived pages. * * Returns `null` when the slot does not exist. * * Throws when `URL.createObjectURL` is unavailable in the host * environment (Node without DOM, restricted workers). Framework * adapters — `useBlobURL` in `@noy-db/in-vue`, etc. — guard against * this for SSR contexts and stay at `null` instead of propagating. */ objectURL(slotName: string, opts?: { mimeType?: string; }): Promise<{ url: string; revoke: () => void; } | null>; /** * Publish the current slot content as a named version snapshot. * * The published version holds an independent refCount reference to * the blob. Even if the slot is later overwritten or deleted, the * published version keeps the blob data alive. * * Publishing with an existing label overwrites it — if the eTags differ, * refCounts are adjusted accordingly. * * #724 Arc 10 Task 2 (C4): the version record is written under the owning * record's CURRENT tier — a version published on a tier-N record is keyed * at tier N, mirroring `put()`'s owner-tier resolution (Task 1). The * refCount hold it takes lands on `slot.eTag`, which is already * tier-scoped (born there by `put()`, or moved there by a prior * `rehomeForTier`) — no separate tier-scoping needed for the content side. */ publish(slotName: string, label: string): Promise; /** * Fetch bytes for a published version. * Returns `null` if the version does not exist. */ getVersion(slotName: string, label: string): Promise; /** * List all published versions for a slot. */ listVersions(slotName: string): Promise; /** * Delete a published version. Decrements refCount on its blob. */ deleteVersion(slotName: string, label: string): Promise; /** * Return a `Response` for a published version — same as `response()` * but reads from the version record's eTag instead of the current slot. */ responseVersion(slotName: string, label: string, opts?: BlobResponseOptions): Promise; /** * Return the `BlobObject` metadata for the named slot. * Returns `null` if the slot or blob does not exist. */ blobInfo(slotName: string): Promise; /** * Generate a presigned URL for direct client download of the blob's * ciphertext. Only works when the blob store supports `presignUrl`. * * **Important:** The URL returns encrypted data. The caller must * decrypt client-side using `decryptResponse()` or a service worker. * * Returns `null` if the slot doesn't exist or the store doesn't support presigning. */ presignedUrl(slotName: string, expiresInSeconds?: number): Promise; /** * Decrypt a ciphertext Response (e.g. from a presigned URL fetch) * back into a plaintext Response with correct headers. * * Usage with service worker or client-side fetch: * ```ts * const url = await blobs.presignedUrl('invoice.pdf') * const cipherResponse = await fetch(url) * const plainResponse = await blobs.decryptResponse('invoice.pdf', cipherResponse) * ``` */ decryptResponse(slotName: string, cipherResponse: Response): Promise; private buildResponse; }