stable

withdrawal

Removal half of `withPortability()`: a non-owner withdraws the data their keyring can decrypt — self-serve (gated) or via a two-party ceremony.
Subpath@noy-db/hub/portability
FactorywithPortability()
Layerwith-audit
Two halves of one service. Withdrawal and export are the two workflows of the single withPortability() opt-in. This page covers withdrawal — the destructive half (export-then-dispose). For a non-destructive portable copy and the shared opt-in setup, see portability.

What it does

Withdrawal lets a non-owner remove their own accessible data from a shared vault — always building a re-keyed portable copy first, then disposing of the source (delete or freeze). Like export, it is scoped cryptographically: it operates only on collections whose DEKs the caller's keyring holds.

There are two workflows, by role:

  • One-party — unilateralWithdrawal() (destructive, disabled by default behind a fail-closed policy gate). For an operator with read-write grants who may remove their own slice in one step.
  • Two-party — requestWithdrawal() files a durable request; an owner or admin reviews it with listWithdrawalRequests() and settles it with approveWithdrawal() or rejectWithdrawal(). This is the path for read-only roles that cannot delete anything themselves.

When you need it

  • GDPR-style erasure (Art. 17) for the clients of a firm-run vault — a departing client's records leave the vault after they take their copy.
  • Letting a read-only client request deletion that the firm then executes under its own authority, with every step recorded in the tamper-evident ledger.
  • Regulated offboarding where the firm must retain a frozen, hash-pinned snapshot of the withdrawn records instead of deleting them outright.

Prerequisites

  • portabilityStrategy: withPortability() — withdrawal is part of the same opt-in as portability; in a multi-user vault add teamStrategy: withTeam().
  • For the one-party (self-serve destructive) path: the firm must enable the policy gate described below, and the withdrawing user needs rw on every withdrawn collection.
  • The two-party path is available to any granted member with no extra configuration.

Opt in

Withdrawal is enabled by the same portabilityStrategy: withPortability() opt-in as export — see portability → Opt in for the full createNoydb() setup. Without it, every vault.user.* method throws PortabilityNotEnabledError. The multi-user scenarios below assume a vault provisioned with withTeam(), where the firm (owner) has granted the caller a keyring.

The one-party path additionally requires the firm to enable a policy gate (below); the two-party path is available to any granted member with no extra configuration.

Two-party withdrawal (request → approve/reject)

The conservative path: the requester files a durable request; a firm-side owner or admin decides it. A read-only client or viewer — who can never delete records themselves — uses exactly this flow.

Requester side — file the request:

// clientVault was opened by a read-only `client` keyring
const req = await clientVault.user.requestWithdrawal({
  disposition: 'freeze',          // 'delete' (default) or 'freeze'
  legalBasis: 'gdpr-art-17',
  expiresInMs: 14 * 24 * 3600_000, // optional: request lapses after 14 days
})
// → { requestId: 'wr-9f2c41d0be57a6e83d1f0a2b', status: 'pending',
//     expiresAt: '2026-07-22T09:00:00.000Z' }

The requester's accessible collections are recorded automatically from their grants. A requester whose role can read every collection (e.g. a viewer) must name the collections explicitly via scope.collections — an all-collection withdrawal request is rejected as too broad.

Owner side — review the queue:

const pending = await ownerVault.user.listWithdrawalRequests({ status: 'pending' })
[
  {
    "requestId": "wr-9f2c41d0be57a6e83d1f0a2b",
    "requester": "client1",
    "collections": ["invoices"],
    "disposition": "freeze",
    "legalBasis": "gdpr-art-17",
    "status": "pending",
    "requestedAt": "2026-07-08T09:00:00.000Z",
    "expiresAt": "2026-07-22T09:00:00.000Z"
  }
]

Owner side — approve (extract-and-dispose under firm authority) or reject:

const { bundle, snapshot } = await ownerVault.user.approveWithdrawal(
  'wr-9f2c41d0be57a6e83d1f0a2b',
  { reKey: { secret: 'handover-secret' } }, // conveyed to the requester out-of-band
)
// bundle   → Uint8Array — the requester's re-keyed portable copy
// snapshot → { withdrawalId, sha256, recordCount, frozenAt }  (freeze disposition only)

// …or decline; no data is touched:
await ownerVault.user.rejectWithdrawal('wr-9f2c41d0be57a6e83d1f0a2b', {
  reason: 'statutory retention period still running',
})
// → the request record with status: 'rejected', decidedBy, decidedAt, rejectReason

