TASK_AXES_DESIGN.md56.1 KBView on GitHub # Task axes: delete `taskType`, unify the output axis
## 1) Introduction — goal, present state, future state
A task has exactly two properties worth modelling: **which bucket does the user file it under** and **what artifact does finishing it produce**. Today those two questions are answered by three overlapping columns — `task_type` (a closed 10-value enum), `task_channel` (a 5-value enum), and `task_action_data` (a 6-variant discriminated union) — whose members disagree with each other, whose categorical half duplicates the task-groups feature that shipped later, and whose CRM outputs have no union member at all, so their payloads are JSON-encoded into the free-text `notes` column and queried with `(notes)::jsonb->>'fieldName'` casts. We are collapsing this to two columns: **`task_group_id`** owns categorisation (per-user, editable, AI-routed, already live for 177 users), and a single **`task_output`** jsonb owns "what does this produce" — the existing `task_action_data` column renamed, its discriminant widened to absorb `crm-field`, `crm-opportunity`, and `file`, and made non-null so it declares intent from creation rather than only recording a result. `task_channel` is folded into it and dropped; `task_type`'s 66,544 historical rows are backfilled onto task groups and the column is dropped outright, so groups are the categorical axis for all time rather than only for rows written after today.
## 2) Present state
### 2.1 Architecture diagram
```text
┌──────────────────────────────────────┐
│ user_tasks row │
└──────────────────────────────────────┘
│
┌─────────────────────────────┼─────────────────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌──────────────────┐ ┌───────────────────┐
│ task_type │ │ task_channel │ │ task_action_data │
│ (10 values) │ │ (5 values) │ │ (6 variants) │
├─────────────────┤ ├──────────────────┤ ├───────────────────┤
│ response │ │ email │ │ email 37866│
│ follow-up │ │ slack │ │ recommendation2630│
│ reminder │ │ multi-action │ │ slack 665│
│ post-meeting │ │ linkedin │ │ calendar 0│ DEAD
│ pre-meeting │ │ whatsapp │ │ linkedin 0│ DEAD
│ reactivation │ │ │ │ whatsapp 0│ DEAD
│ manual │ │ no calendar │ │ │
│ calendar │ │ no recommend. │ │ no multi-action │
│ crm-opportunity │ │ no crm-* │ │ no crm-* │
│ field-approval │ │ no file │ │ no file │
└────────┬────────┘ └────────┬─────────┘ └─────────┬─────────┘
│ │ │
│ └────── SAME AXIS, TWICE ─────┘
│ enums disagree; neither is a superset;
│ 28 rows channel=slack + payload=email
│
│ categorical half structural half
│ (6 values) (crm-opportunity, field-approval)
▼ │
┌─────────────────────┐ ▼
│ DUPLICATES │ ┌──────────────────────────┐
│ task_groups │ │ payload has nowhere to │
│ (7 defaults, live, │ │ live → JSON.stringify │
│ AI-routed, 177 │ │ into `notes` (text), then│
│ users) │ │ queried with ::jsonb │
└─────────────────────┘ │ casts. 1,890 rows. │
│ └──────────────────────────┘
▼
┌────────────────────────────────────────────────────────────────┐
│ downstream readers of task_type │
│ Gmail labels · playbook LABEL RULES · daily recap · draft │
│ analytics · admin timeline · TimelineTaskItem action buttons │
└────────────────────────────────────────────────────────────────┘
```
### 2.2 Step-by-step walkthrough
Two paths matter: how a **CRM-field approval** task is written and read, and how an **email** task becomes a labelled Gmail draft.
#### Path A — a field-approval task
1. **Agent proposes a field change** — `updateConversationFieldsTool` at [updateConversationFieldsTool.ts:1036](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts). It cancels prior duplicates with a jsonb cast over a *text* column, then creates the task.
- Cancellation predicate: `` sql`(${userTasksTable.notes})::jsonb->>'isStageBatch' = 'true'` `` at [updateConversationFieldsTool.ts:1028](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts)
- Row written:
```json
{
"taskChannel": "multi-action",
"taskType": "field-approval",
"description": "Approval required: move to Scoping (3 fields)",
"notes": "{\"isStageBatch\":true,\"conversationId\":\"…\",\"batchFields\":[…]}",
"taskActionData": null
}
```
- `taskActionData` is null for **1,784 of 1,784** field-approval rows.
2. **Routing** — `routeTaskToGroup` at [routeTaskToGroup.ts:73](apps/server/src/services/task-groups/routeTaskToGroup.ts) files it into a group. `draft.taskType` reaches the classifier prompt at [routeTaskToGroup.ts:157](apps/server/src/services/task-groups/routeTaskToGroup.ts) as advisory text only.
- Data after this step:
```json
{ "taskGroupId": "…CRM updates…", "routedBy": "ai", "confidence": 0.95 }
```
3. **Render** — `TimelineTaskItem` at [TimelineTaskItem.tsx:233](apps/mail/modules/conversations/components/timeline/TimelineTaskItem.tsx) recovers the payload by parsing the text column:
```ts
if (task.taskType !== 'field-approval' || !task.notes) return null;
try { const parsed = JSON.parse(task.notes) as Record<string, unknown>; … }
catch { /* malformed notes */ }
```
- Two shapes share one text field: single-field (`fieldName`, `value`, `fieldLabel`, `valueLabel`) and stage-batch (`isStageBatch`, `batchFields`).
- Branch: `fieldChangePayload` → an "apply" badge wired to `applyFieldChange`; `isCrmOpportunityTask` at [TimelineTaskItem.tsx:251](apps/mail/modules/conversations/components/timeline/TimelineTaskItem.tsx) → an "open in chat" badge. Both blocks are **duplicated** across the `canvasMode` branch at [TimelineTaskItem.tsx:375](apps/mail/modules/conversations/components/timeline/TimelineTaskItem.tsx) and the default branch at [TimelineTaskItem.tsx:528](apps/mail/modules/conversations/components/timeline/TimelineTaskItem.tsx).
4. **Apply** — `applyFieldChange` at [user-tasks.ts:2020](apps/server/src/trpc/routes/user-tasks.ts) writes the field, then de-dupes siblings with another text-column jsonb cast at [user-tasks.ts:2244](apps/server/src/trpc/routes/user-tasks.ts):
```ts
eq(userTasks.taskType, 'field-approval'),
sql`(${userTasks.notes})::jsonb->>'fieldName' = ${payload.fieldName}`
```
5. **Other readers of the same shape**, each re-deriving it independently:
- `crm.ts` at [crm.ts:1106](apps/server/src/trpc/routes/crm.ts) maps `taskType` → `kind: 'crm_opportunity' | 'field_approval'`
- `external-crm-events.ts` at [external-crm-events.ts:457](apps/server/src/services/crm/external-crm-events.ts) and [:535](apps/server/src/services/crm/external-crm-events.ts)
- `conversations.ts` at [conversations.ts:5418](apps/server/src/services/crm/conversations.ts)
- `build-invocation-prompt.ts` at [build-invocation-prompt.ts:53](apps/server/src/services/tasks/build-invocation-prompt.ts) swaps the agent prompt header on `crm-opportunity`
- `pre-execution-setup.ts` at [pre-execution-setup.ts:149](apps/server/src/mastra/workflows/event-execution/pre-execution-setup.ts) already calls this axis `exemptOutputTypes` — the codebase names it *output* here
#### Path B — an email task becomes a labelled Gmail draft
6. **Draft tool** — `onEventExecutionDraftEmailTool` at [onEventExecutionDraftEmailTool.ts:170](apps/server/src/mastra/tools/event-execution/onEventExecutionDraftEmailTool.ts) accepts `taskType` via `z.enum(TASK_TYPES)` and passes it down.
7. **Label name derivation** — `getTaskTypeLabel` at [task-type-labels.ts:56](apps/server/src/services/mail/labels/task-type-labels.ts):
- `NON_EMAIL_TASK_TYPES = new Set(['crm-opportunity', 'field-approval'])` at [task-type-labels.ts:40](apps/server/src/services/mail/labels/task-type-labels.ts) returns null — the file already separates *category* from *structure*
- Otherwise `TASK_TYPE_LABEL_OVERRIDES[taskType] ?? 'Cedar/Task/' + toTitleCase(taskType)`
- Data after this step:
```json
{ "taskType": "follow-up", "label": "Cedar/Task/Follow-up" }
```
8. **Apply to Gmail** — `applyCedarMailDraftLabel` at [google.ts:2751](apps/server/src/lib/driver/google.ts), called from three sites ([google.ts:3310](apps/server/src/lib/driver/google.ts), [:3424](apps/server/src/lib/driver/google.ts), [:3483](apps/server/src/lib/driver/google.ts)). Pre-seeds `Cedar/Agent drafts` + the task-type label, then adds both.
9. **Strip on send** — [google.ts:2975](apps/server/src/lib/driver/google.ts) cannot know the task type, so it iterates `Object.values(TASK_TYPE_LABEL_OVERRIDES)` and removes any it finds. The comment at [google.ts:2977](apps/server/src/lib/driver/google.ts) concedes this misses generically-derived labels — **a known, documented bug**: a thread whose label was derived rather than overridden keeps a stale label after send.
10. **Playbook prose** — `buildLabelRulesProse` at [label-rules.ts:77](apps/server/src/services/playbook/templates/label-rules.ts), seeded from the full enum at [seed-playbook.ts:183](apps/server/src/services/playbook/seed-playbook.ts).
11. **Reporting** — three independent re-implementations of the same mapping:
- `categorizeTask` at [recap-helpers.ts:165](apps/server/src/services/recap/recap-helpers.ts)
- `draftedByTaskType` at [draft-analytics.ts:1539](apps/server/src/services/analytics/draft-analytics.ts) and `taskTypeBreakdown` at [:1468](apps/server/src/services/analytics/draft-analytics.ts)
- a third copy in the admin route at [admin.ts:286](apps/server/src/trpc/routes/admin.ts)
#### Live data snapshot (staging, 2026-08-04)
```json
{
"total_tasks": 78934,
"task_channel_vs_payload": {
"email": { "rows": 71146, "payload_null": 31112, "payload_set": 40034 },
"multi-action": { "rows": 5864, "payload_null": 5497, "payload_set": 367 },
"slack": { "rows": 1923, "payload_null": 1074, "payload_set": 849 },
"linkedin": { "rows": 1, "payload_null": 1, "payload_set": 0 }
},
"_note": "payload_null is not redundancy — it is 'intended output, not yet produced'.
One jsonb column expresses both: {kind} alone, then {kind, draftId} once produced.",
"task_action_data_variants_written": { "email": 37866, "recommendation": 2630, "slack": 665,
"calendar": 0, "linkedin": 0, "whatsapp": 0 },
"field_approval": { "rows": 1784, "payload_null": 1784, "notes_present": 1780 },
"crm_opportunity": { "rows": 106, "payload_null": 106 },
"task_type_calendar": { "rows": 178, "task_channel": "email", "uses_CalendarTaskActionData": 0 },
"incoherent_pairs": { "channel=slack,payload=email": 28, "channel=email,payload=slack": 9 },
"task_type_backfill_target": {
"rows_without_a_group": 68948,
"of_which_have_a_task_type": 66544,
"of_which_have_neither": 2404
}
}
```
## 3) Designed state
### 3.1 Architecture diagram
```text
┌──────────────────────────────────────┐
│ user_tasks row │
└──────────────────────────────────────┘
│
┌─────────────────────┴─────────────────────┐
▼ ▼
═══════ CATEGORICAL AXIS (one column) ═══════ ═══════ OUTPUT AXIS (one column) ═══════
┌────────────────────────────────────┐ ┌───────────────────────────────────────┐
│ task_group_id ──FK──► task_groups │ │ task_output jsonb NOT NULL │
│ │ │ │
│ per-user · editable · AI-routed │ │ { kind } → intended │
│ routing_criteria · icon · color │ │ { kind, …payload } → produced │
│ overdue_policy · agent_visible │ ├───────────────────────────────────────┤
│ null = virtual Misc lane │ │ email · slack · calendar │
│ │ │ linkedin · whatsapp │
│ 66,544 legacy task_type rows │ │ crm-field │
│ backfilled in → groups cover │ │ crm-opportunity │
│ ALL history, not just new rows │ │ file │
└──────────────┬─────────────────────┘ │ recommendation · none │
│ └──────────────┬────────────────────────┘
│ │
▼ ▼
┌────────────────────────────────┐ ┌───────────────────────────────────────┐
│ Gmail label Cedar/Task/{Name} │ │ typed payload — no JSON.parse of │
│ playbook LABEL RULES │ │ `notes`, no ::jsonb text casts, │
│ daily recap sections │ │ no cross-column agreement constraint │
│ draft analytics series │ │ TimelineTaskItem switches on kind │
└────────────────────────────────┘ └───────────────────────────────────────┘
task_type → backfilled into task_group_id, then DROP COLUMN
task_channel → folded into task_output.kind, then DROP COLUMN
```
### 3.2 Step-by-step walkthrough
1. **Agent proposes a field change** — `updateConversationFieldsTool` at [updateConversationFieldsTool.ts:1036](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts) writes a typed output instead of a stringified blob.
- Row written:
```json
{
"description": "Approval required: move to Scoping (3 fields)",
"notes": null,
"taskOutput": {
"kind": "crm-field",
"mode": "stage-batch",
"batchFields": [{ "fieldName": "stage", "value": "scoping", "fieldLabel": "Stage", "valueLabel": "Scoping" }]
}
}
```
- Dedupe predicate becomes a real jsonb path on a real jsonb column: `` sql`${userTasks.taskOutput}->>'mode' = 'stage-batch'` ``
2. **An email task declares intent at creation, result later** — the same column carries both states, so there is nothing to keep in sync.
- At creation, before the agent drafts:
```json
{ "taskOutput": { "kind": "email" } }
```
- After `onEventExecutionDraftEmailTool` produces the draft:
```json
{ "taskOutput": { "kind": "email", "threadId": "18f…", "draftId": "r-45…" } }
```
- `hasDraft` at [TimelineTaskItem.tsx:87](apps/mail/modules/conversations/components/timeline/TimelineTaskItem.tsx) already tests exactly this — payload presence *is* the produced signal today, so no new flag is needed.
3. **Routing** — `routeTaskToGroup` at [routeTaskToGroup.ts:73](apps/server/src/services/task-groups/routeTaskToGroup.ts) unchanged in shape; the prompt line `type: …` at [routeTaskToGroup.ts:157](apps/server/src/services/task-groups/routeTaskToGroup.ts) is replaced by `output: crm-field`, a stronger signal than the old free-text type.
4. **Render** — `TimelineTaskItem` switches on `taskOutput.kind`. One shared `renderOutputAction(task)` helper replaces the two duplicated blocks at [TimelineTaskItem.tsx:375](apps/mail/modules/conversations/components/timeline/TimelineTaskItem.tsx) and [:528](apps/mail/modules/conversations/components/timeline/TimelineTaskItem.tsx).
```ts
switch (task.taskOutput.kind) {
case 'crm-field': return <ApplyFieldChangeBadge data={task.taskOutput} />
case 'crm-opportunity': return <OpenOpportunityInChatBadge data={task.taskOutput} />
case 'email': return <ExecuteBadge />
…
}
```
- `JSON.parse(task.notes)` and its `catch { /* malformed notes */ }` are deleted.
5. **Apply** — `applyFieldChange` at [user-tasks.ts:2020](apps/server/src/trpc/routes/user-tasks.ts) reads `taskOutput` directly; the sibling-cancel predicate at [user-tasks.ts:2244](apps/server/src/trpc/routes/user-tasks.ts) becomes `` sql`${userTasks.taskOutput}->>'kind' = 'crm-field'` `` plus a jsonb path, with no text cast.
6. **Gmail label name** — new `getTaskGroupLabel` in `apps/server/src/services/mail/labels/task-group-labels.ts` replaces [task-type-labels.ts:56](apps/server/src/services/mail/labels/task-type-labels.ts).
- Rule, uniformly for every group, default or custom: `Cedar/Task/{slugified group name}`
- Returns null unless `taskOutput.kind === 'email'` — the same exclusion `NON_EMAIL_TASK_TYPES` performs today, now keyed off the output axis where it belongs.
- Data after this step:
```json
{ "groupName": "Post-initial meeting emails", "label": "Cedar/Task/Post-initial-meeting-emails" }
```
7. **Apply to Gmail** — `applyCedarMailDraftLabel` at [google.ts:2751](apps/server/src/lib/driver/google.ts) takes `taskGroupName` in place of `taskType`; the three call sites pass the task's group.
8. **Strip on send** — the fixed-override loop at [google.ts:2975](apps/server/src/lib/driver/google.ts) is replaced by a lookup of the user's own groups (a bounded list, ~7 rows, already cached by `listGroups`). **This closes the documented bug at [google.ts:2977](apps/server/src/lib/driver/google.ts)** — every Cedar task label becomes strippable, not just the six legacy ones.
9. **Group rename propagates** — `taskGroups.updateGroup` in [task-groups.ts](apps/server/src/trpc/routes/task-groups.ts) gains a hook: renaming a group renames its Gmail label via `labels.patch`, which preserves the label id so already-labelled threads follow the rename. Without this, renaming a group would orphan every previously applied label.
10. **Playbook prose** — `buildLabelRulesProse` at [label-rules.ts:77](apps/server/src/services/playbook/templates/label-rules.ts) takes the user's groups instead of `TASK_TYPES`; [seed-playbook.ts:183](apps/server/src/services/playbook/seed-playbook.ts) passes `listGroups(userId)`.
11. **Reporting** — one mapping, three consumers. `categorizeTask` at [recap-helpers.ts:165](apps/server/src/services/recap/recap-helpers.ts), `draftedByTaskType` at [draft-analytics.ts:1539](apps/server/src/services/analytics/draft-analytics.ts), and the admin copy at [admin.ts:286](apps/server/src/trpc/routes/admin.ts) all switch to `(taskGroupName, taskOutput.kind)`.
- Cross-user rollups survive: all 177 users share the same 7 seeded group names. Custom lanes fall into a `custom` bucket.
- **No historical branch.** Because the 66,544 legacy rows are backfilled onto groups in Phase 8, analytics reads one axis across all time rather than switching on row age.
12. **Historical backfill** — `backfill-task-type-to-group.ts` maps each legacy `task_type` onto the equivalent seeded group, then the column is dropped.
```text
response → Responses needed post-meeting → Post-meeting followups
follow-up → Follow-ups reactivation → Reactivation
pre-meeting → Follow-ups crm-opportunity → CRM updates
reminder → Follow-ups field-approval → CRM updates
manual → (router, or Misc) calendar → (router, or Misc)
```
- 2,404 rows have neither a group nor a type and stay in the virtual Misc lane.
### 3.3 Schema
Full schema:
```ts
// ─────────────────────────────────────────────────────────────────────────────
// OUTPUT AXIS — one column. Replaces TASK_CHANNELS + task_action_data +
// the structural half of TASK_TYPES.
// ─────────────────────────────────────────────────────────────────────────────
/** What finishing this task produces. The single discriminant of the output axis. */
export const TASK_OUTPUT_KINDS = [
'email', // Gmail draft
'slack', // Slack message draft
'calendar', // calendar invite (variant existed, 0 rows ever written)
'linkedin', // LinkedIn DM draft (variant existed, 0 rows ever written)
'whatsapp', // WhatsApp message draft (variant existed, 0 rows ever written)
'crm-field', // a proposed CRM field value
'crm-opportunity', // a CRM opportunity/deal record
'file', // a Cedar Doc / generated artifact
'recommendation', // a proposed action the user opts into
'none', // pure reminder — produces nothing
] as const;
export type TaskOutputKind = (typeof TASK_OUTPUT_KINDS)[number];
/**
* `kind` is always present — it declares the INTENDED output at creation.
* Every other field is optional and populated when the artifact is PRODUCED.
* Payload presence is the produced signal; there is no separate flag and no
* second column to keep in agreement.
*/
export type TaskOutputBase = { kind: TaskOutputKind };
export type EmailTaskOutput = TaskOutputBase & {
kind: 'email';
threadId?: string;
draftId?: string;
emailHeaderMessageId?: string;
};
export type SlackTaskOutput = TaskOutputBase & {
kind: 'slack';
channelId?: string;
channelName?: string;
workspaceId?: string;
threadTs?: string;
message?: string;
draftId?: string;
};
export type CalendarTaskOutput = TaskOutputBase & {
kind: 'calendar';
eventId?: string;
calendarId?: string;
htmlLink?: string;
startTime?: string;
};
export type LinkedinTaskOutput = TaskOutputBase & {
kind: 'linkedin';
chatId?: string;
unipileAccountId?: string;
};
export type WhatsappTaskOutput = TaskOutputBase & {
kind: 'whatsapp';
chatId?: string;
unipileAccountId?: string;
phoneE164?: string;
};
export type RecommendationTaskOutput = TaskOutputBase & {
kind: 'recommendation';
sourceFieldId?: string;
};
/** One proposed CRM field value, or a batch moving a deal to a new stage. */
export type CrmFieldTaskOutput = TaskOutputBase & {
kind: 'crm-field';
mode: 'single' | 'stage-batch';
/** mode === 'single' */
fieldName?: string;
value?: string | number | boolean | null;
fieldLabel?: string;
valueLabel?: string;
/** mode === 'stage-batch' */
batchFields?: Array<{
fieldName: string;
value: string | number | boolean | null;
fieldLabel: string;
valueLabel: string;
}>;
};
/** A CRM opportunity/deal to be created in the connected external CRM. */
export type CrmOpportunityTaskOutput = TaskOutputBase & {
kind: 'crm-opportunity';
proposedName?: string;
proposedAmount?: number;
proposedStage?: string;
/** Set once the opportunity exists in the external CRM. */
externalCrmId?: string;
externalCrmUrl?: string;
};
/** A Cedar Doc or generated artifact (collateral, report, brief). */
export type FileTaskOutput = TaskOutputBase & {
kind: 'file';
documentId?: string;
documentPath?: string;
title?: string;
};
/** A task that produces nothing — a plain reminder. */
export type NoneTaskOutput = TaskOutputBase & { kind: 'none' };
export type TaskOutput =
| EmailTaskOutput
| SlackTaskOutput
| CalendarTaskOutput
| LinkedinTaskOutput
| WhatsappTaskOutput
| RecommendationTaskOutput
| CrmFieldTaskOutput
| CrmOpportunityTaskOutput
| FileTaskOutput
| NoneTaskOutput;
// Deleted outright:
// TASK_TYPES, TaskType, TASK_TYPE_DESCRIPTIONS, describeTaskType (aop-schema.ts:636-699)
// TASK_CHANNELS, TaskChannel (aop-schema.ts:632-633)
// TaskActionData and all six *TaskActionData variants (aop-schema.ts:706-773)
// getTaskTypeLabel, TASK_TYPE_LABEL_OVERRIDES, NON_EMAIL_TASK_TYPES,
// POST_MEETING_LABEL (services/mail/labels/task-type-labels.ts)
```
**Migration discipline: expand/contract, never rename-in-place.**
`DATABASE_URL` is a single shared Supabase instance. At time of writing two checkouts
(`cedar-mail-1` on `PORT_API=8787`, `cedar-mail-2` on `8790`) serve it simultaneously, and
deployed environments point at it too. A `RENAME COLUMN` or `DROP COLUMN` takes effect for
**every** reader instantly, including servers still running the old code — so any such
statement breaks the sibling checkout the moment it lands. Every column change below is
therefore additive first, destructive only behind an explicit deploy gate.
```sql
-- ── EXPAND (Phase 2): additive only. Old readers keep working untouched. ────────────
ALTER TABLE user_tasks ADD COLUMN task_output jsonb; -- nullable, no default
CREATE INDEX idx_user_tasks_task_output_kind ON user_tasks ((task_output->>'kind'));
-- ── BACKFILL (Phase 3): populate from the two legacy columns. Still additive. ───────
-- a) rows with a produced payload: copy it, rewriting `channel` → `kind`
UPDATE user_tasks
SET task_output = (task_action_data - 'channel')
|| jsonb_build_object('kind', task_action_data->>'channel')
WHERE task_action_data IS NOT NULL AND task_output IS NULL;
-- b) rows that declared an intent but never produced a payload
-- (31,112 email + 5,497 multi-action + 1,074 slack + 1 linkedin) get a bare {kind}.
UPDATE user_tasks
SET task_output = jsonb_build_object('kind', <derived from task_channel + task_type>)
WHERE task_output IS NULL;
ALTER TABLE user_tasks ADD CONSTRAINT user_tasks_task_output_kind_check
CHECK (task_output IS NULL OR task_output->>'kind' IN
('email','slack','calendar','linkedin','whatsapp',
'crm-field','crm-opportunity','file','recommendation','none'));
-- ── DUAL-WRITE (Phases 3–4): every create/update path writes BOTH task_output and the
-- legacy task_channel/task_action_data, so old and new readers agree. ─────────────
-- ── CONTRACT (deploy-gated, Phase 9): only once every deployed reader is off the old
-- columns. Each step is irreversible and requires explicit operator approval. ─────
ALTER TABLE user_tasks ALTER COLUMN task_output SET NOT NULL;
ALTER TABLE user_tasks DROP COLUMN task_action_data;
ALTER TABLE user_tasks DROP COLUMN task_channel; -- + idx_user_tasks_task_channel
ALTER TABLE user_tasks DROP COLUMN task_type; -- + idx + user_tasks_task_type_check
-- task_groups: schema unchanged; gains a Gmail-label side effect on rename (Phase 5).
```
Relationship diagram:
```text
┌──────────────────────────┐
│ user │
│ id (pk, text) │
└────────────┬─────────────┘
│ 1:N user_id
┌────────┴─────────────────────────────────┐
▼ ▼
┌────────────────────────────┐ ┌──────────────────────────────────────┐
│ task_groups │ │ user_tasks │
│ id uuid pk │ │ id uuid pk │
│ user_id text ──FK──►│ user │ user_id text ──FK──► user │
│ name text │ │ conversation_id uuid ──FK──► crm_ │
│ color text │ │ conversations│
│ icon text │ │ │
│ position int │◄─────────┤ task_group_id uuid ──FK──┘ │
│ routing_criteria text │ N:1 │ CATEGORICAL AXIS │
│ overdue_policy jsonb │ ON DEL │ null = virtual Misc lane │
│ agent_visible bool │ SET NULL│ │
│ created_at / updated_at │ │ task_output jsonb NOT NULL │
└────────────────────────────┘ │ OUTPUT AXIS │
│ ▼ contains TaskOutput │
│ │
│ notes text │
│ (prose only — carries no payload) │
│ status / due_date / is_read / … │
└───────────────────────────────────────┘
task_type — column dropped
task_channel — column dropped
task_output ▼ contains EXACTLY ONE OF (kind always present; rest fills in when produced):
{ kind:'email', threadId?, draftId?, emailHeaderMessageId? }
{ kind:'slack', channelId?, channelName?, workspaceId?, threadTs?, message?, draftId? }
{ kind:'calendar', eventId?, calendarId?, htmlLink?, startTime? }
{ kind:'linkedin', chatId?, unipileAccountId? }
{ kind:'whatsapp', chatId?, unipileAccountId?, phoneE164? }
{ kind:'recommendation', sourceFieldId? }
{ kind:'crm-field', mode, fieldName?/value?/fieldLabel?/valueLabel? | batchFields? }
{ kind:'crm-opportunity', proposedName?, proposedAmount?, proposedStage?, externalCrmId?, externalCrmUrl? }
{ kind:'file', documentId?, documentPath?, title? }
{ kind:'none' }
Gmail label derivation (Phase 5):
task_groups.name ──slugify──► "Cedar/Task/{Name}" applied to the draft thread
(only when task_output->>'kind' = 'email'; every other kind produces no label)
```
## 4) Implementation phases
### Status
| Phase | Commit | Notes |
|---|---|---|
| 1 — Groups config surface | `9f2054ed7` | `/tasks/groups` + inline kanban editing |
| 2 — `TaskOutput` union + column | `b40cf75e6` | Additive; no rename (see divergence 1) |
| 3 — Backfill + dual-write | `eb328d483` | 79,964 rows |
| 3b — Remaining create paths | `ec5068e83` | Six more insert sites; CLI `--env local` fix |
| 4 — Readers onto the output axis | `c16e564db` | Plus a live `notes`-cast crash fix |
| 5 — Gmail labels from groups | `ecbfd0729` | Plus the strip-on-send bug fix |
| 5b — Post-task labelling | `aa1f14777` | Not in the original plan (divergence 2) |
| 6 — LABEL RULES | `eb55bb77f` | Deleted rather than rewritten (divergence 3) |
| 8a — Historical group backfill | `b5004ba0b` | 62,190 rows; ungrouped 69,217 → 7,027 |
| 7 — Recap + analytics | `0af10d3de` | Ran after 8a so the group key is populated |
| 8b — Delete `taskType` from ~92 files | — | **Deferred:** live servers still read it |
| 9 — Drop columns | — | **Gated:** needs the fleet deployed first |
### Divergences from the plan, and why
1. **Nothing was renamed or dropped.** The plan renamed `task_action_data` → `task_output`
and dropped `task_channel`. This `DATABASE_URL` is shared by several checkouts *and* the
deployed fleet, so a rename takes effect for every reader the instant it lands. The
migration is additive; the contract half is phase 9.
2. **Labelling moved to after task creation (5b).** The plan assumed the group name is
available where the Gmail label is applied. It is not — `createDraftInProvider` runs
before the task is created and routed, and the task stores the draft's id, so the
ordering cannot be inverted. Draft creation now applies only `Cedar/Agent drafts`, and
`applyTaskGroupLabel` adds the group label one step later. This also uncovered that
`createUserTaskWithExecutionUpdate` never set `taskGroupId` at all, so every agent draft
task and CRM approval had been landing in Misc.
3. **LABEL RULES prose was deleted, not rewritten.** With labelling now deterministic and
server-side, prose telling the agent to name its own label would put a second,
legacy-named label on every drafted thread.
4. **Phase 7 ran after 8a.** Keying analytics by group is only meaningful once history is
filed into groups; running 7 first would have left the group key null for most rows.
5. **`manual` and `reminder` recap categories still read `task_type`.** They describe who
asked for a task and when it is due — not what it produces — so the output axis
deliberately does not model them. Both become `other` when the column is dropped.
### Gate on phases 8b and 9
The deployed fleet is still writing the old shape: during the backfills, rows appeared with
a null `task_output` at 14–63/hour across 7–21 distinct users, all from executions. Both
backfills are idempotent. Re-run them after deploy; phase 9 is safe only once the residue
holds at zero.
### Phase 1 — Task groups config surface
**Goal:** Make groups a first-class, obvious thing to create and configure, before their names start driving Gmail labels.
- [x] Add `/tasks/groups` route in [routes.ts:62](apps/mail/app/routes.ts) and `app/(routes)/tasks/groups/page.tsx`, as a sibling of `/tasks/agenda|kanban|list`
- [x] Build `TaskGroupsPage` in `apps/mail/modules/userTasks/components/TaskGroupsPage.tsx` — full-width list with drag-to-reorder
- [x] Expose the fields the schema already supports but `TaskGroupsManagerDialog` does not: `icon` (picker over `TASK_GROUP_ICON_MAP`), `position` (reorder → `taskGroups.reorderGroups`), `overduePolicy`, `agentVisible`
- [x] ~~Expose pinned conversations per group (`taskGroups.pinConversation` / `unpinConversation`)~~ — removed: zero production rows in the hard-pin join table, ever; the whole hard-pin feature (schema, tRPC procedures, UI, router step) was deleted outright
- [x] Add a "Groups" entry to the tasks sidebar in [TaskGroupsNav.tsx:69](apps/mail/modules/userTasks/components/TaskGroupsNav.tsx), linking to `/tasks/groups`
- [x] Add inline group editing to the kanban: click a column header to rename/recolor in place, in [TaskKanbanBoard.tsx:356](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx)
- [x] Add an "add column" affordance at the end of the board that calls `taskGroups.createGroup`
- [x] Fix the empty-column collapse at [TaskKanbanBoard.tsx:259](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx) so a newly created group stays visible instead of being immediately hidden by the `visibleColumns` filter — extracted as `keepsEmptyColumn`, which exempts group lanes but leaves the due/channel boards collapsing
- [x] Delete `TaskGroupsManagerDialog.tsx` and its trigger card at [brain/playbooks/page.tsx:163](apps/mail/app/\(routes\)/brain/playbooks/page.tsx); replace the card with a link to `/tasks/groups`
**Not in the original plan, found while building it:**
- [x] `createGroup` / `updateGroup` never accepted `icon` — the column existed and the seed wrote it, but it was unsettable through the API. Added to both input schemas
- [x] Headless drivers for the whole surface: `groups create|update|delete|reorder|move` in [cli/task-groups.ts](apps/server/src/cli/task-groups.ts), hitting the same procedures the page does. `delete` requires `--confirm` (`pin`/`unpin` were removed along with the hard-pin feature)
- [x] **Bug:** `parseFlags` at [cli/lib.ts:16](apps/server/src/cli/lib.ts) collapsed `--flag ''` to the string `'true'` (empty string is falsy), so any verb using `''` to clear a nullable field wrote the literal text `"true"` into it. Affected every CLI sharing the helper. Fixed to `next !== undefined`
- [x] `reorderCachedGroups` had been copy-pasted into two components; extracted to [utils/group-cache.ts](apps/mail/modules/userTasks/utils/group-cache.ts) alongside a new `patchCachedGroup`, and both call sites rewired
**Tests:**
- [x] `apps/mail/tests/modules/userTasks/taskGroupsPage.test.tsx` — renders all groups, reorder calls `reorderGroups`, create/delete round-trip
- [x] `apps/mail/tests/modules/userTasks/taskKanbanBoard.test.ts` — `keepsEmptyColumn` keeps empty group lanes and Misc, still collapses Upcoming and the due/channel boards
- [x] `apps/mail/tests/modules/userTasks/group-cache.test.ts` — reorder and patch leave virtual Misc untouched
- [x] `apps/server/src/cli/__tests__/task-groups.test.ts` + `lib.test.ts` — flag→input mapping, three-state update semantics, the empty-string regression
- [x] `pnpm --filter @zero/mail run types` clean; 8 suites / 71 tests green; `pnpm --filter @zero/server exec vitest run src/cli` 34 green
- [x] Headless end-to-end against `:8790` as jesse@: create with icon + overdue policy → list → rename → clear-to-null → delete, all probe data removed
### Phase 2 — Introduce the `TaskOutput` union
**Goal:** The one output type exists and every kind is representable. Column work only; no behaviour changes.
- [ ] Add `TASK_OUTPUT_KINDS`, `TaskOutputKind`, and the ten `TaskOutput` variants to [aop-schema.ts:632](apps/server/src/db/aop-schema.ts)
- [ ] Keep `TaskActionData` and its six variants in place for now — they are what the sibling checkout still reads; they are deleted in Phase 9
- [ ] Hand-authored **additive** migration: `ALTER TABLE user_tasks ADD COLUMN task_output jsonb` (nullable) + the `(task_output->>'kind')` expression index. No rename, no drop
- [ ] Add the Drizzle column definition `taskOutput: jsonb('task_output').$type<TaskOutput>()` at [aop-schema.ts:1139](apps/server/src/db/aop-schema.ts)
- [ ] Add `isOutput<K>(task, kind)` narrowing helpers in `apps/server/src/services/user-tasks/output.ts`
- [ ] Add `toTaskOutput(taskChannel, taskType, taskActionData)` in the same module — the single legacy→new mapping, reused by the backfill script, the dual-write paths, and the tests
- [ ] Verify headlessly: `pnpm cedar-cli tasks outputs inspect --for <email>` prints `task_output` as null for every existing row and the sibling checkout on `:8787` still serves `groups list` green
**Tests:**
- [ ] `apps/server/src/db/__tests__/task-output.test.ts` — union exhaustiveness and `kind` narrowing across all ten variants
- [ ] `timeout 180 pnpm --filter @zero/server run types`, `timeout 180 pnpm --filter @zero/mail run types`, `pnpm deps:check`
### Phase 3 — Backfill `task_output` and dual-write
**Goal:** Every row carries a correct `task_output`, and every write path keeps it in step with the legacy columns. Nothing is dropped; old readers keep working.
- [ ] Every create path writes `task_output` **in addition to** `taskChannel`/`taskActionData` (dual-write), deriving the bare `{ kind }` via `toTaskOutput`
- [ ] `updateConversationFieldsTool` at [updateConversationFieldsTool.ts:1036](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts) and [:1102](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts) writes `CrmFieldTaskOutput` for both `single` and `stage-batch` modes, keeping the `notes` write for one phase
- [ ] The crm-opportunity creation path writes `CrmOpportunityTaskOutput`
- [ ] Calendar tasks write `CalendarTaskOutput` — the variant that has existed with zero rows since it was declared
- [ ] Write `apps/server/src/db/migrations/scripts/backfill-task-output.ts`: give every null-payload row a bare `{ kind }` derived from `task_channel`, resolving the 5,497 `multi-action` rows to `crm-field` / `crm-opportunity` / `none`, and reconstruct payloads for the 1,784 `field-approval` + 106 `crm-opportunity` + 178 `calendar` rows by parsing `notes`
- [ ] Run with `DRY_RUN=1` first; report unparseable `notes` rather than silently dropping them
- [ ] Reconcile the 37 incoherent rows (`channel=slack` + `payload=email` and the reverse) — payload wins, since it records what was actually produced
- [ ] Add the `user_tasks_task_output_kind_check` constraint (permitting NULL until Phase 9)
- [ ] Verify headlessly: `pnpm cedar-cli tasks outputs verify --for <email>` reports zero rows where `task_output->>'kind'` disagrees with the legacy pair, and `:8787` still serves green
**Tests:**
- [ ] `apps/server/src/db/migrations/scripts/__tests__/backfill-task-output.test.ts` — every legacy `(task_channel, task_action_data, notes)` combination maps to exactly one valid `TaskOutput`; malformed notes are reported, not dropped
- [ ] `apps/server/src/mastra/tools/conversation/__tests__/updateConversationFieldsTool.test.ts` — a typed `CrmFieldTaskOutput` for both modes
- [ ] `timeout 180 pnpm --filter @zero/server exec vitest run src/db src/mastra/tools/conversation`
### Phase 4 — Move readers onto the output axis
**Goal:** Every consumer of the structural half of `taskType` reads `taskOutput.kind`. The `notes` text casts die.
- [ ] Extract one `renderOutputAction(task)` helper and use it in both [TimelineTaskItem.tsx:375](apps/mail/modules/conversations/components/timeline/TimelineTaskItem.tsx) and [:528](apps/mail/modules/conversations/components/timeline/TimelineTaskItem.tsx), removing the duplicated blocks
- [ ] Delete the `JSON.parse(task.notes)` payload recovery at [TimelineTaskItem.tsx:233](apps/mail/modules/conversations/components/timeline/TimelineTaskItem.tsx); read `taskOutput` directly
- [ ] `applyFieldChange` at [user-tasks.ts:2020](apps/server/src/trpc/routes/user-tasks.ts) reads `taskOutput`; the sibling-cancel predicate at [:2244](apps/server/src/trpc/routes/user-tasks.ts) drops its `::jsonb` text cast
- [ ] Replace the cancellation cast at [updateConversationFieldsTool.ts:1028](apps/server/src/mastra/tools/conversation/updateConversationFieldsTool.ts) with a real jsonb path
- [ ] Switch [crm.ts:1106](apps/server/src/trpc/routes/crm.ts), [external-crm-events.ts:457](apps/server/src/services/crm/external-crm-events.ts), [:535](apps/server/src/services/crm/external-crm-events.ts), [conversations.ts:5418](apps/server/src/services/crm/conversations.ts), and [build-invocation-prompt.ts:53](apps/server/src/services/tasks/build-invocation-prompt.ts) to `taskOutput.kind`
- [ ] Rename `exemptOutputTypes` values at [pre-execution-setup.ts:149](apps/server/src/mastra/workflows/event-execution/pre-execution-setup.ts) to the `TaskOutputKind` union it was already informally naming
- [ ] Switch the kanban's `columnBy: 'channel'` mode at [TaskKanbanBoard.tsx:209](apps/mail/modules/userTasks/components/TaskKanbanBoard.tsx) to `columnBy: 'output'`
- [ ] Stop writing `notes` for CRM tasks
**Tests:**
- [ ] `apps/mail/tests/modules/userTasks/timelineTaskItemOutputs.test.tsx` — each output kind renders its correct action button, in both `canvasMode` and default
- [ ] `apps/server/src/trpc/routes/__tests__/applyFieldChange.test.ts` — single and batch payloads apply; siblings cancel without a text cast
- [ ] `timeout 180 pnpm --filter @zero/server exec vitest run src/trpc` and `pnpm --filter @zero/mail exec jest tests/modules/userTasks`
### Phase 5 — Gmail labels from task groups
**Goal:** Labels are named from the user's own groups, uniformly, and the strip-on-send bug is fixed.
- [ ] Create `apps/server/src/services/mail/labels/task-group-labels.ts` with `getTaskGroupLabel(groupName)` → `Cedar/Task/{slug}`, returning null unless the output kind is `email`
- [ ] Change `applyCedarMailDraftLabel` at [google.ts:2751](apps/server/src/lib/driver/google.ts) to take `taskGroupName`; update the three call sites at [:3310](apps/server/src/lib/driver/google.ts), [:3424](apps/server/src/lib/driver/google.ts), [:3483](apps/server/src/lib/driver/google.ts)
- [ ] Replace the fixed-override strip loop at [google.ts:2975](apps/server/src/lib/driver/google.ts) with a lookup over the user's groups, closing the bug documented at [google.ts:2977](apps/server/src/lib/driver/google.ts)
- [ ] Add a Gmail `labels.patch` side effect to `taskGroups.updateGroup` in [task-groups.ts](apps/server/src/trpc/routes/task-groups.ts) so renaming a group renames its label in place rather than orphaning it
- [ ] Write `apps/server/src/db/migrations/scripts/relabel-gmail-task-labels.ts` — one-time rename of the six legacy `Cedar/Task/*` labels to their group-derived names, per user. **Must default to `DRY_RUN`** and require an explicit `--confirm` plus a `--user <email>` scope; a bare invocation never touches anyone's mailbox
- [ ] Run it for `<email>` only, and verify the renamed labels in that mailbox headlessly
- [ ] **Gate — operator approval required before the fleet run.** This mutates the Gmail accounts of 177 real users and cannot be undone by Cedar. Do not run unscoped without explicit approval recorded in the session; until then the fleet rename stays unrun and this box stays unticked
- [ ] Delete `apps/server/src/services/mail/labels/task-type-labels.ts`
**Tests:**
- [ ] `apps/server/src/services/mail/labels/__tests__/task-group-labels.test.ts` — slugification, non-email outputs return null, custom group names
- [ ] `apps/server/src/lib/driver/__tests__/google-draft-labels.test.ts` — a derived label is applied on create and stripped on send (the previously failing case)
### Phase 6 — Playbook LABEL RULES from groups
**Goal:** The playbook prose describes the user's actual lanes.
- [ ] Change `buildLabelRulesProse` at [label-rules.ts:77](apps/server/src/services/playbook/templates/label-rules.ts) to take `Array<{ name, routingCriteria }>` instead of `taskTypes: string[]`
- [ ] Delete `describeLabelRuleTrigger` and `LABEL_RULE_TRIGGER_DESCRIPTIONS` at [label-rules.ts:47](apps/server/src/services/playbook/templates/label-rules.ts) — a group's `routingCriteria` already is the trigger description
- [ ] Pass the user's groups at [seed-playbook.ts:183](apps/server/src/services/playbook/seed-playbook.ts)
- [ ] Regenerate LABEL RULES for existing playbooks via a migration script, following the `playbook-writing` skill's rules for programmatic playbook writes
**Tests:**
- [ ] `apps/server/src/services/playbook/templates/__tests__/label-rules.test.ts` — prose generated from groups; non-email groups excluded
### Phase 7 — Recap and analytics onto groups
**Goal:** Reporting reads the two live axes.
- [ ] Replace `categorizeTask` at [recap-helpers.ts:165](apps/server/src/services/recap/recap-helpers.ts) with a `(taskGroupName, taskOutputKind)` mapping
- [ ] Replace `draftedByTaskType` at [draft-analytics.ts:1539](apps/server/src/services/analytics/draft-analytics.ts) and `taskTypeBreakdown` at [:1468](apps/server/src/services/analytics/draft-analytics.ts) with group-keyed series, bucketing non-default group names as `custom`
- [ ] Delete the duplicate mapper at [admin.ts:286](apps/server/src/trpc/routes/admin.ts) and [:354](apps/server/src/trpc/routes/admin.ts); call the shared one
- [ ] Update `recap-email-html.ts` at [recap-email-html.ts:78](apps/server/src/services/recap/recap-email-html.ts) and `build-recap-data.ts` at [build-recap-data.ts:262](apps/server/src/services/recap/build-recap-data.ts)
- [ ] Update the `draft-analytics` skill's report template so the weekly report names groups, not task types
**Tests:**
- [ ] `apps/server/src/services/recap/__tests__/recap-helpers.test.ts` — categorisation by group and output kind
- [ ] `apps/server/src/services/analytics/__tests__/draft-analytics-groups.test.ts` — group-keyed series, `custom` bucketing
### Phase 8 — Backfill history onto groups, then drop `task_type`
**Goal:** Groups own the categorical axis for all 78,934 rows, not just new ones — then the column goes.
- [ ] Write `apps/server/src/db/migrations/scripts/backfill-task-type-to-group.ts` implementing the §3.2 step 12 mapping, per user, resolving each legacy type to that user's equivalent seeded group by name
- [ ] For `manual` and `calendar` (no clean equivalent), route through `routeTaskToGroup` with `forceAssign: false` and leave low-confidence rows in Misc
- [ ] Run with `DRY_RUN=1`; assert the 66,544 typed-but-ungrouped rows resolve and report the residue
- [ ] Run for real; verify `count(*) WHERE task_type IS NOT NULL AND task_group_id IS NULL` is only the unresolvable residue
- [ ] Delete `TASK_TYPES`, `TaskType`, `TASK_TYPE_DESCRIPTIONS`, `describeTaskType` at [aop-schema.ts:636-699](apps/server/src/db/aop-schema.ts) and `apps/server/src/db/__tests__/describe-task-type.test.ts`
- [ ] Remove `taskType` from the Mastra tool schemas: [schemas.ts:58](apps/server/src/mastra/tools/task/schemas.ts), [createTaskTool.ts](apps/server/src/mastra/tools/task/createTaskTool.ts), [updateTaskTool.ts](apps/server/src/mastra/tools/task/updateTaskTool.ts), [listTasksTool.ts:258](apps/server/src/mastra/tools/task/listTasksTool.ts), [taskTool.ts](apps/server/src/mastra/tools/task/taskTool.ts), [draftCommsTool.ts:194](apps/server/src/mastra/tools/draft-comms/draftCommsTool.ts), [saveSlackDraftTool.ts:56](apps/server/src/mastra/tools/draft-comms/saveSlackDraftTool.ts), [onEventExecutionDraftEmailTool.ts:170](apps/server/src/mastra/tools/event-execution/onEventExecutionDraftEmailTool.ts), [onEventExecutionDraftSlackTool.ts:105](apps/server/src/mastra/tools/event-execution/onEventExecutionDraftSlackTool.ts)
- [ ] Remove the `taskType` filter and the `listLabels` / `listTaskTypes` endpoints at [user-tasks.ts:59](apps/server/src/trpc/routes/user-tasks.ts), [:280](apps/server/src/trpc/routes/user-tasks.ts), [:329](apps/server/src/trpc/routes/user-tasks.ts), [:410](apps/server/src/trpc/routes/user-tasks.ts), [:545](apps/server/src/trpc/routes/user-tasks.ts), [:2314](apps/server/src/trpc/routes/user-tasks.ts)
- [ ] Delete the `taskType` sort attribute at [userTasksSlice.ts:234](apps/mail/modules/userTasks/slice/userTasksSlice.ts) and its cases in [use-organized-tasks.ts:46](apps/mail/modules/userTasks/hooks/use-organized-tasks.ts) and [TaskEmailItem.tsx:338](apps/mail/modules/userTasks/components/sections/TaskEmailItem.tsx) — grouping is the groups' job
- [ ] Delete the `taskType` colour switches in [task-helpers.tsx:5-84](apps/mail/modules/conversations/components/timeline/task-helpers.tsx); colour comes from the group's `color`
- [ ] Remove `taskType` from the remaining UI files, the `task-admin` CLI at [cli.ts:107](apps/server/src/task-admin/cli.ts), and the debug/admin surfaces
- [ ] Stop writing `task_type` on every create path — the column keeps its data but goes stale from here
- [ ] Verify nothing remains in code: `grep -rn "taskType" --include="*.ts" --include="*.tsx" apps/ | grep -v dist` returns only the backfill script
- [ ] Update [TASK_GROUPS_DESIGN.md](apps/mail/modules/userTasks/TASK_GROUPS_DESIGN.md) §2 to describe the two-axis model
**Tests:**
- [ ] `apps/server/src/db/migrations/scripts/__tests__/backfill-task-type-to-group.test.ts` — every legacy type maps to the right seeded group; users missing a group fall back to the router; residue stays in Misc
- [ ] `timeout 180 pnpm --filter @zero/server exec vitest run src/` and `pnpm --filter @zero/mail exec jest tests/` fully green
- [ ] `timeout 180 pnpm --filter @zero/server run types`, `timeout 180 pnpm --filter @zero/mail run types`, `pnpm deps:check`
### Phase 9 — Contract: drop the legacy columns
**Goal:** Remove the old columns once nothing reads them. **Every task in this phase is irreversible and gated on explicit operator approval** — see the deploy gate below.
**Deploy gate — all four must hold before any statement in this phase runs:**
1. Phases 2–8 are merged and deployed to **every** environment pointing at this `DATABASE_URL`
2. Every local checkout serving this database (`cedar-mail-1` on `:8787`, `cedar-mail-2` on `:8790`, any other) runs code that reads `task_output`, verified by `pnpm cedar-cli groups list` green against each port
3. `select count(*) from user_tasks where task_output is null` returns 0
4. The operator has explicitly approved the drops in this session
- [ ] Confirm gate conditions 1–3 headlessly and print the evidence
- [ ] Obtain and record explicit operator approval for gate condition 4
- [ ] Stop dual-writing `taskChannel` / `taskActionData`; remove the legacy writes from every create path
- [ ] Delete `TASK_CHANNELS` / `TaskChannel` and `TaskActionData` + its six variants at [aop-schema.ts:632](apps/server/src/db/aop-schema.ts) and [:706-773](apps/server/src/db/aop-schema.ts); remove `taskChannel` / `taskActionData` from all referencing files
- [ ] `ALTER TABLE user_tasks ALTER COLUMN task_output SET NOT NULL`
- [ ] `ALTER TABLE user_tasks DROP COLUMN task_action_data`
- [ ] `ALTER TABLE user_tasks DROP COLUMN task_channel` + `idx_user_tasks_task_channel`
- [ ] `ALTER TABLE user_tasks DROP COLUMN task_type` + `idx_user_tasks_task_type` + `user_tasks_task_type_check`
- [ ] Verify nothing remains: `grep -rn "taskChannel\|taskActionData\|task_type" --include="*.ts" --include="*.tsx" apps/ | grep -v dist` returns nothing
**Tests:**
- [ ] `timeout 180 pnpm --filter @zero/server exec vitest run src/` and `pnpm --filter @zero/mail exec jest tests/` fully green
- [ ] `pnpm cedar-cli groups list` and `pnpm cedar-cli tasks outputs verify` green against every serving port