Skip to content

feat: delete chat projects and clean up their chats in the background - #30683

Open
f0ssel wants to merge 2 commits into
f0ssel/chat-projects-aclfrom
f0ssel/chat-project-delete
Open

f0ssel wants to merge 2 commits into
f0ssel/chat-projects-aclfrom
f0ssel/chat-project-delete

Conversation

@f0ssel

@f0ssel f0ssel commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Stacked on #30439. Deleting a chat project now removes its chats, including chats started by people it is shared with. Before this, deleting a project detached its chats, and #30479 and #30439 keep that behavior.

  • Request: DELETE /chats/projects/{project} sets chat_projects.deleted (a one-way tombstone), deletes the project's memories and instructions in the same statement, and returns 204; a repeat returns 404. It takes no chat locks, so it can't fail or ask the caller to retry. Only the project delete is audited.
  • Hidden right away: project queries, chat lists (including by workspace), ExtractChatParam, and file download skip a deleted project and its chats. Root chat creation holds the project FOR SHARE, so a creation racing the delete is either cleaned up with the rest or gets 404. Unarchiving is refused, and audit entries show the chats as deleted.
  • Background cleanup: after the tombstone commits, Server.ScheduleDeletedChatProjectCleanup runs chatstate.ArchiveDeletedChatProject. It locks the project's unarchived root chats, archives every unarchived chat in the project with no worker or runner, and clears their queues. It then publishes chat:update (runners stop mid-turn), schedules debug-data cleanup, and publishes a new project_deleted watch event for every chat in the project, archived ones included (sidebars evict the chat). Running chats keep generating until the cleanup runs.
  • Retries: each chat worker sweeps every minute for deleted projects that still have unarchived chats (GetDeletedChatProjectIDsWithUnarchivedChats). The sweep covers cleanups that failed, for example on a deadlock with a chat purge, or never ran because the replica stopped. A second pass leaves chats that are already archived alone, so their retention clock doesn't restart.
  • Retention: archived chats follow chat retention (kept forever when retention is 0). dbpurge then deletes the empty project row.
  • Membership: sub-chats now carry their root's project_id. Migration 000622 backfills it and adds an insert trigger that sets it, so project queries match on that column alone. A sub-chat stays in its project even if retention deletes its root first.
  • UI: both delete dialogs warn that sharees' chats are hidden right away and permanently deleted only if conversation retention is enabled.
Decision log
  • Earlier rounds did the whole archive inside the request transaction, with a retry and a 409 when deadlocks repeated. @f0ssel asked that a delete request never fail or need a retry: either do everything in one go, or mark it and clean up later. This PR marks and cleans up later.
  • Running chats may keep going until the cleanup stops them (@f0ssel), so worker acquisition and heartbeat renewal have no extra deleted-project filter.
  • The cleanup archives with one bulk statement instead of a per-chat ChatMachine transition, to keep the root locks short; its SET list mirrors UpdateChatExecutionState.
  • Deleted-project filtering stays per query rather than a chats_expanded column, to avoid a project join on every chat read.
  • Deleted projects' chats follow chat retention rather than being hard-deleted (@f0ssel).
  • The cleanup runs in chatd (in-process right after the delete, plus the worker sweep) because chatd has the pubsub needed to stop runners and update sidebars. With no chat daemon (AI Gateway off), no chats run. The chats stay hidden and unarchived until a replica with a daemon sweeps them.

Generated by Coder Agents.

@f0ssel
f0ssel requested review from a team and nickvigilante as code owners October 13, 2026 11:50
@github-actions

github-actions Bot commented Oct 13, 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.

@f0ssel

f0ssel commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-10-13 13:27 UTC by @f0ssel

Review history
  • R1 (2026-10-13): 17 reviewers, 2 Nit, 1 P1, 11 P2, 17 P3, 10 P4, REQUEST_CHANGES. Review
  • R2 (2026-10-13): 18 reviewers, 3 Nit, 1 P1, 12 P2, 23 P3, 24 P4, COMMENT. Review

deep-review v0.13.0 | Round 2 | 1e98984..3121c76

Last posted: Round 2, 63 findings (1 P1, 12 P2, 23 P3, 24 P4, 3 Nit), COMMENT. Review

Finding inventory

