custody
| Subpath | @noy-db/hub (root export — no dedicated subpath) |
| Factory | withCustody() |
| Layer | with-party |
What it does
Suppose a service firm runs a client's data day-to-day, but the client must remain the only true owner — the firm must never be able to lock the client out, hand access to a third party, or walk away with the data. The custody service encodes that arrangement cryptographically inside a vault (an encrypted, per-tenant data space — see Core 01 — Vault and collections).
It rests on two pieces:
- The
custodianrole. A custodian's keyring (the per-user file of wrapped encryption keys — see Core 06 — Permissions and keyring) carries every collection's data-encryption key, so the custodian reads and writes everything. But it is structurally denied every ownership-level act: it cannot grant or revoke users, cannot rotate keys, and cannot extract-and-sever the vault. - The Deed. A Deed vault is one whose owner never authenticates: the owner secret
is minted machine-side as random bytes and sealed under a
NoydbSealerthe client controls (their KMS, their keychain — never the operating firm's infrastructure). That sealing boundary is the ownership anchor: only the holder of the provider can ever re-derive the owner key, and there is deliberately no firm-controlled fallback.
The third piece connects them: vault.custody.liberate() is the one audited route by which
a custodian becomes an owner — for example at the end of the service contract. Liberation
mints a distinct new owner from the custodian's keys and records the act in the vault's
lifecycle ledger; it never unseals or impersonates the original sealed owner.
Prerequisites
teamStrategy: withTeam()— custody rides the multi-user keyring engine (grant/revoke), so the team strategy must be passed alongsidewithCustody().
Opt in
Pass custodyStrategy: withCustody() to createNoydb. The custody gates fail closed:
grant-custodian and liberate-vault are denied unless the vault policy explicitly
enables them (revocation runs through the shared revoke-user gate). See
session-tiers for the policy-gates DSL.
import { createNoydb, withCustody, withTeam } from '@noy-db/hub'
import { toMemory } from '@noy-db/to-memory'
const store = toMemory() // any NoydbStore works
const db = await createNoydb({
store,
user: 'owner-01',
secret: 'owner-secret-long',
teamStrategy: withTeam(), // multi-user keyrings (grant/revoke engine)
custodyStrategy: withCustody(), // the custody surface
policy: {
gates: {
'grant-custodian': { enabled: true, minTier: 1 },
'liberate-vault': { enabled: true, minTier: 1 },
'revoke-user': { enabled: true, minTier: 1 },
},
},
})
const vault = await db.openVault('acme')
Without withCustody(), every custody operation throws CustodyNotEnabledError. The
heavy liberation engine is loaded via dynamic import inside withCustody(), so none of it
ships in your bundle until you opt in.
Usage
Provision a Deed vault (sealed owner)
createDeedOwner is a store-level function (it mints the vault's very first owner, so
there is no vault instance yet). It generates a random owner secret, seals it under
the client-held provider, writes the owner keyring, and stamps a plaintext _meta/deed
marker:
import { createDeedOwner, loadDeedMarker, MemorySealer } from '@noy-db/hub'
// Held by the CLIENT — their KMS or keychain, never the operating firm's infra.
// MemorySealer is the in-memory stand-in for tests and examples;
// production providers ship as separate packages.
const sealing = new MemorySealer({ id: 'client-kms' })
await createDeedOwner(store, 'acme', 'owner-01', sealing)
const marker = await loadDeedMarker(store, 'acme')
{
"ownerUserId": "owner-01",
"sealedUnder": "client-kms",
"latent": true,
"issuedAt": "2026-07-08T09:14:03.412Z"
}
The marker is deliberately plaintext: an auditor (or the custodian) must be able to see
"this is a Deed vault, owner is owner-01, sealed under client-kms" without any key. It
carries no secret material — only the owner credential is sealed. The latent owner never
types a secret; re-entry goes through the same sealing provider (managed-secret
mode — see session-tiers).
Grant a custodian — full operation, no ownership
Only the owner can mint a custodian (an admin caller is denied). The custodian receives a keyring wrapping every collection key:
await vault.custody.grantCustodian({
userId: 'firm-01',
displayName: 'Operating firm',
secret: 'firm-pass-long',
})
// The custodian opens the vault and operates every collection…
const firmDb = await createNoydb({
store, user: 'firm-01', secret: 'firm-pass-long', teamStrategy: withTeam(),
})
const firmVault = await firmDb.openVault('acme')
await firmVault.collection('invoices').get('inv-001')
// → { id: 'inv-001', amount: 5000, status: 'draft' }
await firmVault.collection('invoices').put('inv-002', { id: 'inv-002', amount: 1200, status: 'sent' })
// → ok — full read/write on every collection
// …but every ownership-level act is denied:
await firmDb.grant('acme', { userId: 'mole-01', displayName: 'Mole', role: 'admin', secret: 'mole-pass-long' })
// → throws PermissionDeniedError (cannot widen the principal set)
await firmDb.rotate('acme', [])
// → throws PermissionDeniedError (re-key is an owner-only meta-capability)
A custodian is also denied destructive self-service withdrawal
(vault.user.unilateralWithdrawal throws ReadOnlyError) and partition extraction
(extract-and-sever throws PartitionExtractionError — see
transferable-partitions). The only way a custodian
ever gains ownership is the audited liberate() ceremony below.
Revoke a custodian
The owner removes the custodian at any time — without the custodian's cooperation:
await vault.custody.revokeCustodian({ userId: 'firm-01' })
// firm-01's keyring is deleted; a fresh open as firm-01 now fails
Liberate — the audited ownership claim
When ownership legitimately passes to the operating party (say, a contractual handover),
the custodian claims the vault. Liberation is gated (liberate-vault, fail-closed),
custodian-only (an owner or admin caller is denied), and audited:
import { withHistory } from '@noy-db/hub/history'
const firmDb = await createNoydb({
store, user: 'firm-01', secret: 'firm-pass-long',
teamStrategy: withTeam(),
custodyStrategy: withCustody(),
historyStrategy: withHistory(), // so the ceremony lands in the lifecycle ledger
policy: { gates: { 'liberate-vault': { enabled: true, minTier: 1 } } },
})
const firmVault = await firmDb.openVault('acme')
const result = await firmVault.custody.liberate({
newOwnerId: 'firm-owner-01',
newOwnerSecret: 'firm-owner-pass-long',
legalBasis: 'contractual-handover',
})
{
"snapshot": {
"withdrawalId": "01JZGJ3V9GQ2N9W3H4X5T6K7R8",
"sha256": "9f2c41a7c3e8d0b6f5a1e2d3c4b5a6978877665544332211ffeeddccbbaa0099",
"recordCount": 3,
"frozenAt": "2026-07-08T10:02:41.007Z"
}
}
The ceremony runs six steps, in order:
- Check the
liberate-vaultgate (fail-closed). - Verify the caller's role is
custodian. - Freeze a hash-pinned evidence snapshot of every operational collection — the live records are preserved, not deleted: liberation transfers operational continuity.
- Mint a new owner keyring for
newOwnerId, re-wrapping the custodian's collection keys under a fresh key derived fromnewOwnerSecret. - Append
liberation-claimed:<newOwnerId>:<legalBasis>to the lifecycle ledger (skipped silently if history is not enabled). - Stamp
liberatedAton the_meta/deedmarker.
Afterwards the new owner is a fully independent principal and the data is intact:
const entries = await firmVault.ledger().entries()
// → includes { op: 'lifecycle', actor: 'firm-owner-01',
// reason: 'liberation-claimed:firm-owner-01:contractual-handover' }
const newOwnerDb = await createNoydb({
store, user: 'firm-owner-01', secret: 'firm-owner-pass-long', teamStrategy: withTeam(),
})
const newOwnerVault = await newOwnerDb.openVault('acme')
await newOwnerVault.collection('invoices').get('inv-001')
// → { id: 'inv-001', amount: 5000, status: 'draft' } (live data preserved)
The original sealed-owner credential is never touched: its keyring file remains, still sealed under the client's provider, now orphaned. Claiming ownership always produces a different principal plus an audit trail — a custodian can never silently become the latent owner.
API reference
withCustody()
function withCustody(): CustodyStrategy
Builds the active custody strategy. Pass it as custodyStrategy to createNoydb — this is
the only wiring step. Takes no options. The liberation engine is dynamically imported by
the strategy, keeping it out of the base bundle.
vault.custody.grantCustodian(options, factors?)
vault.custody.grantCustodian(options: GrantCustodianOptions, factors?: FactorProofBundle): Promise<void>
Owner-only: mint a custodian. Defended in depth — the grant-custodian gate (fail-closed)
and an explicit owner-role check, so a misconfigured gate can never widen who may grant.
Also available db-level as db.grantCustodian(vault, options, factors?); the vault method
delegates to it.
GrantCustodianOptions is GrantOptions with the role fixed to custodian:
| Field | Type | Purpose |
|---|---|---|
userId | string | The new custodian's principal id. |
displayName | string | Human-readable name stored on the keyring. |
secret | string | The custodian's secret (validated against the vault's secret policy). |
permissions? | Permissions | Optional per-collection permission map. |
exportCapability? / importCapability? | — | Optional format-export/import capability grants (role-based defaults when omitted). |
allowWeakSecret? | boolean | Skip secret-strength validation. Default false. |
initialProfile? | unknown | Bootstrap-only user-envelope payload for the new principal. |
vault.custody.revokeCustodian(options, factors?)
vault.custody.revokeCustodian(options: RevokeOptions, factors?: FactorProofBundle): Promise<void>
Owner-only: remove a custodian. Runs through the shared revoke-user gate plus the same
explicit owner-role check — an admin cannot unwind a custodianship. Also available as
db.revokeCustodian(vault, options, factors?).
| Field | Type | Purpose |
|---|---|---|
userId | string | The custodian to revoke. |
cascade? | 'strict' | 'warn' | Admin-delegation cascade behavior; no effect for custodians (they can never grant, so they have no delegation subtree). |
vault.custody.liberate(opts)
vault.custody.liberate(opts: LiberateOptions): Promise<LiberateResult>
Custodian-only: the audited ownership claim over a Deed vault (ceremony above).
LiberateOptions:
| Field | Type | Purpose |
|---|---|---|
newOwnerId | string | Id of the new owner principal to mint. Must be a fresh id — a collision with any existing principal throws PermissionDeniedError before any side effect. |
newOwnerSecret | string | Derives the new owner's key; the collection keys are re-wrapped under it. |
legalBasis | string | Recorded in the audit entry (e.g. 'contractual-handover'). |
factors? | FactorProofBundle | Proofs for the liberate-vault gate. |
LiberateResult.snapshot is the hash-pinned pre-liberation evidence snapshot:
| Field | Type | Meaning |
|---|---|---|
withdrawalId | string | Id of the frozen snapshot. |
sha256 | string | Hex digest pinning the snapshot contents. |
recordCount | number | Records captured. |
frozenAt | string | ISO timestamp. |
Deed helpers (root exports)
createDeedOwner(store, vault, ownerUserId, sealing)→Promise<UnlockedKeyring>— provision a sealed latent owner + the_meta/deedmarker. Store-level by design: it mints the vault's first owner, so there is no vault instance (and novault.custody) yet.loadDeedMarker(store, vault)→Promise<DeedMarker | null>— read the plaintext marker (nullfor non-Deed vaults).isDeedVault(store, vault)→Promise<boolean>.DEED_RECORD_ID— the reserved marker id under_meta,'deed'.
DeedMarker (plaintext, readable without unlocking):
| Field | Type | Meaning |
|---|---|---|
ownerUserId | string | The latent owner principal. |
sealedUnder | string | Id of the sealing provider anchoring ownership. |
latent | true | Always true for a Deed. |
issuedAt | string | ISO timestamp of provisioning. |
liberatedAt? | string | Set by a completed liberate(); absent until then. |
Other exports
liberateVault(vault, opts)— the lower-level free functionvault.custody.liberatedelegates to. It has nocreateNoydbinstance to check opt-in against, so it is reachable withoutwithCustody()— but it still enforces theliberate-vaultgate and the custodian-only role check.NO_CUSTODY— the default no-op strategy; every operation throwsCustodyNotEnabledError.CustodyNotEnabledError— thrown by every custody surface whenwithCustody()was not passed.- Types:
CustodyStrategy,CustodyHost,GrantCustodianOptions,LiberateOptions,LiberateResult,DeedMarker.
Behavior when NOT opted in
vault.custody.grantCustodian/revokeCustodian/liberate— and the db-leveldb.grantCustodian/db.revokeCustodian— all throwCustodyNotEnabledError.- Everything else on the vault works normally.
- The liberation engine never loads: it is reached only through
withCustody()'s dynamic import.
Limits & gotchas
- The custody gates fail closed.
grant-custodianandliberate-vaultare denied when absent from the vault policy — even for the right role. Enable them explicitly (and consider pinningminTier/ factor requirements for such ownership-level acts). - Role checks are structural, not just policy. Owner-only (grant/revoke) and custodian-only (liberate) are enforced by explicit keyring-role checks in addition to the gates, so a permissive gate cannot widen who may act.
- Liberation never unseals the original owner. It mints a distinct principal; the old sealed credential stays orphaned in place. If you need the original owner back, go through the client's sealing provider — that is the only re-entry point, by design.
- Liberation preserves live data. The evidence snapshot is freeze-only; nothing is delete-closured. For destructive exits, see the withdrawal flows on user-envelope and withdrawal.
newOwnerIdmust be fresh. A collision with any existing principal aborts before any side effect — no orphan snapshot, no partial keyring overwrite.- The ledger entry needs history. Without
withHistory(), step 5 of the ceremony is a silent no-op. Enable history on any vault where liberation must be provable after the fact. - The Deed marker is plaintext on purpose. It is audit metadata (who the latent owner is, which provider seals them) and must stay readable without keys; it never carries secret material.
See also
- Services overview · Orientation map
- team — roles, grant/revoke/rotate; custody builds on the keyring model
- session-tiers — the policy-gates DSL and managed-secret mode
- history — the lifecycle ledger that records liberation
- transferable-partitions — extract-and-sever, the owner-only capability custodians are denied
- Core 06 — Permissions and keyring — keyrings, wrapped keys, roles
- Proven by showcase
129-with-custody.