Documentation
¶
Overview ¶
Package account ends an account's life, and then erases what ending it could not reach.
**It is a gap being closed, not a feature being added** (finding F44). Nothing in this product deleted a user until M52, while the schema had described erasure in the present tense from the first migration — `users.anonymized_at` carried the comment *"set by the GDPR erasure routine"* and had no writer, and by M45 the count of places asserting a routine that did not exist had reached five. Each was corrected in place rather than built. This package is the build.
Its own package rather than a method on internal/auth, for the reason internal/recovery gives: internal/auth answers *who is this request*, and the three services that change an account's existence — signup, recovery, and now this — each own their refusals and land beside it instead of inside it. There is also a hard reason. internal/audit imports internal/auth, so internal/auth cannot record anything without an interface seam, and this operation has to write its audit record inside the transaction that performs it.
The two halves, and why they are two ¶
**Deletion is interactive and immediate.** One transaction: verify the password, refuse what must be refused, remove everything that grants access, stamp `deleted_at` and `status = 'deleted'`. When it returns, no credential reaches the account and the address is free for a new one.
**Erasure is a batched sweep, and it lags.** The identifying residue lives in **four** tables, and *no foreign key to `users`* is no longer the criterion that finds them. It was M52's, and it named two: `audit_logs` and `destination_disputes`, which deliberately carry none because a record that vanishes with its subject is not a record. The sweep since reaches `notifications` (`00600:127`) and `invitations` (`01200:62`), which do carry one — the row belongs to a *different* reader, so a cascade off this account would never have touched it, and nothing else expires it. The criterion that holds over all four is *a record about this person that ending the account does not remove*. They are scrubbed in place, and `anonymized_at` marks the row whose residue has gone. The gap between the two timestamps is the sweep's cadence, it is bounded by an hour, and `docs/SECURITY.md` states that as a number because a compliance reader is entitled to know erasure is not instantaneous.
What each scrub reaches and why is in `query/accounts.sql`'s header on `EraseDeletedAccounts`, which is where the count is kept — one enumeration, beside the statement, rather than a second one here that can drift from it.
What a soft delete does not do ¶
It fires no foreign key. Eight tables declare `ON DELETE CASCADE` against `users` and every one of those clauses triggers on `DELETE`, so under a kept row the cascade never runs. `DeleteAccountDependents` is what stands in for it, and query/accounts.sql enumerates them and says why four are there beyond the four M52 names. Six of them were M52's; `mfa_recovery_codes` and `mfa_pending_logins` joined at M53, which is the milestone that created them — a recovery code admits somebody to an account with no password, so leaving one behind a deleted account is the `password_resets` defect in a new table.
Four other columns reference `users` with `ON DELETE SET NULL` — `links.created_by`, `invitations.invited_by`, `invitations.redeemed_by` and `instance_grants.granted_by` — and those are left pointing at the erased row on purpose. That is D148: the ids survive, the labels become a constant, and correlating an erased actor's entries is the id rather than anything derived from it. Nulling them would destroy the correlation the decision chose to keep, in exchange for hiding a uuid that identifies nobody from inside this instance.
Index ¶
Constants ¶
const TombstoneLabel = "deleted account"
TombstoneLabel is what an erased person's address or name becomes wherever the sweep replaces one rather than blanking it — **seven sites, not the three actor snapshots this comment named until M58**: `audit_logs.actor_label`, the `"email"` key and each matching element of the `"from"` array inside `audit_logs.metadata`, `notifications.data`'s `"email"` key and the address inside the title beside it, and `destination_disputes.created_by_label` and `decided_by_label`.
Two places take the empty string instead, and neither is an omission: `invitations.email`, because redemption compares an address against that column and a placeholder reading like a label is a value the comparison would then have to rule out, and `users.email`/`name` for the same reason one column over. `query/accounts.sql` is where that list is kept current; this comment says what the constant *means* and defers the enumeration to the statement that writes it.
**A constant, and the ids survive.** That is D148, owner-set 2026-08-08 and answered before any of this was written. The alternative shapes were a pseudonym derived from the account — reversible by anybody holding the input — and a random token stored per account with the ids nulled, which is the stronger claim and stays available: this migrates to that, where that could not migrate back.
The cost the decision accepted rather than removed: a surviving uuid is pseudonymous data, so `docs/SECURITY.md` says the residue identifies nobody *from inside this instance* rather than that it is anonymous. Anybody holding an external id-to-person mapping re-identifies the actor, and that is a sentence in the security document, not a defect.
It cannot collide with a live actor's label. `actorLabel` in internal/audit prefers the address and falls back to the display name only when the address is empty, which no live account's is — the column is NOT NULL and every writing path validates one.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Auditor ¶
type Auditor interface {
RecordTx(ctx context.Context, q *dbgen.Queries, actor *auth.Identity, e audit.Event) error
}
Auditor is the seam onto internal/audit.
**RecordTx and not Record**, which is the whole reason this interface is narrower than audit.Recorder. The actor of an account deletion is the account being deleted, so a record written after the commit sits in the window between deletion and erasure carrying an address — and if the hourly sweep lands in that window, `anonymized_at` is set before the record exists and the address stays in `audit_logs` for good. Joining the transaction makes the ordering a fact instead of a race nobody would ever reproduce.
type Config ¶
type Config struct {
// Auth verifies the account's own password. Required: confirmation is the
// password and there is no confirmation without it.
Auth *auth.Service
// Audit records the deletion, inside the transaction that performs it. Nil
// records nothing, on the same terms every other service in this tree
// offers — but see RecordTx's note, because here the record and the change
// stand or fall together.
Audit Auditor
Log *slog.Logger
}
Config is what a Service needs.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service deletes accounts and erases what deletion leaves behind.
func (*Service) Delete ¶
Delete removes the acting account, and only the acting account.
**There is no administrative delete-somebody-else here**, and its absence is a decision rather than an omission. Who may end another person's account is a permission-model question, and D38's precedent is that inventing one inside another milestone is the wrong place to answer it.
What it refuses ¶
**The instance principal** (D98). Deleting the account that administers the box leaves no path back that does not involve SQL: `instance_grants` is how every instance-level permission reaches a person, `ListInstanceGrantHolders` hides a soft-deleted holder, and nothing in the product can confer the principal on somebody new. `lctl instance principal move --to <email>` is the route, and the refusal names it.
**The sole owner of an organization that still exists.** M28.5 refuses removing an organization's last owner; this is the same rule approached from the other side, because a rule you can step around by leaving through a different door is not a rule. Every account has an auto-provisioned personal organization it owns alone, so in practice this is the refusal most people meet first, and the message says which organizations are blocking so the remedy is obvious.
What it does not refuse ¶
**An account that would be left belonging to nothing.** That state is D36's and it is already real: `identityWithoutOrganization` resolves it, the session path treats it as an empty state, and handing over every organization on the way out is exactly how somebody arrives at it. Refusing here would mean the only way to leave is to be the last person out.
What it removes, and what it deliberately does not ¶
Everything that grants access or points a live surface at the account goes in this transaction: memberships, sessions, API keys, notifications, outstanding password-reset tokens and instance-level grants. What stays is what the schema keeps on purpose — the audit trail, disputes, and the links, invitations and grants the account touched — because those are records of the past. Their identifying residue is the erasure sweep's, not this transaction's.
Uploaded QR logos are reached by none of this, and saying so is the point. They hang off `qr_codes` → `links` → workspaces and organizations, never off `users`, and the sole-owner refusal above means every organization the account belongs to outlives it. A deletion that removed their logos would be destroying a surviving tenant's data.
func (*Service) ErasePending ¶
ErasePending scrubs one batch of deleted accounts and returns how many it took.
Called by the hourly `housekeeping` pass under `advisoryLockKeyMaintenance`, and **not** by a job family of its own: a new advisory key and a new goroutine for a sweep that finds nothing on almost every run is cost without a reason, which is the same answer M51's reset purge got.
Idempotent and re-entrant, because the two-leader window during a rolling deploy is a stated property of this scheduler rather than something this milestone may assume away. `FOR UPDATE SKIP LOCKED` gives a second leader a disjoint batch instead of a wait, and a second pass over the same row writes nothing.
**Two reasons, not one**, which is what the *Re-entrant* note on `EraseDeletedAccounts` says and what this comment claimed until M58. **Four** scrubs are keyed on an **id** — the actor label, the two dispute labels and `invitations.email` on the rows the account redeemed — and **three** on the **address**: the `"email"` key and the `"from"` array inside `audit_logs.metadata`, and the notification the inviter received.
The id-keyed ones carry a guard, and it is not one guard. Three compare against the tombstone they are about to write. `invitations` cannot: it blanks the column rather than labelling it, so its guard is an emptiness test on `i.email` beside `i.redeemed_at IS NOT NULL`, which is the same claim in the vocabulary that column uses. The address-keyed ones carry none and need none — after one pass the value they matched on is no longer in the column, so the second pass finds nothing to rewrite.
Counted against the statement rather than recalled, twice now: this comment was rewritten at M58 to fix a miscount and said *three* where the tree says four, because `scrubbed_invitations` fits neither branch of a dichotomy drawn between tombstone guards and no guard at all. The integration test runs the pass twice and diffs.
type SoleOwnerError ¶
type SoleOwnerError struct {
// Organizations is the display names, in the order the query returned them.
Organizations []string
}
SoleOwnerError refuses a deletion and names the organizations blocking it.
A type rather than a formatted string because both surfaces need the list: the API puts the sentence in a problem document and the dashboard puts it beside the button, and "which ones" is the only part of the refusal anybody can act on. It unwraps to domain.ErrConflict, so every caller that already handles a conflict handles this without knowing the type exists.
func (*SoleOwnerError) Error ¶
func (e *SoleOwnerError) Error() string
func (*SoleOwnerError) Unwrap ¶
func (e *SoleOwnerError) Unwrap() error