stable

transferable partitions

Extract a referentially-complete, re-keyed slice of a vault into a standalone bundle and hand it to a new owner — a cryptographic ownership transfer.
Subpath@noy-db/hub/cargo, on every published version since the /pod + /cargo split (noy-db#833, #812) — /cargo carries describeExtraction, extractPartition, adoptPartition, createOwnerOnAdoptedPartition and walkClosure. The old @noy-db/hub/bundle was last present in 0.3.0 and is gone from both rails, so there is no longer a version where it is the right import. (/bundle also re-exported the unrelated .noydb container helpers; those live on /pod — see bundle.)
FactorywithCargo() gates the source-side extractPartition; describeExtraction, adoptPartition, createOwnerOnAdoptedPartition, and walkClosure are ungated named imports. withCargo() itself is exported from @noy-db/hub/cargo.
Layerwith-cargo
ClusterF — Snapshot & Portability
LOC costoff-bundle when not opted in — the extraction crypto is dynamically imported only once withCargo() is passed to createNoydb()

What it does

A vault is noy-db's end-to-end-encrypted, per-tenant data space (see Vaults & collections). Transferable partitions let an owner carve a subset of one vault's records out into a new, independently-owned vault — an owner-transfer ceremony, not a data export. Starting from a seed predicate (e.g. "every client row where operatorUserId equals X"), it walks the foreign-key closure so every reference resolves, re-encrypts the selected records under fresh per-collection data-encryption keys (DEKs), and seals those DEKs under a one-time transfer key. The recipient adopts the resulting bundle into their own store and mints the first owner keyring (the per-user file of wrapped encryption keys — see Core 06 — Permissions and keyring); from that point they hold the only keys, and the source owner has no path back into the partition. The source vault is left untouched (aside from an optional audit entry) — extraction is a copy, not a move.

When you need it

  • A firm spins a sub-portfolio off into a dedicated department's own vault — for example, handing one client group's records (bills, receipts, worker rosters) to a new owner when that department splits off.
  • A client exercises data portability — taking the records they control to a new provider, with audit and schema fidelity intact, as a cryptographic right rather than a vendor favour.
  • Splitting a shared vault along an ownership boundary without a manual dump-and-replay, which loses the FK closure, schema, and audit chain.

Prerequisites

  • cargoStrategy: withCargo() on the source vault's client — it gates extractPartition only; the recipient side adopts with ungated named imports and needs no opt-in.
  • The mandated Shamir recovery profile at partition creation — the @noy-db/on-shamir provider must be injected when the partition is created, or it cannot be extracted later.

Opt in

describeExtraction, adoptPartition, createOwnerOnAdoptedPartition, and walkClosure tree-shake naturally via direct named imports — no strategy to wire in. The source-side extractPartition is the one gated call: pass cargoStrategy: withCargo() to createNoydb() on the source vault's client, or it throws CargoNotEnabledError. The recipient side never needs withCargo() — adoption carries no source instance to gate against.

import { createNoydb } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
import { withCargo } from '@noy-db/hub/cargo'

// ── Source side (owner only) ──────────────────────────────────────────
const source = await createNoydb({
  store: toMemory(),
  user: 'alice',
  secret: 'source-owner-secret',
  cargoStrategy: withCargo(), // required for extractPartition below
})
const sourceVault = await source.openVault('acme')

Usage

Continuing from the source client and sourceVault opened above, the transfer runs in four steps: preview, extract (source side), adopt, then mint the recipient's owner (recipient side).

import {
  describeExtraction,
  extractPartition,
  adoptPartition,
  createOwnerOnAdoptedPartition,
} from '@noy-db/hub/cargo'

const preview = await describeExtraction(sourceVault, {
  seeds: { clients: (c) => c.operatorUserId === 'ops-team-42' },
})
// → {
//     totalRecords: 128,
//     totalBytes: 41230,
//     byCollection: [
//       { name: 'bills', recordCount: 54, bytes: 18120, oldestTs: '2026-01-04T…', newestTs: '2026-06-30T…' },
//       { name: 'clients', recordCount: 12, bytes: 3110 },
//       { name: 'workers', recordCount: 62, bytes: 20000 },
//     ],
//     graph: { depth: 2, cyclesDetected: false },
//     inaccessible: [],
//   }
// Computed from raw encrypted envelopes — decrypts nothing.

const { bundleBytes, transferKey, sealId } = await extractPartition(sourceVault, {
  seeds: { clients: (c) => c.operatorUserId === 'ops-team-42' },
  carrySchemas: true, // optional — carry persisted JSON Schemas (default false)
  carryLedger: true,  // optional — carry the re-chained audit ledger (default false)
})
// → { bundleBytes: Uint8Array(…), transferKey: Uint8Array(32), sealId: 'nQ3f…' }
// Deliver `transferKey` out-of-band; it never travels inside `bundleBytes`.

// ── Recipient side (ungated) ────────────────────────────────────────────
const destinationStore = toMemory()
await adoptPartition(bundleBytes, { transferKey, destinationStore, vaultName: 'dept-spinoff' })
// → { vaultName: 'dept-spinoff', needsOwner: true, sealId: 'nQ3f…' }

await createOwnerOnAdoptedPartition(destinationStore, 'dept-spinoff', {
  userId: 'bob',
  secret: 'recipient-owner-secret',
  transferKey,
})
// → { vaultName: 'dept-spinoff', userId: 'bob' }

// Now open it normally — a keyring exists.
const recipient = await createNoydb({ store: destinationStore, user: 'bob', secret: 'recipient-owner-secret' })
const deptVault = await recipient.openVault('dept-spinoff')

Lifecycle

source vault ──extractPartition──▶ extracted bundle ──adoptPartition──▶ ADOPTED, UNOWNED ──createOwnerOnAdoptedPartition──▶ OWNED
 (owner only)  re-keyed, sealed,    _meta/adoption,    recipient keyring,
               bundleKind header     keyring empty       seal destroyed

The FK graph is auto-derived from ref() declarations (tracked internally as the RefRegistry) — you supply only seeds, not a hand-written edge list. The walk is two-phase: inbound expansion grows the scope from the seeds (a client's bills travel with it), then outbound completion pulls referenced parents so no FK dangles (without re-expanding those parents into unrelated siblings).

API

  • describeExtraction(vault, { seeds, maxDepth? }) → ExtractionPreview — read-only dry-run (counts, bytes, _ts span, graph depth, inaccessible[]); writes nothing. Takes the same seeds/maxDepth as walkClosure below.
  • extractPartition(vault, options) → { bundleBytes, transferKey, sealId } — owner-only; non-destructive on the source. Routes through the vault's cargoStrategy, so an un-opted-in caller throws CargoNotEnabledError.
    OptionTypeDefaultPurpose
    seedsRecord<string, (record) => boolean | Promise<boolean>>requiredPer-collection predicate selecting the root records the closure grows from.
    maxDepthnumber16Max fixed-point iterations of the inbound/outbound walk before throwing PartitionExtractionError.
    compression'auto' | 'brotli' | 'gzip' | 'none''auto'Bundle-byte compression.
    carrySchemasbooleanfalseRe-key and carry _schemas/<collection> for the closure — see Carry opt-ins.
    carryLedgerbooleanfalseFilter, re-chain, and carry the audit ledger for the closure — see Carry opt-ins.
    fieldProjectionRecord<string, readonly string[]>nonePer-collection allow-list of fields to keep; non-listed fields are dropped before re-encryption (id is always kept). A projected collection's schema is not carried.
  • adoptPartition(bundleBytes, { transferKey, destinationStore, vaultName }) → { vaultName, needsOwner: true, sealId }.
  • createOwnerOnAdoptedPartition(store, vaultName, options) → { vaultName, userId } — mints the first owner and destroys the transfer seal. options is one of two shapes:
    Standard modeTypePurpose
    userIdstringThe recipient's user id.
    secretstringThe recipient owner's secret.
    transferKeyUint8ArrayThe 32-byte key delivered out-of-band from extractPartition.
    Managed modeTypePurpose
    userIdstringThe recipient's user id.
    secretMode'managed'Selects managed mode instead of a caller-supplied secret.
    sealingKeyNoydbSealerSeals the minted secret (e.g. an at-* OS keychain) so the partition auto-unlocks on the recipient's device.
    recoveryReadonlyArray<RecoveryEnrollmentInput>Recovery profiles to enroll; must include at least one shamir profile (see Edge cases & limits).
    shamirRecoveryNoydbShamirThe @noy-db/on-shamir provider backing the mandated Shamir profile.
    transferKeyUint8ArrayThe 32-byte key delivered out-of-band from extractPartition.
  • walkClosure(vault, { seeds, maxDepth? }) → { closure, graph } — the underlying FK-closure primitive (also used by describeExtraction).
  • unsealDeks(seal, transferKey) → Map<string, CryptoKey> — the reverse of the internal DEK-seal step; used internally by adoptPartition and exported for callers that need to validate a transfer key without adopting.
  • decryptExtractedPartition(bundleBytes, transferKey) → Record<string, DecryptedRecord[]> — read-side counterpart to extractPartition: decrypts every record of an extracted-partition bundle to plaintext (grouped by collection) without adopting it into a vault. Throws on a bundle that isn't an extracted partition, or a wrong transfer key.
  • Header additions (read pre-decryption by cloud listers): bundleKind: 'extracted-partition' + a transferSeal indicator.
  • Errors: PartitionExtractionError, TransferSealError, and AdoptionStateError are re-exported alongside the functions above, from @noy-db/hub/cargo — the same subpath as withCargo() and as CargoNotEnabledError, which has always lived there: import { CargoNotEnabledError } from '@noy-db/hub/cargo'.

Behavior when NOT opted in

extractPartition is the only gated call: without cargoStrategy: withCargo() on the source vault's client, it throws CargoNotEnabledError pointing you at withCargo() from @noy-db/hub/cargo. The recipient-side functions need no opt-in. An ordinary backup (writePod) has no bundleKind/transferSeal; adoptPartition rejects it with a ValidationError pointing you to readPod + vault.load instead. carrySchemas/carryLedger default to false, producing a clean partition with no _internal payload.

Security model

  • Owner-only extraction. Producing a re-keyed standalone vault is an ownership operation; a non-owner caller (including a custodian) is rejected.
  • The transfer key never travels in the bundle. extractPartition mints a random 32-byte key and seals the DEK set under it; the bundle bytes alone are inert. Deliver the key out-of-band — in person, through a secrets manager, or any channel outside the bundle transport.
  • DEKs stay sealed at rest after adoption. adoptPartition validates the key by unsealing in memory only; the sealed payload persists in _meta/adoption until createOwnerOnAdoptedPartition wraps the DEKs under the recipient's KEK and destroys the seal.
  • One-time per destination. Re-adopting the same bundle into the same store is rejected; into a different store it's allowed (the bundle is immutable — the consumed marker is local).
  • Cryptographic ownership. The recipient's control is that they hold the only DEKs, not a policy flag. The source owner cannot read the adopted partition.

Carry opt-ins

  • carrySchemas — re-keys _schemas/<collection> for the closure under the destination DEKs, so the recipient validates against the same contract and noy-db describe works on the partition.
  • carryLedger — filters the source audit chain to the closure, re-chains it (fresh indices/prevHash), recomputes each payloadHash against the re-keyed ciphertext for the latest put per record, and re-encrypts under a fresh _ledger DEK (sealed with the rest). The recipient's verifyBackupIntegrity() passes over the re-keyed data. (Open the vault with a history strategy to exercise it.)

Audit trail

All three lifecycle events ride a generic 'lifecycle' ledger op (empty collection/id, detail in reason; skipped by the data cross-check, present in the tamper-evident chain):

  • Source: extractPartition appends partition-handed-over:<sealId> (the owner's record that data left). No-op without a source history strategy.
  • Destination (when carryLedger was used): createOwnerOnAdoptedPartition appends creation-of-new-owner:<userId> + transfer-seal-consumed:<sealId> to the carried chain.

Edge cases & limits

  • Managed mode at owner-create is supported: pass { secretMode: 'managed', sealingKey, recovery, shamirRecovery } to createOwnerOnAdoptedPartition and the partition auto-unlocks on the recipient's device (e.g. via an at-* OS keychain) with no secret. Managed mode mandates a strong (Shamir) recovery profile at creation — the shamirRecovery provider (@noy-db/on-shamir) must be injected. A half-completed managed adoption (keyring + sealed secret written, recovery enrollment interrupted) is idempotent under retry — re-run with the same sealingKey + recovery.
  • walkClosure performance is O(frontier · inbound-collections · records) per depth — it re-scans each inbound collection for every frontier element, which is fine at consumer-firm scale and would need an index or pagination to stay fast over very large vaults; maxDepth (default 16) throws PartitionExtractionError on runaway graphs.
  • No source-side delete. The ceremony is non-destructive of source records; deleting the withdrawn records from the source afterward is a separate step the caller performs explicitly — extraction never does it for you.
  • carryLedger carries _ledger entries only — _ledger_deltas (historical versions) and _history snapshots do not travel with the partition.
  • Records need string ids — a non-string id fails loud (PartitionExtractionError) rather than silently dropping (which would dangle an FK).

See also