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

Skip to content

feat: add agent memory database foundation - #28423

Closed
ThomasK33 wants to merge 4 commits into
fix-user-soft-delete-guardsfrom
feat-agent-memory-database
Closed

feat: add agent memory database foundation#28423
ThomasK33 wants to merge 4 commits into
fix-user-soft-delete-guardsfrom
feat-agent-memory-database

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Aug 20, 2026

Copy link
Copy Markdown
Member

Adds the database foundation for agent memory: user_memories (private per-user documents) and chat_memories (documents owned by a root chat), addressed by scope-relative POSIX .md paths with path/content limits owned by coderd/x/memory.

Schema and migration 000593

  • Tables: UUID-keyed rows with (owner, path) uniqueness, 256-byte ASCII path and 64 KiB content CHECK constraints, and an ASCII-only, dot-segment-rejecting path format check.
  • Shared soft-delete guard (adapted to the reworked base): user_memories attaches the shared fail_if_user_deleted('user_memory', 'user_memory_user_deleted') trigger from migration 000592 (check_user_not_deleted takes the users-row FOR NO KEY UPDATE lock on INSERT), instead of the private guard earlier rounds reviewed. There is no fail-closed missing-parent branch: user_memories.user_id has a hard FK to users, so an absent parent is rejected by the RI check at end of statement; the residual snapshot window is unreachable through the Store (details in the migration header).
  • Isolation gate, new tables only: require_read_committed is created here and called by both memory cap triggers. The caps count committed rows after a lock wait, which is race-free exactly at READ COMMITTED; READ UNCOMMITTED is allowlisted because PostgreSQL executes it with READ COMMITTED semantics. The gate is deliberately scoped to the two brand-new tables: rejecting a stronger level here cannot break an existing feature write, while the pre-existing skills/secrets caps state the same contract in migration 000591 instead of enforcing it (a runtime gate there would turn a deployment-level default_transaction_isolation into an outage of shipped features).
  • Caps: 100 memories per user / per root chat, counted under the parent-row lock (users row via the guard; chats row via enforce_chat_memories_insert_invariants, which also fail-closes on missing or non-root chats).
  • Owner immutability: user_id / root_chat_id reassignment is rejected by WHEN (NEW.x IS DISTINCT FROM OLD.x) triggers (NULL assignments included), so the INSERT-only invariants cannot be bypassed and the UPDATE path takes no parent lock.
  • Cleanup: delete_deleted_user_resources gains a user_memories delete; chat memories cascade with the chat.

Authorization, audit, API surface

  • New user_memory RBAC resource (member CRUD on self, owner administrative read/delete) with user_memory:* API key scopes; chat memories authorize through the root chat ACL (ActionRead/ActionUpdate on the chat).
  • Both resource types are audited; deleted rows render as deleted resources without leaking authorization errors.
  • Store queries for get/list(+prefix)/insert/update/rename/delete(+prefix) on both tables, with dbauthz coverage in TestMethodTestSuite.

Validation

PostgreSQL-backed: migrations (incl. TestMigrateUpWithFixtures fixtures for both tables), TestMethodTestSuite, the full memory behavioral suites (TestAgentMemorySchemaConstants, TestUserMemories, TestChatMemories: caps at the exact limit, concurrent-insert races, soft-delete race via the shared lock-race harness, isolation twins incl. READ UNCOMMITTED acceptance, owner-immutability, prefix operations), the soft-delete guard suites of the base, RBAC and audit suites.

Stacking

Merge order: #28870 (caps) → #28546 (soft-delete guards) → this PR, all rebased onto the same main head. The branch carries the base commits via fix-user-soft-delete-guards; they drop out of the diff as the stack merges.


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

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

@ThomasK33 ThomasK33 added the experimental Changes that might not necessarily be merged, until its approved to proceed with. label Aug 20, 2026
@ThomasK33 ThomasK33 changed the title feat agent memory database feat: add agent memory database foundation Aug 20, 2026
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 276756727f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread enterprise/audit/table.go Outdated
Comment thread coderd/database/migrations/000593_agent_memories.up.sql
@ThomasK33
ThomasK33 force-pushed the feat-agent-memory-database branch from 2767567 to 61544d7 Compare August 20, 2026 22:26
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Chat: Review in progress | View chat
Requested: 2026-09-07 21:35 UTC by @ThomasK33

Review history
  • R1 (2026-08-20): 22 reviewers, 9 Nit, 8 Note, 1 P2, 8 P3, REQUEST_CHANGES. Review
  • R2 (2026-08-21): 22 reviewers, 15 Nit, 12 Note, 1 P2, 8 P3, COMMENT. Review
  • R3 (2026-08-25), 15 Nit, 12 Note, 1 P2, 8 P3, COMMENT. Review
  • R4 (2026-08-25): 12 reviewers, 17 Nit, 19 Note, 3 P2, 17 P3, COMMENT. Review
  • R5 (2026-08-25): 5 reviewers, 25 Nit, 22 Note, 1 P1, 4 P2, 21 P3, REQUEST_CHANGES. Review
  • R6 (2026-08-25), 25 Nit, 22 Note, 1 P1, 4 P2, 21 P3, COMMENT. Review
  • R7 (2026-08-25): 4 reviewers, 26 Nit, 25 Note, 1 P1, 4 P2, 24 P3, COMMENT. Review
  • R8 (2026-08-26): 5 reviewers, 26 Nit, 25 Note, 1 P1, 4 P2, 24 P3, COMMENT. Review
  • R9 (2026-08-26): 5 reviewers, 26 Nit, 25 Note, 1 P1, 4 P2, 24 P3, COMMENT. Review
  • R10 (2026-09-01): 5 reviewers, 26 Nit, 25 Note, 1 P1, 4 P2, 24 P3, COMMENT. Review

deep-review v0.9.0 | Round 12 | dd747c8..64f6c45

Last posted: Round 12, 80 findings (1 P1, 4 P2, 24 P3, 26 Nit, 25 Note), COMMENT. Review

Finding inventory

Finding inventory

Law analysis

Effective LOC: 1350 (581 production, 769 test, 1352 generated). Head SHA: 61544d7. Verdict: Don't split. Enforcement: Advisory. Vertical slice impossible below API layer; horizontal cuts create dead intermediate states. Slicing by memory kind possible but marginal benefit.

Law analysis, R5 update

