# A worklist is a query, never a store

> worklists.dev — 2026-08-20
> Written for ED-3 — the in-house supply-chain platform team — the platform engineer who has built three task tables and watched every one of them drift from the events underneath it.

One thesis: **a worklist is a query, never a store, because a task's status is a fold over its own
acts, never a column.** The shape you are used to — a `tasks` table with `status` and `assignee`
and `updated_at` — is the shape that drifts, and the drift is not a bug in any one system. It is
what a mutable column does when the truth lives somewhere else.

## The table that drifts

Every integration team has built it. A bag of lettuce is due at dock 12; a row is inserted with
`status = 'pending'`. The receiver scans; something sets `status = 'done'`. A supervisor reassigns
the task; `assignee` is overwritten. The pallet turns out to be short; someone sets the row back to
`'pending'` and the original close is gone.

Six months later the row says `done`, the events say two scans and a correction, and nobody can
reconstruct which happened first. The table was the source of truth for the UI and the events were
the source of truth for the auditor, and the two were kept in sync by discipline.

## What the package does instead

There is no `Task` type in `worklists.dev` with a `status` you can assign. There is an
`ActionRecord` — an append-only sequence of acts against a task id — and `foldTask()` returns the
projection:

```ts
statusOf(record.actsOf("task-1"));
// "pending" | "claimed" | "in-progress" | "blocked"
// | "escalated" | "timed-out" | "settled" | ...
```

The absence is the model. There is no `update`, no `reopen`, no second settle. Every state change
is a verb that appends an act: `claim`, `start`, `block`, `unblock`, `escalate`, `complete`,
`timeout`, `cancel`. The status you read is what those acts fold to, computed at read time, and
two readers folding the same acts get the same answer.

One fold rule is load-bearing and worth stating because it is easy to get wrong: an `assigned` act
folds to **`pending`**, not to `claimed`. The state machine's `pending` means *waiting — available
to claim*. Folding a push to `claimed` would make the worklist predicate below return an empty
list for every task anyone had assigned.

## The worklist is the predicate

A human's worklist is a query:

> Actions where tier = human, assignee resolves to me, status = pending

*Resolves to* is what lets a person, a role, a team and a department work uniformly — an assignee
reference carries a role kind, and a role resolves to its current filler at the moment of the query.

And **"me" is why `WorklistQuery` has no assignee member.** The assignee side of the join comes from
the principal the auth rail resolved for this request. The request type cannot carry a performer at
all, which means there is no way to ask for somebody else's list by constructing a query. That is
not a permission check; it is a field that does not exist.

## Materialized, because the ledger regenerates it

A fold has no cheap `WHERE status = 'pending'`. Walking every act of every task on every read is
honest and slow, and the cost is accepted rather than hidden: a `MaterializedWorklist` keeps the
fold warm, and `regenerate()` rebuilds it from the record.

That method is the can-the-ledger-regenerate-it test, as code rather than as a promise. The
materialized list is allowed to exist because throwing it away loses nothing. If it ever disagreed
with the record, the record wins, and the fix is to regenerate — never to edit the list.

## A correction, done right

Back to the short pallet. The receiver's scan closed the receiving task with an event hash; the
count was later found to be thirty-eight cases, not forty. Nothing on the task is edited. The
correction happens at the event grain on the [spine](https://epcis.dev): `correct` emits a new event
carrying an `errorDeclaration` that names the original. The task's fold still reads `settled`; its
`trace` now shows the original close, the correcting event, and the order they happened in.

Compare that to `UPDATE tasks SET status = 'pending'`. One of these is an audit trail. The other is
a row.

## What the engineer gives up, and gets

You give up the ergonomics of a status column — the one-line update, the index on `status`, the
`ORDER BY updated_at`. You get a task history that is complete by construction, a worklist that
cannot be pointed at another person by a crafted request, and a list you can rebuild from the
ledger whenever you doubt it.

`query` and `trace` are the read verbs, both stateless and both free, permanently. Get started with
the package, append a few acts to an in-memory record, and call `regenerate()` on the list. Then
delete the list and call it again. That second call is the argument.

---
Get started: https://worklists.dev/get-access/ · All posts: https://worklists.dev/blog/ · Machine face: https://worklists.dev/llms.txt
