Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix: lock parent user row in user soft-delete guards - #28546

Draft
ThomasK33 wants to merge 4 commits into
fix-user-cap-advisory-locksfrom
fix-user-soft-delete-guards
Draft

fix: lock parent user row in user soft-delete guards#28546
ThomasK33 wants to merge 4 commits into
fix-user-cap-advisory-locksfrom
fix-user-soft-delete-guards

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Aug 25, 2026

Copy link
Copy Markdown
Member

Closes the insert-vs-soft-delete race on user child tables: an in-flight child-table insert could read users.deleted = false, lose the race to a concurrent soft-delete (and its delete_deleted_user_resources cleanup), then commit afterwards — resurrecting rows for a deleted account. For api_keys that resurrects a live session token on an account the operator believes they deleted.

Scope (round 5, CRF-51): this PR is now guard-only. The per-user cap rework (advisory locks, isolation contract) was split out to #28870 (fix-user-cap-advisory-locks), which this branch is stacked on.

What this does

Migration 000592 (non-locking by design, CRF-54):

  • One shared check_user_not_deleted() function owns the lock and the gate. The four pre-existing per-table guard functions (api_keys, user_links, user_secrets, user_skills) are swapped with CREATE OR REPLACE FUNCTION — no DROP TRIGGER, so no ACCESS EXCLUSIVE lock on hot tables during the upgrade.
  • The guard takes a FOR NO KEY UPDATE lock on the users row exactly when a row starts belonging to a user: INSERT, or UPDATE reassigning user_id (CRF-56). Same-owner updates keep the unlocked read (the deadlock argument). api_keys gains a dedicated BEFORE UPDATE OF user_id trigger with a WHEN (NEW.user_id IS DISTINCT FROM OLD.user_id) clause so the per-request last_used bump never enters plpgsql.
  • New guards for the directly cleaned tables that had none (user_ai_provider_keys, organization_members) plus user_ai_budget_overrides and group_members, whose rows feed readers that do not filter users.deleted (GetOverBudgetUsersPerGroup, GetAuthorizationUserRolesrbac.Subject.Groups; CRF-61).
  • No backfill DELETE statements in the migration (CRF-54): orphaned child rows of already-soft-deleted users are removed by an idempotent dbpurge reaper (PurgeSoftDeletedUserResources) that runs at startup and on the 10-minute purge cadence under the existing dbpurge advisory lock.
  • The dead TG_ARGV[2] fail-closed branch is deleted (CRF-58; the capability moves to feat: add agent memory database foundation #28423, which owns the one table that needs it).
  • The stated isolation guarantee is now the true one (CRF-57): the locking path is correct at READ COMMITTED (what every production writer uses); under REPEATABLE READ / SERIALIZABLE the lock wait fails with 40001, so guarded inserts must not run inside database.ReadModifyUpdate.

Lock-ordering contract: transactions that write a guarded child row and later insert one for the same user take the users lock first via AcquireUserSoftDeleteGuardLock (OAuth2 token grants, oauthLogin, regenerateSessionToken), matching delete_deleted_user_resources so they cannot deadlock. The query is dbauthz-authorized as a system primitive.

Handlers: guard violations map to 409s (organization_members, user_ai_provider_keys); constraint names are declared once in coderd/database/usersoftdeleteguards.go.

Tests

  • TestSoftDeleteGuardWinsConcurrentInsert — all eight guards lose deterministically to a concurrent soft-delete, pinning each constraint name.
  • TestSoftDeleteGuardBlocksOwnerReassignment — the UPDATE ... SET user_id legs, including the concurrent-soft-delete race.
  • TestSoftDeleteGuardUpdatePathTakesNoUserLock / TestSoftDeleteGuardRejectsUpdatesForDeletedUser — the same-owner gates, both directions.
  • TestSoftDeleteGuardTriggerOrder — name order and BEFORE ROW timing (CRF-63).
  • TestSoftDeleteGuardLockOrderPaths — per-call-site deadlock regressions (deadlock red without the users-first lock).
  • TestOAuth2ProviderTokenExchangeLockOrder — drives the real HTTP token exchange and now pins the lock's position (CRF-55): while blocked in AcquireUserSoftDeleteGuardLock, the exchange backend must hold zero RowExclusiveLocks on api_keys/oauth2_provider_app_codes (pg_locks). Verified red against the reordered-lock mutation.
  • TestMigration000592LockUserSoftDeleteGuards — migration applies with orphans present, orphans survive (reaper owns cleanup), guards and reassignment legs live post-migration.
  • TestPurgeSoftDeletedUserResources — the reaper removes all eight tables' orphans; a live user's rows survive.

Stacking

Merge order: #28870 (caps) → this PR → #28423 (agent memory), all rebased onto the same main head. Base is fix-user-cap-advisory-locks; #28870's commits drop out of the diff when it merges.


Generated with mux • Model: anthropic:claude-fable-5-1 • Thinking: xhigh

@ThomasK33

Copy link
Copy Markdown
Member Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Chat: Review in progress (15/15 reviewers complete) | View chat
Requested: 2026-09-07 21:35 UTC by @ThomasK33

Review history
  • R1 (2026-08-25), 1 Note, 1 P2, 1 P3, COMMENT. Review
  • R2 (2026-08-25), 1 Note, 1 P1, 2 P2, 1 P3, COMMENT. Review
  • R3 (2026-08-26): 17 reviewers, 3 Nit, 1 Note, 3 P1, 6 P2, 12 P3, REQUEST_CHANGES. Review
  • R4 (2026-08-26): 16 reviewers, 9 Nit, 1 Note, 1 P0, 4 P1, 9 P2, 24 P3, REQUEST_CHANGES. Review
  • R5 (2026-08-26): 11 reviewers, 14 Nit, 1 Note, 1 P0, 4 P1, 14 P2, 33 P3, REQUEST_CHANGES. Review
  • R6 (2026-09-01), 14 Nit, 1 Note, 1 P0, 4 P1, 14 P2, 33 P3, COMMENT. Review

deep-review v0.9.0 | Round 7 | 1d5631f..dd747c8

Last posted: Round 7, 76 findings (1 P0, 4 P1, 16 P2, 37 P3, 17 Nit, 1 Note), REQUEST_CHANGES. Review

Finding inventory

Finding inventory - PR #28546

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Author fixed (39171c5) 000585_lock_user_soft_delete_guards.up.sql:16 user_secrets UPDATE-path deadlock still reachable via per-user cap trigger; migration comment overclaims the gate avoids it R1 Netero Yes
CRF-2 P3 Author fixed (39171c5) 000585_lock_user_soft_delete_guards.up.sql:39 Backfill and guard set miss user_ai_provider_keys and organization_members, two of six tables delete_deleted_user_resources wipes R1 Netero Yes
CRF-3 Note Author accepted R2 (intentional SQLSTATE contract for #28423) 000585_lock_user_soft_delete_guards.up.sql:29 Guards now raise SQLSTATE 23514 instead of P0001; three new constraint names are test-only until #28423 lands R1 Netero Yes
CRF-4 P1 Author fixed (5bb95f2) 000587_lock_user_soft_delete_guards.up.sql:64 New INSERT-path users lock inverts lock order vs OAuth2 token delete-then-insert txns; deadlocks against delete_deleted_user_resources, 500 on user delete. Migration comment claims INSERT path is deadlock-safe (false) R2 Netero Yes
CRF-5 P2 Author fixed (5bb95f2) user_soft_delete_guards_test.go:259 TestSoftDeleteGuardUpdatePathTakesNoUserLock never bounds the wait; ctx-timeout rollback rescues a blocked UPDATE, so it passes with or without the TG_OP gates (weak CRF-1 proof) R2 Netero Yes
CRF-6 P1 Author fixed (59f5ebe) provisionerdserver.go:3319 CRF-4 fix incomplete: regenerateSessionToken deletes+inserts api_keys in one tx without the users lock; reproduced deadlock vs concurrent soft-delete R3 Knuckle/Meruem/Takumi/Killua/Hisoka/Razor/Knov/Mafu-san P1, Ryosuke/Melody/Mafuuu/Pariston P2, Kurapika P3 Yes
CRF-7 P1 Author fixed (59f5ebe) userauth.go:1986 New organization_members guard: login tx UPDATEs user_links then INSERTs org member, inverting lock order; reproduced deadlock vs soft-delete (PR-introduced) R3 Komugi/Meruem/Melody/Mafuuu/Pariston/Hisoka P1, Takumi/Knuckle/Kurapika/Ryosuke/Mafu-san P2 Yes
CRF-8 P2 Author fixed (59f5ebe) 000587...up.sql:26 Lock-ordering contract enforced only by prose; no test fails if a lock call is deleted; already violated twice (CRF-6/7) R3 Bisky/Knuckle/Meruem/Kurapika/Ryosuke/Melody/Pariston/Mafuuu/Mafu-san/Hisoka/Razor/Knov Yes
CRF-9 P2 Author fixed (59f5ebe) 000587...up.sql:230 CRF-1 fix removed UPDATE-path serialization: concurrent user_secrets UPDATEs bypass the byte caps (reproduced 300k-400k vs 204800/24576); overshoot not bounded; sticky lockout R3 Bisky/Takumi/Komugi/Ryosuke/Mafuuu Yes
CRF-10 P2 Author fixed (59f5ebe) 000587...up.sql:231 Cap trigger INSERT-path FOR UPDATE upgrades the guard's FOR NO KEY UPDATE, conflicts with FK FOR KEY SHARE across 36 tables and adds a deadlock edge; buys nothing. enforce_user_skills_per_user_limit same R3 Killua/Razor P2, Hisoka Note Yes
CRF-11 P2 Author fixed (59f5ebe) httpmw/apikey.go:476 Authn/authz never filter users.deleted (GetAuthorizationUserRoles, ValidateAPIKey); a resurrected api_keys row authenticates (verified end-to-end). Read-side check is the source-agnostic half; human decision R3 Ryosuke P2, Pariston (P0-worthy) Yes
CRF-12 P3 Author fixed (59f5ebe) PR description Description omits the lock-ordering contract + AcquireUserSoftDeleteGuardLock + tokens.go change; still says 000585; the entire production Go delta is undocumented R3 Leorio/Mafu-san Yes (body)
CRF-13 P3 Author fixed (59f5ebe) 000587...up.sql:15 INSERT-only lock rationale lives only in the migration header, dropped from dump.sql; 4 stale copies of the contract comment R3 Gon Yes
CRF-14 P3 Author fixed (59f5ebe) queries/users.sql:753 AcquireUserSoftDeleteGuardLock is :exec over SELECT 1: locks nothing (nil) for a missing user or outside a tx; silent no-op. Make it :one R3 Meruem/Ryosuke/Melody/Gon/Knov Yes
CRF-15 P3 Author fixed (59f5ebe) dbauthz/dbauthz.go:1809 Write-blocking row lock authorized as ActionRead; any user-reader can block another user's deletion. Use ActionUpdate or system-restricted R3 Kurapika/Razor/Knov Yes
CRF-16 P3 Author fixed (59f5ebe) oauth2provider/tokens.go:558 Refresh grant locks dbToken.UserID but delete/insert use prevKey.UserID; divergence silently disables the fix (combined with :exec no-op). Use prevKey.UserID R3 Kurapika/Ryosuke/Mafuuu/Hisoka/Knov Yes
CRF-17 P3 Author fixed (59f5ebe) 000587...up.sql:50 group_members and user_ai_budget_overrides are wiped transitively via org_member cascade; no guard, no backfill; PR claim of covering "every table" overstated R3 Knuckle/Melody Yes
CRF-18 P3 Author fixed (59f5ebe) 000587...up.sql:54 Six near-identical guard bodies; next table can silently omit the lock or TG_OP gate (CRF-1 was this failure). Consolidate via TG_ARGV R3 Meruem P3, Knuckle Nit Yes
CRF-19 P3 Author fixed (59f5ebe) 000587...up.sql:45 Backfill DELETEs run before the guard CREATE OR REPLACE; an old-code insert committing after the DELETE during the migration is never cleaned. Move DELETEs to end R3 Komugi Yes
CRF-20 P3 Author fixed (59f5ebe) members.go:74 New guards surface as raw HTTP 500 with pq text (postOrganizationMember, exp_chats.go) for caller errors; map to 400 like the unique-violation branch R3 Leorio Yes
CRF-21 P3 Author fixed (59f5ebe) server_dbcrypt_test.go:365 server-level dbcrypt test dropped deleted-user AI-provider-key encryption coverage (moved to if !deleted + require.Empty); description claims coverage preserved R3 Mafu-san Yes
CRF-22 P3 Author fixed (59f5ebe) dbcrypt/cliutil_test.go:126 softDeleteUserKeepingRows ALTER TABLE DISABLE TRIGGER is global/autocommit in a t.Parallel test; unsafe under shared-DB (CODER_PG_CONNECTION_URL). Wrap in a tx R3 Komugi P3, Knov Note Yes
CRF-23 P3 Author fixed (59f5ebe) 000587...up.sql:44 Backfill comment "anything matching here is a product of the race" is false for organization_members/user_ai_provider_keys (pre-cleanup orphans). Deletion is still correct; reword R3 Kurapika/Pariston/Hisoka/Razor Yes
CRF-24 Nit Author fixed (59f5ebe) 000587...up.sql:60 Dead IF (NEW.user_id IS NOT NULL) wrapper kept in 3 of 6 rewritten guards, absent in the other 3; user_id is NOT NULL on all six R3 Meruem/Gon Yes
CRF-25 Nit Author fixed (59f5ebe) user_soft_delete_guards_test.go:118 Six trigger-raised constraint names have no Go declaration (only test string literals); a rename fails open. Declare as CheckConstraint constants like userskills.go R3 Knuckle/Ryosuke/Mafu-san/Gon/Leorio Yes
CRF-26 Nit Author fixed (59f5ebe) user_soft_delete_guards_test.go:260 SET lock_timeout='5s' on a pooled *sql.Conn is not reset before return; leaks to the next drawer under shared *sql.DB. RESET on defer R3 Mafuuu Yes
CRF-27 P0 Author fixed (2f95dc4) oauth2provider/tokens.go:411 Both OAuth2 grants take AcquireUserSoftDeleteGuardLock under the user's own actor (dbauthz.As); the CRF-15 ActionUpdate check denies member self-update, so token exchange AND refresh fail (forbidden) for every non-admin user. Reproduced; control passes R4 Netero Yes
CRF-28 P1 Author fixed (2f95dc4) user_soft_delete_guards_test.go:406 TestSoftDeleteGuardLockOrderPaths replays hand-written SQL mirroring each Go tx, so it cannot fail when the Go call site is missing/wrong/unauthorized (CRF-27 passed it). Drive at least one path through its Go entry point R4 Netero Yes
CRF-29 P3 Author fixed (2f95dc4) httpmw/apikey.go:901 Read-side filter makes GetAuthorizationUserRoles (:one) return sql.ErrNoRows for a deleted user; UserRBACSubject maps it to a Hard 500 instead of 401 for an orphaned key. Distinguish ErrNoRows -> 401 R4 Netero Yes
CRF-30 P3 Author fixed (2f95dc4) provisionerdserver.go:3304 New user.ID != workspace.OwnerID assertion is unreachable (owner derived from workspace); exists only because the lock uses workspace.OwnerID while the insert uses user.ID. Lock user.ID and drop the assertion R4 Netero Yes
CRF-31 P3 Author fixed (2f95dc4) server_dbcrypt_test.go:300 softDeleteUserKeepingRows now written three times (two packages + inline); constructing the orphaned-user state is a standing need. Extract to dbtestutil once R4 Netero/Law Yes
CRF-32 Nit Author fixed (2f95dc4) user_soft_delete_guards_test.go:308 require.Less(t, tc.guard, tc.capName) compares two test-literal strings (constant-true); the pg_trigger count check is what ties it to the DB. Select the tgnames and compare what comes back R4 Netero Yes
CRF-33 P2 Author fixed (2f95dc4) queries/users.sql:646 Law MANDATORY split: extract the read-side GetAuthorizationUserRoles deleted filter (C6) to its own PR. Independent change, different blast radius (non-auth workspace-owner callers render.go:319, provisionerdserver.go:855), added at R4 as CRF-11 remediation, never reviewed as primary. Recommend also extracting the cap advisory-lock rewrite (C2) R4 Law Yes
CRF-34 P2 Author fixed (2f95dc4) provisionerdserver.go:855 Read-side filter makes GetAuthorizationUserRoles (:one) return sql.ErrNoRows for a deleted owner; provisionerdserver.go:855 and dynamicparameters/render.go:319 treat any error as fatal, so every build for a deleted owner fails INCLUDING the delete build -> undeletable workspace + orphaned cloud resources. Concrete bug behind CRF-33 R4 Knuckle/Hisoka P2, Ryosuke/Komugi P3 Yes
CRF-35 P2 Author fixed (2f95dc4) dbauthz/dbauthz.go:1817 The lock's ActionUpdate-on-user gate is role-dependent (denies members, allows owners) and gates a capability the guard trigger hands out for free; it is CRF-27's root cause and a trap for the next caller. Gate as ResourceSystem. Do NOT widen the member role (ResourceUser:update also gates suspend/activate/DeleteUserAIBudgetOverride) R4 Kurapika/Meruem/Ryosuke/Razor/Pariston P2, Hisoka Yes
CRF-37 P3 Author fixed (2f95dc4) 000587...up.sql:159 Advisory-lock cap rewrite (CRF-9/10 fix) created a new cross-statement deadlock edge: UPDATE user_secrets holds advisory, INSERT holds users then waits advisory; two ordinary writers deadlock with no soft-delete. Reproduced. Latent (no current caller), ripens in #28423. Contract text frames it only as soft-delete. Broaden contract + add UPDATE-then-INSERT test R4 Knuckle/Takumi/Komugi/Meruem/Ryosuke P3, Hisoka P2 Yes
CRF-38 P3 Author fixed (2f95dc4) 000587...up.sql:28 CRF-17 "rows are inert" justification is false for user_ai_budget_overrides: GetOverBudgetUsersPerGroup (aicostcontrol.sql:388) reads it with no users.deleted filter, so a surviving override is a phantom over-budget user in an operator metric. Guard the table or filter the query R4 Mafuuu Yes
CRF-39 P3 Author fixed (2f95dc4) migrate_test.go:3658 Migration test coverage of the two transitive backfill tables (group_members, user_ai_budget_overrides) is vacuous: the org_members cascade deletes them before the explicit backfill runs, so commenting out both backfill DELETEs keeps the test green (verified by mutation). Seed a doomed user with no organization_members row R4 Mafuuu/Gon Yes
CRF-40 P3 Author fixed (2f95dc4) members.go:79 The two new 400 mappings (members.go, exp_chats.go) have no test and are deterministically reachable; a wrong constraint-string literal silently reverts to a 500 with raw pq text (the thing CRF-20 removed). Add a coderdtest case per endpoint R4 Chopper/Mafu-san, Netero Yes
CRF-41 P3 Author fixed (2f95dc4) exp_chats.go:6661 Status-code inconsistency: the two new guards return 400 while the established userskills.go handler returns 409 (Conflict) for the same guard class. CRF-20 asked for 400, which conflicts with the codebase convention; 409 also fits RFC 9110 better. Human decision; make all three match R4 Chopper Yes
CRF-42 P3 Author fixed (2f95dc4) members.go:82 The new 400 Detail ("X was deleted while the membership was being created") asserts a race, but the common reachable case is a stale user id (GetUserByID has no deleted filter), so it misdescribes the state to the operator. Say "X has been deleted." R4 Netero/Chopper/Hisoka/Mafuuu/Mafu-san/Leorio/Zoro Yes
CRF-43 P3 Author fixed (2f95dc4) 000587...up.sql:73 The guard's UPDATE branch (reject child-row updates for a deleted user, on user_links/secrets/skills) has no test; inverting or dropping the ELSE arm passes, while an orphaned user_links row could keep refreshing OAuth tokens. Add one UPDATE case. Distinct from CRF-5 R4 Chopper Yes
CRF-44 P3 Author fixed (2f95dc4) queries/users.sql:760 Three comments overclaim: AcquireUserSoftDeleteGuardLock doc says it fails loudly on a wrong user id (only a nonexistent one; a wrong-but-real id locks the wrong row, the CRF-16/30 class); up.sql:61 says the suite "pins each known such path" (SQL replays, not the Go call sites; ships into dump.sql); GetAuthorizationUserRoles comment hides the ErrNoRows contract change. PR description "mirrors" clause misleads too R4 Leorio Yes
CRF-45 P3 Author fixed (2f95dc4) oauth2provider/tokens.go:407 Go-side lock-order rationale duplicated verbatim at 4 call sites plus the canonical querier.go copy (CRF-13 fixed only the SQL side); when the contract changes, 4 copies go stale. Keep only the site-local fact and point to AcquireUserSoftDeleteGuardLock R4 Gon/Zoro Yes
CRF-46 Nit Author fixed (2f95dc4) user_soft_delete_guards_test.go:118 CRF-25 partial: only 2 of 6 trigger constraint names declared as Go constants, in 3 files with 3 copies of the rationale; the guard test still hardcodes all six literals. Declare all six in package database (e.g. check_constraint_trigger.go) so a rename breaks compilation R4 Ryosuke/Mafu-san/Gon/Zoro Yes
CRF-47 Nit Author fixed (2f95dc4) 000587...up.sql:159 The two per-user advisory-lock keys (user_secrets_cap:, user_skills_cap: via hashtextextended) are not registered in coderd/database/lock.go, the declared home for advisory-lock IDs. Discoverability, not collision. Add the SQL-side key formats to the lock.go comment R4 Razor/Mafuuu/Gon/Zoro Yes
CRF-48 Nit Author fixed (2f95dc4) migrate_test.go:3643 CRF-22 fix converted three DISABLE TRIGGER sites to the transactional form but left this fourth site (added in the same PR) on autocommit. Use the transactional form or the shared helper R4 Mafu-san Yes
CRF-49 Nit Author fixed (2f95dc4) migrate_test.go:3658 The migrate_test loop variable guardedTables includes the two tables the migration deliberately leaves unguarded; it asserts backfill for all eight. Rename to backfilledTables so it is not read as the guard inventory R4 Gon/Zoro/Mafuuu Yes
CRF-50 Nit Author fixed (2f95dc4) user_soft_delete_guards_test.go:325 runGuardedWriteRace doc comment tells the reader to "drop the acquireFirst statement", which does not exist; the users lock is hardcoded in the helper. Name the statement or point at the FOR NO KEY UPDATE line R4 Knuckle/Meruem/Komugi/Pariston/Gon Yes
CRF-51 P2 Author fixed (R6; caps in #28870) 000587...up.sql:163 Law MANDATORY split: extract the cap changes (advisory-lock rewrite + require_read_committed + skills-cap UPDATE leg) as slice A, land first. Independently justified, must precede the guard, contains new user-visible behavior (RR/Serializable rejection) unrelated to soft-delete R5 Law Yes
CRF-52 P2 Author fixed (R6; caps in #28870) 000587...up.sql:204 require_read_committed rejects EVERY secret/skill write outside READ COMMITTED unconditionally (a single non-concurrent insert fails), a feature outage under a deployment default_transaction_isolation or database.ReadModifyUpdate, to prevent a soft-cap slip no caller can currently trigger. Remove the gate (advisory lock already makes caps correct under RC) or enforce via a CI test R5 Pariston P2, Hisoka P3 Yes
CRF-53 P2 Author fixed (R6; caps in #28870) 000587...up.sql:203 The same-owner exemption from require_read_committed reopens the exact cap overshoot the gate targets: a same-owner UPDATE recounts from a stale RR snapshot (reproduced 300k vs 204800), reachable via dbcrypt rotation racing a user secret edit. "Caps require READ COMMITTED" is not what ships R5 Komugi P2, Pariston P3, Bisky P3 Yes
CRF-54 P2 Author fixed (R6; caps in #28870) 000587...up.sql:107 Migration DROP TRIGGER takes ACCESS EXCLUSIVE on api_keys (+3 tables) held for the whole single-transaction migration, plus 8 unbounded backfill DELETEs (no index on users WHERE deleted) in that window, blocking authenticated reads fleet-wide during upgrade. Use CREATE OR REPLACE FUNCTION (no table lock) + ALTER TRIGGER RENAME; move backfill to a reaper R5 Knuckle Yes
CRF-55 P2 Author fixed (R6; caps in #28870) oauth2_test.go:562 TestOAuth2ProviderTokenExchangeLockOrder pins that the lock is TAKEN, not taken FIRST: moving AcquireUserSoftDeleteGuardLock after the api_keys DELETE (the CRF-4 inversion) keeps the whole suite green. The deadlock class is reintroducible at all 4 call sites. Assert the blocked backend holds no RowExclusiveLock on guarded tables before release (6 lines, verified) R5 Hisoka P2, Komugi P3, Bisky Yes
CRF-56 P3 Author fixed (R6; caps in #28870) 000587...up.sql:80 Guard fires on INSERT, not ownership change: UPDATE ... SET user_id re-parents a live child row (incl. api_keys) onto a soft-deleted user, reproduced. No current caller, but the PR added a cap UPDATE leg for the same hypothetical. Make guards BEFORE INSERT OR UPDATE OF user_id, lock when INSERT OR user_id changed R5 Meruem Yes
CRF-57 P3 Author fixed (R6; caps in #28870) 000587...up.sql:76 "Safe under any isolation level" is false: the guard's INSERT-path FOR NO KEY UPDATE aborts with 40001 under RR after ANY committed concurrent users update (e.g. last_seen_at), not just soft-delete (reproduced). Latent. State the INSERT path requires READ COMMITTED R5 Knuckle/Pariston Yes
CRF-58 P3 Author fixed (R6; caps in #28870) 000587...up.sql:90 The TG_ARGV[2]/TG_NARGS>=3 fail-closed branch is dead (all 7 triggers pass 2 args), redundant (FK rejects a missing parent), and the header comment describes a capability no table has, shipping into dump.sql. Delete it or wire+test the one table that needs it R5 Netero/Knuckle/Takumi/Kurapika/Meruem/Bisky/Hisoka/Pariston/Mafuuu/Zoro Yes
CRF-59 P3 Author fixed (8c32ed7) aibridge.go:885 The new 7th guard (user_ai_budget_overrides) has no 409 handler mapping; upsertUserAIBudgetOverride falls through to InternalServerError with raw pq text in Detail, in the exact race it was added to close. Add IsCheckViolation(CheckUserAIBudgetOverrideUserDeleted) -> 409 R5 Kurapika/Bisky/Hisoka/Mafuuu Yes
CRF-60 P3 Author fixed (8c32ed7) apikey.go:179 The 409 mapping stops short of api_keys, the guard's headline table: POST tokens/keys still 500s with raw pq for a stale deleted-user id (verified). PR description "Reachable guard violations map to 409" is false. Map CheckAPIKeyUserDeleted at both handlers or amend the description R5 Mafu-san Yes
CRF-61 P3 Author fixed (R6; caps in #28870) 000587...up.sql:32 group_members left unguarded on a false justification: GetAuthorizationUserRoles (users.sql:634) reads it directly/unfiltered into the RBAC subject Groups, and prebuilds.sql:362 too; inertness actually depends on #28634. Guard it, or correct the comment naming the two direct readers and the #28634 dependency R5 Kurapika P3, Mafu-san P2 Yes
CRF-62 P3 Author fixed (R6; caps in #28870) usersoftdeleteguards.go:9 The constants file cites TestSoftDeleteGuardConstraintNames, which does not exist; the pin is real but under other names (TestSoftDeleteGuardWinsConcurrentInsert, TestUserCapsRequireReadCommitted). Point at the real tests R5 Netero/Komugi/Meruem/Bisky/Pariston/Mafuuu/Mafu-san/Zoro Yes
CRF-63 P3 Author fixed (R6; caps in #28870) user_soft_delete_guards_test.go:254 TestSoftDeleteGuardTriggerOrder pins name sort order, not firing order (BEFORE->AFTER stays green); the skills advisory lock and the secrets owner-reassignment isolation leg are both unpinned (removing them keeps tests green). Assert tgtype BEFORE ROW; add the missing concurrency/reassignment cases R5 Bisky/Komugi Yes
CRF-64 P3 Author fixed R6 (partial; comment-falsity untouched) server_dbcrypt_test.go:263 dbcrypt test comment "Deleted users cannot have user_links or user_secrets" is false and hides that dbcrypt rotate (iterates all users incl. soft-deleted, UPDATEs their links/secrets) aborts on the orphaned state the migration cleans up; description's "rotation over legacy orphaned rows" coverage is only user_ai_provider_keys R5 Mafuuu/Bisky Yes
CRF-65 Nit Author fixed (R6; caps in #28870) lock.go:43 LockPrefixUserSecretsCap/LockPrefixUserSkillsCap are exported constants with no reader; CRF-47 asked for a comment, not new API nothing ties to the SQL literals. Drop the consts (keep the comment) or assert them against pg_get_functiondef R5 Netero/Meruem/Pariston/Mafu-san/Zoro Yes
CRF-66 Nit Author fixed (R6; caps in #28870) lock.go:38 "a different derivation space, so the IDs cannot collide" is false: hashtextextended and FNV-1a share one bigint advisory keyspace (~2^-64 collision, not impossible). Same wrong reasoning at the LockIDChatInstruction block. Reword both R5 Knuckle/Meruem/Hisoka/Pariston Yes
CRF-67 Nit Author fixed (R6; caps in #28870) user_soft_delete_guards_test.go:541 Four cap constraint names are still raw literals (not in usersoftdeleteguards.go), so the description's "All trigger-raised constraint names are declared once" is false. Move them into package database R5 Mafu-san/Zoro Yes
CRF-68 Nit Author fixed (R6; caps in #28870) 000587...up.sql:99 The guard raises "Cannot create % for deleted user" on the UPDATE path where nothing is created; PATCH skill logs "create" while the handler says "modify". Use CASE WHEN TG_OP='INSERT' THEN 'create' ELSE 'modify' R5 Zoro Yes
CRF-69 Nit Author fixed (R6; caps in #28870) lockrace_test.go:14 The shared-harness file's header claims two consumers (guard tests + agent memory tests); only the guard tests exist on this branch (agent memory is #28423). State it is extracted for #28423 R5 Netero/Law/Bisky/Hisoka/Mafu-san/Zoro Yes

Law analysis

Round 4 (first assessment). Head 59f5ebe. Effective LOC +1397 -8 (14 files; 569 production, 828 test, 148 generated). Verdict: SPLIT (vertical, three slices). Enforcement: MANDATORY, scoped to slice 3 (C6, the GetAuthorizationUserRoles deleted filter, 5 production lines + test) which must be extracted and reviewed on its own; slice 1 (C2, cap triggers onto advisory locks, a pre-existing-hazard fix) recommended not required; slice 2 (the write-side guard core: guards + contract + 4 call sites + backfill + error mapping + dbcrypt test adaptation) is atomic and must not be cut further. Churn guard treats this mandatory verdict like a P0 on later rounds: author silence = BLOCKED.

Round 5. Head 2f95dc4. Effective LOC +1954 -13 (22 files; 718 production, 1236 test, 206 generated), +557 since R4. R4 mandatory split HONORED (read-side extracted to #28634). NEW verdict: SPLIT (vertical, two slices, slice A first). Enforcement: MANDATORY, scoped to slice A = the cap changes (C3 advisory-lock rewrite + C4 require_read_committed + C5 skills-cap UPDATE leg), which must be extracted and land first. Grounds: C4/C5 are new this round; require_read_committed rejects previously-succeeding writes under RR/Serializable (broad blast radius, a snapshot-isolation review, not soft-delete); the same-owner exemption is caller-enumeration-dependent (dbcrypt); C5 has no production caller. Slice B (the guard core) is atomic, not to be cut further.

Contested and acknowledged

CRF-3 (Note, up.sql:29) - SQLSTATE change to 23514 with stable constraint names

  • Finding: The api_keys/user_links/user_secrets guards now raise SQLSTATE 23514 (check_violation) instead of the plpgsql default P0001, and three of the new constraint names have no non-test consumer in this PR.
  • Author accepted (R2): The SQLSTATE change is intentional so callers can match stable per-table constraint names; message texts are unchanged so existing text assertions still hold, and the stacked memory PR (feat: add agent memory database foundation #28423) consumes the constraint contract. Recorded for the release note. Netero already verified the blast radius is clean (no bare IsCheckViolation catch-all swallows these).

Round log

Round 1

Netero-only gate. 1 P2, 1 P3, 1 Note. P2 gates the panel: first-pass review posted, panel deferred until Netero findings are addressed. Reviewed against 64d2d8a..bcd0d53.

Round 2

Churn guard PROCEED: CRF-1 and CRF-2 author-fixed (39171c5), CRF-3 author-accepted. Netero re-ran and found a new P1 (CRF-4) and P2 (CRF-5). Pre-panel gate: P1 keeps the round Netero-only; panel deferred to round 3. Reviewed against bcd0d53..39171c5.

Round 3

Churn guard PROCEED: CRF-4 (P1) and CRF-5 (P2) author-fixed in 5bb95f2 (branch rebased onto bd4af31, migration renumbered 000585 -> 000587). All prior findings closed or accepted. Consecutive-Netero cap reached, so the panel runs for the first time. Reviewed against bd4af31..e87dccb.

Round 3 panel result

17-reviewer panel. CRF-5 fix verified real; CRF-2 fix verified. But CRF-4 fix is INCOMPLETE: 2 new P1 deadlocks reproduced on unfixed callers (CRF-6 provisionerdserver, CRF-7 login org-sync), root cause an unenforced lock-ordering contract (CRF-8). CRF-1 fix traded the deadlock for a cap bypass (CRF-9) and a wrong INSERT-path lock mode (CRF-10). New: read-side authz gap (CRF-11). Plus 12 P3s and 3 Nits. Event REQUEST_CHANGES.

Round 4

Churn guard PROCEED: all 21 R3 findings addressed in one commit (59f5ebe, +926/-480, 19 files). Substantial rework: AcquireUserSoftDeleteGuardLock (now :one, ActionUpdate) at all four transactions with per-path deadlock tests (TestSoftDeleteGuardLockOrderPaths); advisory-lock cap triggers (pg_advisory_xact_lock, zz_ prefix + order test); shared fail_if_user_deleted() via TG_ARGV; read-side GetAuthorizationUserRoles deleted filter; 400 error mapping; +2 backfill tables. Panel re-runs to verify the new mechanisms (significant restructure). Law runs (effective additions 1397 > 1000, never assessed). Reviewed against bd4af31..59f5ebe.

Round 4 result

Law MANDATORY split (extract C6 read-side filter) -> panel skipped per the Law gate. Netero (advisory, post-panel) found a P0: the CRF-15 ActionUpdate fix breaks both OAuth2 token grants for ordinary users because they run under the user's own actor (CRF-27, reproduced), plus a P1 test-coverage gap (CRF-28, the lock-order test is SQL-only and cannot catch CRF-27) and P3s. Netero verified CRF-4/6/7/8/9/10/11/14/17/19/22/23/26 fixes hold at the SQL/state level. Event REQUEST_CHANGES. Reviewed against bd4af31..59f5ebe.

Round 4 panel

The post command requires panel reviewers in a post-panel round, so the panel ran (16 reviewers) despite the Law mandatory split, and folded Netero+Law in. Convergence: CRF-27 P0 confirmed by 7 reviewers (Bisky/Netero empirically show it is CAUGHT by an existing coderd test TestOAuth2ProviderTokenExchange/OK running as a member, so CI goes red, not silent; the genuine gap CRF-28 is the reverse direction). New: CRF-34 (read-side breaks provisioner delete -> undeletable workspace, the concrete bug behind Law's CRF-33), CRF-35 (ActionUpdate gate is role-dependent, gate as ResourceSystem, don't widen member role), CRF-37 (advisory-lock new deadlock edge, latent/#28423), plus CRF-38..50. Strategic convergence (Law/Pariston/Ryosuke/Meruem, echoing Knov R3): the write-side lock-ordering contract has produced 5 failures in 4 rounds (CRF-6/7/14/16/27); the read-side C6 is the actual security fix; land C6 first, reconsider the lock via a DB-level BEFORE DELETE/UPDATE lock or an idempotent reaper. All R3 fixes verified holding. Event REQUEST_CHANGES.

Round 5

Churn guard PROCEED: all 23 R4 findings addressed in 2f95dc4 (+965/-355). Law mandatory split honored: read-side hardening extracted to #28634. New mechanism: require_read_committed() gates the caps against RR/Serializable overshoot; skills cap gains an UPDATE leg. Effective additions grew to 1954 (+557 since Law's R4 analysis), so Law re-runs. Panel re-runs to verify the system-primitive authz fix, the new isolation guard, and the cap UPDATE leg. Reviewed against bd4af31..2f95dc4.

Round 5 panel + Law

Law: another MANDATORY split (extract the cap changes as slice A, land first). Panel (11) ran per the CLI post-panel constraint; folded Law+Netero in. No P0/P1: all R4 fixes verified holding (Netero/Bisky/Komugi/Mafu-san mutation-tested CRF-5/27/28/39/33/34). New P2 cluster on the NEW mechanisms: CRF-52 (require_read_committed over-broad: rejects all non-RC secret/skill writes, feature outage under config/ReadModifyUpdate), CRF-53 (same-owner exemption reopens the cap overshoot, reproduced), CRF-54 (migration DROP TRIGGER ACCESS EXCLUSIVE on api_keys + unbounded backfill = upgrade auth outage window), CRF-55 (TestOAuth2ProviderTokenExchangeLockOrder pins lock-taken not lock-first; deadlock class still reintroducible). Plus P3s: CRF-56 (UPDATE SET user_id re-parents onto deleted user), CRF-57 ("safe under any isolation level" false for guard INSERT path), CRF-58 (dead TG_ARGV[2] branch, 10 reviewers), CRF-59 (7th guard 500), CRF-60 (api_keys 409 gap), CRF-61 (group_members false justification: GetAuthorizationUserRoles reads it unfiltered into the RBAC subject), CRF-62/63/64 (test/comment accuracy). Nits CRF-65..69. Strategic (Pariston/Law): after the #28634 split this PR is defense-in-depth + metric hygiene; land #28634 first so protection is source-agnostic. Event REQUEST_CHANGES. Reviewed against bd4af31..2f95dc4.

Round 6 update

Churn guard BLOCKED. Base changed to e968694 (stacked on #28870 fix-user-cap-advisory-locks): Law's R5 mandatory cap split is HONORED. Migration renumbered 000587->000591; backfill DELETEs replaced by an idempotent dbpurge reaper (PurgeSoftDeletedUserResources); DROP TRIGGER replaced by CREATE OR REPLACE FUNCTION (CRF-54); 8 guards incl group_members; api_keys BEFORE UPDATE OF user_id trigger. 17 of 19 R5 findings addressed (caps-side CRF-51/52/53/65/66/67/69 in the base; guard-side CRF-54/55/56/57/58/61/62/63/68 in f3bd60a; CRF-64 partial). BLOCKED because CRF-59 (7th guard user_ai_budget_overrides still 500s, no 409 mapping) and CRF-60 (api_keys token/key creation still 500s; description dropped the false claim but does not state api_keys is deliberately unmapped) are SILENT: no code change, no reply (both were R5 body-level non-diff findings), omitted from the author's round-6 summary. No reviewers or Netero spawned per the churn-guard BLOCKED rule. COMMENT posted naming the silent findings; review blocked until the author fixes them or states why not. Also flagged: CRF-64's comment-falsity half (server_dbcrypt_test.go:263 "Deleted users cannot have user_links or user_secrets") is untouched. Reviewed against e968694..c5e2ac3.

Round 7

Churn guard PROCEED: R6 blockers CRF-59 and CRF-60 addressed in 8c32ed7 (409 mappings + tests). Base rebased onto main (1d5631f), migration renumbered 000592. Law does not run (effective 1805 < R5's 1954). First panel on the guard-only PR + the new dbpurge reaper. CI is broadly red (20 jobs incl gen/fmt/lint/sqlc-vet/build/test-js/storybook); this diff builds clean and is gofmt-clean, so the breadth points to a base/rebase issue (unconfirmed, gh 401). Reviewed against 1d5631f..dd747c8.

Round 7 panel

13-reviewer panel + Netero. No P0/P1 in the code: the guard core is solid, authz correct (Kurapika: no security findings), tests mutation-verified (Bisky), CRF-59/60/64 fixes real. Findings concentrate on the never-panel-reviewed dbpurge reaper and the handler/description surface. New: CRF-70 (P2, reaper unbatched + shares the purge tx: a failure rolls back all retention purges; Pariston/Zoro/Killua P2, Knuckle/Takumi/Mafuuu P3), CRF-71 (P2, reaper unindexed full scans forever + never latches; group_members has no index; Killua P2), CRF-72 (P3, reaper has no metric/log; Chopper/Mafuuu), CRF-73 (P3, group_members guard has no 409 handler mapping in patchGroup - class fix one instance short again; Netero/Hisoka), CRF-74 (P3, reaper CTE-order deadlock comment false twice; 6 reviewers), CRF-75 (P3, reaper test global delete flakes sibling tests under shared DB; Komugi), plus nits CRF-77/78/79. PROCESS: CI red is a P1 merge blocker (Mafu-san), likely base/rebase (this diff clean); PR description names 2 of ~6 409-mapped tables (P2 drift, Mafu-san/Leorio). #28634 (read-side) still needed for the auth half. Event REQUEST_CHANGES. Reviewed against 1d5631f..dd747c8.

Round 7 findings

# Sev Status Location Summary Round Reviewer Posted
CRF-70 P2 Open dbpurge.go:244 Reaper is the only unbatched delete in purgeTick and shares its single transaction: one unbounded 8-table DELETE for every soft-deleted user; a large first pass or lost deadlock rolls back ALL retention purges (audit/connection logs grow unbounded). Batch by user per tick (LIMIT) or give it its own transaction R7 Pariston/Zoro/Killua P2, Knuckle/Takumi/Mafuuu P3 Yes
CRF-71 P2 Open queries/users.sql:783 Reaper full-scans every tick forever for zero work after the backlog clears: no index for users WHERE deleted (partial indexes are WHERE deleted=false), group_members has NO index at all, and it never latches like its siblings (identifiedModuleCachePurged). Cost grows with soft-deleted-user count. Add indexes + latch after a clean pass R7 Killua P2, Knuckle/Chopper P3 Yes
CRF-72 P3 Open queries/users.sql:781 Reaper deletes security-relevant orphans (resurrected api_keys, org memberships) with no rowcount, no metric, no log field, unlike every sibling purge; the one operator-visible security cleanup is invisible. Use :execrows + records_purged_total{soft_deleted_user_resources} + slog.F R7 Chopper/Mafuuu Yes
CRF-73 P3 Open groups.go:302 The 8th guard (group_members) has no 409 handler mapping: patchGroup AddUsers for a deleted user 500s with raw pq. The CRF-59/60 class fix (409 mapping) was applied instance-by-instance and missed the 5th guarded insert path. Add IsCheckViolation(CheckGroupMemberUserDeleted) -> 409 R7 Netero/Hisoka Yes
CRF-74 P3 Open queries/users.sql:774 The reaper comment "deletes in the same table order as delete_deleted_user_resources to minimize deadlock exposure" is false twice: Postgres does not order data-modifying CTEs, and the reaper only touches already-committed-deleted users so it never races the cleanup. Drop the ordering claim R7 Knuckle/Killua/Hisoka/Takumi/Mafuuu/Zoro Yes
CRF-75 P3 Open dbpurge_test.go:3636 TestPurgeSoftDeletedUserResources starts a GLOBAL reaper that deletes every soft-deleted user's orphans, so under a shared test DB (CODER_PG_CONNECTION_URL) it can reap fixtures sibling tests build (TestSoftDeleteGuardRejectsUpdatesForDeletedUser, dbcrypt) -> flake. Not CI (per-test DB default). Skip under shared-DB or give it a dedicated DB R7 Komugi Yes
CRF-77 Nit Open exp_chats.go:6854 The AI-provider-key 409 omits the Detail ("%s has been deleted.") that its three sibling guard handlers include; the integrator is not told which user. Add Detail and assert it R7 Chopper/Leorio Yes
CRF-78 Nit Open members.go:77 The organization-member 409 Message drops the trailing period every sibling message carries; members_test.go pins the exact string. Add the period and update the assertion R7 Leorio Yes
CRF-79 Nit Open aibridge.go:879 The user_ai_budget_override 409 is reachable only in the live race; the common stale-deleted-user case hits the membership trigger first and returns 400 "not a member," misdirecting from the real cause (deletion). Consider ordering the deleted-user check first, or accept the membership message R7 Mafuuu Yes
About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First-pass review only. These are mechanical findings from the pre-panel reviewer (Netero); the full review panel has not yet reviewed this PR and will do so after these findings are addressed.

The change is well-constructed: the FOR NO KEY UPDATE choice is correctly reasoned against FOR KEY SHARE from FK validation, the INSERT-only gate is deliberate and documented, the isolation-level analysis is sound, and the tests are genuine (the concurrent-insert test blocks on the real row lock via pg_stat_activity rather than sleeping, and the migration backfill test asserts a non-zero pre-state with a control user so the post-state is not vacuous). Migration numbering and dump.sql are consistent.

Severity count: 1 P2, 1 P3, 1 Note.

The P2 is the one to resolve before the panel spends parallel review time: the migration comment claims the unlocked UPDATE path avoids the delete_deleted_user_resources deadlock, but for user_secrets that deadlock is still reachable through the separate per-user cap trigger, which takes FOR UPDATE on the users row on BOTH INSERT and UPDATE with no TG_OP gate. Netero reproduced the deadlock. Either gate the cap trigger's lock to INSERT-only, or correct the migration comment to state the hazard remains for user_secrets. As Netero put it: the comment "is true for user_links and user_skills and false for user_secrets."

The P3 flags that delete_deleted_user_resources wipes six tables but only four have insert guards; user_ai_provider_keys and organization_members have neither a guard nor a backfill entry here. That is a pre-existing gap this PR does not widen, but it needs a human decision: file a ticket or explicitly accept it.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
@ThomasK33

Copy link
Copy Markdown
Member Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First-pass review only (round 2). These are mechanical findings from the pre-panel reviewer (Netero); the full review panel still has not reviewed this PR and will do so once the P1 below is resolved. The panel runs next round regardless.

What improved since round 1: CRF-1 is genuinely fixed (the user_secrets cap trigger now gates its users-row lock to TG_OP = 'INSERT', and the UPDATE path completes immediately), and CRF-2 is fixed (new BEFORE INSERT guards plus backfill for user_ai_provider_keys and organization_members, and the race and migration tests now cover all six tables). CRF-3 is accepted as an intentional constraint contract. Netero also positively verified the backfill's "only race products survive" claim by confirming every table added to delete_deleted_user_resources shipped its own backfill in an earlier migration.

But the round-1 fix opened a new regression. Severity count: 1 P1, 1 P2.

The P1 is a lock-order inversion introduced by making the INSERT-path guard take users FOR NO KEY UPDATE. The two OAuth2 token transactions (authorization-code exchange and refresh grant) DELETE the previous api_keys row and then InsertAPIKey in the same transaction, so they now acquire the old api_keys tuple before users, while delete_deleted_user_resources acquires users before deleting api_keys. Netero reproduced a live deadlock at HEAD (Postgres aborted the soft-delete with SQLSTATE 40P01) that does not exist at base, where the soft-delete merely waits. The end state is the same one this PR exists to prevent: a user left active with a freshly minted token, now surfaced as a 500 on DELETE /api/v2/users/{user}. The migration comment's claim that gating the lock to the INSERT path avoids the deadlock is false as stated: the inversion is per-transaction statement order, not per-trigger-operation. In Netero's words: "the same end state the PR exists to prevent, reached through a different door." Fix direction: have the token-writer transactions take the parent users lock first (before DeleteAPIKey*), matching the cleanup's order, and correct the migration comment.

The P2 is that the regression test offered as proof of the CRF-1 fix, TestSoftDeleteGuardUpdatePathTakesNoUserLock, never bounds the wait: when the shared context deadline fires, database/sql rolls back the lock-holding transaction and the previously blocked UPDATE then succeeds, so the test passes whether or not the TG_OP gates exist (Netero measured 25s at round-1 base vs 0.14s at HEAD, with no assertion looking at the delay). The CRF-1 fix is real; the guard shipped to protect it is not.

Process note, out of scope: make lint/go is red on this repo state with three staticcheck SA5011 issues in scripts/clidocgen/main.go, a file this PR does not touch. Netero could not map it to the named failing CI jobs (gh returned 401 in the review sandbox), so treat the CI mapping as unverified, but it should not be left red.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/user_soft_delete_guards_test.go Outdated
@ThomasK33

Copy link
Copy Markdown
Member Author

/coder-agents-review

@ThomasK33
ThomasK33 force-pushed the fix-user-soft-delete-guards branch from 4776321 to e87dccb Compare August 26, 2026 07:23
@ThomasK33 ThomasK33 changed the title fix(coderd/database): lock parent user row in soft-delete guards fix: lock parent user row in user soft-delete guards Aug 26, 2026
@ThomasK33

Copy link
Copy Markdown
Member Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First full panel round (17 reviewers). This is a genuinely well-built change and several parts earned praise on their own: the FOR NO KEY UPDATE lock strength is correctly chosen against the FK FOR KEY SHARE, the isolation-level analysis is sound, the backfill runs before CREATE TRIGGER to minimize the ACCESS EXCLUSIVE window, the migration header is (in Leorio's words) "the best migration chart note I have read in this repo," and the race tests are real: multiple reviewers mutation-tested them (remove a guard's lock and TestSoftDeleteGuardWinsConcurrentInsert fails by timing out in waitForBackendBlocked). Prior-round fixes hold up: CRF-5's lock_timeout witness is verified real (removing a TG_OP gate now fails the test with 55P03 instead of being rescued by the context rollback), and CRF-2's six-table guard+backfill coverage is confirmed.

Severity count: 2 P1, 4 P2, 12 P3, 3 Nits.

The headline: the CRF-4 fix is incomplete. CRF-4 (the OAuth2 lock-order inversion) was closed as author-fixed, but the fix reordered only the two tokens.go call sites, and the underlying invariant, take the users lock before touching any guarded child row, is enforced by a migration comment and nothing else. Two more transactions violate it and were reproduced as live deadlocks against a concurrent user soft-delete, PR-introduced (dropping the new trigger or checking out base removes the deadlock in every reviewer's control run): regenerateSessionToken on the workspace-start path (CRF-6) and the OIDC/SSO login org-sync transaction (CRF-7). The consequence in both is the exact state this PR exists to prevent: DELETE /api/v2/users/{user} returns 500 (Postgres picks the victim, so a build or login can 500 instead), and 40P01 is not auto-retried (only 40001 is), so it surfaces as a hard error. This is a fix applied per-instance instead of to the class. As Ryosuke put it: "A contract enforced by prose is a contract that decays."

Three structural directions came up repeatedly and are worth a human decision before another Acquire call is bolted on (CRF-8): (1) put the invariant on the read side, GetAuthorizationUserRoles and ValidateAPIKey do not filter users.deleted today, so a resurrected api_keys row authenticates with full roles (CRF-11, verified end-to-end: a suppressed-cleanup orphan key returned 200 on GET /users/me). One AND NOT users.deleted predicate makes every orphan inert regardless of source and imposes no lock ordering on anyone. It does not deliver the per-table constraint contract #28423 wants, so it is a complement, not a drop-in. (2) Make the guard itself order-safe: Knov's suggestion of BEFORE ROW DELETE triggers that take the users lock (BEFORE ROW fires before heap_delete's tuple lock, so every deleter takes users first unconditionally) makes the inversion unrepresentable and removes the caller contract entirely, at the cost of serializing child deletes on the users row. (3) At minimum, add a deadlock-regression test per delete-or-update-then-insert path using the harness already in this PR (reviewers' repros ran ~1s each, deterministic), since removing either tokens.go lock call currently breaks no test.

The CRF-1 fix also has fallout on the user_secrets cap trigger: gating its lock to INSERT removed UPDATE-path serialization, so concurrent per-row PATCHes now blow past the byte caps (CRF-9, reproduced at ~300-400 KiB against a 204 KiB cap and 72 KiB against a 24 KiB env cap; the "bounded overshoot" the comment promises scales with row count, and the over-cap state is sticky, the user can no longer update any secret). Separately the INSERT-path FOR UPDATE the cap trigger keeps is the wrong lock mode: it upgrades the guard's FOR NO KEY UPDATE, conflicts with FK FOR KEY SHARE across the 36 tables referencing users, and adds a deadlock edge (CRF-10). A per-user advisory lock (pg_advisory_xact_lock keyed on user_id, the pattern already used at chatd/synthetickey.go) on both paths closes the bypass and drops the users-row lock, resolving both.

Process notes, not inline: the PR description is stale, it still says migration 000585, never mentions AcquireUserSoftDeleteGuardLock, the tokens.go change, or the ordering contract, which is the single most important thing a future writer needs (CRF-12). The required CI check title is red; the current title parses as valid, so the run is most likely stale from the earlier scoped title (a scope like coderd/database would now fail on the oauth2provider/ and enterprise/ files), but nobody in the panel could read the log (gh returned 401 in the sandbox), so please confirm rather than assume. New per-user serialization on the hot users row is acceptable but on the record: every api_keys insert now waits on any open write to that user's row. And Pariston's note that the migration comment's stated UPDATE-path deadlock mechanism is imprecise (single-row BEFORE ROW triggers fire before the tuple lock; the real hazard is multi-row UPDATE and ON CONFLICT) is worth a wording pass, though the gates themselves are justified.


coderd/provisionerdserver/provisionerdserver.go:3320

P1 [CRF-6] The CRF-4 fix covered tokens.go but missed regenerateSessionToken, which is the same delete-then-insert on api_keys and still deadlocks against a concurrent user soft-delete. (Knuckle, Meruem, Takumi, Killua, Hisoka, Razor, Knov, Mafu-san P1; Ryosuke, Melody, Mafuuu, Pariston P2; Kurapika P3)

The transaction at 3319 runs deleteSessionToken (nested InTx reuses the outer transaction, db.go:186), which holds the api_keys tuple, then InsertAPIKey at 3325, whose guard now takes users FOR NO KEY UPDATE. Lock order: api_keys tuple, then users. delete_deleted_user_resources takes users then api_keys. Cycle. (Knuckle)

Reproduced by five reviewers with controls (deadlock at HEAD, none at base). Postgres picks the victim, so either DELETE /api/v2/users/{user} 500s and the user stays active with a fresh token, or the build fails at regenerate session token. Runs on WorkspaceTransitionStart.

Reachability debate, resolved toward P1: deleteUser rejects users with non-deleted workspaces (users.go:697), so for a real user the race needs the TOCTOU between that unlocked GetWorkspaces check and UpdateUserDeletedByID (verified: no lock between them). That narrows probability but the path is externally reachable and reintroduces a closed P1 with no 40P01 retry, so consequence sets the floor. Fix: tx.AcquireUserSoftDeleteGuardLock(ctx, workspace.OwnerID) as the first statement inside the InTx at 3319.

🤖

coderd/userauth.go:1986

P1 [CRF-7] The new organization_members guard makes the OIDC/SSO login transaction deadlock against a concurrent soft-delete of the same user, an inversion that did not exist before this PR. (Komugi, Meruem, Melody, Mafuuu, Pariston, Hisoka P1; Takumi, Knuckle, Kurapika, Ryosuke, Mafu-san P2)

UpdateUserLink at 1943 locks the user_links tuple (the UPDATE path takes no users lock by design), then SyncOrganizations reaches InsertOrganizationMember (idpsync/organization.go:143), whose new guard takes users FOR NO KEY UPDATE. Cleanup holds users and waits on the user_links tuple. (Meruem)

Reproduced by six reviewers, each with the control that dropping only trigger_insert_organization_members removes the deadlock, so the PR introduces it (before, the insert took only FK FOR KEY SHARE, which does not conflict). Consequence: DELETE /users/{id} or the login 500s; no 40P01 retry. The trigger is an ordinary OIDC login that adds a membership, concurrent with an admin offboarding that user, which is the common deletion scenario (deletion requires no workspaces, not being logged out). The contract text only names DELETE; an UPDATE inverts identically. Fix: AcquireUserSoftDeleteGuardLock(ctx, user.ID) at the top of the oauthLogin InTx (line 1755), and restate the contract as "holds any lock on a guarded child row, then inserts."

🤖

coderd/httpmw/apikey.go:476

P2 [CRF-11] Authentication and authorization never check users.deleted, so the six write-side guards are the only line of defense against a resurrected row, and the read-side check is the cheaper, source-agnostic half that is missing. (Ryosuke P2; Pariston, who rated the underlying bug P0-worthy)

Verified end-to-end: with a soft-deleted user's api_keys row preserved (cleanup trigger suppressed), the same token returned 200 on GET /api/v2/users/me and GET /api/v2/workspaces. GetAuthorizationUserRoles selects roles with no deleted filter (confirmed) and ValidateAPIKey branches only on UserStatus (suspended/dormant), which stays active through a soft-delete.

This is the security consequence the PR's summary leads with, and it is real. AND NOT users.deleted on the authorization lookup makes every resurrected row inert whatever its provenance (this race, a future unguarded table, a restored backup, a manual insert), and imposes no lock ordering on anyone. It does not deliver the per-table constraint contract #28423 wants, so it is a complement to the guards, not a replacement. This needs a human decision: adopt the read-side check as defense in depth, or explicitly accept that the write-side triggers plus the ordering contract are the whole invariant.

🤖

coderd/members.go:74

P3 [CRF-20] The two new guards surface as raw HTTP 500 with Postgres text for caller errors, instead of a mapped 400. (Leorio)

postOrganizationMember handles 404 and the unique-violation, then falls through to InternalServerError. GetUserByID has no deleted filter, so ExtractUserParam resolves a soft-deleted user and the insert hits organization_member_user_deleted; the admin gets a 500 whose only clue is "Cannot create organization_member for deleted user" in Detail. Map it like the unique-violation branch to a 400 ("Cannot add a deleted user to an organization"). Sibling: exp_chats.go:6656 answers user_ai_provider_key_user_deleted with a 500 and logs server-side, so the caller learns nothing. Both new guards need a mapped branch or the PR ships two new 500s.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/queries/users.sql Outdated
Comment thread enterprise/dbcrypt/cliutil_test.go Outdated
Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/user_soft_delete_guards_test.go Outdated
Comment thread coderd/database/user_soft_delete_guards_test.go Outdated
@ThomasK33

Copy link
Copy Markdown
Member Author

Round 9 pushed as 59f5ebe, addressing all 21 round-8 findings. The body-level ones:

  • CRF-6 (P1): regenerateSessionToken takes AcquireUserSoftDeleteGuardLock(workspace.OwnerID) as the first statement of its transaction, and now asserts user.ID == workspace.OwnerID so the lock cannot silently target the wrong row. Regression: TestSoftDeleteGuardLockOrderPaths/RegenerateSessionToken.
  • CRF-7 (P1): oauthLogin takes the lock at the top of its transaction (system context; skipped for new signups whose users row is created inside the transaction and is invisible to a concurrent soft-delete). The contract is restated as "holds any lock on a guarded child row, then inserts" in fail_if_user_deleted()'s body. Regression: TestSoftDeleteGuardLockOrderPaths/OAuthLoginOrgSync.
  • CRF-11 (P2): adopted as defense in depth: GetAuthorizationUserRoles filters users.deleted = false, which is the exact query httpmw/apikey.go uses to build the auth subject, so any resurrected or orphaned credential is inert regardless of source. TestDeletedUserHasNoAuthorizationRoles reconstructs the orphaned-key state and asserts role resolution refuses it. This complements the per-table constraint contract feat: add agent memory database foundation #28423 consumes; flagged here for the author to veto if the deleted-user filter is too aggressive for any legitimate roles lookup (the parity test with GetActiveUsersAuthorizationRoles, which already excludes deleted users, passes).
  • CRF-12 (P3): PR description rewritten: migration 000587, the ordering contract and AcquireUserSoftDeleteGuardLock with all four call sites, the read-side check, the advisory-lock cap redesign, backfill scope, and error mapping.
  • CRF-20 (P3): both reachable guard violations map to 400s: postOrganizationMember (organization_member_user_deleted) and upsertUserAIProviderKey (user_ai_provider_key_user_deleted), with the constraint names declared beside their handlers.
  • Process notes: the red title check was indeed the stale scoped title; the PR is retitled scope-less and the check is green on the current head. Pariston's wording note on the UPDATE-path deadlock mechanism is incorporated in the function body comment (multi-row UPDATE / ON CONFLICT, not single-row BEFORE ROW timing). The per-user serialization of api_keys inserts on the users row remains, as accepted on the record.

Structural (CRF-8): enforcement is now mechanical on both axes; per-path deterministic deadlock regressions for the availability invariant, and the read-side filter for the security invariant. The BEFORE ROW DELETE design was evaluated and rejected because it moves the users lock onto every child DELETE, including multi-user batched deletes such as DeleteExpiredAPIKeys, which would acquire users rows in arbitrary scan order and open a new inversion class. CRF-9/CRF-10: both cap triggers now serialize on per-user advisory locks on every firing path, with zz_-prefixed names pinning guard-before-cap order (TestSoftDeleteGuardTriggerOrder), and TestUserSecretsCapConcurrentUpdates witnesses the closed UPDATE bypass.

@ThomasK33

Copy link
Copy Markdown
Member Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 4, first full panel on the rework (16 reviewers, plus Netero and Law). Note on process: Law returned a mandatory split (extract the read-side filter, CRF-33), which normally skips the panel, but the tooling requires panel reviewers on a post-panel round, so the panel ran and Law/Netero are folded in here.

The rework is genuinely strong and the panel verified it rather than trusting it: every round-3 fix holds at the SQL and state level (advisory-lock cap rewrite closes the CRF-9 bypass and CRF-10 lock-mode edge, the shared fail_if_user_deleted() passes the right TG_ARGV per trigger, the read-side parity holds, the backfill covers eight tables, and multiple reviewers mutation-tested the race suite and it fails for the right reasons). Several reviewers independently ran the full missed-caller sweep and confirmed the four locked transactions are the complete set.

Severity count: 1 P0, 1 P1, 3 P2, 12 P3, plus nits.

The P0 (CRF-27): the CRF-15 fix authorized the guard lock as ActionUpdate on the target user, but both OAuth2 grants take it under the end user's own actor, and the member role denies self-update, so token exchange and refresh fail for every non-admin. Seven reviewers converged. One correction to Netero's framing, established empirically by Bisky: this is NOT silent, an existing test (coderd/oauth2_test.go TestOAuth2ProviderTokenExchange/OK) runs as a member and fails, so CI catches it (the failing coderd job is likely among the pending checks). The deeper defect (CRF-35, three reviewers) is that the ActionUpdate gate is role-dependent and protects nothing the guard trigger doesn't already hand out for free; the fix is to model the lock as a system primitive (ResourceSystem), not to escalate two call sites and leave the trap armed, and specifically NOT to grant members ResourceUser:update (that action also gates suspend/activate and DeleteUserAIBudgetOverride). In Hisoka's words: "I came for the guards. The guards held. The thing that broke was the lock you swapped in to avoid a deadlock."

Law's mandatory split (CRF-33: extract the read-side GetAuthorizationUserRoles filter) is validated by a concrete bug the panel found (CRF-34, four reviewers): that query is :one, so the new AND users.deleted = false returns sql.ErrNoRows for a soft-deleted owner, and its two non-authentication callers (provisionerdserver.go:855, dynamicparameters/render.go:319) treat any error as fatal. Every build for a deleted owner then fails, including the delete build, leaving the workspace and its cloud resources unrecoverable without DB surgery. This is exactly why the read-side change deserves its own PR and reviewers: it entered at round 3 as CRF-11 remediation and its blast radius was never reviewed as a primary change.

Strategic point, converged independently by Law, Pariston, Ryosuke, and Meruem (echoing Knov in round 3): the write-side lock-ordering contract exported from the database to four handler transactions has now produced five distinct failures on one mechanism across four rounds (CRF-6, CRF-7, CRF-14, CRF-16, CRF-27), and each was caught by a reviewer, never by a check. Meanwhile the authentication harm the mechanism is priced against is fully neutralized by the read-side filter alone (a resurrected key yields no principal). The recommendation is not to abandon the guards, but to land the read-side half first (Law's split) and then reconsider the write-side lock with the security pressure removed, via either a DB-level BEFORE DELETE/UPDATE trigger that takes the users lock before the child row (making the inversion unrepresentable) or an idempotent reaper that moves child cleanup out of the soft-delete transaction. Zoro's useful caveat: simply swapping the guard's lock to an advisory lock does not help, the ordering contract is inherent to any lock the guard takes after the child rows.

The P1 (CRF-28) is why the contract keeps breaking silently in the reverse direction: TestSoftDeleteGuardLockOrderPaths replays hand-written SQL that mirrors each Go transaction, so removing the lock call, weakening the query's FOR NO KEY UPDATE to FOR KEY SHARE, or reverting it from :one all keep the suite green (Bisky, Takumi, and Razor each demonstrated one). Drive at least one path through its real Go entry point.

Process/nits folded here rather than inline: the two advisory-lock keys are not registered in coderd/database/lock.go (CRF-47); the migrate_test.go loop variable guardedTables should be backfilledTables since it includes the two unguarded tables (CRF-49); require.Less on two literals reads as constant-true but is load-bearing via the adjacent pg_trigger count check, so it is a tidy-up not a defect (CRF-32); and a scattering of pure style nits (message punctuation, the stmt type name colliding with database/sql, id.String()[:13] truncation, the 200 KiB literal, raw INSERT seeds where dbgen helpers exist). The AcquireUserSoftDeleteGuardLock doc comment and the in-body guard rationale that reaches dump.sql are, per Leorio, the best writing in the diff; fix the two overclaims in CRF-44 and they do their job.


coderd/provisionerdserver/provisionerdserver.go:855

P2 [CRF-34] The read-side users.deleted filter turns every provisioner job for a soft-deleted owner into a permanent failure, including the delete build, so the workspace and its cloud resources can never be reclaimed. (Knuckle, Hisoka P2; Ryosuke, Komugi P3)

GetAuthorizationUserRoles is :one; with AND users.deleted = false it returns sql.ErrNoRows for a deleted owner, and both provisioning consumers treat any error as fatal (failJob("get owner authorization roles: %s") here, xerrors.Errorf("user roles: %w") at dynamicparameters/render.go:319). The owner is fetched with GetUserByID (no deleted filter) so the job reaches this line and blows up. Reachability is narrow (needs the deleteUser TOCTOU: GetWorkspaces then UpdateUserDeletedByID unserialized, with a workspace landing in the window) but the failure is permanent and --orphan does not help because it still needs a provisioner job; recovery is manual DB surgery. Fix: treat sql.ErrNoRows as an empty role set in these two callers (like GetGitSSHKey handling 150 lines up), or put the predicate in UserRBACSubject/ValidateAPIKey on the auth path only (which also resolves CRF-29). This is the concrete blast radius behind Law's CRF-33: if C6 is extracted, it ships with this bug unless the consumers are fixed too.

🤖

coderd/httpmw/apikey.go:901

P3 [CRF-29] An orphaned API key of a soft-deleted user now produces HTTP 500, not 401. (Netero)

GetAuthorizationUserRoles is :one, so the new filter returns sql.ErrNoRows for a deleted user; UserRBACSubject wraps it and apiKeyFromRequestValidate maps any error to StatusInternalServerError with Hard: true. The credential is correctly rejected, so this is presentation only, but the deployment sees 500s for a denied credential. Distinguish sql.ErrNoRows and return 401. Fixing this in UserRBACSubject/ValidateAPIKey (rather than in the shared query) also resolves CRF-34 by leaving the two provisioning callers untouched.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/oauth2provider/tokens.go Outdated
Comment thread coderd/database/dbauthz/dbauthz.go Outdated
Comment thread coderd/database/user_soft_delete_guards_test.go
Comment thread coderd/database/queries/users.sql Outdated
Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/migrations/migrate_test.go Outdated
Comment thread coderd/database/user_soft_delete_guards_test.go Outdated
Comment thread coderd/database/user_soft_delete_guards_test.go Outdated
Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/migrations/migrate_test.go
@ThomasK33 ThomasK33 added the experimental Changes that might not necessarily be merged, until its approved to proceed with. label Aug 26, 2026
@ThomasK33

Copy link
Copy Markdown
Member Author

Round 10 pushed as 2f95dc4, addressing all 21 round-9 findings. The headline items:

  • CRF-27 (P0) / CRF-35 (P2): AcquireUserSoftDeleteGuardLock is re-modeled as a system primitive (rbac.ResourceSystem, ActionUpdate) per the panel's prescription; user-scoped call sites wrap only the lock call in AsSystemRestricted, no member permissions widened, and the member exchange path is green again.
  • CRF-33 (P2, Law split): the read-side filter is extracted to fix(coderd): reject API keys of soft-deleted users during authentication #28634 as a primary change, redesigned per CRF-34/CRF-29: the query returns users.deleted instead of filtering (provisioning consumers keep resolving deleted owners' roles), and only the authentication path rejects, with 401 not 500.
  • CRF-28 (P1): TestOAuth2ProviderTokenExchangeLockOrder drives the real HTTP exchange as a member and asserts (via pg_stat_activity and the sqlc query name) that the transaction blocks inside AcquireUserSoftDeleteGuardLock before any child write; the panel's three mutations now each fail it.
  • CRF-37 (P3): the ordering contract is restated as write-then-insert (any lock kind) on the guard, both cap bodies, and the query doc; SecretsUpdateThenInsert pins the advisory-lock leg.
  • CRF-38 (P3): user_ai_budget_overrides is now the seventh guarded table (GetOverBudgetUsersPerGroup reads it unfiltered); the false "inert" paragraph is gone.
  • CRF-39/48/49: a second doomed user with no organization_members row makes the transitive backfills non-vacuous; the migration test uses the shared transactional helper; backfilledTables.
  • CRF-40/41/42: both mappings are now 409 Conflict (matching userskills.go; named in the description), the Detail says "%s has been deleted.", and each endpoint has a coderdtest case.
  • CRF-30/31/44/45/46/47/50/32: lock on user.ID with the assertion deleted; one dbtestutil.SoftDeleteUserKeepingRows; overclaims fixed; call-site comments reduced to local fact + pointer; all nine trigger constraint names in coderd/database/usersoftdeleteguards.go; advisory key prefixes registered in lock.go; the guard-order test reads firing order from pg_trigger.

One addition beyond the findings: the cap triggers now require READ COMMITTED via a shared require_read_committed() (the CRF-99 invariant from the stacked PR belongs to the tables this PR rewrote; Meruem's RR overshoot repro is closed by TestUserCapsRequireReadCommitted). Same-owner user_secrets updates are exempt because dbcrypt rotation legitimately rewrites values under REPEATABLE READ; inserts and owner reassignments are gated, and the skills cap gained the UPDATE leg closing CRF-103's reassignment bypass, both with tests.

@ThomasK33

Copy link
Copy Markdown
Member Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 5, focused panel (11) plus Netero and Law. Process note: Law returned another mandatory split (extract the cap changes), which normally skips the panel, but the tooling requires panel reviewers on a post-panel round, so the panel ran and Law/Netero are folded in.

The good news first, and it is substantial: there are no P0 or P1 findings this round. Every round-4 fix was verified holding, most by mutation against live PostgreSQL: the system-primitive authz fix (CRF-27/35), the read-side split honored to #28634 with no dangling consumer (CRF-33/34), the lock-order Go test (CRF-28), the transitive backfill coverage (CRF-39), the shared SoftDeleteUserKeepingRows helper (CRF-31/48), and the isolation gate breaks no current writer (four reviewers checked every RR/Serializable transaction in the tree). As Knuckle put it, "the schema side of this change is now clean. Five rounds of rework to get there, and the temptation at round four was to bolt a fallback onto each table and move on. It was not taken."

What remains is concentrated on the mechanisms added this round, which is exactly what Law's split predicts. Severity count: 5 P2, 9 P3, 5 nits.

Law's verdict (CRF-51) is a mandatory split: extract the cap changes (the advisory-lock rewrite, require_read_committed, and the skills-cap UPDATE leg) as their own PR, landing first. They are independently justified (the pre-existing FOR UPDATE hazard), must precede the guard topologically, and require_read_committed and the skills UPDATE leg are both new this round and change when ordinary writes are rejected. The panel's own findings on those two pieces are the argument for the split.

require_read_committed is over-engineered in two opposite directions at once. Pariston (CRF-52): it rejects every secret and skill write outside READ COMMITTED unconditionally, whether or not any concurrency exists (the author's own Rejected test inserts a single row into an empty table and it fails), so a deployment that sets default_transaction_isolation on the server, database, role, or pooler, or a future caller reaching for database.ReadModifyUpdate, gets a total feature outage, all to prevent a soft cap slipping by one. Komugi (CRF-53): meanwhile the same-owner exemption reopens the exact overshoot the gate was added to close (reproduced, 300k against a 204800 cap, reachable via dbcrypt rotation racing a user's secret edit). The two together say the mechanism is both too broad and too narrow; the advisory lock already makes the caps correct under READ COMMITTED, which is what every caller uses. Removing the gate and enforcing "these writes run at READ COMMITTED" with a CI test is the simpler shape.

Knuckle (CRF-54): the migration DROP TRIGGERs take ACCESS EXCLUSIVE on api_keys and three other tables and hold them, together with eight unbounded backfill DELETEs, until the whole single-transaction migration commits, blocking authenticated reads fleet-wide during the upgrade. CREATE OR REPLACE FUNCTION takes no table lock and keeps the one-shared-function win; ALTER TRIGGER ... RENAME avoids the drop for the zz_ renames; and the backfill belongs in an idempotent reaper.

Hisoka (CRF-55): the new Go lock-order test pins that the lock is taken, not that it is taken first. Moving AcquireUserSoftDeleteGuardLock to after the api_keys DELETE, the precise CRF-4 inversion, keeps the entire suite green; the deadlock class that produced CRF-4, CRF-6, and CRF-7 is still reintroducible at all four call sites. Hisoka wrote and verified the six-line fix (assert the blocked backend holds no RowExclusiveLock on the guarded tables before release). "Two tests, same transaction, one of them notices. Shall I show you which one you shipped?"

The P3s are mostly correctness-of-the-new-surface and comment/test accuracy: the guard fires on INSERT but not on UPDATE ... SET user_id, so a re-parent onto a deleted user (including api_keys) is unguarded (CRF-56, reproduced, no caller today); "safe under any isolation level" is false for the guard's own INSERT path under REPEATABLE READ (CRF-57); the TG_ARGV[2] fail-closed branch is dead and its comment describes a capability no table has, flagged by all ten panel-plus-Netero reviewers (CRF-58); the new seventh guard has no 409 mapping so it 500s with raw pq (CRF-59) and the 409 class also never reached api_keys, the headline table, whose token/key endpoints still 500 (CRF-60); group_members is left unguarded on a justification that is false, since GetAuthorizationUserRoles reads it unfiltered into the RBAC subject and its inertness actually depends on #28634 (CRF-61); and three test/comment accuracy items (CRF-62 a cited test that does not exist, CRF-63 the trigger-order test pins names not firing order, CRF-64 a dbcrypt coverage overclaim).

Nits folded here: the LockPrefix* constants have no reader and nothing ties them to the SQL (CRF-65); the lock.go "different derivation space, so cannot collide" reasoning is false in two places (CRF-66); four cap constraint names are still raw literals so "all declared once" is inaccurate (CRF-67); the guard message says "Cannot create" on the UPDATE path (CRF-68); and the shared lock-race harness file claims two consumers but has one (CRF-69).

Strategic, and a decision for you rather than a defect (Pariston, Law): after the #28634 split, this PR is defense-in-depth plus metric hygiene, and the security consequence the description leads with now lives in #28634. If #28546 lands alone it closes the race but not orphaned rows that predate the backfill or arrive through an unenumerated path, the class that produced CRF-6, CRF-7, CRF-17, and CRF-38 across four rounds; landing #28634 first makes the protection source-agnostic. Worth stating the intended merge order in one of the two descriptions.


enterprise/coderd/aibridge.go:885

P3 [CRF-59] The seventh guard, added this round on user_ai_budget_overrides, has no handler mapping, so a violation returns a 500 with raw pq text. (Kurapika, Bisky, Hisoka, Mafuuu)

upsertUserAIBudgetOverride maps the membership-trigger violation to a 400 and hands everything else to httpapi.InternalServerError(rw, err), which writes err.Error() into Detail, so a user_ai_budget_override_user_deleted violation returns pq: Cannot create user_ai_budget_override for deleted user. CRF-40/41 fixed exactly this class for the other two guards added earlier (409 + endpoint test); the seventh, added in the same commit, was left out. Reachable only in the race (the membership trigger sorts first and rejects a plainly-deleted user with the 400), which is why it is P3, but that race is what the guard exists for. Fix: one IsCheckViolation(err, database.CheckUserAIBudgetOverrideUserDeleted) branch returning 409, matching the other two.

🤖

coderd/apikey.go:179

P3 [CRF-60] The 409 mapping stops short of api_keys, the guard's headline table, which still returns a 500 with raw pq for a reachable request. (Mafu-san, verified end-to-end)

POST /users/{id}/keys/tokens and POST /users/{id}/keys resolve the target user by UUID with no deleted filter (GetUserByID), so a stale deleted-user id reaches InsertAPIKey and returns status=500 "Failed to create API key." detail="insert API key: pq: Cannot create API key for deleted user". The PR description says "Reachable guard violations map to 409 Conflict"; that is false, two of the three reachable ones map and the third, the one the PR's own summary calls the point of the change ("for api_keys that resurrects a live session token"), does not. The 500 is not a regression, but this PR establishes the error contract and leaves the primary table outside it. Map database.CheckAPIKeyUserDeleted at both handlers, or say in the description that api_keys key creation for a deleted user is deliberately unmapped.

🤖

enterprise/cli/server_dbcrypt_test.go:263

P3 [CRF-64] A dbcrypt test comment is false and hides that dbcrypt rotate breaks on the orphaned state this migration cleans up; the description overclaims the coverage. (Mafuuu, Bisky)

The comment "Deleted users cannot have user_links or user_secrets" contradicts the same loop, which reconstructs exactly that state with SoftDeleteUserKeepingRows twelve lines up. The reason those two tables are skipped is behavioral: Rotate iterates AllUserIDs (which includes soft-deleted users) and calls UpdateUserLink / UpdateUserSecretByUserIDAndName, and the guard's UPDATE branch raises on both for a deleted user, so a single orphaned row aborts the whole coder server dbcrypt rotate run. The behavior predates this PR, and post-backfill the state should not exist, but the description claims "dbcrypt rotation/decryption over legacy orphaned rows" as tested coverage when the tested coverage is user_ai_provider_keys only (the one INSERT-only guard). Reword the comment to say why the two tables are excluded, and narrow the description's coverage claim.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/oauth2_test.go
Comment thread coderd/database/lock.go Outdated
Comment thread coderd/database/lock.go Outdated
Comment thread coderd/database/user_soft_delete_guards_test.go Outdated
Comment thread coderd/database/migrations/000587_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/lockrace_test.go Outdated
@ThomasK33
ThomasK33 force-pushed the fix-user-soft-delete-guards branch from 2f95dc4 to c5e2ac3 Compare September 1, 2026 14:04
@ThomasK33
ThomasK33 changed the base branch from main to fix-user-cap-advisory-locks September 1, 2026 14:04
@ThomasK33

Copy link
Copy Markdown
Member Author

Round 6: restructured per the Round 5 panel

The branch was rebuilt per CRF-51: the per-user cap rework moved to #28870 (now this PR's base), and the guard work was reshaped:

  • CRF-54 — migration 000591 no longer drops triggers (CREATE OR REPLACE FUNCTION delegation to one shared check_user_not_deleted(), no ACCESS EXCLUSIVE on hot tables) and has no backfill DELETE statements; orphans are removed by a new idempotent dbpurge reaper (PurgeSoftDeletedUserResources) at startup and on the purge cadence.
  • CRF-56 — every guard now covers UPDATE ... SET user_id (locking exactly when a row starts belonging to a user); api_keys gains a dedicated BEFORE UPDATE OF user_id trigger with a WHEN clause so the per-request last_used bump never enters plpgsql. Same-owner updates stay unlocked.
  • CRF-61group_members is guarded (its readers include GetAuthorizationUserRolesrbac.Subject.Groups); the false "inert" comment is gone.
  • CRF-57 — the stated isolation guarantee is now the true one: correct at READ COMMITTED; 40001 under RR/SERIALIZABLE; guarded inserts must not run in database.ReadModifyUpdate.
  • CRF-58 — the dead TG_ARGV[2] fail-closed branch and its header paragraphs are deleted (the capability moves to feat: add agent memory database foundation #28423 with the table that needs it).
  • CRF-55TestOAuth2ProviderTokenExchangeLockOrder now pins the lock's position: while blocked in AcquireUserSoftDeleteGuardLock, the exchange backend must hold zero RowExclusiveLocks on api_keys/oauth2_provider_app_codes. Verified red against the reordered-lock mutation.
  • CRF-62/63/68 — constants cite the real pinning test; the trigger-order test asserts BEFORE ROW timing alongside name order; the guard raises "modify" (not "create") on UPDATE paths.
  • CRF-52/53/65/66/67/69 — addressed in fix(coderd): serialize per-user caps on advisory locks instead of the users row #28870 (gate removed, guarantees stated, lock.go reworded, cap constants exported, harness header corrected); per-thread replies have details.

Validation (PostgreSQL-backed): migrations suite incl. TestMigration000591LockUserSoftDeleteGuards (orphans survive, guards + reassignment legs live post-migration), TestMethodTestSuite, the full SoftDeleteGuard suite (8-table concurrent-insert race, reassignment race, lock gates, per-call-site deadlock replays), TestPurgeSoftDeletedUserResources, TestOAuth2ProviderTokenExchangeLockOrder, the 409-handler tests, and the dbcrypt orphan-rotation tests.

Note: the branch temporarily carries #28874's story-deflake commits via its base; they drop out of the diff as the stack merges (order: #28874#28870#28634/#28546#28423).


Generated with mux • Model: anthropic:claude-fable-5 • Thinking: xhigh

@ThomasK33

Copy link
Copy Markdown
Member Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 6, churn-guard pass only. This is a blocking status check, not a full review: the panel does not run this round because two findings from round 5 are unaddressed with no response, and the review process holds until they are closed one way or the other.

First, real credit: this round resolved 17 of the 19 open findings and honored Law's mandatory split. The per-user caps (advisory locks, isolation contract, and the round-5 P2s CRF-52/53) are gone from this PR, split to #28870, which this branch is now stacked on. The migration was reworked exactly as asked: no DROP TRIGGER (so no ACCESS EXCLUSIVE on api_keys during upgrade, CRF-54), the backfill DELETEs replaced by the idempotent PurgeSoftDeletedUserResources reaper, the guard now locks on ownership change as well as INSERT (CRF-56), group_members guarded on the corrected justification (CRF-61), the dead TG_ARGV[2] branch removed (CRF-58), the isolation comment corrected (CRF-57), the lock-order test now pins the lock's position (CRF-55), and the trigger-order test now checks BEFORE ROW timing (CRF-63). That is a large, clean round.

What blocks the next review are two round-5 findings that received no fix and no response, and were omitted from the round-6 summary comment. Both were posted in the round-5 review body (their files are outside this PR's diff), so there was no inline thread to reply to, which is likely why they slipped:

  • CRF-60 (P3): POST /users/{user}/keys/tokens and POST /users/{user}/keys still return HTTP 500 with raw pq: Cannot create API key for deleted user in the Detail for a stale deleted-user id. coderd/apikey.go maps only the unique-violation to 409 and falls through to InternalServerError. api_keys is the guard's headline table, and the round-6 description dropped the earlier false "reachable guard violations map to 409" sentence but does not say api_keys is deliberately left unmapped. Either map database.CheckAPIKeyUserDeleted to 409 at both handlers (six lines, like members.go), or state in the description that api_keys key creation for a deleted user is intentionally a 500.

  • CRF-59 (P3): the seventh guard, user_ai_budget_overrides, added to close CRF-38, has no handler mapping. upsertUserAIBudgetOverride (enterprise/coderd/aibridge.go) maps only the membership-trigger violation and falls through to InternalServerError, so the guard violation surfaces as a 500 with raw pq text in exactly the race it was added to catch. Add the IsCheckViolation(err, database.CheckUserAIBudgetOverrideUserDeleted) branch returning 409, matching the two guards that already do.

Neither needs to be a fix if there is a reason not to: a one-line reply ("api_keys creation for a deleted user intentionally returns 500 because ...") closes each. A bare deferral without a ticket does not; if either is out of scope, say why or file an issue.

One related loose end, not itself blocking (CRF-64 was otherwise addressed): the comment at enterprise/cli/server_dbcrypt_test.go:263, "Deleted users cannot have user_links or user_secrets.", is still there and still false (the same test reconstructs that state with SoftDeleteUserKeepingRows); reword it while you are in the file.

The full panel will run once CRF-59 and CRF-60 are addressed or explicitly declined.

🤖 This review was automatically generated with Coder Agents.

@ThomasK33
ThomasK33 force-pushed the fix-user-cap-advisory-locks branch from e968694 to 8f2226f Compare September 6, 2026 16:04
@ThomasK33
ThomasK33 force-pushed the fix-user-soft-delete-guards branch from c5e2ac3 to 8c32ed7 Compare September 6, 2026 16:04
@ThomasK33

Copy link
Copy Markdown
Member Author

Round 7: the two body-only Round-5 findings, closed with fixes

Pushed as 8c32ed7, rebased onto #28870's new head (8f2226f, itself on main acae170664). Sorry for letting these two slip: they were body-only findings with no thread to answer and fell out of the Round-6 summary.

  • CRF-60POST /users/{user}/keys and POST /users/{user}/keys/tokens map database.CheckAPIKeyUserDeleted to 409 (coderd/apikey.go, same shape as members.go), with a message naming the deleted user instead of the raw pq: text. Covered by handler tests in coderd/apikey_test.go that soft-delete the user (rows kept) between the middleware fetch and the insert.
  • CRF-59upsertUserAIBudgetOverride maps database.CheckUserAIBudgetOverrideUserDeleted to 409 (enterprise/coderd/aibridge.go). The handler test holds an uncommitted soft-delete on the users row so the racing upsert blocks on the guard lock and is rejected by the guard, not by the alphabetically earlier membership trigger.
  • CRF-64 loose end — the enterprise/cli/server_dbcrypt_test.go comment no longer claims deleted users cannot have user_links/user_secrets; it says the guards reject inserting them for a deleted user, so the test seeds them only for live users.
  • The beforeCommit hook returns to runLockRace here, with the guard races as its consumers (dropped from fix(coderd): serialize per-user caps on advisory locks instead of the users row #28870 as unexercised, per its CRF-3).

No other behavior changed since Round 6; the branch was rebased as-is onto the restructured base (the storybook carry is gone from the whole stack, which is now #28870 → this PR → #28423 on main).

Validation (PostgreSQL-backed): go test ./coderd -run 'TestAPIKeysDeletedUser|TestAPIKey_Deleted', go test ./enterprise/coderd -run 'TestUserAIBudgetOverride' (incl. Upsert/DeletedUserRace), go test ./coderd/database/... (guard suites, TestMigration000591LockUserSoftDeleteGuards, TestPurgeSoftDeletedUserResources, TestMethodTestSuite), go test ./enterprise/cli -run TestServerDBCrypt, and the full repository Go suite at the stack head (31,577 tests; the only failure was an unrelated agent/agentssh shutdown-timing flake that passes in isolation).


Generated with mux • Model: anthropic:claude-fable-5-1 • Thinking: xhigh

@ThomasK33

Copy link
Copy Markdown
Member Author

/coder-agents-review

@ThomasK33
ThomasK33 force-pushed the fix-user-soft-delete-guards branch from 8c32ed7 to db94229 Compare September 7, 2026 08:26
@ThomasK33
ThomasK33 force-pushed the fix-user-cap-advisory-locks branch from 8f2226f to c5aeace Compare September 7, 2026 08:26
@ThomasK33

Copy link
Copy Markdown
Member Author

/coder-agents-review

A child-table insert racing a user soft-delete could commit after
delete_deleted_user_resources ran, resurrecting rows (including live
session tokens in api_keys) for a deleted account.

Migration 000591 delegates the four existing per-table guard functions to
one shared check_user_not_deleted() via CREATE OR REPLACE (no DROP
TRIGGER, no ACCESS EXCLUSIVE on hot tables), locks the users row with
FOR NO KEY UPDATE on INSERT and owner reassignment, adds guards to
user_ai_provider_keys, organization_members, user_ai_budget_overrides,
and group_members, and covers UPDATE ... SET user_id everywhere while
keeping same-owner updates unlocked.

There is no backfill in the migration: orphaned rows of already-deleted
users are removed by the idempotent dbpurge reaper
(PurgeSoftDeletedUserResources) at startup and on the purge cadence.

Transactions that write a guarded child row and later insert one for the
same user take the users lock first via AcquireUserSoftDeleteGuardLock
(OAuth2 token grants, oauthLogin, regenerateSessionToken) so their lock
order matches the cleanup trigger and cannot deadlock;
TestOAuth2ProviderTokenExchangeLockOrder pins both the lock and its
position via pg_locks.
… mocks

The strict-mock TestMetrics chat-retention subtests enumerate every store
call purgeTick makes; the new PurgeSoftDeletedUserResources call made the
mock abort before the chat purges ran.
Review round 5 body-only findings: POST /users/{user}/keys and
/keys/tokens surfaced the api_keys guard as a raw 500 (CRF-60), and
upsertUserAIBudgetOverride did the same for the budget-override guard in
exactly the deletion race it exists to catch (CRF-59). Map both check
violations to 409 like members.go and cover them with handler tests; the
budget test holds an uncommitted soft-delete so the guard, not the
alphabetically-earlier membership trigger, rejects the insert.

Also thread the beforeCommit hook through the lock-race harness here:
the base cap branch dropped it as unexercised, and this branch's guard
tests are the consumers that flip users.deleted while the racing insert
is parked on the users-row lock. Reword the stale dbcrypt test comment
about deleted users' user_links/user_secrets.
Trunk took 000590 for workspace_agent_session_counts (#28126), which
shifted the cap advisory-lock migration to 000591 and this one to
000592. The migration test's stepping constant, its name, and the
comments citing either migration number follow; dump.sql and the sqlc
output regenerate identically apart from those comments.

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `anthropic:claude-fable-5-1` • Thinking: `xhigh`_
@ThomasK33
ThomasK33 force-pushed the fix-user-soft-delete-guards branch from db94229 to dd747c8 Compare September 7, 2026 21:34
@ThomasK33
ThomasK33 force-pushed the fix-user-cap-advisory-locks branch from c5aeace to 1d5631f Compare September 7, 2026 21:34
@ThomasK33

Copy link
Copy Markdown
Member Author

Rebased onto the renumbered base: trunk took 000590 (#28126), so the soft-delete guard migration is now 000592 (renumber commit dd747c8: files renamed, TestMigration000592LockUserSoftDeleteGuards steps to 591, and the comments citing 000590/000591 shift; dump.sql and the sqlc output regenerate identically apart from those comments). No behavior change since Round 7. Full local pre-push gate green on the new head.


Generated with mux • Model: anthropic:claude-fable-5-1 • Thinking: xhigh

@ThomasK33

Copy link
Copy Markdown
Member Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 7, full panel (13) plus Netero, first panel on the guard-only PR and the new dbpurge reaper. The round-6 blockers are resolved (churn guard PROCEED): CRF-59 and CRF-60 now map their guard violations to 409 with real, race-driven handler tests.

This is in good shape and worth saying so. There are no P0 or P1 findings in the code. The guard core is solid, the authorization is correctly scoped (Kurapika found no security issues: the lock is a ResourceSystem primitive, the reaper runs as subjectDBPurge with exactly ResourceSystem:delete, the 409 mappings match specific constraints with no info disclosure), and the tests are genuine and mutation-verified (Bisky: strip the guard lock and the race tests fail red; the CRF-55 lock-position test, the CRF-63 firing-order test, and the CRF-59/60/64 fixes all hold). Moving the backfill out of the migration into a reaper (CRF-54) was the hard, correct call. Bisky: "I came in expecting costume jewelry. I stayed to appraise real stones."

The findings cluster on the reaper (never panel-reviewed until now) and one missed handler mapping. Severity: 2 P2, 4 P3, 3 nits, plus a merge blocker and a description issue below.

The reaper needs two reworks before it ships. CRF-70 (P2): it is the only delete in purgeTick with no batch bound, and it shares the single purge transaction with every retention delete. A large first pass on an old deployment, or one lost deadlock, rolls back the whole tick, so audit-log and connection-log purges make zero progress while the reaper keeps failing on the same oversized statement. Pariston put it well: a fix whose failure mode (all retention cleanup stalls, disk fills) is worse than the problem it solves. Batch it by user per tick like its siblings, or give it its own transaction. CRF-71 (P2): after the backlog clears the reaper does zero work forever but still runs every 10 minutes, and it runs unindexed: SELECT id FROM users WHERE deleted seq-scans users (every partial index is WHERE deleted = false), and group_members has no index at all, so that table is full-scanned end to end each tick, cost growing with the soft-deleted-user count. Its siblings latch after a clean pass (identifiedModuleCachePurged); this one does not. Add the indexes and latch, or gate the sweep. Also CRF-72 (P3): the reaper is the one operator-visible security cleanup with no rowcount, no metric, and no log field, so nobody can confirm the resurrectable-api_keys cleanup ran or measure it; and CRF-74 (P3, six reviewers): its "deletes in the same table order to minimize deadlock exposure" comment is false twice over (Postgres does not order data-modifying CTEs, and the reaper only touches already-committed-deleted users so it never races the cleanup).

CRF-73 (P3): the class fix for guard-violation-to-409 is one instance short again. The eighth guard, group_members, has no handler mapping, so patchGroup AddUsers for a deleted user still returns a raw 500 with pq text. Hisoka: "You built eight guards and mapped seven of them. One table still bleeds a raw 500." CheckGroupMemberUserDeleted is already declared; add the IsCheckViolation -> 409 branch. CRF-75 (P3): the reaper's test starts a global delete that can reap fixtures other tests in this PR build, a flake under a shared dev database (not CI). Nits CRF-77/78/79 are small message-consistency items.

Two things for you, not code findings:

  1. CI is red across 20 required jobs (gen, fmt, lint, sqlc-vet, build, test-go, test-js, storybook, offlinedocs). This is a merge blocker and Mafu-san rates it P1: a change is not done with required red and no diagnosis in the PR. I ran the checks this diff is responsible for and they are clean: the migrations are contiguous (000590/591/592, no collision with the base's cap migration), the generated layers are in sync (AcquireUserSoftDeleteGuardLock and PurgeSoftDeletedUserResources in querier/queries.sql.go/dbmock/dbmetrics), gofmt -l is empty, dump.sql carries check_user_not_deleted, and go build passes. The breadth (frontend jobs red on a backend-only diff) points to a base/rebase/stacking problem rather than this content, but I cannot confirm the cause (gh is 401 in the review sandbox). Please diagnose and get CI green, or state what is inherited from the base; do not treat "the diff builds locally" as clearance while sqlc-vet/gen are red.

  2. The description's Handlers line still says guard violations "map to 409s (organization_members, user_ai_provider_keys)", but the code now maps five tables including api_keys (both token and key endpoints) and user_ai_budget_overrides, which are the exact CRF-59/60 fixes that unblocked this round. Mafu-san rates this P2 as the third recurrence of description-vs-code drift; leaving the two headline fixes out of the one summarizing sentence undersells them. Name all the mapped tables or say "every guarded handler maps its violation to 409." (Minor, related: commit 86668c8's body cites "Migration 000591" which is now 000592; harmless if squash-merged with a corrected body.)

Finally, the strategic reminder the panel has carried since round 4: this guard-only PR stops orphan rows from being created and reaps existing ones, but it does not stop an orphan api_keys row from authenticating; that read-side filter is #28634. Until #28634 lands, an orphan key between soft-delete and the reaper's next tick still authenticates. The split is deliberate; worth confirming the intended merge order in the description.


enterprise/coderd/groups.go:302

P3 [CRF-73] The eighth guard, group_members, has no 409 handler mapping, so patchGroup AddUsers for a deleted user returns a raw 500 with pq text. (Netero, Hisoka)

Migration 000592 installs trigger_insert_group_members raising group_member_user_deleted, and CheckGroupMemberUserDeleted is declared, but referenced only by tests. patchGroup inserts each req.AddUsers id via tx.InsertGroupMember after only a uuid.Parse (no deleted filter), and the error ladder checks IsUniqueViolation/IsUnauthorizedError/Is404Error then falls through to httpapi.InternalServerError, leaking pq: Cannot create group_member for deleted user. This is the exact class fixed for api_keys (CRF-60) and user_ai_budget_overrides (CRF-59); the remediation was applied instance-by-instance and missed the fifth guarded insert path. Add if database.IsCheckViolation(err, database.CheckGroupMemberUserDeleted) returning 409 before the fallthrough. (The guard firing is correct: pre-PR this silently created an orphan group_members row that GetAuthorizationUserRoles reads into rbac.Subject.Groups.)

🤖

🤖 This review was automatically generated with Coder Agents.

// Remove child rows orphaned by a user soft-delete that predates the
// guard triggers and cleanup coverage (migration 000592). The guards
// prevent new orphans, so after the first pass this is a no-op.
if err := tx.PurgeSoftDeletedUserResources(ctx); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 [CRF-70] The reaper is the only delete in purgeTick with no batch bound, and it shares the single purge transaction, so a large first pass or one lost deadlock rolls back every retention purge in the tick. (Pariston, Zoro, Killua P2; Knuckle, Takumi, Mafuuu P3)

Every sibling delete in this InTx carries a LimitCount (10000 / 1000). PurgeSoftDeletedUserResources is DELETE ... WHERE user_id IN (SELECT id FROM users WHERE deleted) across eight tables for every soft-deleted user, in one statement, at LevelDefault with no statement_timeout, on the forced initial tick at startup. The first pass on an old deployment (pre-000492 users carry orphaned organization_members plus transitive group_members/user_ai_budget_overrides) is exactly the largest. Two consequences: no partial progress (all-or-nothing statement; an interrupted large backlog never converges, re-doing the whole thing each tick), and blast radius (a reaper failure rolls back the audit-log, connection-log, and chat purges already done in the same tick, so all retention cleanup stalls and disk grows). The query comment's "a lost deadlock surfaces as a failed purge cycle and is retried" understates this. Batch by user per tick (LIMIT on doomed_users, loop/defer the rest) like the siblings, or run the reaper in its own transaction so its failure cannot roll back unrelated purges.

🤖

-- were already cleaned up.
-- name: PurgeSoftDeletedUserResources :exec
WITH doomed_users AS (
SELECT id FROM users WHERE deleted

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 [CRF-71] After the backlog clears the reaper does zero work forever but still runs every tick, unindexed, so its cost grows with the soft-deleted-user count for no benefit. (Killua P2; Knuckle, Chopper P3)

The guards make new orphans impossible post-migration, so the steady state deletes nothing, yet PurgeSoftDeletedUserResources runs unconditionally every 10 minutes. SELECT id FROM users WHERE deleted has no supporting index (every partial index on users is WHERE deleted = false), so it seq-scans users; and group_members has no index of any kind, so DELETE FROM group_members WHERE user_id IN (...) seq-scans the whole table each tick, which for OIDC group-sync deployments is users x groups_per_user. Soft-deleted users are never hard-deleted, so doomed_users only grows. The sibling one-off cleanups in this file latch after a clean pass (identifiedModuleCachePurged, chatSearchStaleDrained); this one does not. Fix: latch after the first successful pass, and add a partial index ON users (id) WHERE deleted plus an index on group_members (user_id) (which also helps the existing cleanup).

🤖

-- transitively (BEFORE DELETE triggers on organization_members); the direct
-- deletes catch rows orphaned after the user's organization_members rows
-- were already cleaned up.
-- name: PurgeSoftDeletedUserResources :exec

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-72] The reaper deletes security-relevant orphans (resurrected api_keys, org memberships) with no rowcount, no metric, and no log field, so an operator cannot confirm it ran or how much it cleaned. (Chopper, Mafuuu)

Every other purge in purgeTick reports a slog.F in the "purged old database entries" line and a recordsPurged.WithLabelValues(...) counter. This one, the cleanup the PR description leads with (resurrectable session tokens for deleted accounts), contributes to neither. On the first pass after upgrade an operator watching coderd_dbpurge_records_purged_total sees every purge type except this one. Make the query :execrows (or add per-table RETURNING counts), add a records_purged_total{record_type="soft_deleted_user_resources"} label, and log the count like its siblings.

🤖

-- soft-deleted users, and delete_deleted_user_resources cleans rows at
-- soft-delete time; this reaper removes what predates both (legacy orphans
-- from before cleanup coverage, and race products from before the guards).
-- It deletes in the same table order as delete_deleted_user_resources to

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-74] The reaper's "deletes in the same table order as delete_deleted_user_resources to minimize deadlock exposure" comment is false twice over. (Knuckle, Killua, Hisoka, Takumi, Mafuuu, Zoro)

First, the eight deletes are data-modifying CTEs in one WITH, and Postgres executes those in an unspecified order under one snapshot, so the written CTE order does not control lock-acquisition order (delete_deleted_user_resources gets its order only because it is sequential plpgsql statements). Second, the deadlock it guards against is unreachable: delete_deleted_user_resources fires as an AFTER trigger inside the soft-delete transaction, so any user the reaper sees as deleted has already had its cleanup committed and locks released; the reaper and a live soft-delete operate on disjoint user sets. Drop the ordering rationale and state what is true: the reaper only touches already-committed-deleted users, so it is disjoint from any in-flight soft-delete, and a lost deadlock is retried next tick. (This codebase has a history of comments asserting an ordering guarantee that does not hold.)

🤖

}

// The initial tick runs the purge immediately.
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), dbpurge.WithClock(quartz.NewReal()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-75] TestPurgeSoftDeletedUserResources starts a global reaper that can reap orphan fixtures other tests in this PR construct, a flake under a shared test database. (Komugi)

PurgeSoftDeletedUserResources deletes child rows for every soft-deleted user in the database (doomed_users is unscoped), and dbpurge.New forces an immediate initial tick. TestSoftDeleteGuardRejectsUpdatesForDeletedUser and the dbcrypt tests build exactly that orphan state (SoftDeleteUserKeepingRows) and then assert the rows still exist. Under CODER_PG_CONNECTION_URL (shared DB, no per-test isolation) with concurrent package test binaries, if the reaper's tick commits between a victim's setup and its assertion, the surviving rows vanish and the guarded UPDATE matches zero rows. It cannot fire in CI (per-test DB is the default), only in shared-DB dev mode. Skip this test when CODER_PG_CONNECTION_URL is set, or give it a dedicated database, so its global reaper cannot reach fixtures owned by concurrent tests.

🤖

Comment thread coderd/exp_chats.go
// the sibling deleted-user guard in userskills.go already uses Conflict.
if database.IsCheckViolation(err, database.CheckUserAIProviderKeyUserDeleted) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Cannot store an AI provider key for a deleted user.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-77] The AI-provider-key 409 is the only one of the four sibling guard handlers that names no user in its Detail. (Chopper, Leorio)

members.go, aibridge.go, and both apikey.go handlers render Detail: fmt.Sprintf("%s has been deleted.", <user>.Username); this one sends only the Message. targetUser (httpmw.UserParam) carries .Username and is already used in the adjacent log line. Add Detail: fmt.Sprintf("%s has been deleted.", targetUser.Username) and assert it in the test (which currently checks only Message).

🤖

Comment thread coderd/members.go
// the sibling deleted-user guard in userskills.go already uses Conflict.
if database.IsCheckViolation(err, database.CheckOrganizationMemberUserDeleted) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "Cannot add a deleted user to an organization",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-78] The organization-member 409 Message drops the trailing period every sibling guard message carries. (Leorio)

"Cannot create a token for a deleted user.", "Cannot set an AI budget override for a deleted user.", and "Cannot store an AI provider key for a deleted user." all end in a period; this one is "Cannot add a deleted user to an organization" with none. members_test.go pins the exact string, so add the period and update the assertion together.

🤖

})
return
}
// The soft-delete guard rejects overrides for a user deleted after the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-79] The user_ai_budget_override 409 is reachable only in the live race; the common stale-deleted-user case returns 400 "not a member," which misdirects from the real cause. (Mafuuu)

trigger_enforce_user_ai_budget_override_membership sorts before trigger_insert_user_ai_budget_overrides, and a committed soft-delete has already removed the user's group_members rows, so an upsert against a stale deleted-user id trips the membership constraint first and returns userAIBudgetOverridesMustBeGroupMemberConstraint (400 "not a member"), not the new 409. The message is not false (a deleted user is not a member), but it points the operator at membership when the root cause is deletion, the CRF-42 class. Not blocking; decide whether the deleted-user check should precede the membership check for this handler, or accept the membership message and note it.

🤖

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

experimental Changes that might not necessarily be merged, until its approved to proceed with.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: user_secrets and user_skills soft-delete guard triggers race with concurrent user soft-delete

1 participant