Finding inventory: PR #30683

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Author fixed (3121c76) coderd/database/queries/chats.sql:2436 GetChatsByWorkspaceIDs does not skip chats of a deleted project R1 Netero Yes
CRF-2 Note Note (body) coderd/httpmw/chatparam.go:52 Deleted-project check uses AsSystemRestricted rather than AsChatd; matches httpmw convention R1 Netero No
CRF-3 P1 Author fixed (3121c76) coderd/database/migrations/000618_chat_project_sharing.up.sql:19 Schema changes appended to the unmerged parent PR's migration 000618 R1 Ryosuke, Law Yes
CRF-4 P3 Author fixed (3121c76) coderd/database/queries/chatprojects.sql:138 LockChatProjectRootChats locks archived roots and deadlocks with DeleteOldChats; purge tick can be the victim R1 Knuckle, Takumi Yes
CRF-5 P3 Author fixed (3121c76) coderd/chat_projects.go:285 Delete request loads all project chats and audits each root chat synchronously; entries lost on crash or list error R1 Killua, Knuckle, Ryosuke Yes
CRF-6 P3 Author fixed (3121c76) coderd/database/queries/chatprojects.sql:182 Sweep EXISTS reads every chat of fully archived deleted projects every minute; unbounded with retention 0 R1 Killua Yes
CRF-7 P4 Author fixed (3121c76) coderd/x/chatd/chatstate/family.go:202 Cleanup reloads all project chats and matches with slices.Contains while holding root locks R1 Knuckle, Killua Yes
CRF-8 Note Note (body) coderd/x/chatd/projects.go:47 scheduleArchiveDebugCleanup starts one concurrent debug delete per archived chat R1 Killua No
CRF-9 P4 Author fixed (3121c76) coderd/database/queries/chatprojects.sql:100 UpdateChatProjectDeletedByID is :exec; concurrent second delete returns 204 and re-audits R1 Knuckle, Melody, Mafuuu Yes
CRF-10 Note Note (body) coderd/database/migrations/000618_chat_project_sharing.down.sql:4 Down migration permanently deletes chats of deleted projects without warning R1 Knuckle No
CRF-11 P3 Author fixed (3121c76) coderd/database/queries/chatprojects.sql:194 Project memories survive the delete until all chats are purged, forever with retention 0, with no API to erase them R1 Kurapika Yes
CRF-12 Note Note (body) coderd/x/chatd/projects.go:39 Project delete archives and hides sharees' chats without notice (maintainer decision) R1 Kurapika No
CRF-13 P3 Author fixed (3121c76) site/src/api/queries/chats.ts:584 applyWatchedChatRemoved refetches and aborts list, search, and by-workspace queries once per removed event R1 Nami, Luffy Yes
CRF-14 Note Note (body) coderd/database/migrations/000618_chat_project_sharing.up.sql:21 With retention 0, deleted projects' chats and memories stay forever with no API path R1 Luffy No
CRF-15 P3 Author fixed (3121c76) coderd/x/chatd/projects.go:46 Chats already archived before the delete never get a removed watch event R1 Nami P3, Pariston P4, Mafuuu P4, Meruem P4 Yes
CRF-16 P4 Author fixed (3121c76) site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx:932 RemovedWatchEventRemovesOpenChat passes while the reset entity is still loading R1 Nami Yes
CRF-17 P3 Author contested; panel closed R2 (3/3 accept) coderd/database/queries/chats.sql:643 Deleted-project hiding is a per-read-site predicate; a project_deleted column on chats_expanded would remove the class R1 Meruem, Ryosuke, Pariston Yes
CRF-18 P3 Author fixed (3121c76) coderd/audit.go:493 auditLogIsResourceDeleted reports is_deleted false for deleted project chats R1 Pariston Yes
CRF-19 P3 Author fixed (3121c76) coderd/x/chatd/ARCHITECTURE.md:1023 Doc and PR description say running chats finish their turn; cleanup stops them mid-turn R1 Leorio P3, Pariston P4, Takumi Note Yes
CRF-20 P3 Author fixed (3121c76) coderd/x/chatd/runner_manager.go:500 Lease-lost log message names project deletion, which the code does not check R1 Gon P3, Leorio P4 Yes
CRF-21 P3 Open (re-raised R2: first sentence at family.go:91 still names the delete) coderd/x/chatd/chatstate/family.go:91 Comments name the delete where the cleanup is the actor (also runner_manager.go:496, projects_internal_test.go:44) R1 Gon P3, Mafuuu P4 Yes
CRF-22 P3 Author fixed (3121c76) codersdk/chats.go:2179 New watch kind removed is a synonym of deleted with opposite client behavior R1 Gon Yes
CRF-23 P2 Author fixed (3121c76) coderd/database/queries/chatprojects.sql:172 Comment about file placement is wrong in the sqlc-generated method doc R1 Gon Yes
CRF-24 P2 Author fixed (3121c76) coderd/database/queries/chatprojects.sql:101 UpdateChatProjectDeletedByID doc repeats the cleanup sequence R1 Gon Yes
CRF-25 P2 Author fixed (3121c76) coderd/database/queries/chatprojects.sql:143 ArchiveChatsOfDeletedChatProject comment restates SET list and says workers where runners stop R1 Gon Yes
CRF-26 P2 Author fixed (3121c76) coderd/x/chatd/chatstate/transitions.go:158 Six-line sub-chat lock comment repeats the CreateChat doc R1 Gon Yes
CRF-27 P2 Author fixed (3121c76) coderd/x/chatd/chatstate/transitions.go:168 Comment repeats GetChatProjectByIDForShare doc R1 Gon Yes
CRF-28 P2 Author fixed (3121c76) coderd/x/chatd/chatstate/family.go:175 ArchiveDeletedChatProject doc repeats ErrChatUpdatePublishFailed doc R1 Gon Yes
CRF-29 P2 Author fixed (3121c76) coderd/x/chatd/projects.go:34 cleanupDeletedChatProject doc lists its body R1 Gon Yes
CRF-30 P2 Author fixed (3121c76) coderd/x/chatd/options.go:256 Sweep option comment restates field names R1 Gon Yes
CRF-31 P2 Author fixed (3121c76) codersdk/chats.go:2177 ChatWatchEventKindRemoved doc ends with server retention detail R1 Gon Yes
CRF-32 P2 Author fixed (3121c76) coderd/chat_projects.go:259 Handler comment second sentence restates following code R1 Gon Yes
CRF-33 P2 Author fixed (3121c76) coderd/database/dbauthz/dbauthz.go:3939 GetChatsByProjectID authz comment names one of two callers R1 Gon Yes
CRF-34 P3 Author fixed (3121c76) coderd/database/queries/chats.sql:2318 Comment says chats stay archived; the filter exists for the window before they are archived (also chatparam.go:49) R1 Gon Yes
CRF-35 P3 Author fixed (3121c76) codersdk/chats.go:2409 DeleteChatProject doc says it deletes every chat; chats are archived and follow retention R1 Gon, Leorio Yes
CRF-36 Nit Author fixed (3121c76) site/src/pages/AgentsPage/AgentsPageLayout.tsx:353 Edited comment not rewrapped R1 Gon Yes
CRF-37 Nit Author fixed (3121c76) coderd/database/querier_test.go:2354 "the project lock" does not say which of three locks R1 Gon Yes
CRF-38 P4 Author fixed (3121c76) codersdk/chats.go:2174 ChatWatchEventKindDeleted doc no longer holds for project-delete archives R1 Leorio Yes
CRF-39 P4 Author fixed (3121c76) coderd/x/chatd/chatstate/family.go:23 RootID doc does not mention ErrChatNotFound for deleted-project unarchive R1 Leorio Yes
CRF-40 P4 Author fixed (3121c76) coderd/x/chatd/ARCHITECTURE.md:485 "from any execution state outside this endpoint" is ambiguous R1 Leorio Yes
CRF-41 P4 Author fixed (3121c76) coderd/x/chatd/ARCHITECTURE.md:1023 "(the delete authorization check)" does not say what is checked R1 Leorio Yes
CRF-42 P3 Author fixed (3121c76) coderd/x/chatd/chatstate/transitions.go:171 No test proves root creation holds the project FOR SHARE; ErrChatProjectNotFound 404 untested R1 Bisky Yes
CRF-43 P4 Author fixed (3121c76) coderd/x/chatd/projects_internal_test.go:81 Publish-failure test does not assert removed events still go out R1 Bisky Yes
CRF-44 P4 Author fixed (3121c76) coderd/database/querier_test.go:2404 Querier race test copies the production cleanup sequence by hand R1 Bisky Yes
CRF-45 P4 Author fixed (3121c76) coderd/x/chatd/chatstate/transitions.go:164 Sub-chat ProjectID guard covers input no caller sends; its test accepts any error R1 Bisky Yes
CRF-46 P3 Author contested; panel re-raised R2 coderd/database/queries/chatprojects.sql:151 ArchiveChatsOfDeletedChatProject is a second execution-state writer kept in sync by a comment R1 Meruem P3, Ryosuke Note Yes
CRF-47 Note Note (body) coderd/chat_projects.go:282 Root chats audited as delete while rows stay until retention, forever with retention 0; sub-chats not audited R1 Melody, Mafuuu No
CRF-48 Note Dropped by orchestrator (affirmation, no issue) coderd/chat_projects.go:259 Overall tombstone plus background cleanup shape is right R1 Luffy No
CRF-49 Note Dropped by orchestrator (affirmation, no issue) site/src/api/queries/chatProjects.ts:68 Entity reset in mutationFn is right R1 Nami No
CRF-50 Note Dropped by orchestrator (affirmation, no issue) site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx:915 RemovedWatchEventRemovesOpenChat tests the real handler R1 Bisky No
CRF-51 Note Dropped by orchestrator (affirmation, no issue) site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx:348 Dialog text matches the code R1 Leorio No
CRF-52 Note Dropped by orchestrator (affirmation, no issue) coderd/database/migrations/000618_chat_project_sharing.up.sql:19 Schema change has no growth cost R1 Knuckle No
CRF-53 OOS Out of scope (body) coderd/database/dump.sql:5374 Hard delete of the project owner's users row cascades and detaches tombstoned chats, making them visible R1 Knuckle No
CRF-54 OOS Out of scope (body) coderd/x/chatd/chatd.go:3243 No project-level watch event; sharees without chats in the project keep the stale project folder R1 Melody, Nami No
CRF-55 OOS Out of scope (body) site/src/api/queries/chats.ts:554 applyWatchedChatArchived has the same per-event refetch-and-abort pattern R1 Nami No
CRF-56 OOS Out of scope (body) coderd/database/dump.sql:5404 parent/root chat FKs ON DELETE SET NULL turn orphaned sub-chats into roots R1 Ryosuke No
CRF-57 OOS Out of scope (body) coderd/audit.go:406 auditLogIsResourceDeleted has no ResourceTypeChatProject case R1 Pariston No
CRF-58 OOS Out of scope (body) codersdk/chats.go:2175 ChatWatchEventKindDeleted is published for archive, not deletion R1 Gon No
CRF-59 OOS Out of scope (body) coderd/x/chatd/chatstate/family_test.go:271 Pre-existing inline pg_stat_activity poll duplicates the new waitForLockWait helper R1 Bisky No
CRF-60 P2 Open site/src/pages/AgentsPage/AgentsPageLayout.stories.tsx:921 ProjectDeletedWatchEventRemovesOpenChat fails every run: getChat mock rejects without a 404 status R2 Netero Yes
CRF-61 P4 Open coderd/audit.go:514 New ResourceTypeChatProject case in auditLogIsResourceDeleted has no test R2 Netero Yes
CRF-62 P4 Open coderd/x/chatd/deleted_projects.go:21 No test runs the sweep from deletedChatProjectsLoop's ticker R2 Netero Yes
CRF-63 P4 Open coderd/chat_project_acl_test.go:429 Failure message still says removed events after the rename R2 Netero Yes
CRF-64 P3 Open coderd/database/queries/chatprojects.sql:111 Memory or instructions write in flight during the tombstone survives on the deleted project R2 Knuckle, Kurapika, Takumi, Kite P3; Melody, Mafuuu P4 Yes
CRF-65 P4 Open coderd/x/chatd/chatstate/family.go:91 Since CRF-4 fix, an unarchive racing the delete leaves the family unarchived until the sweep R2 Meruem, Ryosuke, Pariston, Melody Yes
CRF-66 P3 Open site/src/api/queries/chats.ts:583 List refetch started before the tombstone re-adds the deleted chat; comment claims in-flight refetches exclude it R2 Nami, Gon Yes
CRF-67 P4 Open coderd/x/chatd/projects.go:46 GetChatsByProjectID failure after archive commit sends no project_deleted events and no retry; log says sweep retries R2 Pariston, Nami, Takumi Yes
CRF-68 P3 Open coderd/database/dbpurge/dbpurge.go:51 Batch-size comment says purge cascades memories; tombstone already deletes them R2 Gon P3, Melody P4, Leorio P4, Knuckle Nit Yes
CRF-69 P4 Open coderd/database/migrations/000622_chat_project_deletion.up.sql:36 Partial index includes unarchived chats outside any project R2 Knuckle Yes
CRF-70 P4 Open coderd/database/queries/chatprojects.sql:185 Queue delete's deleted-project subquery is unreachable and scans all project chats under root locks R2 Knuckle, Bisky Yes
CRF-71 P3 Open coderd/database/queries/chatprojects.sql:153 No test pins either half of the CRF-4 deadlock fix R2 Bisky Yes
CRF-72 P4 Open coderd/database/queries/chatprojects.sql:116 No test proves the tombstone deletes instructions R2 Bisky Yes
CRF-73 P4 Open coderd/x/chatd/options.go:258 DeletedChatProjectSweepBatchSize option has no setter R2 Luffy Yes
CRF-74 P4 Open coderd/database/queries/chatprojects.sql:19 FOR NO KEY UPDATE comment says chat inserts do not wait; root inserts take FOR SHARE and wait R2 Mafuuu Yes
CRF-75 P4 Open coderd/database/queries/chatprojects.sql:147 LockChatProjectRootChats locks only unarchived roots; name says all R2 Gon Yes
CRF-76 P4 Open coderd/x/chatd/chatstate/family_test.go:481 TestArchiveDeletedChatProjectWaitsForRootCreation names the wrong waiter R2 Gon Yes
CRF-77 P4 Open coderd/database/migrations/000622_chat_project_deletion.up.sql:33 chats.project_id column comment no longer says what it means for a root R2 Gon Yes
CRF-78 P3 Open site/src/pages/AgentsPage/components/ProjectPage/ProjectHeaderSection.tsx:167 Delete dialog omits that memories and instructions are deleted at once R2 Leorio P3, Gon P4 Yes
CRF-79 P3 Open site/src/pages/AgentsPage/components/ChatsSidebar/ChatsSidebar.tsx:348 Same dialog omission in the sidebar delete dialog R2 Gon P4, Leorio P3 Yes
CRF-80 P4 Open coderd/audit.go:494 Comment says the rows exist and are gone in one sentence R2 Leorio Yes
CRF-81 Nit Open coderd/audit.go:504 nolint justification gives the use, not why the system context is needed R2 Kite Yes
CRF-82 OOS Out of scope (body) coderd/x/chatd/chatd.go:3256 Watch events go only to the owner; chat-ACL sharees get no project_deleted event R2 Kite No
CRF-83 Note Dropped by orchestrator (affirmation, fixes verified) various R2 verification notes: Killua, Knuckle, Kurapika, Luffy, Melody, Nami, Takumi, Bisky, Leorio, Netero R2 various No

