Bug Report: Deleting project with archived threads fails with "cannot be deleted without force=true"
Summary
When attempting to delete a project that contains only archived threads, the UI shows "No threads yet" and sends project.delete without force=true. The server rejects this because archived threads are still considered "active" (not deleted), causing a confusing invariant error. This is a client-server state synchronization bug caused by inconsistent definitions of what makes a project "empty."
Steps to Reproduce
- Create a project.
- Create one or more threads in that project.
- Archive all threads in the project.
- Wait for a shell snapshot reload (e.g. reconnect, restart app, or trigger rebootstrap).
- Right-click the project and select Remove project.
- The confirmation dialog shows "This removes only this project entry" (simple dialog, not the force-delete warning).
- Click Yes.
- Error toast appears:
Project '...' is not empty and cannot be deleted without force=true.
Expected Behavior
Either:
- Option A: The client should detect archived threads and show the force-delete confirmation dialog ("Delete anyway"), then send
project.delete with force=true.
- Option B: The server should consider a project with only archived threads as "empty" for deletion purposes, since archived threads are already hidden from the user.
Actual Behavior
The client thinks the project is empty (0 visible threads) and sends a plain project.delete command. The server sees archived threads with deletedAt === null and rejects the command.
Root Cause Analysis
Server-side logic
In apps/server/src/orchestration/decider.ts (lines 169-172):
const activeThreads = listThreadsByProjectId(readModel, command.projectId).filter(
(thread) => thread.deletedAt === null,
);
if (activeThreads.length > 0 && command.force !== true) {
return yield* new OrchestrationCommandInvariantError({
commandType: command.type,
detail: `Project '${command.projectId}' is not empty and cannot be deleted without force=true.`,
});
}
The server considers any thread with deletedAt === null as making the project non-empty. Archived threads (archivedAt !== null) are included because they are not deleted.
Client-side logic
In apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts (lines 346-374), the shell snapshot query listActiveThreadRows filters with:
WHERE deleted_at IS NULL
AND archived_at IS NULL
This means archived threads are excluded from the shell snapshot sent to the client.
In apps/web/src/store.ts (lines 1084-1122), syncEnvironmentShellSnapshot completely rebuilds the client's threadIdsByProjectId from the snapshot:
let nextState: EnvironmentState = {
...state,
...buildProjectState(nextProjects),
threadIds: [],
threadIdsByProjectId: {},
// ...
};
After a snapshot reload, archived threads disappear from the client's view.
In apps/web/src/components/Sidebar.tsx (lines 1085-1099), memberThreadCountByPhysicalKey is derived from projectThreads, which comes from selectSidebarThreadsForProjectRefs. Since archived threads are no longer in threadIdsByProjectId, the count is 0.
In apps/web/src/components/Sidebar.tsx (lines 1315-1371), handleRemoveProject checks:
const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0;
if (memberThreadCount > 0) {
// Show force-delete warning with "Delete anyway"
} else {
// Show simple confirmation dialog, call removeProject(member) WITHOUT force=true
}
Because memberThreadCount is 0, the client skips the force-delete path and sends project.delete without force=true, which the server rejects.
Inconsistent state sources
The client has two sources of truth for thread membership:
- Event-driven updates: When
thread.archived is received, the reducer (apps/web/src/store.ts:1293-1298) updates archivedAt but does not remove the thread from threadIdsByProjectId.
- Snapshot-driven resets: When a shell snapshot is received,
threadIdsByProjectId is rebuilt from scratch, excluding archived threads.
This creates a synchronization gap: after a snapshot reload, the client and server have fundamentally different views of which threads belong to a project.
Evidence
- Error toast:
Project is not empty and cannot be deleted without force=true.
- Confirmation dialog shows the simple "Remove project?" prompt instead of the force-delete warning with "Delete anyway".
- The project sidebar displays "No threads yet" even though the server knows about threads.
Suggested Fix
Recommended approach: Align the client's thread counting with the server's deletion logic. The client should query the full thread list (including archived threads) when deciding whether to prompt for force deletion, or the server should exclude archived threads from the "non-empty" check.
Option A (Client fix, preferred for UX consistency)
In apps/web/src/components/Sidebar.tsx, when determining if a project is empty for deletion purposes, the client should consider threads that exist on the server even if they are archived. This could be done by:
- Including archived threads in
memberThreadCountByPhysicalKey when checking for project deletion eligibility, OR
- Querying the server for the actual thread count before attempting deletion.
Option B (Server fix)
In apps/server/src/orchestration/decider.ts, change the "active threads" check to exclude archived threads:
const activeThreads = listThreadsByProjectId(readModel, command.projectId).filter(
(thread) => thread.deletedAt === null && thread.archivedAt === null,
);
This would align the server's "empty project" definition with the client's visible state. However, this might have implications for other operations that expect archived threads to still block deletion.
Option C (Projection/Snapshot fix)
Include archived threads in the shell snapshot's threadIdsByProjectId but mark them with archivedAt. The client already knows how to filter archived threads for display; it just needs to know they exist for deletion checks.
Environment
- App: T3 Code Nightly
- OS: macOS
Additional Notes
- The
decider.delete.test.ts does not include a test case for deleting a project with archived threads.
- The
ProjectionSnapshotQuery tests do not verify that archived threads are excluded from the shell snapshot while still being considered "active" by the decider.
Bug Report: Deleting project with archived threads fails with "cannot be deleted without force=true"
Summary
When attempting to delete a project that contains only archived threads, the UI shows "No threads yet" and sends
project.deletewithoutforce=true. The server rejects this because archived threads are still considered "active" (not deleted), causing a confusing invariant error. This is a client-server state synchronization bug caused by inconsistent definitions of what makes a project "empty."Steps to Reproduce
Project '...' is not empty and cannot be deleted without force=true.Expected Behavior
Either:
project.deletewithforce=true.Actual Behavior
The client thinks the project is empty (0 visible threads) and sends a plain
project.deletecommand. The server sees archived threads withdeletedAt === nulland rejects the command.Root Cause Analysis
Server-side logic
In
apps/server/src/orchestration/decider.ts(lines 169-172):The server considers any thread with
deletedAt === nullas making the project non-empty. Archived threads (archivedAt !== null) are included because they are not deleted.Client-side logic
In
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts(lines 346-374), the shell snapshot querylistActiveThreadRowsfilters with:This means archived threads are excluded from the shell snapshot sent to the client.
In
apps/web/src/store.ts(lines 1084-1122),syncEnvironmentShellSnapshotcompletely rebuilds the client'sthreadIdsByProjectIdfrom the snapshot:After a snapshot reload, archived threads disappear from the client's view.
In
apps/web/src/components/Sidebar.tsx(lines 1085-1099),memberThreadCountByPhysicalKeyis derived fromprojectThreads, which comes fromselectSidebarThreadsForProjectRefs. Since archived threads are no longer inthreadIdsByProjectId, the count is 0.In
apps/web/src/components/Sidebar.tsx(lines 1315-1371),handleRemoveProjectchecks:Because
memberThreadCountis 0, the client skips the force-delete path and sendsproject.deletewithoutforce=true, which the server rejects.Inconsistent state sources
The client has two sources of truth for thread membership:
thread.archivedis received, the reducer (apps/web/src/store.ts:1293-1298) updatesarchivedAtbut does not remove the thread fromthreadIdsByProjectId.threadIdsByProjectIdis rebuilt from scratch, excluding archived threads.This creates a synchronization gap: after a snapshot reload, the client and server have fundamentally different views of which threads belong to a project.
Evidence
Project is not empty and cannot be deleted without force=true.Suggested Fix
Recommended approach: Align the client's thread counting with the server's deletion logic. The client should query the full thread list (including archived threads) when deciding whether to prompt for force deletion, or the server should exclude archived threads from the "non-empty" check.
Option A (Client fix, preferred for UX consistency)
In
apps/web/src/components/Sidebar.tsx, when determining if a project is empty for deletion purposes, the client should consider threads that exist on the server even if they are archived. This could be done by:memberThreadCountByPhysicalKeywhen checking for project deletion eligibility, OROption B (Server fix)
In
apps/server/src/orchestration/decider.ts, change the "active threads" check to exclude archived threads:This would align the server's "empty project" definition with the client's visible state. However, this might have implications for other operations that expect archived threads to still block deletion.
Option C (Projection/Snapshot fix)
Include archived threads in the shell snapshot's
threadIdsByProjectIdbut mark them witharchivedAt. The client already knows how to filter archived threads for display; it just needs to know they exist for deletion checks.Environment
Additional Notes
decider.delete.test.tsdoes not include a test case for deleting a project with archived threads.ProjectionSnapshotQuerytests do not verify that archived threads are excluded from the shell snapshot while still being considered "active" by the decider.