decision
ADR-0024: User account status lifecycle (ban / suspend / scheduled-delete)
ADR-0024 (Approved, 2026-05-26): User account status lifecycle (ban / suspend / scheduled-delete).
Status: Approved
Date: 2026-05-26
Decider: Seth Shoultes
Author: Seth Shoultes
Closes decision portion of: #125
Context
The User row in V1 has no formal lifecycle state beyond is_admin, email_verified, and tier. “Ban a user” was discussed during the admin foundation work (PRs #131–#143) but never specified — different admin actions risked colliding on overlapping ad-hoc fields. The brain editor (#143) gives Sam content-moderation power; without a clear account-status model the next step (a Ban button on the admin user-detail page) would have ambiguous semantics.
This ADR locks the state model + schema before any code lands. Implementation ticket files separately.
Decision
State model — four states
users.account_status is a VARCHAR(32) NOT NULL DEFAULT 'active' constrained to one of:
| Status | Login | Chat append | Read own data | Billing | Sam visibility | Reversible |
|---|---|---|---|---|---|---|
active | yes | yes | yes | normal | yes | n/a |
suspended | yes | no — 403 | yes (read-only) | usage paused | yes | yes |
banned | no — 403 | no | n/a (no session) | usage paused | yes | yes (admin) |
deleted_pending | no — 403 | no | n/a | refund processed | yes | yes (within 30 days) |
Suspended users get read-only access. They can log in, view past conversations, view dashboards, and export their data (GDPR-friendly). chat.append and brain mutations return 403 with “your account is on hold — contact support.” Sam can investigate without removing the user’s content from view.
No additional read_only / dispute_hold state. suspended with a reason field already covers refund-dispute holds. Adding a fifth state for marginal cases creates UI ambiguity (Sam: “should I suspend or hold?”) without operational payoff.
email_unverified_locked and trial_expired are NOT account_status values. email_verified is already a separate boolean; trial_expired is a tier concept (entitlement layer), not a moderation state.
Schema — full transition history table
-- 1. Last-change snapshot on the user row (cheap, single-row read for hot paths)
ALTER TABLE users
ADD COLUMN account_status VARCHAR(32) NOT NULL DEFAULT 'active',
ADD COLUMN account_status_reason TEXT,
ADD COLUMN account_status_changed_at TIMESTAMPTZ,
ADD COLUMN account_status_changed_by UUID REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE users
ADD CONSTRAINT ck_users_account_status
CHECK (account_status IN ('active', 'suspended', 'banned', 'deleted_pending'));
CREATE INDEX ix_users_account_status ON users(account_status)
WHERE account_status != 'active';
-- 2. Full transition log (forensic / legal trail)
CREATE TABLE account_status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
from_status VARCHAR(32) NOT NULL,
to_status VARCHAR(32) NOT NULL,
reason TEXT,
changed_by UUID REFERENCES users(id) ON DELETE SET NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT ck_status_history_to CHECK (to_status IN ('active', 'suspended', 'banned', 'deleted_pending'))
);
CREATE INDEX ix_account_status_history_user ON account_status_history(user_id, changed_at DESC);
Why both fields AND history table: hot-path reads (get_current_user, chat append guard) need O(1) status lookup on the user row. The history table is forensic — read only when Sam clicks “show history” or counsel needs a legal answer. Retrofitting the history table after we have banned users in production is annoying (no transition data to backfill); cost is tiny to add now.
Application-layer enforcement points
| Layer | File | Rule |
|---|---|---|
| Auth (login) | api/auth/router.py::login | If account_status in ('banned', 'deleted_pending') → 403 with status-specific message. suspended allowed. |
| Auth (session) | api/auth/deps.py::get_current_user | Same as login. JWT may have been issued before status changed; re-check on every request. |
| Chat append | api/chat/router.py (POST endpoints) | If account_status == 'suspended' → 403 “account is on hold — contact support.” |
| Brain mutations | api/brain/router.py (PUT / DELETE) | Same suspended-403 rule. |
| Admin endpoint | api/admin/users.py::update_account_status (NEW) | POST /api/v1/admin/users/{id}/status body {status, reason}. Writes both users.account_status* columns AND an account_status_history row in the same transaction. Calls log_admin_action() with user.account_status_changed namespace. |
| Background sweep | api/internal/account_deletion_sweep.py (NEW, deferred) | Hard-delete users in deleted_pending whose account_status_changed_at < now() - 30 days. Stubbed until first deleted_pending row exists. |
Read-only access for suspended users — concrete rules
Suspended users hitting any mutating endpoint receive 403 with body:
{"detail": "account is on hold — contact support", "account_status": "suspended"}
Read-only endpoints (GET conversations, GET dashboard, GET brain) return normally. The frontend should surface a persistent banner when auth.me returns account_status != 'active'.
Alternatives considered
Five-state model with dispute_hold: Adds a state semantically identical to suspended but with different reason coding. Rejected — reason text on the existing state field carries the same information.
Last-change fields only, no history table: Simpler schema. Rejected because transition history is impossible to reconstruct after multiple state changes; the cost to add the table now is two columns of effort vs. months of regret if counsel ever asks “show me every state change for user X.”
Separate account_deletions table for deleted_pending: Cleaner separation of GDPR-delete plumbing from access control. Rejected for V1 — the same enforcement logic (block login + chat) applies to banned and deleted_pending users, so collapsing them into one column keeps the hot path simple. Revisit if the deletion sweep grows complex.
Implementation scope
This ADR is decision-only. Implementation lands in a separate PR series:
- PR A: Migration (the SQL above) + model fields +
_resolve_userupdates inget_current_userto enforce login/session guards. - PR B: Chat + brain endpoint guards for
suspended. Frontend banner. - PR C:
POST /api/v1/admin/users/{id}/status+ admin SPA button on the user-detail page + status history view. - PR D (deferred): Background sweep for
deleted_pending30-day hard-delete.
A follow-up implementation ticket will be filed referencing this ADR.
Related
- #125 — this decision ticket
- ADR 0014 — Cross-pollination security boundary (user-scoping invariants)
- ADR 0007 — Auth via JWT (where
get_current_userlives) - PRs #131–#143 — admin foundation series (the surface this attaches to)