Law analysis

R1. Effective LOC +1860 -89 (42 files). Head 6f91b9b. Verdict: Don't split. Enforcement: Advisory. Ordering constraint noted: this PR appends to 000618_chat_project_sharing.up.sql, owned by the unmerged parent #30439; if #30439 merges first, these edits never run on deployments that applied 000618. main already has 000618 and 000619, so the stack's migration needs renumbering before it lands. Raised as CRF-3 with Ryosuke.

Contested and acknowledged

CRF-17 (P3, chats.sql:643) - Per-read-site deleted-project predicate

  • Finding: Hiding a deleted project's chats is a separate predicate or lookup at each read site (GetChats, GetChatsByChatFileID, ExtractChatParam, SetFamilyArchived); two sites were missed in R1. Proposed a project_deleted column on chats_expanded via LEFT JOIN chat_projects so readers get it from the row.
  • Author defense: Fixed the two missed sites (GetChatsByWorkspaceIDs filter, audit is_deleted). Keeps per-site filters because the join would add a project lookup to every chat read and change the Chat model everywhere. PR decision log records this choice.
  • Panel closure (R2, 3/3 accept): Meruem, Ryosuke, Pariston (original R1 agents, resumed with message_agent) each accepted; see round 2 log.

CRF-46 (P3, chatprojects.sql:151) - Second execution-state writer

  • Finding: ArchiveChatsOfDeletedChatProject writes execution-state columns outside ChatMachine, kept in sync with UpdateChatExecutionState only by a comment; proposed a Tx transition through applyExecutionState run per chat.
  • Author defense: A per-chat ChatMachine transition would hold every root lock across N lock/load/count/update round trips and add a matrix row from every state. The SET list is pinned by querier_test and documented next to UpdateChatExecutionState.
  • Panel (R2): see round 2 log entry; re-raised narrowed by Meruem (missing back-reference at UpdateChatExecutionState), accepted by Ryosuke.

