Summary
The soft-delete guard triggers on user_secrets and user_skills check users.deleted without taking a lock on the parent users row, leaving open the race their own comments claim to close: an in-flight insert can read deleted = false, a concurrent soft-delete (UPDATE users SET deleted = true) plus its delete_deleted_user_resources() cleanup can commit, and the insert then commits afterwards, resurrecting a row for a soft-deleted user.
Affected triggers:
insert_user_secret_fail_if_user_deleted (coderd/database/migrations/000490_trigger_delete_user_secrets.up.sql): IF (SELECT deleted FROM users WHERE id = NEW.user_id LIMIT 1) THEN ... with no FOR ... lock clause.
insert_user_skill_fail_if_user_deleted (coderd/database/migrations/000502_user_skills.up.sql): PERFORM 1 FROM users WHERE id = NEW.user_id AND deleted = true LIMIT 1 with no lock.
Under READ COMMITTED both reads see a snapshot that a concurrent uncommitted soft-delete does not affect, and nothing orders the insert against the soft-delete transaction, so the guard is advisory rather than race-free.
Reference implementation
enforce_user_memories_insert_invariants() (added in coderd/database/migrations/000580_agent_memories.up.sql, #28423) closes the same window for user_memories:
SELECT deleted FROM users WHERE id = NEW.user_id FOR NO KEY UPDATE; — the FOR NO KEY UPDATE parent-row lock serializes the insert against a concurrent soft-delete's UPDATE users (which takes NO KEY UPDATE/UPDATE row locks) without conflicting with the FOR KEY SHARE locks taken by FK validation on other child tables.
- After acquiring the lock, re-check
deleted and raise check_violation when true. If the soft-delete committed first, its delete_deleted_user_resources() cleanup already ran, so no orphan row survives.
A deterministic regression test pattern (second connection + pg_stat_activity wait_event_type = 'Lock' synchronization, no sleeps) exists in coderd/database/agent_memories_test.go (TestUserMemories/SoftDeleteWinsConcurrentInsert).
Suggested fix
New migration replacing both trigger functions to take SELECT ... FROM users WHERE id = NEW.user_id FOR NO KEY UPDATE before evaluating deleted, mirroring enforce_user_memories_insert_invariants(), plus a one-time backfill/cleanup of any rows already orphaned by the race (the 000490 migration shows the backfill pattern for user_secrets).
Raised by coder-agents-review on #28423 (finding CRF-22).
Generated with mux • Model: anthropic:claude-fable-5 • Thinking: xhigh
Summary
The soft-delete guard triggers on
user_secretsanduser_skillscheckusers.deletedwithout taking a lock on the parentusersrow, leaving open the race their own comments claim to close: an in-flight insert can readdeleted = false, a concurrent soft-delete (UPDATE users SET deleted = true) plus itsdelete_deleted_user_resources()cleanup can commit, and the insert then commits afterwards, resurrecting a row for a soft-deleted user.Affected triggers:
insert_user_secret_fail_if_user_deleted(coderd/database/migrations/000490_trigger_delete_user_secrets.up.sql):IF (SELECT deleted FROM users WHERE id = NEW.user_id LIMIT 1) THEN ...with noFOR ...lock clause.insert_user_skill_fail_if_user_deleted(coderd/database/migrations/000502_user_skills.up.sql):PERFORM 1 FROM users WHERE id = NEW.user_id AND deleted = true LIMIT 1with no lock.Under
READ COMMITTEDboth reads see a snapshot that a concurrent uncommitted soft-delete does not affect, and nothing orders the insert against the soft-delete transaction, so the guard is advisory rather than race-free.Reference implementation
enforce_user_memories_insert_invariants()(added incoderd/database/migrations/000580_agent_memories.up.sql, #28423) closes the same window foruser_memories:SELECT deleted FROM users WHERE id = NEW.user_id FOR NO KEY UPDATE;— theFOR NO KEY UPDATEparent-row lock serializes the insert against a concurrent soft-delete'sUPDATE users(which takesNO KEY UPDATE/UPDATErow locks) without conflicting with theFOR KEY SHARElocks taken by FK validation on other child tables.deletedand raisecheck_violationwhen true. If the soft-delete committed first, itsdelete_deleted_user_resources()cleanup already ran, so no orphan row survives.A deterministic regression test pattern (second connection +
pg_stat_activitywait_event_type = 'Lock'synchronization, no sleeps) exists incoderd/database/agent_memories_test.go(TestUserMemories/SoftDeleteWinsConcurrentInsert).Suggested fix
New migration replacing both trigger functions to take
SELECT ... FROM users WHERE id = NEW.user_id FOR NO KEY UPDATEbefore evaluatingdeleted, mirroringenforce_user_memories_insert_invariants(), plus a one-time backfill/cleanup of any rows already orphaned by the race (the000490migration shows the backfill pattern foruser_secrets).Raised by
coder-agents-reviewon #28423 (finding CRF-22).Generated with
mux• Model:anthropic:claude-fable-5• Thinking:xhigh