worklists.dev

A worklist is a query, never a store

There is no Task table with a status column. A task's status is a fold over its own acts, a worklist is a predicate over those folds, and the materialized list exists only because the ledger can regenerate 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:

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: 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.

Say what you will dispatch. Access opens the verbs against your own demand, the closing event validated at epcis.dev, and the frontline app under your own name.