Round log

Round 1

Full panel (round 1). Netero + Law (effective additions 1860 > 1000), then 17 panel reviewers: ging-go, ging-ts, ging-react, killua, knuckle, kurapika, luffy, melody, nami, ryosuke, takumi (triggered specialists), pariston, mafuuu (core), bisky (tests changed), gon, leorio (once-per-PR floor, round 1 default), meruem (wildcard, random draw). Size target 9-12 exceeded because 11 triggered specialists plus core already total 13. Ging variants: no findings. Cross-check: convergent findings merged (CRF-4, 5, 7, 9, 13, 15, 17, 19, 20, 21, 35, 46) at the higher severity. CRF-3: kept P1; keep-argument (stack merges bottom-up, so this PR would be retargeted onto a main that already applied 000618, and every deleted-filter query fails) outweighs downgrade argument (merging into the parent branch as currently targeted is correct). Gon's 11 comment-bloat findings posted at the persona's fixed P2. Bisky Nit on family_test.go:271 moved to out of scope (pre-existing copy); its querier_test copy is covered by CRF-44. Five affirmation Notes dropped. Reviewed against e0fbe12..6f91b9b.

Round 2

Restructure round (migration renamed 000618 to 000622; coderd/audit.go and audit_internal_test.go added). Churn guard PROCEED: 39 addressed, 2 contested. Netero advisory (CRF-60 to 63); Law skipped (effective additions +114 since R1). Full panel of 18: same 17 personas as R1 (Gon and Leorio mandatory on restructure) plus Kite (wildcard, random draw from distilled set). Meruem, Ryosuke, Pariston resumed via message_agent for the dispute check-in. New: 1 P2, 6 P3, 14 P4, 1 Nit (CRF-60 to CRF-81). Re-raised: CRF-21 (Gon), CRF-46 (Meruem). Reviewed against 1e98984..3121c76.

  • Panel closure CRF-17 (R2, 3/3): Meruem, Ryosuke, and Pariston, each the agent that raised it in R1, accepted the defense. They verified every current chat read site carries the predicate (GetChats, GetChatsByChatFileID, GetChatsByWorkspaceIDs, ExtractChatParam, SetFamilyArchived, auditLogIsResourceDeleted; remaining GetChatByID callers run after ExtractChatParam). Accepted consequence: a future chat list query without the predicate shows deleted projects' archived chats to already-authorized readers until retention, forever at retention 0. Meruem noted the column would need adding to every hand-written chats_expanded CTE.
  • CRF-46 dispute (R2): Ryosuke (original agent) accepted the bulk statement; worker acquisition requires archived = false so a stale column has no effect today. Meruem (original agent) accepted the bulk statement for the lock-hold reason but re-raised narrowed: the defense says the SET list is documented next to UpdateChatExecutionState, yet that query's comment (chats.sql:2763) does not mention the copy. Fix: one back-reference line. Ryosuke raised the same back-reference as a Note.
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.

