Account transfer — design
Status: approved design, ready for an implementation plan
Date: 2026-08-11
Depends on: migration 00183 (users.deleted_at, soft delete), aclSpecificityLadder
(dms/adapters/acl_pg.go), usersLackingDocumentRead (httpapi/assign_guard.go)
The problem
SoftDeleteUser is unconditional. There is no precondition check anywhere between the admin
pressing Delete and the account being stripped. Deletion today removes everything that
grants the person access — role bindings, ACL entries, position assignments, group
memberships, delegations, secure-folder key wraps — and keeps the users row so historical
references still resolve to a name.
What it does not do is deal with anything the person still holds. Five things strand:
| # | What strands | Consequence |
|---|---|---|
| 1 | documents.owner_id, folders.owner_id — plain text, never touched by delete |
The deleted account stays owner. Owner implies Manage and outranks every deny in the ladder. The UI filters deleted users out, so nobody can see or change it. |
| 2 | workflow_tasks.assignee_user_id (state pending), document_requests.requester_id, esign_envelope_signers.user_id, esign_pending_seals.signer_user_id, esign_seal_lands.signer_user_id, triage_items.principal_user_id, workflow_escalations.to_user_id |
Work in flight freezes with no one able to act. A paid seal mid-ceremony has no one to finish it. |
| 3 | checkout_locks.locked_by |
The document is locked forever; there is no steal-lock UI. |
| 4 | secure_folder_member_keys — deleted by soft delete |
For an at_rest vault the server still holds the KEK. For a Private Vault this is terminal: migration 00180 states there is no escrow and no break-glass, by design. If the leaver held the last wrap, the folder's contents are unrecoverable. |
| 5 | share_links.created_by, short_links.created_by |
Live links keep serving, attributed to an account that no longer exists. |
Doctrine
Transfer authority, never attribution.
Signatures (signatures.signer_user_id), document versions (document_versions.created_by),
the append-only audit chain, sent letters, meterai_records.requested_by — these keep the
original person's name forever. Moving them would falsify the record, which is the one thing
a records system must never do.
Only two classes of thing move:
- Authority — what the person may do that someone must continue doing (ownership).
- Obligation — work assigned to them that blocks until someone acts (tasks, requests,
locks, in-flight ceremonies).
Everything else is history and stays put. This single rule decides most of the questions
below, and any future question about a table not listed here.
Decisions taken
| Fork | Decision |
|---|---|
| When it runs | Both. Transfer is a standalone action available any time (offboarding, role change, extended leave), and delete refuses while the blocking set is non-empty. |
| Successor granularity | One successor for everything, with a review screen that allows per-item override before committing. Rules-based routing (e.g. "documents go to the owner's manager by position") is explicitly rejected: it sounds smart and produces surprises nobody can predict. |
| Private Vault | Refuse + explicit loss confirmation. Deletion is refused if the leaver holds the last wrap for a private vault. The owner may override with a type-to-confirm acknowledgement of permanent, unrecoverable loss. The server cannot re-wrap on their behalf — it can never unwrap. |
Model
The blocking set
One read-only computation, used by three callers (the transfer screen, the delete
precondition, and the transfer executor). It returns, for a given user, a list of
(category, table, count, sample) rows.
type HoldingKind string // "ownership" | "obligation" | "lock" | "vault" | "link"
type Holding struct {
Kind HoldingKind
Category string // stable machine key, e.g. "documents.owned", "workflow.tasks"
Count int
Blocking bool // does this refuse a delete?
Transferable bool // can the executor move it?
SampleIDs []string // at most 5, for the UI to link to
}
Blocking and Transferable are separate axes on purpose. A Private Vault wrap is blocking
and NOT transferable — which is exactly the case that needs its own confirmation path. A
share link is transferable and not blocking.
Initial catalogue:
| Category | Kind | Blocking | Transferable | Action on transfer |
|---|---|---|---|---|
documents.owned |
ownership | yes | yes | documents.owner_id := successor; rebuild document_acl_read |
folders.owned |
ownership | yes | yes | folders.owner_id := successor; rebuild folder_acl_read for the subtree |
workflow.tasks |
obligation | yes | yes | reassign pending workflow_tasks; append a workflow_transitions row recording the reassignment |
workflow.escalations |
obligation | yes | yes | workflow_escalations.to_user_id := successor |
requests.outstanding |
obligation | yes | yes | document_requests.requester_id := successor |
esign.envelope_signers |
obligation | yes | no | a signer is a named legal party — see below |
esign.pending_seals |
obligation | yes | no | a paid ceremony is bound to its signer — see below |
triage.items |
obligation | yes | yes | triage_items.principal_user_id := successor |
checkout.locks |
lock | yes | yes | checkout_locks.locked_by := successor (a re-lock, not a release) |
vault.sole_wrap |
vault | yes | no | refuse; loss confirmation only |
share.links |
link | no | yes | share_links.created_by / short_links.created_by := successor |
gatekeeper.rules |
obligation | no | yes | already deleted by soft-delete; listed so the UI can say so |
Why in-flight signatures are NOT transferable
A pending signature is a named legal party's obligation, not a work item. Reassigning it
would mean person B's signature appears where person A was asked to sign, under an audit
chain that says A was invited. The only correct outcomes are cancel (voiding the
ceremony and, for a paid seal, routing it to the existing repair queue from migration 00175)
or let A complete it before the account goes. The transfer screen offers cancel; it never
offers reassign.
This is the same principle as signatures.signer_user_id never moving. It is the doctrine
applied to something that has not happened yet.
Flow
A. Standalone transfer (POST /admin/users/{id}/transfer)
- Admin opens Admin → Users → ⋮ → Transfer holdings.
- Screen loads
GET /admin/users/{id}/holdings— the blocking set, grouped by category,
each with a count and up to five sample links. - Admin picks one successor. The picker is the existing subject picker, restricted to
live, enabled users, excluding the leaver. - Eligibility check runs immediately, before anything is offered. See Security below.
- Optional per-category override: a category can be pointed at a different successor, or
excluded from this run. - Non-transferable categories are shown with what they do offer (cancel, or the loss
confirmation). - Admin confirms. The executor runs in one transaction and writes one audit event per
category plus one summary event. - Result screen: what moved, what did not, what is left.
The account is untouched — still enabled, still logged in. Transfer is not deletion.
B. Delete with a non-empty blocking set
DELETE /admin/users/{id} gains a precondition. When the blocking set is non-empty it
returns 409 with code auth.user.holdings_outstanding and the blocking set in the problem
detail. The UI turns that into the transfer screen with a "then delete" checkbox, so the
common path is one flow, not two.
?force=true is not offered. The only bypass is the vault loss confirmation, which is
its own explicit body field naming the folder ids being abandoned — not a general override.
C. Vault loss
If vault.sole_wrap is non-empty, the transfer screen shows each affected folder by name
with the plain sentence: this folder's contents cannot be recovered after this account is
deleted; no administrator, including you, can restore them. Proceeding requires typing the
folder count. The body carries abandon_vaults: ["<folder-id>", ...] — an explicit list, so
a stale UI cannot abandon a vault that appeared after the screen loaded.
The right answer is almost always to have the leaver add a second member wrap while they
can still log in. The screen says so, first, above the destructive option.
Security design
Transfer is a privilege-granting operation. Five rules:
-
Its own permission —
users.transfer. Not folded intousers.admin. Otherwise
everyone who can delete an account can absorb everything it held, and offboarding becomes
a privilege-escalation primitive. -
The successor must pass the assign-beyond-ACL guard. Reuse
usersLackingDocumentRead(httpapi/assign_guard.go) — the same check that decides
whether a person may be assigned a workflow step. Without this, "transfer to me" is a
one-click read of everything the leaver could read. Where the successor lacks access, the
executor refuses that item and reports it; it never silently grants.
The exception, stated explicitly: ownership transfer necessarily confers access, since
owner implies Manage. So documents.owned and folders.owned require the successor to
already hold at least Read on the item, or the admin must accept an explicit
grant_access: true flag which is recorded in the audit event as a deliberate widening.
-
Step-up required. Same class as break-glass. Fail-closed: no valid step-up grant, no
transfer. -
One transaction, idempotent, fail-closed. A partial transfer is worse than none — it
leaves a person half-owning things with no record of which half. Re-running with the same
(leaver, successor, categories)must be a no-op, so the executor filters on the leaver
still being the current holder rather than blindly updating. -
The audit event names rows, not counts. "moved 43 documents" is unauditable. Each
category event carries the id list (capped, with the full list written to the audit
payload). This is the record of a privilege change and it has to answer which.
Two things that must not regress
- Read-model rebuild. Moving
owner_idwithout callingReplaceDocumentReadModel/
ReplaceFolderReadModelleavesdocument_acl_read.is_ownerpointing at the ghost, and
the ladder keeps resolving against it. Folder ownership moves must walk the subtree
(FolderIDsUnderSubtree). - Deleted users must stay invisible. The successor picker, the holdings screen and the
result screen all read/admin/users; that endpoint excludes deleted users by default and
needs?include_deleted=trueonly where a historical name is being rendered. The leaver
themself is addressed by id, never by list membership.
Data
No new tables are strictly required — every change is an UPDATE on an existing column. One
new table is added for the record:
-- Migration 001XX
CREATE TABLE account_transfers (
id uuid PRIMARY KEY,
from_user_id text NOT NULL,
to_user_id text NOT NULL,
-- category -> {moved: n, refused: n, ids: [...]}
outcome jsonb NOT NULL DEFAULT '{}'::jsonb,
abandoned_vaults text[] NOT NULL DEFAULT '{}',
performed_by text NOT NULL,
performed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX account_transfers_from_idx ON account_transfers (from_user_id, performed_at DESC);
The audit chain already records the event; this table exists so the user screen can show
"holdings transferred to X on date Y" without reconstructing it from audit entries.
Out of scope
- Rules-based / per-position routing (rejected above).
- Transferring anything in the attribution class. Ever.
- Bulk transfer of many leavers at once. One account at a time; a bulk wrapper can come later
over the same executor. - Re-wrapping Private Vault keys. The server cannot, and no design makes it able to.
Testing
Backend verification follows the house rule — never go test against the shared DSN.
The blocking-set query and the executor are exercised against a scratch pgvector/pgvector:pg17
in dind with a seeded fixture; the eligibility and precedence logic is pure and gets a scratch
go run harness. End-to-end runs against the demo with a non-admin leaver, since every demo
user is admin by default and an all-admin roster cannot prove the guard fires.
Specific cases that must be covered because they are where this breaks:
- Owner moves →
document_acl_read.is_ownerfollows, and the ladder resolves for the
successor and no longer for the leaver. - Folder owner moves → the whole subtree's read model is rebuilt, not just the folder row.
- Successor lacking Read → that item is refused and reported, and no grant is written.
- Re-running the same transfer → no-op, no duplicate audit event.
- Delete with a pending workflow task → 409 with the category named, not a generic error.
- Sole Private Vault wrap → delete refused; with
abandon_vaultsnaming that folder,
permitted, and the folder is recorded as abandoned.