What
"Who is assigned to this task" is stored twice: legacy taskItems.assigneeId/assigneeAgentId (packages/db/src/schema/tasks.ts:75-76, marked [DEPRECATED: use taskAssignees]) and the task_assignees junction (tasks.ts:104-120). The schema comment (:101-103) promises the legacy columns are "kept in sync with the first user/agent assignee" — by convention, enforced nowhere.
The sync is hand-reimplemented per writer
api/pages/[pageId]/tasks/[taskId]/route.ts:296-341 — delete+reinsert junction rows, then mirror first user/agent into the legacy columns.
lib/ai/tools/task-helpers.ts:147-171,477-499 — the same full-replace + first-assignee mirroring, implemented separately for AI tools.
tasks/route.ts:487,521 — create path, again.
The smoking gun: a writer that updates only one side
apps/web/src/lib/onboarding/drive-setup.ts:92-102 (and its duplicated copy apps/admin/src/lib/onboarding/drive-setup.ts) inserts taskItems with assigneeId: task.assignee === 'self' ? userId : null and never inserts a task_assignees row. Onboarding-seeded tasks are assigned in the legacy column but unassigned in the canonical junction. Readers consume both representations (api/pages/[pageId]/tasks/route.ts:161,183 selects/filters legacy assigneeId; task-list-types.ts:56 "Legacy single-assignee relations (backward compat)"; pulse/activity routes), so the two views of the same task disagree from the moment a workspace is created.
Proposed fix
- Immediate: fix
drive-setup.ts (both copies) to write the junction row, or seed via the existing task-create service instead of raw inserts.
- Real fix: collapse — either drop the legacy columns and migrate readers to the junction, or (if backward compat must persist) extract one shared
setTaskAssignees(tx, taskId, users, agents) helper that owns the dual-write, and make raw taskItems inserts/updates of assignee columns a lint error.
Filed from the sources-of-truth audit.
What
"Who is assigned to this task" is stored twice: legacy
taskItems.assigneeId/assigneeAgentId(packages/db/src/schema/tasks.ts:75-76, marked[DEPRECATED: use taskAssignees]) and thetask_assigneesjunction (tasks.ts:104-120). The schema comment (:101-103) promises the legacy columns are "kept in sync with the first user/agent assignee" — by convention, enforced nowhere.The sync is hand-reimplemented per writer
api/pages/[pageId]/tasks/[taskId]/route.ts:296-341— delete+reinsert junction rows, then mirror first user/agent into the legacy columns.lib/ai/tools/task-helpers.ts:147-171,477-499— the same full-replace + first-assignee mirroring, implemented separately for AI tools.tasks/route.ts:487,521— create path, again.The smoking gun: a writer that updates only one side
apps/web/src/lib/onboarding/drive-setup.ts:92-102(and its duplicated copyapps/admin/src/lib/onboarding/drive-setup.ts) insertstaskItemswithassigneeId: task.assignee === 'self' ? userId : nulland never inserts atask_assigneesrow. Onboarding-seeded tasks are assigned in the legacy column but unassigned in the canonical junction. Readers consume both representations (api/pages/[pageId]/tasks/route.ts:161,183selects/filters legacyassigneeId;task-list-types.ts:56"Legacy single-assignee relations (backward compat)"; pulse/activity routes), so the two views of the same task disagree from the moment a workspace is created.Proposed fix
drive-setup.ts(both copies) to write the junction row, or seed via the existing task-create service instead of raw inserts.setTaskAssignees(tx, taskId, users, agents)helper that owns the dual-write, and make rawtaskItemsinserts/updates of assignee columns a lint error.Filed from the sources-of-truth audit.