worklists.dev

A task is the causal parent of its events

An event says what happened; it has never said what it was for. A task supplies the missing parent, so the record reads "this was scanned because this task said to" — and the join is a hash, not a foreign key.

One thesis: *an event says what happened; it has never said what it was for, and a task is the object that supplies the for. A task is the causal parent of the events that satisfy it. Once that parent exists, the record reads this was scanned because this task said to*, and the join between the two is the event's own hash rather than a column somebody filled in afterward.

What an event can and cannot say

Take a bag of lettuce moving through a DC. An EPCIS event records that a case containing it was observed at dock 12 at 06:14, business step receiving, disposition in_progress, read point urn:epc:id:sgln:.... That is a complete and honest statement. It is what the event engine is for, and it answers what, where, when, and which.

Ask it why and it goes quiet. Was that scan a receiving count against purchase order 2026-0007? A cycle count a supervisor ordered because the lot looked short? An accident while someone was reaching for the case beside it? The event dictionary has no field for intent, and it should not — a field for intent on an event is a field anyone can fill with anything.

Every integration team meets this at the same place: the auditor or the customer asks show me that the receiving count for this PO was done, and the team has a ledger of scans that is consistent with the count having been done and equally consistent with it not having been.

The parent is a task

worklists.dev adds one object above the event. A task is dispatched from a demand — the PO that arrived at transactions.dev, an exception that fired, a plan, a schedule — and it names what will satisfy it: an action verb (the bizStep, from the barcode layer's reading of the mark), a subject, a place, a window.

Then the scan happens. The event lands on the spine exactly as it would have without any task. And complete takes that event's hash and settles the task with it. Nothing about the event changes; the task now points at it, and the pointer is content-addressed.

So the causal arrow runs one way, and it is readable in both directions:

  • From the task: here is the event that satisfied me, by hash.
  • From the event, through trace: here is the task that asked for me, by lineage.

With the action verb supplying the step and the badge supplying the performer, a complete event composes from scans with no application in the loop. The app that rendered the worklist did not author the evidence; the spine did.

Why a hash and not a foreign key

The obvious design is a task_id column on the event, or an event_id column on the task. Both are mutable, and both put intent where it can be edited. A hash cannot be repointed without the repointing being visible, because the closing act is appended to the task's record and the record is append-only. A task has no update. A mistaken close is corrected at the event grain with epcis.dev.correct, which emits a new event with an errorDeclaration — the task record stays exactly as it was, and the correction is itself on the ledger.

That is what makes this was scanned because this task said to a statement a third party can check rather than a statement your application asserts.

What this looks like in the package

The five verbs that answer in code today ship as the package worklists.dev: query, trace, claim, complete, timeout. There is no Task type with a status you can assign. There is an ActionRecord, and foldTask() returns the projection over it:

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

trace returns the lineage: the document or plan that dispatched the task, its assignments, its blocks (to their author only), and its closing evidence. It composes to epcis.dev.trace for the evidence hop rather than walking the ledger itself, which is the same act at a different grain.

What it means for the integration

If you already emit EPCIS events from your WMS, you keep doing that. Worklists does not become a second write door; complete returns a capture intent for epcis.dev.capture and the package's own test suite fails on any fetch in its source. What you add is the parent: dispatch tasks from the demand you already have, and close them with the hashes you already produce.

The question was the receiving count for PO 2026-0007 done? then has an answer that is a task, a hash, and an event — a real who, at a real place, inside the window — instead of a query over scans that might mean anything.

Get started with the package, dispatch one task against one PO, and close it with a real event hash. The bag of lettuce will not notice. The auditor will.

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.