withdrawal
| Subpath | @noy-db/hub/portability |
| Factory | withPortability() |
| Layer | with-audit |
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 anoperatorwith 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 withlistWithdrawalRequests()and settles it withapproveWithdrawal()orrejectWithdrawal(). 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 addteamStrategy: withTeam().- For the one-party (self-serve destructive) path: the firm must enable the policy gate described below, and the withdrawing user needs
rwon 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
| Role | unilateralWithdrawal | requestWithdrawal | approve / reject |
|---|---|---|---|
owner / admin | no — use extractPartition | needs explicit scope.collections | yes (the only roles that can) |
operator | yes — rw grants only, gate must be enabled | yes | no |
client / viewer | no — read-only by construction | yes — this is their path (a viewer must name scope.collections) | no |
custodian | no — use the audited custody liberate() ceremony | needs explicit scope.collections | no |
- Read-only roles can never delete anything, regardless of policy — a
client/viewercallingunilateralWithdrawalgets aReadOnlyErrorpointing atrequestWithdrawal. - Destructive self-serve requires
rwon every withdrawn collection; the export scope of a destructive op is the caller'srwgrants 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
owneroradminkeyring in addition to the policy gate — acustodianis 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 aFrozenSnapshotRef:
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.
| Gate | Guards | PERSONAL_POLICY default | STRICT_POLICY default |
|---|---|---|---|
client-unilateral-withdraw | unilateralWithdrawal | disabled | disabled; if a firm enables it, it inherits a two-factor proof (TOTP/email-OTP ×2) + shared-device block |
user-request-withdrawal | requestWithdrawal | enabled, tier 1 | enabled, tier 1 |
approve-user-withdrawal | approveWithdrawal / rejectWithdrawal | enabled, tier 2 | tier 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 }.
| Option | Type | Default | Meaning |
|---|---|---|---|
legalBasis | string | required | Recorded in the audit entry (e.g. 'gdpr-art-17') |
reKey | { secret: string } | inherit | Re-key the exported bundle |
disposition | 'delete' | 'freeze' | 'delete' | What happens to the source |
scope | { collections?: string[] } | all rw grants | Narrow the withdrawal |
withdrawalId | string | random | Stable id for idempotent resume after a crash |
vault.user.requestWithdrawal(opts?)
Files a durable pending request; returns { requestId, status, expiresAt? }.
| Option | Type | Default | Meaning |
|---|---|---|---|
scope | { collections?: string[] } | caller's grants | Required when the caller can read every collection |
disposition | 'delete' | 'freeze' | 'delete' | Requested source disposition |
legalBasis | string | — | Recorded on the request |
expiresInMs | number | never | After 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
| Error | Thrown when |
|---|---|
PortabilityNotEnabledError | Any method called without portabilityStrategy: withPortability() |
PolicyDeniedError | The guarding gate is disabled, the session tier is too low, or a required factor is missing |
ReadOnlyError | A role without the required write authority attempts a destructive op |
WithdrawalRequestError | Request 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:
| Operation | reason |
|---|---|
| One-party withdrawal | user-unilateral-withdrawal:<userId>:<disposition>:<legalBasis> |
| Request filed | user-withdrawal-request:<requestId>:<userId> |
| Request approved | user-withdrawal-approved:<requestId>:<requester>:<disposition> |
| Request rejected | user-withdrawal-rejected:<requestId>:<requester> |
| Freeze snapshot pinned | withdrawal-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_requestscollection — collection names, disposition, legal basis. Nothing secret in this trust model (the owner sees the data anyway), and no secret is ever stored. - A
freezesnapshot stays under the firm's own DEKs — it is retention, not erasure: the firm can still read it. Usedeletewhen the goal is that the data is gone. unilateralWithdrawalis unusable until the firm enablesclient-unilateral-withdrawin 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():exportMyAccessibleDataand the shared opt-in. - bundle — the
.noydbcontainer 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
extractPartitionfor 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.