@linear-code

linear-code Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

CODAGT-1044

CODAGT-1416

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

This PR changes project delete to a tombstone that hides the project's chats at once, with a background cleanup and sweep that archive them; 1 P1, 11 P2, 17 P3, 10 P4, and 2 Nit findings.

Comment quality is a pattern in this PR: 11 new comments restate code or repeat a fact another comment holds, most often the cleanup sequence (chatprojects.sql:101, family.go:170, errors.go:53, ARCHITECTURE.md). The PR description's claim that running chats finish their current turn is also wrong; see the ARCHITECTURE.md:1023 comment.

Out of scope (needs a ticket or explicit acceptance by a human):

  • coderd/database/dump.sql:5374: a hard delete of the project owner's users row cascades the project and sets chats.project_id NULL, so a deleted project's chats become visible again as chats outside any project.
  • coderd/x/chatd/chatd.go:3243: watch events go only to the chat owner, and there is no project-level event, so sharees with no chats in a deleted project keep its sidebar folder and composer until reload, and a send from it gets 404.
  • site/src/api/queries/chats.ts:554: applyWatchedChatArchived has the same per-event refetch-and-abort pattern for family archive bursts.
  • coderd/database/dump.sql:5404: chats_parent_chat_id_fkey and chats_root_chat_id_fkey are ON DELETE SET NULL, so when retention deletes a root first, its direct child is treated as a root by LockChatProjectRootChats, GetChats, and the auto-archive index.
  • coderd/audit.go:406: auditLogIsResourceDeleted has no ResourceTypeChatProject case, so deleted chat project audit entries never show "(deleted)".
  • codersdk/chats.go:2175: ChatWatchEventKindDeleted is published for archive, not deletion, which misleads watch clients.
  • coderd/x/chatd/chatstate/family_test.go:271: an older inline pg_stat_activity poll duplicates the new waitForLockWait helper.

