TASK_CLEANUP_DESIGN.md33.6 KBView on GitHub
# Task cleanup — finishing the migrations we started

**Status:** phases 0, 1, 2 and 7 implemented 2026-08-15; 3–6 and 8 outstanding. All figures
below are from production Postgres that day.

**Where this stands.** The data is healed and every reader is on the new axis behind a
fallback, which is what makes the remaining phases safe. **Nothing has been dropped yet, and
Phase 3 is a one-week soak on live traffic — that gate is the whole point.** Phase 6
(`task_type`) still needs a design decision before any code, because groups do not replace all
three of its jobs.

This doc owns one job: **finish the half-done task migrations and remove the dead vocabulary.**
It is not a feature doc. Where a prior design doc has unfinished *feature* phases, this doc
names them and hands them back rather than absorbing them (§2).

Reference for how the system works today: [`task-lifecycle.md`](../../../server/docs/wiki/task-lifecycle.md).
Reference for why it misbehaves: [`task-system.md`](../../../server/docs/wiki/task-system.md).

---

## 1. Current state

### 1.1 Nothing was dropped

The two-axis migration ([TASK_AXES_DESIGN.md](./TASK_AXES_DESIGN.md)) is widely believed
complete. It is not. Verified against the tree and the database:

| Column | Nominal replacement | Non-test files | jsonb read predicates | Dropped? |
|---|---|---|---|---|
| `task_type` | `task_group_id` | 122 | — | no |
| `task_channel` | `task_output.kind` | (within the 122) | — | no |
| `task_action_data` | `task_output` | 81 | **19** | no |
| `task_output` | *(the replacement)* | 28 | **0** | n/a |

