stable

custody

Sovereign custody: `withCustody()` adds a custodian role that operates a vault fully but can never grant, revoke, rotate, or extract — plus `liberate()`.
Subpath@noy-db/hub (root export — no dedicated subpath)
FactorywithCustody()
Layerwith-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 custodian role. 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 NoydbSealer the 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 alongside withCustody().

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:

  1. Check the liberate-vault gate (fail-closed).
  2. Verify the caller's role is custodian.
  3. Freeze a hash-pinned evidence snapshot of every operational collection — the live records are preserved, not deleted: liberation transfers operational continuity.
  4. Mint a new owner keyring for newOwnerId, re-wrapping the custodian's collection keys under a fresh key derived from newOwnerSecret.
  5. Append liberation-claimed:<newOwnerId>:<legalBasis> to the lifecycle ledger (skipped silently if history is not enabled).
  6. Stamp liberatedAt on the _meta/deed marker.

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:

FieldTypePurpose
userIdstringThe new custodian's principal id.
displayNamestringHuman-readable name stored on the keyring.
secretstringThe custodian's secret (validated against the vault's secret policy).
permissions?PermissionsOptional per-collection permission map.
exportCapability? / importCapability?—Optional format-export/import capability grants (role-based defaults when omitted).
allowWeakSecret?booleanSkip secret-strength validation. Default false.
initialProfile?unknownBootstrap-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?).

FieldTypePurpose
userIdstringThe 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:

FieldTypePurpose
newOwnerIdstringId of the new owner principal to mint. Must be a fresh id — a collision with any existing principal throws PermissionDeniedError before any side effect.
newOwnerSecretstringDerives the new owner's key; the collection keys are re-wrapped under it.
legalBasisstringRecorded in the audit entry (e.g. 'contractual-handover').
factors?FactorProofBundleProofs for the liberate-vault gate.

LiberateResult.snapshot is the hash-pinned pre-liberation evidence snapshot:

FieldTypeMeaning
withdrawalIdstringId of the frozen snapshot.
sha256stringHex digest pinning the snapshot contents.
recordCountnumberRecords captured.
frozenAtstringISO timestamp.

Deed helpers (root exports)

  • createDeedOwner(store, vault, ownerUserId, sealing) → Promise<UnlockedKeyring> — provision a sealed latent owner + the _meta/deed marker. Store-level by design: it mints the vault's first owner, so there is no vault instance (and no vault.custody) yet.
  • loadDeedMarker(store, vault) → Promise<DeedMarker | null> — read the plaintext marker (null for non-Deed vaults).
  • isDeedVault(store, vault) → Promise<boolean>.
  • DEED_RECORD_ID — the reserved marker id under _meta, 'deed'.

DeedMarker (plaintext, readable without unlocking):

FieldTypeMeaning
ownerUserIdstringThe latent owner principal.
sealedUnderstringId of the sealing provider anchoring ownership.
latenttrueAlways true for a Deed.
issuedAtstringISO timestamp of provisioning.
liberatedAt?stringSet by a completed liberate(); absent until then.

Other exports

  • liberateVault(vault, opts) — the lower-level free function vault.custody.liberate delegates to. It has no createNoydb instance to check opt-in against, so it is reachable without withCustody() — but it still enforces the liberate-vault gate and the custodian-only role check.
  • NO_CUSTODY — the default no-op strategy; every operation throws CustodyNotEnabledError.
  • CustodyNotEnabledError — thrown by every custody surface when withCustody() was not passed.
  • Types: CustodyStrategy, CustodyHost, GrantCustodianOptions, LiberateOptions, LiberateResult, DeedMarker.

Behavior when NOT opted in

  • vault.custody.grantCustodian / revokeCustodian / liberate — and the db-level db.grantCustodian / db.revokeCustodian — all throw CustodyNotEnabledError.
  • 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-custodian and liberate-vault are denied when absent from the vault policy — even for the right role. Enable them explicitly (and consider pinning minTier / 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.
  • newOwnerId must 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