Notes:

  • coderd/httpmw/chatparam.go:52: the deleted-project check uses AsSystemRestricted; AsChatd also holds chat_project:read, but AsSystemRestricted matches the rest of httpmw.
  • coderd/x/chatd/projects.go:47: scheduleArchiveDebugCleanup starts one concurrent DeleteChatDebugDataByChatID per archived chat, so a large project cleanup queues one delete per chat on the connection pool.
  • 000618_chat_project_sharing.down.sql:4: rolling back permanently deletes every chat of a deleted project, including chats retention 0 would keep, with no operator warning.
  • coderd/x/chatd/projects.go:39: a delete authorized only by chat_project:delete archives and hides sharees' chats, which they cannot recover or export; the decision log records this as intended.
  • 000618_chat_project_sharing.up.sql:21: with retention 0, a deleted project's chats and memories stay in the database permanently and no API path can read or delete them.
  • coderd/chat_projects.go:282: root chats are audited as delete at request time while their rows stay until retention, and forever with retention 0; sub-chats are archived the same way but not audited.

coderd/database/queries/chats.sql:2436

P3 [CRF-1] GetChatsByWorkspaceIDs does not skip chats of a deleted project, so GET /chats/by-workspace keeps returning them until the cleanup archives them. (Netero)

This query filters only archived = false, and a tombstoned chat stays unarchived until ArchiveDeletedChatProject runs, or indefinitely with no chat daemon. chatsByWorkspace (coderd/exp_chats.go:385) can then return the hidden chat's ID instead of a visible chat on the same workspace, and that link 404s at ExtractChatParam. DeleteHidesChats checks GetChatsByWorkspace only after waiting for the cleanup, so the window is untested. Add the same NOT EXISTS (... chat_projects.deleted) clause and assert an empty result in DeleteWithoutAIGateway.

🤖

coderd/audit.go:493

P3 [CRF-18] Audit entries for chats in a deleted project, including the delete entries this PR writes, report is_deleted: false. (Pariston)

This case assumes chats are hard-deleted and calls GetChatByID, which returns the archived row of a deleted project's chat. The audit page then shows "deleted chat X" with no "(deleted)" marker and a /agents/{id} link that ExtractChatParam answers with 404, for as long as the row lives, which is forever with retention 0. Treat a chat whose project is deleted as deleted here, and update the comment.

🤖

coderd/x/chatd/chatstate/family.go:23

P4 [CRF-39] The RootID doc says ErrChatNotFound means an unknown chat, but this PR also returns it when unarchiving a root whose project is deleted. (Leorio)

A caller reading the input contract will map the error to "the chat was purged". Add: "Unarchiving a root whose project is deleted also returns [ErrChatNotFound]."

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/database/migrations/000618_chat_project_sharing.up.sql Outdated
Comment thread coderd/database/queries/chatprojects.sql Outdated
Comment thread coderd/chat_projects.go Outdated
Comment thread coderd/database/queries/chatprojects.sql
Comment thread coderd/x/chatd/chatstate/family.go Outdated
Comment thread coderd/x/chatd/chatstate/transitions.go
Comment thread coderd/x/chatd/projects_internal_test.go
Comment thread coderd/database/querier_test.go Outdated
Comment thread coderd/x/chatd/chatstate/transitions.go Outdated
Comment thread coderd/database/queries/chatprojects.sql Outdated
Moves the deletion schema into migration 000622 and addresses review:
the tombstone deletes memories and instructions and 404s on a repeat,
per-chat delete audits are dropped, the cleanup skips archived roots,
reloads only the chats it archived, and publishes project_deleted for
every project chat, and GetChatsByWorkspaceIDs and audit logs treat
deleted-project chats as gone.
@f0ssel

