Skip to main content

ADR-031: User Lifecycle State Machine β€” Admin Panel Scope & Groom

Date: 2026-06-23 Status: Proposed (PM scope/groom β€” pending review) Issue: #1291 (parent epic #1236) Authors: degen11 (PM agent)

Numbering note: #1291 asked for this file at 024-user-lifecycle-state-machine.md. By the time this was written, 024 was already taken by 024-haystack-migration.md (and 025–030 are also in use), so this ADR lands at 031. Same content, next free number.

Context​

The /ops/users and /ops/team surfaces render every user with a green "Active" pill regardless of whether they have ever logged in. Eusden flagged this on the client-creation UAT thread (2026-05-31):

what's the point of the 7d invite if all users are "Active" by default after invite? There's no separate Invited state. Also as a superadmin there's no way in the More actions to reset someone's password, just Remove / Impersonate. Also is there more metadata associated with a user? If so, there's no expanded view either inline or as a new page.

ADR-023 (PRs #1250 + #1286) closed the invite half of the lifecycle (link shape, one-shot accept, token rotation, self-resend). This ADR is the user half β€” what happens after (or instead of) acceptance: the canonical state model, how each state surfaces in the admin panel, and the actions that move users between states.

This is a scope + groom deliverable. No implementation. The output is the canonical spec that #1235 (password reset), #1240 (edit-user), #682 (suspend workspace user), and #474 (auth/login-blocked states) consume so they stop inferring conflicting semantics from the codebase.

What the model actually supports today (and where it has drifted)​

The data layer already supports a multi-state lifecycle. The grooming surfaced three layers that disagree with each other β€” this is the structural problem #1291 exists to settle, not just the missing UI:

LayerSourceStates it knows about
Canonical typeapi/src/auth/auth.entities.ts:26 β€” UserStatusactive, invited, suspended
Entity column defaultauth.entities.ts:101-102default "invited", indexed (@Index(["status"]))
Admin write DTOapi/src/auth/auth.dto.ts:227,250 β€” AdminSettableUserStatus + @IsIn([...])active, suspended, deactivated
Session-revocation setapi/src/auth/auth.controller.ts:67 β€” JWT_REVOKING_STATUSESsuspended, deactivated
Login gateapi/src/auth/auth.service.ts:214-219blocks suspended, invited β€” NOT deactivated
Frontend edit modalfrontend/src/app/ops/users/page.tsx:65 β€” EDITABLE_STATUSESactive, suspended, deactivated
Status badgefrontend/src/components/ui/UserStatusBadge.tsxactive→success, invited→warning, suspended→danger, else default

Concrete bugs this drift produces (must be fixed by the consuming issues, called out here so they aren't lost):

  1. deactivated is a ghost state. The DTO accepts it, the controller persists it (updateUser, auth.controller.ts:473), and it triggers JWT revocation β€” but it is not in the UserStatus type, so it's an untyped string in the DB, and login never blocks it (auth.service.ts:214-219 only checks suspended/invited). A deactivated user's existing sessions are revoked, but they can obtain a fresh token via password/OTP login. This is a real auth gap, not just cosmetic.
  2. invited users exist as a status value but, in the dominant flow, never as a User row. inviteMember creates an Invite row, not a User row; the User row is created on accept and stamped status = "active" directly (auth.service.ts:1584,1591 for the no-OTP path; 1084,1092 for the legacy OTP path). So in practice the invited status is mostly dead β€” pending invites live in the invites table and are returned as pendingInviteList from GET /admin/members (admin.service.ts:169-212) but rendered only as a counter. The team table shows everyone as Active by construction.
  3. removed has no representation. DELETE /admin/members/:id (admin.service.ts:614-625) deletes the OrgMembership row only β€” it does not touch the User. "Remove" is org-scoped detach, not user lifecycle. There is no anonymise / hard-delete path for the User itself.

So the surface is wrong and the model is internally inconsistent. This ADR locks the canonical set so the dependent issues converge instead of each picking a different enum.

Industry survey​

Surveyed 2026-06-23 (member/seat lifecycle, not org/billing lifecycle):

SystemPending invite shown as…Suspend/disable (block login, keep data)Deactivate / removeRight-to-erasure
Stripe DashboardRow, status "Invited", Resend/Revokeβ€” (role removal only)Remove memberOn request
LinearRow, "Pending" + Resend/RevokeSuspend member (re-activatable)Remove (data reassigned)On request
NotionRow, "Pending", Resend/RevokeSuspend (Enterprise)Remove from workspaceOn request
VercelRow, "Pending"β€”Remove memberOn request
GitHub OrgsRow, "Pending invitation", CancelSuspend user (Enterprise/SAML)Remove from orgOn request
Google WorkspaceSeparate "Pending" filterSuspend (canonical; blocks sign-in, retains data)Delete user (20-day recovery)Delete after recovery window
OktaRow, "Pending activation"Suspend (re-activate) + Deactivate (terminal, re-activatable) + DeleteDeactivate→Delete two-stepDelete
Auth0β€”blocked: true flagDelete userDelete

Patterns that converge:

  • Pending invites render as rows, with status "Invited/Pending" and Resend/Revoke. (Stripe / Linear / Notion / Vercel / GitHub.) This is option A in #1291 and the recommended default.
  • Suspend = reversible login block, data retained. Universal. This is what our suspended already does at login.
  • Deactivate vs Delete split (Okta/Google): deactivate is a terminal-but-reversible "off" state that retains the row for audit; delete/erasure is the GDPR path that anonymises. We adopt this two-tier model: deactivated (retain, reversible) + removed (anonymise, terminal).

Decision​

D1 β€” Canonical user lifecycle states​

The canonical UserStatus becomes a four-value enum at the platform-user (users table) layer:

// api/src/auth/auth.entities.ts
export type UserStatus = "invited" | "active" | "suspended" | "deactivated";

removed is not a status value β€” it is a terminal transition (anonymise/hard-delete) that destroys or scrubs the row rather than flagging it (see D2). This matches the Okta "Deactivated is a state; Deleted is an event" model and keeps the enum honest: every value in it is a state a live row can be in.

                 accept invite
invited ───────────────────────► active
β”‚ β”‚ β–²
β”‚ revoke invite β”‚ β”‚ reactivate
β–Ό suspend β”‚ β”‚
(Invite row deleted) β”‚ β”‚ reactivate
β–Ό β”‚
suspended
β”‚
deactivateβ”‚ (also reachable from active)
β–Ό
deactivated
β”‚
remove β”‚ (reachable from any non-removed state)
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ removed = anonymiseβ”‚ (terminal; row scrubbed/deleted)
β”‚ or hard-delete β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Rationale for adding deactivated rather than overloading suspended:

  • suspended carries an operational/punitive connotation ("payment failed", "policy violation") and is expected to be temporary. The OrgStatus enum already uses both suspended and deactivated with exactly this split (auth.entities.ts:44-51) β€” mirroring it at the user layer keeps the two lifecycles legible.
  • deactivated is the "this person left / offboarded, keep the audit trail" state. It is the user-layer analogue of OrgStatus.deactivated.
  • It is already half-implemented (DTO, JWT revocation set, frontend modal). D1 finishes the job by (a) adding it to the type, (b) adding a login gate, and (c) deciding its semantics here β€” rather than leaving it as the ghost state described in Context.

D2 β€” State semantics matrix (the canonical table)​

For each state: DB representation, who can transition in, the audit action, login behaviour, and channel reachability. Implementation issues consume this table verbatim.

StateDB representationEntered by (actor β†’ action)Audit actionLoginEmail / Slack / channel reachability
invitedNo User row yet (dominant flow): an Invite row exists (used_at IS NULL, expires_at > now()). The users.status="invited" value is retained for the rare pre-provisioned-user case but is not the primary representation.SuperAdmin/AM/org_admin β†’ POST /admin/members/invite (creates Invite, per ADR-023)admin.member.invitedBlocked with "complete your account setup" (auth.service.ts:216-219) β€” only reachable if a User row was pre-created.Transactional invite email only (ADR-023). No notifications, no channel binding.
activeusers.status = "active"invitee β†’ accept invite (auth.service.ts:1584/1591/1084/1092); or SuperAdmin β†’ reactivate via PATCH /admin/users/:id {status:"active"}admin.member.accepted / admin.user.updatedAllowed (MFA gate applies if enrolled).Full: notifications honour notificationPreferences + emailSuppressed; channel bindings live.
suspendedusers.status = "suspended"SuperAdmin β†’ PATCH /admin/users/:id {status:"suspended"} (auth.controller.ts:397). Bumps passwordChangedAt β†’ revokes live JWTs (auth.controller.ts:479-494).admin.user.updated (diff includes status + passwordChangedAt:"revoked")Blocked, "Account suspended" (auth.service.ts:214-215). Reversible β†’ active.Transactional only (security/account emails). No notifications, no proactive channel sends.
deactivatedusers.status = "deactivated" (D1 adds to type). Same JWT-revocation as suspend (already in JWT_REVOKING_STATUSES, auth.controller.ts:67).SuperAdmin β†’ PATCH /admin/users/:id {status:"deactivated"}admin.user.updatedBlocked β€” requires a new login gate (see D2-gap). Reversible β†’ active (reactivate).None. Treated as offboarded; suppress all email; channel bindings inert.
removedNot a status. Terminal: either anonymise (scrub PII: emailβ†’removed+<id>@…, name/avatar/phone/bio nulled, passwordHash cleared) or hard-delete the User row (cascades org_memberships).SuperAdmin β†’ DELETE /admin/users/:id (new β€” distinct from today's DELETE /admin/members/:id which only detaches a membership).admin.user.removed (retain audit row even when the user row is gone β€” audit_logs.userId is nullable, common/entities.ts:853-887).N/A.N/A.

Reachability rules: active ⇄ suspended, active β†’ deactivated β†’ active, suspended β†’ deactivated, and {any non-removed} β†’ removed. invited β†’ active (accept) or invited β†’ (revoked) (Invite deleted, per ADR-023 D-revoke). No transition into invited from a live active/suspended row (re-inviting an accepted user is a 400 per ADR-023 D5).

D2-gap (login gate for deactivated) β€” REQUIRED FIX, owned by the lifecycle-migration sub-issue:

auth.service.ts:214-219 must add the deactivated branch so a fresh login is blocked, not just existing sessions revoked:

if (user.status === "suspended")
throw new ForbiddenException("Account suspended");
if (user.status === "deactivated")
throw new ForbiddenException("Account deactivated. Contact your administrator.");
if (user.status === "invited")
throw new ForbiddenException("Please complete your account setup β€” check your email…");

Until this lands, deactivated is not a real login block (see Context bug #1). This is the single highest-priority correctness item falling out of the groom.

D3 β€” Pending invites render as rows (option A)​

Adopt option A from #1291: unaccepted invites surface as rows in the team/users table with status = "Invited" and a Resend / Revoke affordance, not as a hidden counter.

  • Matches Stripe / Linear / Notion / Vercel / GitHub (industry consensus, see survey).
  • The data already exists: pendingInviteList from GET /admin/members (admin.service.ts:184-204) carries {id, email, orgRole, expiresAt}. The team page (frontend/src/app/ops/team/page.tsx) and users page (frontend/src/app/ops/users/page.tsx) merge it into the row set rather than rendering a count.
  • Row shape for an invite (no User row yet): email, role (from invite), status badge Invited (UserStatusBadge already maps invitedβ†’warning), "invited Nd ago" derived from the invite's history/expiry, and a More menu scoped to Resend invite (ADR-023 D3 rotation) + Revoke invite.
  • Disambiguation: invite-rows are keyed by invite:<id>, user-rows by user:<id>, so the merged table never collides and the More menu can branch on row kind.
  • The existing count stays as a header summary ("3 pending invites"), but is no longer the only representation.

Rejected: option B (separate tab β€” less discoverable) and option C (count only β€” today's behaviour, the thing the issue exists to kill).

D4 β€” User-detail expanded view: full-page route /ops/users/[id]​

Spec a full-page route at /ops/users/[id], not a modal or slide-over.

Why full-page over modal:

  • The field set (identity + lifecycle timeline + memberships + Specialist access + activity) exceeds what a modal holds comfortably; #1291's own field list has five sections.
  • A route is linkable/shareable ("look at this user") and bookmarkable for support β€” modals are not.
  • The existing edit interaction stays a modal (it already is β€” ops/users/page.tsx:551-679), launched from the detail page's "Edit" action. Detail = read + navigate; Edit = focused mutation. This keeps #1240 (edit form) a contained change layered on top of the detail route.

Fields, in precedence order:

  1. Identity β€” avatar, name/displayName, email, platform role, roleDomain, HP credential ID (hpCredentialId), MFA status (twoFactorEnabled + enrolled/last-used). Edit affordance β†’ #1240; role escalation re-confirmation β†’ #1240 (the superadminConfirm field already exists in the edit modal).
  2. Lifecycle β€” current status badge + an audit timeline: invited at, accepted at (OrgMembership.joinedAt / accept audit), last login (from audit_logs auth.login), suspended/deactivated at + actor + reason, passwordChangedAt. Sourced per D6.
  3. Memberships β€” orgs the user belongs to with org_role per row (org_memberships), and, for Experts, the Specialists they hold access on via ExpertAccessService (ADR-007 β€” do not query expert_access directly or re-invent per-surface ACL logic; CLAUDE.md security rule 4).
  4. Actions β€” the More menu per D5.
  5. Activity β€” last N audit_logs entries for the user; for Experts, recent queue items / conversations they touched (scoped through ExpertAccessService, never an org_id-only query β€” ADR-020 row-1/row-5 contract).

D5 β€” More-actions menu: per role Γ— per state, capability-gated​

The … menu today offers only Remove + Impersonate (ops/team/page.tsx:252-266); ops/users adds Edit + Reset-password-when-active (ops/users/page.tsx:486-510). Canonical menu, gated by (viewer role) Γ— (target state):

SuperAdmin viewing another user:

Target stateMenu items
invited (invite-row)Resend invite Β· Revoke invite Β· View details
activeView details Β· Edit (#1240) Β· Reset password (#1235) Β· Impersonate Β· Suspend Β· Deactivate Β· Remove
suspendedView details Β· Edit Β· Reset password Β· Reactivate Β· Deactivate Β· Remove
deactivatedView details Β· Reactivate Β· Remove

AM viewing an Expert in their roster: View details Β· Reset password (#1235 stretch) Β· Remove from roster (membership detach, not user remove). AM viewing another AM: View details only. Expert: not present on /ops/team at all.

Gating rules (consume #1236 role-boundary work):

  • Last-active-SuperAdmin guard already enforced server-side (auth.controller.ts:434-449): cannot suspend/deactivate/demote the only remaining active SuperAdmin. The menu must hide/disable those items for that case, but the server check is the source of truth.
  • Self-target guard already enforced (auth.controller.ts:414-418): cannot suspend/deactivate self. Hide those items when viewer === target.
  • Impersonate is SuperAdmin-only (admin.controller.ts:297 @PlatformRoles("superadmin")); hide for AM.
  • Reset password appears only for active today (ops/users/page.tsx:492-500); D5 keeps it for active+suspended (a suspended user may need a reset before reactivation) but not invited (use Resend) or deactivated.

D6 β€” Lifecycle timeline is sourced from audit_logs, not new columns​

The detail-view timeline (D4 Β§2) reads from the existing audit_logs table (common/entities.ts:853-887), filtered by resourceType="user" / resourceId=<id> plus the user's own auth.login rows. No new per-event timestamp columns beyond what the entity already has (passwordChangedAt, MFA timestamps, createdAt, membership joinedAt).

  • "Last login" = most recent audit_logs row with action="auth.login" for the user. (If login isn't yet audited, the lifecycle-migration sub-issue adds that one auditService.tryLog call β€” cheap, eventual-consistency per the Data Consistency Contract.)
  • "Suspended at + reason" = the admin.user.updated audit diff that set status; reason is captured by extending that audit payload with an optional reason (additive).
  • Audit writes stay outside the business transaction (intentional eventual consistency β€” CLAUDE.md Data Consistency Contract); a missing audit row never blocks a lifecycle transition.

This keeps the spec additive: the timeline is a read over data we already capture, so it does not gate the migration on backfilling new columns.

D7 β€” Capability matrix for the org-membership (#682) layer is deferred but reconciled​

#682 (suspend, not delete, a workspace user) operates at the org_memberships layer β€” a user can be active platform-wide but suspended within one org. This ADR scopes the platform-user (users.status) layer. To keep the two from conflicting:

  • Platform suspended/deactivated is global (all orgs) β€” it's a property of the person's account.
  • Org-membership suspension (#682) is per-org and belongs on org_memberships (add org_memberships.status there, not on users). The two compose: effective access = min(platform_status, membership_status).
  • The user-detail Memberships section (D4 Β§3) is where per-org status will surface once #682 lands.

This is called out so #682 adds a membership status column rather than overloading users.status.

Sub-issues to file under #1236​

This ADR is the spec. Implementation breaks into (file under epic #1236, each consuming this document):

  1. Lifecycle migration (additive). Add deactivated to the UserStatus type (auth.entities.ts:26); add the deactivated login gate (D2-gap, auth.service.ts); add DELETE /admin/users/:id (anonymise/hard-delete, distinct from membership detach) with the IS_PRODUCTION hard-delete guard pattern (CLAUDE.md security rule 3). No column changes β€” the enum is a string column, so adding a value is non-breaking under expand-contract.
  2. Pending-invite-as-row UI (D3) β€” merge pendingInviteList into the team/users tables with Resend/Revoke.
  3. User-detail page /ops/users/[id] scaffolding (D4).
  4. More-actions menu wiring per state Γ— role (D5), reconciled with #1236 role boundaries.
  5. Audit-timeline queries powering the detail view (D6), incl. the auth.login audit call if absent.

Existing issues that should reference this ADR once it lands: #1235 (password reset β€” consume D2/D5), #1240 (edit-user β€” consume D1/D4/D5), #682 (workspace suspend β€” consume D7), #474 (auth login-blocked states β€” consume D2 + D2-gap), #1236 (parent).

Consequences​

Positive​

  • One canonical four-state enum (invited/active/suspended/deactivated) with a defined removed terminal β€” ends the type/DTO/login/frontend drift documented in Context.
  • Closes a real auth gap: deactivated becomes a true login block (D2-gap), not just session revocation.
  • Pending invites become visible work, not a hidden counter (Eusden's core complaint).
  • #1235 / #1240 / #682 / #474 build on a settled model instead of each inventing a status set.
  • Detail view + audit timeline reuse audit_logs β€” no new state tables, no backfill gating.

Negative / costs​

  • Adding deactivated to the type touches every exhaustive switch (status) β€” small, compile-time-caught.
  • DELETE /admin/users/:id (anonymise) is a new, sensitive endpoint β€” must inherit the production hard-delete guard and SuperAdmin-only gate; needs careful audit-retention review (right-to-erasure vs audit trail).
  • A new login gate changes observable behaviour for any currently-deactivated rows (they lose fresh-login ability) β€” intended, but call it out in the migration PR.

Out of scope (deliberately)​

  • Implementation. This is scope + groom only.
  • Org lifecycle β€” covered by OrgStatus + #587 / #680.
  • Org-membership (org_memberships) lifecycle β€” #682 (D7 reconciles the boundary).
  • Client-side end-user lifecycle (org_admin / org_member / client_admin / client_member as clients) β€” different blast radius / threat model; spec separately if needed.
  • Bulk operations (CSV suspend/remove) β€” separate feature.

Migration​

Forward-only and additive, following expand-contract (CLAUDE.md zero-downtime rules):

  • UserStatus is a varchar column with no DB-level enum constraint, so adding the deactivated value is a type-only change in auth.entities.ts β€” no DDL.
  • The login gate (D2-gap) and DELETE /admin/users/:id are pure code additions.
  • No column drops/renames. The removed anonymise path mutates/deletes rows at runtime, not schema.
  • ADR-023 β€” Invite lifecycle (the invite half; this is the user half)
  • ADR-007 β€” Expert access scope (Specialist access in D4 Β§3 goes through ExpertAccessService)
  • ADR-020 β€” Isolation classification (activity queries in D4 Β§5 must not be org_id-only)
  • ADR-011 β€” Workspace lifecycle (org-layer analogue)
  • #1291 (this issue), #1236 (epic), #1235, #1240, #682, #474