Appearance
ADR-0036 · Account holds, and every point where they are enforced
Status: 🟡 Proposed · Raised: 2026-09-28, from the Admin Panel MVP plan the owner approved that day, and revised the same day with the spike results and the owner's decisions on them · Depends on:ADR-0035 (operators, the audit writer, the intent and completion pattern) and ADR-0027 (purge on write) · Evidence:the Stage 0.5 spikes · Touches core entities: users, workspaces, setu_cards, so the architecture change protocol applies (D6) · Tracker: QRS-1415 (holds and enforcement) · QRS-1413 (sign-out by id) · QRS-1432 (tokens without a session) · QRS-1433 (an unban revives sessions) · QRS-870 · QRS-892 · QRS-1404 (programme)
The decision, in one sentence
A hold on a person bans them and deletes their sessions; a hold on a business withdraws its public pages and refuses its writes while its team can still sign in. Either way the hold is a record, never a status, and every definer function checks the caller's session is still live, so nothing a hold closes stays open for the rest of a token's life.
Context
Today, suspend and block enforce nothing beyond a sign-in ban, and the ban does less than it appears to.
| Measured | Evidence |
|---|---|
users.status has three values, active, suspended, deleted. Two functions read it: get_my_context projects it, and soft_delete_account reads it to stay idempotent. Nothing that grants access reads it | supabase/migrations/20260808100000_v2_identity_and_tenancy.sql:79-80; 20260909150000_v2_context_projects_avatar.sql:60; 20260904150210_v2_soft_delete_cascade.sql:70 (QRS-870) |
workspaces.status can say suspended, with its provenance, and nothing that grants access reads it: the membership helpers check the membership only | 20260808100000_v2_identity_and_tenancy.sql:194-195; 20260808210000_v2_production_hardening.sql:54-58; 20260808190000_v2_rls_policies.sql:57-60; supabase/functions/_shared/workspace.ts:37-55 (QRS-892) |
setu_cards.status includes suspended | 20260808160000_v2_cards.sql:50 |
A ban deletes no session. It refuses sign-in and refresh with 400 user_banned, and an unban revives every pre-ban session | spike (b) (QRS-1433) |
GoTrue has no sign-out by user id. auth.admin.signOut(user.id, 'global'), the call at manage-account/index.ts:138, returns 403 bad_jwt on every call | spike (b) (QRS-1413) |
After a ban or a session delete, GoTrue's /user refuses the token at once, but PostgREST keeps honouring the last access token until it expires | spike (b) |
| The access token lives 3,600 s on the local stack (read from the token itself); Dev's value is unverified | spike (b); supabase/config.toml:51 (the file governs the local stack, :3-4) |
61 non-trigger functions in public are executable by authenticated, and 5 of them write: acknowledge_merchant_payment_alerts, mark_notifications_read, set_my_display_name, set_my_prefs, set_my_primary_context. Ten of the 61 were revoked only from PUBLIC and anon, and keep EXECUTE for authenticated through the schema's default privileges, so a search of GRANT statements finds 51 | the catalog on the local stack, 2026-09-28 (method in Evidence); the writers at 20260817110000_v2_merchant_payment_alerts.sql:280, 20260907120000_v2_consumer_read_state_consolidated.sql:160, 20260901160000_v2_set_my_display_name.sql:33, 20260907121000_v2_consumer_prefs_write.sql:52, 20260817230500_v2_set_my_primary_context.sql:47 |
| The public card is cached at the edge for seven days | apps/web/src/app/routes/setu-card.tsx:133-134 (s-maxage=604800) |
| The purge runs inline and best-effort, not through the outbox | supabase/functions/_shared/cardCache.ts:12-14, :28-33 |
⚠ The precedent that shapes this ADR. The account-deletion cascade missed the biodata tables because it was written before they existed, and nothing re-examined it when they arrived (20260904150210_v2_soft_delete_cascade.sql:5-25, QRS-1056). A hold faces exactly that risk, so this ADR states its reach as an enumerated list (D2) and proposes a gate that fails when the list goes stale.
Decision
D1 · An append-only record of account holds, with status derived from it
Column (names illustrative until the migration passes check:naming) | Holds |
|---|---|
kind | suspend or block |
subject_kind, subject_id | a user or a workspace |
reason | required, typed by the operator |
placed_by, placed_at | the operator, and when |
prior_state | what the hold changed and what it was before, for example the card's previous status |
lifted_at, lifted_by, lift_reason | null while the hold is active |
version | for optimistic concurrency (D5) |
- One active hold per kind per subject, by a partial unique index over the rows not yet lifted.
- Append-only in the sense an audit needs: no row is ever deleted, and the only update ever allowed is the single write of the lift columns. A trigger refuses any other update and any second lift.
- Status is derived, never churned. The account status an operator sees is computed from the active holds, plus
users.status = 'deleted'from the soft delete; a block outranks a suspension. A hold writes neitherusers.statusnorworkspaces.status. - Placing and lifting are operator actions under ADR-0035: permission-checked inside the definer function, with the audit row in the same transaction.
- Suspend and block are the same enforcement (owner decision 1, 2026-09-28). For a person, each is a ban plus the session delete (D3). The account holder sees one generic state, with no reason and no kind, because GoTrue answers the same
user_bannedfor both. The reason stays on the hold and in the audit log only. Who may lift which kind is the permission registry's to state (ADR-0035 D2). - A hold's subject is explicit. A person hold never implies a workspace hold: a multi-member workspace must not go dark because one member is held, and the two must be liftable separately. Holding both is the default for a solo merchant (
design-system/account-holds-round-1-prompt.md:84-87), so that one action places two holds, each recorded and each lifted on its own.
What a hold does, by subject:
| Subject | Enforced by | The holder's read path | Its public pages |
|---|---|---|---|
| A person | a ban plus the session delete (D3): no sign-in, no refresh, no live session | none: the person has no session, so the app shows one generic state from GoTrue's user_banned when they next sign in (D3) | their public biodata closes (D2, row 8) |
| A business (a workspace) | our layer only, because a workspace is not an auth user and cannot be banned: its public pages are withdrawn (D2, rows 5 to 7) and every write into it is refused (rows 2 and 4) | its members still sign in. get_my_context is the exempt read: it keeps answering them and projects that the business is on hold, never the reason or the kind. Today it projects the raw w.status (20260909150000_v2_context_projects_avatar.sql:96); the hold replaces that with a derived flag | one neutral state on every page under the address (D2, row 6) |
The business row follows from owner decision 1, for the owner to confirm. It matches the holds prompt: the business side shows that the business is on hold without the reason, and a member's personal side is unaffected (account-holds-round-1-prompt.md:121-123).
D2 · The reach list: every point where a hold is enforced
| # | Enforcement point | Mechanism | What it closes |
|---|---|---|---|
| 1 | requireAuth in every Edge Function (_shared/auth.ts:72-82) | refuse a principal with an active person hold. Once the ban lands, GoTrue's /user, which requireAuth calls (:66), refuses the token by itself (403 user_banned, or session_not_found after the delete: spike b), so this check covers the moments before revocation completes | every user-facing Edge Function at once |
| 2 | The Edge Function workspace assertions (_shared/workspace.ts:37-55, :67-85) | refuse a write into a held workspace | every Edge Function write into it |
| 3 | The my_* SQL helpers: my_workspace_ids, my_oversight_workspace_ids, my_shared_org_ids (20260808190000_v2_rls_policies.sql:50-118) and my_conversation_ids (20260905193000_v2_principal_conversations.sql:236) | is_session_live() first (D3), then the person-hold predicate: nothing for a dead session or a held person. A held workspace stays in the result, so its members can still read what the design shows them; its writes are refused at rows 2 and 4 | every RLS policy and definer function scoped through them, at once rather than at token expiry |
| 4 | The definer functions a live token calls directly: 61 are executable by authenticated, 5 of which write | is_session_live() and the person-hold predicate at the top of each function that acts on the caller's own data (keyed on auth.uid()); a function that writes into a workspace also refuses a held workspace. get_my_context is the one exempt read for a held workspace's members (D1) | the PostgREST path, which never passes through requireAuth |
| 5 | The public card | a workspace hold sets setu_cards.status = 'suspended', recording the previous status in the hold | every public reader at once, including order intake (below) |
| 6 | Every public page under the address: the card, the biodata page and the occasion pages (the Invitation and Birthday pages, designed and not yet built) | one neutral state shared by all of them, which never says suspended, blocked or why and offers no order, booking, contact or chat action (account-holds-round-1-prompt.md:98-104). The address's public read reports only that it is held, so a page can tell "held" from "no such address" | the printed QR codes, which keep pointing at the address |
| 7 | The card's edge copy, through _shared/cardCache.ts (inline, not the outbox) | the card write is followed by a purge, as an external call under ADR-0035 D9: intent row, purge, completion row, re-driven until complete | the copy cached for up to seven days |
| 8 | The person's public biodata | must close under a person hold, reversibly; the mechanism is decided in the implementation proposal | the second public surface a person owns |
Why row 5 writes the card's status instead of joining holds into every reader. The latest definitions of ten functions filter the card on status = 'published': get_public_setu_card (20260808210000_v2_production_hardening.sql:276), resolve_setu_card_payment_readiness (20260817160000_v2_public_buyer_read_path.sql:112), get_public_catalogue, consumer_vendor_json, get_consumer_vendor_feed, get_consumer_vendor, consumer_item_base, get_consumer_item (20260818120000_v2_consumer_discovery_read_path.sql:189 to :813), get_consumer_item_feed (20260822120000_v2_consumer_item_feed.sql:86) and verify_qr_lookups (20260909170000_v2_qr_reports_and_verify_lookups.sql:165). All ten are among the 61 an authenticated caller can execute. Eight are also executable by anon; the other two, consumer_vendor_json and consumer_item_base, are helpers the others call. place-public-order refuses a card that is not published (supabase/functions/place-public-order/index.ts:77-94). One write reaches all eleven, where a predicate would have to be remembered by each, the drift the deletion cascade already suffered once.
Row 8, why it is on the list. The soft delete closes a person's biodata by retiring the profiles and withdrawing the shares (20260904150210_v2_soft_delete_cascade.sql:34-37). A hold must do the same and be reversible, so it records what it changed in prior_state.
What stays open, by design, named here so that no door is closed by accident:
- Under a person hold: nothing authenticated. The anonymous surfaces stay what any visitor gets.
- Under a business hold: its members' sign-in, their own personal side, and
get_my_contextfor them, which says the business is on hold and nothing more. - Under either: the Admin Panel's operator reads, which are the operator's and not the holder's.
D3 · The revocation primitive, and the order of a suspend, a block and a lift
Measured on the local stack (spike b: GoTrue v2.197.0, all 118 migrations applied):
| Probe | Result |
|---|---|
auth.admin.signOut(user.id, 'global'), the call at manage-account/index.ts:138 | 403 bad_jwt on every call: GoTrue has no sign-out by user id (QRS-1413, now measured) |
| A ban | refuses sign-in and refresh with 400 user_banned, and deletes no session |
| An unban after a ban alone | revives every pre-ban session: the old refresh token renews again (QRS-1433) |
Deleting the person's auth.sessions rows (their refresh tokens go with them), plus any of their refresh tokens with no session | the same effect as GoTrue's own global sign-out: every old refresh token answers refresh_token_not_found |
| An Edge Function after revocation | refuses at once: GoTrue's /user returns 403 user_banned or session_not_found |
| PostgREST after revocation | keeps honouring the last access token until it expires (3,600 s locally; Dev's value unverified) |
is_session_live(), a definer that looks up the token's session_id in auth.sessions | closes that window at about 30 µs a call, index-only (10,000 calls in 301.5 ms), and refuses a token with no session_id at all (QRS-1432). authenticated cannot read auth.sessions directly, so the check must be a definer |
The primitive. A definer function, executable by the service role only, deletes the person's sessions and their orphaned refresh tokens. The same primitive without a ban is the Users desk's "sign out everywhere", which is not a hold: the person simply signs in again (account-holds-round-1-prompt.md:93-94).
Closing the PostgREST window. is_session_live() runs first in every definer helper of D2, rows 3 and 4, for every principal, staff included: the operator check calls the same function (ADR-0035 D7). A revoked session is therefore refused everywhere at once, not at its token's expiry.
Suspend or block a person (owner decision 1):
- One transaction: the hold row, its intent row and its audit row (ADR-0035 D9). From this commit the person-hold predicate refuses them in every definer function.
- The ban, through the Auth admin API.
- The session delete, with the completion row in the same transaction.
The ban comes first: a session created between a delete and a ban would survive the ban, and a later unban would revive it. An incomplete suspend (the ban landed, the delete did not) is re-driven from its intent row until the completion row exists.
Lift (owner decision 1):
- The lift writes its intent row.
- If the suspend's completion row is missing, the lift first completes the suspend with the session delete, because an unban revives every surviving session.
- The unban, and only when this is the person's last active hold.
- The lift columns, the restored prior state (D4) and the audit row, with the completion row.
An unban is safe only because the suspend already deleted the sessions.
What the holder sees, and when. An app whose session was deleted learns only that its session ended: its refresh answers refresh_token_not_found, and GoTrue's /user answers session_not_found (spike b). The hold state appears when the person signs in again and GoTrue answers user_banned: measured for a password sign-in, expected, unverified for the WhatsApp code. The holds prompt asks for the state on reopening as well as at sign-in (account-holds-round-1-prompt.md:114-115), so on reopening the app reaches it through the sign-in step, and the design round is told so.
D4 · Lifting a hold restores the prior state
- A lift restores each row the hold changed to the state recorded in
prior_state, but only a row still in the state the hold left it in. A row changed since then is reported to the operator as a conflict, never overwritten. - The unban waits for an incomplete suspend to be completed, and for the person's last active hold to lift (D3).
- The card's edge copy is purged again after a lift, so the card returns, published only if it was published before (
account-holds-round-1-prompt.md:91-92).
D5 · Concurrency and retries
Every place and every lift carries expected_version, and a stale version is refused. Every one takes a required idempotency key, reused across retries, one key per call (CLAUDE.md §5, QRS-210).
D6 · This is a core-entity change
users, workspaces and setu_cards are core entities (architecture change protocol §5, lines 130-139). Before any migration, this decision is carried as a protocol proposal under architecture/proposals/, with its ten steps answered and check:arch-proposal green. That proposal is not written yet.
Alternatives rejected
| Option | Why not |
|---|---|
Widen users.status with blocked, and churn it | It widens a core CHECK for one caller, keeps no history of who, why and what came before, and one column cannot hold a suspension and a block at the same time. The column's own comment warns that a status check is "a check that a hundred other call sites will not have" (20260904102614_v2_account_soft_delete.sql:88-96) |
| The ban alone | A ban deletes no session and an unban revives every one of them (QRS-1433); meanwhile PostgREST honours each live token until it expires |
| Sign-out by user id through GoTrue | It does not exist: 403 bad_jwt on every call (QRS-1413) |
A shorter project-wide jwt_expiry | It shrinks the window without closing it, and it is project-wide: every merchant and consumer session would refresh more often, multiplying refresh traffic, GoTrue load and background wake-ups on phones, to buy what is_session_live() gives at about 30 µs a call |
| Ban a workspace | A workspace is not an auth user, so there is nothing to ban; it is held in our layer (D1) |
| Join the holds into every public reader | Eleven readers would each need the predicate, and the platform's hottest read would gain a subquery; one materialised write reaches all of them (D2, row 5) |
| Queue the purge through the outbox | Nothing drains the outbox (_shared/cardCache.ts:28-33, QRS-885) |
| A person hold that implicitly holds the workspace | A multi-member workspace would go dark because one member is held, and the two could not be lifted apart (D1) |
Consequences
users.status = 'suspended'becomes a value nothing writes, and the workspace suspension columns (20260808210000_v2_production_hardening.sql:54-58) become a second record of the same fact. Both are retired by expand-contract once holds ship.- What the holder sees is decided: one generic state, with no reason and no kind (owner decision 1). Its screens come from the holds design round (
design-system/account-holds-round-1-prompt.md); until they are approved, a refusal shows as a generic failure. is_session_live()changes what a revoked session means for every principal. A global sign-out, an account deletion or a hold ends every live token at PostgREST at once instead of at its expiry, so a second device signed out by a global sign-out stops working immediately. That is the intent.manage-account's deletion path calls the sign-out that does not exist (manage-account/index.ts:138, QRS-1413). It adopts the session-delete primitive, because a soft delete is reversible inside its window (20260904150210_v2_soft_delete_cascade.sql:42-43) and an unban would otherwise revive the deleted account's sessions.- Tests owed (pgTAP):
- A held person's live token reads none of their own data and writes nothing through any function executable by
authenticated, the list generated from the catalog. - A held workspace's writes are refused, and
get_my_contextstill answers its members with the flag and no reason. - A revoked session is refused by every helper.
- An unban after a completed suspend revives nothing.
- A lift restores the prior state.
- A held person's live token reads none of their own data and writes nothing through any function executable by
- A gate is proposed: every function the catalog says
authenticatedcan execute either calls the session and hold checks or is on a written exemption list. It readspg_procafter a local reset, never migration text, because ten of the 61 get EXECUTE from default privileges with noGRANTstatement. Blind spot: a granted function that calls another function which writes. - ADR-0027's same-transaction guarantee is not met by today's inline purge (ADR-0027 D3, lines 74-86, against
_shared/cardCache.ts:28-33). Holds close that gap for their own purges through the intent row, not for ordinary card edits. - An unconfigured purge counts as incomplete for a hold. For an ordinary card edit, "Cloudflare is not configured" is logged at info level (
_shared/cardCache.ts:76-91); for a hold it is an unfinished step shown to the operator. - The proposal's §9 is superseded in one respect: it said a suspension should set both the ban and
users.status(proposal lines 331-334). Holds replace the status half.
Evidence
Measured for this ADR, 2026-09-28:
| Claim | Where |
|---|---|
| The three status vocabularies and the workspace provenance columns | 20260808100000_v2_identity_and_tenancy.sql:79-80, :194-195; 20260808160000_v2_cards.sql:50; 20260808210000_v2_production_hardening.sql:54-58 |
The two readers of users.status | 20260909150000_v2_context_projects_avatar.sql:60; 20260904150210_v2_soft_delete_cascade.sql:70 |
| Membership is the whole check in the helpers and the Edge Function kit | 20260808190000_v2_rls_policies.sql:50-118; supabase/functions/_shared/workspace.ts:37-55 |
requireAuth asks GoTrue for the user | supabase/functions/_shared/auth.ts:61-82 |
61 functions executable by authenticated, 5 writing, 10 by default privileges | the catalog on the local stack: pg_proc filtered on has_function_privilege('authenticated', oid, 'EXECUTE'), non-trigger, schema public, bodies searched for insert into, update … set and delete from; pg_default_acl for public grants EXECUTE on new functions to anon, authenticated and service_role |
| The ten readers and the order function filter on published; all ten are among the 61 | the lines cited in D2; the same catalog query |
| The seven-day edge cache and the inline purge | apps/web/src/app/routes/setu-card.tsx:133-134; supabase/functions/_shared/cardCache.ts:12-14, :28-33, :76-91 |
| The deletion cascade precedent, and its reversible window | supabase/migrations/20260904150210_v2_soft_delete_cascade.sql:5-37, :42-43 |
| The public, holder and team states the design round draws | documentation/portal/design-system/account-holds-round-1-prompt.md:84-123 |
The ban, the unban, the missing sign-out by id, the session delete, and is_session_live() | the spike results, spike (b), on the local stack |
Expected, unverified:
- The hosted JWT expiry and GoTrue version on Dev (dashboard state).
- That a WhatsApp-code sign-in by a banned person also answers
user_banned. - That no public reader reaches a card without the published filter: the scan behind D2 matched the
c.andcd.aliases, so a reader using another alias would be missed. The proposed catalog gate closes that.