f0ssel commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

/coder-agents-review

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Round 2 of the chat project delete review: 39 of 41 round 1 findings are verified fixed, the per-read-site filter dispute is closed, and this round adds 1 P2, 6 P3, 14 P4, and 1 Nit, plus two re-raised threads.

Re-raised: the comment on SetFamilyArchived's project check still names the delete, and the execution-state SET list copy still has no back-reference at UpdateChatExecutionState. Two of this round's findings come from round 1 fixes: skipping archived roots opened the unarchive race, and dropping cancelChatListRefetches lets a pre-tombstone refetch re-add a deleted chat.

Out of scope (needs a ticket or explicit acceptance by a human):

  • coderd/x/chatd/chatd.go:3256: watch events go only to the chat owner, so users who see a project chat through the chat's own ACL get no project_deleted event and keep it in their sidebar until a refetch.

🤖 This review was automatically generated with Coder Agents.

spyOn(API.experimental, "getChats")
.mockResolvedValueOnce([watchedChat()])
.mockResolvedValue([]);
spyOn(API.experimental, "getChat").mockRejectedValue(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 [CRF-60] ProjectDeletedWatchEventRemovesOpenChat fails on every run, so the project_deleted handler has no working story coverage. (Netero)

The getChat mock rejects with mockApiError(...), which has no HTTP status, and AgentChatPage.tsx:721 renders AgentChatPageNotFoundView only when the status is 404, so the page shows the generic error view and findByText("Chat not found") times out. Reproduced 3 of 3 with pnpm exec vitest run --project=storybook src/pages/AgentsPage/AgentsPageLayout.stories.tsx -t 'Project Deleted Watch Event'; CI's test-js runs only --project=unit, so it does not catch this. Reject with { ...mockApiError({ message: "Resource not found." }), status: 404 }; with that change the story passes, and it fails when the project_deleted branch in AgentsPageLayout.tsx is disabled.

🤖

Comment thread coderd/audit.go
}
}
return false
case database.ResourceTypeChatProject:

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.

P4 [CRF-61] The new ResourceTypeChatProject case has no test. (Netero)

TestAuditLogIsResourceDeleted covers only ResourceTypeChat, so if this case regresses, a deleted project's delete audit entry reports is_deleted: false and nothing fails. Add a row that mocks GetChatProjectByID returning sql.ErrNoRows and expects true.

🤖

case <-ctx.Done():
return
}
w.sweepDeletedChatProjects(ctx)

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.

P4 [CRF-62] No test reaches the sweep through deletedChatProjectsLoop's ticker. (Netero)

Coverage shows this call never runs: TestSweepDeletedChatProjects calls the method directly and testOptions sets the interval to an hour. This loop is the only retry for failed cleanups and for deletes on a replica that stopped, so miswiring it would leave deleted projects' chats unarchived with no failing test. Advance the mock clock by the sweep interval and assert the chat is archived.

🤖

delete(pending, event.Chat.ID)
}
case <-ctx.Done():
t.Fatalf("missing removed events for %v", pending)

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.

P4 [CRF-63] The failure message "missing removed events for %v" still uses the event name from before the rename. (Netero)

The loop waits for ChatWatchEventKindProjectDeleted; change the message to "missing project_deleted events for %v".

🤖