The disposition the requester chose flows through end-to-end — approval runs the same extract-and-dispose engine as unilateralWithdrawal. The request record itself is plaintext metadata (collection names, disposition, legal basis — nothing the owner cannot already see) and carries no secret: the re-key secret is supplied by the approver at approval time and handed to the requester out-of-band, so no secret is ever stored at rest.

One-party withdrawal (operator self-serve)

An operator — a member with read-write grants on their collections — can withdraw in one step, if the firm has enabled the client-unilateral-withdraw gate. The gate is disabled by default and fails closed; the firm opts in at vault creation, per jurisdiction or contract:

const owner = await createNoydb({
  user: 'firm',
  secret: 'owner-pw-long-enough',
  teamStrategy: withTeam(),
  policy: {
    gates: { 'client-unilateral-withdraw': { enabled: true, minTier: 1 } },
  },
})
// operatorVault was opened by an `operator` keyring with rw grants
const { bundle, snapshot } = await operatorVault.user.unilateralWithdrawal({
  legalBasis: 'gdpr-art-17',
  disposition: 'delete',                       // default
  reKey: { secret: 'takeaway-secret' },
})
// → { bundle: Uint8Array } — the re-keyed copy; the source records are gone

Who can do what

RoleunilateralWithdrawalrequestWithdrawalapprove / reject
owner / adminno — use extractPartitionneeds explicit scope.collectionsyes (the only roles that can)
operatoryes — rw grants only, gate must be enabledyesno
client / viewerno — read-only by constructionyes — this is their path (a viewer must name scope.collections)no
custodianno — use the audited custody liberate() ceremonyneeds explicit scope.collectionsno
  • Read-only roles can never delete anything, regardless of policy — a client/viewer calling unilateralWithdrawal gets a ReadOnlyError pointing at requestWithdrawal.
  • Destructive self-serve requires rw on every withdrawn collection; the export scope of a destructive op is the caller's rw grants only.
  • Owner-class roles hold blanket authority over the vault, so "withdrawing my slice" is the wrong tool for them; they extract and hand over whole partitions instead.
  • Approving/rejecting requires an owner or admin keyring in addition to the policy gate — a custodian is deliberately excluded, because approving a destructive extract-and-dispose is a governance act on par with grant/revoke.

(For who may take a non-destructive copy, see portability → Who can do what — export is open to every role.)

Source dispositions

Both withdrawal paths accept a disposition deciding what happens to the source records after the portable copy is built:

  • delete (default) — delete-closure: the records leave the vault entirely (GDPR Art. 17-style erasure). The re-keyed bundle is always produced first, so a crash before deletion loses nothing; deletion is idempotent, so a crashed run can be re-run safely.
  • freeze — the firm retains a write-once, hash-pinned snapshot of the original ciphertext envelopes at _frozen_snapshots/<withdrawalId> (already under the firm's own DEKs — no re-key), then delete-closures the live records. The result includes a FrozenSnapshotRef:
interface FrozenSnapshotRef {
  withdrawalId: string   // 'wd-…'
  sha256: string         // hex digest of the snapshot body
  recordCount: number
  frozenAt: string       // ISO timestamp
}

The sha256 is pinned into the tamper-evident ledger, making the retained snapshot provably unaltered. The snapshot write is compare-and-swap "create-only", so re-running an interrupted withdrawal with the same withdrawalId never overwrites it.

Policy gates

Policy gates are named checkpoints evaluated against the caller's session strength — its session tier (tier 1 = full secret unlock, tier 2 = authenticated login, tier 3 = quick resume) — plus any extra factor proofs the vault's policy demands (see policy). Built-in gates fail closed when the policy does not configure them; only developer-defined app:* gates default to allow.

GateGuardsPERSONAL_POLICY defaultSTRICT_POLICY default
client-unilateral-withdrawunilateralWithdrawaldisableddisabled; if a firm enables it, it inherits a two-factor proof (TOTP/email-OTP ×2) + shared-device block
user-request-withdrawalrequestWithdrawalenabled, tier 1enabled, tier 1
approve-user-withdrawalapproveWithdrawal / rejectWithdrawalenabled, tier 2tier 2 + TOTP/email-OTP factor + shared-device block

Non-destructive export has no gate by design: a portable copy of data the caller can already decrypt cannot be denied — it is only audited.

API reference

All five withdrawal methods live on vault.user.* once the shared withPortability() strategy is wired. (The non-destructive exportMyAccessibleData lives on portability.)

vault.user.unilateralWithdrawal(opts)

Destructive one-party export-and-dispose. WithdrawResult is { bundle: Uint8Array; snapshot?: FrozenSnapshotRef }.

OptionTypeDefaultMeaning
legalBasisstringrequiredRecorded in the audit entry (e.g. 'gdpr-art-17')
reKey{ secret: string }inheritRe-key the exported bundle
disposition'delete' | 'freeze''delete'What happens to the source
scope{ collections?: string[] }all rw grantsNarrow the withdrawal
withdrawalIdstringrandomStable id for idempotent resume after a crash

vault.user.requestWithdrawal(opts?)

Files a durable pending request; returns { requestId, status, expiresAt? }.

OptionTypeDefaultMeaning
scope{ collections?: string[] }caller's grantsRequired when the caller can read every collection
disposition'delete' | 'freeze''delete'Requested source disposition
legalBasisstring—Recorded on the request
expiresInMsnumberneverAfter this, the request can no longer be approved

vault.user.listWithdrawalRequests({ status? })

Owner side. Optionally filter by status: 'pending' | 'approved' | 'rejected'.

interface WithdrawalRequest {
  requestId: string
  requester: string
  collections: readonly string[]
  disposition: 'delete' | 'freeze'
  legalBasis?: string
  status: 'pending' | 'approved' | 'rejected'
  requestedAt: string
  expiresAt?: string
  decidedAt?: string
  decidedBy?: string
  rejectReason?: string
  snapshotSha256?: string   // set on approved freeze-disposition requests
}

vault.user.approveWithdrawal(requestId, { reKey? })

Owner side. Extracts the requester's recorded scope under firm authority, disposes of the source per the request's disposition, marks the request approved, and returns the re-keyed bundle to hand back.

vault.user.rejectWithdrawal(requestId, { reason? })

Owner side. Declines a pending request — no data is touched; returns the updated record.

Errors

ErrorThrown when
PortabilityNotEnabledErrorAny method called without portabilityStrategy: withPortability()
PolicyDeniedErrorThe guarding gate is disabled, the session tier is too low, or a required factor is missing
ReadOnlyErrorA role without the required write authority attempts a destructive op
WithdrawalRequestErrorRequest not found, already decided, expired, or approve/reject called by a non-owner/admin

Audit

Every operation appends an op: 'lifecycle' entry to the tamper-evident ledger when the vault has history enabled (without it, auditing is a silent no-op). The entries participate in the ledger's hash chain but carry no data payload — only a structured reason:

Operationreason
One-party withdrawaluser-unilateral-withdrawal:<userId>:<disposition>:<legalBasis>
Request fileduser-withdrawal-request:<requestId>:<userId>
Request approveduser-withdrawal-approved:<requestId>:<requester>:<disposition>
Request rejecteduser-withdrawal-rejected:<requestId>:<requester>
Freeze snapshot pinnedwithdrawal-frozen-snapshot:<withdrawalId>:<sha256>

(The non-destructive export entry, user-export:<userId>, is on portability → Audit.)

Limits & gotchas

  • Withdrawal requests live as plaintext metadata in the reserved _user_withdrawal_requests collection — collection names, disposition, legal basis. Nothing secret in this trust model (the owner sees the data anyway), and no secret is ever stored.
  • A freeze snapshot stays under the firm's own DEKs — it is retention, not erasure: the firm can still read it. Use delete when the goal is that the data is gone.
  • unilateralWithdrawal is unusable until the firm enables client-unilateral-withdraw in the vault policy — the gate fails closed by default.
  • A requester with all-collection read access must pass an explicit scope.collections; the ceremony refuses an unbounded "withdraw everything" request.
  • Expired requests (past expiresAt) can no longer be approved or rejected — WithdrawalRequestError.
  • Audit entries are only written when a history/ledger strategy is configured; enable withHistory() for the compliance trail.

See also

  • portability — the non-destructive other half of withPortability(): exportMyAccessibleData and the shared opt-in.
  • bundle — the .noydb container format; readPod() to open a withdrawn bundle.
  • team — roles, grants, and per-collection permissions.
  • session-tiers / policy — the tier ladder and the gate DSL the withdrawal gates are built on.
  • transferable-partitions — owner-side extractPartition for handing over whole partitions.
  • custody — the custodian role and the audited liberate() ceremony.
  • forget — per-record crypto-shred erasure by subject key.
  • history — the tamper-evident ledger the audit entries land in.
  • Proven by showcase 120-client-portability-withdrawal.