TASK_REORDERING_HANDOFF.md10.9 KBView on GitHub # Task Reordering — handoff
Continuation notes for [TASK_REORDERING_DESIGN.md](TASK_REORDERING_DESIGN.md). The design doc is
the spec and the checklist; this file is the state of the world around it — what has landed, what
is broken, and what will bite you.
Last updated 2026-08-30 (phase 7).
---
## Where it stands
| Phase | | |
|---|---|---|
| 1 — Schema, trigger, backfill | **done** | `1bea051c2` |
| 2 — Read path and manual ordering | **done** | `3e93519f1` |
| 2.5 — Concurrency-safe placement | **done in code**, SQL not applied | |
| 3 — Re-stamp on switching to manual | **done** | |
| 4 — Drag to reorder | **done** | |
| 5 — Upcoming as a drop target | **done** | |
| 6 — Headless mirror and wiki | **done** | |
| 7 — Apply the hardening, repair the damage | **done** | `7c5b8fe54` |
The board now reorders and reschedules by drag. Proved end to end against the running dev server:
`groups move --sort-order 15` on a real task moved it to the top of its lane and `groups board`
printed it there with a ★, before it was restored to where it had been.
Branch: `fix/skip-agent-runs-for-warmup-traffic`. Both commits are ancestors of HEAD; other work
has landed on top since.
To continue: `/implement-design apps/mail/modules/userTasks/TASK_REORDERING_DESIGN.md`. It resumes
at the first unchecked box, which is Phase 3.
### Uncommitted at the time of writing
Four files are **staged but not committed**, correcting phase 1 and 2:
- `user_tasks_sort_order.sql` — the seed's idempotency guard (see below; this one is a real fix)
- `TASK_REORDERING_DESIGN.md` — the matching checklist wording
- `TasksToolbar.tsx`, `use-task-list-view-options.ts` — comments that overstated what `manual`
currently does
They are correct and should go in. They are not the author's own work, so they were left staged
rather than swept into a commit.
---
## What is live in the database
Applied to the **shared Supabase staging pooler** — there is no local Postgres, so "dev database"
and "staging" are the same thing here.
- `user_tasks.sort_order` (float8, NOT NULL, default 0) and `user_tasks.sort_order_pinned`
(bool, NOT NULL, default false)
- `idx_user_tasks_user_sort_order`, and the partial `idx_user_tasks_unpinned_due`
- Two triggers, both live: `user_tasks_place_new_row_trg` (BEFORE INSERT) and
`user_tasks_replace_on_due_change_trg` (BEFORE UPDATE)
- 9,421 open tasks seeded from the order the board already showed (due_date DESC). Zero pinned
rows — nothing can pin one until phase 4 ships.
**The migration is idempotent and safe to re-run.** It was not, on first authoring: the seed was
guarded row-wise on `sort_order = 0`, which looks idempotent and is not, because 0 is a value
placement legitimately produces (a user's first task, and `next - 1` whenever the top card sits at
1). Re-running would have re-stamped exactly those rows by due date and undone wherever they had
been dragged. The guard is now per user on `bool_and(sort_order = 0) AND bool_and(NOT
sort_order_pinned)` — seed a user only while every card is still at the column default.
---
## The design is complete. What is left is operational
All seven phases are built, tested, applied and verified. The audit is green:
**96 users, 9268 open tasks, 0 collision groups, 0 inversions.**
Three things a future session should know rather than rediscover:
**1. There is still no headless way to apply a function or trigger change.**
[migrations/README.md](../../../server/src/db/migrations/README.md) documents `psql -f`; there is
no `psql` on this machine, and `db:push` diffs the drizzle *schema*, which does not describe
functions or triggers at all. Phase 7's SQL went in through a hand-written postgres.js script. This
is the second time this file has recorded a written-but-unapplied migration, and it will be the
third unless the gap gets its own ticket.
**2. Nothing records what has been applied where.** The functions live in the database now; the
only evidence is `pg_proc.prosrc`. `cedar-cli tasks sort-order stress` is the closest thing to a
check — it exits non-zero if placement is not concurrency-safe, so it doubles as a post-deploy
assertion.
**3. The repair is re-runnable, and should be re-run rather than reasoned about.** Placement will
drift again — a restored backup, a bulk import, a deploy that lands the SQL without the code.
`tasks sort-order audit` is cheap and answers the question directly.
### Known-open, deliberately not fixed here
- **`listUserTasks` caps at 500 rows, ordered by due date.** Past 500 open tasks the truncation is
by DATE, so manual mode can silently drop cards the user placed. Real, and its own design
question (cursor? per-column fetch?) rather than a line change.
- **Five `as HydratedUserTask` casts** in `use-optimistic-task-actions.ts` spread a
`ConversationUserTask` and are safe only because `services/crm/conversations.ts` selects `*`.
Narrow that select and they start producing rows with `sortOrder === undefined`, with no type
error, because of the cast.
- **`TaskTicketView` prev/next** fetches lane siblings with no ordering input, so ticket navigation
order does not match the board under manual.
## Deviations from the design doc, and why
-1. **Three defects were found by auditing the phases already shipped**, not by building the new
one. The advisory lock as first written shared the one-argument keyspace with the CRM
reconciliation cron's session lock; neighbour selection was non-deterministic on equal due dates
(which the lock does not fix, and which a single-threaded test cannot see); and a reopened task
kept a dead position forever if it was pinned. All three are in the same `CREATE OR REPLACE`, so
applying the SQL fixes all three at once. The lesson worth keeping: the SQL has **no in-process
test coverage at all** — `createOutboundTestDb` builds DDL from the drizzle schema, which carries
the columns but not the triggers. `cedar-cli tasks sort-order stress` is now the only check that
makes the database actually do it, and it exits non-zero when it finds a collision.
0. **The drop DECISION is a pure function**, `planTaskDrop` in
[task-drop-plan.ts](../utils/task-drop-plan.ts), rather than logic inside `handleDragEnd`. A
single drag means one of four things depending on where it started, where it landed and which
ordering mode the board is in — reorder, re-file, reschedule, pull-back — and leaving that
matrix in the handler put every branch behind a real mouse. `handleDragEnd` is now the *writing*
of a plan and nothing else.
1. **A second trigger.** The doc specified only `BEFORE INSERT`. The invariant it states — an
unpinned row's `sort_order` tracks its `due_date` — breaks the first time anyone snoozes a
card, and every later insert then measures against the stale value.
`user_tasks_replace_on_due_change_trg` re-places unpinned rows on a date change. It skips
pinned rows, no-op updates, and updates that set `sort_order` explicitly — that last exemption
is what lets phase 5's drag-out-of-Upcoming write date and position in one statement.
2. **A fourth optimistic literal.** The handoff predicted three; there is a fourth, in
`clientExecutionResponseProcessors.ts` — the task-updated event patch, which inserts into
`conversation.data.userTasks`. `tsc -b` had cached the error away, so it surfaced only on a
later full pass. Expect the same whenever a `user_tasks` column is added.
3. **The placement rule has a real home** at
[sort-order.ts](../../../server/src/services/user-tasks/sort-order.ts), not just a mirror
inside the test. The doc assumed a DB-backed test harness; `createOutboundTestDb`, which
`migrations/README.md` still references, no longer exists. The module is the SQL's executable
spec — if the two disagree, the SQL is wrong.
4. **Three optimistic literals, not one.** `ConversationUserTask` is inferred from the wire row,
so phase 1's columns made `InlineTaskCreation.tsx` and `TaskCreatorDialogue.tsx` incomplete
alongside `use-create-task-optimistic.ts`. Expect the same whenever a `user_tasks` column is
added.
---
## Environment notes
- **`node`/`pnpm` are not on PATH.** Prefix with
`export PATH="$HOME/.nvm/versions/node/v22.19.0/bin:$PATH"`.
- **No `psql`.** Use a postgres.js runner; a write-capable one was used for this work. Read-only
queries can go through `/tmp/sqlq.mjs`.
- **`tsc -b` caches away errors in untouched projects.** After a schema or shared-type change,
force it: `cd apps/<app> && node_modules/.bin/tsc -b --force`. It takes several minutes — run it
backgrounded, and read the output file rather than trusting the wrapper's exit code, which
reports the pipeline's status and not tsc's.
- **The working tree routinely holds several sessions' work at once,** sometimes with files
already staged. Both commits here were made with
`git commit -m "…" -- <explicit paths>` so another session's staged index survived untouched.
Verify with `git diff --staged --name-only` before and after. Never `git stash`.
### A pre-existing typecheck failure that is not this work
`apps/server/src/trpc/routes/crm.ts:4745` — `MeetingRecordingUrlStatus` gained a
`"permission_required"` variant that the tRPC procedure's declared return type does not list. It
belongs to concurrent meeting-recording work and makes `pnpm --filter @zero/mail run types` red.
No error references a file this design touches. Don't try to fix it as part of these phases.
---
## Decisions already settled — don't relitigate
- **Float `sort_order`, not an integer position or a string key.** A drop writes one row. The
~50-halvings precision cliff is real but far off; renormalize a column on write if it ever
matters.
- **One global order per task, not one per column.** A total order restricted to any subset is
still a valid order for that subset, so a single value serves `columnBy` = group | due | channel
simultaneously.
- **Sort modes are live comparators; `manual` is the only one that reads `sort_order`.** Switching
back to manual re-stamps from the mode you were just in and clears every pin, so no stale
arrangement can survive underneath (phase 3).
- **Placement lives in the database.** There are nine `insert(userTasks)` call sites; a rule
enforced in nine places will eventually be enforced in eight.
- **A dismissed Upcoming drop needs no rollback.** Column membership derives from `dueDate`, and
nothing is written until a date is picked — so the card never moved (phase 5).
---
## Worth its own ticket, outside this design
There is no local Postgres and no applied-migrations ledger, so applying a migration means writing
to shared staging and nothing records what has been applied where.
[migrations/README.md](../../../server/src/db/migrations/README.md) documents that `db:generate`
is drifted and cannot be driven headlessly, which is why the hand-apply habit exists. The triggers
here were proved against a throwaway clone inside a rolled-back transaction before being attached
to the real table — but that is a habit, not a safeguard.