No `DROP COLUMN` migration exists for any of the three. `task_type` is still written on every
insert and is still *behavioural*, not vestigial — [`executeTask`](../../../server/src/services/task-scheduling/execution.ts#L92)
injects `Task type: X` into the scheduled-task prompt so the orchestrator tailors its directive.

### 1.2 The blocker: the dual-write is lossy

`task_output` is dual-written but **never read for a decision**. Worse, the two columns
disagree on real rows:

```
rows where task_action_data has a key that task_output lost:   502   (178 still open)
   by output kind:  email 282 (115 open) · null output 210 (60 open) · slack 6 (3 open)

rows with task_output IS NULL:                                 940
   ... created in the last 24h:                                  0   ← fleet gate PASSES
```

The fleet gate passes — every one of the 253 rows created in the last 24h has a `task_output`,
so deployed code is writing the new shape. The 940 nulls and 502 lossy rows are historical
residue that the backfills have not yet healed.

**Cause.** [`producedOutput`](../../../server/src/services/user-tasks/output.ts#L123) exists
precisely so that every path writing `taskActionData` also writes `taskOutput`, and its own
docstring concedes *"the create paths were audited for this and the update paths were not."*
Three sites still write one axis without the other:

Auditing all 11 writers, **two** are unpaired — and they fail in opposite directions:

| Site | Kind | Defect |
|---|---|---|
| [`saveSlackDraftTool.ts`](../../../server/src/mastra/tools/draft-comms/saveSlackDraftTool.ts#L117) `linkSlackDraftToTask` | UPDATE | writes `taskActionData`, leaves `taskOutput` at the bare `{kind:'slack'}` from creation |
| [`user-tasks.ts`](../../../server/src/trpc/routes/user-tasks.ts#L1151) `clearTaskOutput` | UPDATE | nulls `taskActionData`, leaves `taskOutput` still carrying the draftId it just severed |

The remaining nine pair correctly, either directly
([`onEventExecutionDraftSlackTool.ts:414`](../../../server/src/mastra/tools/event-execution/onEventExecutionDraftSlackTool.ts#L414),
[`onEventExecutionDraftEmailTool.ts:838`](../../../server/src/mastra/tools/event-execution/onEventExecutionDraftEmailTool.ts#L838),
[`upsertDraftTask`](../../../server/src/services/user-tasks/tasks.ts#L1182),
[`createStandaloneTask`](../../../server/src/trpc/routes/user-tasks.ts#L2569)) or via
[`createUserTaskWithExecutionUpdate`](../../../server/src/services/user-tasks/tasks.ts#L146).

Two unpaired writers do not explain 502 lossy rows on their own — most of that residue predates
the fixes already made at the paired sites. Phase 1's parity query is what proves whether any
writer is still producing *new* drift after Phase 0.

### 1.3 Why the obvious order is the wrong order

Dropping `task_action_data` first destroys the only key 19 predicates match on — including both
automatic completion paths ([`completeTaskByDraftId`](../../../server/src/services/task-scheduling/execution.ts#L249),
[`markEmailTasksCompleteByThreadId`](../../../server/src/services/user-tasks/tasks.ts#L258)).
Both treat "no match" as success, so **task auto-completion would stop silently.** Given
`task-system.md` already measures non-completion as the dominant defect, that is the single
worst change available here.

Migrating the reads first is equally wrong on its own: 502 rows would lose their key the moment
the read moved, because `task_output` does not carry it yet.

The only safe order is **heal the data, then move the reads, then drop.**

### 1.4 Dead status vocabulary

`planned_followup` is dead in code — it appears only in four type-union declarations
([admin.ts:151,203](../../../server/src/trpc/routes/admin.ts#L151),
[recap-helpers.ts:39,77](../../../server/src/services/recap/recap-helpers.ts#L39)) and the stale
CHECK literal. Nothing writes it and nothing compares against it. **3 rows carry it in
production**, so it cannot simply be removed from the constraint without migrating them.

Three descriptions of the `status` constraint disagree:

| Source | Values |
|---|---|
| migration [0049](../../../server/src/db/migrations/0049_user_task_recommended_status.sql) (**authoritative**) | todo, done, deleted, agent_deleted, planned_followup, archived, recommended |
| [`aop-schema.ts`](../../../server/src/db/aop-schema.ts#L1382) `check()` literal | todo, done, deleted, agent_deleted, planned_followup |
| [`aop-schema.ts`](../../../server/src/db/aop-schema.ts#L1300) `$type` | todo, done, deleted, agent_deleted, recommended |

Live distribution: `agent_deleted` 34,949 · `done` 30,045 · `deleted` 10,464 · `todo` 7,855 ·
`recommended` 5,588 · `archived` 358 · `planned_followup` 3.

Separately, [`.claude/skills/tasks/SKILL.md`](../../../server/.claude/skills/tasks/SKILL.md)
documents a vocabulary that has never existed: statuses `pending/in_progress/completed/canceled`,
types `internal`/`admin`, a `priority` field, and `agentCanExecute` (the real parameter is
`agentExecutionEnabled`). An agent following it writes values the DB rejects.

---

## 2. Inventory of unfinished task work — what this doc owns

169 unticked boxes across seven prior design docs. Ruling each in or out:

| Doc | Open | This doc? |
|---|---|---|
| [TASK_AXES_DESIGN.md](./TASK_AXES_DESIGN.md) | 81 | **Yes** — phases 3, 4, 8, 9 are exactly this cleanup |
| [TASK_GROUPS_DESIGN.md](./TASK_GROUPS_DESIGN.md) | 4 | **Partly** — the `task_type` retirement half only |
| [TASK_KANBAN_DESIGN.md](./TASK_KANBAN_DESIGN.md) | 10 | No — Phase 7/8 are rollout, Phase 0 is unrelated |
| [TASK_ATTRIBUTES_DESIGN.md](./TASK_ATTRIBUTES_DESIGN.md) | 13 | No — net-new feature, never started |
| [TASK_OPTIMISTIC_RENDERING_DESIGN.md](./TASK_OPTIMISTIC_RENDERING_DESIGN.md) | 61 | No — feature work, never started |
| TASK_EXECUTION_MODE_DESIGN.md | 0 | Complete |
| TASK_SLICE_REFACTOR_DESIGN.md | 0 | Complete |

The two never-started docs (74 boxes) are worth a separate decision: `TASK_OPTIMISTIC_RENDERING`
§6 is the one that measures agent task re-creation, which is the symptom driving the current
complaints. It is not cleanup, so it does not belong here — but it should not stay at zero.

---

## 3. Proposed changes

Eight phases. Every phase is independently shippable and leaves the system correct.

```text
  P0  stop the bleeding ──► P1 heal data ──► P2 dual-read ──► P3 soak
                                                                 │
   P7 status vocab ◄── P6 task_type ◄── P5 task_channel ◄── P4 output-only
        │
        └──► P8 DROP COLUMN x3
```

### Phase 0 — Stop the bleeding

Pair `taskOutput: producedOutput(...)` with `taskActionData` at the three sites in §1.2.
Add a regression test asserting every task-writing path emits both axes.

- [x] `saveSlackDraftTool.ts` `linkSlackDraftToTask` writes `taskOutput: producedOutput(...)`
- [x] `user-tasks.ts` `clearTaskOutput` strips `taskOutput` back to `{ kind }` (not null — that
      is the axis's "intended, not produced" state, and it keeps the column non-null for Phase 4)
- [x] Test: both directions — a linked draft leaves the axes agreeing, a cleared output leaves
      neither carrying a payload

**Verify:** parity query in §4 returns 0 for rows created after deploy.

### Phase 1 — Heal the historical rows

Re-run both idempotent backfills, then a new parity backfill for the 502 lossy rows.

- [x] Extend `backfill-task-output.ts` with a second, *repair* pass — the original only
      matches `task_output IS NULL`, so a row that already has an output but lost a key is
      invisible to it forever. The repair merges additively (`task_output || rebuilt`) so a
      key already present is never removed.
- [x] Run it (`DRY_RUN=1` first)
- [ ] Re-run `backfill-task-type-to-group.ts`

**Result, 2026-08-15:**

| metric | before | after |
|---|---|---|
| `task_output IS NULL` | 940 | **0** |
| lost `draftId` | 336 | **0** |
| lost `threadId` | 478 | **0** |

One row is excluded by design and reads as a false positive under the naive query: a
`crm-opportunity` carrying a stale email payload, where `resolveOutputKind` deliberately lets
the declared CRM type outrank the produced payload (otherwise an opportunity hides behind a
draft). It is `agent_deleted`, so no completion path depends on it. The parity query in §4 is
scoped with `task_action_data->>'channel' = task_output->>'kind'` to express that — we only
care about a lost key when the payload actually belongs to the resolved kind.

### Phase 2 — Move the 19 reads onto `task_output`, with fallback

Each predicate becomes `COALESCE(task_output->>'k', task_action_data->>'k')`, routed through a
new [`output-predicates.ts`](../../../server/src/services/user-tasks/output-predicates.ts) —
the same shape as the existing `crm-field-predicates.ts`, and for the same reason: **the window
then closes in one edit instead of nineteen.** Hand-copying the COALESCE 19 times would
recreate exactly the drift this doc exists to clean up.

- [x] `services/task-scheduling/execution.ts` — `completeTaskByDraftId`
- [x] `services/user-tasks/tasks.ts` ×4 — thread-complete, stale-draft ×2, `upsertDraftTask`
- [x] `trpc/routes/mail.ts` — remind-me lookup
- [x] `trpc/routes/admin.ts` ×3 — analytics joins (these compare against a *column*, so
      `matchesOutputKey` accepts an `SQLWrapper` as well as a literal)
- [x] `services/debug/account-health.ts` ×6
- [x] `scripts/account-health-report.ts` ×4
- [x] `trpc/routes/crm.ts` ×2 — **found late, and only because something else led there.** These
      probe the legacy column with jsonb *containment*
      (`taskActionData::jsonb @> '{"threadId":…}'`), not the `->>` accessor, so the grep that
      verified the other 19 sites did not see them. `matchesOutputKey('threadId', …)` is the
      same test over both axes, and simpler.
- [x] Zero legacy-only reads remain — checked for **both** access shapes:
      `grep -E "task_action_data->>|taskActionData}::jsonb @>" | grep -v COALESCE` is empty
      apart from the backfill's own axis-comparison predicate.

**The lesson worth keeping:** "I grepped for the accessor" is not the same as "I found every
read". jsonb has more than one way to ask, and the second one hid two live call sites through
an entire migration pass.

**Why COALESCE and not a straight swap.** These predicates decide whether a task *closes*, and
both completion paths treat "no match" as success — so a predicate that silently stops matching
does not error, it just quietly stops completing people's tasks. That is already the single
most-reported defect in the system. The fallback costs one jsonb lookup and removes the whole
failure class.

**Verify:** send a Cedar draft → its task flips to `done`. Reply on a thread with an open email
task → task closes. Both must hold for a slack draft too.

### Phase 3 — Soak

- [ ] Parity query = 0 continuously for one week
- [ ] `done : agent_deleted` ratio not worse than the pre-change baseline (≈1 : 1.16)

Gate on real traffic, not a test run. This is the phase that catches a writer nobody audited.

### Phase 4 — Output-only reads

- [ ] Drop the `COALESCE` fallbacks added in Phase 2
- [x] ~~`task_output` → `NOT NULL`~~ — **reversed, deliberately. See below.**

#### `task_output` stays NULLABLE, and null means something

An earlier revision of this doc planned `NOT NULL`, on the reasoning that `{ kind }` alone
already expresses "intended but not produced", so null added nothing. That was wrong, and the
counter-example is the ordinary case:

> A task says *"follow up with Sarah."* At creation nobody knows whether that follow-up will be
> an email, a Slack message, or a LinkedIn DM — and by the time it comes due, the answer may
> have changed, because a Slack channel was opened with them in the meantime.

So there are **three** states, not two:

| Value | Meaning |
|---|---|
| `null` | **the output is undecided** — choose at execution, with more information |
| `{ kind: 'email' }` | decided: this will produce an email draft; none exists yet |
| `{ kind: 'email', draftId }` | produced |

Forcing a `kind` at creation does not remove the uncertainty, it just makes the agent **guess
and write the guess down as fact** — and a guessed channel then biases the executor toward it.
An explicit null is strictly more honest than a premature commitment, and it lets the decision
be made where the information is.

This changes the agent guidance too: when scheduling future work, the agent should NOT pick a
channel unless the channel is genuinely part of the commitment ("reply to this thread" is;
"follow up with Sarah" is not).

**Consequence for the drop gate.** `task_output IS NULL` can no longer be read as "unmigrated",
because it is now a legitimate state. The completeness check becomes narrower: a row is
unmigrated only if it has legacy signal (`task_channel` / `task_action_data`) that never made
it onto the output axis. The parity query in §4 already expresses exactly that, so it remains
the gate — but the `null_output` column in it must be retired, not just watched.

### Phase 4b — `multi-action` and the conflict rule

**`multi-action` goes, and nothing replaces it.** It is written at exactly two sites, both CRM
approvals that already pass an explicit `taskOutput`, so the value is legacy filler. It was
never an output kind — `TASK_OUTPUT_KINDS` omits it deliberately, because it described *a task
with several possible actions*, which is not an artifact. The output axis already models those
rows correctly as `crm-field`, `crm-opportunity` or `none`.

A task that genuinely needs several actions should be **several tasks**. One task, one output
is what makes the completion key meaningful at all — a row with two artifacts has no single
answer to "is this done".

**Conflict rule: `task_output.kind` always wins.** Where the axes disagree — ~12,000 rows —
the output axis is authoritative, without exception. This is already how `resolveOutputKind`
behaves (the produced payload beats the declared channel) and how the swapped readers behave;
stating it as a rule removes the temptation to re-litigate it per call site. `task_channel` is
a declaration of intent made before the work happened; `task_output.kind` reflects what the
work actually is.

### Phase 5 — Retire `task_channel`

**Not a pure derivation, which is what an earlier revision of this doc assumed.** The two axes
disagree on ~12,000 rows, so every read is a decision — *does this site want the declared
channel, or what the task actually produces?* The answer is the artifact, essentially always,
and that changes which rows match (see `isOutputKind`'s docstring for the measured deltas).

- [x] **`multi-action` deleted** ([migration 0059](../../../server/src/db/migrations/0059_retire_multi_action_channel.sql), applied).
      It was never a channel — it meant "several possible actions", which is not a place a
      message is sent, and `TASK_OUTPUT_KINDS` omits it deliberately. Only two sites wrote it,
      both CRM approvals that already pass an explicit `taskOutput`.
- [x] **`task_channel` is now NULLABLE, no default.** Removing `multi-action` forced the
      question: the column was `NOT NULL DEFAULT 'email'`, so those rows had to become either
      a lie or nothing. A CRM approval sends nothing; `multi-action` existed to avoid admitting
      that. 7,200 rows → NULL. Relaxing the constraint is also a step toward the drop —
      constraints come off before columns do, so a newer server can stop writing it first.
- [x] `toLegacyTaskChannel` returns null (not `'multi-action'`) for the CRM kinds
- [x] `createUserTaskWithExecutionUpdate` writes `taskChannel ?? null`, never `|| 'email'`
- [ ] Replace remaining reads with `task_output.kind`
- [ ] Delete `toLegacyTaskChannel` once nothing writes the column

**Two traps hit while doing this, both worth keeping:**

1. The migration ordered `UPDATE … SET NULL` **before** `DROP NOT NULL` and was rejected by the
   constraint it had not yet dropped. Relax first, then write.
2. `createUserTaskWithExecutionUpdate` had `taskChannel: taskChannel || 'email'`. Left alone it
   would have silently converted every "no channel" back into "email" on write, undoing the
   migration for all new rows while the backfilled ones stayed correct — the kind of divergence
   that only shows up months later as "why do CRM approvals say email".

**Headless gate for this phase:**

```bash
# no non-dual-write reads of the legacy column remain
grep -rn "userTasks.taskChannel\|task_channel =\|taskChannel ===" apps/server/src \
  --include="*.ts" | grep -v "__tests__\|\.test\.\|taskChannel: \|aop-schema.ts"   # → empty

# and the value is gone from the data
SELECT count(*) FROM user_tasks WHERE task_channel = 'multi-action';               # → 0
```

### Phase 6 — Retire `task_type`

**Decided.** `task_type` looked hard to remove because it is doing **four unrelated jobs** in one
column. Split by job, each already has a home — and no new column is needed.

| Job | Values | Home |
|---|---|---|
| Filing | follow-up · manual · reactivation · calendar · reminder | `task_group_id` + its existing `routingCriteria` |
| **Output contract** | field-approval · crm-opportunity | **`task_output`, typed** — see below |
| Semantics ("how do I handle a reactivation?") | all | **the playbook** |
| Provenance ("did the orchestrator already do this?") | response · post-meeting · pre-meeting | `[CREATED BY THIS RUN]` markers |

Two of those were nearly built already, which is why the column felt load-bearing:

- **`CrmFieldTaskOutput` and `CrmOpportunityTaskOutput` already exist**, fully typed, and their
  docstring says outright that they replace *"the JSON-in-`notes` smuggling"*. The only thing
  missing is that `create-task` never lets the agent supply them, so the agent still writes
  JSON prose into a TEXT column because the `taskType` description tells it to. **A payload
  contract enforced by prose is a schema pretending to be a prompt.**
- **The playbook already carries per-category semantics** at finer grain than `task_type` ever
  did — `next-steps.ts` ships `## STALLED DEALS` and `## REACTIVATION` sections with real
  trigger conditions and timing, user-editable, and already rendered into the subagent prompt
  by `resolvePlaybookContext` → `renderSections(ctx, NEXT_STEPS_SECTIONS)`.

Rejected along the way: **a `task_groups.instructions` column.** It duplicated playbook prose
into a second, worse source of truth that nobody would have kept updated, and it put semantic
guidance in a place a user edit could silently contradict the playbook. Groups answer *where a
task is filed*; the playbook answers *how it is handled*.

Also rejected: a narrow `intent` enum. It is `task_type` with fewer values and the same defect.

#### Phase order

- [ ] **6a — Typed output on `create-task`.** Add an `output` parameter carrying the
      discriminated union, so the CRM payload is written to `task_output` as real jsonb.
      Prerequisite for everything else: `DECLARED_OUTPUT_TASK_TYPES` currently resolves the CRM
      kinds *from `task_type`*, so the column cannot go while that is the only signal.
- [ ] **6b — Move the CRM readers onto `task_output`.** `applyFieldChange` and friends parse
      `notes` today; they read typed jsonb instead. `crm-field-predicates.ts`'s `notes` half
      then becomes deletable, which was always its stated exit condition.
- [x] **6c — Replace the column readers.**

      **The grep overstates this by ~7×.** `task_type` appears in ~102 files, but that conflates
      two unrelated things: `inputData.taskType` is a TOOL PARAMETER (the agent telling a
      drafting tool "this is a post-meeting recap") and has nothing to do with the column. The
      true column readers are **~15 sites in 5 files**, and they are overwhelmingly filters and
      `groupBy`, not behavioural branches. `admin-at-a-glance.ts:83` already read
      `COALESCE(taskGroups.name, userTasks.taskType)` — half-migrated and unnoticed.

      | Job | Sites | Moved to |
      |---|---|---|
      | Output contract | `crm.ts` activity feed | `task_output->>'kind'` |
      | Filing | `admin-at-a-glance` ×5, `admin.ts` drill-down, `list-tasks` filter | `task_group_id` |
      | Recap categorisation | `recap-helpers` + both duplicated blocks | output first, lane second |
      | Provenance | `listRecentPostMeetingTasks` | **left alone** — see below |

      `list-tasks`' `taskType` param is replaced by two: `groups` (lane names) and `outputKinds`
      (artifacts), ANDed. They are NOT interchangeable — a task can be in the Follow-ups lane
      and produce a Slack message.

- [ ] **6d — Stop writing it, then drop it.**

**`listRecentPostMeetingTasks` is provenance, and neither axis models it.** It means "this task
came out of the meeting run". The lane is user-renameable (and already split in two for some
accounts) and the output is an ordinary email, so forcing it onto either axis would change what
it returns. The honest replacement is a join from `creation_run_id` to the triggering
execution's event type — a change of meaning, not of column. Left with a comment. (It currently
has no live consumer: the hook is exported from the barrel and called nowhere.)

**Two latent bugs this phase surfaced:**

- `admin.ts` never selected `ut.task_output` while calling `categorizeTask`, which already
  preferred the output axis — the admin recap viewer was categorising on a value it wasn't
  fetching. It now gains the `crm-opportunity` / `field-approval` / `calendar` buckets.
- **`SKILL.md` still told agents to pass `taskType` to `list-tasks`.** Unknown params are
  silently stripped, so every "just show me follow-ups" would have returned *everything*
  — a filter that fails open, with no error to notice. Rewritten to `groups`/`outputKinds`.

**Behaviour changes:** admin lanes are now the user's real, renameable groups rather than a
fixed nine-value enum; `reminder` and `manual` recap categories fold into `other` (as the
helper's own docstring predicted — `manual` was already dead there, the query filters to
agent-created).

Measure 6c before shipping it: `playbook-instruction-eval` can check the duplicate rate holds
without the type-keyed rules. That is the only step with a behavioural risk.

### Phase 7 — Status vocabulary

- [x] Migration [0056](../../../server/src/db/migrations/0056_retire_planned_followup_status.sql):
      the 3 `planned_followup` rows → `agent_deleted`, then the CHECK re-added without it
- [x] One exported `TASK_STATUSES` / `TaskStatus` in `aop-schema.ts` — the `$type` and the
      `check()` literal are now both *generated from it*, so they cannot drift apart again
- [x] The four hand-copied unions (`admin.ts` ×2, `recap-helpers.ts` ×2) import `TaskStatus`
- [x] `SKILL.md` rewritten: real statuses, real task types, real parameter names
      (`agentExecutionEnabled`, not `agentCanExecute`), `priority` removed, `threadId` and the
      done-vs-cancel distinction documented
- [x] **Decided against `superseded`.** `agent_deleted` already means "this stopped being
      necessary"; a sixth status buys nothing. The rule that matters is keeping it out of
      `done`, which is a prompt/tool discipline, not a schema gap. `task-system.md` §8a updated.
- [x] Migration [0057](../../../server/src/db/migrations/0057_retire_archived_task_status.sql):
      `archived` retired too — 358 rows → `agent_deleted` (none had a `completed_at`, 350 of
      358 agent-created), and the value removed from the constraint
- [x] **One definition of "active."** `activeTaskStatusConditions()` now excludes `deleted`,
      `agent_deleted`, `done` and `recommended`, and the agenda fetch query uses it instead of
      inlining its own three exclusions — which is what let `recommended` rows render on the
      conversation agenda as committed work
- [x] Its test asserts WHICH statuses are excluded, not just how many. The count-only
      assertion is precisely what let `archived` look load-bearing for seven months.

- [x] **No divergence.** The agenda reconciler uses `activeTaskStatusConditions()` too.

**This one is a behaviour change, not a refactor — call it out in the release.** The reconciler
previously kept `done` rows so a completed task stayed on the agenda rendered as a **ticked**
box. It no longer fetches them, so:

| | before | after |
|---|---|---|
| tick a task on the agenda | stays visible, ticked | **leaves the agenda on the next read** |
| complete it in kanban / by sending its draft | appears ticked on the agenda | **removed from the agenda** |
| delete a task (tombstone) | X'd in place | X'd in place — unchanged |

The agenda becomes strictly *what is still owed*. The self-healing tick described in
[`agenda-task-state-corruption.md`](../../docs/agenda-task-state-corruption.md) now has nothing
to heal **to** — the rows it healed are removed instead. What survives is the other half of
that rule: every live row is open, so a tick on one is stale and gets cleared.

Two supporting fixes this forced, both worth keeping:

- `doneIds` and the `status` column selection are gone from the snapshot — dead once the
  filter moved into SQL.
- **The test `fakeDb` ignored the `WHERE` clause entirely** (`where: async () => rows`), so the
  CHECK tests were asserting on `done` rows production would never have fetched — pinning
  in-memory branching rather than real behaviour. It now honours the same open-task filter.
  A fake that answers questions the database would not is worse than no fake.

**Deploy ordering.** `TaskStatus` no longer includes `planned_followup` or `archived` while
production rows still hold them — migrations 0056 and 0057 must run with or before this code.
Nothing compares against either value, so the gap is inert, but do not split them across
releases.

### Phase 7b — The same audit, applied to the other enums

`archived` and `planned_followup` were shipped features abandoned at the write sites while the
schema and read guards kept them alive. That is a pattern, so the other two enums were checked
the same way — every value cross-referenced against what code writes AND what the table holds.

**Result: no residue. The status enum was the only place carrying dead values.**

| Enum | Values | Dead? |
|---|---|---|
| `task_type` | response · follow-up · post-meeting · pre-meeting · reactivation · manual · calendar · crm-opportunity · field-approval | all live, all still written on 2026-08-15 |
| `task_type` | `reminder` | **0 rows ever** — but a live writer exists (below). Not residue; do not remove. |
| `task_channel` | email · multi-action · slack | all live |
| `task_channel` | `linkedin` (24 rows) · `whatsapp` (2) | new as of 2026-08-14, not abandoned |

Nothing written in code falls outside a constraint, and nothing in a constraint is unwritten.

#### The `reminder` anomaly — a live path that has never produced a row

`reminder` is the mirror image of `archived`: `archived` had rows and no writer; `reminder` has
a writer and no rows. **Not one reminder task has ever been created**, yet
[`upsertReminderTaskForConversation`](../../../server/src/trpc/routes/mail.ts#L168) is called on
every `mail.setRemind`, which the thread UI reaches via `use-optimistic-actions.ts`.

Two candidate explanations, both visible at the call site
([mail.ts:2283](../../../server/src/trpc/routes/mail.ts#L2283)):

1. it is gated on `threadData.conversationId` being present, so remind-me on a thread not linked
   to a CRM conversation silently creates nothing;
2. it is wrapped in a `try/catch` that swallows failures to a `console.warn` — *"Reminder
   delivery should not fail if task creation fails"* — so a throw leaves no trace either.

Both are defensible individually; together they mean the feature cannot report its own failure.

**Instrumented, not removed** — deleting the type would delete a working feature. All three
outcomes now report under one event name so a single query settles it:

```apl
['cedar'] | where message == '[REMIND] reminder task outcome'
          | summarize count() by outcome     // created · updated · skipped_no_conversation · failed
```

The `skipped_no_conversation` gate is **not** a bug to remove: `user_tasks.conversation_id` is
NOT NULL, so a thread with no linked conversation cannot carry a task at all. The defect was
that the exit was invisible.

This also turned up a **third unpaired axis write** — the update branch of
`upsertReminderTaskForConversation` re-pointed `taskActionData` at a new thread while leaving
`taskOutput` on the previous one. The phase-0 guard had missed it because the property is ES6
shorthand (`taskActionData,`) and the guard's regex required a colon. Guard widened to match
both forms; it now catches shorthand, and that was the only remaining offender in the tree.

### Phase 8 — Drop the columns

- [ ] `ALTER TABLE user_tasks DROP COLUMN task_action_data, task_channel, task_type`
- [ ] Remove them from `aop-schema.ts`
- [ ] Delete `output.ts`'s legacy-mapping helpers

---

## 4. Verification

The parity query is the spine of this whole plan. It must read 0 before Phase 4 and stay 0.

```sql
SELECT
  count(*) FILTER (WHERE task_output IS NULL) AS null_output,
  count(*) FILTER (
    -- Only a payload that BELONGS to the resolved kind should have carried across; a
    -- deliberate kind override (crm-opportunity beating a stale email payload) is not a loss.
    WHERE task_action_data->>'channel' = task_output->>'kind'
      AND EXISTS (
        SELECT 1 FROM jsonb_each_text(task_action_data) kv(k, v)
         WHERE kv.k <> 'channel' AND NOT (task_output ? kv.k)
      )
  ) AS lossy
FROM user_tasks;
```

Baseline before Phase 1: `null_output=940 · draftId lost=336 · threadId lost=478`.
After Phase 1: **`null_output=0 · lossy=0`**.

Behavioural checks that must pass after every phase from 2 onward:

1. Send a Cedar email draft → originating task becomes `done`
2. Send a Cedar **slack** draft → originating task becomes `done`
3. Reply on a thread carrying an open email task → task closes
4. `done : agent_deleted` ratio does not regress

Static checks, both cheap and both already wired:

```bash
# no predicate may read the legacy column alone
grep -rn "task_action_data->>" apps/server/src --include="*.ts" | grep -v COALESCE   # → empty

# no write may set one axis without the other
pnpm --filter @zero/server exec vitest run src/services/user-tasks/__tests__/task-axes-paired-write.test.ts
```

### A trap worth not re-stepping into

The repair pass's first run **span on one row** after correctly repairing ~1,500. Its predicate
selected rows the rebuild is constitutionally unable to fix — a `crm-opportunity` whose declared
type deliberately outranks its stale email payload rebuilds to the `{ kind }` it already has, so
the merge is a no-op and the batch loop re-selects it forever. Two changes, both kept:

1. the predicate is scoped with `task_action_data->>'channel' = task_output->>'kind'`, so it
   only selects rows a rebuild can actually repair;
2. a no-progress backstop stops the loop when a batch repeats, because termination is a property
   of the predicate rather than of the loop, and the next person to widen the predicate will not
   be thinking about that.

It also exited **0** despite being killed, because it was piped to `tail` and a pipeline reports
the last command's status. Check the script's own exit code, not the pipeline's.

---

## 5. Critical files

| Concern | File |
|---|---|
| Schema + CHECK constraints | [`db/aop-schema.ts`](../../../server/src/db/aop-schema.ts) |
| Legacy → output mapping | [`services/user-tasks/output.ts`](../../../server/src/services/user-tasks/output.ts) |
| Completion paths | [`services/task-scheduling/execution.ts`](../../../server/src/services/task-scheduling/execution.ts), [`services/user-tasks/tasks.ts`](../../../server/src/services/user-tasks/tasks.ts) |
| Lossy writers | [`saveSlackDraftTool.ts`](../../../server/src/mastra/tools/draft-comms/saveSlackDraftTool.ts), [`onEventExecutionDraftSlackTool.ts`](../../../server/src/mastra/tools/event-execution/onEventExecutionDraftSlackTool.ts) |
| Backfills | [`db/migrations/scripts/backfill-task-output.ts`](../../../server/src/db/migrations/scripts/backfill-task-output.ts), [`backfill-task-type-to-group.ts`](../../../server/src/db/migrations/scripts/backfill-task-type-to-group.ts) |
| Agent-facing vocabulary | [`.claude/skills/tasks/SKILL.md`](../../../server/.claude/skills/tasks/SKILL.md) |