WHERE id = @id::uuid AND NOT deleted
RETURNING id
),
memories AS (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3 [CRF-64] A memory insert or instructions upsert that is in flight when the tombstone runs still commits, and the row stays on the deleted project with no API path to remove it. (Knuckle, Kurapika, Takumi, Kite, Melody, Mafuuu)

The writers (writeProjectMemories, putChatProjectInstructions) check the project with a plain GetChatProjectByID, their foreign key check takes FOR KEY SHARE, which does not conflict with the tombstone's UPDATE, and these CTEs delete only rows visible in the statement's snapshot. Reproduced independently by several reviewers on Postgres 13 and 16 and through chattool.InsertProjectMemory: the tombstone returned without waiting and the deleted project kept 1 memory, which dbpurge never removes while retention is 0. Locking the project FOR SHARE in the writers alone is not enough: the tombstone then waited but this CTE kept its pre-wait snapshot and still left the memory. Have both writers read the project WHERE id = @project_id AND NOT deleted FOR SHARE in their transaction (also add NOT deleted to UpsertChatProjectInstructions' SELECT), and run the tombstone as UPDATE ... RETURNING id followed by the two DELETEs as separate statements in one InTx; that sequence left 0 rows.

🤖

AND root.project_id IS NOT NULL
AND chats.project_id IS NULL;

COMMENT ON COLUMN chats.project_id IS 'Sub-chats carry their root chat''s project; an insert trigger sets it and replaces any passed value.';

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.

P4 [CRF-77] The new chats.project_id comment documents only sub-chats, so the column no longer says what it means for a root chat. (Gon)

sqlc copies it to ChatTable.ProjectID. Suggested: 'Optional project the chat belongs to. Sub-chats carry their root chat''s project; an insert trigger sets it and replaces any passed value.'

🤖

name={project.name}
confirmLoading={deleteMutation.isPending}
info="Its memories will be deleted. Chats in it, including chats started by people it is shared with, will be kept and move back to each owner's Chats list."
info="Every chat in this project, including chats started by people it is shared with, is hidden right away, and running chats are stopped. If conversation retention is enabled, the chats are permanently deleted when the retention period passes."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3 [CRF-78] The delete dialog no longer says the project's memories and instructions are deleted, and they are now deleted the moment the user confirms. (Leorio, Gon)

The base text began "Its memories will be deleted."; the new text covers only chats, so an owner can conclude the project's contents survive for the retention period when its memories and instructions are gone with no restore path. Start the text with "Its memories and instructions are deleted now." The PR description's UI bullet should say the same.

🤖

}
confirmLoading={deleteProjectMutation.isPending}
info="Its memories will be deleted. Chats in it, including chats started by people it is shared with, will be kept and move back to each owner's Chats list."
info="Every chat in this project, including chats started by people it is shared with, is hidden right away, and running chats are stopped. If conversation retention is enabled, the chats are permanently deleted when the retention period passes."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3 [CRF-79] Same omission as the project page dialog: this text no longer says memories and instructions are deleted at once. (Gon, Leorio)

Apply the same sentence so both dialogs stay identical.

🤖

Comment thread coderd/audit.go
// Chats are hard-deleted, so a 404 means deleted.
_, err := api.Database.GetChatByID(ctx, alog.AuditLog.ResourceID)
// Chats are hard-deleted, so a 404 means deleted. A deleted
// project's chats keep their rows until retention but are gone.

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.

P4 [CRF-80] "A deleted project's chats keep their rows until retention but are gone" says the rows exist and are gone in one sentence. (Leorio)

Suggested: "A chat in a deleted project keeps its row until chat retention removes it, but counts as deleted because no API can read it."

🤖

Comment thread coderd/audit.go
return false
}
if chat.ProjectID.Valid {
//nolint:gocritic // Only whether the project is deleted is used.

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-81] The //nolint:gocritic justification says what the result is used for, not why the system context is needed. (Kite)

Use the reason ExtractChatParam gives at httpmw/chatparam.go:51 (the viewer may not be able to read the project) here and at line 515.

🤖

f0ssel added a commit that referenced this pull request Oct 14, 2026
Database and authorization layer for chat project sharing (experiment
`chat-projects`), part 1 of 2. #30439 adds the API on top of it. Nothing
here changes behavior: projects start with empty ACLs, no endpoint
writes them yet, and deleting a project still detaches its chats.
Project deletion is in #30683.

- **Schema:** migration `000621` adds `user_acl` and `group_acl` jsonb
columns to `chat_projects`, typed as `ChatACL` like chats, with GIN
indexes. It also adds the `chat_project:share` API key scope.
- **Queries:**
- `GetChatProjectsOwnedOrSharedWithUserID`: owned projects plus projects
with an ACL entry for the user directly, through a group, or through the
Everyone group (whose ID is the organization ID). Entries count only for
members of the project's organization. Keys match regardless of actions,
so dbauthz filters the rows.
- `IsChatProjectAccessibleByUserID`: whether a user owns a project or
holds a `read` or `*` grant on it, for code running under a system
subject. With sharing disabled, dbauthz answers owner-only.
- `GetChatProjectByIDForUpdate`: lets the ACL update read, modify, and
write in one transaction.
  - `UpdateChatProjectACLByID`.
- **RBAC:**
- New `share` action on `chat_project`. dbauthz requires it to update
the ACL.
- `ChatProject.RBACObject` attaches the project ACL. Memory objects get
a derived ACL: project `read` grants memory read, and `read` with
`update` grants memory create and delete. Wildcard grants count. Read
sharees change memories through their chats' agents, which #30439 gates.
Project instructions authorize through the same object, so a `read`
grant reads them and an `update` grant edits them.
- `--disable-chat-sharing` also covers projects: ACLs are ignored, the
ACL update is rejected, and roles deny `chat_project:share`. The help
text and chat sharing docs say so.
- **Audit:** `user_acl` and `group_acl` changes are tracked.
- **Tests:**
- Querier tests run the access queries both raw and through dbauthz
(owner, direct user, group, Everyone, wildcard grants, a member of
another org, grants without read).
- RBAC object and memory ACL tests, with an authorizer check, in both
sharing modes.
- dbauthz cases including the disabled guards, and the
`chat_project:share` negation in the roles test.

The generated TypeScript changes only add the new scope value.

> Generated by Coder Agents.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant