transferable partitions
| 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.) |
| Factory | withCargo() gates the source-side extractPartition; describeExtraction, adoptPartition, createOwnerOnAdoptedPartition, and walkClosure are ungated named imports. withCargo() itself is exported from @noy-db/hub/cargo. |
| Layer | with-cargo |
| Cluster | F — Snapshot & Portability |
| LOC cost | off-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 gatesextractPartitiononly; 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-shamirprovider 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,_tsspan, graph depth,inaccessible[]); writes nothing. Takes the sameseeds/maxDepthaswalkClosurebelow.extractPartition(vault, options)→{ bundleBytes, transferKey, sealId }— owner-only; non-destructive on the source. Routes through the vault'scargoStrategy, so an un-opted-in caller throwsCargoNotEnabledError.Option Type Default Purpose seedsRecord<string, (record) => boolean | Promise<boolean>>required Per-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[]>none Per-collection allow-list of fields to keep; non-listed fields are dropped before re-encryption ( idis 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.optionsis one of two shapes:Standard mode Type Purpose userIdstringThe recipient's user id. secretstringThe recipient owner's secret. transferKeyUint8ArrayThe 32-byte key delivered out-of-band from extractPartition.Managed mode Type Purpose 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 shamirprofile (see Edge cases & limits).shamirRecoveryNoydbShamirThe @noy-db/on-shamirprovider 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 bydescribeExtraction).unsealDeks(seal, transferKey)→Map<string, CryptoKey>— the reverse of the internal DEK-seal step; used internally byadoptPartitionand exported for callers that need to validate a transfer key without adopting.decryptExtractedPartition(bundleBytes, transferKey)→Record<string, DecryptedRecord[]>— read-side counterpart toextractPartition: 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'+ atransferSealindicator. - Errors:
PartitionExtractionError,TransferSealError, andAdoptionStateErrorare re-exported alongside the functions above, from@noy-db/hub/cargo— the same subpath aswithCargo()and asCargoNotEnabledError, 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.
extractPartitionmints 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.
adoptPartitionvalidates the key by unsealing in memory only; the sealed payload persists in_meta/adoptionuntilcreateOwnerOnAdoptedPartitionwraps 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 andnoy-db describeworks on the partition.carryLedger— filters the source audit chain to the closure, re-chains it (fresh indices/prevHash), recomputes eachpayloadHashagainst the re-keyed ciphertext for the latest put per record, and re-encrypts under a fresh_ledgerDEK (sealed with the rest). The recipient'sverifyBackupIntegrity()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:
extractPartitionappendspartition-handed-over:<sealId>(the owner's record that data left). No-op without a source history strategy. - Destination (when
carryLedgerwas used):createOwnerOnAdoptedPartitionappendscreation-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 }tocreateOwnerOnAdoptedPartitionand the partition auto-unlocks on the recipient's device (e.g. via anat-*OS keychain) with no secret. Managed mode mandates a strong (Shamir) recovery profile at creation — theshamirRecoveryprovider (@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 samesealingKey+recovery. walkClosureperformance 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) throwsPartitionExtractionErroron 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.
carryLedgercarries_ledgerentries only —_ledger_deltas(historical versions) and_historysnapshots 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
- Pairs with: bundle (the
.noydbcontainer format), history (carryLedger), persisted-schemas (carrySchemas), custody (ownership and the custodian role), team - Permissions & Keyring — the key hierarchy that
createOwnerOnAdoptedPartitionbootstraps on the recipient side - Proven by showcase
88-transferable-partition.