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,024was already taken by024-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:
| Layer | Source | States it knows about |
|---|---|---|
| Canonical type | api/src/auth/auth.entities.ts:26 β UserStatus | active, invited, suspended |
| Entity column default | auth.entities.ts:101-102 | default "invited", indexed (@Index(["status"])) |
| Admin write DTO | api/src/auth/auth.dto.ts:227,250 β AdminSettableUserStatus + @IsIn([...]) | active, suspended, deactivated |
| Session-revocation set | api/src/auth/auth.controller.ts:67 β JWT_REVOKING_STATUSES | suspended, deactivated |
| Login gate | api/src/auth/auth.service.ts:214-219 | blocks suspended, invited β NOT deactivated |
| Frontend edit modal | frontend/src/app/ops/users/page.tsx:65 β EDITABLE_STATUSES | active, suspended, deactivated |
| Status badge | frontend/src/components/ui/UserStatusBadge.tsx | activeβ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):
deactivatedis 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 theUserStatustype, so it's an untyped string in the DB, and login never blocks it (auth.service.ts:214-219only checkssuspended/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.invitedusers exist as a status value but, in the dominant flow, never as aUserrow.inviteMembercreates anInviterow, not aUserrow; theUserrow is created on accept and stampedstatus = "active"directly (auth.service.ts:1584,1591for the no-OTP path;1084,1092for the legacy OTP path). So in practice theinvitedstatus is mostly dead β pending invites live in theinvitestable and are returned aspendingInviteListfromGET /admin/members(admin.service.ts:169-212) but rendered only as a counter. The team table shows everyone as Active by construction.removedhas no representation.DELETE /admin/members/:id(admin.service.ts:614-625) deletes theOrgMembershiprow only β it does not touch theUser. "Remove" is org-scoped detach, not user lifecycle. There is no anonymise / hard-delete path for theUseritself.
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):
| System | Pending invite shown as⦠| Suspend/disable (block login, keep data) | Deactivate / remove | Right-to-erasure |
|---|---|---|---|---|
| Stripe Dashboard | Row, status "Invited", Resend/Revoke | β (role removal only) | Remove member | On request |
| Linear | Row, "Pending" + Resend/Revoke | Suspend member (re-activatable) | Remove (data reassigned) | On request |
| Notion | Row, "Pending", Resend/Revoke | Suspend (Enterprise) | Remove from workspace | On request |
| Vercel | Row, "Pending" | β | Remove member | On request |
| GitHub Orgs | Row, "Pending invitation", Cancel | Suspend user (Enterprise/SAML) | Remove from org | On request |
| Google Workspace | Separate "Pending" filter | Suspend (canonical; blocks sign-in, retains data) | Delete user (20-day recovery) | Delete after recovery window |
| Okta | Row, "Pending activation" | Suspend (re-activate) + Deactivate (terminal, re-activatable) + Delete | DeactivateβDelete two-step | Delete |
| Auth0 | β | blocked: true flag | Delete user | Delete |
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
suspendedalready 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:
suspendedcarries an operational/punitive connotation ("payment failed", "policy violation") and is expected to be temporary. TheOrgStatusenum already uses bothsuspendedanddeactivatedwith exactly this split (auth.entities.ts:44-51) β mirroring it at the user layer keeps the two lifecycles legible.deactivatedis the "this person left / offboarded, keep the audit trail" state. It is the user-layer analogue ofOrgStatus.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.
| State | DB representation | Entered by (actor β action) | Audit action | Login | Email / Slack / channel reachability |
|---|---|---|---|---|---|
| invited | No 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.invited | Blocked 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. |
| active | users.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.updated | Allowed (MFA gate applies if enrolled). | Full: notifications honour notificationPreferences + emailSuppressed; channel bindings live. |
| suspended | users.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. |
| deactivated | users.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.updated | Blocked β requires a new login gate (see D2-gap). Reversible β active (reactivate). | None. Treated as offboarded; suppress all email; channel bindings inert. |
| removed | Not 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:
pendingInviteListfromGET /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
Userrow yet): email, role (from invite), status badgeInvited(UserStatusBadgealready mapsinvitedβwarning), "invited Nd ago" derived from the invite's history/expiry, and a More menu scoped toResend invite(ADR-023 D3 rotation) +Revoke invite. - Disambiguation: invite-rows are keyed by
invite:<id>, user-rows byuser:<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:
- 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 (thesuperadminConfirmfield already exists in the edit modal). - Lifecycle β current status badge + an audit timeline: invited at, accepted at (
OrgMembership.joinedAt/ accept audit), last login (fromaudit_logsauth.login), suspended/deactivated at + actor + reason,passwordChangedAt. Sourced per D6. - Memberships β orgs the user belongs to with
org_roleper row (org_memberships), and, for Experts, the Specialists they hold access on viaExpertAccessService(ADR-007 β do not queryexpert_accessdirectly or re-invent per-surface ACL logic; CLAUDE.md security rule 4). - Actions β the More menu per D5.
- Activity β last N
audit_logsentries for the user; for Experts, recent queue items / conversations they touched (scoped throughExpertAccessService, never anorg_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 state | Menu items |
|---|---|
| invited (invite-row) | Resend invite Β· Revoke invite Β· View details |
| active | View details Β· Edit (#1240) Β· Reset password (#1235) Β· Impersonate Β· Suspend Β· Deactivate Β· Remove |
| suspended | View details Β· Edit Β· Reset password Β· Reactivate Β· Deactivate Β· Remove |
| deactivated | View 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
activetoday (ops/users/page.tsx:492-500); D5 keeps it foractive+suspended(a suspended user may need a reset before reactivation) but notinvited(use Resend) ordeactivated.
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_logsrow withaction="auth.login"for the user. (If login isn't yet audited, the lifecycle-migration sub-issue adds that oneauditService.tryLogcall β cheap, eventual-consistency per the Data Consistency Contract.) - "Suspended at + reason" = the
admin.user.updatedaudit diff that setstatus; reason is captured by extending that auditpayloadwith an optionalreason(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/deactivatedis global (all orgs) β it's a property of the person's account. - Org-membership suspension (#682) is per-org and belongs on
org_memberships(addorg_memberships.statusthere, not onusers). 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):
- Lifecycle migration (additive). Add
deactivatedto theUserStatustype (auth.entities.ts:26); add thedeactivatedlogin gate (D2-gap,auth.service.ts); addDELETE /admin/users/:id(anonymise/hard-delete, distinct from membership detach) with theIS_PRODUCTIONhard-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. - Pending-invite-as-row UI (D3) β merge
pendingInviteListinto the team/users tables with Resend/Revoke. - User-detail page
/ops/users/[id]scaffolding (D4). - More-actions menu wiring per state Γ role (D5), reconciled with #1236 role boundaries.
- Audit-timeline queries powering the detail view (D6), incl. the
auth.loginaudit 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 definedremovedterminal β ends the type/DTO/login/frontend drift documented in Context. - Closes a real auth gap:
deactivatedbecomes 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
deactivatedto the type touches every exhaustiveswitch (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-
deactivatedrows (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_memberas 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):
UserStatusis avarcharcolumn with no DB-level enum constraint, so adding thedeactivatedvalue is a type-only change inauth.entities.tsβ no DDL.- The login gate (D2-gap) and
DELETE /admin/users/:idare pure code additions. - No column drops/renames. The
removedanonymise path mutates/deletes rows at runtime, not schema.
Relatedβ
- 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