Effective LOC: 2125 (795 production, 1330 test, 1445 generated). Head SHA: 30d3f2a. Verdict: Split. Enforcement: Mandatory. Extract 000586_lock_user_soft_delete_guards + user_soft_delete_guards_test.go into its own PR; it is fully independent of C1..C7 (zero code coupling except a 15-line test helper), it is a destructive data migration on pre-existing production tables (DELETE FROM user_secrets/user_skills WHERE user_id IN (SELECT id FROM users WHERE deleted)), and it changes locking on shipped triggers on every existing deployment. Bundled under a title that names only the feature.

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Author fixed (a226177) enterprise/audit/table.go:41 chat_memories writes leave no audit trail; asymmetric with user_memories R1 Ryosuke P2, Hisoka P3, Knuckle Note, Knov Note, Kite Note Yes
CRF-2 P3 Author fixed (a226177) coderd/database/queries/user_memories.sql:54 DeleteByPrefix wipes owner scope when @path_prefix is empty (both queries) R1 Chopper P3, Knuckle P3, Knov P3 Yes
CRF-3 P3 Author fixed (a226177) coderd/database/migrations/000580_agent_memories.up.sql:56 FOR UPDATE on parent rows conflicts with FK check's FOR KEY SHARE; blocks concurrent chat_message/user_link inserts (three trigger sites) R1 Ryosuke P3, Takumi P3, Knuckle Note, Killua Note Yes
CRF-4 P3 Author fixed (a226177) coderd/database/migrations/000580_agent_memories.up.sql:203 trigger_upsert_user_memories fires on UPDATE too; every content edit takes users FOR UPDATE for no correctness gain R1 Takumi P3, Killua Note, Meruem P4 Yes
CRF-5 P3 Author fixed (a226177) coderd/database/migrations/000580_agent_memories.up.sql:183 insert_user_memory_fail_if_user_deleted / trigger_upsert_user_memories / 'Cannot create' message all describe insert-only but fire on INSERT OR UPDATE R1 Leorio P3, Chopper Nit, Meruem Nit Yes
CRF-6 P3 Author fixed (a226177) coderd/audit.go:406 auditLogIsResourceDeleted has no case for ResourceTypeUserMemory; deleted memories render as existing in audit UI R1 Melody Yes
CRF-7 P3 Author fixed (a226177) coderd/database/agent_memories_test.go:310 PerUserLimit test proves the numeric cap but not the FOR UPDATE serialization the trigger claims; test would still pass if the lock is later removed R1 Bisky Yes
CRF-8 P3 Author fixed (a226177) coderd/database/migrations/000580_agent_memories.up.sql:88 enforce_chat_memories_per_root_chat_limit silently skips root-chat check when NEW.root_chat_id refers to a non-existent chat; leans on FK to reject R1 Meruem Yes
CRF-9 P3 Author contested; panel closed R2 (7/7 accept) coderd/database/migrations/000580_agent_memories.up.sql:70 chats.parent_chat_id/root_chat_id immutability is convention-only; mutating either would silently promote chat_memories into a subagent-owned namespace R1 Meruem Yes
CRF-10 Nit Author fixed (a226177) coderd/database/dbauthz/dbauthz.go:153 authorizeChatMemoryUpdate is called from Insert/Update/Rename/Delete; name says Update R1 Gon Nit, Zoro Nit, Ryosuke Nit Yes
CRF-11 Nit Author contested; panel closed R2 (6/6 accept) coderd/database/queries/user_memories.sql:26 content_prefix uses left() which counts characters, not bytes R1 Hisoka Nit, Razor Note, Gon Nit Yes
CRF-12 Nit Author fixed (a226177) coderd/database/agent_memories_test.go:344 TestChatMemories/PathFormatRejected covers 2 cases vs TestUserMemories' 12; no ContentSizeRejected on chat side R1 Bisky Nit, Meruem Nit, Robin Nit, Chopper Note, Kite Note Yes
CRF-13 Nit Author fixed (a226177) coderd/database/migrations/000580_agent_memories.up.sql:62 RAISE EXCEPTION messages lack user_id, cap value, current count (both cap triggers) R1 Leorio P3 (downgraded) Yes
CRF-14 Nit Author fixed (a226177) coderd/database/migrations/000580_agent_memories.up.sql:194 'Cannot create user_memory for deleted user' capitalization inconsistent with sibling messages R1 Leorio Nit Yes
CRF-15 Nit Author fixed (a226177) coderd/database/migrations/000580_agent_memories.up.sql:76 enforce_chat_memories_per_root_chat_limit name understates its second responsibility (root-required) R1 Knov Nit Yes
CRF-16 Nit Author fixed (a226177) coderd/database/migrations/000580_agent_memories.up.sql:207 Several comments restate the DDL/code they precede R1 Gon P2 (downgraded), Zoro Nit Yes
CRF-17 Nit Author contested; panel closed R2 (5/5 accept, body diverged) coderd/database/migrations/000580_agent_memories.up.sql:44 enforce_user_memories_per_user_limit is a copy of enforce_user_skills_per_user_limit; shared helper opportunity R1 Robin Nit Yes
CRF-18 Note Author contested; panel closed R2 (6/6 accept) coderd/database/queries/user_memories.sql:26 content_prefix truncates without an is_truncated / octet_length signal to callers R1 Meruem Note Yes
CRF-19 Note Author contested; panel closed R2 (5/5 accept) coderd/database/queries/user_memories.sql:54 RETURNING * on DeleteByPrefix can ship up to ~6.4MB per call R1 Killua Note Yes
CRF-20 Note Author fixed (a226177) coderd/database/migrations/000580_agent_memories.up.sql:70 Two BEFORE INSERT triggers on user_memories; error precedence depends on alphabetical trigger-name ordering R1 Takumi Note Yes
CRF-21 Note Author fixed (a226177) coderd/database/migrations/testdata/fixtures/000580_agent_memories.up.sql:37 chat_memories fixture picks earliest chat without a root filter; latent failure if a subagent chat is ever seeded earlier R1 Razor Note Yes
CRF-22 Note Deferred (issue #28538) coderd/database/migrations/000580_agent_memories.up.sql:183 Same insert-vs-soft-delete race still open in user_secrets and user_skills sibling triggers; the correct pattern now lives once here R1 Pariston Note, Zoro Note Yes
CRF-23 Note Author contested; panel closed R2 (6/6 accept, layer-boundary) coderd/database/queries/user_memories.sql:52 starts_with(path, prefix) is a byte prefix, not a directory prefix; 'notes' matches 'notes.md' and 'notes/foo.md' R1 Kite Note Yes
CRF-24 Nit Author fixed (a226177) coderd/database/agent_memories_test.go:168 UpdateRenameDelete tests happy paths only; UPDATE-side constraint violations (rename to invalid path, oversize UPDATE) not exercised R1 Bisky Nit Yes
CRF-25 Note Author fixed (a226177) coderd/database/migrations/000580_agent_memories.up.sql:88 UPDATE branch of enforce_chat_memories_per_root_chat_limit is unreachable through any query in this PR R1 Hisoka Note Yes
CRF-26 Note Author contested; panel closed R2 (4/4 accept) commit 61544d7 'fix: protect agent memory integrity and privacy' bundles two orthogonal fixes (subagent-chat rejection + content ActionSecret) without naming either R1 Leorio Nit, Mafu-san Note Yes
CRF-7 P3 Author fixed (3605da1) coderd/database/agent_memories_test.go:663 User side got ConcurrentInsertPerUserLimit, chat side (PerRootChatLimit) still lacks the symmetric concurrency proof R2 Bisky P3 (Pariston, Chopper, Takumi, Kite echo) Yes (re-raised via in_reply_to)
CRF-27 Nit Author fixed (3605da1) coderd/audit.go:530 user_memory/chat_memory branches lack the IsNotAuthorized-suppression comment that the user_secret sibling carries R2 Mafuuu Yes
CRF-28 Note Author fixed (3605da1) coderd/database/migrations/000580_agent_memories.up.sql:44 Cap check assumes READ COMMITTED; under REPEATABLE READ (used by database.ReadModifyUpdate in chatd) two concurrent inserts could burst the 100-cap R2 Meruem Yes
CRF-29 Note Author contested R3 coderd/database/dbauthz/dbauthz.go:153 authorizeChatMemoryMutation could be generalized to authorizeChatByID(ctx, chatID, action) matching sibling helpers (authorizeWorkspaceByAgentID, authorizeAIBridgeInterceptionAction) R2 Robin Yes
CRF-30 Nit Author fixed (3605da1) coderd/database/dbauthz/dbauthz.go:2231 Inline // Chat memories are owned by the root chat... comment restates the helper's doc and appears on only one of five callsites R2 Gon Yes
CRF-31 Nit Author fixed (3605da1) coderd/database/queries/user_memories.sql:26 4096 magic number is undocumented; add comment naming YAML-frontmatter character budget (both queries) R2 Gon Yes
CRF-32 Nit Author fixed (3605da1) coderd/database/migrations/000580_agent_memories.up.sql:92 chat_parent_id / referenced_root_chat_id are asymmetric variable names for two columns from the same row R2 Gon Yes
CRF-33 Nit Author fixed (3605da1) coderd/database/agent_memories_test.go:156 // Listing is path-ordered. restates the three require.Equal assertions on the following lines R2 Gon Yes
CRF-34 Nit Author fixed (3605da1) coderd/database/migrations/000580_agent_memories.up.sql:188 Comment -- Chat memories are authorized through the root chat's existing chat scopes. sits above the user_memory:* ALTER TYPE block and reads as a positive assertion about the wrong lines R2 Leorio Yes
CRF-35 Note Author fixed (3605da1) coderd/database/agent_memories_test.go:292 SoftDeleteWinsConcurrentInsert uses SELECT ... FOR UPDATE which is stronger than production's UPDATE users SET deleted=true (takes FOR NO KEY UPDATE); test would still pass if trigger lock were weakened to FOR KEY SHARE R2 Takumi Yes
CRF-36 Note Author fixed (3605da1) coderd/database/queries/user_memories.sql:52 RETURNING * ships content bytes that enterprise/audit/diff.go zeros before persisting; narrower RETURNING (id, path, user_id, timestamps) saves up to 6.4MB per call without losing audit fidelity R2 Zoro Yes
CRF-21 P3 Author fixed (30d3f2a) coderd/database/migrations/testdata/fixtures/000585_agent_memories.up.sql R5: scalar subquery in VALUES turns zero matches into a NOT NULL violation R4 Hisoka P3 Yes (in_reply_to PRRT_kwDOGkVX1s6a_Qrn)
CRF-28 P2 Author fixed (30d3f2a) coderd/database/migrations/000585_agent_memories.up.sql:67,123 R5: triggers reject repeatable read explicitly with named constraint; RepeatableReadRejected tests on both tables R4 Hisoka P2, Mafuuu P3, Komugi P3, Takumi P3, Meruem P3, Knuckle P3, Mafu-san P3 Yes (in_reply_to PRRT_kwDOGkVX1s6bAtOE)
CRF-29 Note Author contested; panel closed R4 (5/5 accept) coderd/database/dbauthz/dbauthz.go:153 see contested-detail R2 Robin Yes
CRF-37 P2 Author fixed (30d3f2a) coderd/database/migrations/000585_agent_memories.up.sql:44 R5: table comment reads "access is gated on the root chat permissions"; "shared with descendants" clause removed R4 Hisoka P2 Yes
CRF-38 P2 Author fixed (30d3f2a) coderd/rbac/scopes_catalog.go:64 R5: PR body rewritten; DB/RBAC/audit ship unconditionally, experiment named as future work in a later stack PR R4 Mafu-san P2, Pen Botter Note Yes
CRF-39 P3 Author contested; panel closed R5 (3/3 accept) coderd/database/migrations/000585_agent_memories.up.sql:27 see contested-detail R4 Pen Botter P3 Yes
CRF-40 P3 Author contested; panel closed R5 (3/3 accept, Knuckle Note on defense reasoning) coderd/database/migrations/000585_agent_memories.up.sql:133 see contested-detail R4 Pariston P3, Takumi P3 Yes
CRF-41 P3 Author fixed (30d3f2a) coderd/database/queries/user_memories.sql:26,39 R5: four list queries now ORDER BY path COLLATE "C" ASC with rationale comments; CaseSensitivePaths asserts an order a linguistic collation would flip R4 Komugi P3 Yes
CRF-42 P3 Author fixed (30d3f2a) coderd/database/agent_memories_test.go:58 R5: coderd/x/memory package added; TestAgentMemorySchemaConstants walks pg_get_functiondef / pg_get_constraintdef against Go constants R4 Robin P3, Ryosuke P3 Yes
CRF-43 P3 Author contested; panel closed R5 (3/3 accept) coderd/rbac/roles.go:415 see contested-detail R4 Ryosuke P3, Pen Botter Note Yes
CRF-44 P3 Author fixed (30d3f2a) coderd/database/agent_memories_test.go:155 R5: PathSizeRejected subtests on both tables insert memory.MaxMemoryPathBytes-2 byte + .md valid paths and assert constraint names R4 Bisky P3 Yes
CRF-45 P3 Author fixed (30d3f2a) coderd/database/agent_memories_test.go:187 R5: ContentPrefixWidth inserts multi-byte content longer than the budget and asserts utf8.RuneCountInString(prefix) == memory.ContentPrefixChars plus HasPrefix R4 Bisky P3 Yes
CRF-46 P3 Author fixed (30d3f2a) coderd/database/agent_memories_test.go:323 R5: UpdateRenameDelete tests advance clock with pg_sleep(0.001) and assert updated.UpdatedAt.After(inserted.UpdatedAt) and renamed.UpdatedAt.After(updated.UpdatedAt) on both tables R4 Bisky P3 Yes
CRF-47 P3 Author fixed (30d3f2a) coderd/database/agent_memories_test.go:31 R5: waitForBackendBlocked extracted and reused across three memory tests plus the new user_soft_delete_guards_test.go R4 Robin P3, Bisky Nit Yes
CRF-48 Note Author fixed (30d3f2a) coderd/database/migrations/000585_agent_memories.up.sql:44 R5: COMMENT ON TABLE chat_memories names cascade retention and the absence of per-memory audit events R4 Knuckle Note Yes
CRF-49 Nit Author fixed (30d3f2a) coderd/database/agent_memories_test.go:756 R5: comment names DeleteOldChats and its archived-threshold precondition R4 Netero Nit Yes
CRF-50 Nit Author fixed (rebase) commit 3605da1 R5: rebase squashed the process commits into feat: add agent memory database layer and follow-up fix(coderd): enforce memory insert isolation and lock user soft-delete guards R4 Mafu-san Nit Yes
CRF-51 Note Author fixed (30d3f2a) coderd/database/queries/user_memories.sql:28 R5: both List prefix queries carry a comment naming the empty-prefix semantics and the deliberate fail-safe asymmetry R4 Hisoka Note Yes
CRF-52 Note Author fixed (30d3f2a) coderd/database/queries/chat_memories.sql:6 R5: file header states no upsert exists by design because create must not clobber an existing document R4 Takumi Note Yes
CRF-53 Note Author fixed (30d3f2a) coderd/database/queries/chat_memories.sql:1 R5: file header documents that all queries key on root_chat_id; callers canonicalize subagent IDs first; reads match no rows and writes are trigger-rejected R4 Meruem Note Yes
CRF-54 Note Author fixed (30d3f2a) coderd/database/migrations/000585_agent_memories.up.sql:139 R5: root-chat check comment names the ON DELETE SET NULL exception to Store-level immutability and the bounded consequence R4 Netero Note, Ryosuke, Pariston Yes
CRF-55 Note Author fixed (30d3f2a) coderd/database/agent_memories_test.go:28 R5: "notes/café.md" added to invalidMemoryPaths; ASCII-only decision recorded in migration header R4 Pen Botter Note Yes
CRF-44 P3 Re-raised (R5, partial fix; chat side missing) coderd/database/agent_memories_test.go:538 R5: R5 fix commit claim of "PathSizeRejected subtests on both tables" is false; only TestUserMemories/PathSizeRejected exists at line 155, TestChatMemories has no path-size subtest, so chat_memories_path_size is unproven behaviorally. R4 Netero P3 Yes (in_reply_to PRRT_kwDOGkVX1s6cBfV9)
CRF-56 P3 New coderd/database/agent_memories_test.go:538 TestChatMemories lacks CaseSensitivePaths and ContentPrefixWidth subtests that TestUserMemories carries at lines 167 and 187. chat_memories_root_chat_id_path_idx has the identical case-sensitivity property with no witness (bearing on CRF-39 contest), and left(content, 4096) in chat_memories.sql:41 is not pinned to memory.ContentPrefixChars. Add both subtests, mirroring the user-side implementations. R5 Netero P3 Yes
CRF-57 P2 New coderd/database/migrations/000586_lock_user_soft_delete_guards.up.sql:29 Both retrofitted trigger functions take FOR NO KEY UPDATE on the users row unconditionally, but the triggers fire BEFORE INSERT OR UPDATE. Cross-check with Hisoka narrows scope: user_secrets UPDATE already blocked pre-PR because trigger_user_secrets_per_user_limits (dump.sql:5387) fires BEFORE INSERT OR UPDATE and takes FOR UPDATE on users; the pre-PR authentication-path contention is not new for user_secrets. Cross-check with Takumi sharpens consequence: on user_skills, the retrofit newly creates a deadlock cycle against UPDATE users SET deleted = true (T1 locks user_skills tuple + waits for users lock; T2 holds users + AFTER trigger delete_deleted_user_resources DELETEs the tuple T1 holds; deadlock_timeout 1s, 40P01, no retry in coderd because IsSerializedError matches only serialization_failure). Empirically reproduced on PostgreSQL 13.21. Same defect class CRF-4/CRF-5 closed for user_memories. Fix: IF TG_OP = 'INSERT' THEN FOR NO KEY UPDATE ELSE unlocked SELECT; apply to both bodies. Also apply whatever fix lands here to api_keys and user_links (see CRF-62). R5 Netero P2, Takumi P2 (deadlock), Hisoka Note (narrowing) Yes
CRF-58 Nit New coderd/database/queries/user_memories.sql:28 The '' empty-string literal in the SQL comment is emitted as U+201D right double quotation mark in queries.sql.go:5370 and :30878, breaking readability of the generated Go doc comment (starts_with(path, ") is true). make lint/emdash does not catch it. Reword to avoid the doubled apostrophe, e.g., "An empty path_prefix matches every row because starts_with returns true for an empty prefix". Apply to user_memories.sql:28 and chat_memories.sql:33. R5 Netero Nit Yes
CRF-59 Nit New coderd/database/agent_memories_test.go:237 memory, err := insertMemory(...) (also at :618) shadows the imported coderd/x/memory package. Compiles today because neither GetAndList subtest references the package, but the next constant reference in either subtest will not compile. Rename the locals to mem or created. R5 Netero Nit Yes
CRF-60 Note New coderd/database/agent_memories_test.go:387 Four concurrency tests (TestUserMemories/SoftDeleteWinsConcurrentInsert, TestUserMemories/ConcurrentInsertPerUserLimit, TestChatMemories/ConcurrentInsertPerRootChatLimit, TestSoftDeleteGuardWinsConcurrentInsert) still repeat the same ~35-line lock-race scaffolding (blocking tx + committed flag + dedicated Conn + pg_backend_pid() + waitForBackendBlocked + commit + result channel). CRF-47 extracted only the waitForBackendBlocked step; the surrounding choreography is still copied four times. Extract a helper taking the blocking statement, the racing statement, and an error assertion. R5 Netero Note Yes
CRF-61 Note New coderd/database/migrations/000585_agent_memories.up.sql:58 The isolation-guard comment argues SERIALIZABLE is safe "because a concurrent soft-delete updates the locked users row, which makes a waiting REPEATABLE READ transaction fail with 40001". That argument covers the soft-delete check on the user side; for the count cap the racing transaction only locks (not updates) the parent, so 40001 does not fire from the lock wait. Empirically SERIALIZABLE is still safe because SSI catches the rw-antidependency (independently verified by Netero and Takumi on PostgreSQL 13.21/16.14, cap held at 2, Reason code "Canceled on identification as a pivot, during write"), but the stated reason is not the mechanism that makes it work. Fix the comment or add a SerializableCapHolds subtest that asserts 40001 under SERIALIZABLE. R5 Netero Note, Takumi Note (empirical confirmation) Yes
CRF-8 P3 Author fixed (68fdbfc) coderd/database/migrations/000586_agent_memories.up.sql:84,148 R7: both trigger bodies now RAISE EXCEPTION with ERRCODE='check_violation' and named constraints (user_memory_user_deleted, chat_memory_root_chat_required) on NOT FOUND instead of returning NEW; author added MissingUserRejected / MissingChatRejected witness tests R5 Takumi P3 Yes (in_reply_to PRRT_kwDOGkVX1s6a_QrF)
CRF-62 P1 New coderd/database/migrations/000586_lock_user_soft_delete_guards.up.sql:16 Same insert-vs-soft-delete race that this PR closes on user_secrets and user_skills is still open on api_keys and user_links, both cleaned by the same delete_deleted_user_resources() in 000585:171. insert_apikey_fail_if_user_deleted (dump.sql:1269) and insert_user_links_fail_if_user_deleted (dump.sql:1328) each hold IF (SELECT deleted FROM users WHERE id = NEW.user_id LIMIT 1) THEN with no lock. Empirically reproduced on PostgreSQL 13.21 (Hisoka, Knuckle both independently). On api_keys the resurrected row is a live session token: UpdateUserDeletedByID flips deleted only and leaves status = 'active'; ExtractAPIKey never reads users.deleted; GetAuthorizationUserRoles filters on users.id alone and returns the site rbac_roles (org roles are gone because delete_deleted_user_resources wipes organization_members). Result: api_key, deleted=t, status=active, {owner}. The window opens on every login (coderd/apikey.go:571) plus OAuth2 exchange/refresh. Deleting an account is what an admin does while it authenticates. Fix in this PR: extend the pattern to both siblings with the same FOR NO KEY UPDATE + matching cleanup DELETE FROM api_keys/user_links WHERE user_id IN (SELECT id FROM users WHERE deleted). If that widens the migration past what this PR should carry, extract the whole class into the separate PR Law already asks for. R5 Hisoka P1, Knuckle P2 Yes
CRF-63 P3 New coderd/database/migrations/000586_lock_user_soft_delete_guards.up.sql:16 The destructive backfill runs DELETE FROM user_secrets/user_skills WHERE user_id IN (SELECT id FROM users WHERE deleted) on every deployment; nothing exercises it. No TestMigration000586... in migrate_test.go, and no fixture pairs a soft-deleted user with user_secrets/user_skills rows. In CI both statements match zero rows. Half the property is covered by accident because TestMigrateUpWithFixtures (migrate_test.go:290) fails if any table ends empty (catches too-broad DELETE); the uncovered half is that a too-narrow or wrong predicate silently no-ops, leaving resurrected rows in the database with green CI. Mirror TestMigration000498SoftDeleteStaleWorkspaceAgents (migrate_test.go:2108): step to 585, seed one soft-deleted user with a user_secrets and user_skills row + one live user with each, apply 586, assert deleted's counts are zero and live's are one. Also cover api_keys/user_links if CRF-62 lands here. R5 Bisky P3 Yes
CRF-64 P3 New coderd/database/queries/user_memories.sql:28 The empty-prefix asymmetry ("preview everything, delete nothing") is asserted on the delete side (agent_memories_test.go:361, :735) but only asserted in a query comment on the list side. No test passes PathPrefix: "" to ListUserMemoriesByUserIDAndPathPrefix or its chat twin. A later "make these consistent" edit that copies the octet_length guard onto the list queries silently makes preview-of-scope return nothing and no test fails. Verified starts_with('notes.md', '') = t and octet_length('') > 0 = f. Add PathPrefix: "" assertions to both TestUserMemories/GetAndList and TestChatMemories/GetAndList requiring Len == 2. R5 Bisky P3 Yes
CRF-65 P3 New coderd/database/queries/user_memories.sql:1 The enforce_*_insert_invariants triggers reject REPEATABLE READ with user_memory_insert_isolation / chat_memory_insert_isolation (CRF-28 fix), but the contract is documented only in the migration comment. sqlc lifts SQL file-header comments into the generated Go method doc (queries.sql.go:30784-30787 for InsertUserMemory), which is the caller-visible hover doc. Without a header note, the next service PR that wraps a memory insert in database.ReadModifyUpdate (which hardcodes sql.LevelRepeatableRead at coderd/database/tx.go:36-38) compiles, passes review, and fails at runtime with the check_violation. Add: -- Memory inserts require READ COMMITTED; the insert trigger rejects REPEATABLE READ, so callers must not wrap them in database.ReadModifyUpdate. to both user_memories.sql:1 and chat_memories.sql:1. R5 Knuckle P3 Yes
CRF-66 Nit New coderd/database/migrations/000586_lock_user_soft_delete_guards.up.sql:34 The secrets guard raises a bare RAISE EXCEPTION 'Cannot create user_secret for deleted user'; the skills guard sets ERRCODE = 'check_violation' and CONSTRAINT = 'user_skill_user_deleted'. The new test (user_soft_delete_guards_test.go:44 vs :57) has to assert on English on one side and constraint name on the other. The message predates this PR but this PR is already rewriting the function body; two lines close the divergence: USING ERRCODE = 'check_violation', CONSTRAINT = 'user_secret_user_deleted'. R5 Bisky Nit Yes
CRF-67 Nit New coderd/database/agent_memories_test.go:868 _ = dbgen.ChatProvider(...) in insertTestChat is dead. dbgen.ChatModelConfig already creates an openai AIProvider when seed.AIProviderID is unset (dbgen.go:159-180); querier_test.go:2029 calls ChatModelConfig with no provider seed. The discarded result is the tell. Deleting the line and running go test ./coderd/database/ -run TestChatMemories passes all subtests. R5 Zoro Nit Yes
CRF-68 Nit New coderd/x/memory/memory.go:3 Package doc says the memory list-query literals must stay in sync with these values, "TestAgentMemorySchemaConstants walks the live schema definitions against them." That test walks pg_get_functiondef and pg_get_constraintdef, never the queries. left(content, 4096) in user_memories.sql:35 and chat_memories.sql:41 is not touched by it. TestUserMemories/ContentPrefixWidth (:208) pins it on the user side; nothing pins the chat side (CRF-56). Name the real mechanism in the doc. R5 Zoro Nit Yes
CRF-69 Nit New coderd/database/agent_memories_test.go:160 strings.Repeat("a", memory.MaxMemoryPathBytes-2) + ".md" is 257 bytes only because .md is exactly 3 bytes. If .markdown (or another suffix length) ever enters the format regex, -2 silently stops testing the boundary. Replace with memory.MaxMemoryPathBytes-len(".md")+1 so the expression states its intent. R5 Zoro Nit Yes
CRF-70 Nit New coderd/rbac/roles.go:404 The pre-existing comment above allPermsExcept explains a grant that is not there ("Owners can inspect and delete personal skills for operability and abuse handling ..."). The R5 diff adds the correctly-placed twin at line 415 for ResourceUserMemory.Type, so the block now carries two near-identical sentences, one pointing at the wrong line. Drop the two rationale lines above allPermsExcept (keep the workspace note) and fold skills into the memory comment ("skills and memories for operability and abuse handling ..."). R5 Zoro Nit Yes
CRF-71 Nit New coderd/database/agent_memories_test.go:124 18 constraint-violation assertions hand-roll the pq.Error unwrap + literal Constraint comparison. database.IsCheckViolation(err, ...CheckConstraint) + generated database.CheckUserMemoriesPathFormat etc. exist and are already used at lines 75-80 of this same file; sibling coderd/userskills_test.go:477 writes them the short way. Substituting is a mechanical rewrite (4 lines to 1); constraint renames become compile errors instead of runtime test failures. Verified go test ./coderd/database/ -run 'TestUserMemories/PathFormatRejected' passes with the substitution. R5 Zoro Nit Yes
CRF-72 Note New coderd/audit/request.go:476 ResourceRequiresOrgID[database.ChatMemory]() returns true, but database.ChatMemory has no organization column (id, root_chat_id, path, content, created_at, updated_at). The organization must come from the caller's RequestParams; requireOrgID panics under test.v and logs an error otherwise (request.go:491-501). No audit callsite for chat memories exists in this PR, so the arm is dead until the API layer lands. The next PR that wires an audit request will panic. R5 Bisky Note Yes
CRF-73 P3 Author fixed (6671f58) coderd/database/migrations/000586_agent_memories.up.sql:87 R8 (6671f58): NOT FOUND branch now raises user_memory_user_required; MissingUserRejected witness at agent_memories_test.go:460 asserts the new constraint; migration renumbered to 000588. The CRF-8 fix labels the NOT FOUND branch with CONSTRAINT = 'user_memory_user_deleted', the same constraint name the genuine soft-delete branch uses at line 93. Raise message says "user % does not exist" but the machine-readable constraint says "deleted". Sibling coderd/userskills.go:87 maps user_skill_user_deleted to HTTP 409 "Cannot create skills for deleted users.", so a memory API written against the same pattern answers a request naming a nonexistent user ID with 409 "this user has been deleted" instead of 404. MissingUserRejected and SoftDeletedUserRejected (agent_memories_test.go:355,367) assert the same string, so neither test can tell the branches apart. Chat side has no equivalent problem because chat_memory_root_chat_required (:151,:162) honestly covers both branches. Rename the NOT FOUND branch to user_memory_user_required, update MissingUserRejected at agent_memories_test.go:364, regenerate dump.sql. R7 Netero P3, Razor P3, Takumi Nit Yes
CRF-74 P3 Author fixed (6671f58) coderd/database/migrations/000586_agent_memories.up.sql:78 R8 (6671f58): lock-ordering contract added to both trigger headers (user side :60-68, chat side :132-139) and both sqlc query headers (user_memories.sql:7-13, chat_memories.sql:12-18); new AcquireUserSoftDeleteGuardLock query gives callers a lock-first primitive. Both enforce_*_insert_invariants take FOR NO KEY UPDATE on the parent row inside a BEFORE INSERT trigger; a transaction that first touches an existing memory tuple (via UPDATE/DELETE) and then INSERTs another memory for the same parent creates a deadlock cycle against soft-delete cleanup and against DeleteOldChats retention on the chat side (ON DELETE CASCADE walks the child tuples). Independently reproduced by Hisoka and Takumi on PostgreSQL 13.21 with the schema from head. 40P01 is not classified as retryable in coderd (IsSerializedError matches only serialization_failure; grep for 40P01/DeadlockDetected returns nothing in non-test code), so the user's turn dies raw. No production caller exists today (no service layer wraps two memory statements in one transaction) but the natural memory-tool shapes are rename-then-create and delete-then-create. The ordering rule that survives (INSERT first, or take the parent lock first, or don't mix INSERT with prior memory tuple touches in one transaction) exists nowhere in the migration. Cheapest honest fix: state the ordering contract in both trigger comments and both query file headers (next to the READ COMMITTED contract at user_memories.sql:5, chat_memories.sql:10) since sqlc lifts them into the caller-visible Go doc. Structural fix (making coderd retry 40P01) is a separate PR. R7 Hisoka P3, Takumi P3 Yes
CRF-75 P3 Author fixed (6671f58) coderd/database/agent_memories_test.go:30 R8: TestUserMemories/UpdatePathTakesNoUserLock and TestChatMemories/UpdatePathTakesNoChatLock added (parent held FOR NO KEY UPDATE, SET lock_timeout='2s' on the mutator, UPDATE of the memory row succeeds). Nothing pins the memory triggers to BEFORE INSERT. Verified: change line 110 to BEFORE INSERT OR UPDATE ON user_memories and line 179 to the same on chat_memories, run `DB=ci go test ./coderd/database/ -run 'TestUserMemories TestChatMemories TestAgentMemorySchemaConstants', all green. TestAgentMemorySchemaConstantswalkspg_get_functiondef/pg_get_constraintdef, never pg_get_triggerdef, and every subtest inserts before it mutates. CRF-4 and CRF-5 (R1, author fixed) were exactly this defect; the fix shipped without a witness. Under the widened trigger, ordinary UPDATE user_memorieswaited 9.99s and only proceeded when the users FOR NO KEY UPDATE holder was rolled back, which is the precondition for the CRF-57 deadlock cycle now closed onuser_skillsin base PR #28546. AddUpdatePathTakesNoUserLockon both tables: holdusers FOR NO KEY UPDATE(orchats FOR NO KEY UPDATE) on a dedicated connection with lock_timeout='2s', run UPDATE user_memories/chat_memories SET content ...` on another, require no lock error. R7
CRF-76 P3 Author fixed (6671f58) coderd/database/agent_memories_test.go:567 R8: TestChatMemories/OrphanedSubagentChatRejected at agent_memories_test.go:680 builds a child with only RootChatID set and asserts chat_memory_root_chat_required. SubagentChatRejected only exercises the first leg of IF referenced_parent_chat_id IS NOT NULL OR referenced_root_chat_id IS NOT NULL: the test builds a child with both columns set. Verified: deleting the second leg still passes all TestChatMemories subtests. The unwitnessed second-leg encoding (child with parent_chat_id NULL but root_chat_id set) is reachable because InsertChat takes both columns as independent sqlc.narg values with no schema tie (queries/chats.sql:796-797) and chats_parent_chat_id_fkey is ON DELETE SET NULL, so hard-deleting an intermediate subagent leaves its children in this shape. Add a second subtest: build root, then build child with RootChatID set and ParentChatID zero, insert memory under it, assert chat_memory_root_chat_required. R7 Bisky P3 Yes
CRF-77 Note Author fixed (6671f58) coderd/database/migrations/000586_agent_memories.up.sql:154 R8: root-chat check comment (000588:173-181) documents that a subagent outliving its purged root has both columns nulled, passes the check, and becomes a root chat with an empty namespace, and explains why both columns are checked independently. Purging a root chat via DeleteOldChats (or any hard DELETE FROM chats) leaves surviving children with both hierarchy columns NULL because chats_parent_chat_id_fkey and chats_root_chat_id_fkey are ON DELETE SET NULL. That shape passes the root-chat check, so a subagent chat that outlives its root gets promoted to a root chat with its own memory namespace. Reachability narrow (ArchiveChatByID archives the family together, DeleteOldChats splits families only across LIMIT batch boundary), but UnarchiveChatByID matches on id = @id OR root_chat_id = @id, so an orphaned survivor can be unarchived on its own. Distinct from CRF-9 (Store immutability) and CRF-54 (which framed this as a hypothetical mechanism). Add one sentence to the root-chat check comment. R7 Hisoka Note Yes
CRF-78 Note Author fixed (6671f58) coderd/database/migrations/000586_agent_memories.up.sql:81 R8: both comments rewritten to the snapshot re-read mechanism (user side :88-92, chat side :161-165), dropping the "waits on the inserting transaction" clause. The fail-closed comment added by CRF-8 fix says the FK check "waits on the inserting transaction". It does not wait; the row is invisible to the FK's SELECT ... FOR KEY SHARE and the check errors immediately with foreign_key_violation. Verified on PostgreSQL 13.21 with a bare child table: INSERT INTO fk_probe ... user_id=NEW_UUID raises insert or update on table "fk_probe" violates foreign key constraint while the parent INSERT is uncommitted. The window that actually justifies fail-closed is: BEFORE trigger reads at statement snapshot, RI re-reads at end of statement with fresh snapshot, and a parent committing between the two is visible to the second read but never to the first. Same conclusion, different mechanism. Same wrong sentence at :145 on the chat trigger. R7 Takumi Note Yes
CRF-79 Nit Author fixed (6671f58) coderd/database/migrations/000586_agent_memories.up.sql:58 R8: 000588:58-60 now reads "a waiting SERIALIZABLE transaction fails with a serialization error (40001)"; "REPEATABLE READ or" removed. The isolation-comment says "a waiting REPEATABLE READ or SERIALIZABLE transaction fails with 40001 instead of reading stale state." The SELECT ... FOR NO KEY UPDATE it describes is at :75-78, and :69-73 rejects repeatable read with user_memory_insert_isolation before control reaches it. Only SERIALIZABLE can actually reach the lock. Same on the chat side (:132-136 before :138-142). Drop "REPEATABLE READ or" so the comment describes a state the code can be in. R7 Razor Nit Yes
CRF-80 Note Author fixed (6671f58) coderd/database/agent_memories_test.go:212 R8: SerializableCapHolds subtest added on both tables; fills the cap to N-1, races two SERIALIZABLE inserts via waitForBackendBlocked, asserts database.IsSerializedError on the loser and final list length equals the cap. RepeatableReadRejected pins the rejected isolation level on both tables; nothing pins the allowed SERIALIZABLE path. A future trigger edit that broke the SSI-visible read (e.g. replacing count(*) with something that doesn't create the rw-antidependency) would ship green. The CRF-61 comment fix names SSI as the mechanism, so the comment is load-bearing. A SerializableCapHolds subtest that inserts up to the cap under SERIALIZABLE, then two racing serializable transactions, and asserts the second fails with serialization_failure would pin the property. Optional; SSI safety was independently verified three times (Netero, Takumi, Razor). R7 Razor Note Yes
CRF-81 P3 Author fixed (140d455) coderd/database/migrations/000588_agent_memories.up.sql:119,204 R9: new enforce_user_memories_owner_immutable/enforce_chat_memories_owner_immutable functions with BEFORE UPDATE triggers raising user_memory_owner_immutable/chat_memory_owner_immutable (000588:129-149,:237-257); down migration drops both (000588_agent_memories.down.sql:53,56); OwnerImmutable subtests at agent_memories_test.go:251 and :902 assert via database.IsCheckViolation. Both memory triggers fire BEFORE INSERT only; the owner columns (user_memories.user_id, chat_memories.root_chat_id) are unguarded on UPDATE, leaving the memory tables the only ones in this family whose soft-delete guard does not fire on UPDATE. Empirically verified against head schema on PostgreSQL 13.21 using dbgen + real Store: UPDATE user_skills SET user_id = <soft-deleted user> rejected with pq: Cannot create user_skill for deleted user; UPDATE user_memories SET user_id = <soft-deleted user> succeeds and lands the row on a deleted=true owner; UPDATE chat_memories SET root_chat_id = <subagent chat> succeeds and puts the row in exactly the state chat_memory_root_chat_required blocks on INSERT; moving one row between root chats leaves a root chat holding 101 memories vs cap 100. Not reachable through the current Store surface (no query in queries/user_memories.sql or queries/chat_memories.sql writes user_id or root_chat_id, no upsert); the consequence lands when a future query does (memory move between chats, ownership transfer, generated upsert). Not CRF-4/CRF-5 (asked for UPDATE arm dropped as gain-free; new evidence: UPDATE arm on siblings rejects owner reassignment). Not CRF-75 (asks for INSERT-only witness; that landed and now pins this gap open). Not CRF-9 (parent hierarchy immutability, not child owner columns). Fix without reintroducing CRF-74 deadlock or CRF-40 contention: separate BEFORE UPDATE trigger taking no parent lock that only rejects owner-column changes, IF NEW.user_id <> OLD.user_id THEN RAISE EXCEPTION ... CONSTRAINT = 'user_memory_owner_immutable', and root_chat_id twin, plus two-line subtest per table. Class also open on pre-existing user_skills (trigger_user_skills_per_user_limit is BEFORE INSERT only, dump.sql:5492); outside this PR. R8 Netero P3 Pending
CRF-82 P3 Author fixed (140d455) coderd/database/agent_memories_test.go:231,912 R9: t.Cleanup(func() { _, _ = updateConn.ExecContext(context.Background(), "RESET lock_timeout") }) added at agent_memories_test.go:298 and :949; sibling user_soft_delete_guards_test.go:193 gained the same cleanup. SET lock_timeout = '2s' on sqlDB.Conn() is session-scoped, pq's driver has no ResetSession implementation that clears GUCs (coder/[email protected].../conn.go:2103-2108), and dbtestutil.NewDBWithSQLDB shares the same *sql.DB between the Store and the raw connections (dbtestutil/db.go:125-137); every subtest here is t.Parallel(), so the tainted connection is handed to whichever sibling asks next. Empirically verified: probe tainted four pooled connections at 100ms, then read SHOW lock_timeout on four fresh sqlDB.Conn() handles; all four reported 100ms. Under contention, SoftDeleteWinsConcurrentInsert (:472), ConcurrentInsertPerUserLimit (:523), SerializableCapHolds (:238) and the three chat twins die with pq: canceling statement due to lock timeout naming a constraint that is not the real cause. Latent flake, not observed live. Fix: wrap the update in a transaction with SET LOCAL lock_timeout = '2s', or RESET lock_timeout before Close(). Same edit at :912 for UpdatePathTakesNoChatLock. Sibling user_soft_delete_guards_test.go:260 (base, not this PR) has the same leak with 5s. R8 Bisky P3 Pending
CRF-83 P3 Author fixed (140d455) coderd/database/agent_memories_test.go:663 R9: SubagentChatRejected (:664) recut into a three-case table {ParentAndRoot, RootOnly, ParentOnly} over (parentSet, rootSet); OrphanedSubagentChatRejected removed. Root-chat guard is IF referenced_parent_chat_id IS NOT NULL OR referenced_root_chat_id IS NOT NULL (000588:182). SubagentChatRejected sets both columns; OrphanedSubagentChatRejected (CRF-76 R7 fix) sets root_chat_id only. Nothing sets parent_chat_id only. Verified by mutation: delete referenced_parent_chat_id IS NOT NULL OR, all TestChatMemories still passes. Parent-only shape is reachable by the exact mechanism that earned CRF-76: both hierarchy FKs are ON DELETE SET NULL, so purging a root chat nulls root_chat_id on a grandchild while parent_chat_id still points at the surviving intermediate subagent. The CRF-76 fix closed the encoding it was asked about and left its mirror open. Do not add a third subtest; recut into a table []{{ParentAndRoot,true,true},{RootOnly,false,true},{ParentOnly,true,false}} that retires the whole case class. R8 Bisky P3 Pending
CRF-84 P4 Author fixed (140d455) coderd/database/agent_memories_test.go:472 R9: SERIALIZABLE claim removed. 000588:54-62 no longer asserts SERIALIZABLE safety; the trigger now rejects every level except read committed (:82, :179); SerializableCapHolds deleted; Serializable is one of two cases in the new NonReadCommittedRejected tables (:220-249, :875-900). Closed by removing the allowance, not by proving SERIALIZABLE. 000588:56-60 claims the soft-delete branch needs no isolation guard because a waiting SERIALIZABLE transaction fails with 40001. SerializableCapHolds pins that story for the cap branch only. SoftDeleteWinsConcurrentInsert runs at READ COMMITTED. Bisky wrote and ran a SERIALIZABLE variant: blocks on SELECT id FROM users ... FOR NO KEY UPDATE, UPDATE users SET deleted = true commits, racing insert returns pq: could not serialize access due to concurrent update, database.IsSerializedError true, empty list, 0.15s. Same shape as SerializableCapHolds. Advisory; the isolation-level parameter the existing SoftDeleteWinsConcurrentInsert never claims is what would fill the gap. R8 Bisky P4 Pending
CRF-85 P3 Author fixed (140d455) coderd/database/migrations/000588_agent_memories.up.sql:55-56,77,149 R9: guard changed from = 'repeatable read' to <> 'read committed' on both triggers (000588:82, :179); migration comment rewritten to state that any snapshot-holding level recounts stale and that SSI tracks antidependencies only among SERIALIZABLE participants (:54-62). Allowlist as the finding proposed. The isolation guard blocklists REPEATABLE READ and blesses SERIALIZABLE on an SSI argument that only holds when every racing writer is also SERIALIZABLE. PostgreSQL SSI tracks rw-antidependencies only among SERIALIZABLE participants; a READ COMMITTED insert's write is not observed by a SERIALIZABLE reader's predicate read, so a SERIALIZABLE inserter racing a READ COMMITTED inserter commits with no serialization error. Reproduced on PostgreSQL 13.21 against full migration chain through 000588, user seeded with 99 memories: A (READ COMMITTED) INSERT memory-099.md holds users lock, counts 99, passes; B (SERIALIZABLE) starts, takes snapshot, INSERT blocks on users lock; A commits (only locked users, did not modify); B's lock wait ends, B re-counts at its stale snapshot to 99, cap passes, B commits with no serialization error; SELECT count(*) = 101. Every coderd transaction defaults to sql.LevelDefault = server default (coderd/database/db.go:138), and coderd/mcp.go:1388 shows serializable transactions are a live pattern in this codebase. Soft-delete leg holds under mixed pairing because A actually modifies the users row and B dies at the locked read with could not serialize access due to concurrent update, verified. SerializableCapHolds (agent_memories_test.go:238, :919) races two SERIALIZABLE transactions, the pairing that cannot occur in production, so it certifies the safe pairing and cannot see the broken one. Fix, one line per trigger, deletes the reasoning instead of documenting it: IF current_setting('transaction_isolation') <> 'read committed' THEN RAISE EXCEPTION 'user_memories inserts require READ COMMITTED isolation' USING ERRCODE = 'check_violation', CONSTRAINT = 'user_memory_insert_isolation'; END IF;. db.go:161 already retries serializable, so an accidental serializable caller spins on check_violation rather than corrupts the invariant. Chat twin at 000588:149, same defect. Same class open on base PR's enforce_user_secrets_per_user_limits (000587_lock_user_soft_delete_guards.up.sql:230); outside this PR. R8 Hisoka P3 Pending
CRF-86 Note Author fixed (140d455) coderd/database/migrations/000588_agent_memories.up.sql:69 R9: both RAISE EXCEPTION sites gained DETAIL = format('transaction_isolation is %s; if the caller did not set it, check default_transaction_isolation on the deployment', ...) (000588:83-85, :180-182). No startup or migration-time assertion added. Panel to judge whether DETAIL suffices. The isolation guard reads a deployment-level GUC. current_setting('transaction_isolation') reflects default_transaction_isolation, which is settable per server, per database (ALTER DATABASE ... SET), per role, or by a managed-Postgres provider, and some poolers pin it too. Verified on scratch database: SET default_transaction_isolation='repeatable read', then autocommit INSERT INTO user_memories ... fails with ERROR: user_memories inserts require READ COMMITTED isolation at line 8 of the trigger. No explicit transaction, no ReadModifyUpdate, nothing a caller did wrong. Consequence: on such a deployment agent memory is the only broken feature in Coder, failing with a check_violation naming a constraint that does not exist in the schema. Cheapest containment: startup or migration-time assertion that default_transaction_isolation is read committed, so the failure names the real cause once instead of once per memory write. read uncommitted normalizes to read committed in current_setting, verified, so that level is neither rejected nor unsafe. R8 Hisoka Note Pending
CRF-87 P3 Author fixed (140d455) coderd/database/queries/user_memories.sql:8-13, coderd/database/migrations/000588_agent_memories.up.sql:62-68 R9: migration header (000588:64-72) and queries/user_memories.sql:8-15 now scope the hazard to "a lock on any row that delete_deleted_user_resources deletes" and enumerate the seven tables; AcquireUserSoftDeleteGuardLock named as the primitive. Chat header (chat_memories.sql:13-21) reworded to "any chat-owned child row". Ordering contract added in R8 to close CRF-74 names the wrong precondition. sqlc lifts the header into InsertUserMemory's godoc (queries.sql.go:30852-30857), scoping the hazard to "a transaction that updates or deletes an existing memory row and then inserts another for the same user". The lock inversion has nothing to do with memory rows; any child tuple that delete_deleted_user_resources cleans up will do. Reproduced on head schema, PostgreSQL 13.21, no memory row touched by the losing transaction: T1 UPDATE user_skills SET content='edited' WHERE user_id=U holds the user_skills tuple; T2 UPDATE users SET deleted=true WHERE id=U holds the users row and its AFTER trigger delete_deleted_user_resources blocks on T1's user_skills tuple; T1 INSERT INTO user_memories ... enters enforce_user_memories_insert_invariants, waits for users FOR NO KEY UPDATE; ERROR: deadlock detected on FOR NO KEY UPDATE in trigger line 13. Class the caller must avoid: api_keys, user_links, user_secrets, user_ai_provider_keys, organization_members, user_skills, user_memories, written before the insert in the same transaction. That is the class the base migration already states correctly at 000587_lock_user_soft_delete_guards.up.sql:26-31, and it ships AcquireUserSoftDeleteGuardLock (queries/users.sql:753) which this header does not name. Fix: replace "an existing memory row" with "any row that delete_deleted_user_resources deletes" and point at AcquireUserSoftDeleteGuardLock. Same edit at 000588_agent_memories.up.sql:62-68. Chat side has the mirrored wording defect (any child tuple that a chats delete cascades over inverts the order); kept at Nit not P3 because the chat-side hazard is closed for the caller that matters, every ChatMachine.Update transaction opens with LockChatAndBumpSnapshotVersion (coderd/x/chatd/chatstate/machine.go:165), so a memory insert inside a chat turn is already lock-first. Name that primitive next to the same edit at 000588_agent_memories.up.sql:133-139 and queries/chat_memories.sql:13-18. R8 Takumi P3 Pending
CRF-88 P3 Author fixed (140d455) coderd/database/agent_memories_test.go:238,919,207,888 R9: stmt, waitForBackendBlocked, runLockRace moved out of user_soft_delete_guards_test.go into new coderd/database/lockrace_test.go (99 lines, header states it belongs to neither feature); runLockRace gained isolation sql.IsolationLevel and commits the racing transaction; all four remaining race subtests call it (:480, :535, :979, user_soft_delete_guards_test.go:120). File drops 373 changed lines net. SerializableCapHolds on both tables (:238-301, :919-982) and UpdatePathTakesNoUserLock/UpdatePathTakesNoChatLock (:207, :888) hand-roll blocking tx, committed flag, dedicated Conn, pg_backend_pid(), waitForBackendBlocked, commit, result channel. runLockRace (user_soft_delete_guards_test.go:45) exists to own this sequence and is used by three of the four older concurrency subtests. Zoro applied the fix (add isolation parameter, run racing statement inside a transaction it commits) and ran -count=3 on the four race subtests: all pass, both SerializableCapHolds still block on the row lock and still fail with IsSerializedError, agent_memories_test.go drops from 1042 to 976, +6 in helper, net -60. Also file cross-boundary Note (matches Hisoka, Netero): stmt, waitForBackendBlocked, runLockRace are defined in user_soft_delete_guards_test.go, which arrived with base PR #28546 and is absent from this PR's diff. The memory suite compiles only because both files are package database_test; renaming or reverting there breaks this suite, and a reader has no pointer. Cheap fix: move helpers into coderd/database/lockrace_test.go owned by neither feature. R8 Zoro P3, Bisky Nit, Hisoka Note, Netero Note Pending
CRF-89 Nit Author fixed (140d455) coderd/database/agent_memories_test.go:204 R9: nine database.CheckConstraint constants declared in coderd/x/memory/memory.go:12-48 with comment explaining why check_constraint.go cannot own them; test references them (agent_memories_test.go:26,34,112). Seven trigger-raised constraint names appear as 13 raw string literals in the test file (user_memory_insert_isolation, user_memory_user_required, user_memory_user_deleted, user_memories_per_user_limit, chat_memory_insert_isolation, chat_memory_root_chat_required, chat_memories_per_root_chat_limit) with no named Go-side home. Sibling coderd/userskills.go:34-35 names them once (userSkillsPerUserLimitConstraint, userSkillUserDeletedConstraint) with the comment explaining why dbgen cannot emit them. CRF-71 (R5) moved the table CHECK assertions onto generated database.CheckUserMemoriesPathFormat and friends; this is the complement CRF-71 did not cover. coderd/x/memory already exists to own Go-side mirrors and no API layer exists yet to own the HTTP mapping. Declaring them there costs seven lines and buys: TestAgentMemorySchemaConstants already holds pg_get_functiondef output for both trigger functions whose bodies contain CONSTRAINT = '<name>' verbatim, so one require.Contains per name pins the names. R8 Zoro Nit Pending
CRF-90 Nit Author fixed (140d455) coderd/database/agent_memories_test.go:207,888 R9: renamed to UpdateTakesNoUserLock (:268) and UpdateTakesNoChatLock (:919). UpdatePathTakesNoUserLock and UpdatePathTakesNoChatLock name a path update; the bodies run UPDATE user_memories SET content='edited' and UPDATE chat_memories SET content='edited'. In a table with a path column, "UpdatePath" reads as "update the path column", promising rename coverage the subtest does not have. The intended reading is "the update code path", which the trigger scope makes equivalent (neither content nor path update fires BEFORE INSERT), so nothing is untested. Drop Path from both names: UpdateTakesNoUserLock, UpdateTakesNoChatLock. R8 Razor Nit Pending
CRF-91 Nit Author fixed (140d455) coderd/database/queries/chat_memories.sql:13-18, coderd/database/queries/user_memories.sql:8-13 R9: chat_memories.sql:18-21 and 000588:164-168 name GetChatByIDForUpdate and ChatMachine.Update via LockChatAndBumpSnapshotVersion, and state that LockChatByID is system-scoped; user side names AcquireUserSoftDeleteGuardLock (user_memories.sql:14). Deadlock advice "Take the parent lock first" is actionable only if the reader knows the primitive that takes the chats-row / users-row lock. LockChatByID looks like the answer and is not: dbauthz gates it on policy.ActionUpdate against rbac.ResourceSystem (dbauthz.go:7203-7208), so a user-scoped caller gets NotAuthorized. The two that work are GetChatByIDForUpdate (fetch-based read authorization, dbauthz.go:3242) and LockChatAndBumpSnapshotVersion (ActionUpdate on the chat, dbauthz.go:7191), which is what ChatMachine.Update already calls first (chatstate/machine.go:165). Name GetChatByIDForUpdate (or ChatMachine.Update) in the chat header the way queries/users.sql:753 names AcquireUserSoftDeleteGuardLock for the user side, and add that name to user_memories.sql:11-13 too. R8 Razor Nit Pending
CRF-92 Note Author fixed (140d455) coderd/database/migrations/000588_agent_memories.up.sql:48-50 R9: 000588:50-53 now names the conflict with ordinary UPDATE users on the same row, cites UpdateUserLastSeenAt and its per-minute-per-session frequency, and records the cost as accepted. Chat-side header (:124-127) documents that the chats lock contends with title changes, archived flips, and heartbeat writes; user-side header (:48-50) omits that FOR NO KEY UPDATE on users also conflicts with any ordinary UPDATE users on that row, and users is written roughly once per minute per active session by UpdateUserLastSeenAt in the API key middleware (coderd/httpmw/apikey.go:458, throttled changed branch). Runtime cost is negligible (both sides single short statements). Symmetry gap in the comment, not a defect: a reader who trusts the user-side comment concludes the lock is free of contention with users. R8 Razor Note Pending
CRF-93 Nit Author fixed (140d455) coderd/x/memory/memory.go:3 R9: doc rewritten to name the tables and enforce_*_insert_invariants triggers; 000588_agent_memories.up.sql reference is gone (memory.go:1-8). Package doc pins the migration file number (migration 000588_agent_memories.up.sql); git log on the file has two commits, the feature commit and 6671f58ce2 fix(coderd): renumber agent memories migration to 000588, whose entire production diff is this one line changing 000586 to 000588. Number has changed at 585, 586, and 588. Stable coordinates are the table and trigger function names, which the test actually queries by name. Drop the number: "the agent memories migration". House convention already elsewhere (user_soft_delete_guards_test.go:94, enterprise/cli/server_dbcrypt_test.go:246, 000588_agent_memories.down.sql:5). R8 Netero Note, Zoro Nit Pending
CRF-94 P3 Author fixed (b5080f1) coderd/database/migrations/000588_agent_memories.up.sql:82,179 R10 (migration renumbered to 000592): 000592_agent_memories.up.sql:99-103 creates shared require_read_committed(guard_name, constraint_name) with NOT IN ('read committed', 'read uncommitted'); both cap triggers call it (:127, :201). New ReadUncommittedAccepted (agent_memories_test.go:273, :970) and ReadUncommittedCapHolds (:292, :989) witnesses. R9 allowlist rejects READ UNCOMMITTED, which PostgreSQL executes with exactly READ COMMITTED semantics. current_setting('transaction_isolation') reports the level the client asked for, not the level the server runs. Netero, Takumi, and Hisoka independently reproduced on PostgreSQL 13.21 against the head schema: BEGIN ISOLATION LEVEL READ UNCOMMITTED; INSERT INTO user_memories ... fails with user_memories inserts require READ COMMITTED isolation. Takumi additionally widened the trigger to NOT IN ('read committed','read uncommitted') and ran runLockRace(..., sql.LevelReadUncommitted, ...) seeded to N-1: the cap held at 100. The interleaving the allowlist defends against never opens at READ UNCOMMITTED because PostgreSQL refreshes the snapshot per statement, same as READ COMMITTED. Deployment risk: default_transaction_isolation='read uncommitted' (settable per server, per database, per role, or by a pooler) loses every memory write; the DETAIL from CRF-86 sends the operator to fix a setting that was never unsafe. R8 CRF-86 disposition claimed "read uncommitted normalizes to read committed in current_setting, verified" but that claim is false on 13.21. Same defect on chat trigger at :179. queries/user_memories.sql:5-7 and queries/chat_memories.sql:10-12 say the trigger "rejects every other isolation level" which is literally true and behaviorally wrong. While rewriting: the comment at :61-63 says "coderd retries serialization failures, so an accidental non-default-isolation caller fails loudly here instead of corrupting the invariant", but the trigger raises check_violation not serialization_failure, so it escapes the retry loop rather than spinning in it. Fix, both triggers: IF current_setting('transaction_isolation') NOT IN ('read committed', 'read uncommitted') THEN. Add sql.LevelReadUncommitted as an accepted case in NonReadCommittedRejected tables (:220, :875) or rename them. R9 Netero P3, Takumi P3, Hisoka Note Pending
CRF-95 P3 Author fixed (b5080f1) coderd/database/queries/user_memories.sql:15, coderd/database/migrations/000588_agent_memories.up.sql:71 R10: queries/user_memories.sql:17-20 states the lock is a system primitive and to wrap only that call in dbauthz.AsSystemRestricted. The R9 ordering-contract fix names AcquireUserSoftDeleteGuardLock as the lock-first primitive; that method is not callable by a user-scoped memory caller, the same authorization gap CRF-91 closed on the chat side. dbauthz.go:1827 authorizes AcquireUserSoftDeleteGuardLock as policy.ActionUpdate on the fetched user (ResourceUserObject(id) = ResourceUser.WithID(id).WithOwner(id), modelmethods.go:656); member role grants only {ActionRead, ActionReadPersonal, ActionUpdatePersonal} on user-scoped objects (roles.go:446-448, comment says "Users cannot do create/update/delete on themselves"); roles_test.go:390-397 case AUser pins that. InsertUserMemory blesses ActionCreate on ResourceUserMemory.WithOwner(self) (dbauthz.go:6689), so the actor allowed to insert cannot execute the prelude the header prescribes. Both Netero and Hisoka independently ran the RBAC check with NewStrictCachingAuthorizer / member subject and got rbac: forbidden ... (action: update) for update, nil for update_personal. Existing production caller confirms it: coderd/userauth.go:1773 calls it as dbauthz.AsSystemRestricted(ctx). Consequence: the service-layer PR wrapping a memory insert after the named lock call gets NotAuthorized on the ordinary member path unless it widens authorization via AsSystemRestricted, on a write path that is otherwise owner-scoped. CRF-91 on the chat side closed this exact class by naming GetChatByIDForUpdate/LockChatAndBumpSnapshotVersion. Fix: state that AcquireUserSoftDeleteGuardLock requires a system-authorized context, or drop the name and keep the ordering rule (which still works). Same edit at 000588:71. R9 Netero P3, Hisoka P3 Pending
CRF-96 P3 Author fixed (b5080f1) coderd/database/migrations/000588_agent_memories.up.sql:74-122 R10: private guard replaced by the shared one. 000592:86 EXECUTE FUNCTION fail_if_user_deleted('user_memory', 'user_memory_user_deleted'); check_user_not_deleted (migration 000591 in the base) owns the lock and message (:49-51, :73). Base migration 000587_lock_user_soft_delete_guards.up.sql created shared fail_if_user_deleted(constraint_class, constraint_name) and pointed six guarded-table triggers at it "so the lock and the TG_OP gate exist in exactly one place" (000587:11-17). 000588:307 adds user_memories to delete_deleted_user_resources cleanup, making it the seventh table in the same class, but 000588:74-122 writes a private third encoding of the same guard: own SELECT deleted ... FOR NO KEY UPDATE, own message text, own constraint name, own NOT FOUND branch. Copies already diverged: shared guard raises Cannot create % for deleted user from TG_ARGV; memory copy raises cannot create user_memory for deleted user %; shared guard has explicit TG_OP gate; memory copy relies on trigger being INSERT-only; memory copy fails closed on NOT FOUND, shared guard returns NEW so an invisible parent lands the row until the FK check fires. That NOT FOUND divergence is the exact CRF-8 shape (R5 P3 re-raised, R7 author fixed): the fail-closed fix went into the memory copy, six sibling tables still carry the version the panel rejected. Verified by Meruem: attaching EXECUTE FUNCTION fail_if_user_deleted('user_memory', 'user_memory_user_deleted') produces the same error and constraint name Go code already expects. Every future edit to guard contract, error shape, lock, or TG_OP handling has to be applied twice and the second site is easy to miss, which is the exact failure 000587 was written to prevent. Fix: attach shared guard trigger to user_memories; reduce enforce_user_memories_insert_invariants to isolation check + count cap, renamed trigger_zz_user_memories_per_user_limit to fire after the guard per 000587:227-229 convention. The cap still counts under the users-row lock because the guard takes that lock on INSERT before the cap trigger runs; does not touch READ COMMITTED argument or CRF-40 row-lock decision. MissingUserRejected and fail-closed branch move with the cap trigger, or the fail-closed argument gets promoted into fail_if_user_deleted for all seven tables. R9 Hisoka P3, Meruem P3 Pending
CRF-97 Nit Author fixed (b5080f1) coderd/database/migrations/000588_agent_memories.up.sql:137,245 R10: 000592:169 WHEN (NEW.user_id IS DISTINCT FROM OLD.user_id) and :272 WHEN (NEW.root_chat_id IS DISTINCT FROM OLD.root_chat_id); comment at :162 names the NULL case. Both enforce_*_owner_immutable functions write IF NEW.user_id <> OLD.user_id / IF NEW.root_chat_id <> OLD.root_chat_id. The schema's prior art for column immutability is set_chat_message_revision_before (dump.sql:1624-1626, live since 000519) which uses IS DISTINCT FROM. NEW.owner <> OLD.owner is NULL when NEW.owner is NULL, the IF does not fire, trigger returns NEW, and the row fails the column's NOT NULL constraint instead of the immutability constraint. Verified by Robin against a shaped table on PostgreSQL 13: UPDATE t SET owner = gen_random_uuid() raises t.owner is immutable; UPDATE t SET owner = NULL raises null value in column "owner" ... violates not-null constraint. Nothing escapes (both shapes rejected), but a caller matching on memory.UserMemoryOwnerImmutableConstraint via database.IsCheckViolation gets false for the NULL shape and falls through to the generic 500 path (sibling userskills.go:87). OwnerImmutable (agent_memories_test.go:251, :902) asserts only the reassignment shape. Meruem's proposed WHEN (NEW.user_id IS DISTINCT FROM OLD.user_id) clause solves both problems: skips plpgsql call on ordinary content edits (visible in \d user_memories and dump.sql) and handles NULL correctly. Same edit at :254 on chat twin. R9 Robin Nit, Meruem Nit (WHEN sub) Pending
CRF-98 Nit Author fixed (b5080f1) coderd/database/agent_memories_test.go:940,289, coderd/database/user_soft_delete_guards_test.go:186 R10: single lockTimeoutConn(ctx, t, sqlDB, timeout) helper (user_soft_delete_guards_test.go:400, base file) called from both memory sites (agent_memories_test.go:365, :1062); no verbatim block remains in this PR's test file. Author noted placement is base file, not lockrace_test.go. The dedicated-connection-with-lock_timeout block is written three times verbatim: sqlDB.Conn(ctx), t.Cleanup(Close), SET lock_timeout='2s', the four-line comment about pooled connections and LIFO cleanups, t.Cleanup(RESET lock_timeout), then the statement. agent_memories_test.go:289-298 (UpdateTakesNoUserLock), :940-949 (UpdateTakesNoChatLock), and user_soft_delete_guards_test.go:186-193 (uses '5s' and shortened comment). grep -rn lock_timeout --include=*.go . returns these three sites and nothing else. lockrace_test.go was created this round to own exactly this kind of shared harness; header says it "belongs to neither feature." A lockTimeoutConn(ctx, t, sqlDB, timeout) *sql.Conn next to runLockRace collapses each site to one line and puts the pool-taint reasoning (the CRF-82 fix) in one place. Re-raise note against CRF-88: CRF-88 named :207 and :888; the fix could not convert these two because they assert a statement does NOT block and runLockRace waits for a block. So the copies survived the extraction that was supposed to absorb them. R9 Robin Nit Pending
CRF-99 P3 Author contested; panel closed R10 (4/4 accept) coderd/database/migrations/000588_agent_memories.up.sql:82,179 R10: duplication half addressed (require_read_committed created once, 000592:99, called by both memory cap triggers). Sibling-wiring half declined: author replies wiring the gate into enforce_user_skills_per_user_limit / enforce_user_secrets_per_user_limits was reviewed on the base stack (CRF-52, #28870) and rejected because a runtime isolation gate on shipped tables converts a deployment-level default_transaction_isolation into an outage of secret and skill writes, naming dbcrypt rotation as a legitimate REPEATABLE READ writer. Migration 000590 documents the narrowed contract instead. Consequence: bounded cap slip on user_skills/user_secrets stays reachable; the gate enforces only on the two new tables. The "per-owner caps are only correct under READ COMMITTED" invariant this PR discovered is enforced as a per-trigger string check, so the two sibling cap triggers this stack rewrote in the base still overshoot silently. enforce_user_skills_per_user_limit and enforce_user_secrets_per_user_limits (rewritten in 000587:133-225, both count under a lock and compare against a cap) neither check the isolation level. Verified by Meruem on PostgreSQL 13.21: seeded one user with 99 user_skills rows (cap 100), Session B BEGIN ISOLATION LEVEL REPEATABLE READ, count(*) = 99 (snapshot taken), Session A inserts skill 100 and commits, Session B inserts: advisory lock free, cap trigger counts 99 at B's snapshot, passes, B commits. Final count(*) = 101. Identical sequence against user_memories is rejected with user_memory_insert_isolation. Codebase now answers the same hazard two ways on adjacent tables; the answer that does nothing is on the older, more-used tables; the next cap trigger inherits whichever neighbour the author copies. Fix: put the check in one function alongside the shared soft-delete guard, require_read_committed(constraint_name text) raising check_violation with passed constraint and the existing DETAIL, then call it as first statement of every cap trigger. Closes the two open instances, gives the next cap trigger one obvious thing to call, and removes the verbatim duplication between 000588:82-87 and :179-184. user_skills/user_secrets half belongs in the base PR if still open; the shared function belongs here either way because this PR is the one that discovered the invariant. R9 Meruem P3 Pending
CRF-100 Nit Author fixed (b5080f1) coderd/database/migrations/000588_agent_memories.up.sql:146,254 R10: same WHEN clauses as CRF-97 (000592:169, :272) skip the plpgsql call on ordinary content edits. trigger_user_memories_owner_immutable and its chat twin fire FOR EACH ROW on every UPDATE and discover in the function body that user_id/root_chat_id did not change, which is the case for every content edit and every rename. A WHEN clause puts the condition where \d user_memories and dump.sql show it, and skips the plpgsql call entirely on the ordinary paths: WHEN (NEW.user_id IS DISTINCT FROM OLD.user_id) EXECUTE PROCEDURE .... Verified by Meruem on scratch database: with the WHEN clause, content updates succeed and owner updates still fail with user_memories.user_id is immutable. Same edit on chat twin at :254. Also fixes the NULL-owner escape hatch (CRF-97) mechanically. R9 Meruem Nit Pending
CRF-101 Nit Author fixed (b5080f1) coderd/database/lockrace_test.go:47 R10: runLockRace signature no longer carries the parameter (lockrace_test.go:47); branch adds runIsolationLockRace (agent_memories_test.go:21-27) with real callers at :311 and :1008. runLockRace's isolation sql.IsolationLevel parameter added in R9 (CRF-88 fix) has one value at every callsite: agent_memories_test.go:480, :535, :979, and user_soft_delete_guards_test.go:120 all pass sql.LevelDefault. The subtest the parameter was added for (SerializableCapHolds) was deleted in the same commit (140d455, CRF-84 fix). For the three memory callers the parameter is not merely unused, it is unusable: the insert triggers reject every level except READ COMMITTED, so a memory lock race at another level cannot reach the code it would be testing. Doc comment :46 "Pass sql.LevelDefault for the ordinary READ COMMITTED race" reads as if the other branch is exercised somewhere; it is not. The extension also added a raceTx, second cleanup, and Commit() in the goroutine that the pre-R9 harness did not need; readers now have to confirm BeginTx before pg_backend_pid() still yields the right pid. Either drop the parameter and open both transactions at the default, or restore a caller passing something other than sql.LevelDefault (the CRF-94 finding above provides one: a ReadUncommittedCapHolds race). Independent identifications by Bisky, Hisoka, and Takumi. R9 Bisky Nit, Hisoka Nit, Takumi Nit Pending
CRF-102 Nit Author fixed (b5080f1) coderd/database/agent_memories_test.go:39-83 R10: no pg_get_functiondef remains in agent_memories_test.go; only the pg_get_constraintdef loop survives (:123, rationale at :103). Two pg_get_functiondef loops in TestAgentMemorySchemaConstants re-prove what the behavioral suite already proves, by string-matching plpgsql source. Loop one (:39-51) asserts memory_limit constant int := 100; PerUserLimit (:511) and PerRootChatLimit (:955) insert exactly memory.MaxUserMemoriesPerUser/MaxChatMemoriesPerRootChat rows and require the next one to fail. Cap pinned in both directions already. Loop two (:56-83) asserts each of nine CONSTRAINT = '<name>' strings appears in the matching function body. IsCheckViolation compares pqErr.Constraint against the constant (errors.go:74), so every one is already pinned by a real failing write: NonReadCommittedRejected, MissingUserRejected, SoftDeletedUserRejected, PerUserLimit, OwnerImmutable, MissingChatRejected, PerRootChatLimit, etc. Nine for nine. What the redundant copies add is fragility: rename memory_limit to max_memories, reflow CONSTRAINT = 'x' to CONSTRAINT='x', or restructure the cap as an inline literal, and 45 lines go red while the schema is correct. Behavioral assertions survive all three. This is not a re-raise of CRF-89 (which asked for Go-side constant names); the coderd/x/memory/memory.go:17-44 constants are the right answer and keep their value without these source-text loops. Keep the pg_get_constraintdef loop at :85-109 (that one pins exact numeric caps octet_length(path) <= 256 from above, which PathSizeRejected only bounds from below). R9 Bisky Nit Pending
CRF-103 Note Acknowledged; closed on base stack (#28870, migration 000590) coderd/database/dump.sql:5443 R10: base stack closed on user_skills in migration 000590 with UPDATE OF user_id leg plus two named tests. Verified in head schema: dump.sql:5515 CREATE TRIGGER trigger_zz_user_skills_per_user_limit_update BEFORE UPDATE ON user_skills ... WHEN ((new.user_id IS DISTINCT FROM old.user_id)) EXECUTE FUNCTION enforce_user_skills_per_user_limit(). Cap-bypass-by-owner-reassignment hole still open on user_skills: trigger_zz_user_skills_per_user_limit is BEFORE INSERT ON user_skills (dump.sql:5443) while trigger_upsert_user_skills is BEFORE INSERT OR UPDATE (:5435), so UPDATE user_skills SET user_id = <live user with 100 skills> skips the cap and lands the 101st row. Soft-delete half is guarded there; cap half is not. user_secrets is clean: cap trigger is BEFORE INSERT OR UPDATE (:5441). Not this PR's stone (does not touch user_skills, and no Store query reassigns user_skills.user_id, same as memories before this change). Distinct from CRF-22 / issue #28538 (insert-versus-soft-delete race, not cap bypass by owner reassignment). Recording it so the class is written down while the pattern is fresh; belongs on the base PR if still open, otherwise a separate follow-up. R9 Bisky Note Pending
CRF-104 P3 Author fixed (8c89c76) coderd/database/migrations/000592_agent_memories.up.sql:127 R11: adds trigger_aa_user_memories_require_read_committed (name sorts before trigger_insert_user_memories) and TestUserMemories/NonReadCommittedRejected/GateBeforeGuardLock subtest; migration comment at :91-104 documents gate-before-guard ordering. User-side isolation gate fires after the shared guard's users-row lock, so on a non-READ-COMMITTED insert the guard's SELECT deleted FROM users ... FOR NO KEY UPDATE raises 40001 could not serialize access due to concurrent update before require_read_committed runs. The CRF-96 fix (swap to the shared fail_if_user_deleted guard) inverted the order that CRF-79 depended on; nothing moved the gate ahead of the lock. Four-way convergence (Netero, Hisoka, Takumi, Zoro) all reproduced independently on PostgreSQL 13.21: session B BEGIN ISOLATION LEVEL REPEATABLE READ, session A UPDATE users SET last_seen_at = now() and commit, session B inserts a memory -> 40001 from check_user_not_deleted line 42, user_memory_insert_isolation never fires, DETAIL from CRF-86 never surfaces. UpdateUserLastSeenAt writes this row roughly once per minute per active session (the migration says so at :67-70), so the contended path is the common one, not the corner. Three consequences: (1) the operator-facing diagnostic engineered for CRF-86 never reaches the operator on the user-memory table where a wrong deployment default hurts; (2) migration comment at :113-116 claims the raised error is check_violation not a serialization failure so it "surfaces to the caller instead of entering any retry loop", but 40001 is exactly the retryable class ReadModifyUpdate catches at sql.LevelRepeatableRead and burns five attempts before returning "too many errors"; (3) TestUserMemories/NonReadCommittedRejected only asserts uncontended-user case, so subtest passes without proving anything about production. Chat side has the correct order: require_read_committed at :201 runs before the chats lock at :203-207; the two tables now answer the same operator error differently. Fix: separate BEFORE INSERT trigger named to sort ahead of trigger_insert_user_memories (e.g. trigger_aa_user_memories_require_read_committed) that only PERFORM require_read_committed('user_memories', 'user_memory_insert_isolation'). Also fix the retry-loop claim at :113-116. R10 Netero P3, Hisoka P3, Takumi P3, Zoro P3 Pending
CRF-105 P3 Author fixed (8c89c76) coderd/database/agent_memories_test.go:27 R11: deletes runIsolationLockRace from agent_memories_test.go; runLockRace in lockrace_test.go:47 regains the isolation parameter; READ UNCOMMITTED cap race callers pass through the shared harness. runIsolationLockRace is a 53-line verbatim copy of runLockRace, differing only in two BeginTx calls (nil vs &sql.TxOptions{Isolation: isolation}). Same file calls both twins (runLockRace at :547, :602, :1092; runIsolationLockRace at :311, :1008). lockrace_test.go:13 says that file "owns the deterministic lock-race harness"; that stopped being true this round. Re-raise gate: CRF-101 (author fixed R10) asked for the unusable isolation parameter to go. The fix removed the parameter by cloning the whole harness rather than parameterizing it, so the choreography this harness exists to guarantee (blocking tx, dedicated connection, pg_backend_pid, waitForBackendBlocked, commit ordering) now has two copies that can drift; a correctness fix applied to one silently leaves the memory races on the old one. Netero/Bisky/Zoro all applied the recut in worktree: parameter back on runLockRace with sql.LevelDefault default (byte-identical to nil under lib/pq), delete runIsolationLockRace, runIsolationLockRace callers use the same harness. All lock-race tests pass; net -53 lines. Bisky proposed a wrapper pattern (runLockRace calls runLockRaceAt with default) if threading LevelDefault through seven callsites is unwanted. R10 Netero P3, Bisky P3, Zoro P3 Pending
CRF-106 Nit Author fixed (8c89c76) coderd/x/memory/memory.go:16 R11: rewrites the doc, user_memory_user_required removed, migration reference updated from 000588 to 000591 (the shared guard's real home). Constant-block doc names a constraint that exists nowhere in the tree (user_memory_user_required) and pins migration 000588 which is stale by two renumbers (memory migration is 000592; shared guard is in 000591, which passes exactly two arguments 'user_memory' and 'user_memory_user_deleted'). Re-raise gate: CRF-93 (author fixed R9) asked for the number to be dropped and the fix rewrote the top-of-file doc at memory.go:1-8 but left this second occurrence in the const block, which then went stale twice: 000588 is now unpriced_ai_models_notification, and the user_memory_user_required constraint was deleted this round when the shared guard took over (000592:55-62 documents the hard FK now rejecting an absent parent). The failure mode CRF-93 predicted has already happened. grep -rn user_memory_user_required returns exactly one hit (this comment); grep -rn 000588 in coderd/ (excluding migrations) shows this is the only stale reference. Drop the sentence to: "The user_memory_user_deleted name is passed as an argument to the shared fail_if_user_deleted guard." Four-way convergence: Netero (P3), Hisoka Nit, Knuckle Nit, Zoro Nit. Highest classification would be P3 but three independent Nit assessments plus zero behavioral impact (no consumer checks constant list completeness) argue Nit fits better; kept as Nit here on the merits. R10 Netero P3, Hisoka Nit, Knuckle Nit, Zoro Nit Pending
CRF-107 P3 Author fixed (8c89c76) coderd/database/agent_memories_test.go:344 R11: adds TestUserMemories/UpdateAtCapAllowed and TestChatMemories/UpdateAtCapAllowed (fill to memory.MaxUserMemoriesPerUser/MaxChatMemoriesPerRootChat, edit + rename one row, expect no error); stale UpdateTakesNoUserLock comment also removed. UpdateTakesNoUserLock stopped witnessing the user-side triggers' INSERT-only scope after the CRF-96 shared-guard swap. Its comment claims "widening one to UPDATE would block this edit on the locked users row"; that was true in R8 against the private guard which locked on every operation. It is false against fail_if_user_deleted (000591:113+) on a same-owner UPDATE: the shared guard takes the unlocked read path, and the cap trigger takes no lock at all. Nothing blocks, so nothing fails. Verified by Bisky on PostgreSQL 13.21, two mutations: 000592:84 BEFORE INSERT -> BEFORE INSERT OR UPDATE: all of TestUserMemories green. 000592:143 (cap trigger) BEFORE INSERT -> BEFORE INSERT OR UPDATE: all of TestUserMemories green. Second mutation is the one that hurts: a cap trigger that fires on UPDATE recounts the owner's rows on every content edit and rename, so a user at exactly 100 memories can no longer edit any of them. Chat twin at :1041 catches the same mutation because the chat trigger really does hold the chats-row lock. This is CRF-75 (R7, closed R8) reopening: the R10 CRF-96 rework moved the lock out from under the test that closed it. Add UpdateAtCapAllowed subtest (fill to memory.MaxUserMemoriesPerUser, edit and rename one row, assert no error); Bisky verified it fails under the widened cap trigger and passes on head. Keep UpdateTakesNoUserLock for the deadlock-freedom property it still proves, and fix its comment. R10 Bisky P3 Pending
CRF-108 P3 Author fixed (8c89c76) coderd/database/agent_memories_test.go:327 R11: adds UPDATE ... SET user_id = NULL and UPDATE ... SET root_chat_id = NULL assertions to both OwnerImmutable subtests, checking database.IsCheckViolation against memory.UserMemoryOwnerImmutableConstraint / ChatMemoryOwnerImmutableConstraint. OwnerImmutable asserts the reassignment shape only; the NULL shape the R10 WHEN clause was written for (000592:162-163: "IS DISTINCT FROM, so a NULL assignment is also caught here rather than by the NOT NULL constraint") has no witness. Verified by Bisky on head: UPDATE user_memories SET user_id = NULL raises user_memories.user_id is immutable and IsCheckViolation(err, memory.UserMemoryOwnerImmutableConstraint) is true. Rewrite the WHEN clause to WHEN (NEW.user_id <> OLD.user_id) (the shape a later "simplification" would reach for), and the same statement raises null value in column "user_id" ... violates not-null constraint, the check violation is false, and TestUserMemories/TestChatMemories/TestAgentMemorySchemaConstants all stay green. Consequence bounded (no Store query writes the owner column, and both shapes are rejected either way), but the error label is the whole point: a caller matching memory.UserMemoryOwnerImmutableConstraint via database.IsCheckViolation falls through to the generic 500 path. Two lines inside the existing subtest add the witness. Chat twin at :1024 needs the same. R10 Bisky P3 Pending
CRF-109 P3 Author fixed (8c89c76) coderd/database/queries/users.sql:781 R11: adds delete_user_memories CTE to PurgeSoftDeletedUserResources in queries/users.sql:798-799; TestPurgeSoftDeletedUserResources seeds a user_memories row and extends guardedTables with "user_memories". user_memories joins the guarded-table class in the trigger half but is missing from the reaper half; PurgeSoftDeletedUserResources deletes from api_keys, user_links, user_secrets, user_ai_provider_keys, organization_members, user_skills, group_members, user_ai_budget_overrides (queries/users.sql:781-799); dbpurge_test.go:3622 guardedTables slice pins the same eight; migrate_test.go:3720 uses the same list. 000592:48-51 states plainly: "user_memories joins the delete_deleted_user_resources cleanup set below, so it attaches the shared fail_if_user_deleted guard from migration 000591 like the other guarded tables." The class has two halves. Migration 000591 deliberately ships no backfill and delegates catch-up to the idempotent reaper (000591:30-35), whose own comment says it "removes what predates both (legacy orphans from before cleanup coverage, and race products from before the guards)". user_memories, added by this PR, is not in the reaper. Unverified reachability today: with guard and cleanup both present from table creation there are no legacy orphans, and the residual window the migration header describes at 000592:55-62 (a users row invisible to the trigger's statement snapshot, committed before the RI check, so the insert skips the lock) was not reproduced. But that window is the reason the header spends eight lines arguing "unreachable through the Store" rather than impossible, and the reaper exists precisely to sweep what the argument misses. Today the argument is the only thing between a private document of a deleted user and permanent survival, and it is an argument about all current callers, none of which are written yet. Every sibling table gets a second line of defense; the table holding user-authored prose does not. Fix: add user_memories CTE last (keeping stated table order) to PurgeSoftDeletedUserResources, plus one entry in each guardedTables slice. Hisoka + Knuckle independent identifications. R10 Hisoka P3, Knuckle Note Pending
CRF-110 Nit Author fixed (8c89c76) coderd/database/migrations/000592_agent_memories.up.sql:99 R11: moves the isolation-cap rationale inside require_read_committed function body and inside enforce_user_memories_per_user_limit body; comment says explicitly "the reasoning lives inside the function body so it survives into dump.sql and \sf". Reasoning for require_read_committed and both cap functions sits above CREATE FUNCTION, so none of it survives into dump.sql, contra the convention 000591_lock_user_soft_delete_guards.up.sql:8-9 set explicitly ("The guard and lock-ordering rationale lives inside check_user_not_deleted() below so it survives into dump.sql."). 000590 follows same rule for its cap triggers. awk '/^CREATE FUNCTION enforce_user_skills_per_user_limit/,/^\$\$;/' coderd/database/dump.sql prints the five-line explanation; same command on the three functions this PR adds (require_read_committed at dump.sql:1660-1671, enforce_user_memories_per_user_limit at :1207-1237, enforce_chat_memories_insert_invariants) prints bare bodies with no rationale. Operator hitting user_memories inserts require READ COMMITTED isolation reads \sf require_read_committed or dump.sql and finds a raise with no reason attached. Move the two paragraphs at :88-98 and :113-119 inside the function bodies. R10 Knuckle Nit Pending
CRF-111 Nit Author fixed (8c89c76) coderd/database/queries/user_memories.sql:13 R11: rewrites the ordering paragraph to "any row user soft-deletion removes (directly in delete_deleted_user_resources or by cascade from those deletes, for example group_members, user_ai_budget_overrides, and oauth2_provider_app_tokens)". Lock-ordering contract attributes group_members, user_ai_budget_overrides to delete_deleted_user_resources, which deletes exactly api_keys, user_links, user_secrets, user_ai_provider_keys, organization_members, user_skills, user_memories (000592:279-328); those two tables go away through cascade triggers trigger_delete_group_members_on_org_member_delete and trigger_delete_user_ai_budget_overrides_on_org_member_delete (dump.sql:5461, :5467). Deadlock hazard does cover them (deletes run in the same transaction under the same users lock), so the advice is right and the attribution is wrong. Same rule brings oauth2_provider_app_tokens onto the list (dump.sql:5750, ON DELETE CASCADE from api_keys), which is the argument for stating the rule instead of enumerating: "any row removed when a user is soft-deleted, directly or by cascade". Same wording at 000592:74-76 and in the generated copies. R10 Netero Nit Pending
CRF-112 Nit Author fixed (8c89c76) coderd/database/agent_memories_test.go:301,323,998,1020 R11: replaces generate_series(1, 99) with generate_series(1, $2) bound to memory.MaxUserMemoriesPerUser-1 / MaxChatMemoriesPerRootChat-1, and asserts against the same constants. ReadUncommittedCapHolds hardcodes 99 and 100 in the one file whose whole point is that the caps have a Go-side home (coderd/x/memory exists per CRF-42). generate_series(1, 99) at :301 and require.Equal(t, 100, count, ...) at :323, identical pair on chat side at :998 and :1020. Every other cap subtest uses the constants: PerUserLimit, ConcurrentInsertPerUserLimit, PerRootChatLimit, ConcurrentInsertPerRootChatLimit. Mechanical fix: FROM generate_series(1, $2::int) AS g with memory.MaxUserMemoriesPerUser-1; assert against memory.MaxUserMemoriesPerUser. Zoro verified passing. R10 Zoro Nit Pending
CRF-113 Nit Author fixed (8c89c76) coderd/database/migrations/000592_agent_memories.down.sql:51 R11: removes all five DROP TRIGGER IF EXISTS ... ON user_memories/chat_memories lines and adds header comment noting "Dropping the tables removes every trigger on them". Five DROP TRIGGER statements on tables the same file drops ten lines later. DROP TABLE user_memories and DROP TABLE chat_memories (:61-62) remove every trigger on those tables, so :51, :52, :54, :56, :58 do nothing. They also create a maintenance obligation the up migration will outgrow silently: add a trigger up there and forget it here, and nothing notices. Only the function drops are load-bearing (functions are schema-level). Shorter version: two DROP TABLE statements then five DROP FUNCTION IF EXISTS lines including require_read_committed(text, text). R10 Zoro Nit Pending
CRF-114 Nit Author fixed (8c89c76) coderd/database/agent_memories_test.go:722 R11: rewrites the comment to "the trigger reads at its own snapshot while the RI check re-reads at end of statement, so a chat committing between the two is visible only to the FK", matching the migration wording CRF-78 established. MissingChatRejected's comment states the FK mechanism CRF-78 established is wrong. Says "an uncommitted chat would be invisible to the trigger's locked read but visible to the FK". An uncommitted parent is not visible to the FK either; the RI check errors immediately with foreign_key_violation (CRF-78 reproduced on 13.21). The window that actually justifies failing closed is a chat that commits between the trigger's statement snapshot and the RI re-read, which is exactly what the migration comment now says at :209-213 after the CRF-78 fix. The fix corrected both migration sites and left the test file asserting the rejected mechanism. Copy the migration's wording: "the trigger reads at its own snapshot while the RI check re-reads at end of statement, so a chat committing between the two is visible only to the FK". R10 Zoro Nit Pending
CRF-115 Note Author fixed (8c89c76) coderd/database/migrations/000592_agent_memories.up.sql:52 R11: extends the user-memories header comment: "INSERT-only also means, unlike the BEFORE INSERT OR UPDATE guards on user_links/user_secrets/user_skills, it does not reject edits to a soft-deleted user's surviving rows". user_memories is the only guarded table whose soft-delete guard is INSERT-only. From dump.sql:5471-5505: group_members, organization_members, user_ai_budget_overrides, user_ai_provider_keys are BEFORE INSERT OR UPDATE OF user_id; user_links, user_secrets, user_skills are BEFORE INSERT OR UPDATE; api_keys gets a second trigger for the owner leg. user_memories is BEFORE INSERT. The comment covers reassignment (which the immutability trigger handles). It does not cover the other thing the siblings' UPDATE leg does: reject any modification of a row whose owner is soft-deleted (check_user_not_deleted runs the unlocked read on same-owner updates and still raises). On user_memories a content edit or rename of a soft-deleted user's memory would succeed. Unreachable today (delete_deleted_user_resources removes the rows and the guard blocks new ones), so not asking for the UPDATE leg. Worth one clause in the comment so the next reader comparing the eight tables does not re-derive it: "...there is no reassignment path to guard, and no row survives soft deletion to modify." R10 Zoro Note Pending
CRF-116 Nit Author fixed (8c89c76) coderd/database/migrations/000592_agent_memories.up.sql:149 R11: rewrites the immutability paragraph: "The insert-path triggers (the isolation gate, the shared guard, and the cap) fire BEFORE INSERT only; an UPDATE that reassigns the owner column would bypass all of them." Contradictory "guard's UPDATE branch checks deleted" clause is gone. Parenthetical at :147-149 ("The insert-path triggers (the shared guard and the cap) fire BEFORE INSERT only; an UPDATE that reassigns the owner column would bypass both (the guard's UPDATE branch checks deleted but the cap never recounts).") describes a guard UPDATE branch that does not exist on this table and contradicts the same file at :52. Shared function does have an UPDATE path (000591:89-98), but trigger_insert_user_memories is BEFORE INSERT only (:83-86), which :52-53 states explicitly, so on user_memories no guard code runs on UPDATE. Parenthetical also undercuts the "bypass both" it is attached to. Delete it. R10 Netero Nit Pending
CRF-117 Nit Author fixed (8c89c76) coderd/database/migrations/000592_agent_memories.up.sql:100 R11: reindents the require_read_committed body with four spaces, matching every other function in the file. require_read_committed body is tab-indented in a space-indented file. grep -Pc '^\t' returns 8 and grep -Pc '^ ' returns 136 for this file; the 8 tab lines are exactly require_read_committed (:100-110), lifted from the tab-indented base migration. Every other function in the file uses four spaces. Reindent. R10 Netero Nit Pending
CRF-118 Note Pending author response coderd/database/agent_memories_test.go:949 Chat-side gate-before-lock ordering has no test witness; a swap of the two adjacent statements in enforce_chat_memories_insert_invariants reopens the hazard GateBeforeGuardLock was written to close, this time on chat. Migration header (000592:130-133) explicitly cites the user-side test as the pin and dismisses the chat side because ordering is intra-function. Under REPEATABLE READ, the SELECT ... FOR NO KEY UPDATE on chats raises 40001 for any writer whose snapshot predates a committed UPDATE chats on the same row (title changes, archived flips, ChatMachine.Update's snapshot bumps) before require_read_committed runs, burning ReadModifyUpdate retries and hiding the gate's diagnostic. No chat-side subtest catches it: NonReadCommittedRejected (:949) tests uncontended rows only; SubagentChatRejected, MissingChatRejected, PerRootChatLimit run at default isolation. Add a GateBeforeChatLock twin symmetric with :216-241 (pin snapshot at RR, commit outside UPDATE chats SET updated_at = now(), insert under tx, assert IsCheckViolation(err, memory.ChatMemoryInsertIsolationConstraint)). R11 Hisoka Note Pending
CRF-119 Note Pending author response coderd/database/migrations/000592_agent_memories.up.sql:159 Comment at :156-159 ("this re-assertion keeps the cap self-contained if that trigger is ever dropped") over-promises what the re-check protects. Trigger name order is trigger_aa_..._require_read_committed, trigger_insert_user_memories (shared guard, sorts between aa_ and zz_), trigger_zz_..._per_user_limit. Drop the aa_ trigger and the guard runs first: check_user_not_deleted takes SELECT deleted FROM users ... FOR NO KEY UPDATE (000591:89-93), and 000591:81-88 says plainly that this locking read raises 40001 under REPEATABLE READ whenever any transaction committed an update to the users row after the snapshot. Cap trigger's re-check never runs. Defense only holds on the uncontended path, which is not the path the R11 split fix cares about. Two remediations: tighten the comment to what the re-check actually protects (both aa_ and the guard absent, or the guard replaced with a non-locking variant), or fold the invariant into a single BEFORE INSERT trigger and delete the aa_/zz_ name-ordering sentinel (chat side already does this). R11 Meruem Note Pending
CRF-120 Nit Pending author response coderd/database/agent_memories_test.go:578 Commit 8c89c76135 body says "witness UpdateAtCapAllowed on both tables"; grep -c UpdateAtCapAllowed coderd/database/agent_memories_test.go returns 1 (only user side). Chat-side cap widening (BEFORE INSERT -> BEFORE INSERT OR UPDATE on enforce_chat_memories_insert_invariants) is caught by UpdateTakesNoChatLock (:1056) because the cap check and the chats-row lock live in the same trigger function, so no behavioral coverage is lost. Either add the symmetric TestChatMemories/UpdateAtCapAllowed, or drop "on both tables" from the commit body. Leaving the two out of step is what CRF-107 was raised to close. R11 Hisoka Nit Pending

Contested and acknowledged

CRF-9 (P3, coderd/database/migrations/000580_agent_memories.up.sql:70) - chats.parent_chat_id/root_chat_id immutability convention-only

  • Finding: Meruem argued the chat memory root-only invariant is enforced only when chat_memories mutates. A future migration/backfill that sets chats.parent_chat_id or chats.root_chat_id on an existing chat with memories would silently promote them to a subagent-owned namespace. Proposed either a BEFORE UPDATE trigger on chats, or a schema-level rejection of updates to those columns.
  • Author defense (R2): "The chat hierarchy columns are immutable in the current Store query surface: they are assigned only by InsertChat, and no query updates parent_chat_id or root_chat_id. The memory insert trigger still rejects non-root chats. A new UPDATE trigger on the hot chats table is outside this database-layer change."
  • Panel status: Contested, awaiting R2 panel evaluation.

CRF-11 (Nit, coderd/database/queries/user_memories.sql:26) - content_prefix character slice vs byte slice

  • Finding: left(content, 4096) counts characters, not bytes; the field name reads like a byte budget.
  • Author defense (R2): "The text prefix intentionally uses PostgreSQL character slicing so it never returns malformed UTF-8 by splitting a code point. This query feeds YAML frontmatter parsing rather than enforcing the 64 KiB storage limit, which remains byte-based."
  • Panel status: Contested, awaiting R2 panel evaluation.

CRF-17 (Nit, coderd/database/migrations/000580_agent_memories.up.sql:44) - shared cap-trigger helper

  • Finding: enforce_user_memories_per_user_limit is a copy of enforce_user_skills_per_user_limit; suggested extracting a shared helper.
  • Author defense (R2): "The memory trigger remains domain-specific so it owns its constraint name, error context, soft-delete check, and lock semantics independently of user_skills. Sharing a migration-local trigger function would couple unrelated resource invariants."
  • Panel status: Contested, awaiting R2 panel evaluation.

CRF-18 (Note, coderd/database/queries/user_memories.sql:26) - content_prefix truncation signal

  • Finding: Caller cannot tell preview from full document; suggested is_truncated/octet_length column.
  • Author defense (R2): "The prefix query is only an index/frontmatter optimization. Full memory reads use the by-ID or by-path queries, so callers do not treat content_prefix as a complete document."
  • Panel status: Contested, awaiting R2 panel evaluation.

CRF-19 (Note, coderd/database/queries/user_memories.sql:54) - RETURNING * bandwidth

  • Finding: Prefix delete can ship up to 6.4MB back per call.
  • Author defense (R2): "Prefix deletion returns deleted rows intentionally so the service layer can produce per-resource audit records and confirmation. The schema cap bounds the worst case to 100 rows."
  • Panel status: Contested, awaiting R2 panel evaluation.

CRF-22 (Note, coderd/database/migrations/000580_agent_memories.up.sql:183) - sibling triggers race

  • Finding: Same insert-vs-soft-delete race is open in user_secrets and user_skills triggers.
  • Author defense (R2): "The user_skills and user_secrets races predate this feature and are not changed by this PR. This change uses FOR NO KEY UPDATE for the new memory invariant without expanding scope into existing resource triggers."
  • Panel status: Contested (out-of-scope claim, no follow-up ticket linked), awaiting R2 panel evaluation.

CRF-23 (Note, coderd/database/queries/user_memories.sql:52) - byte-prefix vs directory-prefix

  • Finding: starts_with(path, prefix) is a byte prefix; prefix='notes' matches notes.md and notes/foo.md. Convention lives only in tests.
  • Author defense (R2): "Directory-boundary validation belongs to the API/tool path parser, which supplies canonical directory prefixes with a trailing slash. The database query remains a generic non-empty prefix primitive."
  • Panel status: Contested, awaiting R2 panel evaluation.

CRF-26 (Note, commit 61544d7) - commit message quality

  • Finding: Commit bundles two orthogonal fixes without naming either.
  • Author defense (R2): "The current branch history keeps the schema integrity and audit redaction changes within the cohesive database foundation layer. No functional change is needed for this history-only note."
  • Panel status: Contested, awaiting R2 panel evaluation.

CRF-29 (Note, coderd/database/dbauthz/dbauthz.go:153) - authorizeChatMemoryMutation generalization

  • Finding: authorizeChatMemoryMutation extracts a pattern that repeats inline at roughly nine other chat-mutation callsites. Sibling helpers like authorizeAIBridgeInterceptionAction and authorizeWorkspaceByAgentID take an action policy.Action parameter; a parameterised authorizeChatByID(ctx, chatID, action) would match those helpers.
  • Author defense (R3): "Keeping this helper memory-specific in this database-layer PR is intentional. Generalizing it and refactoring existing chat mutation callsites would broaden the diff without changing memory authorization behavior, so those unrelated callsites remain unchanged."
  • Panel status (R4): Closed. Every panel reviewer who evaluated it (Ryosuke, Robin, Hisoka, Ryosuke, Pen Botter) accepted the defense. Runtime authorization is identical either way; the change would touch roughly nine unrelated chat mutation paths in a database-foundation PR at maintenance-only benefit. Recommendation: file a follow-up if the eleventh copy of the pattern appears.

CRF-39 (P3, coderd/database/migrations/000585_agent_memories.up.sql:27) - case-sensitive path uniqueness

  • Finding: user_memories_user_id_path_idx and chat_memories_root_chat_id_path_idx are plain btrees on (owner, path) and the path regex allows [a-zA-Z0-9_.-], so Notes.md and notes.md are distinct documents. Sibling user_skills closed this with a lowercase regex. Pen Botter proposed either lowercasing the regex or indexing (owner, lower(path)).
  • Author defense (R5): "Case sensitivity is intentional: memory paths follow mux semantics (case-sensitive POSIX-style paths), and callers pass canonical paths. The decision is now recorded in the migration header, and a CaseSensitivePaths witness test asserts the case-only pair coexists as two documents (and pins their COLLATE "C" order)."
  • Panel status (R5): Closed. Bisky, Zoro, and Knuckle each independently accepted. CaseSensitivePaths (agent_memories_test.go:167) is a real witness (inserts Notes.md and notes.md, requires both survive, pins list[0] == "Notes.md", an order a linguistic collation would flip). System-level consequence of the defended behavior: a caller that doesn't canonicalize case creates a shadow document, same as a filesystem. Documented as a decision rather than an accident. Chat-side twin witness missing is CRF-56, not CRF-39.

CRF-40 (P3, coderd/database/migrations/000585_agent_memories.up.sql:133) - chats row lock for cap

  • Finding: enforce_chat_memories_insert_invariants takes FOR NO KEY UPDATE on chats purely for the count cap; every chat memory insert blocks against ordinary UPDATE chats on the same row (heartbeats, title updates, archived flips). Alternatives: pg_advisory_xact_lock or unique-index insertion arbitration.
  • Author defense (R5): "Kept the row lock (30d3f2a): an advisory lock alone would not fix the REPEATABLE READ snapshot hazard (CRF-28), which is now rejected outright in the trigger, and the cap plus root-chat check both want the parent row stable. The trigger comment now documents the contention cost against ordinary chats-row updates."
  • Panel status (R5): Closed. Takumi (original raiser), Bisky (no test opinion), Zoro, and Knuckle accepted. Bounded contention (per root chat, single short statement, cap at 100). Memory writes are agent tool calls inside a chat turn, and a chat turn is already serialized per chat by ChatMachine.Update, so the insert joins an existing serialization point rather than creating one. Knuckle Note: the defense's "root-chat check wants the parent row stable" leg does not hold because parent_chat_id/root_chat_id are Store-immutable (CRF-9 defense) and the FK ON DELETE SET NULL exception cascade-deletes the memory anyway; only the cap needs the lock. Recommendation for a future revisit: trim the trigger comment to the cap.

CRF-43 (P3, coderd/rbac/roles.go:415) - owner role vs private memory content

  • Finding: Owner role grants {ActionRead, ActionDelete} on ResourceUserMemory. enterprise/audit/table.go marks content ActionSecret (same treatment as user_secret.value), and ResourceUserSecret is excluded from owner permissions on line 406. Table comment called memory "private per-user". Three artifacts described three different policies for the same field.
  • Author defense (R5): "Owner-read is intentional (product decision: the owner role gets administrative read/delete but not create/update on user memories). Aligned the artifacts (30d3f2a): the table comment now names the owner-role administrative access and that audit logging treats content as a secret; the audit ActionSecret marking is about log persistence, not access, so it stays."
  • Panel status (R5): Closed. Bisky, Zoro, and Knuckle each independently accepted. roles_test.go:1239 and :1252 pin the policy in both directions; the coverage loop at :1533 means the entry cannot be omitted. Byte-identical to ResourceUserSkill, which the codebase already ships with the same owner grant and "personal" framing. System-level consequence of the defended behavior: a site owner can read any user's memory content through the database layer, ActionSecret prevents that content from being persisted into audit logs but does not gate the read. Deliberate product line separating memory (personal, not secret) from user_secret (secret, owner-excluded).

CRF-99 (P3, coderd/database/migrations/000588_agent_memories.up.sql:82,179) - READ COMMITTED gate not applied to sibling cap triggers

  • Finding (R9): The "per-owner caps are only correct under READ COMMITTED" invariant this PR discovered is enforced as a per-trigger string check on the two new memory tables. The two sibling cap triggers (enforce_user_skills_per_user_limit, enforce_user_secrets_per_user_limits) rewritten in the base 000587:133-225 both count under a lock and compare against a cap but neither checks the isolation level. Meruem reproduced 101 user_skills at cap 100 under REPEATABLE READ. Codebase now answers the same hazard two ways on adjacent tables; the answer that does nothing is on the older, more-used tables. Proposed: shared require_read_committed(constraint_name text) alongside the shared soft-delete guard, called as the first statement of every cap trigger, including the two siblings.
  • Author reply (R10): "Wiring the gate into enforce_user_skills_per_user_limit / enforce_user_secrets_per_user_limits was reviewed on the base stack (CRF-52, fix(coderd): serialize per-user caps on advisory locks instead of the users row #28870) and rejected because a runtime isolation gate on shipped tables converts a deployment-level default_transaction_isolation into an outage of secret and skill writes. dbcrypt rotation is a legitimate REPEATABLE READ writer against user_secrets. Migration 000590 documents the narrowed contract instead." The require_read_committed helper duplication half was addressed (created once, 000592:99, called by both memory cap triggers).
  • Panel status (R10): Closed by unanimous panel accept (4/4 who evaluated: Hisoka, Takumi, Knuckle, Zoro). Verified: enterprise/dbcrypt/cliutil.go:88 (Rotate) and :304 (Decrypt) run through UpdateUserSecretByUserIDAndName inside InTx(..., &database.TxOptions{Isolation: sql.LevelRepeatableRead}) (:135, :343), and trigger_zz_user_secrets_per_user_limits fires BEFORE INSERT OR UPDATE (dump.sql:5511). Wiring require_read_committed into enforce_user_secrets_per_user_limits would fail coder server dbcrypt rotate and dbcrypt decrypt outright on the first secret (check_violation is not retryable). On user_skills the dbcrypt argument does not apply (nothing in enterprise/dbcrypt touches that table) but the symmetric deployment-GUC argument does. Panel accepted the split-behaviour posture: bounded cap slip on user_skills/user_secrets under snapshot isolation stays reachable (bounded to the number of concurrent snapshot-isolation racers, reachable only from a REPEATABLE READ writer i.e. maintenance jobs), while user_memories/chat_memories writes fail loudly. Migration 000590:25-36 documents the narrowed contract. 000592:92-98 restates it on the memory side. Split is deliberate and readable. Third option Zoro checked (denormalized counter column) is more machinery than the hazard justifies.

Round log

Round 1

Netero: no findings. Law: don't split (advisory). Panel: 22 reviewers (Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Komugi, Gon, Leorio, Knuckle, Kurapika, Razor, Meruem, Takumi, Ryosuke, Chopper, Ging-Go, Zoro, Kite, Knov, Robin, plus wildcards Killua and Melody). 1 P2, 8 P3, 8 Nit, 9 Note. Reviewed against e7eea0f..61544d7.

Round 2

Churn guard: PROCEED. 18 addressed, 8 contested, 0 silent. Contested findings CRF-9, CRF-11, CRF-17, CRF-18, CRF-19, CRF-22, CRF-23, CRF-26 transferred to context for panel evaluation.

Panel: 21 reviewers (Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Komugi, Meruem, Knuckle, Takumi, Chopper, Kurapika, Ryosuke, Ging-Go, Gon, Leorio, Zoro, Knov, Kite, Robin, Razor, Pen Botter, Killua wildcard). Panel closed 7 of 8 contested findings; re-raised CRF-22 (8/8 vote, needs human decision on ticket) and CRF-7 (partial fix; chat-side symmetric test missing). Added CRF-27..CRF-36 (1 P3 re-raise, 6 Nit, 5 Note). Reviewed against fd09112..a226177.

Round 3

BLOCKED. Churn guard: 10 addressed, 1 contested (CRF-29), 1 silent (CRF-22). No panel, no Netero. Reviewed against fd09112..3605da1.

Round 4

Churn guard: PROCEED. 0 new commits since R3 (head unchanged at 3605da1). Author reply on CRF-22 links issue #28538, so CRF-22 is Deferred and out of scope. CRF-29 contested, evaluated by panel.

Netero: 1 Nit (CRF-49), plus notes flagging CRF-9 evidence (folded into CRF-54) and CRF-28 latent scope.

Panel: 12 reviewers (Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Komugi, Takumi, Meruem, Knuckle, Robin, Ryosuke, plus wildcard Pen Botter). Actions:

  • Re-raised CRF-28 at P2 (R3 fix was comment-only; empirical PostgreSQL 13.21 reproduction under REPEATABLE READ; database.ReadModifyUpdate hardcodes REPEATABLE READ and chatd calls it at three sites).
  • Re-raised CRF-21 at P3 (R3 filter turned loud failure into silent zero-row insert).
  • Closed CRF-29 on unanimous panel accept of author defense.
  • Added CRF-37 (P2), CRF-38 (P2), CRF-39..CRF-47 (P3), CRF-48..CRF-55 (Note/Nit).

Reviewed against fd09112..3605da1.

Round 5

Churn guard: PROCEED. History rewritten during rebase (fd09112e28..3605da13d0 collapsed into 5d029f59c2 feat: add agent memory database layer + 30d3f2aba1 fix(coderd): enforce memory insert isolation and lock user soft-delete guards); migration renumbered 000580 -> 000585; new 000586_lock_user_soft_delete_guards. Author fixed 18 of 21 open findings and contested 3 (CRF-39, CRF-40, CRF-43). CRF-22 explicitly resolved in-PR (supersedes issue #28538); CRF-29 remains closed.

Reviewed against b428c8f..30d3f2a.

Law: Split (Mandatory). Extract 000586_lock_user_soft_delete_guards + user_soft_delete_guards_test.go. Effective LOC grew from 1350 (R1) to 2125 (R5); the new migration is fully independent of the memory work (zero code coupling except a 15-line test helper), it runs an irreversible DELETE FROM user_secrets/user_skills WHERE user_id IN (SELECT id FROM users WHERE deleted), and it changes locking behavior on shipped triggers on every existing deployment. Bundled under a title that names only the feature.

Netero: 1 P2 (CRF-57), 2 P3 (CRF-44 re-raise, CRF-56), 2 Nit (CRF-58, CRF-59), 2 Note (CRF-60, CRF-61).

Per skill: Law Mandatory-Split + Netero P2 -> REQUEST_CHANGES with Netero findings + Law's split proposal. Skip panel. Round ends.

Tool overrode skip-panel: the deep-review CLI blocks REQUEST_CHANGES/COMMENT posts on any round after a prior panel round unless the current round also has a panel. Ran a minimal 5-person panel (Bisky, Hisoka, Knuckle, Takumi, Zoro) to satisfy the CLI. Panel converged:

  • Hisoka raised P1 CRF-62: the class fix is incomplete. insert_apikey_fail_if_user_deleted and insert_user_links_fail_if_user_deleted have the identical unlocked guard; the api_keys race resurrects a working session token on a soft-deleted user (active status, site rbac_roles intact, cleanup wipes org memberships only). Reproduced on PostgreSQL 13.21. Knuckle independently reproduced and framed at P2; highest severity wins.
  • Takumi sharpened CRF-57: the retrofit creates a deadlock cycle on user_skills (T1 UPDATE user_skills locks tuple + waits for users; T2 UPDATE users deleted=true holds users + AFTER trigger cleanup DELETE waits for tuple). 40P01, no retry in coderd. Consequence upgrade from contention to aborted transaction. Hisoka narrowed scope: user_secrets UPDATE-path lock was pre-existing via trigger_user_secrets_per_user_limits.
  • Takumi re-raised CRF-8 at P3: BEFORE INSERT trigger's IF NOT FOUND THEN RETURN NEW fails open under an uncommitted-parent race with the FK's later RI check. Bounded consequence, invariant skipped.
  • Bisky added CRF-63 (backfill DELETE has no test), CRF-64 (List-side empty prefix no witness), CRF-66 (user_secret guard error contract asymmetric).
  • Knuckle added CRF-65 (READ COMMITTED contract missing from sqlc-lifted query doc).
  • Zoro added CRF-67..CRF-71 (dead ChatProvider setup, memory package doc misnames test, path-size arithmetic idiom, owner-role rationale duplicated, IsCheckViolation not used).
  • Bisky added CRF-72 Note (ResourceRequiresOrgID[ChatMemory] latent panic).
  • Contested from R4 closed: CRF-39 (3/3), CRF-40 (3/3, Knuckle Note on defense reasoning), CRF-43 (3/3).

R5 posted event: REQUEST_CHANGES (P1 present).

Round 6

Churn guard: BLOCKED. Head moved to 4048a4d85d after author acted on Law's mandatory-split verdict: soft-delete guard work extracted as PR #28546 (fix(coderd/database): lock parent user row in soft-delete guards, base commit bcd0d53ac5), memory migration renumbered 000585 -> 000586. 18 of 19 R5-open findings addressed (CRF-44, CRF-56 through CRF-72 all landed, split included). CRF-8 (P3 re-raise from R5) silent: no reply on thread PRRC_kwDOGkVX1s7lnmAm, code at 000586_agent_memories.up.sql:80,138 unchanged.

CI failure: 21 failed on the current head. Not evaluated further per BLOCKED gate. No panel, no Netero. Reviewed against bcd0d53..4048a4d.

Round 7

Churn guard: PROCEED. Head 4048a4d85d -> 68fdbfc389; base moved to 39171c5c65 (base PR #28546 extension: guard set expanded to user_ai_provider_keys and organization_members, and pre-existing enforce_user_secrets_per_user_limits cap trigger lock gated to INSERT only). CRF-8 addressed in-PR: both enforce_*_insert_invariants triggers now RAISE EXCEPTION with ERRCODE='check_violation' on NOT FOUND, with MissingUserRejected / MissingChatRejected witness tests. 1 of 1 open finding closed.

Law: not spawned. Effective additions dropped from 2125 (R5 last analysis) to 1884 in this round, below the +500 growth threshold.

Netero: 1 P3 (CRF-73, the CRF-8 fix's constraint-name defect). Convention/dead-code/build sections clean.

Panel: 4 reviewers (Bisky, Hisoka, Takumi, Razor wildcard). Actions:

  • CRF-73 P3 convergence: Netero P3, Razor P3, Takumi Nit. Highest wins; three independent identifications.
  • CRF-74 P3 new: memory triggers create a deadlock cycle for two-step transactions (UPDATE/DELETE then INSERT) against soft-delete cleanup and cascade DELETE. Independently reproduced by Hisoka and Takumi on PostgreSQL 13.21. Razor's contradicting note ("no new deadlock cycle") missed the interleaving; empirical wins.
  • CRF-75 P3 new: trigger BEFORE INSERT scope is unwitnessed; widening to INSERT OR UPDATE leaves the suite green. Bisky verified with mutation testing.
  • CRF-76 P3 new: SubagentChatRejected only exercises the first leg of a two-part guard; deleting the second leg still passes all subtests. Bisky verified.
  • CRF-77 Note new: surviving subagent chats promote to root via ON DELETE SET NULL on the FKs, distinct mechanism from CRF-9/CRF-54.
  • CRF-78 Note new: CRF-8 fail-closed comment cites the wrong mechanism (FK does not wait, it fails immediately; the real justification is snapshot re-read between BEFORE trigger and RI check).
  • CRF-79 Nit new: isolation comment describes REPEATABLE READ path made unreachable by the CRF-28 guard above it.
  • CRF-80 Note new: RepeatableReadRejected pins the rejected level, nothing pins the allowed SERIALIZABLE path.

Reviewed against 39171c5..68fdbfc.

Highest severity: P3. Post as COMMENT.

Round 8

Churn guard: PROCEED. Head 68fdbfc389 -> 6671f58ce2; base moved to e87dccbc15. Two commits on branch (34c0ad1d84 feat: add agent memory database layer, 6671f58ce2 fix(coderd): renumber agent memories migration to 000588); migration renumbered 000586 -> 000588. All 8 open R7 findings addressed with real witnesses (Netero and multiple panel members mutation-verified CRF-75 and CRF-76 fixes).

Law: not spawned. Effective additions 2133, delta from R5 last analysis (2125) below the +500 growth threshold.

Netero: 1 P3 (CRF-81, UPDATE-path owner-column reassignment gap; empirically verified 101 cap overshoot and subagent-namespaced rows via bare UPDATE user_memories/UPDATE chat_memories). 1 Nit and 2 Notes on the shared harness and pkg doc pinning.

Panel: 5 reviewers (Bisky, Hisoka, Takumi, Zoro, Razor). Actions:

  • CRF-81 (Netero P3): held; no panel contradiction.
  • CRF-82 P3 new (Bisky): SET lock_timeout='2s' leaks on pooled connection under pq. Empirically probed with four fresh handles reporting the tainted value. Latent flake vector for six race subtests.
  • CRF-83 P3 new (Bisky): CRF-76 fix witnessed only the root-only leg; deleting the parent leg passes all TestChatMemories. Mutation verified. Recut both encoding subtests into a table over (parentSet, rootSet).
  • CRF-84 P4 new (Bisky): SoftDeleteWinsConcurrentInsert runs READ COMMITTED only, so the CRF-79/CRF-80 comment claim about SERIALIZABLE has no witness on the soft-delete branch. Advisory.
  • CRF-85 P3 new (Hisoka): CRF-28/CRF-61/CRF-80 comment/trigger blesses SERIALIZABLE on an SSI argument that only holds when every writer is also SERIALIZABLE; a SERIALIZABLE inserter vs READ COMMITTED inserter walks past the cap. Reproduced 101 memories on head schema. Fix: replace blocklist with allowlist of read committed only. Same defect on chat twin.
  • CRF-86 Note new (Hisoka): guard reads a deployment-level GUC; default_transaction_isolation set at server/database/role level breaks all memory writes. Startup or migration-time assertion recommended.
  • CRF-87 P3 new (Takumi): CRF-74 ordering-contract sentence names too narrow a precondition (only "memory rows"). Any child tuple that delete_deleted_user_resources deletes triggers the same deadlock cycle, empirically verified with user_skills. Fix wording and name AcquireUserSoftDeleteGuardLock.
  • CRF-88 P3 new (Zoro, Bisky Nit, Hisoka Note, Netero Note): R8 additions bypass runLockRace and hand-roll the choreography again; harness lives in unrelated base file. Zoro's proposed extension applied and verified.
  • CRF-89 Nit (Zoro): trigger constraint names have no Go-side home in coderd/x/memory.
  • CRF-90 Nit (Razor): UpdatePathTakesNoUserLock/NoChatLock misnames (body updates content, not path).
  • CRF-91 Nit (Razor): ordering-contract advice names no callable primitive.
  • CRF-92 Note (Razor): user-side lock header omits UPDATE users contention; asymmetric with chat side.
  • CRF-93 Nit (Netero, Zoro): memory.go pkg doc pins migration number, the one coordinate this PR keeps moving.

CI red 21/21 (all workflows failing) noted by Bisky, Netero, Razor; all report local greens and characterize as a workflow-level abort rather than a code failure. Recorded, not raised as a finding.

Reviewed against e87dccb..6671f58.

Highest severity: P3 (five independent P3s: CRF-81, CRF-82, CRF-83, CRF-85, CRF-87, CRF-88). Post as COMMENT.

Round 9

Churn guard: PROCEED. Head 6671f58ce2 -> 140d4550730e; base moved to 59f5ebe4fc00 (base PR now uses a shared fail_if_user_deleted() and advisory-lock cap triggers). One new branch commit: 140d455073 fix(coderd): allowlist READ COMMITTED for memory inserts and make owners immutable (11 files, +493/-351). All 13 R8 findings addressed with real diffs; two disposition notes for the panel: CRF-84 and CRF-85 closed by removing the SERIALIZABLE allowance rather than proving it (author replaced blocklist with allowlist), and CRF-86 got an error DETAIL rather than the deployment-level assertion asked for.

Law: not spawned. Effective additions 2306, delta from R5 last analysis (2125) below the +500 growth threshold.

Netero: 2 P3 (CRF-94, CRF-95) + 1 Note.

  • CRF-94: R9 allowlist rejects READ UNCOMMITTED which PostgreSQL runs with READ COMMITTED semantics; falsifies R8 CRF-86 disposition claim that current_setting normalizes.
  • CRF-95: AcquireUserSoftDeleteGuardLock in the R9 header requires ActionUpdate on User (system-only), same authorization gap CRF-91 closed on chat side.

Panel: 5 reviewers (Bisky, Hisoka, Takumi, Robin, Meruem). Actions:

  • CRF-94 (P3): held; three-way convergence Netero+Takumi P3, Hisoka Note. Takumi empirically ran runLockRace(..., sql.LevelReadUncommitted, ...) on a widened trigger; cap held. Takumi also noted the migration comment at :61-63 has a non sequitur about coderd retries vs check_violation.
  • CRF-95 (P3): held; Netero+Hisoka independently ran the RBAC check with member subject and got forbidden (action: update).
  • CRF-96 P3 new: user_memories writes a private soft-delete guard when the base migration this stack sits on created shared fail_if_user_deleted() for exactly this class of tables; user_memories is the only cleanup-set table not using it. Copies have already diverged (memory has fail-closed NOT FOUND, shared function returns NEW). Hisoka + Meruem independently identified; Meruem verified attaching the shared guard produces the same error text.
  • CRF-97 Nit new: owner-immutability triggers use <> not IS DISTINCT FROM; NULL owner falls through to NOT NULL constraint with wrong error label. Robin + Meruem sub-observation.
  • CRF-98 Nit new: lock_timeout dedicated-Conn block written 3 times verbatim; belongs in lockrace_test.go alongside runLockRace. Robin.
  • CRF-99 P3 new: sibling cap triggers (user_skills, user_secrets) rewritten in the base still overshoot silently under REPEATABLE READ; Meruem reproduced 101 user_skills at cap 100. Recommends shared require_read_committed() helper.
  • CRF-100 Nit new: owner-immutability triggers could use WHEN clause to skip plpgsql on ordinary content edits. Meruem.
  • CRF-101 Nit new: runLockRace isolation parameter is dead (all four callers pass sql.LevelDefault after SerializableCapHolds was deleted). Bisky + Hisoka + Takumi independent identifications.
  • CRF-102 Nit new: TestAgentMemorySchemaConstants pg_get_functiondef loops re-prove what behavioral tests prove, with source-text fragility. Bisky.
  • CRF-103 Note new: cap-bypass-by-owner-reassignment hole still open on user_skills (base). Recording, out of scope for this PR.

CI at head: pending (5 passed, 15 pending, 7 skipped) at setup; workflow-level red observed in R8 appears resolved. Local greens across TestUserMemories, TestChatMemories, TestAgentMemorySchemaConstants, TestSoftDeleteGuard*, TestMethodTestSuite, TestMigrate*, TestAuditLogIsResourceDeleted, rbac and audit test suites.

Reviewed against 59f5ebe..140d455.

Highest severity: P3 (five independent P3s: CRF-94, CRF-95, CRF-96, CRF-99, plus wildcards). Post as COMMENT.

Round 10

Churn guard: PROCEED. Head 140d4550730e -> cd4fa1cf3099; base moved 59f5ebe4fc00 -> c5e2ac38ef. History rewritten again: branch is now two commits (b5080f1d74 feat: add agent memory database foundation, cd4fa1cf30 docs(coderd/database): state the true isolation allowlist in memory query headers). Migration renumbered 000588 -> 000592. Class work moved into base stack: #28546 landed guards, #28870 landed advisory-lock caps. 8 R9 findings addressed, 1 acknowledged (CRF-103, closed on base), 1 contested (CRF-99, sibling-wiring half declined with dbcrypt-availability argument).

Law: not spawned. Effective additions 2339, delta from R5 last analysis (2125) below the +500 growth threshold.

Netero: 3 P3 (CRF-104 trigger order regression, CRF-105 harness duplication, CRF-106 stale doc initially P3 downgraded to Nit) + 3 Nit.

  • CRF-104: user-side isolation gate fires after the shared guard's users-lock, so REPEATABLE READ inserts die with 40001 not user_memory_insert_isolation. Chat side clean.
  • CRF-105: runIsolationLockRace is 53-line verbatim copy of runLockRace; R9 CRF-101 fix cloned the harness rather than parameterizing it.

Panel: 5 reviewers (Bisky, Hisoka, Takumi, Knuckle, Zoro). Fresh eyes on contested CRF-99. Actions:

  • CRF-104 (P3): four-way convergence (Netero + Hisoka + Takumi + Zoro), all reproduced independently on PostgreSQL 13.21.
  • CRF-105 (P3): three-way convergence (Netero + Bisky + Zoro). Bisky and Zoro applied the fix in worktree; both verified passing with net -53 lines.
  • CRF-106 (Nit): four-way convergence but three independent Nit assessments vs Netero P3. Zero behavioral impact; kept Nit on merits.
  • CRF-107 (P3, Bisky): UpdateTakesNoUserLock stopped witnessing user-side triggers' INSERT-only scope after CRF-96 shared-guard swap. Cap trigger widened to INSERT OR UPDATE goes green under existing tests. This is CRF-75 (R7 closed R8) reopening: R10 CRF-96 rework moved the lock out from under the test that closed it.
  • CRF-108 (P3, Bisky): OwnerImmutable asserts reassignment shape only; NULL shape the R10 WHEN clause was written for has no witness.
  • CRF-109 (P3, Hisoka; Note Knuckle): user_memories joins delete_deleted_user_resources trigger-half but is missing from PurgeSoftDeletedUserResources reaper-half. Two lists that define the guarded-table class now disagree; class invariant unwritten.
  • CRF-110 Nit (Knuckle): reasoning for require_read_committed and cap functions sits above CREATE FUNCTION, doesn't survive into dump.sql, against 000591/000590 convention.
  • CRF-111 Nit (Netero): lock-ordering contract attribution list is wrong on two tables.
  • CRF-112 Nit (Zoro): ReadUncommittedCapHolds hardcodes 99/100 not memory.MaxUserMemoriesPerUser.
  • CRF-113 Nit (Zoro): five DROP TRIGGER statements in down migration are redundant (DROP TABLE removes them).
  • CRF-114 Nit (Zoro): MissingChatRejected comment states rejected FK mechanism (CRF-78 regression in test file).
  • CRF-115 Note (Zoro): user_memories is the only guarded table with INSERT-only soft-delete guard; comment covers half the divergence.
  • CRF-116 Nit (Netero): parenthetical describes UPDATE branch that doesn't fire on user_memories.
  • CRF-117 Nit (Netero): require_read_committed tab-indented in space-indented file.

CRF-99 disposition (contested at R9, panel-evaluated R10): closed by unanimous panel accept (4/4 who evaluated: Hisoka, Takumi, Knuckle, Zoro). Author's dbcrypt argument verified end-to-end: enterprise/dbcrypt/cliutil.go:88,304 runs UpdateUserSecretByUserIDAndName inside InTx(..., Isolation: sql.LevelRepeatableRead); wiring the gate into enforce_user_secrets_per_user_limits would fail coder server dbcrypt rotate on the first secret with a non-retryable check_violation. Split behavior documented at 000590:25-36 and 000592:92-98. Bounded cap slip on user_skills/user_secrets accepted as tolerable trade-off vs breaking a shipped admin operation.

Reviewed against c5e2ac3..cd4fa1c.

Highest severity: P3 (five independent P3s: CRF-104, CRF-105, CRF-107, CRF-108, CRF-109). Post as COMMENT.

Round 11

Churn guard: PROCEED. Head cd4fa1cf3099 -> 8c89c76135; base unchanged at db94229361. One new branch commit: 8c89c76135 fix(coderd): gate memory isolation before the guard lock and reap orphaned memories. All 14 R10 findings (CRF-104 through CRF-117) addressed with real diffs; CRF-99 row corrected to reflect R10 panel closure (was stale as "contested" in the inventory). No R10 findings re-raised.

Law: not spawned. Effective additions 2410, delta from R5 last analysis (2125) below the +500 growth threshold.

Netero: no findings. Verified trigger_aa_user_memories_require_read_committed sorts before shared guard and GateBeforeGuardLock is a real ordering witness (renaming so aa_ sorts after guard flips the subtest red with pq: could not serialize access due to concurrent update), runIsolationLockRace deleted and runLockRace regained isolation parameter with all seven callsites routed through the one harness, UpdateAtCapAllowed catches CRF-75 cap-widening mutation, NULL owner-immutability assertions close the R10 WHEN (... IS DISTINCT FROM ...) shape, delete_user_memories added to PurgeSoftDeletedUserResources with guardedTables extended. Local greens on go build ./..., go vet ./coderd/database/... ./coderd/x/memory/, memory suites against PostgreSQL. CI failures at head unchanged in shape from R10 (22 failed / 18 passed / 1 pending / 9 skipped), consistent with a pipeline-level issue rather than any code regression, not claimed as proof.

Panel: 4 reviewers (Bisky, Hisoka, Zoro, Meruem). Actions:

  • Bisky: no findings. Independently verified GateBeforeGuardLock is a real ordering witness by renaming aa_ so it sorts after the shared guard (subtest goes red with pq: could not serialize access due to concurrent update), UpdateAtCapAllowed catches CRF-75 (widening the cap trigger flips the content edit at cap red), NULL branch of OwnerImmutable closes the R10 WHEN shape, ReadUncommittedAccepted / ReadUncommittedCapHolds pin CRF-94, dbpurge extension closes the reaper half of CRF-109, and runLockRace regained isolation parameter with all seven callsites routed through the one harness. Also confirmed chat side doesn't need UpdateAtCapAllowed twin because UpdateTakesNoChatLock catches the same widening mutation.
  • Zoro: no findings.
  • CRF-118 (Note, Hisoka): chat-side enforce_chat_memories_insert_invariants calls the isolation gate before the chats-row lock inside one function body, but no test pins that intra-function ordering. Migration header (000592:130-133) dismisses the need for a separate chat-side test on that basis. Verified: grep GateBeforeChatLock returns nothing; NonReadCommittedRejected chat-side subtest at :949 tests uncontended rows only. Same construction as user-side GateBeforeGuardLock witness applies against chats.
  • CRF-119 (Note, Meruem): rationale at 000592:158-159 claims dropping trigger_aa_... is safe because the cap re-PERFORMs require_read_committed. Verified false on contended path: name-sort order puts trigger_insert_user_memories (shared guard) between aa_ and zz_, so dropping aa_ makes the guard's FOR NO KEY UPDATE on users raise 40001 under REPEATABLE READ before the cap re-check runs. 000591:81-88 documents this exact failure mode. Comment invites a later reader to silently reopen the R10 regression closed this round.
  • CRF-120 (Nit, Hisoka): R11 commit body says "witness UpdateAtCapAllowed on both tables"; grep -c UpdateAtCapAllowed coderd/database/agent_memories_test.go returns 1 (only user side). Chat-side cap widening is covered behaviorally by UpdateTakesNoChatLock, so no coverage gap, but commit body is out of step with the code.

Reviewed against db94229..8c89c76.

Highest severity: Note (2 independent Notes: CRF-118, CRF-119; plus 1 Nit CRF-120). Post as COMMENT.

Round 12

Churn guard: BLOCKED. Head 8c89c76135 -> 64f6c450b1; base moved db94229361 -> dd747c862a (trunk absorbed unrelated work and took 000590 for workspace_agent_session_counts, base stack renumbered caps 000589 -> 000591 and guards -> 000592). Branch delta is one commit 64f6c450b1 chore(coderd): renumber agent memory migration to 000593; git diff to R11 head is 6 files, +6/-6, all migration/fixture rename and citation syncs (agent_memories_test.go:509, dump.sql:1679, 000593_agent_memories.up.sql:50,77,106, coderd/x/memory/memory.go:17). The R11 fix commit is rebased and renamed to afa4b03d8b; its body is byte-for-byte identical, still says "witness UpdateAtCapAllowed and the NULL owner-immutability shape on both tables". Author's rebase note explicitly reports "No behavior change since Round 12" [author's numbering].

All three R11 findings verified silent at head:

  • CRF-118: grep -Rn GateBeforeChatLock coderd/database/ returns nothing; migration header at 000593:130-133 still cites the user-side test as the pin.
  • CRF-119: grep -n 'self-contained if that trigger is ever dropped' 000593_agent_memories.up.sql returns line 159. Trigger name-sort still puts trigger_insert_user_memories between aa_ and zz_.
  • CRF-120: grep -Rn 'UpdateAtCapAllowed' agent_memories_test.go returns one match (user side); the rebased commit body still claims both tables.

Law: not spawned. Effective additions 2410 unchanged from R11.

No panel run. Body-only COMMENT that names the three silent findings and asks for a fix, defense, or explicit accept-as-drop before I resume. Same shape as R3 and R6.

CI at head: 21 failed / 4 passed / 16 pending / 9 skipped. Shape unchanged from R11.

Reviewed against dd747c8..64f6c45.

Highest severity: (no new findings). Post as body-only COMMENT.

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.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 61544d7846

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@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.

Strong PR overall. Two mirror tables, cap and soft-delete-race invariants pushed into triggers rather than into Go, RBAC/audit/scope surface plumbed end to end for user_memory, and a real second-connection concurrency test (SoftDeleteWinsConcurrentInsert) that holds FOR UPDATE on users in one transaction, polls pg_stat_activity until the concurrent insert is blocked on that specific lock, then commits the soft delete and asserts the insert fails with the named constraint. Migration comments explain the non-obvious pieces at the point of use. Pariston walked the diagnostic panel and said it plainly: "I tried to build a case that the diagnosis was wrong, that the solution was oversized, or that the fix intervened at the wrong causal depth, and I could not."

Round 1 findings: 1 P2, 8 P3, 8 Nit, 9 Note.

The P2 is the chat_memory audit gap. Five reviewers converged from different angles: UserMemory is wired into Auditable, AuditActionMap, auditableResourcesTypes with content masked as ActionSecret, plus the user_memory resource_type enum value. ChatMemory is in none of them. Any actor holding chat:update on a root chat can create, rewrite, or delete markdown documents that shape agent context, and no audit event fires anywhere. The parent chat's audit ignores updated_at and every content-adjacent field. Either mirror UserMemory on the audit surface, or state the omission in the migration and PR body.

P3 cluster:

  • FOR UPDATE on the parent row at all three trigger sites is stronger than needed for cap serialization and conflicts with the FOR KEY SHARE that FK checks take on child inserts. While a memory insert is in flight, chat_messages, user_secrets, and every other FK-child insert against that parent stalls. FOR NO KEY UPDATE preserves the cap/soft-delete guarantees without the cross-subsystem lock coupling.
  • trigger_upsert_user_memories fires on BEFORE INSERT OR UPDATE; the soft-delete guard only needs BEFORE INSERT. Every content edit and rename currently locks the users row for no correctness gain.
  • trigger_upsert_user_memories, function insert_user_memory_fail_if_user_deleted, and exception message 'Cannot create user_memory for deleted user' all describe an insert-only path attached to an INSERT-OR-UPDATE trigger.
  • Melody caught a paired-switch gap: auditLogIsResourceDeleted has no ResourceTypeUserMemory case, so deleted memories render as still existing in the audit UI.
  • Bisky pinned the missing concurrency proof for the per-user cap: PerUserLimit exercises the cap sequentially, which passes with or without the FOR UPDATE lock. The scaffolding for the missing test is already in SoftDeleteWinsConcurrentInsert.
  • Two structural correctness concerns on chat_memories: the trigger silently skips the root-chat check when NEW.root_chat_id refers to a non-existent chat (leans on the FK), and chats.parent_chat_id/root_chat_id immutability is convention-only. A future migration that promotes or demotes a chat with memories would silently break the root-only invariant, and per the audit gap above the violation would leave no trace.

Empty-prefix wipe on DeleteUserMemoriesByUserIDAndPathPrefix and its chat twin: starts_with(path, '') is unconditionally true, so a zero-value PathPrefix deletes every memory the owner has. The foundation PR bakes this trap in for every future caller.

Nits are largely naming and messaging drift (authorizeChatMemoryUpdate used for Insert/Update/Rename/Delete, restating comments, RAISE EXCEPTION messages that name neither the user nor the cap) and the test-coverage asymmetry between TestUserMemories (12 invalid-path cases, ContentSizeRejected) and TestChatMemories (2 invalid-path cases, no content-size test).

Notes cover follow-ups and observations: the same insert-vs-soft-delete race is still open in the sibling user_secrets and user_skills triggers (worth a follow-up ticket now that the correct pattern lives here once); a preview truncation without an is_truncated/octet_length signal; RETURNING * on prefix delete ships full content back; trigger error precedence depends on alphabetical name ordering; the chat fixture's latent breakage if a subagent chat is ever seeded earlier; and the starts_with byte-prefix vs directory-prefix ambiguity.

Process: TestUserMemories/SoftDeleteWinsConcurrentInsert is the kind of test that actually proves a concurrency claim. Please keep that pattern for the missing per-user-cap variant and, where cheap, for PerRootChatLimit.


coderd/audit.go:406

P3 [CRF-6] auditLogIsResourceDeleted has no case for ResourceTypeUserMemory, so audit rows for deleted memories always report IsDeleted: false. (Melody)

Enumerated side: this PR adds database.UserMemory to the Auditable union (+coderd/audit/diff.go:47) and adds cases for it in ResourceID, ResourceType, ResourceTarget, and ResourceRequiresOrgID in coderd/audit/request.go. It also adds ResourceTypeUserMemory = "user_memory" and appends the same value to the postgres resource_type enum.

Paired filter side: coderd/audit.go:406 auditLogIsResourceDeleted is a switch on alog.AuditLog.ResourceType. case database.ResourceTypeUserSecret lives at line 518 and fetches the secret to distinguish deleted rows; there is no case for ResourceTypeUserMemory. The default arm at line 531 returns false. The caller at coderd/audit.go:254 sets alog.IsDeleted from that return value and the response ships that flag to the frontend.

User memory rows are deletable both directly (DeleteUserMemoryByUserIDAndPath, DeleteUserMemoriesByUserIDAndPathPrefix) and transitively via the extended delete_deleted_user_resources() trigger this migration installs. Audit rows persist after those deletions. Consequence: the audit UI shows every user memory as still existing even once it has been purged from the database.

Add a case for ResourceTypeUserMemory mirroring the UserSecret case: fetch by ID, treat sql.ErrNoRows as deleted. auditLogResourceLink at coderd/audit.go:632 also lacks a case, but that mirrors UserSecret's own TODO(PLAT-102) placeholder rather than being wrong; the deleted-flag gap is the shippable-now bug.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread enterprise/audit/table.go
Comment thread coderd/database/queries/user_memories.sql Outdated
Comment thread coderd/database/migrations/000580_agent_memories.up.sql Outdated
Comment thread coderd/database/migrations/000580_agent_memories.up.sql Outdated
Comment thread coderd/database/migrations/000580_agent_memories.up.sql Outdated
Comment thread coderd/database/migrations/000580_agent_memories.up.sql Outdated
Comment thread coderd/database/queries/user_memories.sql
Comment thread coderd/database/agent_memories_test.go
Comment thread coderd/database/migrations/000593_agent_memories.up.sql
Comment thread coderd/database/migrations/000580_agent_memories.up.sql Outdated
@ThomasK33
ThomasK33 force-pushed the feat-agent-memory-database branch from 61544d7 to a226177 Compare August 21, 2026 00:49
@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.

R2 lands the R1 fix cleanly. chat_memory is now wired into every audit dispatch site (Auditable, AuditActionMap, auditableResourcesTypes with content as ActionSecret, resource_type enum, auditLogIsResourceDeleted, codersdk, TS types). Both parent-row locks moved from FOR UPDATE to FOR NO KEY UPDATE, so cap serialization and soft-delete guarantees hold without conflicting with FK FOR KEY SHARE on sibling child tables. The two BEFORE INSERT triggers on user_memories collapsed into enforce_user_memories_insert_invariants, deleting the alphabetical-ordering ambiguity from CRF-20 rather than papering over it. TestUserMemories/ConcurrentInsertPerUserLimit uses pg_stat_activity to observe the second insert blocking on the users row lock, so the lock is now load-bearing in the test suite, not just the code.

Churn guard verdict: PROCEED. 18 of 26 R1 findings addressed; 8 contested.

Of the 8 contested findings, the panel closed 7 by verifying the defense against the current tree:

  • CRF-9 (chats hierarchy immutability): no query in coderd/database/queries/chats.sql writes parent_chat_id or root_chat_id; ON DELETE SET NULL only promotes a subagent to root, never the other way. Convention holds today.
  • CRF-11 (character vs byte slice): left(text, n) guarantees valid UTF-8; the 64 KiB storage cap remains byte-based via octet_length.
  • CRF-17 (shared cap-trigger helper): the R2 body diverged from user_skills (adds deleted check, uses FOR NO KEY UPDATE), so sharing would couple two invariants.
  • CRF-18 (truncation signal): content_prefix is a named projection distinct from content; full-document callers use Get*ByID / Get*ByPath.
  • CRF-19 (RETURNING * bandwidth): worst case 6.4 MiB per call is the intended cost of per-resource audit records; see CRF-36 below for a narrower slice suggestion.
  • CRF-23 (byte-prefix vs directory-prefix): the DB primitive is honestly named; directory semantics live in the API/tool path parser landing next.
  • CRF-26 (commit message): history-only; final squashed commit inherits the PR body.

One re-raise on a contested finding:

  • CRF-22 (sibling triggers race in user_secrets and user_skills): the pattern this PR proved for user_memories is still absent in both siblings. Both trigger bodies carry comments claiming they "close the window between an in-flight Create request and the soft-delete UPDATE committing," and neither takes any row lock, so the claim is demonstrably false. Author declined the drive-by fix (correct on scope) but linked no follow-up ticket. Per the review rules a deferral without a ticket is a drop, not a deferral. As Knuckle put it: "Bankruptcy is not one migration away; it is one soft-delete-plus-insert race away, and the race exists in production today." This needs a human decision: file a follow-up ticket that names both sibling triggers and links back to this PR's enforce_user_memories_insert_invariants as the fix pattern, or explicitly accept the drop.

One partial fix:

  • CRF-7 (concurrency proof): the user-side ConcurrentInsertPerUserLimit verifies the FOR NO KEY UPDATE lock, but the chat side (agent_memories_test.go:663) still runs PerRootChatLimit sequentially. R1 explicitly asked for both. If a future refactor drops FOR NO KEY UPDATE from enforce_chat_memories_insert_invariants, the sequential test still passes.

R2 new findings: 0 P0-P2, 1 P3 (CRF-7 re-raise), 6 Nit, 5 Note. See inline comments.

Dismissing the R1 REQUEST_CHANGES since the P2 and every P3 raised in R1 are addressed. The re-raised CRF-7 partial and the seven new Nits/Notes should not block merge on their own; CRF-22's follow-up ticket is the one item that needs a human on it before or after merge.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/audit.go
Comment thread coderd/database/migrations/000588_agent_memories.up.sql Outdated
Comment thread coderd/database/dbauthz/dbauthz.go
Comment thread coderd/database/dbauthz/dbauthz.go Outdated
Comment thread coderd/database/queries/user_memories.sql
Comment thread coderd/database/migrations/000593_agent_memories.up.sql
Comment thread coderd/database/agent_memories_test.go Outdated
Comment thread coderd/database/migrations/000580_agent_memories.up.sql Outdated
Comment thread coderd/database/agent_memories_test.go Outdated
Comment thread coderd/database/queries/user_memories.sql
@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.

Blocked. R3 commit 3605da13d0 addressed 10 of 12 open findings from R2, but one silent finding blocks further panel review.

Silent (blocking):

  • CRF-22 (Note): the R2 panel re-raised the sibling insert-vs-soft-delete race in user_secrets and user_skills asking for either a linked follow-up ticket or an explicit accept of the drop. No code change to 000490_trigger_delete_user_secrets.up.sql or 000502_user_skills.up.sql. No reply on the thread after the R2 panel note. No issue linked anywhere in the PR. A deferral without a tracker entry is a drop, not a deferral, and this one needs a human decision before the panel spends another round.

Request: file a follow-up issue that names both insert_user_secret_fail_if_user_deleted and insert_user_skill_fail_if_user_deleted, links back to enforce_user_memories_insert_invariants as the reference implementation, and either paste the link on the CRF-22 thread or reply explicitly accepting the drop. Then I will resume.

Contested (still open):

  • CRF-29 (Note): authorizeChatMemoryMutation generalization declined as diff-scope. Recorded; panel does not need to press.

Addressed in R3 (10): CRF-7 (ConcurrentInsertPerRootChatLimit added, exercises the chat-side FOR NO KEY UPDATE), CRF-27 (audit branches carry the IsNotAuthorizedError comment), CRF-28 (READ COMMITTED isolation named at both triggers), CRF-30 (redundant callsite comment removed), CRF-31 (4096 character budget documented at both queries), CRF-32 (variable naming symmetric), CRF-33 (restating comment removed), CRF-34 (misplaced scope-block comment rewritten), CRF-35 (test now uses FOR NO KEY UPDATE matching production), CRF-36 (prefix deletes narrowed to RETURNING id, path, ... with generated row types propagated).

Not re-running the panel this round. Post the CRF-22 ticket or accept and I will resume.

🤖 This review was automatically generated with Coder Agents.

@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.

R3 addressed 10 findings from R2, then closed CRF-28 with a schema comment only. The comment is enforced by nothing, and misdescribes what it guards. Verified against coderd/database/tx.go:41 (database.ReadModifyUpdate hardcodes sql.LevelRepeatableRead), coderd/x/chatd/chatd.go:545, coderd/x/chatd/context_hydration.go:111, coderd/x/chatd/context_hydration.go:259, and an empirical two-transaction reproduction on PostgreSQL 13.21 (the same image coder-test-postgres runs). Under REPEATABLE READ the second transaction's snapshot predates the first's commit; the parent FOR NO KEY UPDATE does not refresh it, so both inserts see the same pre-cap count and commit past the cap. Soft-delete under the same isolation is safe (the deleter actually UPDATEs the parent row, Postgres raises 40001, database.ReadModifyUpdate retries), but the count cap is exposed. Re-raising CRF-28 at P2.

The CRF-21 fix on the chat memory migration fixture kept the WHERE filter but dropped the loud failure mode: zero matching root chats now inserts zero rows silently, so chat_memories can miss migration coverage entirely. Re-raising CRF-21 at P3 with a scalar subquery in VALUES so no match becomes a NOT NULL violation.

Churn guard: PROCEED. No commits since R3, head still 3605da13d0. Only change is the author reply on CRF-22 linking #28538, so CRF-22 is deferred and out of scope.

CRF-29 (authorizeChatMemoryMutation generalization): closed. Panel accepted the defense. Runtime authorization is identical either way; generalizing would touch roughly nine unrelated chat mutation paths in a database-foundation PR at maintenance-only benefit.

R4 severity counts: 2 new P2 (CRF-37 chat_memories comment vs auth mismatch, CRF-38 nonexistent agent-memory experiment claim in PR body), 2 re-raise (CRF-28 P2, CRF-21 P3), 9 P3 new (path casing collision, chats row lock reach, list COLLATE, magic-number ownership, owner role reads private content, three test-coverage gaps, harness duplication), 2 Nit, 6 Note.

A test that passes for the wrong reason is still a test that passes.

Not blocking merge on any single finding, but CRF-28 is the one to fix mechanically before any memory-write endpoint lands on this schema.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/migrations/000580_agent_memories.up.sql Outdated
Comment thread coderd/rbac/scopes_catalog.go
Comment thread coderd/database/migrations/000593_agent_memories.up.sql
Comment thread coderd/database/migrations/000593_agent_memories.up.sql
Comment thread coderd/database/queries/user_memories.sql Outdated
Comment thread coderd/database/queries/user_memories.sql
Comment thread coderd/database/queries/chat_memories.sql
Comment thread coderd/database/queries/chat_memories.sql
Comment thread coderd/database/migrations/000593_agent_memories.up.sql
Comment thread coderd/database/migrations/000593_agent_memories.up.sql
ThomasK33 added a commit that referenced this pull request Aug 25, 2026
…e guards

Address coder-agents-review R4 on #28423:

- Reject REPEATABLE READ in the memory insert triggers: the parent-row
  lock does not refresh the snapshot, so the count caps could silently
  overshoot (CRF-28). READ COMMITTED re-counts correctly and a
  SERIALIZABLE waiter fails with 40001.
- Take FOR NO KEY UPDATE in the user_secrets and user_skills
  soft-delete guards and clean up rows resurrected by the race
  (CRF-22, supersedes #28538), with deterministic regression tests.
- Pin memory list ordering with COLLATE "C" so results are stable
  across database collations (CRF-41).
- Add coderd/x/memory as the Go owner of the schema limits and assert
  the live schema against it (CRF-42, CRF-44, CRF-45).
- Align table comments with the enforced authorization and lifecycle
  semantics (CRF-37, CRF-43, CRF-48, CRF-54) and document empty-prefix,
  upsert, and root-chat-ID query semantics (CRF-51, CRF-52, CRF-53).
- Make the chat memory migration fixture fail loudly when no root chat
  fixture exists (CRF-21); assert updated_at advancement, case-sensitive
  paths, and the ASCII-only path decision (CRF-39, CRF-46, CRF-55).
@ThomasK33
ThomasK33 force-pushed the feat-agent-memory-database branch from 3605da1 to 781b6be Compare August 25, 2026 10:17
ThomasK33 added a commit that referenced this pull request Aug 25, 2026
…e guards

Address coder-agents-review R4 on #28423:

- Reject REPEATABLE READ in the memory insert triggers: the parent-row
  lock does not refresh the snapshot, so the count caps could silently
  overshoot (CRF-28). READ COMMITTED re-counts correctly and a
  SERIALIZABLE waiter fails with 40001.
- Take FOR NO KEY UPDATE in the user_secrets and user_skills
  soft-delete guards and clean up rows resurrected by the race
  (CRF-22, supersedes #28538), with deterministic regression tests.
- Pin memory list ordering with COLLATE "C" so results are stable
  across database collations (CRF-41).
- Add coderd/x/memory as the Go owner of the schema limits and assert
  the live schema against it (CRF-42, CRF-44, CRF-45).
- Align table comments with the enforced authorization and lifecycle
  semantics (CRF-37, CRF-43, CRF-48, CRF-54) and document empty-prefix,
  upsert, and root-chat-ID query semantics (CRF-51, CRF-52, CRF-53).
- Make the chat memory migration fixture fail loudly when no root chat
  fixture exists (CRF-21); assert updated_at advancement, case-sensitive
  paths, and the ASCII-only path decision (CRF-39, CRF-46, CRF-55).
@ThomasK33
ThomasK33 force-pushed the feat-agent-memory-database branch from 781b6be to 30d3f2a Compare August 25, 2026 10:20
@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.

The memory work landed the R4 findings clean: enforce_*_insert_invariants now rejects REPEATABLE READ outright with named check_violation constraints and RepeatableReadRejected subtests on both tables, coderd/x/memory owns the schema limits with TestAgentMemorySchemaConstants walking pg_get_functiondef / pg_get_constraintdef against them, four list queries pin ORDER BY path COLLATE "C" ASC with CaseSensitivePaths making the collation choice load-bearing, and waitForBackendBlocked is the shared harness across four concurrency tests. 18 of 21 R4-open findings closed; the three contested (CRF-39, CRF-40, CRF-43) are now panel-closed on unanimous accept (3/3 each), with a Knuckle Note on the CRF-40 defense reasoning (only the cap needs the row lock, not the root-chat check).

Blocking on R5 findings. Highest severity is P1.

Law: Split (Mandatory). The R5 fix commit resolved CRF-22 in-PR by adding 000586_lock_user_soft_delete_guards + user_soft_delete_guards_test.go. That change is fully independent of the memory feature (zero code coupling except the 15-line waitForBackendBlocked helper). It runs a destructive one-shot cleanup on pre-existing production tables (DELETE FROM user_secrets/user_skills WHERE user_id IN (SELECT id FROM users WHERE deleted)) whose down migration does not restore, and it changes locking behavior of two shipped triggers on every existing deployment. Both risks warrant their own title, release note, and reviewer attention; they currently ship under feat: add agent memory database foundation. Extract as fix(coderd/database): lock parent user row in soft-delete guards, land against #28538, rebase the memory PR on top. The memory work (C1..C7) is one reviewable idea and does not split further; R1 Don't-split still holds.

CRF-62 (P1). The class fix stops two tables short of the class it names. insert_apikey_fail_if_user_deleted and insert_user_links_fail_if_user_deleted hold the identical unlocked SELECT deleted FROM users WHERE id = NEW.user_id LIMIT 1, and both are cleaned by the same delete_deleted_user_resources(). Independently reproduced by Hisoka and Knuckle on PostgreSQL 13.21. On api_keys the resurrected row is a live session token: deleted=t, status=active, {owner} rbac_roles intact (org roles gone because cleanup wipes organization_members). UpdateUserDeletedByID flips deleted only and leaves status; ExtractAPIKey never reads users.deleted; GetAuthorizationUserRoles filters on users.id alone. Every login opens the window (coderd/apikey.go:571), plus OAuth2 exchange/refresh. Deleting an account is precisely what an admin does while it authenticates. Extend the same FOR NO KEY UPDATE and matching backfill DELETE to both siblings. If that widens 000586 past what this PR should carry, that's an argument to make it its own PR (see Law above).

CRF-57 (P2, sharpened). The retrofit's unconditional FOR NO KEY UPDATE fires on BEFORE INSERT OR UPDATE. Hisoka narrowed: on user_secrets the UPDATE-path users-row lock is pre-existing via trigger_user_secrets_per_user_limits, so the CRF-57 contention is not new there. Takumi sharpened: on user_skills, the retrofit newly creates a full deadlock cycle against UPDATE users SET deleted = true, 40P01, no retry in coderd because IsSerializedError matches only serialization_failure. Same defect class CRF-4/CRF-5 closed for user_memories. Fix: IF TG_OP = 'INSERT' THEN FOR NO KEY UPDATE ELSE unlocked SELECT, applied to both bodies (and to the two CRF-62 siblings).

CRF-8 re-raise (P3). Takumi shows the R1 "leans on FK to reject" defense does not hold under the race window: the BEFORE INSERT trigger reads chats on the statement snapshot before the FK's RI check runs, so an uncommitted subagent chat insert lets a chat_memories row land under a subagent chat. Reproduced with an injected sleep on PostgreSQL 13.21. Bounded consequence (row in unreachable namespace) but the invariant this trigger exists to enforce is skipped. Fail closed on NOT FOUND.

R5 findings: 1 P1, 2 P2, 6 P3 (3 re-raises + 3 new), 8 Nit, 3 Note. See inline.

The class fix stops two tables short of the class, and the one it skipped mints session tokens.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/migrations/000586_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/migrations/000586_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/agent_memories_test.go
Comment thread coderd/database/migrations/000586_lock_user_soft_delete_guards.up.sql Outdated
Comment thread coderd/database/queries/user_memories.sql Outdated
Comment thread coderd/rbac/roles.go Outdated
Comment thread coderd/database/agent_memories_test.go Outdated
Comment thread coderd/database/agent_memories_test.go
Comment thread coderd/database/migrations/000585_agent_memories.up.sql Outdated
Comment thread coderd/audit/request.go Outdated
@ThomasK33
ThomasK33 force-pushed the feat-agent-memory-database branch from 30d3f2a to 4048a4d Compare August 25, 2026 11:39
@ThomasK33
ThomasK33 changed the base branch from main to fix-user-soft-delete-guards August 25, 2026 11:39
@ThomasK33

Copy link
Copy Markdown
Member Author

/coder-agents-review

@ThomasK33
ThomasK33 force-pushed the feat-agent-memory-database branch from 6671f58 to 140d455 Compare August 26, 2026 09:33
@ThomasK33

Copy link
Copy Markdown
Member Author

Round 9 pushed as 140d455 (rebased onto the base branch's round-9 head 59f5ebe), addressing all 13 round-8 findings:

  • CRF-85/CRF-86 (P3): both insert triggers now allowlist READ COMMITTED (<> 'read committed') instead of blocklisting REPEATABLE READ; the mixed-pairing SSI reasoning is gone from the comments, and the error carries a DETAIL naming the observed transaction_isolation and pointing at default_transaction_isolation for deployments that pin a non-default level. SerializableCapHolds is retired (the scenario is now unrepresentable) and NonReadCommittedRejected tables both non-default levels on both tables.
  • CRF-81 (P3): dedicated BEFORE UPDATE triggers make user_memories.user_id and chat_memories.root_chat_id immutable (user_memory_owner_immutable / chat_memory_owner_immutable), taking no parent lock so the UPDATE path stays deadlock-free; OwnerImmutable subtests witness both and the down migration drops them.
  • CRF-87/CRF-91 (P3): the lock-ordering contract on the migration header and both sqlc query headers now scopes the hazard to a lock on any row delete_deleted_user_resources deletes (listing the seven tables) and names the primitives: AcquireUserSoftDeleteGuardLock on the user side; GetChatByIDForUpdate or ChatMachine.Update via LockChatAndBumpSnapshotVersion on the chat side (LockChatByID is system-scoped).
  • CRF-88 (P4): the stmt/waitForBackendBlocked/runLockRace harness moved to coderd/database/lockrace_test.go, owned by neither feature, with an isolation parameter and racer-commit mode.
  • CRF-82 (P4): every SET lock_timeout probe registers a LIFO RESET lock_timeout cleanup so the tainted session GUC never returns to the pool (base-file sibling fixed on fix: lock parent user row in user soft-delete guards #28546).
  • CRF-83 (P4): SubagentChatRejected is one three-case table over the chat-row shape (ParentAndRoot, RootOnly, ParentOnly), so each guard leg fails a subtest on its own.
  • CRF-89 (P4): all nine trigger-raised constraint names are database.CheckConstraint constants in coderd/x/memory, each pinned against pg_get_functiondef by TestAgentMemorySchemaConstants.
  • CRF-84 (P4): isolation documentation across migration and query headers is aligned with the allowlist.
  • CRF-92 (Nit): the user-side header documents the accepted contention with ordinary users-row UPDATE statements (UpdateUserLastSeenAt, roughly once per minute per active session).
  • CRF-90/CRF-93 (Nit): tests renamed to UpdateTakesNoUserLock/UpdateTakesNoChatLock; the package doc names tables and trigger functions instead of the migration file number.

Validation: make gen/db clean (no drift), build + vet, migration fixtures, TestUserMemories/TestChatMemories/TestAgentMemorySchemaConstants, the soft-delete guard suite, and dbauthz against live PostgreSQL.

@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.

R9 closed all thirteen R8 findings with real diffs. enforce_*_owner_immutable triggers + OwnerImmutable witnesses close the UPDATE-path owner-column gap (CRF-81). RESET lock_timeout cleanups on all three sites close the session-taint (CRF-82). SubagentChatRejected recut into a three-case table over (parentSet, rootSet) covers the parent-only leg (CRF-83). SERIALIZABLE allowance removed by narrowing the isolation guard to an allowlist of one; SerializableCapHolds deleted and Serializable is now a rejected case in NonReadCommittedRejected (CRF-84, CRF-85). stmt, waitForBackendBlocked, runLockRace moved into coderd/database/lockrace_test.go owned by neither feature (CRF-88). Trigger constraint names declared in coderd/x/memory/memory.go (CRF-89). Ordering-contract wording, primitive names, error DETAIL, chat-vs-user header symmetry, and memory.go pkg doc are all updated (CRF-86, CRF-87, CRF-90 through CRF-93).

Ten new findings, none above P3.

CRF-94 (P3, Netero and Takumi P3, Hisoka Note). The allowlist rejects READ UNCOMMITTED, which PostgreSQL executes with exactly READ COMMITTED semantics. current_setting('transaction_isolation') reports the level the client asked for, not the level the server runs, so the <> 'read committed' guard rejects a level that is behaviorally safe. Reproduced independently on PostgreSQL 13.21: BEGIN ISOLATION LEVEL READ UNCOMMITTED; INSERT INTO user_memories ... fails with user_memories inserts require READ COMMITTED isolation. Takumi additionally widened the trigger to NOT IN ('read committed','read uncommitted'), seeded to N-1, and ran runLockRace(..., sql.LevelReadUncommitted, ...): the cap held at 100. Deployment risk: default_transaction_isolation='read uncommitted' on the server, database, role, or pooler loses every memory write; the R8 CRF-86 DETAIL sends the operator to fix a setting that was never unsafe. The R8 CRF-86 disposition (Hisoka Note, verified: read uncommitted normalizes in current_setting) is falsified on 13.21. Fix: IF current_setting('transaction_isolation') NOT IN ('read committed', 'read uncommitted') THEN, both triggers, and add sql.LevelReadUncommitted as an accepted case in NonReadCommittedRejected (:220, :875). While rewriting: the comment at :61-63 claims coderd retries serialization failures so an accidental caller "fails loudly here instead of corrupting the invariant", but the trigger raises check_violation not serialization_failure, so it escapes the retry loop rather than spinning in it.

CRF-95 (P3, Netero and Hisoka). The R9 ordering-contract fix names AcquireUserSoftDeleteGuardLock as the lock-first primitive; that method is not callable by a user-scoped memory caller. dbauthz.go:1827 authorizes it as policy.ActionUpdate on the fetched user (ResourceUser.WithID(id).WithOwner(id)), and the member role grants only {ActionRead, ActionReadPersonal, ActionUpdatePersonal} on user-scoped objects (roles.go:446-448, comment says "Users cannot do create/update/delete on themselves"). Both Netero and Hisoka independently ran the RBAC check with a member subject and got rbac: forbidden ... (action: update). Existing production caller confirms it: coderd/userauth.go:1773 calls it as dbauthz.AsSystemRestricted(ctx). Consequence: the service-layer PR wrapping a memory insert after the named lock call gets NotAuthorized on the ordinary member path unless it widens authorization via AsSystemRestricted, on a write path that is otherwise owner-scoped. CRF-91 on the chat side closed exactly this class by naming GetChatByIDForUpdate/LockChatAndBumpSnapshotVersion; the user-side fix is incomplete. Fix: state that AcquireUserSoftDeleteGuardLock requires a system-authorized context, or drop the name and keep the ordering rule.

CRF-96 (P3, Hisoka and Meruem). Base 000587_lock_user_soft_delete_guards.up.sql created shared fail_if_user_deleted(constraint_class, constraint_name) and pointed six guarded triggers at it "so the lock and the TG_OP gate exist in exactly one place". 000588:307 adds user_memories to the same delete_deleted_user_resources cleanup set, making it the seventh table in the class, but 000588:74-122 writes a private third encoding of the guard: own SELECT deleted ... FOR NO KEY UPDATE, own message text, own constraint name, own NOT FOUND branch. Copies have already diverged: memory copy fails closed on NOT FOUND; shared function returns NEW, so an invisible parent lands the row until the FK check fires. That divergence is the exact CRF-8 shape (R7 fix went into the memory copy; six sibling tables still carry the version the panel rejected). Meruem verified attaching EXECUTE FUNCTION fail_if_user_deleted('user_memory', 'user_memory_user_deleted') produces the same error text and constraint name Go code already expects. Fix: attach shared guard to user_memories; reduce enforce_user_memories_insert_invariants to isolation check + cap, renamed trigger_zz_user_memories_per_user_limit per the 000587:227-229 convention. Cap still counts under the users-row lock because the shared guard takes that lock on INSERT before the cap trigger runs, so this does not touch the READ COMMITTED argument or the CRF-40 row-lock decision. Alternatively, promote the fail-closed argument into fail_if_user_deleted for all seven tables.

CRF-99 (P3, Meruem). The "per-owner caps are only correct under READ COMMITTED" invariant this PR discovered is enforced as a per-trigger string check, so the two sibling cap triggers this stack rewrote in the base still overshoot silently. enforce_user_skills_per_user_limit and enforce_user_secrets_per_user_limits (rewritten in 000587:133-225) neither check the isolation level. Meruem reproduced on PostgreSQL 13.21: seeded 99 user_skills rows, Session B REPEATABLE READ count=99, Session A inserts skill 100 and commits, Session B inserts (advisory lock free, cap trigger counts 99 at B's snapshot, passes, commits): final count 101. Identical sequence against user_memories is rejected. The codebase now answers the same hazard two ways on adjacent tables; the answer that does nothing is on the older, more-used tables; the next cap trigger inherits whichever neighbour the author copies. Fix: put the check in a require_read_committed(constraint_name text) helper alongside the shared soft-delete guard, then call it as the first statement of every cap trigger. The user_skills/user_secrets wiring belongs on the base PR if still open; the shared function belongs here because this PR is the one that discovered the invariant.

CRF-97 (Nit, Robin, Meruem sub). Both enforce_*_owner_immutable functions write IF NEW.user_id <> OLD.user_id. The schema's prior art is set_chat_message_revision_before (live since 000519) which uses IS DISTINCT FROM. Robin verified: UPDATE t SET owner = gen_random_uuid() raises the immutability error; UPDATE t SET owner = NULL raises the NOT NULL error instead, because NULL <> OLD is NULL. Nothing escapes, but a caller matching on memory.UserMemoryOwnerImmutableConstraint via database.IsCheckViolation gets false for the NULL shape and falls through to the generic 500 path. Meruem's WHEN (NEW.user_id IS DISTINCT FROM OLD.user_id) fix (CRF-100) solves this mechanically.

CRF-98 (Nit, Robin). The dedicated-connection-with-lock_timeout block is written three times verbatim: agent_memories_test.go:289-298, :940-949, and user_soft_delete_guards_test.go:186-193. grep -rn lock_timeout --include='*.go' returns those three sites and nothing else. lockrace_test.go was created this round to own this kind of shared harness. A lockTimeoutConn(ctx, t, sqlDB, timeout) *sql.Conn collapses each site to one line and puts the pool-taint reasoning (the CRF-82 fix) in one place. CRF-88's fix could not absorb these two subtests because they assert a statement does not block and runLockRace waits for a block; the copies survived the extraction that was supposed to absorb them.

CRF-100 (Nit, Meruem). trigger_*_owner_immutable fire FOR EACH ROW on every UPDATE and discover in the function body that the owner did not change, which is the case for every content edit and every rename. A WHEN (NEW.user_id IS DISTINCT FROM OLD.user_id) EXECUTE PROCEDURE ... clause puts the condition where \d user_memories and dump.sql show it, skips the plpgsql call on ordinary paths, and closes CRF-97 mechanically.

CRF-101 (Nit, Bisky, Hisoka, Takumi). The isolation sql.IsolationLevel parameter added to runLockRace in the CRF-88 fix has one value at every callsite (all four pass sql.LevelDefault). The subtest it was added for (SerializableCapHolds) was deleted in the same commit for CRF-84. For the three memory callers the parameter is unusable: the insert triggers reject every level except READ COMMITTED. Doc :46 "Pass sql.LevelDefault for the ordinary READ COMMITTED race" reads as if the other branch is exercised somewhere; it is not. Drop the parameter and open both transactions at the default, or restore a caller that exercises it (a ReadUncommittedCapHolds race from CRF-94 supplies one).

CRF-102 (Nit, Bisky). The two pg_get_functiondef loops in TestAgentMemorySchemaConstants (:39-51, :56-83) re-prove what the behavioral suite already proves, by string-matching plpgsql source. Cap is pinned in both directions by PerUserLimit/PerRootChatLimit; each of the nine constraint names is already pinned by a real failing write. What the copies add is fragility: rename memory_limit to max_memories, reflow CONSTRAINT = 'x' to CONSTRAINT='x', or restructure the cap as an inline literal, and 45 lines go red while the schema is correct. Keep the pg_get_constraintdef loop at :85-109; that one pins exact numeric caps from above, which the size-rejection subtests only bound from below.

CRF-103 (Note, Bisky). The cap-bypass-by-owner-reassignment class is still open on user_skills: trigger_zz_user_skills_per_user_limit is BEFORE INSERT ON user_skills while trigger_upsert_user_skills is BEFORE INSERT OR UPDATE, so UPDATE user_skills SET user_id = <live user with 100 skills> skips the cap. user_secrets is clean. Not this PR's stone; recording so the class is written down while the pattern is fresh.


Generated by Coder Agents.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/migrations/000588_agent_memories.up.sql Outdated
Comment thread coderd/database/queries/user_memories.sql Outdated
Comment thread coderd/database/migrations/000588_agent_memories.up.sql Outdated
Comment thread coderd/database/migrations/000588_agent_memories.up.sql Outdated
Comment thread coderd/database/migrations/000588_agent_memories.up.sql Outdated
Comment thread coderd/database/migrations/000593_agent_memories.up.sql
Comment thread coderd/database/agent_memories_test.go Outdated
Comment thread coderd/database/lockrace_test.go
Comment thread coderd/database/agent_memories_test.go Outdated
Comment thread coderd/database/migrations/000593_agent_memories.up.sql
@ThomasK33

Copy link
Copy Markdown
Member Author

Round 11: rebased onto the reworked guard stack, Round-10 findings addressed

The branch was rebuilt on the restructured base (#28874#28870#28546) and renumbered to migration 000592. Substantive changes since the last reviewed head:

  • CRF-96user_memories now attaches the shared soft-delete guard (fail_if_user_deletedcheck_user_not_deleted, migration 000591) instead of a private copy; the cap trigger is reduced to isolation + count and renamed trigger_zz_user_memories_per_user_limit. The fail-closed missing-parent branch is dropped, not promoted: the hard FK to users rejects absent parents at end of statement, the residual snapshot window is unreachable through the Store (reasoning in the migration header), and the base panel's CRF-58 reached the same verdict for the seven sibling tables. MissingUserRejected now pins the FK violation; user_memory_user_required is gone. The chat-side fail-closed branch stays (root-vs-subagent validation, inexpressible as an FK).
  • CRF-94require_read_committed allowlists READ UNCOMMITTED (NOT IN ('read committed','read uncommitted')); ReadUncommittedAccepted + ReadUncommittedCapHolds pin it, and the check_violation-escapes-retry wording replaced the old claim in the migration and both query headers.
  • CRF-99 — the gate is one shared function, created here and called by both memory cap triggers; the skills/secrets wiring was rejected on the base stack (CRF-52, fix(coderd): serialize per-user caps on advisory locks instead of the users row #28870: a runtime gate outages shipped features on non-default default_transaction_isolation) and migration 000590 states their contract instead. The migration comment documents the split.
  • CRF-97/100 — owner immutability enforced via WHEN (NEW.x IS DISTINCT FROM OLD.x) clauses on both tables: NULL assignments raise the immutability constraint, and content edits/renames never enter plpgsql.
  • CRF-95 — the InsertUserMemory header (and migration comment) states that AcquireUserSoftDeleteGuardLock is a system primitive requiring dbauthz.AsSystemRestricted around only that call.
  • CRF-98/101 — one lockTimeoutConn helper (in the base's guard suite) serves all three lock-timeout sites; the shared runLockRace lost its single-valued isolation parameter in fix(coderd): serialize per-user caps on advisory locks instead of the users row #28870, and the READ UNCOMMITTED memory races use a local runIsolationLockRace twin.
  • CRF-102 — the two plpgsql source-string loops are deleted; only the pg_get_constraintdef loop remains.
  • CRF-103 — the skills cap-reassignment bypass was closed in fix(coderd): serialize per-user caps on advisory locks instead of the users row #28870 (trigger_zz_user_skills_per_user_limit_update + reassignment tests).

Validation (PostgreSQL-backed): migrations incl. fixtures for both new tables, TestMethodTestSuite, the memory behavioral suites, the base's guard suites, RBAC, and audit (incl. TestAuditLogIsResourceDeleted for both new resource types).

Merge order: #28874#28870#28634/#28546 → this PR; carried base commits drop out of the diff as the stack merges.


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.

R10 addressed 8 of 10 R9 findings, acknowledged 1 (CRF-103, closed on base #28870), and contested 1 (CRF-99). Migration renumbered 000588 -> 000592, class work moved into the base stack (000590 advisory-lock caps + 000591 shared soft-delete guard), private memory guard replaced by shared fail_if_user_deleted, require_read_committed extracted once and called from both cap triggers, owner-immutability triggers gained WHEN (... IS DISTINCT FROM ...) clauses, lockTimeoutConn extracted, pg_get_functiondef source-string loops deleted.

CRF-99 closed (unanimous panel accept, 4/4 who evaluated). Author's dbcrypt argument verified end-to-end: enterprise/dbcrypt/cliutil.go:88,304 runs UpdateUserSecretByUserIDAndName inside InTx(..., &database.TxOptions{Isolation: sql.LevelRepeatableRead}) (:135,343); trigger_zz_user_secrets_per_user_limits fires BEFORE INSERT OR UPDATE (dump.sql:5511), so wiring require_read_committed into enforce_user_secrets_per_user_limits would fail coder server dbcrypt rotate and dbcrypt decrypt on the first secret with a non-retryable check_violation. Split behaviour documented at 000590:25-36 and restated on the memory side at 000592:92-98.

Twelve new findings, five P3. The two-round pattern of a fix growing its own next finding continues.

CRF-104 (P3, four-way convergence: Netero, Hisoka, Takumi, Zoro). The R10 CRF-96 fix (swap the private guard for the shared fail_if_user_deleted) put the users-row FOR NO KEY UPDATE ahead of the isolation gate on the user side. All four reviewers independently reproduced on PostgreSQL 13.21: session B BEGIN ISOLATION LEVEL REPEATABLE READ, session A UPDATE users SET last_seen_at = now() and commit, session B inserts a memory -> 40001 could not serialize access due to concurrent update from check_user_not_deleted line 42. require_read_committed never runs, the CRF-86 DETAIL never surfaces. UpdateUserLastSeenAt writes this row roughly once per minute per active session (the migration says so at :67-70), so the contended path is the common one, not the corner. Three consequences: (1) the diagnostic engineered for CRF-86 never reaches the operator on the user-memory table where a wrong deployment default hurts; (2) migration comment at :113-116 claims the raised error is check_violation not a serialization failure so it "surfaces to the caller instead of entering any retry loop", but 40001 is exactly the retryable class ReadModifyUpdate catches at sql.LevelRepeatableRead and burns five attempts before returning "too many errors"; (3) TestUserMemories/NonReadCommittedRejected only asserts the uncontended-user case and passes without proving anything about production. Chat side clean: require_read_committed at :201 runs before the chats lock at :203-207. The two tables now answer the same operator error differently. Fix: separate BEFORE INSERT trigger named to sort ahead of trigger_insert_user_memories (e.g. trigger_aa_user_memories_require_read_committed) that only PERFORM require_read_committed(...). Also fix the retry-loop claim at :113-116.

CRF-105 (P3, three-way convergence: Netero, Bisky, Zoro). The R10 CRF-101 fix removed the unusable isolation parameter from runLockRace by cloning the whole harness into runIsolationLockRace (agent_memories_test.go:27-79), a 53-line verbatim copy of runLockRace (lockrace_test.go:47-99) that differs only in two BeginTx calls. lockrace_test.go:13 claims to "own the deterministic lock-race harness"; that stopped being true this round. Bisky and Zoro applied the recut in worktree (parameter back on runLockRace with sql.LevelDefault default, byte-identical to nil under lib/pq); runIsolationLockRace deleted, all lock-race tests green, net -53 lines. Bisky proposed a three-line wrapper pattern if threading LevelDefault through seven callsites is unwanted.

CRF-107 (P3, Bisky). UpdateTakesNoUserLock (agent_memories_test.go:344) stopped witnessing the user-side triggers' INSERT-only scope after the CRF-96 shared-guard swap. Its comment claims "widening one to UPDATE would block this edit on the locked users row"; that was true against the private guard, false against fail_if_user_deleted (000591:113+) on a same-owner UPDATE. Verified: widening the cap trigger 000592:143 to BEFORE INSERT OR UPDATE leaves all TestUserMemories green. That mutation would make a user at exactly 100 memories unable to edit any of them. This is CRF-75 (R7 closed R8) reopening: the R10 rework moved the lock out from under the test that closed it. Chat twin correctly catches the same mutation. Add UpdateAtCapAllowed subtest (fill to memory.MaxUserMemoriesPerUser, edit and rename one row, assert no error).

CRF-108 (P3, Bisky). OwnerImmutable (agent_memories_test.go:327) asserts the reassignment shape only. The NULL shape the R10 WHEN clause was explicitly written for (000592:162-163: "IS DISTINCT FROM, so a NULL assignment is also caught here rather than by the NOT NULL constraint") has no witness. Verified: on head UPDATE user_memories SET user_id = NULL raises the immutability constraint; rewrite WHEN (NEW.user_id <> OLD.user_id) and the same statement raises the generic NOT NULL constraint with tests still green. Two lines inside the existing subtest add the witness. Chat twin at :1024 needs the same.

CRF-109 (P3, Hisoka; Knuckle Note). user_memories joins the guarded-table class in the trigger half but is missing from the reaper half. PurgeSoftDeletedUserResources (queries/users.sql:781-799) deletes from eight tables; dbpurge_test.go:3622 guardedTables and migrate_test.go:3720 pin the same eight. 000592:48-51 states plainly that user_memories "joins the delete_deleted_user_resources cleanup set below", so it is the ninth entry in the class. The reaper's own header (000591:30-35) says it exists to sweep "legacy orphans from before cleanup coverage, and race products from before the guards". Unreachable today (no legacy orphans, and the residual RI-race window at 000592:55-62 was not reproduced), but that window is why the header spends eight lines arguing "unreachable through the Store" rather than impossible, and the reaper is the second line of defense every sibling table gets. Today the argument is the only thing between a private document of a deleted user and permanent survival, on a class of writers none of which exist yet. Fix: add user_memories to the reaper CTE and to each guardedTables slice.

CRF-106 (Nit). coderd/x/memory/memory.go:16 constant-block comment names user_memory_user_required (does not exist anywhere in the tree; the fail-closed branch that raised it was removed this round) and pins migration 000588 (memory migration is 000592; 000588 is now unpriced_ai_models_notification). This is CRF-93 (author fixed R9) partially reopening: that fix rewrote the top-of-file doc and left this sibling reference thirteen lines below, which then went stale twice. Four-way convergence (Netero P3, Hisoka Nit, Knuckle Nit, Zoro Nit); kept Nit on merits (zero behavioral impact).

CRF-110 (Nit, Knuckle). Reasoning for require_read_committed and both cap functions sits above CREATE FUNCTION, so dump.sql gets bare bodies. 000591:8-9 set the convention explicitly ("lives inside the function below so it survives into dump.sql"), and 000590 follows it. Move the two paragraphs at :88-98 and :113-119 inside the function bodies. Operator hitting user_memories inserts require READ COMMITTED isolation reads \sf require_read_committed or dump.sql, and today finds neither the reasoning nor the guard-before-cap ordering argument.

CRF-111 (Nit, Netero). Lock-ordering contract at queries/user_memories.sql:13 attributes group_members, user_ai_budget_overrides to delete_deleted_user_resources, which deletes seven tables and neither of those two. They go away through cascade triggers. Advice is correct (deadlock hazard covers them) but attribution is wrong. Same rule brings oauth2_provider_app_tokens onto the list (ON DELETE CASCADE from api_keys). State the rule instead of enumerating: "any row removed when a user is soft-deleted, directly or by cascade".

CRF-112 (Nit, Zoro). ReadUncommittedCapHolds on both tables hardcodes generate_series(1, 99) and require.Equal(t, 100, ...). coderd/x/memory exists (CRF-42) so the schema numbers have one Go-side name, and every other cap subtest uses memory.MaxUserMemoriesPerUser / memory.MaxChatMemoriesPerRootChat.

CRF-113 (Nit, Zoro). Five DROP TRIGGER statements in the down migration are redundant: DROP TABLE user_memories and DROP TABLE chat_memories (:61-62) remove every trigger on those tables. Only the function drops are load-bearing.

CRF-114 (Nit, Zoro). MissingChatRejected's comment (agent_memories_test.go:722) states the FK mechanism CRF-78 rejected: "an uncommitted chat would be invisible to the trigger's locked read but visible to the FK". An uncommitted parent is not visible to the FK either; the CRF-78 fix corrected the migration to "the trigger reads at its own snapshot while the RI check re-reads at end of statement, so a chat committing between the two is visible only to the FK". The test file was left asserting the rejected mechanism.

CRF-115 (Note, Zoro). user_memories is the only guarded table whose soft-delete guard is INSERT-only (user_links/user_secrets/user_skills are BEFORE INSERT OR UPDATE; four others are BEFORE INSERT OR UPDATE OF user_id; api_keys has a second trigger). The :52-53 comment covers reassignment but not the other UPDATE-leg property: rejecting any modification of a soft-deleted user's row. Unreachable today (cleanup removes the rows) but worth one clause in the comment so the next reader comparing eight tables does not re-derive it.

CRF-116 (Nit, Netero). Parenthetical at 000592:147-149 describes a guard UPDATE branch that does not exist on user_memories (trigger is INSERT-only, per :52-53), and contradicts the "bypass both" it is attached to. Delete it.

CRF-117 (Nit, Netero). require_read_committed body (000592:100-110) is tab-indented in a space-indented file. Every other function uses four spaces. Reindent.


Generated by Coder Agents.


coderd/database/queries/users.sql:781

P3 [CRF-109] user_memories joins the guarded-table class in the trigger half and is missing from the reaper half; an orphaned memory of a deleted user is never collected.

000592:48-51 says it plainly: "user_memories joins the delete_deleted_user_resources cleanup set below, so it attaches the shared fail_if_user_deleted guard from migration 000591 like the other guarded tables." The class has two halves. Migration 000591 deliberately ships no backfill and delegates catch-up to the idempotent reaper (000591:30-35), whose own comment says it "removes what predates both (legacy orphans from before cleanup coverage, and race products from before the guards)". PurgeSoftDeletedUserResources deletes from api_keys, user_links, user_secrets, user_ai_provider_keys, organization_members, user_skills, group_members, user_ai_budget_overrides. Eight of eight guarded tables. The ninth, added by this PR, is not there. dbpurge_test.go:3622 pins the same eight in guardedTables, so adding the table to the reaper and the list is a two-line change plus one seed statement.

Unverified reachability: with the guard and the cleanup both present from table creation there are no legacy orphans, and the residual window the migration header describes at :55-62 (a users row invisible to the trigger's statement snapshot, committed before the RI check, so the insert skips the lock) was not reproduced. But that window is the reason the header spends eight lines arguing it is "unreachable through the Store" rather than impossible, and the reaper exists precisely to sweep what the argument misses.

Today the argument is the only thing between a private document of a deleted user and permanent survival, and it is an argument about all current callers, none of which are written yet. Every sibling table gets a second line of defense; the table holding user-authored prose does not.

Hisoka + Knuckle independent identifications.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/migrations/000593_agent_memories.up.sql
Comment thread coderd/database/agent_memories_test.go Outdated
Comment thread coderd/database/agent_memories_test.go
Comment thread coderd/database/agent_memories_test.go
Comment thread coderd/x/memory/memory.go Outdated
Comment thread coderd/database/migrations/000592_agent_memories.down.sql Outdated
Comment thread coderd/database/agent_memories_test.go
Comment thread coderd/database/migrations/000593_agent_memories.up.sql
Comment thread coderd/database/migrations/000592_agent_memories.up.sql Outdated
Comment thread coderd/database/migrations/000592_agent_memories.up.sql Outdated
@ThomasK33
ThomasK33 force-pushed the fix-user-soft-delete-guards branch from c5e2ac3 to 8c32ed7 Compare September 6, 2026 16:04
@ThomasK33
ThomasK33 force-pushed the feat-agent-memory-database branch from cd4fa1c to 3c44caa Compare September 6, 2026 16:05
@ThomasK33

Copy link
Copy Markdown
Member Author

Round 12: all fourteen Round-11 findings addressed (CRF-104 to CRF-117)

Pushed as 3c44caa on the rebased stack (#28870 8f2226f#28546 8c32ed7 → this PR, all on main acae170664; the storybook carry is gone from the whole stack). Thanks for closing CRF-99 on the dbcrypt argument; the split stays documented in 000590 and 000592.

  • CRF-104require_read_committed runs from its own trigger_aa_user_memories_require_read_committed, so the gate fires before the shared guard's users-row read. NonReadCommittedRejected/GateBeforeGuardLock pins it (RR snapshot, concurrent committed users-row update, insert fails with user_memory_insert_isolation, not 40001); red against the old ordering. The retry-loop claim in the migration is rewritten.
  • CRF-105runIsolationLockRace is deleted; runLockRace takes the isolation level again (nine sql.LevelDefault callers, the two READ UNCOMMITTED cap races are the consumers).
  • CRF-107UpdateAtCapAllowed on both tables (fill to the constant, edit + rename, no error); the stale UpdateTakesNoUserLock comment is gone.
  • CRF-108 — both OwnerImmutable subtests witness the NULL shape against the immutability constraint name.
  • CRF-109user_memories is the ninth table in PurgeSoftDeletedUserResources, seeded and asserted in TestPurgeSoftDeletedUserResources' guardedTables. The migrate_test.go list is not extended: that test runs at migration 000591, where user_memories does not exist yet.
  • CRF-106/110/111/113/114/115/116/117 — constant-registry comment fixed (no user_memory_user_required, 000591 referenced); reasoning moved inside require_read_committed and the cap function bodies; lock-ordering contract states the rule (direct or cascade removal) with oauth2_provider_app_tokens among the examples; the five redundant DROP TRIGGERs are gone; MissingChatRejected states the CRF-78 mechanism; the INSERT-only guard comment covers the UPDATE-leg property; the contradictory parenthetical is deleted; the function body is space-indented.
  • CRF-112ReadUncommittedCapHolds sizes from memory.MaxUserMemoriesPerUser / memory.MaxChatMemoriesPerRootChat.

Validation (PostgreSQL-backed): TestUserMemories, TestChatMemories, TestAgentMemorySchemaConstants, TestPurgeSoftDeletedUserResources, migrations incl. fixtures, TestMethodTestSuite, RBAC and audit suites, plus the full repository Go suite at this 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 feat-agent-memory-database branch from 3c44caa to 8c89c76 Compare September 7, 2026 08:26
@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.

R11 addressed all 14 R10 findings with real diffs. Netero and 2 of 4 panelists (Bisky, Zoro) found no defects; the two remaining panelists (Hisoka, Meruem) each raised one Note independently, plus one Nit from Hisoka. Highest severity is Note; no re-raises, no contested defenses. The R11 fixes were verified honest end to end: TestUserMemories/NonReadCommittedRejected/GateBeforeGuardLock is a genuine ordering witness (renaming trigger_aa_... so it sorts after the shared guard flips it red with pq: could not serialize access due to concurrent update), UpdateAtCapAllowed catches the CRF-75 cap-widening mutation on the user side, the NULL branch of OwnerImmutable closes the R10 WHEN (... IS DISTINCT FROM ...) shape both sides, runIsolationLockRace is gone and runLockRace regained its isolation parameter with all seven callsites routed through the one harness, and PurgeSoftDeletedUserResources picks up user_memories with the reaper test seeding and guardedTables extended.

CRF-118 (Note, Hisoka). Chat-side gate-before-lock ordering has no test witness. The migration header (000592:130-133) explicitly cites GateBeforeGuardLock as the pin for user-side ordering and dismisses the chat side because enforce_chat_memories_insert_invariants calls the gate before the chats-row lock inside one function body. grep GateBeforeChatLock returns nothing. Swap the two adjacent statements at :237-243 and the pre-R11 hazard reopens on chat.

CRF-119 (Note, Meruem). The rationale comment at 000592:158-159 ("this re-assertion keeps the cap self-contained if that trigger is ever dropped") over-promises what the re-check protects. Name order for the three BEFORE INSERT triggers is trigger_aa_..._require_read_committed, trigger_insert_user_memories (shared guard, sorts between aa_ and zz_), trigger_zz_..._per_user_limit. Drop the aa_ trigger and the guard runs first: its SELECT deleted FROM users ... FOR NO KEY UPDATE (000591:89-93) raises 40001 for any REPEATABLE READ writer whose snapshot predates a committed users update, before the cap trigger's re-check ever executes. The re-check only protects the uncontended path, not the path the R11 split was designed to close.

CRF-120 (Nit, Hisoka). The R11 commit body says "witness UpdateAtCapAllowed on both tables"; grep -c UpdateAtCapAllowed coderd/database/agent_memories_test.go returns 1. The chat side is genuinely covered by UpdateTakesNoChatLock at :1056, because widening enforce_chat_memories_insert_invariants to BEFORE INSERT OR UPDATE would make a content edit contend with the held FOR NO KEY UPDATE on chats and time out. Coverage is not lost; only the commit body is out of step.

CI at head still shows 22 failed / 18 passed / 1 pending / 9 skipped, unchanged in shape from R10 and the same workflows (changes/gen/Storybook/lint/fmt/Go tests/build/sqlc-vet/required). Netero reproduced local greens on go build ./..., go vet ./coderd/database/... ./coderd/x/memory/, and the memory test suites against PostgreSQL; the CI failures are consistent with a pipeline-level issue rather than any regression this PR introduces, but I have not proved that.

Post as COMMENT.

🤖 This review was automatically generated with Coder Agents.

-- committing between the two reads is visible only to the FK check.
-- Returning NEW here would let a memory land under an unvalidated,
-- possibly subagent, chat.
IF NOT FOUND THEN

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.

Note [CRF-118] Chat-side gate-before-lock ordering has no test witness, so a swap of the two adjacent statements inside enforce_chat_memories_insert_invariants reopens the same hazard R11 was reworked to close on the user side.

enforce_chat_memories_insert_invariants runs PERFORM require_read_committed('chat_memories cap', 'chat_memory_insert_isolation'); at :237 and then SELECT ... FROM chats WHERE id = NEW.root_chat_id FOR NO KEY UPDATE; at :239-243. The migration header (:130-133) leans on that order: "The chat side needs no separate trigger: its single trigger function calls the gate before the chats-row lock. TestUserMemories/NonReadCommittedRejected/GateBeforeGuardLock pins this ordering." That test only pins the user side. Reverse the two statements in the chat function body and the exact hazard GateBeforeGuardLock was written to close reopens on chat: under REPEATABLE READ, the SELECT ... FOR NO KEY UPDATE raises 40001 for any writer whose snapshot predates a committed UPDATE chats on the same row (title changes, archived flips, ChatMachine.Update's snapshot bumps, all documented at :208-212 as ordinary contenders on this row) before require_read_committed runs. That burns database.ReadModifyUpdate retries and hides the gate's diagnostic.

No chat-side subtest catches the reordering. NonReadCommittedRejected at agent_memories_test.go:949 only proves the gate fires on an uncontended parent row; SubagentChatRejected, MissingChatRejected, and PerRootChatLimit all run at default isolation.

Add a GateBeforeChatLock twin symmetric with the user-side witness at agent_memories_test.go:216-241: pin a snapshot at REPEATABLE READ, commit an UPDATE chats SET updated_at = now() from outside, insert a chat_memory under the tx, and assert database.IsCheckViolation(err, memory.ChatMemoryInsertIsolationConstraint). Same construction, just against chats instead of users.

🤖

-- guard's users-row lock; this re-assertion keeps the cap
-- self-contained if that trigger is ever dropped.
PERFORM require_read_committed('user_memories cap', 'user_memory_insert_isolation');

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.

Note [CRF-119] The "self-contained if that trigger is ever dropped" rationale over-promises what the re-assertion actually protects, and the misleading comment invites a later reader to silently reopen the pre-R11 regression.

enforce_user_memories_per_user_limit re-PERFORMs require_read_committed(...) at :160 and the comment at :156-159 tells the next editor that dropping trigger_aa_user_memories_require_read_committed is safe because this call catches it. Trigger firing order on user_memories is name-sorted: trigger_aa_user_memories_require_read_committed (:143), trigger_insert_user_memories (:87, the shared fail_if_user_deleted guard, sorts between aa_ and zz_), trigger_zz_user_memories_per_user_limit (:178). Drop the aa_ trigger and the guard runs first: check_user_not_deleted takes SELECT deleted FROM users ... FOR NO KEY UPDATE at 000591:89-93, and its own header (000591:81-88) states plainly that this locking read raises 40001 under REPEATABLE READ whenever any transaction committed an update to the users row after the snapshot. That is exactly the contended-path failure GateBeforeGuardLock was written to prevent, and the cap trigger's re-check never runs.

The defense only holds on the uncontended path, which is not the path the R11 split fix cares about. A reader taking the current wording at face value can drop the aa_ trigger in a later cleanup and silently reopen the R10 regression this round closed.

Two remediations, pick one:

  • Tighten the comment to what the re-check actually protects: both the aa_ trigger and the shared guard absent, or the guard replaced with a non-locking variant.
  • Fold the invariant into a single BEFORE INSERT trigger and delete the aa_/zz_ name-ordering sentinel entirely. The chat side already does this.

🤖

// The cap triggers are INSERT-only by design: a user at exactly the cap
// must keep editing and renaming existing memories. Widening the cap
// trigger to BEFORE INSERT OR UPDATE (the CRF-75 mutation) fails here.
t.Run("UpdateAtCapAllowed", func(t *testing.T) {

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-120] Commit 8c89c76135 body says "witness UpdateAtCapAllowed on both tables"; grep -c UpdateAtCapAllowed coderd/database/agent_memories_test.go returns 1 (only user side).

The chat-side cap widening (BEFORE INSERT -> BEFORE INSERT OR UPDATE on enforce_chat_memories_insert_invariants) is caught by UpdateTakesNoChatLock at :1056, because the cap check and the chats-row lock live in the same trigger function, so no behavioral coverage is lost. Either add the symmetric TestChatMemories/UpdateAtCapAllowed subtest, or drop "on both tables" from the commit body. Leaving the two out of step is what CRF-107 was raised to close.

🤖

Adds user_memories (private per-user documents) and chat_memories (root
chat documents) with path/content limits owned by coderd/x/memory, a
dedicated user_memory RBAC resource with API key scopes, chat-ACL
authorization for chat memories, and audit coverage for both resource
types.

Migration 000592 attaches the shared soft-delete guard from 000591 to
user_memories (no fail-closed missing-parent branch: the hard FK rejects
absent parents at end of statement), introduces a memory-table-only
require_read_committed isolation gate for the count caps, makes owner
columns immutable via WHEN-clause triggers, validates root chats on
chat-memory inserts, and extends delete_deleted_user_resources to wipe
user_memories on soft delete.
…uery headers

The insert triggers accept READ UNCOMMITTED (PostgreSQL runs it with READ
COMMITTED semantics); saying they reject every non-READ COMMITTED level
restated the CRF-94 defect in prose. Also list the two newly guarded
tables in the lock-ordering contract.
…haned memories

Review round 10: run require_read_committed from its own aa_-named
trigger so it fires ahead of the shared soft-delete guard's users-row
lock (a REPEATABLE READ writer previously got a retryable 40001 from the
locked read on the contended path instead of the gate's diagnostic;
GateBeforeGuardLock pins the ordering red-to-green), add user_memories
to the PurgeSoftDeletedUserResources reaper as the ninth guarded table,
fold the isolation-parameter clone back into the shared lock-race
harness, witness UpdateAtCapAllowed and the NULL owner-immutability
shape on both tables, size the ReadUncommitted cap subtests from the
coderd/x/memory constants, move migration reasoning inside function
bodies so dump.sql carries it, drop the redundant down-migration DROP
TRIGGER statements, and fix the stale constant-registry, lock-ordering
enumeration, and fail-closed mechanism comments.
Trunk took 000590 for workspace_agent_session_counts (#28126), which
shifted the base stack to 000591 (cap advisory locks) and 000592
(soft-delete guards) and this migration and its fixture to 000593. The
comments citing any of the three numbers follow; dump.sql regenerates
identically apart from the cited number.

---
_Generated with [`mux`](https://github.com/coder/mux) • Model: `anthropic:claude-fable-5-1` • Thinking: `xhigh`_
@ThomasK33
ThomasK33 force-pushed the feat-agent-memory-database branch from 8c89c76 to 64f6c45 Compare September 7, 2026 21:34
@ThomasK33
ThomasK33 force-pushed the fix-user-soft-delete-guards branch from db94229 to dd747c8 Compare September 7, 2026 21:34
@ThomasK33

Copy link
Copy Markdown
Member Author

Rebased onto the renumbered base: trunk took 000590 (#28126), so the stack is now 000591 (caps) → 000592 (guards) → 000593 (agent memories; renumber commit 64f6c45 renames the migration and its fixture and shifts the cited numbers in the migration, coderd/x/memory, and the test). No behavior change since Round 12. Full local pre-push gate green on the new head (Go 31,606 tests, vitest, Storybook).


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.

Blocked. R12 head 64f6c450b1 is a mechanical renumber only (000592 -> 000593 for the memory migration; the base stack renumbered 000589 -> 000591 caps and to 000592 guards after trunk took 000590); the branch diff to R11 head 8c89c76135 is 6 files, +6/-6, all migration/fixture rename and citation syncs (agent_memories_test.go:509, dump.sql:1679, 000593_agent_memories.up.sql:50,77,106, coderd/x/memory/memory.go:17), plus a chore(coderd): renumber agent memory migration to 000593 commit whose body states "No behavior change since Round 12" [author's numbering]. Nothing in the diff touches the three R11 findings, and no thread reply landed on any of them.

Silent (blocking):

  • CRF-118 (Note, chat-side gate-before-lock test witness). enforce_chat_memories_insert_invariants (000593:238) still calls PERFORM require_read_committed(...) immediately followed by SELECT ... FOR NO KEY UPDATE on chats at :240-244. grep -Rn GateBeforeChatLock coderd/database/ returns nothing. The migration header at 000593:130-133 still cites the user-side test as the pin and dismisses the chat side on the intra-function-ordering argument that has no test to enforce it. Add the GateBeforeChatLock twin symmetric with agent_memories_test.go:216-241, or reply explaining why the chat-side ordering does not need the same red-to-green pin.

  • CRF-119 (Note, misleading "self-contained" rationale). enforce_user_memories_per_user_limit at 000593:150-176 still carries the comment (:156-159) claiming that dropping trigger_aa_user_memories_require_read_committed is safe because the cap re-PERFORMs require_read_committed. grep -n 'self-contained if that trigger is ever dropped' coderd/database/migrations/000593_agent_memories.up.sql returns line 159. Trigger name-sort still puts trigger_insert_user_memories between aa_ and zz_, so the shared guard still runs before the cap trigger's re-check and still raises 40001 under REPEATABLE READ on the contended path (000592:89-93 / :81-88 after the base renumber). Tighten the comment to what the re-check actually protects, or fold the invariant into a single BEFORE INSERT trigger.

  • CRF-120 (Nit, commit body "on both tables" claim). The rebased R11 commit is now afa4b03d8b fix(coderd): gate memory isolation before the guard lock and reap orphaned memories; the body is byte-for-byte identical to the pre-rebase 8c89c76135, still says "witness UpdateAtCapAllowed and the NULL owner-immutability shape on both tables", and grep -Rn 'UpdateAtCapAllowed' coderd/database/agent_memories_test.go still returns one match (user side). Add the symmetric TestChatMemories/UpdateAtCapAllowed, or amend the commit body to drop "on both tables".

CI at head: 21 failed / 4 passed / 16 pending / 9 skipped, shape unchanged from R11. changes, gen, fmt, lint, sqlc-vet, build, Storybook, test-go-pg, test-go-pg-17, test-go-race-pg, test-js, check-build, required all still red. Author's rebase note reports full local pre-push gate green (Go 31,606 tests, vitest, Storybook); I have not reproduced.

Request: reply on the three R11 threads (fix, defense, or explicit accept-as-drop), then I resume the panel. Rebasing without responding is silent on the findings, not a response to them.

🤖 This review was automatically generated with Coder Agents.

@ThomasK33

Copy link
Copy Markdown
Member Author

Closing this PR; the agent memory database foundation is not moving forward in this form. The branch (feat-agent-memory-database, head 64f6c45) stays available if the work is picked up again.


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

@ThomasK33 ThomasK33 closed this Sep 9, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

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.

1 participant