M MemberIntel KB
Activity Decisions

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:

StatusLoginChat appendRead own dataBillingSam visibilityReversible
activeyesyesyesnormalyesn/a
suspendedyesno — 403yes (read-only)usage pausedyesyes
bannedno — 403non/a (no session)usage pausedyesyes (admin)
deleted_pendingno — 403non/arefund processedyesyes (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

LayerFileRule
Auth (login)api/auth/router.py::loginIf account_status in ('banned', 'deleted_pending') → 403 with status-specific message. suspended allowed.
Auth (session)api/auth/deps.py::get_current_userSame as login. JWT may have been issued before status changed; re-check on every request.
Chat appendapi/chat/router.py (POST endpoints)If account_status == 'suspended' → 403 “account is on hold — contact support.”
Brain mutationsapi/brain/router.py (PUT / DELETE)Same suspended-403 rule.
Admin endpointapi/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 sweepapi/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_user updates in get_current_user to 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_pending 30-day hard-delete.

A follow-up implementation ticket will be filed referencing this ADR.

  • #125 — this decision ticket
  • ADR 0014 — Cross-pollination security boundary (user-scoping invariants)
  • ADR 0007 — Auth via JWT (where get_current_user lives)
  • PRs #131–#143 — admin foundation series (the surface this attaches to)
For: S Seth Shoultes A AI Engineer B Blair Williams S Santiago Perez Asis P Product Lead