Skip to content

About

Task lifecycle state machine for a two-sided data marketplace (abandonment, expiration, dispute resolution) — reproduced from the DataQuest portfolio case study

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

dataquest-task-lifecycle

Task lifecycle state machine for a two-sided human-data marketplace, reproduced from the DataQuest portfolio case study (human-action data marketplace for embodied AI). Covers the full path from task creation to payout, plus the edge cases the case study calls out: abandonment, expiration, and dispute resolution.

TypeScript, zero runtime dependencies.

Install

Node.js ≥ 20.

npm install
npm run build
npm test   # 1610 tests, all local

Prefer the demo: npm run demo builds, then runs dist/src/demo.js — one task through the full DRAFT → PAID chain, including a first submission that is rejected in QA and resubmitted (the demo sets maxResubmits: 2 and uses 1 of the 2; the library default is unlimited) before passing on re-review, with the append-only audit history printed to stdout, an audit hash-chain segment that runs verifyHistoryChain on that history — the intact 12-entry chain passes, a detached copy whose PAYOUT_COMPLETE entry's payoutAmount is altered by 1 is detected (false: the stored hash no longer recomputes), and the original history re-verifies untouched — the verify-before-replay check an auditor runs before trusting a persisted history, then a second task running the SLA watchdog branch (its ACCEPTED-state deadline is already past at a fixed screening instant, expiredTasks() finds it while it is still ACCEPTED — the deadline alone never expires a task — and a watchdog explicitly dispatches EXPIRE into the EXPIRED terminal state; every branch timestamp is a fixed literal or the injected screening now), then a third task running the DISPUTE appeal branch (rejected in QA, the contributor DISPUTEs the rejection into DISPUTED, and a moderator's ARBITRATE_REJECT lands it back in REJECTED — not a terminal state; the demo sets maxDisputes: 1 and uses 1 of the 1, while the library default is unlimited; every branch timestamp is a fixed literal), and finally the signed payout webhook for the settled task built from that history and verified with the demo secret, a receiver-side dedupe segment that feeds the webhook's eventId through a PayoutEventDedupe under an injected fixed clock (the first delivery is booked, a redelivery of the same eventId is skipped as a duplicate, and the same id is accepted again once the demo TTL — 60 seconds, against the library default of one hour — lapses at exactly its boundary), a batch segment that runs buildPayoutWebhooks over all three demo tasks under one shared fixed now and a deterministic eventId factory (the PAID task builds and verifies; each non-PAID task lands as { taskId, error } only and never blocks the tasks after it), a fan-out delivery segment that delivers the main task's payout event with deliverPayoutWebhookToMany to three fixed endpoints through an injected fake fetch (zero real network) under one shared fixed eventId and now, each endpoint signed with its own demo secret — the endpoint scripted to answer 500 exhausts its retries and fails without blocking the other two, and the per-endpoint results print in input order — a Retry-After parsing segment that runs parsePayoutRetryAfter over a fixed 6-value corpus under the fan-out segment's shared fixed instant reused as nowMs (delay-seconds "2" → 2000 ms and " 30 " → 30000 ms, a future HTTP-date → 5000 ms, a past HTTP-date → 0 ms, and "banana" / absent values → undefined, the fallback to exponential backoff — the hint a 429 response, unlike the risk endpoint's 500, would have used to set the next retry delay) — and a final reconciliation screening branch that drives three more tasks to PAID under fixed-literal timestamps — one settled with no payoutRef on its PAYOUT_COMPLETE entry, one settled for less than its ACCEPT quote, one settled under a payoutRef but with no payoutAmount recorded — and screens all six demo tasks with unreconciledPayouts() / mismatchedPayouts() / totalPaidOut(): each flawed task is flagged by exactly one screen and the paid-out total is the hand-computed 16349 — then a quoted-total branch sums the owed half of the same batch with totalQuoted(), plus one more task ACCEPTed with a quote of 9999 and then REJECTed, pinning the owed total 6500 and the quoted-vs-settled gap -9849 — a minor-unit display branch that runs the settlement amounts through formatMinorUnits — eleven fixed vectors (positive, zero, negative, large, up to MAX_SAFE_INTEGER) plus the finance-pass total itself, 1634900 minor units rendered "16349.00", so a statement prints one pinned format — a minor-unit conversion branch that turns the finance-pass total 16349 major units into 1634900 integer cents with toMinorUnits and back with fromMinorUnits, and shows a fractional minor unit (1634900.5) rejected fail-fast — a minor-unit parsing branch that turns reconciliation strings into integer cents with parseMinorUnits — nine fixed vectors (positive, zero, negative, whitespace-trimmed, -0.00 without a minus sign) plus the finance-pass total itself as a string and the quoted total itself as a string ("6500.00" → 650000, cross-checked against toMinorUnits(totalQuotedValue)), and three illegal shapes pinned to their exact fail-fast wording — a settlement summary diff + rendering branch that summarizes the screened batch twice with summarizeTaskSettlements (as recorded: 6 tasks, 4 PAID, quoted 650000, paid 1634900, gap -984900; then recomputed after DEMO-087 re-drives DEMO-001's chain with the payout corrected from 10098.5 to 100.00: paid 635050, gap 14950), diffs the two with diffTaskSettlementsSummaries (exactly three fields move — paidTotal, gap, unquotedPaidTotal — gap as its own entry), and prints both summaries with renderTaskSettlements (the first reads -9849.00 (overpaid), the second 149.50 (underpaid)) — a dispatch subscription branch that subscribes a collecting listener to a sixth task under fixed-literal timestamps — the listener receives the first three dispatches in order, each (event, from, to) matching the audit history entry with the same seq, and receives nothing for a fourth dispatch after unsubscribe, a stale-task dwell screening branch that drives three more tasks under fixed-literal timestamps to one fixed screening now — one sat in OPEN for 21 days, past its 7-day dwell budget, but its SLA deadline is still ahead; one sat in IN_REVIEW for 1 day, within its 5-day dwell budget, but its SLA deadline has passed; and one sat in OPEN for 1 day with no SLA deadline at all — and prints each task's dwell beside its budget plus the two screens' subsets side by side: staleTasks() selects only the first task and expiredTasks() only the second, so the same batch reads differently under the dwell lens than under the deadline lens (both screens are pure; no task is dispatched or mutated), an approaching-deadline screening branch that drives four more tasks under the same fixed screening now with a 24-hour warning window — one deadline 12 hours out, one exactly 24 hours out, one exactly at now, and one a day past — and prints the half-open window (now, now + 24h] semantics beside the two screens' subsets: tasksApproachingDeadline() selects only the first two (the upper bound is inclusive) while expiredTasks() selects only the last two (the lower bound is exclusive, so a deadline exactly at now is already overdue; both screens are pure; no task is dispatched or mutated), a next-SLA-deadline scheduler branch that drives seven more tasks under the same fixed screening now — one OPEN task with a deadline 12 hours out, one ACCEPTED task whose earlier deadline sits on OPEN (a state already left), one terminal ABANDONED task with the earliest deadline of all, one overdue OPEN task, one OPEN task with no deadline, and two OPEN tasks sharing the same deadline — and prints nextSlaDeadline()'s pick (the 12-hour-out task, not the earliest timestamp in the batch) plus the tie-break (input order wins; both calls are pure, no task is dispatched or mutated), an actor-filter branch that drives four more tasks under fixed-literal timestamps and slices the same batch two ways — tasksByActor() selects every task a given actor touched (DEMO-050, DEMO-051, DEMO-053 for "moderator"; DEMO-051 alone for "contributor", matched once despite two moderator entries on DEMO-053, and never the anonymous DEMO-052) while tasksByState() selects only the OPEN pair (DEMO-050, DEMO-052) — so the actor slice and the state slice are orthogonal, and both filters are pure; no task is dispatched or mutated), an event-filter branch that drives four more tasks under fixed-literal timestamps and slices the same batch three ways — tasksByEvent() selects the task whose history carries DISPUTE (DEMO-086), the twice-resubmitted task (DEMO-087, matched once despite two RESUBMIT entries), and the expired task (DEMO-088), while the fresh DEMO-089 matches no event — so the event slice cuts across states, and all three filters are pure; no task is dispatched or mutated), a payout-reference reverse-lookup branch that drives six more tasks under fixed-literal timestamps and lists every task settled under one transfer — tasksByPayoutRef() selects the two PAID tasks settled under "xfer-88f2" (DEMO-077, DEMO-078) and the one settled under "xfer-99g3" (DEMO-079), while the ref-less settlement (DEMO-080), the task that only requested under "xfer-88f2" (DEMO-081), and the fresh task with no history (DEMO-082) match neither lookup — only a PAYOUT_COMPLETE entry counts as settlement evidence, and both lookups are pure; no task is dispatched or mutated), a combined-filter branch that drives six more tasks under fixed-literal timestamps and asks all three questions at once — tasksByFilters() with { state: "IN_REVIEW", actor: "reviewer", event: "REJECT" } selects only DEMO-092 (the task that went through a full dispute round and came back to review), while dropping the event condition widens the slice to DEMO-090 and DEMO-092 — each condition narrows, and both calls are pure; no task is dispatched or mutated), a state visit budget branch that drives two more tasks under fixed-literal timestamps — DEMO-083 carries maxVisitsByState: { CAPTURING: 2 } and is driven to REJECTED (CAPTURING visited 1 of 2), takes one RESUBMIT round back through CAPTURING (2 of 2), then has its second RESUBMIT rejected with the pinned state visit budget exhausted: CAPTURING already visited 2 of 2 times error, state and history untouched, while the unbudgeted DEMO-084 runs the same two RESUBMIT rounds and both pass (the library default is unlimited; the cap is a demo policy choice, not a built-in limit), a stale-task batch execution branch that runs actOnStaleTasks() over four more tasks at the same fixed now under 7-day dwell budgets for ACCEPTED / CAPTURING / SUBMITTED — the stale task stuck in SUBMITTED (a state with no ABANDON edge) is placed first and fails in isolation, left untouched, while the stale ACCEPTED and CAPTURING tasks after it are still abandoned and a fresh ACCEPTED task is never attempted (3 attempted, 2 acted, 1 failed), an SLA watchdog batch execution branch that rebuilds the SLA screening batch fresh from the same fixed literals under the very same fixed screening now (the executor mutates its inputs and the original SLA tasks are already settled) and runs expireOverdueTasks() over the rebuilt batch — the ACCEPTED mirror of the SLA branch's task and an OPEN task are both overdue and both expired, a third ACCEPTED task whose deadline is still ahead produces no result entry and is never attempted, and the printed screening subset equals the execution's acted set (2 attempted, 2 expired, 0 failed; the executor's own EXPIRE audit entries carry the library-default wall-clock timestamp, and the segment now prints each successful result's entry citation — seq/event/from/to/at, with a "no entry" line for any failed dispatch — so its printed lines agree across runs only after timestamp normalization, like the golden output) — and a closing event-sourcing segment that exports the main task's audit history with historyToNdjson(), prints all 12 NDJSON lines, parses them back with historyFromNdjson(), and replays them with TaskLifecycle.fromHistory(): the replayed task lands in the same PAID state with the same 12-entry history, last entry included (the segment adds no new nondeterminism — it only serializes the main task's existing history), then derives the terminal state a second way with the pure replay() function over the same round-tripped entries (no instance, no snapshot envelope), cross-checking the derivation against the live task's state and the fromHistory task's state, and pins the empty-history DRAFT sentinel — and a closing allowed-events listing segment that runs allowedEvents() over all 13 states in transition-table order and prints one line per state — the query a task-list UI or a watchdog consults to decide what a task can do next — with the three terminal states (PAID / ABANDONED / EXPIRED) returning the empty list, and the main task's own PAID state called out on its own line as that same empty set — and a closing standalone history parsing segment that parses a fixed JSON text with parseHistory() alone (no TaskLifecycle instance involved): the text carries the same five-entry PUBLISH..BEGIN_REVIEW chain that opens the main task, in hashless legacy form with fixed timestamps, the parsed entries print one line each, a comparison line confirms the parsed seq/event/from/to chain equals the main task history's first five entries, and a detached copy whose third entry's from is rewritten is rejected with the chain-break error on one closing line (asserted outside the printed parse) — and a closing transition-table JSON export segment that runs transitionTableJson() and prints an export summary for front-end / docs tooling: the 19-edge total plus the first edge (DRAFT --PUBLISH--> OPEN), the payout edge at index 14 (PAYOUT_PENDING --PAYOUT_COMPLETE--> PAID), and the last edge (DISPUTED --ARBITRATE_REJECT--> REJECTED) parsed back from the canonical JSON, with the per-edge from, event, to key order confirmed on every edge (the demo tests cross-check the full parsed edge list against the committed transition-table snapshot) — and a closing state diagram generation segment that runs stateDiagram() and prints a generation summary: the 24-line total and 19-edge count plus the first, second, initial, first-edge, last-edge, and last lines (the demo tests cross-check the full generated block line by line against hand-written expectations and the copy embedded above) — and a closing README diagram sync check segment that runs readmeDiagramInSync() (imported directly from src/diagram.ts, as the benchmark does) and prints its verdict: true, the README embeds the exact generated block with no diagram drift, asserted outside the printed verdict against the hand-pinned expectation and cross-checked against the diagram tests (the only file read is this README; no network, no randomness, and no wall clock) — and a closing JSON snapshot round-trip segment that exports the main task with toJSON(), prints the serialized byte count of the JSON.stringify snapshot text, restores the task with TaskLifecycle.fromJSON(), and confirms the persistence round-trip: state and the full 12-entry history match the original entry for entry, the used RESUBMIT count reads 1 of the stored budget of 2 on both sides (the count is derived from the history, never stored), and re-exporting the restored task yields the identical snapshot content at the same serialized length (the round-trip is a fixed point; fromJSON normalizes history-entry key order, values and hashes unchanged), all asserted outside the printed lines — and a closing single-task payout reconciliation detail segment that calls payoutMismatch() on the reconciliation branch's two settled tasks one at a time: the mismatched task prints its full detail { quoted: 4000, paid: 3750.5, delta: -249.5 } (underpaid by 249.5), and the task that settled exactly as quoted (2500 against 2500) prints the no-mismatch form, undefined — the per-task follow-up finance runs after the batch mismatchedPayouts() screen flags a task, asserted outside the printed lines against the hand-pinned amounts — and a closing snapshot version envelope segment that prints the main task snapshot's v field and asserts it equals the imported SNAPSHOT_VERSION (1), retags a detached copy to v: 2 and prints the TaskLifecycle.fromJSON() rejection (invalid snapshot: unsupported snapshot version 2 — a future format is refused, not silently misread), and reads the original snapshot back to show it still restores DEMO-001 in PAID — the version gate a document store relies on when a snapshot format evolves, asserted outside the printed lines against the hand-pinned version and error — and a closing note length limit segment that drives a dedicated task (DEMO-017) under fixed timestamps: a dispatch whose note is exactly MAX_NOTE_LENGTH (32768) characters succeeds and stores a note of that length (only the length is printed, never the note itself), and a following dispatch whose note is one character over (32769) is rejected with invalid dispatch options: note must be at most 32768 characters, got 32769, leaving the task still in OPEN with its single history entry untouched — the audit-history anti-bloat guard a reader would otherwise never see, asserted outside the printed lines against the hand-pinned limit and error text (cross-checked against the dispatch source and the actor/note hardening tests) — and a closing standalone overdue judgement segment that calls isOverdue(task, now) on three dedicated tasks (DEMO-018 with an ACCEPTED-state deadline, DEMO-019 with no deadline, and DEMO-020 ABANDONED with an ABANDONED-state deadline) at fixed instants and prints its five boundary verdicts: before the deadline false, exactly at the deadline true (the boundary is inclusive), after the deadline true, the no-deadline task false, and the terminal task past its deadline false — the advisory SLA judgement itself, asserted outside the printed lines against the hand-pinned verdicts (cross-checked against the isOverdue source and the SLA tests) — and a closing terminal-state judgement segment that calls the standalone isTerminal(state) over all 13 states in transition-table order and prints every verdict with that state's outgoing-edge count: only PAID / ABANDONED / EXPIRED are terminal (3 of 13), and the segment cross-checks the printed set against the transition table itself — it is exactly the set of states with no outgoing edges in transitionTable() — with the main task's PAID state called out as terminal under both the standalone function and the instance getter, all asserted outside the printed lines against the hand-pinned verdicts (cross-checked against the TERMINAL set in the source and the isTerminal tests; the branches above only ever showed the getter as a (terminal: …) aside) — and a closing SLA deadline clearing segment that drives a dedicated task (DEMO-021) in ACCEPTED carrying fixed deadlines for ACCEPTED, CAPTURING, and SUBMITTED, overdue at a fixed instant: clearSlaDeadline("ACCEPTED") clears only that state (its read-back is undefined and the overdue verdict turns false, the other states untouched), and clearSlaDeadline() with no argument then clears every remaining deadline — the renegotiated / cancelled deadline revocation, asserted outside the printed lines against the hand-pinned deadlines and verdicts (cross-checked against the clearSlaDeadline source and the SLA tests). — and a single-target payout webhook delivery segment that delivers the main task's signed webhook with deliverPayoutWebhook to one fixed endpoint through an injected fake fetch scripted to answer 500 then 200 (backoffMs 0 with a no-op sleep — no network, no real waiting), prints the result (ok, 2 attempts, status 200; a 429 Retry-After hint would have set the retry's delay, clamped to maxRetryDelayMs, while the 500 fell back to exponential backoff), and asserts outside the printed lines that both captured wire calls carry the byte-identical signed body and that the captured signature verifies over that body — the single-endpoint delivery path, shown instead of only asserted by the delivery tests. — and a closing subscribe onError error routing segment that subscribes a listener that throws on every notification with a collecting onError hook on a dedicated task (DEMO-022), runs a short fixed-timestamp dispatch chain (PUBLISH / ACCEPT / START_CAPTURE), prints how many errors the hook received (3) and the first report's dispatch context (PUBLISH DRAFT -> OPEN), and asserts outside the printed lines that every dispatch still returned normally, the audit history is complete, every routed context matches the hand-pinned chain, and — on a separate probe task (DEMO-023) — that an onError hook that itself throws is swallowed too — the listener-failure observability path, shown instead of only asserted by the subscribe onError tests. — and a payout dedupe snapshot persistence segment that records two fixed payout eventIds on a fresh PayoutEventDedupe under an injected fixed clock (each new on first sight, a duplicate on immediate redelivery), exports them with exportSnapshot() (only the snapshot version and entry count are printed), round-trips the snapshot through JSON text, and restores it with PayoutEventDedupe.restore(): the restored store's observability counters start at zero, yet both ids still read as duplicates and an unseen id reads as new, and a detached copy of the snapshot with its version rewritten to 2 is rejected with the exact invalid-snapshot error — the receiver-restart persistence path, shown instead of only asserted by the dedupe snapshot tests. — and a payout dedupe stats observability segment that runs a fresh PayoutEventDedupe under an injected fixed clock with a small maxEntries (3) through a pinned script — a new eventId, its immediate redelivery, two more new ids that fill the store, and a fourth new id that evicts the least recently seen one — printing stats() (size / hits / misses / evictions) after every step (the duplicate is the only hit, every first sighting is a miss, exactly one eviction), and then mutates a returned stats object (hits set to 999) to show the store's own counters stay at { size: 3, hits: 1, misses: 4, evictions: 1 } — the snapshot is a detached copy, not a live view — the receiver-operations observability surface, shown instead of only asserted by the dedupe stats tests. — and a payout dedupe clear reset segment that records one fixed payout eventId on a fresh PayoutEventDedupe under an injected fixed clock (new on first sight, a duplicate on immediate redelivery, stats pinned at { size: 1, hits: 1, misses: 1, evictions: 0 }), then calls clear() — printing size and stats back at all zero — and records the same eventId again, which reads as a new event with the counters re-accumulating from zero ({ size: 1, hits: 0, misses: 1, evictions: 0 }) — the explicit reset surface for key rotation, test isolation, or a deliberate window reset, shown instead of only asserted by the dedupe clear test. — and a payout webhook 429 Retry-After delivery segment that delivers the main task's signed webhook with deliverPayoutWebhook to one fixed endpoint through an injected fake fetch scripted to answer 429 with a pinned Retry-After: 30 header on the first attempt and 200 on the retry, with a recording fake sleep in place of a real wait: the recorded sleep is exactly [5000] — the parsed hint (30 s = 30000 ms) clamped to the segment's maxRetryDelayMs (5000), never the exponential backoff (backoffMs 100) — and both wire calls carry the byte-identical signed body, whose signature verifies under the demo secret — the actual end-to-end wait, shown instead of only parsed in isolation or asserted by the delivery tests. — and a payout webhook secret rotation segment that builds two fixed notifications from the main task — one signed with a new secret, one with the old, both under a fixed signing time — and verifies both under { secrets: [new, old] }, then shows the window's edges failing closed (the new-signed notification under the old secret alone, and both under an unrelated secret list) and the configuration error thrown when the positional secret and secrets are passed together — the rotation window, shown instead of only asserted by the secret rotation tests. — and a payout webhook AbortSignal segment that delivers the main task's signed webhook with deliverPayoutWebhook under an external AbortSignal in two pinned forms: a signal aborted before the call (zero fetch calls, the abort reported as { ok: false, attempts: 0, error: "payout webhook delivery aborted" }) and a signal aborted inside the backoff sleep after a first 500 answer (the pending 30000 ms wait is cut short, no second attempt is started, and the result reports the one attempt actually made, with its 500 status and the same pinned error) — the caller cancellation path, shown instead of only asserted by the payout webhook abort-signal tests. — and a payout webhook freshness window segment that builds one fixed notification from the main task at a pinned signing time and verifies the same body and signature with verifyPayoutWebhook under injected receiver clocks: inside the maxAgeMs window (3600000 ms) it verifies, exactly at the window boundary it still verifies (the boundary is inclusive — only now - at > maxAgeMs fails), past the window it fails closed even though the signature alone still verifies, and a payload lying further ahead of the receiver than maxFutureSkewMs (3600000 ms) allows also fails closed — the replay bound, shown instead of only asserted by the payout webhook freshness and future-skew tests. — and a closing payout reference enforcement segment that drives a dedicated task constructed with requirePayoutRef: true to PAYOUT_PENDING under fixed timestamps: its PAYOUT_COMPLETE without a payoutRef is rejected with the pinned payout reference required: … error and leaves state and history untouched, the same dispatch with the pinned payoutRef xfer-demo-require-ref then settles it and the history's last entry carries that reference verbatim, and a second dedicated task under the default (the option unset) still settles without a reference — the fail-fast settlement gate, shown instead of only asserted by the requirePayoutRef tests. — and a closing dispatch idempotency key segment that dispatches a dedicated task under fixed timestamps with pinned idempotencyKeys: the same key sent twice for the same event appends a single history entry and notifies its listener once, after the task moves on retries under the old key still dedupe — both for a different (would-be legal) event and for the stale original event, returning the current state without throwing invalid transition — a second dedicated task first fails a dispatch under a pinned key (an illegal transition), which consumes nothing, so the corrected dispatch under the same key then succeeds, and a final dispatch with no key at all appends normally — the retry-safety valve, shown instead of only asserted by the idempotency key tests.

The package entry point is declared in package.json via an exports map (. → dist/src/index.js, types dist/src/index.d.ts); main/types are kept pointing at the same files for older resolvers.

Quickstart

import { TaskLifecycle } from "./dist/src/index.js";

const task = new TaskLifecycle("task-042");
task.dispatch("PUBLISH", { actor: "researcher" });
task.dispatch("ACCEPT", { actor: "contributor" });
task.dispatch("START_CAPTURE", { actor: "contributor" });
task.dispatch("SUBMIT", { actor: "contributor" });
task.dispatch("BEGIN_REVIEW", { actor: "reviewer" });
task.dispatch("APPROVE", { actor: "reviewer", note: "meets rubric" });
task.dispatch("REQUEST_PAYOUT", { actor: "contributor" });
task.dispatch("PAYOUT_COMPLETE", { actor: "system" });
console.log(task.state); // PAID

State machine

The diagram below is generated from the transitionTable() single source of truth — regenerate it any time with npm run diagram (prints the block to stdout; node dist/src/diagram.js --check exits 1 if the README copy drifts; --write <file> writes bare mermaid source for standalone .mmd use). test/stateDiagram.test.ts and test/diagram.test.ts fail the build if the README copy or any rendered edge ever drifts from transition() behavior.

stateDiagram-v2
    direction LR
    [*] --> DRAFT
    DRAFT --> OPEN : PUBLISH
    OPEN --> ACCEPTED : ACCEPT
    OPEN --> EXPIRED : EXPIRE
    ACCEPTED --> CAPTURING : START_CAPTURE
    ACCEPTED --> ABANDONED : ABANDON
    ACCEPTED --> EXPIRED : EXPIRE
    CAPTURING --> SUBMITTED : SUBMIT
    CAPTURING --> ABANDONED : ABANDON
    CAPTURING --> EXPIRED : EXPIRE
    SUBMITTED --> IN_REVIEW : BEGIN_REVIEW
    SUBMITTED --> EXPIRED : EXPIRE
    IN_REVIEW --> APPROVED : APPROVE
    IN_REVIEW --> REJECTED : REJECT
    APPROVED --> PAYOUT_PENDING : REQUEST_PAYOUT
    PAYOUT_PENDING --> PAID : PAYOUT_COMPLETE
    REJECTED --> DISPUTED : DISPUTE
    REJECTED --> CAPTURING : RESUBMIT
    DISPUTED --> APPROVED : ARBITRATE_APPROVE
    DISPUTED --> REJECTED : ARBITRATE_REJECT
Loading
  • transition(state, event) is a pure function; invalid transitions throw.
  • TaskLifecycle wraps it with an append-only history: each entry has (seq, event, from → to, ISO timestamp), optional (actor, note, payoutRef, payoutAmount), and a prevHash/hash SHA-256 audit chain.
  • allowedEvents(state) / isTerminal(state) helpers for UI gating.
  • Terminal states: PAID, ABANDONED, EXPIRED.
  • transitionTableJson() exports the whole transition table as canonical JSON; test/__snapshots__/transitionTable.snapshot.json is the committed snapshot (test/transitionSnapshot.test.ts fails if the table ever changes without regenerating it).

SLA deadlines

A state may carry an optional SLA deadline (TaskLifecycle.setSlaDeadline, per-state, stored as normalized ISO). isOverdue(task, now) answers "is the task past its deadline right now" — false when no deadline is set, false in terminal states, and true at/after the deadline.

Relationship to EXPIRE: the deadline is advisory only and never moves the task by itself — there is no timer, no background scheduler. Expiry stays an explicit EXPIRE event so it lands in the append-only history. The intended wiring is a watchdog (cron, queue consumer) that polls isOverdue() and dispatches EXPIRE itself, which is exactly what test/sla.test.ts demonstrates in its last case.

Deadlines can also be attached at construction instead of one setSlaDeadline call at a time: new TaskLifecycle(id, { slaDeadlines: { OPEN: "…", ACCEPTED: "…" } }). The option applies the same validation and canonical-ISO normalization as setSlaDeadline — an unknown state or unparseable date throws at construction and no task is created. getSlaDeadline and clearSlaDeadline apply the same unknown-state gate: a misspelled state throws invalid SLA deadline: unknown state … instead of returning undefined or silently doing nothing (only the no-argument clearSlaDeadline() clears every state). It is also how a history-only rebuild re-attaches deadlines: the audit log itself carries none, so fromHistory(id, history, { slaDeadlines }) passes the option through the constructor (see "Replay" below); a snapshot restore does not need it, because toJSON() already carries slaDeadlines.

For batch polling there is a dedicated helper — expiredTasks(tasks, now) returns the "non-terminal and past deadline" subset of a task list. It is a pure filter (it never mutates or dispatches); the watchdog executor expireOverdueTasks(tasks, now) does the dispatch for you, one line:

The "warn before it is too late" counterpart is tasksApproachingDeadline(tasks, withinMs, now) — it returns the non-terminal tasks whose current-state SLA deadline falls inside the half-open window (now, now + withinMs]. A task already past its deadline is never reported here (that is expiredTasks' job, so no task is reported twice), and a deadline landing exactly on now + withinMs counts as "within". Tasks with no deadline for their current state and terminal tasks are never selected; an invalid withinMs (negative, non-finite, non-number) fails fast instead of silently returning an empty list. Like expiredTasks, it is a pure filter — a watchdog uses it to nudge or reassign before the deadline passes, not to expire.

The scheduler counterpart is nextSlaDeadline(tasks, now) — it returns the single task whose current-state SLA deadline comes next, as { task, deadline } (the deadline is the canonical ISO string), or undefined when no task qualifies. Only non-terminal tasks with a deadline on their current state, strictly after now, are candidates: a deadline exactly at now is overdue (that is expiredTasks' job), a terminal task's deadline never counts, and a deadline set for a state the task has already left is ignored. Ties keep input order, so repeated polls over an unchanged batch are stable. It is pure — it never dispatches — so a scheduler uses it to pick the next wake-up time and still expires through expireOverdueTasks() when that time arrives; an invalid now fails fast.

The whole-map counterpart is nextTaskDeadline(tasks, now) — it returns the single earliest SLA deadline anywhere in any task's per-state SLA map, as { task, state, deadline }, or undefined when no task qualifies. It scans every non-terminal state's deadline in each task's map, so the winning state may be the task's current state, a state it has not reached yet (a planned SLA for a later step), or a state it already left (the map is caller-managed planning data, so a stale entry still counts here — clear it with clearSlaDeadline when it no longer applies). A deadline attached to a terminal state (PAID/ABANDONED/EXPIRED) never counts, and neither does a deadline at or before now. Ties keep input order across tasks (and transition-table declaration order within one task). Use nextSlaDeadline when you want only the deadline that binds right now; use nextTaskDeadline when you want the earliest date attached to a task in any state. Like its sibling it is pure and fails fast on an invalid now.

import { expireOverdueTasks, TaskLifecycle } from "./src/index.js";

const tasks: TaskLifecycle[] = loadTasks(); // your store
const outcomes = expireOverdueTasks(tasks); // [{ task, expired: true, entry }, …]
// { task, expired: false, error } — see below

Successful outcomes also carry entry — a frozen, detached copy of the EXPIRE audit entry the dispatch just appended (event: "EXPIRE", from, to, seq, at, …). It is there so an SLA operations report can cite the outcome per task without re-reading task.history (which task expired, from which state, at which timestamp); failed dispatches omit the field entirely, so the { task, expired: false, error } failure shape is unchanged and backward compatible.

The executor takes an optional third argument, at — a Date or a canonical ISO-8601 string — that pins the EXPIRE audit entry's timestamp: expireOverdueTasks(tasks, now, at). A caller replaying a fixed screening instant gets deterministic watchdog entries instead of wall-clock stamps. Omitted, the dispatch stamps the wall clock, as before, so a caller that passed a past now for screening sees no silent change. An at earlier than a task's last history entry trips dispatch's non-decreasing-timestamp check and lands as a per-task { expired: false, error } — the batch continues. An invalid at (a non-Date/non-string value, an invalid Date, a non-canonical string) throws invalid at: … before any task is screened or dispatched, so a bad pin can never half-stamp a batch.

For rehearsing a batch before expiring it for real, pass { dryRun: true } as a fourth argument: expireOverdueTasks(tasks, now, at, { dryRun: true }). The executor screens the same set in the same order but dispatches nothing — no state change, no history append, no subscription notification — and each result carries wouldExpire (true exactly when the live run would have expired that task) and, when false, the same-wording error the live run would have reported (no EXPIRE edge, a backdated at pin, or a rolePolicy that does not admit system); expired is false throughout and entry is never present. The clock check reads the at pin when given; without a pin the live dispatch stamps the wall clock unchecked, so the prediction does the same. Illegal options throw invalid expireOverdueTasks options: … fail-fast, before any screening.

Every successful EXPIRE dispatch is signed with the system actor — the same attribution actOnStaleTasks uses for its default ABANDON action — so a watchdog-driven expiry stays distinguishable from a human one in the append-only history.

Why the executor exists instead of a hand-written loop: expiredTasks() only checks the deadline and the terminal flag, but EXPIRE is not a legal event from every non-terminal state — the transition table only has EXPIRE edges on OPEN, ACCEPTED, CAPTURING and SUBMITTED. An overdue IN_REVIEW task (or a deadline set on any other state without the edge) makes task.dispatch("EXPIRE") throw invalid transition: EXPIRE from IN_REVIEW, aborting a naive for (const task of expiredTasks(tasks)) task.dispatch("EXPIRE") loop on the first such task. expireOverdueTasks catches that error per task — { task, expired: false, error: <message> } — and keeps going, so one unexpirable task never blocks the rest of the batch. Only overdue (non-terminal, past deadline) tasks are attempted; nothing auto-migrates, every expiry is an explicit dispatch that lands in the append-only history.

A related but distinct screening is staleTasks(tasks, maxAgeByState, now) — it finds tasks stuck in their current non-terminal state longer than a per-state budget, measured from the last history entry's timestamp. That is the watchdog for "nobody picked up this OPEN task in a month" or "this review has been pending a week", as opposed to "this task passed an absolute deadline". Terminal states and states with no configured budget are never selected; the helper is pure (never mutates or dispatches), and invalid budget configuration fails fast with invalid maxAgeByState: …:

import { staleTasks, TaskLifecycle } from "./src/index.js";

const tasks: TaskLifecycle[] = loadTasks(); // your store
const stale = staleTasks(tasks, {
  IN_REVIEW: 7 * 24 * 3600_000, // a week
  OPEN: 30 * 24 * 3600_000,    // a month
});

For acting on that list there is a dedicated executor — actOnStaleTasks(tasks, action, maxAgeByState, now) — which runs an action per stale task and reports { task, acted, error?, entry? } per task. A successful outcome carries entry — a frozen, detached copy of the audit entry the action appended — when the action actually grew the history (the default ABANDON dispatch does); a notify-only action appends nothing, so its successful outcomes omit entry, and failed actions omit it as well. A stale-patrol report can therefore cite the entry (task, from/to state, timestamp) without re-reading task.history, mirroring what expireOverdueTasks already does for ExpireOverdueResult:

import { actOnStaleTasks, TaskLifecycle } from "./src/index.js";

const tasks: TaskLifecycle[] = loadTasks(); // your store
const budgets = {
  IN_REVIEW: 7 * 24 * 3600_000, // a week
  OPEN: 30 * 24 * 3600_000,    // a month
};
// action omitted (undefined): the default dispatch("ABANDON", { actor: "system" })
const outcomes = actOnStaleTasks(tasks, undefined, budgets);
// pin the default action's audit timestamp as well — a Date or a
// canonical ISO-8601 string; omitted, the dispatch stamps the wall
// clock. A custom action manages its own timestamps, so `at` is
// ignored when one is passed (it is still validated fail-fast).
// const outcomes = actOnStaleTasks(tasks, undefined, budgets, now, at);
// or a custom action, e.g. notify-only paging that never transitions:
// const outcomes = actOnStaleTasks(tasks, (t) => pageOnCall(t.id), budgets);

For rehearsing a sweep before acting on it for real, pass { dryRun: true } as a sixth argument: actOnStaleTasks(tasks, undefined, budgets, now, at, { dryRun: true }). The executor screens the same stale set, in the same order, but calls the action for none of them — no state change, no history append, no subscription notification — and returns { task, acted: false, wouldAct: true } per selected task. wouldAct answers only "selected", never "would succeed": the default ABANDON dispatch is illegal from SUBMITTED, IN_REVIEW and OPEN, and a custom action can throw for its own reasons, so a dry run never predicts the outcome of an action it did not run. The same batch can immediately be acted on for real. at is still validated fail-fast in a dry run, and illegal options throw invalid actOnStaleTasks options: … before any screening. This mirrors the sibling escrow actOnStaleEscrows dry-run port — same trailing opts, same wouldAct semantics.

Why the executor exists instead of a hand-written loop: ABANDON is not a legal event from every non-terminal state — the transition table only has ABANDON edges on ACCEPTED and CAPTURING. A stale SUBMITTED, IN_REVIEW or OPEN task makes task.dispatch("ABANDON", …) throw invalid transition: ABANDON from SUBMITTED, aborting a naive for (const task of stale) task.dispatch("ABANDON", …) loop on the first such task and leaving the rest of the batch unprocessed. actOnStaleTasks catches that error per task — { task, acted: false, error: <message> } — and keeps going. Configuration (maxAgeByState, now, a non-function action) is validated fail-fast before any action runs. The hand-written loop remains the clearest statement of the semantics:

for (const task of stale) {
  task.dispatch("ABANDON", { actor: "system" }); // or page a human, not expire
}

Persistence

A task can be exported to plain JSON and rebuilt later — no database built in, you choose the store:

const task = new TaskLifecycle("task-042");
task.dispatch("PUBLISH", { actor: "researcher" });
task.setSlaDeadline("2026-12-01T00:00:00.000Z"); // advisory SLA deadline

// snapshot: { v, id, state, history, slaDeadlines, [maxResubmits],
//   [maxDisputes], [maxVisitsByState], [rolePolicy], [idempotencyKeys] } — plain JSON, no Maps, no class
//   instances. The bracketed fields appear only when the corresponding
//   option is configured (see TaskSnapshot). Also what
//   JSON.stringify(task) produces.
const snapshot = task.toJSON();
await db.save(snapshot);

// restore later, in another process:
const restored = TaskLifecycle.fromJSON(await db.load("task-042"));
restored.dispatch("ACCEPT"); // history continues at seq 3, no gaps

TaskLifecycle.fromJSON() is defensive by design: it validates the untrusted snapshot against the same audit invariants the tests enforce — seq restarts at 1 with no gaps, the from/to chain is continuous and starts at DRAFT, every (from, event) → to edge is a legal transition, and timestamps are canonical ISO-8601 and non-decreasing. SLA deadline strings are normalized exactly like setSlaDeadline does. Malformed or inconsistent input throws a specific invalid snapshot: … error instead of producing a task with a broken audit trail. See test/serialization.test.ts for the full checklist.

Snapshots carry a schema version: toJSON() always writes v: 1 (the exported SNAPSHOT_VERSION constant), and fromJSON() checks it before any structural or hash-chain validation. A snapshot with no v is a legacy pre-versioning snapshot and is still accepted, with state, history, and SLA deadlines restored exactly as before; a snapshot carrying any other v value (2, "1", null, …) throws invalid snapshot: unsupported snapshot version …, so a future format change stays distinguishable from corruption. The parser is also fail-closed about unknown fields, exactly like the sibling escrow-state-machine-ts parser: a top-level field outside the toJSON() shape throws invalid snapshot: unknown field "<name>", and a history-entry field outside TaskHistoryEntry throws invalid history: entry[i]: unknown field "<name>" — both checked before any field is consumed (the version gate still runs first), so a typo like deadlline or payoutAmout fails loudly instead of quietly dropping the SLA deadlines or payout amount it was meant to carry. Snapshots produced by toJSON() always pass, and legacy snapshots without v are unaffected.

Signed snapshots close the remaining gap: the hash chain covers history entries only, so the snapshot-level fields (state, slaDeadlines, maxResubmits, maxDisputes, maxVisitsByState, rolePolicy, idempotencyKeys) sit outside it — a rewritten persisted file could push an SLA deadline back, loosen a budget or a policy, or clear consumed idempotency keys without breaking any history link. task.exportSignedSnapshot(secret) wraps the snapshot in { v: 1, snapshot, mac } (HMAC-SHA256 over the canonical JSON of the whole snapshot, same envelope shape as the sibling escrow-state-machine-ts Escrow signed snapshot and this repo's PayoutEventDedupe signed snapshot), and TaskLifecycle.restoreSignedSnapshot(envelope, secret, opts?) (alias fromSignedSnapshot) verifies the MAC in constant time before any snapshot parsing — a tampered field, a wrong secret, or a smuggled extra field fails closed with no task produced, and a MAC-valid envelope whose snapshot is itself malformed still fails strict snapshot validation. opts.auditSecret is handed through to fromJSON, so a keyed history chain still needs its own key on restore; the envelope secret need not equal the audit secret. The secret is never written into the envelope, the MAC is integrity protection only (not encryption), and the unsigned path is unchanged — the two forms never accept each other, so a signed restore cannot silently downgrade to trusted configuration. The envelope can also carry an optional expiry: task.exportSignedSnapshot(secret, { expiresAt }) or { maxAgeMs, now? } writes a canonical ISO-8601 UTC expiresAt into the envelope, covered by the MAC, and restoreSignedSnapshot rejects an expired envelope (now > Date.parse(expiresAt)) before any snapshot parsing — restoring at exactly expiresAt still succeeds — with the restore clock injectable through opts.now; an envelope without expiresAt never expires.

To answer "what moved since the last snapshot I stored" without hand-written field compares, diffTaskSnapshots(before, after) returns only the keys that actually changed: stateChanged, quoteChanged (the quoted amount from the last ACCEPT entry, undefined when none is recorded — in live use only ever absent to present, since the machine permits a single ACCEPT and there is no re-price path), payoutChanged (the settled PAYOUT_COMPLETE amount, undefined when none is recorded), historyAppended (the appended entries with their seq range), slaChanged (the full before/after deadline maps), and idempotencyKeysChanged (a plain boolean, true when the consumed idempotency-key set moved — compared order-insensitively, and a snapshot without the field counts as the empty set, so a key consumed or dropped between two stored snapshots is visible in the diff). Two equal snapshots diff to {}. History is append-only: a rewritten, truncated, or seq-breaking later history throws instead of being reported as progress, and snapshots of different tasks throw. The comparison is pure and does not re-validate transitions or the hash chain. See test/diffTaskSnapshots.test.ts.

Hash-chained audit history

Every entry dispatch() appends carries prevHash and hash: prevHash is the previous entry's hash (the genesis entry's is the exported GENESIS_PREV_HASH constant, "GENESIS"), and hash = sha256(canonical(entry sans hash) + prevHash) via node:crypto — the same convention the sibling escrow-state-machine-ts repo uses. parseHistory() re-verifies the chain whenever entries carry it, so a persisted snapshot or NDJSON line that was rewritten, deleted from the middle, or reordered fails rehydration (invalid history: history hash chain is broken) instead of replaying as a clean log; a history mixing chained and hashless entries is rejected outright. Histories produced before this feature (no hash fields) still parse — fromJSON() / fromHistory() chain them deterministically on rehydration, so the live history is always fully chained.

Immutability is not free at read time: get history() returns a frozen array of frozen entry copies, so the runtime guarantee that the append-only log cannot be rewritten costs an O(n) copy plus one Object.freeze per entry on every read — a polling UI or audit exporter looking at the trail pays this on every poll. The Benchmarks section times exactly this path (history getter (defensive copy, 64 entries)).

Verify a log without rehydrating:

import { verifyHistoryChain } from "dataquest-task-lifecycle";

verifyHistoryChain(log); // true when intact; false on tampering, deletion, or reordering

Honest limit: by default this is an unkeyed chain. It detects edits by anyone who rewrites entries without recomputing the chain (manual edits, log-shipper corruption, partial restores). It does not stop an attacker who rewrites the whole log and recomputes the hashes.

Optional keyed mode closes exactly that gap: construct the task with an auditSecret and every link becomes hash = hmac_sha256(secret, canonical(entry) + prevHash) instead of plain SHA-256 (the canonical byte format is unchanged):

const task = new TaskLifecycle("task-042", { auditSecret });
verifyHistoryChain(log, { auditSecret }); // keyed logs need the secret

Rewriting the log then requires the secret as well as the data, so a full-log rewrite recomputed under a wrong (or no) key is detected. Verification is fail-closed across modes: a keyed chain does not verify without the secret or with the wrong one, and an unkeyed chain does not verify when a secret is supplied. The secret is task configuration, not audit data — it is never written into the snapshot or the NDJSON export — so rehydration re-supplies it: TaskLifecycle.fromJSON(snapshot, { auditSecret }) or TaskLifecycle.fromHistory(id, log, { auditSecret }), and the NDJSON helpers take it as historyToNdjson(task, { auditSecret }) / historyFromNdjson(text, { auditSecret }). A legacy hashless snapshot restored with a secret is chained in keyed mode on rehydration. Key generation, storage, and distribution stay the caller's responsibility — a keyed chain is a MAC, not a signature: anyone who holds the secret can still rewrite the log undetectably.

Event-sourced replay

When only the raw audit log survives (e.g. an event stream, a forwarded batch), the history alone is enough to rebuild the task — no snapshot envelope needed, and no state field to trust:

import { TaskLifecycle, replay } from "dataquest-task-lifecycle";

const log = await eventStore.read("task-042"); // plain JSON entries

replay(log); // => "PAID" (pure: just the final state)

const task = TaskLifecycle.fromHistory("task-042", log);
task.dispatch("ACCEPT"); // history continues at the next seq, no gaps

The same log also answers "what state was this task in at a moment?" without a rebuild — a read-only time-travel query over the audit history, for audit and support questions:

task.stateAt("2026-03-01T15:00:00.000Z"); // => "ACCEPTED"
// The pure form works on a raw log, with the same parseHistory checks:
stateAtFromHistory(log, new Date("2026-03-01T15:00:00.000Z"));

The answer is the to of the last entry at or before the query time (inclusive: a query exactly on an entry's timestamp sees that entry's effect, the same boundary isOverdue uses); a time before the first entry returns the initial state, "DRAFT". A broken log throws invalid history: … instead of a guessed state, a keyed chain needs its auditSecret, and an invalid query time throws invalid stateAt time: …. The query never mutates the task — its state and history are untouched, and dispatching afterwards continues at the next seq.

NDJSON history export

When the audit log must travel as a line-delimited stream (log shippers, batch forwarders, one event per line in a store), export and re-import it without losing the integrity checks:

import { historyFromNdjson, historyToNdjson, replay } from "dataquest-task-lifecycle";

const lines = historyToNdjson(task); // "…\n…\n…\n" — canonical key order per line
await stream.write(lines);

const entries = historyFromNdjson(await stream.readAll());
replay(entries); // => "PAID"

historyToNdjson validates the entries with the same parseHistory checklist before serializing, so a broken history refuses to export instead of producing a file that could never be re-imported. historyFromNdjson skips blank lines, accepts \r\n, and attributes every failure to its 1-based line number (invalid ndjson: line 7: … — the entry index shifts when blank lines are present, the line number does not).

Every entry is validated with the same audit invariants as fromJSON() (seq continuity, from/to chain, legal edges, canonical ISO timestamps) — a broken log throws a specific invalid history: … error instead of a guessed state. Replay restores state + history only: the task id is not recoverable from the log (entries carry no id), so it is passed explicitly, and advisory SLA deadlines are not part of the audit log, so they are not restored from the log — re-attach them with fromHistory(id, history, { slaDeadlines }), or use fromJSON() for the full snapshot.

Settlement payout references

dispatch(event, { payoutRef }) records an external payment reference (payout batch id, transfer id, …) verbatim into the audit entry — a typed, queryable field for settlement reconciliation, instead of free-text note:

const task = new TaskLifecycle("task-042");
/* … DRAFT → APPROVED … */
task.dispatch("REQUEST_PAYOUT", { actor: "contributor", payoutRef: "batch-2026-1006" });
task.dispatch("PAYOUT_COMPLETE", { actor: "system", payoutRef: "xfer-88f2" });
task.history[7].payoutRef; // => "xfer-88f2"

payoutRef must be a non-empty string of at most 256 characters (MAX_PAYOUT_REF_LENGTH, exported); anything else throws invalid dispatch options: … before the entry is appended, like actor/note. The same cap is enforced when parsing an untrusted snapshot and when validating ledger rows in reconcilePayouts(), because a ref is written verbatim into the append-only history and fanned out in webhook payloads and NDJSON exports; the cap is a rejection, never a truncation, since truncating would change which transfer the ref names. Read-only lookups (tasksByPayoutRef / tasksByFilters) deliberately impose no cap: they only read a ref, never persist it. The reference survives toJSON() / fromJSON() and fromHistory() round-trips (an empty, non-string, or overlong payoutRef in an untrusted snapshot is rejected as invalid history), and it is preserved by the NDJSON export.

Honest limit: a PAYOUT_COMPLETE without a payoutRef is legal — advisory only, not enforced. The library cannot verify whether an external payment actually happened, so requiring the field would be a guess, not a guarantee. Deployments whose payout flow always produces a reference can opt in to up-front enforcement instead: constructing the task with new TaskLifecycle(id, { requirePayoutRef: true }) makes a PAYOUT_COMPLETE dispatch without a payoutRef throw payout reference required: … (checked after the transition/budget checks, before anything is appended — a rejected dispatch leaves no trace). The switch is task configuration, not audit data: it is never written into the toJSON() snapshot, so a restored task re-enables it via fromHistory(id, history, { requirePayoutRef: true }). The settled amount has the same opt-in: new TaskLifecycle(id, { requirePayoutAmount: true }) makes a PAYOUT_COMPLETE dispatch without a payoutAmount throw payout amount required: …; a present amount still goes through the usual validation (finite, non-negative, at most 2 decimal places), and 0 is a legal amount. Same in-memory-only semantics, re-enabled via fromHistory(id, history, { requirePayoutAmount: true }). For the other half of the loop, the unreconciledPayouts() reconciliation helper flags PAID tasks whose PAYOUT_COMPLETE entry lacks one:

import { unreconciledPayouts } from "dataquest-task-lifecycle";

// PAID tasks whose PAYOUT_COMPLETE entry has no payoutRef or no
// payoutAmount — settlements that still need chasing. Pure: reads
// the tasks, never mutates them.
for (const task of unreconciledPayouts(allTasks)) {
  alertFinance(task.id, "paid without complete settlement evidence");
}

A reference on REQUEST_PAYOUT alone does not reconcile the task: the check reads the PAYOUT_COMPLETE entry (the one that moved the task to PAID), because that is the entry that evidences the settlement itself. The same holds for the amount: a reference without a recorded settled amount cannot be checked against a ledger by amount, so it stays unreconciled too — an amount of 0 still counts as recorded (a zero-value settlement is still a settlement, the same rule the requirePayoutAmount gate uses); only a missing amount is flagged.

The same population can be aged instead of just listed. payoutAging() buckets the unreconciled tasks by whole days since that PAYOUT_COMPLETE entry, so the oldest unevidenced settlements get chased first: under7Days, days7To30 (7 up to, not including, 30 days — exactly 7 lands here), over30Days (30 days or more — exactly 30 lands here), and unknown for a task with no completion entry at all, which is aged by nothing and dropped by nothing:

import { payoutAging } from "dataquest-task-lifecycle";

// Pure: reads the tasks, never mutates them. Reconciled tasks and
// non-PAID tasks appear in no bucket.
const { under7Days, days7To30, over30Days, unknown } = payoutAging(
  allTasks,
  new Date(),
);
// over30Days first: [{ id, ageDays }], unknown last (no age to sort by)

The reverse lookup uses the same rule. tasksByPayoutRef() returns every task whose PAYOUT_COMPLETE entry carries the given reference, in input order — the batch answer to "which tasks did this transfer settle":

import { tasksByPayoutRef } from "dataquest-task-lifecycle";

// Tasks settled under transfer xfer-88f2. Pure: reads the tasks,
// never mutates them. A request-only reference never matches, and
// an empty or non-string reference throws instead of returning [].
const settled = tasksByPayoutRef(allTasks, "xfer-88f2");

The event slice is the same shape over history rather than settlement. tasksByEvent() returns every task whose audit history contains the given event, in input order — the batch answer to "which tasks were ever disputed" or "which tasks went through a resubmit round":

import { tasksByEvent } from "dataquest-task-lifecycle";

// Tasks that were ever disputed. Pure: reads the tasks, never
// mutates them. A task that resubmitted twice appears once, terminal
// tasks match on their history, and an unknown event (including a
// state name such as "DISPUTED") throws instead of returning [].
const disputed = tasksByEvent(allTasks, "DISPUTE");

The four selectors above each filter on one axis. tasksByFilters() combines them: give any subset of state, actor, event, payoutRef, and overdue, and it returns the tasks matching every condition given, in input order, in a single pass over the batch — the board query "tasks the moderator disputed while IN_REVIEW" without chaining filters and intersecting the results. The overdue axis follows isOverdue's rule (past SLA deadline, non-terminal, boundary now >= deadline inclusive); false selects the complement, and omitting it leaves the deadline out of the match. Its screening instant is the optional third argument now (default: the real clock).

import { tasksByFilters } from "dataquest-task-lifecycle";

// Pure: reads the tasks, never mutates them. Each condition uses its
// single-axis selector's rule and validation, an empty condition
// object returns every task, and a typo in any axis throws instead
// of returning [].
const moderatedDisputes = tasksByFilters(allTasks, {
  state: "IN_REVIEW",
  actor: "moderator",
  event: "DISPUTE",
});

The other half of the loop pairs those tasks against an external payment ledger, in both directions — a task the ledger never paid, and a ledger payment no task accounts for. reconcilePayouts() takes the tasks and ledger rows ({ payoutRef, payoutAmount? }, the amount in the same major-unit convention as the PAYOUT_COMPLETE entry) and returns five groups: matched, taskOnly, ledgerOnly, amountMismatch, and unreconciled:

import { reconcilePayouts } from "dataquest-task-lifecycle";

const { matched, taskOnly, ledgerOnly, amountMismatch, unreconciled } =
  reconcilePayouts(allTasks, ledgerRows);
// matched: reference agrees, and the amount agrees where both sides recorded one
// taskOnly: PAID with a reference no ledger row carries
// ledgerOnly: ledger row no PAID task carries
// amountMismatch: [{ task, ledger, taskAmount, ledgerAmount, groupAmount? }] — same reference, different amounts
// unreconciled: PAID with no reference or no settled amount (the unreconciledPayouts() population)

Pairing reads only the PAYOUT_COMPLETE entry of PAID tasks, so a request-only reference never pairs and a non-PAID task never enters a group. Several tasks may share one reference (one transfer settling a batch); the group is then compared as a batch total, not task by task: the ledger row's amount is checked against the sum of the sharing tasks' settled amounts, summed in integer minor units (the totalPaidOut() cents convention, so 0.1 + 0.2 compares as 0.30). Equal sums match the whole group; a difference puts every task in the group into amountMismatch, each entry carrying its own taskAmount and the group's sum as groupAmount. A reference carried by exactly one task keeps the per-task comparison. A group member that recorded no amount never pairs (it lands in unreconciled) and contributes nothing to the sum. An amount disagreement is reported only when both sides recorded an amount and they differ — when the ledger row recorded none, the task (or the whole group) pairs on the reference alone; when the task recorded none, it never pairs: it lands in unreconciled instead, the same population unreconciledPayouts() returns. A reference appearing twice in the ledger throws (duplicate ledger payoutRef), as does any malformed ledger row (including a payoutRef longer than 256 characters); pure, like the other screens.

Payout amounts

A payoutRef answers "was there a transfer id"; finance reconciliation also needs "how much actually settled". dispatch(event, { payoutAmount }) records the settled amount verbatim on the audit entry — the payoutRef companion for money:

task.dispatch("PAYOUT_COMPLETE", {
  actor: "system",
  payoutRef: "xfer-88f2",
  payoutAmount: 10098.5,
});
task.history[8].payoutAmount; // => 10098.5

payoutAmount must be a finite number ≥ 0 with at most 2 decimal places — exactly representable in integer minor units; a sub-cent value throws invalid dispatch options: … before the entry is appended, like payoutRef. It is generic audit metadata (accepted on any event), but intended for PAYOUT_COMPLETE; it survives toJSON() / fromJSON() and NDJSON round-trips, is committed into the audit-history hash chain, and an untrusted snapshot carrying a malformed value is rejected as invalid history. The totalPaidOut() helper sums it across PAID tasks:

import { totalPaidOut } from "dataquest-task-lifecycle";

totalPaidOut(allTasks); // => 20630.0 — sum of recorded PAYOUT_COMPLETE amounts

Honest accounting rules: only PAID tasks contribute; a PAID task whose PAYOUT_COMPLETE entry carries no payoutAmount contributes 0 — this sums only what was recorded, not what was owed; the total is accumulated in integer minor units (toMinorUnits, exported for callers that hold cents) and returned in major units, so no float error can accumulate across the batch, while per-entry amounts are stored verbatim after the dispatch gate. Overflow is fail-fast: a legacy amount (or batch sum) whose minor-unit total overflows throws total paid out computation overflowed: inputs too large instead of returning Infinity — legacy snapshots stay readable, only summing them fails, loudly.

Migration note: the stored fields stay major-unit numbers — no field is renamed and no existing amount changes meaning. Only new dispatch() writes are gated; a legacy snapshot or webhook payload carrying a float amount still parses, builds, and verifies (the verifyPayoutWebhook path is untouched). Callers holding integer cents convert with the exported fromMinorUnits / toMinorUnits helpers. For display, formatMinorUnits renders integer cents as one bare fixed format — exactly two decimals, no thousands separator, no currency symbol (10098 → "100.98", -100 → "-1.00"); a non-integer input throws the same error as fromMinorUnits, and the split is integer arithmetic, so large amounts format exactly. Callers holding amounts as text parse with the exported parseMinorUnits instead of going through Number() first: it accepts only a strict decimal (optional minus, at most 2 decimal places, surrounding whitespace trimmed), rejects thousands separators, scientific notation, and anything beyond the safe-integer range, and round-trips with formatMinorUnits ("12.34" → 1234).

The owed half of that comparison is quotedAmount: the amount the task was agreed to pay, recorded with dispatch(event, { quotedAmount }) — conventionally on ACCEPT, the moment the price is agreed:

task.dispatch("ACCEPT", { actor: "contributor", quotedAmount: 10098.5 });

It follows the exact payoutAmount rules: finite number ≥ 0 or invalid dispatch options: … before anything is appended, stored verbatim, survives toJSON() / fromJSON() and NDJSON round-trips, is committed into the audit-history hash chain, and a malformed value in an untrusted snapshot is rejected as invalid history. The payoutMismatch() helper compares the last ACCEPT quote against the PAYOUT_COMPLETE settled amount, and mismatchedPayouts() screens a batch with it:

import { mismatchedPayouts, payoutMismatch } from "dataquest-task-lifecycle";

payoutMismatch(paidTask); // => { quoted: 100, paid: 90, delta: -10 } — underpaid by 10
mismatchedPayouts(allTasks); // => PAID tasks whose settled amount differs from the quote

payoutMismatch() returns undefined — no mismatch to report — when the task is not PAID, when either amount was never recorded (the library cannot compare what it cannot observe), or when quoted === paid exactly. delta is paid - quoted rounded to cents: negative means underpaid, positive overpaid.

The batch form of the owed half is totalQuoted(): it sums the last ACCEPT quote across PAID tasks in integer minor units, the exact pair of totalPaidOut():

import { totalPaidOut, totalQuoted } from "dataquest-task-lifecycle";

totalQuoted(allTasks); // => 220.0 — sum of recorded ACCEPT quotes on PAID tasks
totalQuoted(allTasks) - totalPaidOut(allTasks); // => net owed-minus-settled gap

It follows the same honest rules as totalPaidOut(): only PAID tasks contribute — a quote on an in-flight task is a promise, not a settlement, so it never enters the total; a PAID task whose last ACCEPT carries no quote contributes 0; a quotedAmount recorded on any other event is ignored (the field is generic dispatch metadata); and overflow is fail-fast with total quoted computation overflowed: inputs too large. On a batch where every task carries both amounts, the net gap equals the sum of quoted - paid over the tasks mismatchedPayouts() flags — each payoutMismatch() delta is paid - quoted, so the batch gap is the negated sum of the per-task deltas. That identity holds if and only if every PAID task carries a quote: a quoteless PAID task contributes 0 to the quoted total but its settled amount to the paid total, so the gap additionally absorbs that settled amount — the demo batch pins the shape, gap -9849 against a -249.5 delta sum, the -10098.5 difference being exactly DEMO-001's quoteless settlement of 10098.5. A quoteless or unpaid task still counts in the batch totals but is invisible to payoutMismatch(), so it sits outside that identity by construction.

The one-call form of that pair is summarizeTaskSettlements(): it returns the whole batch position at once — task and PAID counts, quotedTotal and paidTotal in integer minor units, the gap (quotedTotal - paidTotal), the mismatched count, and the unquotedPaid / unquotedPaidTotal split that makes the gap's identity visible in the numbers instead of only in documentation: gap + unquotedPaidTotal is the negated sum of the per-task payoutMismatch() deltas, for every batch.

import { summarizeTaskSettlements } from "dataquest-task-lifecycle";

summarizeTaskSettlements(allTasks);
// => { tasks: 6, paid: 3, quotedTotal: 650000, paidTotal: 1634900,
//      gap: -984900, mismatched: 1, unquotedPaid: 1,
//      unquotedPaidTotal: 1009850 }

The demo batch pins the shape: 6 tasks, 3 PAID, quoted 650000, paid 1634900, gap -984900, 1 mismatched (DEMO-005, delta -249.5), 1 unquoted PAID task (DEMO-001, settled 10098.5 with no recorded quote). Single-currency by construction — tasks carry no currency field, so a batch mixing currencies must be grouped by currency by the caller first — and overflow is fail-fast, like the two helpers it builds on. An empty batch returns an all-zero summary.

The summary also has canonical text forms, so a caller doing batch reconciliation no longer has to hand-write the export — the escrow renderReportCsv / parseSettlementReportCsv / renderReportJson / parseSettlementReportJson paradigm, for this summary:

import {
  parseTaskSettlementsCsv,
  parseTaskSettlementsJson,
  renderTaskSettlements,
  renderTaskSettlementsCsv,
  renderTaskSettlementsJson,
  summarizeTaskSettlements,
} from "dataquest-task-lifecycle";

const summary = summarizeTaskSettlements(allTasks);
renderTaskSettlements(summary);
// => "Task settlements summary\nTasks: 6 (paid: 3)\nQuoted total: 6500.00\nPaid total: 16349.00\nGap: -9849.00 (overpaid)\nMismatched payouts: 1\nUnquoted paid tasks: 1\nUnquoted paid total: 10098.50\nAmounts are integer minor units (cents)."
renderTaskSettlementsCsv(summary);
// => "tasks,paid,quotedTotal,paidTotal,gap,mismatched,unquotedPaid,unquotedPaidTotal\n6,3,650000,1634900,-984900,1,1,1009850"
renderTaskSettlementsJson(summary);
// => '{"tasks":6,"paid":3,"quotedTotal":650000,"paidTotal":1634900,"gap":-984900,"mismatched":1,"unquotedPaid":1,"unquotedPaidTotal":1009850}'
parseTaskSettlementsCsv(renderTaskSettlementsCsv(summary)); // => the same summary
parseTaskSettlementsJson(renderTaskSettlementsJson(summary)); // => the same summary

The CSV is a header row plus exactly one data row, in canonical field order, all money figures integer minor units — a row that pastes straight into a reconciliation spreadsheet or a settlement statement. The JSON writes the same eight fields as numbers, in canonical key order, for the API/storage hop. Unlike escrow's CSV, which drops the deposit breakdown and audit window, this CSV carries the whole summary: a parsed value is the full summary and re-renders byte-identically. Both parsers are strict and fail closed — a bad header, a wrong column count, a non-integer or non-canonical amount (5.0, 1e3, 007), a negative count or total, a gap that does not equal quotedTotal - paidTotal, a missing or unknown field, or string amounts in JSON all throw with the direction's prefix instead of returning a half-summary — and both renderers apply the same shape check before emitting text, so a malformed object never becomes a plausible-looking document. CRLF endings, a trailing newline, trailing blank lines, a UTF-8 BOM, and RFC 4180 quoted fields are transport artifacts and parse; import is structural validation only, not a re-verification of the audit histories the summary claims to summarize.

The same summary also has a plain-text form for a log line or a terminal paste: renderTaskSettlements(summary) prints a title line, a counts line, the three money lines (amounts through formatMinorUnits, two decimals, no currency symbol), the mismatched and unquoted rows, and a units line, in that fixed order. The gap carries its meaning in parentheses — (overpaid) when the batch settled more than it was quoted, (underpaid) when it settled less, (balanced) when the totals agree. It is a display form only: there is no parser back (the CSV and JSON forms are the round-trippable ones), and it applies the same shape check first, so a malformed summary throws cannot render task settlements summary: … instead of printing a plausible-looking document.

The same question one re-run later — "what moved since the summary I stored?" — is diffTaskSettlementsSummaries(before, after), the dataquest-side mirror of escrow's diffSettlementReports (backlog #1092): it reports one { <field>Changed: { from, to } } entry per moved field, and {} when nothing moved. Every comparison is an exact integer comparison (all eight fields are integer minor units or counts, so there is no float tolerance to disagree about), and gap is its own entry, reported separately from quotedTotal / paidTotal — a re-run that moves both totals by the same amount leaves the gap untouched and reports no gapChanged:

import { diffTaskSettlementsSummaries } from "dataquest-task-lifecycle";

const before = { tasks: 6, paid: 3, quotedTotal: 650000,
  paidTotal: 1634900, gap: -984900, mismatched: 1,
  unquotedPaid: 1, unquotedPaidTotal: 1009850 };
const after = { ...before, paidTotal: 1635000, gap: -985000 };
diffTaskSettlementsSummaries(before, after);
// => { paidTotalChanged: { from: 1634900, to: 1635000 },
//      gapChanged: { from: -984900, to: -985000 } }

Two honest limits, both forced by what the summary carries: the diff is a comparison, not a validation — it never re-checks that either summary's gap equals quotedTotal - paidTotal — and there is no identity guard, because a summary carries no batch id at all (unlike escrow's escrowId check), so diffing summaries of two different batches is the caller's responsibility.

Payout webhooks

The in-process subscribe() hook cannot notify anything outside the process. buildPayoutWebhook(task, secret) closes that gap for the payout path: it derives a compact signed notification from a PAID task's audit history — the dataquest-side peer of escrow's src/webhooks.ts:

import { buildPayoutWebhook, verifyPayoutWebhook } from "dataquest-task-lifecycle";

const { payload, signature } = buildPayoutWebhook(paidTask, webhookSecret);
// payload: { event: "PAYOUT_COMPLETE", taskId, payoutRef?, payoutAmount?, quotedAmount?, at, eventId }
// signature: "sha256=<hex>"

The payload carries both sides of settlement reconciliation: the settled side (payoutRef/payoutAmount, from the PAYOUT_COMPLETE entry) and the owed side (quotedAmount, from the last ACCEPT entry — the same derivation payoutMismatch() uses in-repo). A receiver can therefore recompute the quoted-vs-settled comparison locally, without querying the sender's store. Each field follows the same advisory omission rule: when its source entry carries no value, the key is omitted entirely (legacy PAID tasks keep building unchanged), and every present field is covered by the signature. The payload only carries the data — whether a quotedAmount / payoutAmount difference is a problem, and what to do about it, remains the receiver's adjudication. Opt-in gate: pass { requirePayoutRef: true } to refuse building a notification for a PAID task whose PAYOUT_COMPLETE entry carries no payoutRef (single build throws; the batch path records it as that task's { taskId, error } without blocking the rest). Default off, so legacy ref-less PAID tasks keep building unchanged.

Field-set audit (checked field by field against the audit history and escrow's SettlementWebhookPayload): every identifier and settlement field deterministically derivable from the same history is carried — no further field is added, and the exact set and canonical key order are pinned by snapshot tests (test/payoutWebhookFieldSet.test.ts):

Payload field Source Escrow counterpart When omitted
event constant "PAYOUT_COMPLETE" event ("escrow.settled") never
taskId the task's own id escrowId never
payoutRef PAYOUT_COMPLETE entry — (escrow identifies settlement via escrowId + parties) the entry carries no ref
payoutAmount PAYOUT_COMPLETE entry — (escrow splits amounts across the parties ledger) the entry carries no amount
quotedAmount last ACCEPT entry deposit is the closest owed-side field the last ACCEPT entry carries no quote
at payload build time at never
eventId build (UUID / injected) eventId never

The remaining history fields are deliberately not carried: actor is a caller-asserted role label (contributor / reviewer / system), not a unique payee identity; note is free-text annotation, not settlement data; seq / from / to / prevHash / hash are audit-chain internals. Escrow's deposit / parties ledger has no dataquest counterpart to derive from — the history records no per-party inflow/outflow — so none is invented here.

For a batch of tasks — a reconciliation run that owes one signed notification per settled task — buildPayoutWebhooks(tasks, secret, opts?) builds them all without a hand-written try/catch loop:

import { buildPayoutWebhooks } from "dataquest-task-lifecycle";

const results = buildPayoutWebhooks(tasks, webhookSecret, { now: new Date() });
// results[i]: { taskId, webhook } | { taskId, error } — one per input, in input order

Each task is attempted independently: a non-PAID task records its single-build error as { taskId, error } and never blocks later tasks. One exception: a repeated taskId in the batch throws for the whole batch before anything is built — two entries for one task would sign two webhooks with different eventIds for the same payout, which a receiver deduplicating on eventId could not recognize as one payout. The single build is unaffected: calling it twice for the same task is legitimate. A second exception, per entry rather than per batch: when the eventIdFactory returns an eventId already issued in this batch (a counter reset or fixed value), that entry records duplicate eventId "…" in batch: eventIds must be unique within a batch as its { taskId, error } and is never signed, while later entries are still attempted — and a failed build never claims its id, so a later task may legitimately reuse that value. This mirrors escrow's buildSettlementWebhooks issued-eventId guard. Call-level configuration errors throw for the whole batch instead — an empty secret, an invalid shared now, or a non-array tasks. The batch shares the now timestamp and the eventId generation strategy (opts.eventIdFactory, called once per task; default crypto.randomUUID()), never the eventId value itself: each task is a distinct payout, so each built webhook carries its own eventId and verifies under the batch secret exactly as a single build does. Honest limit: building is a signing primitive, not delivery — nothing is POSTed, queued, or retried here; deliver each built webhook with deliverPayoutWebhook (or fan one task out with deliverPayoutWebhookToMany).

eventId defaults to crypto.randomUUID() — receivers MUST deduplicate on it, because a retried delivery would otherwise look like a second payout. An injected eventId longer than MAX_EVENT_ID_LENGTH (256 characters) is rejected at build, never truncated — truncation would change the deduplication key, so a retry and its original could no longer be recognised as the same event; the default UUID is 36 characters, so the cap leaves orders of magnitude of headroom. A caller that needs a longer reference should store it out-of-band and deduplicate on a short id. verifyPayoutWebhook(rawBody, signature, secret) checks the HMAC-SHA256 in constant time and returns a boolean; malformed signatures return false instead of throwing. Verify over the raw request body bytes — the object overload re-stringifies for in-process convenience, but bytes are the transport-safe path.

Verification is signature-only by default — and a signature has no expiry, so a captured webhook could otherwise be replayed forever. Pass the opt-in fourth argument to bound replays with a freshness window:

verifyPayoutWebhook(rawBody, signature, secret, { maxAgeMs: 5 * 60_000 });
// => false when the signature is valid but now - payload.at > maxAgeMs

The signature is checked first (unauthenticated input is never parsed for its timestamp); only then is the payload at compared against now (injectable as a Date or ISO string, default the real clock). The boundary is inclusive (an age of exactly maxAgeMs passes), future timestamps pass under maxAgeMs alone (sender clock skew is tolerated), and an unparseable at fails closed as false. An illegal maxAgeMs (negative, NaN, infinite, non-number) or an invalid now throws a caller configuration error. Freshness is defense-in-depth only — it does not replace deduplication on eventId.

That deduplication step is included: PayoutEventDedupe is the receiver-side store a handler runs after a successful verify, so receivers no longer hand-roll a Map with ad-hoc TTL/capacity/clock choices. It follows the sibling rfc9421 ReplayCache paradigm (TTL + LRU, injectable clock):

import { PayoutEventDedupe } from "dataquest-task-lifecycle";

const dedupe = new PayoutEventDedupe({ ttlMs: 24 * 60 * 60_000 }); // defaults: 1h TTL, 10_000 entries
if (dedupe.checkAndRecord(payload.eventId)) {
  // duplicate delivery — acknowledge it, do not pay out twice
}

checkAndRecord(eventId) returns false on first sighting and true for a repeat within the TTL ("true = is a replay"); at exactly ttlMs the record has expired and the id counts as unseen again, and a duplicate hit never extends the window. At capacity, expired entries are reclaimed before the least recently seen entry is evicted. Illegal configuration (ttlMs not a positive finite number, maxEntries not a positive integer) throws at construction, and an empty or non-string eventId throws instead of silently passing. An eventId longer than MAX_EVENT_ID_LENGTH (256 characters) throws the same way, and a snapshot carrying one is rejected at restore — the dedupe key is never truncated, so a restored id still matches the deliveries it was recorded for. size and stats() ({ size, hits, misses, evictions }) expose observability, and clear() resets both. Honest limit: the store is single-process and in-memory only — two receiver processes cannot see each other's records, so a multi-process receiver must deduplicate over shared storage (a database unique constraint, Redis, …) instead.

A single process can still carry its ids across its own restart: exportSnapshot() returns a detached, JSON-serializable { v: 1, entries: [eventId, seenAtMs][] } of the entries still live against the store's clock, and PayoutEventDedupe.restore(snapshot, opts) (alias fromSnapshot) rebuilds a store from it:

// before shutdown:
persist(JSON.stringify(dedupe.exportSnapshot()));
// at startup:
const dedupe2 = PayoutEventDedupe.restore(JSON.parse(saved), { ttlMs: 24 * 60 * 60_000 });

Restore validates strictly — a wrong version, a non-array entries, a malformed entry, or an unknown top-level field throws instead of silently disabling deduplication. Entries already expired are dropped at load, an over-maxEntries snapshot evicts the oldest seenAtMs first, and TTLs keep counting from each id's original first-seen time — restoring never grants a fresh window. The hits/misses/ evictions counters are not part of the snapshot: a restored store starts them at zero. The snapshot bridges one process across its own restart only; two processes restoring the same snapshot afterwards diverge, so it is not a substitute for shared storage.

The unsigned snapshot above is trusted configuration: anyone who can rewrite the persisted file can delete a consumed eventId (a retried duplicate payout notification is then processed a second time) or inject a forged one (a legitimate payout notification is then dropped as an apparent duplicate). When the persisted file is not fully trusted, use the signed envelope instead — exportSignedSnapshot(secret) returns { v: 1, snapshot, mac } with mac = HMAC-SHA256 over the canonical JSON of the snapshot, and PayoutEventDedupe.restoreSignedSnapshot(envelope, secret, opts) (alias fromSignedSnapshot) verifies it before restoring:

// before shutdown:
persist(JSON.stringify(dedupe.exportSignedSnapshot(process.env.DEDUPE_SNAPSHOT_SECRET!)));
// at startup:
const dedupe2 = PayoutEventDedupe.restoreSignedSnapshot(JSON.parse(saved), process.env.DEDUPE_SNAPSHOT_SECRET!, { ttlMs: 24 * 60 * 60_000 });

The secret is never written into the envelope, and the two forms do not accept each other — a signed envelope is rejected by the unsigned restore(), and an unsigned snapshot is rejected by the signed restore, so a signed restore cannot silently downgrade to the trusted-configuration path. A tampered entry, a tampered seenAtMs, a tampered mac, or a wrong secret all fail closed with no store produced, and a MAC-valid envelope whose snapshot is itself malformed still fails the strict snapshot validation. The envelope can also carry an optional expiry: exportSignedSnapshot(secret, { expiresAt }) or { maxAgeMs, now? } writes a canonical ISO-8601 UTC expiresAt into the envelope, covered by the MAC, and restoreSignedSnapshot rejects an expired envelope (now > Date.parse(expiresAt)) before any snapshot parsing — restoring at exactly expiresAt still succeeds — with the restore clock injectable through opts.now; an envelope without expiresAt never expires. The MAC is integrity protection only, not encryption: the eventIds inside are wire-visible values, so keep the secret where the snapshot writer keeps its other signing-side secrets. This mirrors the sibling escrow SettlementEventDedupe signed snapshot (exportSignedSnapshot / restoreSignedSnapshot), same envelope shape and canonical form.

maxAgeMs alone leaves the future direction unbounded: a legitimately-signed payload issued far into the future (a sender clock set wrong or fast) would gain a near-unbounded replay window. Pass maxFutureSkewMs to bound that direction too:

verifyPayoutWebhook(rawBody, signature, secret, {
  maxAgeMs: 5 * 60_000,
  maxFutureSkewMs: 60_000,
});
// => false when the signature is valid but payload.at - now > maxFutureSkewMs

It is enforced only after the signature matches, fails closed as false, and its boundary is likewise inclusive (a skew of exactly maxFutureSkewMs passes); an illegal value throws the same style of configuration error as maxAgeMs. Together the two fields form a two-sided freshness window. Left unset, maxFutureSkewMs changes nothing — far-future timestamps still pass, exactly as before — and neither bound replaces deduplication on eventId.

During a secret rotation window, a receiver that verified with a single secret would reject in-flight notifications signed with the other one. Pass the candidates instead — the positional secret becomes undefined and secrets lists both, newest first:

verifyPayoutWebhook(rawBody, signature, undefined, {
  secrets: [newSecret, oldSecret],
});
// => true when the signature matches EITHER secret

Any one matching secret authenticates (array order is a performance preference only); all-mismatch fails closed as false. The positional secret and secrets are mutually exclusive — passing both throws a configuration error, as do an empty secrets array and any empty entry in it. Signing never rotates: buildPayoutWebhook always signs with the single current secret it is given. Rotation does not relax freshness — a maxAgeMs window passed alongside secrets is still enforced after the signature matches. Honest limit: how long the old secret stays in the list (the window length) is the caller's policy, and anyone holding either listed secret can forge signatures for the duration of the window.

Delivery is included: deliverPayoutWebhook(url, webhook) POSTs the signed payload as JSON with the X-Hub-Signature-256: <signature> header — the request body is byte-identical to what was signed, so the receiver verifies it over the raw bytes. Every attempt also carries an Idempotency-Key: <payload eventId> header and an X-Event-Id: <payload eventId> header (retries included, no opt-out; fan-out endpoints each carry the shared eventId the same way), so a receiver that deduplicates on either header need not parse the body first — and a gateway that keys only on X-Event-Id still has its dedup key when body parsing fails. Honest limit: both headers are transport-layer hints, not part of the signature — a receiver must still verify the signature over the raw body and deduplicate on the verified body's eventId, never on a header alone:

import { deliverPayoutWebhook } from "dataquest-task-lifecycle";

const result = await deliverPayoutWebhook("https://example.com/hooks/payout", webhook);
// result: { ok: boolean, attempts: number, status?: number, error?: string }

Retry policy and defaults: maxAttempts: 3 total attempts, timeoutMs: 5000 per attempt (enforced with an AbortController), exponential backoff starting at backoffMs: 1000. An opt-in jitterMs (default 0) adds a uniform Math.random() * jitterMs spread on top of each backoff wait — useful when many deliveries fail together and would otherwise retry on the same beat (thundering herd); the jitter is plain de-synchronization, never cryptographic, and a 429 Retry-After hint is always waited exactly with no jitter added. Only 429, 5xx, and network errors (including timeouts) are retried; other 3xx/4xx fail immediately (redirects are never followed with the signed payload). A 429 Retry-After hint (delay-seconds or HTTP-date) wins over backoff and is clamped to maxRetryDelayMs: 60000 by default, so a runaway hint can never stall delivery. An optional signal (AbortSignal) lets the caller cancel a delivery — a process shutting down, a job being cancelled: aborting stops an in-flight request or a pending backoff sleep, makes no further attempts, and is reported as { ok: false, attempts: <attempts actually made>, error: "payout webhook delivery aborted" } (a pre-aborted signal reports attempts: 0 and fetches nothing); a non-AbortSignal value is a configuration error. Delivery outcomes are reported in the result object, never thrown; invalid URLs and invalid options are caller configuration errors and throw before any request is made. Transport security: deliverPayoutWebhook (and each fan-out endpoint in deliverPayoutWebhookToMany) rejects http: URLs by default — a plaintext payout webhook would expose the settlement amount and let the X-Hub-Signature-256 header be captured and replayed in full — throwing cannot deliver payout webhook: insecure http url rejected: use https or set allowInsecureHttp: true before any request is made. Set allowInsecureHttp: true (call-level, or per endpoint for fan-out) only for a loopback/test receiver or an endpoint secured some other way; a non-boolean value is itself a configuration error. Honest limit: this only rejects plaintext — it performs no certificate validation or TLS pinning, so an https: URL to an untrusted endpoint is still accepted. fetchImpl and sleepImpl are injectable so delivery can be tested with no network and no real sleeps.

On the sending side, delivery itself keeps no memory: a failed deliverPayoutWebhook call only tells you that one call failed. PayoutWebhookOutbox is the in-memory ledger that answers "which (endpoint, eventId) pairs are still undelivered, and which have exhausted their retries" (the dataquest-side peer of escrow's SettlementWebhookOutbox, same counting rules — a pair is keyed by (endpoint, eventId), so one event fanned out by deliverPayoutWebhookToMany under a shared eventId still counts each endpoint independently):

import { PayoutWebhookOutbox } from "dataquest-task-lifecycle";

const outbox = new PayoutWebhookOutbox({ maxAttempts: 3 });
const result = await deliverPayoutWebhook(url, webhook);
if (result.ok) outbox.acknowledge(url, webhook.payload.eventId);
else outbox.recordAttempt({ endpoint: url, eventId: webhook.payload.eventId, ok: false, error: result.error });
outbox.pending();    // failed but not yet exhausted — still worth a retry
outbox.deadLetter(); // 3 consecutive failures — needs a human, not another retry
outbox.stats();      // { size, pending, deadLettered, totalAttempts } — the same counts, without materializing either list

A failed attempt increments the pair's consecutive-failure count (capped at maxAttempts, while later failures still refresh lastError/lastAt); a success (ok: true, or acknowledge()) clears the pair, so a later failure counts from 1 again. The stored lastError is capped at MAX_OUTBOX_ERROR_LENGTH (1024 characters) at record time — a longer error keeps its prefix and ends with …[truncated], so one oversized failure (a full HTML error page, a long stack trace) cannot bloat the ledger or the snapshot exported from it. The full error body belongs in the log where the delivery happened; the outbox keeps only the diagnostic hint. stats() is the cheap way to watch the ledger: it returns { size, pending, deadLettered, totalAttempts } (the same populations as pending() / deadLetter(), counted without materializing either list, and totalAttempts sums attempts over every tracked pair, dead-lettered pairs included), is a pure read, and needs no list allocation. An eventId longer than MAX_EVENT_ID_LENGTH (256 characters) is rejected at recordAttempt() and at snapshot restore, never truncated — truncation would change the pair's key.

A dead letter is not a dead end: redrive(endpoint, eventId, { now? }) moves a dead-lettered pair back to pending() for a fresh delivery round — the recovery path after the downstream endpoint is fixed. The redriven pair keeps its first-recorded position, its attempts reset to 0, its lastAt stamped with the redrive time, and no lastError (the old error belonged to the exhausted round); a later failure counts from 1 again, exactly like a pair cleared by acknowledge(). Only a dead-lettered pair can be redriven — redriving a pending or untracked pair throws and changes nothing — and redrive is a manual, per-pair decision, not an automatic retry: the outbox still never retries on its own. A redriven pair exports with attempts: 0, which restore() accepts as pending (a hand-built snapshot entry with attempts: 0 restores the same way; negative and non-integer counts still fail closed), so a redrive survives a restart through the same snapshot round-trip as any other pair.

The ledger is capped at maxEntries pairs (default 10_000, mirroring PayoutEventDedupe): recording a failure for a new pair past the cap evicts dead-lettered pairs first (oldest lastAt first) and only then the oldest pending pair, so a burst of fresh failures never silently drops a pair that still has retries left while a dead letter is available to drop; re-failing a pair the ledger already tracks never evicts anything, because the ledger does not grow. Restoring an over-capacity snapshot keeps the newest pairs by lastAt instead — the same trim PayoutEventDedupe.restore applies — and the signed restore funnels through the same path, so both restore forms trim alike.

npm run demo has an outbox segment near the end: a signed payout webhook is delivered to the billing endpoint through a scripted fetch that answers 500 three times — each failure recorded in a PayoutWebhookOutbox on a fixed clock — so after two failures the pair is still pending (attempts 2) and after the third it dead-letters at the maxAttempts cap of 3, with the exact server responded with status 500 delivery error as its lastError; a second event then fails once and delivers 200, so its success clears its own pair and the ledger ends pending 0, dead letter 1. The demo then shows the dead letter coming back: redrive() at a fixed later clock (13:30) moves the pair back to pending with attempts reset to 0, a second redrive while pending is rejected with the pinned error, and one scripted 200 after the fix clears the pair, so the ledger ends pending 0, dead letter 0. The segment records each attempt from the delivery result's ok branch — this repo's delivery reports a failure instead of throwing, so there is no try/catch in the loop. The demo closes with a caller string length-limit segment: each of the four guard caps the library landed one item at a time — dispatch actor, webhook eventId, task id, dispatch idempotencyKey, all 256 characters — is shown firing once, a 257-character caller rejected with its verbatim error and the task left untouched, and then its exactly-256-character boundary accepted verbatim (the boundary key's same-key retry is still a no-op, so it still dedupes). The ledger is process-local and records only — it never retries, and its snapshot (exportSnapshot() / PayoutWebhookOutbox.restore()) is unsigned trusted configuration in the same posture as PayoutEventDedupe.exportSnapshot(): persist it where the webhook configuration itself is trusted. When the persisted file is not fully trusted, use the signed envelope instead — exportSignedSnapshot(secret) returns { v: 1, snapshot, mac } with mac = HMAC-SHA256 over the canonical JSON of the snapshot, and PayoutWebhookOutbox.restoreSignedSnapshot(envelope, secret, opts) (alias fromSignedSnapshot) verifies it before restoring:

// before shutdown:
persist(JSON.stringify(outbox.exportSignedSnapshot(process.env.OUTBOX_SNAPSHOT_SECRET!)));
// at startup:
const outbox2 = PayoutWebhookOutbox.restoreSignedSnapshot(JSON.parse(saved), process.env.OUTBOX_SNAPSHOT_SECRET!, { maxAttempts: 3 });

The secret is never written into the envelope, and the two forms do not accept each other — a signed envelope is rejected by the unsigned restore(), and an unsigned snapshot is rejected by the signed restore, so a signed restore cannot silently downgrade to the trusted-configuration path. A tampered attempts count, a tampered lastError/lastAt, a tampered (endpoint, eventId) key, a tampered mac, or a wrong secret all fail closed with no outbox produced, and a MAC-valid envelope whose snapshot is itself malformed still fails the strict snapshot validation. The envelope can also carry an optional expiry: exportSignedSnapshot(secret, { expiresAt }) or { maxAgeMs, now? } writes a canonical ISO-8601 UTC expiresAt into the envelope, covered by the MAC, and restoreSignedSnapshot rejects an expired envelope (now > Date.parse(expiresAt)) before any snapshot parsing — restoring at exactly expiresAt still succeeds — with the restore clock injectable through opts.now; an envelope without expiresAt never expires. The MAC is integrity protection only, not encryption: the endpoints and eventIds inside are wire-visible values, so keep the secret where the snapshot writer keeps its other signing-side secrets. Same envelope shape and canonical form as the PayoutEventDedupe signed envelope.

The outbox records only — it never retries on its own. The opt-in retryPayoutWebhookOutbox(outbox, deliverOne, opts?) runner is the one correct retry loop over the ledger, so callers do not have to hand-write it (and hand-write its two easiest mistakes: retrying a dead letter, and forgetting to clear a pair after a success). You supply the re-delivery — usually one line wrapping deliverPayoutWebhook — and the runner takes the pending list, re-delivers each pair, acknowledges successes, records failures toward the dead letter, and sleeps the exponential backoff between rounds. This module reports delivery failure instead of throwing it, so the wrapper returns result.ok rather than a try/catch:

import { retryPayoutWebhookOutbox } from "dataquest-task-lifecycle";

const summary = await retryPayoutWebhookOutbox(
  outbox,
  async (endpoint, eventId) => {
    // Rebuild the signed webhook for this (endpoint, eventId) pair
    // and deliver it; `true` means the receiver answered 2xx.
    // `maxAttempts: 1` leaves the retry policy to the runner.
    const result = await deliverPayoutWebhook(endpoint, webhook, { maxAttempts: 1 });
    return result.ok;
  },
  { maxRounds: 3, backoffMs: 1000 },
);
// summary: { attempted, acknowledged, stillPending, deadLettered, aborted }

Each round re-reads outbox.pending(), so dead-lettered pairs are never re-delivered and a pair that dead-letters mid-run is not revisited. maxRounds (default 1) bounds the loop; backoffMs (default 1000) is the exponential base between rounds, with opt-in jitterMs; maxConcurrency (default 1, serial) bounds the in-flight re-deliveries the same way deliverPayoutWebhookToMany bounds its fan-out; signal aborts at round boundaries with already-made attempts kept. A deliverOne that throws is recorded as a failed attempt with its message, never a runner crash. Pass dryRun: true to rehearse instead of retrying: the runner takes one pass over outbox.pending() and reports each pair as { endpoint, eventId, wouldRetry: true } on the summary's wouldRetry field — no delivery, no ledger writes, no sleep — so a retry storm over dozens of pending pairs can be previewed before it is really sent. A dry run never changes the ledger, so rehearsing and then running the live retry still delivers every pair; an already-aborted signal reports aborted: true with an empty wouldRetry list. wouldRetry answers only "in the pending selection", never "would succeed": a pair whose delivery would fail is still listed, because the dry run never calls deliverOne. Honest limit: the runner is in-process only and persists nothing — a dead process loses the ledger unless it exported a snapshot first (see Limitations).

npm run demo includes a payout webhook timeout segment: the main task's signed webhook is delivered to a hanging injected fetch under a pinned timeoutMs of 50 with maxAttempts: 1, and the timeout is reported — never thrown — as { ok: false, attempts: 1, error: "webhook delivery timed out after 50ms" } after exactly one fetch call; a timeoutMs of 0 throws the pinned cannot deliver payout webhook: timeoutMs must be a positive number, got 0 configuration error before any request, with zero fetch calls; and the same endpoint under a loose timeoutMs of 5000 answers 200 at once and reports the unchanged success shape. The 50 ms wait is the behaviour under test itself, at its pinned short length — no elapsed wall-clock duration is asserted. The demo then continues with a fan-out per-endpoint timeout override segment: deliverPayoutWebhookToMany resolves timeoutMs per endpoint (endpoint.timeoutMs ?? call-level timeoutMs), shown in three pinned forms over three fixed endpoints — under a loose call-level timeoutMs of 5000, one endpoint's override of 50 (with its own hanging injected fetch) makes that endpoint alone report { ok: false, attempts: 3, error: "webhook delivery timed out after 50ms" } while the others still succeed in input order; an invalid override of 0 is reported for its endpoint alone as { ok: false, attempts: 0, error: "cannot deliver payout webhook: timeoutMs must be a positive number, got 0" } with zero fetch calls to it; and a fan-out with no override anywhere behaves exactly as before. No real network, no randomness, and no elapsed wall-clock duration is asserted. The demo then continues with a fan-out limits segment: the two call-level guards of deliverPayoutWebhookToMany that the earlier fan-out segments never exercised — 3 endpoints under a maxEndpoints cap of 2 throw the pinned 3 endpoints exceed maxEndpoints 2 configuration error before any request, with zero fetch calls; and under an overallDeadlineMs budget of 50, two endpoints answering 200 at once keep their results while the hanging endpoint reports { ok: false, attempts: 1, error: "payout webhook delivery timed out: overall deadline of 50ms exceeded" } with the aggregate deadlineExceeded: true. No elapsed wall-clock duration is asserted.

Multi-endpoint fan-out is included too: deliverPayoutWebhookToMany(task, endpoints) delivers ONE settlement event concurrently to every endpoint — the marketplace shape where billing, risk, and notification systems each need the same payout event, each holding its own secret:

import { deliverPayoutWebhookToMany } from "dataquest-task-lifecycle";

const out = await deliverPayoutWebhookToMany(paidTask, [
  { url: "https://billing.example.com/hooks/payout", secret: billingSecret },
  { url: "https://risk.example.com/hooks/payout", secret: riskSecret },
]);
// out: { results: DeliverWebhookResult[], delivered: number, failed: number, deadlineExceeded: boolean }
// results[i] corresponds to endpoints[i], in input order.

Each endpoint's copy is built and signed independently with that endpoint's own secret (one endpoint's secret cannot verify another's delivery), and retry budgets are counted per endpoint — an endpoint can also override maxAttempts/timeoutMs/backoffMs/fetchImpl/ signal for itself. The call-level signal is passed through to every endpoint, so one cancellation can stop the whole fan-out, or a per-endpoint signal just one endpoint's delivery. All copies share ONE eventId (and one at timestamp): this is a single logical event fanned out, not one event per endpoint, so each receiver deduplicates on eventId within its own endpoint, exactly as for a retried single-endpoint delivery. One endpoint's failure — retries exhausted, network error, even an invalid URL — marks only that endpoint's result { ok: false, … } and never blocks the others. Call-level configuration errors still throw before any request: an empty/non-array endpoints list, invalid global delivery options, or a non-PAID task. Two fan-out limits are also call-level configuration: maxEndpoints caps how many endpoints one call may carry (exceeding it throws before any request), and overallDeadlineMs caps the whole call's wall-clock time — when it expires, every still-in-flight endpoint is aborted through a deadline signal composed with that endpoint's own signal (a per-endpoint signal override does not exempt an endpoint from the deadline), the stopped endpoints report the deadline timeout error, and the aggregate result reports deadlineExceeded: true while already-completed endpoints keep their results. A fetch implementation that ignores its signal cannot be force-stopped by the deadline, exactly as with an external abort.

Pass maxConcurrency to cap how many deliveries run at once: the fan-out then runs as a bounded pool (a new endpoint starts only when one settles), so a 50-endpoint fan-out never opens 50 simultaneous connections. Unset, every endpoint starts at once, as before. A deadline that fires while endpoints are still queued reports those endpoints as deadline failures with 0 attempts and no request made; results stay in endpoint order either way.

Honest limits: the payload carries only what the audit history records — payoutRef/payoutAmount are omitted when the entry lacks them (a PAID task with no ref still builds; a missing reference is unevidenced, not a build failure). Secret distribution is the caller's responsibility.

Dispatch idempotency keys

Payment flows retry webhooks, and a retried dispatch used to mean a duplicate audit entry. dispatch(event, { idempotencyKey }) fixes that: the task remembers every key consumed by a successful dispatch, and a repeat with a known key is a full no-op — it returns the current state, appends nothing, notifies no listeners, and does not even validate the transition (safe to replay after the task has moved on):

task.dispatch("PUBLISH", { idempotencyKey: "webhook-dlv-9f3a" });
task.dispatch("PUBLISH", { idempotencyKey: "webhook-dlv-9f3a" }); // no-op
task.history.length; // => 1

Keys are global to the task, not per-event: reusing the same key for a different event still dedupes. The key itself must be a non-empty string of at most 256 characters (MAX_IDEMPOTENCY_KEY_LENGTH, exported from the index — the same ceiling the sibling escrow repo uses): an overlong key is rejected up front (invalid dispatch options: …), never truncated, at dispatch, at the constructor seed, at snapshot parse, and at fromHistory(). Failed dispatches consume nothing — fix the bad call and retry with the same key and it executes. Omitting the key preserves the exact pre-idempotency behavior.

Snapshot persistence: the consumed-key set is written into the toJSON() snapshot whenever it is non-empty (an empty set is omitted, so keyless tasks keep the legacy snapshot shape), and fromJSON() restores it through strict validation — a snapshot whose key list is not an array of non-empty strings, or carries an entry longer than 256 characters, is rejected, and duplicates are deduped. A retry with a consumed key after a snapshot restore is therefore still a no-op. fromHistory() cannot recover keys from the audit log (they are not audit data), so re-attach them explicitly: TaskLifecycle.fromHistory(id, history, { idempotencyKeys: [...] }).

Honest limit: this preserves exactly-once across restarts that go through the snapshot, within one process/store. It is not a distributed lock — two processes restoring the same snapshot in parallel can both execute the same key. For cross-process exactly-once semantics, pair this with a durable store (e.g. a UNIQUE constraint on the key column).

npm run demo closes with a preseeded-keys segment: a task constructed with a duplicated idempotencyKeys seed carries the deduped key list in its snapshot, a dispatch under a seeded key is a complete no-op even for an event that is illegal from the current state (no transition validation, no history append, no listener notification), the same event under fresh keys advances and consumes them, an empty-string seed throws the pinned invalid option: idempotencyKeys … error at construction, and a seed-free task dispatches keylessly exactly as before.

Optimistic concurrency (expectedSeq)

Two writers holding the same snapshot of a task can both dispatch against it — the second writer then advances a state it never saw, a classic cause of double-settlement accidents in payment flows. dispatch(event, { expectedSeq }) is the optimistic-lock guard: expectedSeq asserts the task's current sequence number — the history length, which is the last entry's seq (0 for a task with no history yet):

task.dispatch("PUBLISH", { expectedSeq: 0 }); // seq 0 -> 1
task.dispatch("ACCEPT", { expectedSeq: 1 });  // seq 1 -> 2
task.dispatch("START_CAPTURE", { expectedSeq: 1 });
// throws: dispatch conflict: expected seq 1 but task is at seq 2

The check runs before every other dispatch check — option validation, the idempotency dedupe, transition legality, RBAC, and the budgets — and a rejected dispatch changes nothing: no state move, no history entry, no idempotency key consumed, no listener notified, so re-reading the task and retrying with the fresh expectedSeq works. A non-integer or negative expectedSeq is a caller error and throws invalid dispatch options: … up front. Omitting it preserves the exact pre-guard behavior.

Honest limit: this is a single-process optimistic lock — it compares against the in-memory history, so it cannot stop two processes that each restored the same snapshot from racing. Cross-process writers still need compare-and-swap in the durable store itself (e.g. a conditional update keyed on a version column).

npm run demo includes an expectedSeq segment: five dedicated tasks under fixed timestamps (no network, no randomness) act the guard out — a task driven to a pinned 2-entry ACCEPTED history advances exactly one step under START_CAPTURE with expectedSeq: 2 (its last entry is seq 3, pinned in full); a second task at the same history rejects the same dispatch with a stale expectedSeq: 1 with dispatch conflict: expected seq 1 but task is at seq 2 and its state and history are byte-identical afterwards; a fresh task advances under expectedSeq: 0, the guard for an empty history; -1 and 1.5 are rejected with their pinned invalid dispatch options: … configuration errors and append nothing; and a task dispatched with no expectedSeq at all advances exactly as before the guard existed — the optimistic-concurrency guard, shown instead of only asserted by the expectedSeq tests.

npm run demo also includes a history immutability segment: a task (DEMO-045) driven PUBLISH -> ACCEPT under fixed timestamps has its returned history snapshot probed — a push of a forged entry, a splice, and an entry.note rewrite all throw TypeError while the internal history stays at exactly 2 unchanged entries; two getter calls return deep-equal but reference-independent snapshots (the array is never handed out twice); and a later START_CAPTURE dispatch leaves the earlier snapshot at 2 entries while the live history grows to 3 — a frozen detached copy, with dispatch() the only append path. The audit-trail immutability the historyFreeze tests pin, shown instead of only asserted.

The demo then shows a stale patrol segment: five further tasks screened by staleTasks() alone at the same fixed now under a patrol maxAgeByState map (DRAFT / OPEN / PAID at 7 days each, IN_REVIEW deliberately absent) — exactly one task is stale; the other four are each excluded for a different pinned reason (exactly at the budget, terminal, no history, unmapped state) — with a notify-only disposition through actOnStaleTasks() that dispatches nothing, and a fail-fast check pinning the exact invalid maxAgeByState: unknown state "BOGUS" configuration error an unknown state name throws.

The demo then shows an Idempotency-Key header segment: one more PAID task (DEMO-054) whose signed payout webhook is delivered twice to a real loopback receiver — the first delivery and the redelivery of the same webhook object — and both captured requests carry Idempotency-Key equal to the pinned payload eventId (evt_demo_idempotency_key_001): deliverPayoutWebhook has no separate idempotency-key option, the eventId IS the key, so a redelivery necessarily reuses the same value. The captured wire body verifies under the demo secret as the contrast: the header is an unsigned transport hint, and the verified body's eventId is the authoritative dedup key.

The demo then shows an X-Event-Id header segment: one more PAID task (DEMO-055) whose signed payout webhook is delivered twice to a real loopback receiver — the first delivery and the redelivery of the same webhook object — and both captured requests carry X-Event-Id equal to the pinned payload eventId (evt_demo_x_event_id_001), with the same requests also carrying Idempotency-Key equal to that same eventId: one logical event, two header names for it. The captured wire body verifies under the demo secret as the contrast: the headers are unsigned transport hints, and the verified body's eventId is the authoritative dedup key.

The demo then closes with a jitter backoff segment: one more PAID task (DEMO-056) whose signed payout webhook is delivered three times to a real loopback receiver, once per path-scripted script, each with its own recording sleep and no real wait. Under jitterMs 1000 and backoffMs 100, the 500 / 500 / 200 script's two waits land inside [100, 1100] and [200, 1200] — only the intervals are pinned, never the random draw — while the jitterMs 0 control over the same script waits exactly [100, 200], the pure exponential schedule. A 429 with Retry-After: 30 under jitterMs 1000 still waits exactly [30000]: the receiver's prescribed time is waited verbatim, with no jitter added on top.

Retry budgets

A task can cap how many times it may be resubmitted — the production "bound appeals" habit, as a task-level policy:

const task = new TaskLifecycle("task-042", { maxResubmits: 2 });
// …REJECTED → RESUBMIT → CAPTURING → … → REJECTED → RESUBMIT → …
// the third dispatch("RESUBMIT") throws:
//   resubmit budget exhausted: 2 of 2 RESUBMITs already used

maxResubmits defaults to unlimited (the pre-budget behavior is unchanged) and must be a non-negative integer or Infinity. The used count is derived from the append-only history — the audit entries are the source of truth — so it survives persistence with the budget: toJSON() stores the finite budget in the snapshot envelope, and fromJSON() / TaskLifecycle.fromHistory(id, log, { maxResubmits }) rehydrate it without resetting either. The check runs after the transition legality check and before anything is appended, so a rejected dispatch leaves no trace in the history.

Dispute budgets

The transition table lets the appeal path (REJECTED → DISPUTED → ARBITRATE_REJECT → REJECTED) loop without limit; a task can cap how many appeal rounds it may run — the production "bound appeals" habit, as a task-level policy:

const task = new TaskLifecycle("task-042", { maxDisputes: 1 });
// …REJECTED → DISPUTE → DISPUTED → ARBITRATE_REJECT → REJECTED…
// the second dispatch("DISPUTE") throws:
//   dispute budget exhausted: 1 of 1 DISPUTEs already used

maxDisputes defaults to unlimited (the pre-budget behavior is unchanged) and must be a non-negative integer or Infinity. The used count is derived from the append-only history — the audit entries are the source of truth — so it survives persistence with the budget: toJSON() stores the finite budget in the snapshot envelope, and fromJSON() / TaskLifecycle.fromHistory(id, log, { maxDisputes }) rehydrate it without resetting either. The check runs after the transition legality and RBAC checks and before anything is appended, so a rejected dispatch leaves no trace in the history. It is independent of maxResubmits: the two budgets govern disjoint events on the same task.

State visit budgets

The event budgets cap actions; a task can still bounce between states without bound (CAPTURING → REJECTED → CAPTURING → …). A per-state visit budget caps entries into a state instead — the production "a task stuck in a loop" guard, as task-level policy:

const task = new TaskLifecycle("task-042", { maxVisitsByState: { CAPTURING: 2 } });
// …first pass through CAPTURING (visit 1 of 2), REJECTED → RESUBMIT → CAPTURING (visit 2 of 2)…
// the next dispatch("RESUBMIT") throws:
//   state visit budget exhausted: CAPTURING already visited 2 of 2 times

A visit is counted as 1 for the task's initial state (the first history entry's from, or the current state when the history is still empty) plus 1 per history entry landing in that state — so the count is derived from the append-only history and survives persistence with the budget: toJSON() stores a non-empty map in the snapshot envelope (an empty map is omitted, keeping legacy snapshots byte-identical), and fromJSON() / TaskLifecycle.fromHistory(id, log, { maxVisitsByState }) rehydrate it without resetting either. States absent from the map are unlimited, and omitting the map disables the check entirely (the pre-budget behavior). Each budget must be a positive integer — 0 is a construction error, since the initial visit alone would already spend it. The check runs after the transition legality, RBAC, and event-budget checks and before anything is appended, so a rejected dispatch leaves no trace in the history. Note the consequence: a budget of 1 for a state the task already passed through is already spent — the budget counts total visits, not re-entries on top of the first visit. The CLI demo shows this end to end (npm run demo, state visit budget branch): DEMO-083 with { CAPTURING: 2 } takes one RESUBMIT round inside the budget and has its second RESUBMIT rejected with the pinned error, while the unbudgeted DEMO-084 runs the same two rounds and both pass.

Reading budget usage

The three budgets' used counts used to be readable only by waiting for the budget-exhausted error text — a watchdog that wants to ask "how many RESUBMITs are left" before dispatching had no read path. Read-only accessors expose the same history-derived counts the budget checks read, so they can never drift from the audit trail:

task.resubmitCount; // RESUBMITs used so far
task.disputeCount; // DISPUTE rounds used so far
task.visitCount("CAPTURING"); // visits to a state so far (1 for the initial state)

The matching remaining values answer "how many are left" without recomputing max - used or hand-rolling the unconfigured-budget branch:

task.remainingResubmits; // RESUBMITs left (Infinity with no maxResubmits)
task.remainingDisputes; // DISPUTE rounds left (Infinity with no maxDisputes)
task.remainingVisits("CAPTURING"); // entries into a state left (Infinity with no budget for it)

Each remaining value is Math.max(0, budget - used) from the same history-derived counts the budget checks read, so 0 means the next dispatch of that kind is rejected and the budget-exhausted error names the same used and budget numbers. A 0 budget reads 0 from the start. A budget of 1 for a state the task already passed through — including its starting state, whose initial visit already counts — reads 0.

All six are readable with or without a budget configured: unlimited is not uncounted — a remaining value with no budget is Infinity, never a guess. visitCount throws invalid visit count: unknown state … on a misspelled state — the same fail-fast bar setSlaDeadline applies — rather than returning a misleading 0, and remainingVisits throws invalid remaining visits: unknown state … on the same bar. Counts and remaining values are pure reads (no state or history mutation) and need no new snapshot fields: they are derived from the history, so toJSON() / fromJSON() and fromHistory() preserve them automatically.

Event-level RBAC

Tasks can gate individual events on named actors — the "only a senior moderator may arbitrate" rule from the case study, as task configuration:

const task = new TaskLifecycle("task-042", {
  rolePolicy: {
    ARBITRATE_APPROVE: ["senior-moderator"],
    ARBITRATE_REJECT: ["senior-moderator"],
    PUBLISH: ["researcher", "admin"],
  },
});
task.dispatch("ARBITRATE_APPROVE", { actor: "contributor" });
// throws: actor not authorized for ARBITRATE_APPROVE: "contributor" is not in [senior-moderator]

Rules: the policy is Partial<Record<TaskEvent, string[]>> — events it does not list are unrestricted (no actor needed), and a task built without a policy behaves exactly as before. A listed event requires an actor that exactly matches one of the allowed names (case-sensitive); missing or non-matching actors throw actor not authorized for …, after the transition legality check and before anything is appended, so rejected dispatches leave no history trace. Invalid policies (unknown events, empty or non-string role lists) throw invalid option: rolePolicy … at construction. The policy survives toJSON()/fromJSON() (tampered policies in stored snapshots are rejected) and re-attaches via TaskLifecycle.fromHistory(id, log, { rolePolicy }).

A policy only constrains the events it lists; deployments that need every state change attributable can opt in to the blanket gate instead (or as well): constructing the task with new TaskLifecycle(id, { requireActor: true }) makes any dispatch without an actor throw actor required: … (checked after the transition/RBAC/budget checks, before the idempotency key is consumed — a rejected dispatch leaves no trace and consumes nothing). A listed event still needs a matching actor, not just any actor. Like the payout switch, the gate is task configuration, not audit data: it is never written into the toJSON() snapshot, so a restored task re-enables it via fromHistory(id, history, { requireActor: true }).

Honest caveat: this is a caller-supplied allowlist, not identity — the library records the actor string it is given but cannot verify who "senior-moderator" really is. It guarantees policy violations never dispatch and every allowed dispatch is audited with its actor string.

Actor length cap: an actor is at most 256 characters (MAX_ACTOR_LENGTH, exported). dispatch() and parseHistory() (hence fromJSON() / fromHistory() and the NDJSON import) reject a longer actor with an explicit error, never truncating it — truncating would change who the audit trail says performed the dispatch. The check runs beside the actor shape check, before the idempotency dedupe and the RBAC check, so an overlong actor reports the length error (and consumes no key) even on a policy-listed event. Policy names themselves are deliberately not capped: a policy is task configuration, never audit data, and an overlong policy name can never match a legal dispatch (dispatch rejects first), so it is only dead configuration.

Task id length cap: a task id is at most 256 characters (MAX_TASK_ID_LENGTH, exported). The constructor and the snapshot parser (hence fromJSON() and, through it, every restore path) and TaskLifecycle.fromHistory() (hence the NDJSON import) all reject a longer id with an explicit error, never truncating it — truncating would change which task a log belongs to — so an oversized id can never inflate the log, a snapshot, an NDJSON export, or a payout webhook payload (taskId).

npm run demo includes this segment: a task under a pinned policy (PUBLISH by researcher, ACCEPT by contributor, arbitration by senior-moderator) rejects wrong and missing actors with no history trace, advances under the allowlisted actors, runs its uncovered events actor-less, and a policy-free task arbitrates actor-less exactly as before.

Dispatch subscriptions

dispatch() has an in-process notification seam — the starting point for notification fan-out (emails, Slack pings, queue messages), which callers build on top:

const task = new TaskLifecycle("task-042");
const unsub = task.subscribe((event, from, to, entry) => {
  // fire-and-forget: hand off to your notifier here
  console.log(`${event}: ${from} -> ${to} (seq ${entry.seq})`);
});
task.dispatch("PUBLISH", { actor: "researcher" }); // listener runs here
unsub();

Rules: listeners run after the audit entry is appended, in subscription order, and receive the exact entry (a frozen, detached copy — listeners cannot rewrite the audit trail). Failed dispatches notify nobody. A listener that throws is isolated: the error is swallowed, the remaining listeners still run, and dispatch returns normally — a bad fan-out consumer can never break the state machine or corrupt the history. That silence is deliberate by default, but you can opt into visibility without wrapping every listener yourself:

const unsub = task.subscribe(
  (event, from, to, entry) => notifier.send(`${event}: ${from} -> ${to}`),
  {
    onError: (err, { event, from, to }) => {
      // called with the exact thrown error and the dispatch context
      logger.error("fan-out consumer failed", { err, event, from, to });
    },
  },
);

Isolation semantics are unchanged by onError: the error is still swallowed after the hook runs, and a throwing onError is swallowed too — no error hook can ever break dispatch or starve peers. Invalid options (non-object opts, non-function onError) fail fast with a clear invalid subscribe: … error.

Honest caveat: subscriptions are in-memory only — they do not survive toJSON()/fromJSON()/fromHistory() (rehydrated tasks start with zero listeners), and the library provides no durable fan-out (queues, webhooks, retries). That remains the caller's infrastructure.

FAQ (honest)

  • Is this the real DataQuest platform backend? No. This is an off-chain reproduction of the task lifecycle rules from the DataQuest portfolio case study (a human-action data marketplace for embodied AI), written as a TypeScript library with zero runtime dependencies. There is no production backend here: no built-in store, no identity layer, and no deadline scheduler (see "Limitations" below). It models the lifecycle — creation through payout, plus abandonment, expiration, and dispute resolution — not the platform itself.

  • Does a task reaching PAID mean money actually moved? No. PAID means a caller dispatched PAYOUT_COMPLETE; the library records the settlement evidence it is given (payoutRef, payoutAmount) verbatim in the audit history, but it cannot verify whether an external payment actually happened — see "Settlement payout references". unreconciledPayouts() exists precisely to flag PAID tasks whose PAYOUT_COMPLETE entry carries no payoutRef or no payoutAmount.

  • If an SLA deadline passes, does the task expire on its own? No. SLA deadlines are advisory only: there is no timer and no background scheduler, and a deadline never moves a task by itself. Expiry is always an explicit EXPIRE event, so it lands in the append-only history. The intended wiring is a watchdog (cron, queue consumer) that polls isOverdue() / expiredTasks() and dispatches EXPIRE itself — expireOverdueTasks() is that executor; see "SLA deadlines".

  • Is the payout webhook a payment? No. buildPayoutWebhook derives a compact HMAC-SHA256-signed notification from a PAID task's audit history — it announces that the lifecycle reached payout; it does not move money. Delivery (deliverPayoutWebhook / deliverPayoutWebhookToMany) is in-process with retries but no durable queue and no cross-process retry, so a process dying mid-delivery leaves reconciliation — and receiver-side deduplication on eventId — to the caller; see "Payout webhooks".

  • Are snapshots and NDJSON a database? No. toJSON() / fromJSON() snapshots and the NDJSON history export are interchange formats, not built-in storage: you choose the store, save the plain JSON, and rehydrate later (see "Persistence"). Snapshots carry the full task (state, history, SLA deadlines, configured budgets/policy); NDJSON carries the audit history only — replay restores state + history, while the task id and SLA deadlines are not part of the log and are not restored from it (re-attach deadlines via fromHistory's slaDeadlines option).

  • Does event-level RBAC authenticate who is calling? No. rolePolicy is a caller-supplied actor allowlist, not identity: the library records the actor string it is given and guarantees a policy violation never dispatches, but it cannot verify who "senior-moderator" really is. The same holds for the requireActor opt-in: it guarantees every dispatch carries a non-empty actor string, not that the string names the real caller. Authentication belongs to the deployment in front of the library; see "Event-level RBAC" and SECURITY.md.

Limitations (honest)

Security scope, the caller-trust model, and what this library does not guarantee are collected in SECURITY.md.

  • Off-chain reproduction. The case study is a product-design artifact; this models the rules of the lifecycle, not a production backend. toJSON() / fromJSON() export and rehydrate in-memory snapshots (see "Persistence") — there is still no built-in store, no identity layer (event-level RBAC is a caller-supplied actor allowlist, not authentication — see "Event-level RBAC"), no deadline scheduler (expiry is an explicit event, not a timer). In-process dispatch subscriptions exist (task.subscribe — fire-and-forget, listener errors swallowed, not persisted). Multi-endpoint webhook fan-out does exist (deliverPayoutWebhookToMany, see "Payout webhooks" above), but there is still no durable queue and no cross-process retry: delivery is in-process only, so if the process dies mid-fan-out some endpoints may have received the event and others not — the caller must reconcile and re-deliver, letting receivers deduplicate on the shared eventId. The PayoutWebhookOutbox failure ledger is likewise in-process only: a process that dies loses it unless it exported a snapshot first, and the outbox itself never retries, but the opt-in retryPayoutWebhookOutbox runner re-delivers the pending pairs in-process (bounded rounds, exponential backoff, dead letters never retried) — it is still process-local and persists nothing, so a deployment that needs retries across restarts or processes must build them over shared storage (a database outbox table, a queue, …) instead. Webhook-shaped payout signing does exist — buildPayoutWebhook/ verifyPayoutWebhook derive and HMAC-sign a PAYOUT_COMPLETE notification from a PAID task's audit history — and single-endpoint delivery with retries exists too (deliverPayoutWebhook, see "Payout webhooks" above).
  • Simplified arbitration. Appeal rounds (REJECTED → DISPUTED → arbitration → REJECTED) can be capped per task with maxDisputes (default unlimited; used-round count derived from the append-only history so it survives persistence) — see "Dispute budgets" above. Per-state visit budgets also exist (maxVisitsByState — see "State visit budgets" above) for tasks bouncing between states without bound. Production would additionally keep these bounds in a durable policy store rather than task configuration. (Task-level RESUBMIT retry budgets exist since v0.1.0 — see "Retry budgets" above; per-state SLA deadlines also exist — see "SLA deadlines"; expiry remains explicit.)
  • No reputation/quality scoring. The case study's contributor tiers and earnings wallet are out of scope here.
  • Append-only history is runtime-frozen. task.history returns a frozen copy (array and entries), so consumers cannot rewrite the audit log at runtime, even by accident — dispatch() is the only append path. This is in-memory integrity, not tamper-proofing: persisted snapshots and NDJSON exports additionally carry a SHA-256 hash chain (prevHash/hash on every entry, genesis links to "GENESIS"), which parseHistory() re-verifies on rehydration — a rewritten, truncated, or reordered persisted log fails loudly. The chain is unkeyed by default: it catches edits made without recomputing the hashes, not a full-log rewrite by someone who recomputes them. The opt-in keyed mode (auditSecret, HMAC-SHA256 links) covers that rewrite attacker — anyone without the secret can no longer recompute a valid chain — at the price of key management, which stays the caller's problem.

Reproducibility

npm test runs 1610 tests covering the happy path, reject→resubmit (including the RESUBMIT retry budget: budget enforcement, invalid budgets, and snapshot round-trips that preserve the budget and used count), the DISPUTE appeal budget (maxDisputes: budget enforcement, exhaustion-before-append semantics, invalid-budget rejection, snapshot round-trips that preserve the budget and used count, fromHistory re-attachment, and independence from the RESUBMIT budget), the per-state visit budget (maxVisitsByState: total-visit counting including the initial visit, state-based (not event-based) blocking, check ordering after transition/RBAC/event budgets, snapshot round-trips that preserve the budget and used count, fromHistory re-attachment, and malformed-envelope rejection), the budget-usage read-only API (resubmitCount / disputeCount / visitCount(state): counts stepping with dispatch, agreement with the exhausted-error numbers, snapshot and fromHistory survival, unknown-state rejection for visitCount, and read purity) and the budget-remaining read-only API (remainingResubmits / remainingDisputes / remainingVisits(state): remaining values stepping down to 0 exactly at the blocking point, agreement with the exhausted-error numbers, Infinity with no budget configured, a 0 budget reading 0 from the start, an already-spent budget of 1 for a passed-through state, unknown-state rejection for remainingVisits, and snapshot and fromHistory survival), dispute→arbitration (both outcomes, plus event-level RBAC: moderator-only arbitration, partial policies, exact actor matching, and policy persistence round-trips), abandonment, expiration, SLA deadlines and overdue checks (including the system-actor attribution the two watchdog executors share), JSON snapshot persistence (round-trip, detached copies, rejection of 18 malformed-snapshot shapes, and the snapshot schema-version gate: v: 1 always written, legacy snapshots without v still accepted, any other v rejected before structural checks, plus fail-closed unknown-field rejection at both the snapshot and history-entry level), event-sourced replay (golden paths, input detachment, and 10 malformed-history shapes), invalid transitions, terminal-state locking, the README-diagram sync guard, the npm run diagram CLI output, audit-history integrity (seq increment, from/to chain continuity, canonical ISO timestamps, no partial entry on failed dispatch, dispatch actor/note input validation (non-empty actor, MAX_NOTE_LENGTH note cap, rejection of oversized/untrusted entries, no-trace fail-fast), injected dispatch timestamp (at: canonical ISO-8601, non-decreasing vs. the previous entry, fail-fast before the transition check), runtime freeze of the returned history), dispatch subscription hooks (order, unsubscribe, listener-error isolation, frozen detached entries, in-memory-only semantics, plus the optional onError observability hook: context delivery, peer isolation preserved, throwing onError containment, unsubscribe semantics, invalid-option fail-fast), stale-task watchdog screening (staleTasks: dwell budgets per state, terminal/history-less exclusion, invalid-budget fail-fast, purity), the stale-task watchdog executor (actOnStaleTasks: mixed-batch per-task outcomes with the default ABANDON action, unactionable stale tasks never blocking the batch, failed tasks untouched, custom notify-only and throwing actions isolated per task, fail-fast validation of the budget/now/action before any action runs, dry-run rehearsal ({ dryRun: true }: same stale set, zero action calls, wouldAct answering "selected" only, illegal options and a bad at pin failing fast, zero subscription notifications), injectable now, auditable actor: "system" dispatch with an intact hash chain), and per-edge agreement between the rendered diagram and transition(). Settlement payout references are covered too (payoutRef verbatim recording, empty/non-string rejection fail-fast before any mutation, toJSON/fromJSON/fromHistory round-trip preservation, malformed-ref snapshot rejection, the no-ref PAYOUT_COMPLETE advisory path, and the requirePayoutRef opt-in enforcement: gate-on-PAYOUT_COMPLETE-only, non-boolean rejection at construction, failed-dispatch key non-consumption, in-memory-only switch semantics with fromHistory re-attachment), and the requirePayoutAmount opt-in enforcement (same gate shape, plus a 0-amount pass and invalid-amount shape errors ahead of the gate), and settlement reconciliation screening (unreconciledPayouts: mixed PAID batches, request-only refs not reconciling, ref-without-amount now unreconciled with a 0-amount pass, empty-input and purity, snapshot-rehydrated tasks), and dispatch idempotency keys (duplicate no-op without transition validation, per-task global keys, failed-dispatch key non-consumption, shape validation, snapshot persistence: non-empty-only envelope field, strict rehydration validation with dedupe, fromHistory re-attachment, failed keys never persisted, seq-continuity), the optimistic concurrency guard (expectedSeq: matching-seq advance, seq 0 on an empty history, stale and ahead conflicts leaving state, history, the idempotency-key set, and listeners untouched, non-integer / negative / non-number rejection, check-first ordering ahead of option validation, the idempotency dedupe, and transition legality, conflict-then-fresh-retry, and unchanged behavior when the option is omitted), payout amounts (dispatch-time finite/≥0 validation with fail-fast semantics, verbatim recording on the entry, toJSON/fromJSON round-trips, malformed-amount snapshot rejection, hash-chain commitment, totalPaidOut: per-PAID-task sums, unrecorded amounts as 0, non-PAID exclusion, cent-rounded totals, purity), quoted amounts (quotedAmount: same dispatch-time finite/≥0 validation and fail-fast semantics, verbatim recording on the ACCEPT entry, toJSON/fromJSON and NDJSON round-trips, malformed-quote snapshot rejection, hash-chain commitment with legacy canonical bytes unchanged, payoutMismatch: underpaid negative delta / overpaid positive delta, exact-settlement and missing-amount and non-PAID undefined, and mismatchedPayouts mixed-batch screening with purity, plus totalQuoted: per-PAID-task last-ACCEPT sums, quoteless-ACCEPT as 0, non-PAID exclusion, non-ACCEPT quote ignored, second-ACCEPT impossibility pinning last-wins by construction, legacy-giant and accumulation-only overflow fail-fast, batch-gap identity against totalPaidOut and the per-task deltas plus its quoteless-settled boundary (demo-batch deviation, multi-quoteless sum, all-quoteless negated settled total, no-payout-amount quoteless case, empty batch, non-PAID exclusion), cent-exact multi-task sums, and purity with rehydrated tasks, and the payout webhook carrying the same derived quote (quotedAmount: canonical key order after payoutAmount, signature-covered with tamper rejection, whole-key omission when the last ACCEPT entry is quoteless, zero quote not omitted, and last-ACCEPT-wins cross-checked against payoutMismatch on the same histories)), and the batch settlement summary (summarizeTaskSettlements: demo-batch numbers pinned in integer minor units, empty batch all zeros, two-unquoted deviation split, all-quoted gap identity against the per-task deltas, non-PAID exclusion, minor-unit integer-ness on a 0.1 + 0.2 batch, quote without a recorded payout neither mismatched nor unquoted, exact-settlement non-mismatch, legacy-giant and accumulation-only overflow fail-fast, and purity), and the audit-history SHA-256 hash chain (verifyHistoryChain: genesis linkage, tamper/deletion/ reorder/forgery detection, legacy pass-through, mixed-history rejection, parseHistory/replay enforcement, NDJSON round-trip chain preservation, legacy snapshot re-chaining on rehydration), and the keyed HMAC audit chain (auditSecret: correct-secret verification, wrong/missing-secret rejection, unkeyed-with-secret rejection — fail-closed both ways, identical string/Buffer chains, caller-Buffer mutation defense, empty/invalid secret construction errors, wrong-key full-log-rewrite forgery detection, snapshot secrecy — the secret never enters toJSON() — keyed fromJSON/fromHistory re-attachment and continued dispatch, legacy snapshot keyed re-chaining, and keyed NDJSON round-trips), and the overdue-expiry watchdog executor (expireOverdueTasks: mixed-batch per-task outcomes, unexpirable tasks never blocking the batch, failed tasks untouched, auditable dispatch appends with an unbroken hash chain), and documentation-shape coverage (the README claims about history-entry fields and the snapshot shape are verified against TaskHistoryEntry and TaskSnapshot in src/taskLifecycle.ts; no behavior changed in this release), and payout webhook freshness (the opt-in maxAgeMs replay window on verifyPayoutWebhook: fresh/stale payloads, the inclusive boundary and maxAgeMs: 0, future-timestamp tolerance, illegal-window configuration errors, signature-before-freshness ordering, fail-closed unparseable at, and legacy signature-only behavior when the option is omitted), and payout webhook future skew (the opt-in maxFutureSkewMs future-timestamp bound on verifyPayoutWebhook: far-future rejection for object and string bodies, in-window and inclusive-boundary passes including maxFutureSkewMs: 0, legacy far-future acceptance when the option is omitted, illegal-skew configuration errors, signature-before-timestamp ordering, and the two-sided maxAgeMs + maxFutureSkewMs window), and payout webhook secret rotation (the secrets candidate list on verifyPayoutWebhook: new-secret and old-secret acceptance inside the rotation window, unrelated-secret failure, positional/secrets mutual-exclusion and empty-candidate configuration errors, Buffer candidates, malformed-signature fail-closed behavior, and maxAgeMs freshness still enforced after a rotation match), and payout webhook multi-endpoint fan-out (deliverPayoutWebhookToMany: per-endpoint secrets with cross-secret verification failure, one shared eventId across endpoints, input-ordered results with aggregate counts, single-endpoint equivalence with deliverPayoutWebhook, per-endpoint retry counting and maxAttempts/fetchImpl overrides, an invalid-URL or network-error endpoint failing only itself, and empty-endpoints / invalid-global-option configuration errors thrown before any request), and payout webhook batch building (buildPayoutWebhooks: mixed PAID/non-PAID batches with input-ordered per-task results, a bad item first never blocking later items, distinct per-task eventIds under a shared now with each webhook verifying under the batch secret, empty-secret and invalid-shared-option configuration errors thrown for the whole batch, and one-item equivalence with buildPayoutWebhook), and payout webhook receiver deduplication (PayoutEventDedupe: first-sighting false / duplicate true semantics, TTL expiry with an age of exactly ttlMs counting as unseen, duplicate hits not extending the window, capacity eviction — expired entries reclaimed before LRU eviction and hits refreshing recency — injectable-clock deterministic double-runs, illegal-configuration and empty/non-string-eventId rejection, size/stats() observability with detached snapshots, clear(), per-instance isolation, and snapshot export/restore: live-only detached exports, restored ids still deduping with TTLs counted from the original first-seen time, strict malformed-snapshot rejection, expired entries dropped at load, over-capacity loads evicting the oldest seenAtMs first, and counters restarting at zero). No network, no randomness in assertions.

Benchmarks

npm run bench measures locally-observed throughput for the one hundred seven hot paths (3000 timed iterations per op after 200 warmup, printing the Node version and CPU): hash-chained dispatch, dispatch idempotency dedupe (dispatch with a pinned idempotencyKey — one keyed dispatch plus its same-key duplicate on a fresh task), dispatch payout-reference enforcement (dispatch with requirePayoutRef: true — one with-reference PAYOUT_COMPLETE on a fresh task driven to PAYOUT_PENDING), dispatch actor-attribution enforcement (dispatch with requireActor: true — one with-actor PAYOUT_COMPLETE on a fresh task driven to PAYOUT_PENDING), dispatch optimistic-concurrency guard (dispatch with expectedSeq — one guarded START_CAPTURE on a fresh task driven to a pinned 2-entry history), dispatch dispute budget (dispatch with maxDisputes — one budgeted DISPUTE on a fresh task driven to a pinned 6-entry history in REJECTED), dispatch retry budget (dispatch with maxResubmits — one budgeted RESUBMIT on a fresh task driven to a pinned 6-entry history in REJECTED), dispatch state-visit budget (dispatch with maxVisitsByState — one budgeted RESUBMIT into CAPTURING on a fresh task driven to a pinned 6-entry history in REJECTED, the second of 2 budgeted visits), event-sourced replay/fromHistory, verifyHistoryChain, keyed hash-chain verification (verifyHistoryChain with auditSecret), payout webhook build/verify, payout webhook secret-rotation verification (verifyPayoutWebhook with { secrets: [new, old] }), payout webhook maxAgeMs freshness-window verification (verifyPayoutWebhook with { maxAgeMs, now }), payout webhook maxFutureSkewMs future-skew verification (verifyPayoutWebhook with { maxFutureSkewMs, now }), payout webhook secrets-rotation x maxFutureSkewMs combination verification (verifyPayoutWebhook with { secrets: [new, old], maxFutureSkewMs, now } — the old-signed payload inside the future-skew window, so every pass pays one failed HMAC trial before the match and then the freshness gate), payout webhook verify x dedupe receiver flow (verifyPayoutWebhook then PayoutEventDedupe.checkAndRecord — one measured op per receiver pass: a first sighting, its redelivery duplicate, and a tampered-body rejection whose claimed id still records as first-seen afterwards), payout webhook freshness x dedupe receiver flow (verifyPayoutWebhook under the two-sided freshness window { maxAgeMs, maxFutureSkewMs, now } then PayoutEventDedupe.checkAndRecord — one measured op per receiver pass: an in-window first sighting, its redelivery duplicate, and an over-age rejection (1 ms past maxAgeMs) whose claimed id still records as first-seen afterwards), NDJSON history export/import (historyToNdjson / historyFromNdjson, whose import path interleaves per-line parsing with a full hash-chain re-verification), payout reconciliation screening (mismatchedPayouts / unreconciledPayouts / totalPaidOut / totalQuoted / summarizeTaskSettlements over a batch of tasks), summary comparison (diffTaskSettlementsSummaries over two fixed summaries — one identical pair, one four-field move) and summary text rendering (renderTaskSettlements over the same fixed summary — nine lines, pinned verbatim), NDJSON x unreconciled screening (historyToNdjson -> historyFromNdjson -> TaskLifecycle.fromHistory -> unreconciledPayouts over the same 12-task screening batch), payout webhook fan-out delivery (deliverPayoutWebhookToMany to 4 endpoints), payout webhook fan-out x dedupe receiver flow (deliverPayoutWebhookToMany to 4 endpoints, each receiver verifying under its own secret then PayoutEventDedupe.checkAndRecord — one measured op per fan-out pass: a fresh fan-out's 4 first sightings plus its redelivery's 4 duplicates), payout webhook fan-out x resubmit payoutRef x dedupe receiver flow (deliverPayoutWebhookToMany to 4 endpoints for a task rejected, resubmitted, then paid — each receiver verifying under its own secret then PayoutEventDedupe.checkAndRecord — one measured op per fan-out pass: a fresh eventId's 4 first sightings, each copy carrying the new PAYOUT_COMPLETE payoutRef and never the REJECT entry's ref, plus a pre-recorded old eventId's re-fan-out reporting 4 duplicates), payout webhook fan-out x secrets rotation x dedupe receiver flow (deliverPayoutWebhookToMany to 4 endpoints, each receiver verifying under its own rotation candidate list [new, old] then PayoutEventDedupe.checkAndRecord — one measured op per fan-out pass: a fresh fan-out's 4 first sightings, its redelivery's 4 duplicates, and a tampered copy's failed rotation verification whose claimed id still records as first-seen afterwards), payout webhook fan-out x dedupe x jitterMs receiver flow (deliverPayoutWebhookToMany to 4 endpoints, one endpoint on a 500 / 500 / 200 script with call-level jitterMs 1000, each receiver verifying under its own secret then PayoutEventDedupe.checkAndRecord — one measured op per fan-out pass: a jittered fan-out's 4 first sightings (the retrying endpoint's successful delivery is a first sighting, not a duplicate) plus its jittered redelivery's 4 duplicates), payout webhook fan-out limits (deliverPayoutWebhookToMany under maxEndpoints / overallDeadlineMs — the timed loop runs the 50 ms deadline case, so that row's throughput is deadline-bound, not fan-out overhead), payout webhook fan-out limits x dedupe receiver flow (deliverPayoutWebhookToMany under maxEndpoints 3 over 4 endpoints, each receiver verifying under its own secret then PayoutEventDedupe.checkAndRecord — one measured op per fan-out pass: an over-cap batch that throws with zero fetch calls and zero dedupe records, an in-limit fan-out's 4 first sightings, plus its redelivery's 4 duplicates), single-target payout webhook delivery (deliverPayoutWebhook to 1 endpoint), payout webhook Retry-After cap delivery (deliverPayoutWebhook with maxRetryDelayMs clamping a 429 Retry-After hint), payout webhook AbortSignal pre-abort delivery (deliverPayoutWebhook with a pre-aborted external signal), payout webhook timeoutMs timeout delivery (deliverPayoutWebhook with a pinned short timeoutMs over a hanging injected fetch), payout webhook jitterMs backoff delivery (deliverPayoutWebhook with jitterMs over a 500 / 500 / 200 script — the timed loop checks the jittered sleeps land inside [100, 1100] and [200, 1200] and the zero-jitter control sleeps exactly [100, 200], with the exact [100, 700] spread pinned outside the timed loop under a stubbed Math.random sequence), payout webhook fan-out jitterMs backoff delivery (deliverPayoutWebhookToMany to 4 endpoints, every endpoint on the same 500 / 500 / 200 script with jitterMs 1000 — the timed loop checks per-endpoint sleeps land inside [100, 1100] and [200, 1200] and the zero-jitter control fan-out sleeps exactly [100, 200] per endpoint, with every endpoint reporting { ok: true, attempts: 3, status: 200 }; the exact spread stays pinned by the single-endpoint row, since concurrent endpoints share the global Math.random), payout webhook fan-out maxRetryDelayMs cap delivery (deliverPayoutWebhookToMany to 4 endpoints, one endpoint on a 429 Retry-After: 30 script with call-level maxRetryDelayMs 5000 and backoffMs 100 — the timed loop checks the 429 endpoint reports { ok: true, attempts: 2, status: 200 } with exactly one recorded sleep of exactly 5000 ms, the three healthy endpoints report { ok: true, attempts: 1, status: 200 } with zero sleeps, and all four bodies are byte-identical copies of the same shared eventId; outside the timed loop, per-endpoint signatures are verified under each endpoint's own secret, a below-cap Retry-After: 2 hint is honored exactly at 2000 ms, and a 500 carrying a Retry-After header ignores the hint for the 100 ms backoff base; sleeps are recorded, never slept), payout webhook delivery-header verification (deliverPayoutWebhook to a loopback server answering 500 then 200 — every attempt's Idempotency-Key and X-Event-Id equal to the body eventId, with the captured signature pinned equal to the build-time signature outside the timed loop, pinning both headers outside signature coverage; the row is local-HTTP-bound, not a measure of delivery logic overhead), receiver-side payout event dedupe (PayoutEventDedupe.checkAndRecord duplicate hits, PayoutEventDedupe.restore snapshot restore, PayoutEventDedupe.exportSnapshot snapshot export, PayoutEventDedupe.clear window reset, PayoutEventDedupe.stats observability snapshot, and PayoutEventDedupe.checkAndRecord first-sighting misses with LRU eviction), payout webhook outbox (PayoutWebhookOutbox.recordAttempt failure recording, PayoutWebhookOutbox.pending() / PayoutWebhookOutbox.deadLetter() listing over a fixed 1,000-pair ledger of 700 pending and 300 dead-lettered pairs, PayoutWebhookOutbox.acknowledge clearing, PayoutWebhookOutbox.exportSnapshot export, and PayoutWebhookOutbox.restore from the JSON-round-tripped snapshot -- the failure ledger a payout gateway keeps for its webhooks, previously unit tests only with no throughput baseline), signed-snapshot envelopes (exportSignedSnapshot / restoreSignedSnapshot on all three HMAC-SHA256 signed snapshot envelopes — PayoutEventDedupe #1011, TaskLifecycle #1018, PayoutWebhookOutbox #1031 — six rows over the fixtures the unsigned snapshot rows already pin: the dedupe exports / restores its fixed 1,000-entry snapshot (ids bench-restore-0000 .. bench-restore-0999 under the fixed restore clock), the task exports / restores the fixed BENCH-001 DRAFT -> PAID 8-entry chain, and the outbox exports / restores its fixed 1,000-pair ledger (700 pending, 300 dead-lettered); the restore rows run through the JSON round-tripped envelope on every iteration — the persisted form a process reloads at startup — and every per-iteration self-check pins the restored state against the hand-written composition (dedupe size 1,000 with the first / middle / last ids still duplicates, task id / state / 8-entry history entry for entry through the canonical NDJSON serializer, outbox size / pending / dead-letter counts with their pinned attempts / error / timestamp); outside the timed loop, each signed envelope's snapshot is pinned byte-identical to its unsigned snapshot, and a tampered envelope and a wrong secret are pinned to be rejected by each restoreSignedSnapshot with the MAC-mismatch error while a previously restored instance keeps its state — this is the restart cost a gateway pays for tamper-evident persistence, the canonical-JSON + HMAC overhead over the unsigned snapshot rows), batch payout webhook building (buildPayoutWebhooks over a batch of PAID tasks), watchdog screening (expiredTasks / staleTasks over a batch of mixed-state tasks), state-filter listing (tasksByState over the same 12-task batch — the OPEN slice), actor-filter listing (tasksByActor over a fixed 6-task mixed-actor batch — the moderator slice), event-filter listing (tasksByEvent over a fixed 6-task mixed-history batch — the RESUBMIT slice, the twice-resubmitted task matched once), combined-filter listing (tasksByFilters over a fixed 6-task mixed batch — the reviewer + IN_REVIEW + REJECT slice, the dispute-round task matched once), external-ledger reconciliation (reconcilePayouts over a fixed 5-task batch against a 4-row ledger — matched, task-only, ledger-only, amount-mismatch, and unreconciled groups, self-checked per iteration for the five group counts and the matched amount 350), snapshot comparison (diffTaskSnapshots over a fixed 25-entry before snapshot against a 28-entry after snapshot — state, settled payout, appended history, and SLA-map changes, self-checked per iteration for the four changed keys and the appended seq range 26..28), payout aging (payoutAging over a fixed 10-task batch — seven unreconciled PAID tasks across all four buckets including the 7-day and 30-day endpoints, one unknown stand-in, one reconciled task, and one PAYOUT_PENDING task, self-checked per iteration for the four bucket counts and the pinned age days), payout-reference reverse lookup (tasksByPayoutRef over a fixed 6-task batch — two PAID tasks settled under one ref, one under a different ref, one request-only task, one no-ref task, and one fresh task, self-checked per iteration for the two-hit slice in input order holding the same task objects), minor-unit display formatting (formatMinorUnits over the fixed 12-vector demo corpus — positive, zero, negative, -0, large, and MAX_SAFE_INTEGER, plus the finance-pass total — self-checked against the demo's pinned strings), minor-unit string parsing (parseMinorUnits over a fixed 10-vector corpus — positive, zero, negative, whitespace-trimmed, -0.00 without a minus sign, and MAX_SAFE_INTEGER-adjacent — self-checked per iteration against the hand-pinned minor units, with illegal shapes pinned to their exact fail-fast errors), minor-unit conversion (toMinorUnits/fromMinorUnits over a fixed 4-vector corpus — the finance-pass total, zero, a negative amount, and the 0.33 round-trip — plus the 0.333 nearest-cent rounding vector, self-checked in both directions, with a fractional minor input pinned to its exact fail-fast error), SLA approaching-deadline screening (tasksApproachingDeadline over a fixed 6-task batch — in-window, upper-bound, lower-bound, overdue, no-deadline, and terminal cases under the half-open window (now, now + withinMs]), next-SLA-deadline scheduling (nextSlaDeadline over a fixed 8-task batch: earliest future current-state deadline wins, tie keeps input order, terminal, overdue, and departed-state deadlines excluded, self-checked against the hand-pinned NSL-01 winner), whole-map next-deadline scheduling (nextTaskDeadline over a fixed 8-task batch: earliest future deadline anywhere in any task's SLA map wins — including deadlines on states not yet reached — tie keeps input order, within-task ties keep transition-table declaration order, terminal-state deadlines never count, a departed-state deadline counts, self-checked against the hand-pinned NTD-03 winner), budget-usage reads (resubmitCount / disputeCount / visitCount over one fixed 12-entry REJECTED task — 1 resubmit of 2 budgeted, 1 dispute of 1, 2 CAPTURING visits of 2 — self-checked against the hand-pinned counts), and watchdog execution (expireOverdueTasks / actOnStaleTasks over the same batch, plus actOnStaleTasks with a notify-only action over the same batch — the reminder-loop shape that notifies without dispatching), plus actOnStaleTasks with the default ABANDON action over the same batch with a collecting listener subscribed to every task — the #746 bench variant of the #732 test row, self-checked per iteration for exactly 1 notification (the WD-04 ABANDON), plus expireOverdueTasks over the same batch with a collecting listener subscribed to every task — the #754 bench variant of the #748 test row, self-checked per iteration for exactly 3 notifications (the WD-01 / WD-10 / WD-12 EXPIREs), payout Retry-After parsing (parsePayoutRetryAfter over a fixed corpus of header values), payout mismatch judgement (payoutMismatch over a fixed set of 5 tasks), allowed-events query (allowedEvents over all 13 states), standalone history parsing (parseHistory over the same fixed 8-entry chained history), and terminal-state judgement (isTerminal over all 13 states), transition-table JSON export (transitionTableJson), transition-table pure object construction (transitionTable), state diagram generation (stateDiagram), README diagram sync check (readmeDiagramInSync), SLA overdue judgement (isOverdue over a fixed corpus of 4 tasks), dispatch subscription notification (subscribe with one collecting listener over the full lifecycle), subscribe onError error routing (subscribe with one throwing listener and a collecting onError hook over the full lifecycle), JSON snapshot round-trip (toJSON / TaskLifecycle.fromJSON over the same fixed 8-entry snapshot), policy snapshot round-trip (toJSON / JSON text / TaskLifecycle.fromJSON over a fixed 10-entry REJECTED snapshot carrying a pinned rolePolicy and finite maxResubmits / maxDisputes budgets — the persistence path where the RBAC policy and budgets must come back enforceable), SLA deadline read (getSlaDeadline over all 13 states), dispatch note length guard (MAX_NOTE_LENGTH over a fixed 2-case corpus), SLA deadline write (setSlaDeadline / clearSlaDeadline as a write–overwrite–clear cycle on a fixed OPEN task), and single-step transition dispatch (benchTransition restoring a fixed OPEN snapshot and dispatching one ACCEPT step). Fixtures are deterministic — one task driven through the full DRAFT → PAID lifecycle (8 hash-chained audit entries), with a fixed webhook secret, payload timestamp, and eventId; the screening paths use a fixed batch of 12 PAID tasks (settled exactly as quoted or for a different amount, with or without a payoutRef). The fan-out path injects a deterministic fake fetch (immediate 200, no network, no real sleep), so it measures this library's fan-out overhead — one shared eventId, one byte-identical body, each copy signed with its endpoint's own secret — not network throughput. The single-target path injects the same kind of fake fetch (immediate 200, no network, backoffMs 0 with an injected no-op sleep), so it measures the steady-state cost of one signed POST — the path under the fan-out wrapper; its per-iteration self-check pins the reported result ({ ok: true, attempts: 1, status: 200 }), exactly one captured call to the pinned URL, and a captured body byte-identical to the signed body whose X-Hub-Signature-256 equals the built webhook's signature and verifies over the captured body, and a persistent-500 retry-exhaustion shape ({ ok: false, attempts: 3, status: 500, error: "server responded with status 500" } after exactly 3 calls) is pinned once outside the timed loop. The dedupe path pre-records 1,000 fixed eventIds under an injected fixed clock (the clock never advances, so no entry expires or is evicted in the timed loop) and cycles steady-state duplicate hits over them. The dedupe restore path rebuilds a dedupe on every iteration from a 1,000-entry snapshot (the persisted JSON-round-tripped form of a dedupe that recorded 1,000 fixed eventIds under the same fixed clock; default TTL and capacity, so every entry is live at load and none is evicted); its per-iteration self-checks pin the restored size and the zeroed observability counters to the hand-written expectations in the bench comments, spot-check the first / middle / last snapshot ids as duplicates, and an exhaustive all-ids check runs once outside the timed loop. The dedupe export path exports a fixed dedupe on every iteration (6 ids recorded under an injectable clock that advances only while the fixture is built, with a 60-second TTL: the first id recorded exactly one TTL before the export instant, so it is expired at export time, and 5 later ids with staggered first-seen times all still live); its per-iteration self-check pins the snapshot to the hand-written expectations in the bench comments (v is 1, exactly the 5 live entries in recorded order, the first and last entries pinned in full, the expired id absent); outside the timed loop, a mutated detached copy is pinned to leave a re-export unchanged, and an empty dedupe is pinned to export { v: 1, entries: [] }. The dedupe clear path rebuilds a fresh dedupe on every iteration (1,000 fixed eventIds recorded under an injected fixed clock, plus one duplicate hit on the first id, so the stats about to be zeroed read { size: 1000, hits: 1, misses: 1000, evictions: 0 }), then calls clear(); because clear() mutates the store, its numbers include the fixture-rebuild cost (prefill plus clear) — a conservative floor for the pure clear cost, not the pure cost itself. Its per-iteration self-check pins the size and every counter at zero right after the clear and pins that the first id records as a new event again; outside the timed loop, clearing an empty dedupe is pinned as a harmless no-op. The dedupe stats path reads the observability snapshot of a fixed dedupe on every iteration (7 ids recorded under an injectable clock that advances only while the fixture is built, with a 60-second TTL and a capacity of 6: one id is LRU-evicted during construction, one id sits expired in the map at the stats instant, and 5 ids are live); its per-iteration self-check pins the snapshot to the hand-written expectation in the bench comments ({ size: 5, hits: 1, misses: 7, evictions: 1 } — size counts only the live ids, so the expired id still sitting in the map is excluded); outside the timed loop, a mutated detached snapshot is pinned to leave a re-read unchanged, and an empty dedupe is pinned to report all-zero stats. The dedupe miss/evict path rebuilds a fresh dedupe on every iteration (a capacity of 100 under an injected fixed clock, recording a deterministic sequence of 250 brand-new ids on every pass, so every call is a first sighting and the 150 records beyond capacity force LRU evictions of the oldest surviving ids); because a miss mutates the store, its numbers include the fixture-rebuild cost. Its per-iteration self-check pins the stats to the hand-written expectation in the bench comments ({ size: 100, hits: 0, misses: 250, evictions: 150 } — size pinned at capacity); outside the timed loop, a probe pins that the oldest surviving id still records as a duplicate hit and an already-evicted id records as a new event again. The receiver-flow path reuses the fixed signed webhook and a dedicated dedupe under the same fixed clock (capacity 100,000, so the 6,400 ids recorded across warmup + timed calls never evict); its per-iteration self-checks pin the first-sighting / redelivery-duplicate / tampered-rejection shapes described in the bench comments, with the tampered delivery's claimed id still recording as first-seen on a later genuine delivery. The fan-out receiver-flow path reuses the plain fan-out fixture (4 endpoints, injected fake fetch answering 200 immediately) and one independent dedupe per endpoint under the same fixed clock (capacity 100,000, so the 3,200 ids recorded per dedupe across warmup + timed calls never evict); its per-iteration self-checks pin a fresh fan-out's delivered 4 / first-seen 4 and its same-eventId redelivery's delivered 4 / duplicate 4, with every copy verified under its own endpoint's secret — the #740 test row's bench variant, which neither the fan-out-only row nor the single-endpoint receiver-flow row exercises together. The batch-build path builds webhooks for a fixed batch of 12 PAID tasks (settled exactly as quoted, each with a payoutRef) under one shared payload timestamp, with per-task eventIds from an injected deterministic factory; its self-checks pin the result count and input order, verify every built webhook under the batch secret, and confirm — on a separate mixed batch, outside the timed loop — that one non-PAID task fails only itself and never blocks the batch. The totalPaidOut path reuses the same 12-task screening batch; its per-iteration self-check pins the returned total to the hand-computed sum of the batch's recorded payoutAmounts (100 + 80 + 150 + 200 + 132 + 75 + 300 + 200 + 175 + 80 + 440 + 54 = 1986), written out in the bench comments rather than derived from the implementation itself. The totalQuoted path runs over its own fixed mixed batch of 10 tasks (6 PAID with quotes, 2 PAID without a quote, 2 non-PAID carrying quotes that must contribute 0); its per-iteration self-check pins the returned total to the hand-computed sum of the contributing quotes (100 + 200 + 150 + 300 + 250 + 175 = 1175), and the empty batch is pinned to 0 once, outside the timed loop. The summarizeTaskSettlements path runs the one-call batch summary over its own fixed mixed batch of 10 tasks (6 PAID quoted-and-settled, 2 of them mismatched, 2 PAID without a quote, 2 non-PAID carrying quotes that must contribute 0); its per-iteration self-check pins all eight summary fields to the hand-computed expectation (tasks 10, paid 8, quoted 117500, paid 131500, gap -14000, mismatched 2, unquotedPaid 2, unquotedPaidTotal 15000, all in minor units), and the empty batch is pinned to the all-zero summary once, outside the timed loop. The diffTaskSettlementsSummaries path compares that same fixed summary against a second hand-computed summary over the same 10-task batch re-driven with three payout amounts moved (STS-04 settles 290 instead of 280, STS-05 settles exactly its 250 quote instead of 260, STS-06 settles 100 instead of 90); its per-iteration self-check pins the four moved fields (paidTotal 131500 -> 132500, gap -14000 -> -15000, mismatched 2 -> 1, unquotedPaidTotal 15000 -> 16000) and the four unmoved ones' absence from the diff, and an identical pair is pinned to {} once, outside the timed loop. The renderTaskSettlements path renders the fixed summary to its nine-line plain text; its per-iteration self-check pins the rendering verbatim, line for line, and the re-driven summary's rendering is pinned verbatim once, outside the timed loop. The NDJSON x unreconciled row runs the whole forwarder-to-reconciliation chain over that same batch on every iteration: each task is exported to NDJSON, parsed back, and rebuilt with TaskLifecycle.fromHistory, and the rebuilt batch is screened; its per-iteration self-check pins every rebuilt task's id, PAID state, and 8-entry history, byte-identical re-exported NDJSON, and exactly the hand-computed unreconciled ids (BENCH-SCR-03 / -06 / -09 / -12) in batch order. The watchdog paths use a separate fixed batch of 12 mixed-state tasks (OPEN / ACCEPTED / IN_REVIEW / CAPTURING / SUBMITTED / DRAFT, plus one terminal PAID task; past, future, exactly-at, and absent SLA deadlines; dwell times over, under, and exactly at the per-state budgets) screened under one injected fixed now (BASE + 40 days) with budgets { OPEN: 30 days, ACCEPTED: 7 days, IN_REVIEW: 7 days }; their per-iteration self-checks pin both selections to the hand-counted expectations written out in the bench comments (expired: WD-01, WD-06, WD-10, WD-12; stale: WD-01, WD-04, WD-06, WD-08), never derived from the helpers themselves. The watchdog execution paths act on the same 12-task batch but rebuild it fresh on every iteration — both executors dispatch (EXPIRE, or the default ABANDON action) and so mutate the tasks they act on — which makes their numbers include the fixture-rebuild cost: a conservative floor for the pure executor cost, not the pure cost itself. Their per-iteration self-checks pin the hand-counted outcomes (expire: WD-01, WD-10 and WD-12 expire, WD-06 — IN_REVIEW, which has no EXPIRE edge — fails in isolation; stale action: only WD-04 — ACCEPTED — is abandoned, WD-01 / WD-08 (OPEN) and WD-06 (IN_REVIEW) fail in isolation) and the resulting task states. A third execution row runs the same batch through actOnStaleTasks with a notify-only action (the reminder-loop shape: record the id, dispatch nothing). It mutates nothing, but it still rebuilds the fixture fresh on every iteration, so its numbers stay comparable with the other executor rows instead of flattering the notify path with a reused batch; its per-iteration self-check pins 4 notifications in batch order (WD-01, WD-04, WD-06, WD-08), every outcome acted with no audit entry, and byte-identical states and histories across the whole batch. The expire self-check additionally pins each successful outcome's entry against that task's history last entry (seq/event/from/to/at), and requires the failed WD-06 outcome to carry no entry. The parsePayoutRetryAfter path parses a fixed 11-value corpus on every iteration under one injected fixed nowMs (2026-06-01T12:00:00.000Z, never the wall clock, and no network): delay-seconds values (120, 0, 2.5, and a whitespace-padded 30), a future HTTP-date, a past HTTP-date, signed numbers, garbage, and absent (null / undefined) values. Its per-iteration self-check pins every returned value to the hand-written expectation written out in the bench comments (a future date yields its exact delta, a past date clamps to 0, and signed / garbage / absent values yield undefined, so the caller falls back to exponential backoff); one timed pass is one full corpus pass of 11 parses. The payoutMismatch path judges a fixed set of 5 tasks on every iteration (fixed dispatch timestamps, no network, no wall clock): one settled exactly as quoted, one settled for a different amount, one with no recorded quote, one whose settlement entry records no amount, and one non-PAID task. Its per-iteration self-check pins every returned value to the hand-written expectation written out in the bench comments (the mismatched task yields { quoted: 200, paid: 175.5, delta: -24.5 } with the delta computed by hand; the other four yield undefined); one timed pass is one full set pass of 5 per-task calls. The allowedEvents path queries every state on every iteration (no network, no wall clock): all 13 states in transition-table declaration order. Its per-iteration self-check pins every returned list to the hand-written expectation written out in the bench comments (each non-terminal state returns exactly its outgoing events in declaration order; the terminal states PAID / ABANDONED / EXPIRED return an empty list); one timed pass is one full state-set pass of 13 per-state calls. The parseHistory path parses the same fixed 8-entry chained history on every iteration (no network, no wall clock) — the direct validation a holder of a bare history array pays, without the per-line JSON parsing the NDJSON import path bundles in. Its per-iteration self-check pins the returned entry count and the last entry's seq / state to the hand-written expectations in the bench comments (8 entries, last seq 8, final state PAID); a detached tampered copy (its PAYOUT_COMPLETE entry's payoutAmount altered from 125 to 126) is rejected once, outside the timed loop, with the hash-chain error. The isTerminal path judges every state on every iteration (no network, no wall clock): all 13 states in transition-table declaration order. Its per-iteration self-check pins every returned value to the hand-written truth table written out in the bench comments (only the terminal states PAID / ABANDONED / EXPIRED return true; the other ten states return false); one timed pass is one full state-set pass of 13 per-state calls. The transitionTableJson path exports the full transition table on every iteration (no network, no wall clock): all edges enumerated in declaration order and serialized as canonical JSON (2-space indent, trailing newline) — the export a frontend or documentation tool consumes. Its per-iteration self-check parses the output back and pins it to the hand-written expectations in the bench comments (exactly 19 edges — the count pinned by the committed snapshot — with the first edge DRAFT / PUBLISH / OPEN, the payout edge PAYOUT_PENDING / PAYOUT_COMPLETE / PAID present, and the last edge DISPUTED / ARBITRATE_REJECT / REJECTED in place); one timed pass is one full export call. The transitionTable path constructs the full transition table on every iteration (no network, no wall clock): all edges enumerated in declaration order as plain objects, without any serialization — the form a state-machine consumer uses directly, and the construction the JSON export and the diagram both build on. Its per-iteration self-check pins the output to the hand-written expectations in the bench comments (exactly 19 edges written out in full in declaration order — the list pinned by the committed snapshot — with every edge's keys in from / event / to order, spot-checked against the transitionTableJson edge set through the shared pinned first edge DRAFT / PUBLISH / OPEN, payout edge (index 14) PAYOUT_PENDING / PAYOUT_COMPLETE / PAID, and last edge DISPUTED / ARBITRATE_REJECT / REJECTED); outside the timed loop, the full edge set is compared to the parsed JSON edge set edge for edge and a hand-pinned non-existent edge (DRAFT / PAYOUT_COMPLETE / PAID) is confirmed absent from both; one timed pass is one full construction call. The stateDiagram path renders the full transition table on every iteration (no network, no wall clock): all edges enumerated in declaration order and assembled as a Mermaid stateDiagram-v2 block — the block this README embeds verbatim above. Its per-iteration self-check pins the output to the hand-written expectations in the bench comments (exactly 24 lines — the opening fence, the stateDiagram-v2 / direction / initial lines, the 19 edges, and the closing fence — with all 13 states present, the first edge DRAFT / PUBLISH / OPEN, the payout edge PAYOUT_PENDING / PAYOUT_COMPLETE / PAID present, and the last edge DISPUTED / ARBITRATE_REJECT / REJECTED in place); one timed pass is one full render call. The readmeDiagramInSync path runs the README diagram sync check on every iteration (no network, no randomness, no wall clock): it reads only this repo's README.md from disk and confirms it embeds the exact diagram block generated from the transition table — the check a documentation CI pays on every run. Its per-iteration self-check pins the return value to the hand-written expectation in the bench comments (exactly true, the in-sync state the test suite pins via the CLI --check test and the README drift test); one timed pass is one full check call. The isOverdue path judges a fixed corpus of 4 tasks on every iteration (no network, no randomness, no wall clock): all four tasks in ACCEPTED under one injected fixed now (2026-10-04T00:00:00.000Z) — one already past its deadline, one whose deadline is exactly now, one whose deadline is still ahead, and one with no SLA deadline at all. Its per-iteration self-check pins every returned value to the hand-written expectation written out in the bench comments (true / true / false / false, cross-checked against the SLA tests; the exactly-at case pins the inclusive boundary — now >= deadline, not >); one timed pass is one full corpus pass of 4 per-task calls, and an exhaustive check runs once outside the timed loop. The subscribe path drives a fresh task through the same fixed full DRAFT → PAID lifecycle on every iteration with one collecting listener subscribed before any dispatch (no network, no randomness, no wall clock; the task is rebuilt fresh per iteration because dispatch mutates it, so the number includes the fixture-rebuild cost). Its per-iteration self-check pins the notification sequence to the hand-written expectations written out in the bench comments (exactly 8 notifications in dispatch order, each (event, from, to) triple in place, each carried entry equal to the history entry with the same seq, field for field); outside the timed loop, an unsubscribe check pins that a further dispatch notifies nobody and an isolation check pins that a throwing listener neither breaks a dispatch nor starves its peer; one timed pass is one full subscribed lifecycle of 8 dispatches + 8 notifications. The toJSON + fromJSON snapshot round-trip path exports the same fixed full-lifecycle task and rehydrates it on every iteration (no network, no randomness, no wall clock): one export of the plain-JSON snapshot envelope (v 1, state PAID, the full 8-entry history, empty SLA deadlines, and no budget / policy / idempotency fields — both budgets are unlimited, so none are stored) plus one strict rehydration through the untrusted-snapshot reader. Its per-iteration self-check pins the restored state and the full history to the hand-written expectations in the bench comments (entry for entry, hashes included), the history-derived used RESUBMIT / DISPUTE counts (0 on both sides — the used counts are derived from the history, never stored), and the re-export fixed point (identical snapshot content at the same serialized byte length); outside the timed loop, a JSON text round-trip (JSON.stringify / JSON.parse — the form a document store actually persists) is pinned to restore identically, and two detached tampered copies are pinned to be rejected with their hand-pinned envelope errors (a copy whose v is rewritten to 2 with invalid snapshot: unsupported snapshot version 2, and a copy carrying one extra top-level field with invalid snapshot: unknown field "benchExtraField"), with the intact fixture pinned to restore afterwards; one timed pass is one export + one rehydration of the 8-entry snapshot. The toJSON + fromJSON policy snapshot round-trip path covers the persistence combination the plain snapshot path never times: on every iteration it exports a fixed task (BENCH-522, 10 hash-chained entries ending in REJECTED) that carries a pinned rolePolicy (DISPUTE/RESUBMIT → ["contributor"]) and finite budgets (maxResubmits 1 with 1 of 1 used, maxDisputes 2 with 0 used), round-trips the envelope through JSON text, and rehydrates it through the strict reader. Its per-iteration self-check pins the restored state and the full history entry for entry (hashes included), the history-derived used counts (1 and 0), the envelope's byte-identical policy and budget fields, and the re-export fixed point; outside the timed loop, an unauthorized actor on the policy-covered DISPUTE is pinned to the exact authorization wording and an authorized RESUBMIT to the exact budget-exhaustion wording — the budget is history-derived, so persistence does not reset it — each leaving state and history byte-identical. The fromHistory path rebuilds a task from the same fixed 8-entry history on every iteration (no network, no randomness, no wall clock): the event-sourcing rebuild a crash recovery pays once per task. Its per-iteration self-check pins the rebuilt id and state and the full history to the source history entry for entry (hashes included; compared field-wise and through the canonical NDJSON serializer, because rehydration normalizes history-entry key order); outside the timed loop, a detached tampered copy (the PAYOUT_COMPLETE entry's payoutAmount altered from 125 to 126) is pinned to be rejected with the hash-chain error, and the intact fixture is pinned to rebuild afterwards; one timed pass is one rebuild of the 8-entry history. The verifyHistoryChain path re-verifies the same fixed 8-entry history on every iteration; its per-iteration self-check pins the genesis link (the first entry's prevHash is GENESIS_PREV_HASH) and every successor link (each entry's prevHash equals its predecessor's hash); outside the timed loop, a detached tampered copy (the middle SUBMIT entry gaining a payoutAmount of 999 it never carried) is pinned to return false, and the intact fixture is pinned to verify afterwards; one timed pass is one verification of the 8-entry history. The verifyHistoryChain with auditSecret path re-verifies a fixed 8-entry keyed history on every iteration (no network, no randomness, no wall clock): the same full lifecycle steps and fixed timestamps as the unkeyed fixture, chained under a pinned audit secret so every link is HMAC-SHA256 instead of plain SHA-256 — the tamper-evidence check a holder of a keyed audit log pays on every read. Its per-iteration self-check pins the verdict to exactly true and pins the genesis and successor links (the first entry's prevHash is GENESIS_PREV_HASH, each later entry's prevHash equals its predecessor's hash); outside the timed loop, a wrong secret is pinned to return false, a detached tampered copy (the middle SUBMIT entry gaining a payoutAmount of 999 it never carried) is pinned to return false, and the same keyed history verified without a secret is pinned to return false (fail-closed, exactly as the keyed hash-chain tests pin), with the intact fixture pinned to verify afterwards; one timed pass is one keyed verification of the 8-entry history. The dispatch idempotency dedupe path runs one keyed dispatch pair on a fresh task on every iteration (no network, no randomness, no wall clock): a PUBLISH under a pinned idempotencyKey, then the same event under the same key again — the retry-safety cost every payment-flavored dispatch pays, previously invisible inside the plain dispatch row, which never carries a key. Its per-iteration self-check pins both returns to the current state (OPEN), the history growth to exactly one entry with a gap-free seq, the consumed-key snapshot to exactly the pinned key, and the listener notifications to exactly the first dispatch; outside the timed loop, the same key under a different event is pinned to dedupe, a stale retry after the task moved on is pinned to answer with the current state, a failed dispatch is pinned to consume no key (the corrected retry under the same key executes), and two keyless dispatches are pinned to append two entries; one timed pass is one fresh task plus the keyed dispatch pair, so its numbers include the fresh-task rebuild cost. The verifyPayoutWebhook secret-rotation path verifies the fixed old-signed webhook on every iteration under the rotation candidate set { secrets: [new, old] } (no network, no randomness, no wall clock): the matching candidate is the second one tried, so every pass pays one failed HMAC trial before the match — the steady-state receiver cost inside a rotation window, previously invisible inside the single-secret verify row. Its per-iteration self-check pins the verdict to exactly true; outside the timed loop, a candidate pair in which neither secret matches is pinned to return false (all-mismatch fails closed, no throw), and an empty secrets array is pinned to throw its exact configuration error (cannot verify payout webhook: secrets must be a non-empty array); one timed pass is one rotation verification of the fixed webhook. The verifyPayoutWebhook maxAgeMs freshness-window path verifies the fixed webhook on every iteration under { maxAgeMs: 300000, now } with the receiver now injected 60 seconds after signing (no network, no randomness, no wall clock): the freshness gate every freshness-checking receiver pays after the signature matches — a legitimately-signed payload older than the window fails closed even though its signature alone would verify forever — previously invisible inside the plain verify row, whose options never carry a freshness window at all. Its per-iteration self-check pins the verdict to exactly true (an age of 60,000 ms inside the 5-minute window); outside the timed loop, a payload aged 1 ms past the window is pinned to return false, a payload aged exactly maxAgeMs is pinned to still verify (the boundary is inclusive — only now - at > maxAgeMs fails), and an illegal maxAgeMs of -1 is pinned to throw its exact configuration error (cannot verify payout webhook: maxAgeMs must be a finite non-negative number, got -1); one timed pass is one freshness-window verification of the fixed webhook. The verifyPayoutWebhook maxFutureSkewMs future-skew path verifies the fixed webhook on every iteration under { maxFutureSkewMs: 300000, now } with the receiver now injected 60 seconds before signing (no network, no randomness, no wall clock): the future side of the two-sided freshness window every freshness-checking receiver pays after the signature matches — a legitimately-signed payload dated further in the future than the window fails closed even though a maxAgeMs-only check would pass it (a negative age always passes) and its signature alone would verify forever — previously invisible inside the plain verify row and the maxAgeMs row, whose options never bound the future direction at all. Its per-iteration self-check pins the verdict to exactly true (a future skew of 60,000 ms inside the 5-minute window); outside the timed loop, a payload 1 ms beyond the window is pinned to return false, a payload skewed exactly maxFutureSkewMs is pinned to still verify (the boundary is inclusive — only at - now > maxFutureSkewMs fails), the two-sided window formed with maxAgeMs is pinned on all three sides (inside both bounds verifies, beyond the future bound returns false, beyond the age bound returns false), and an illegal maxFutureSkewMs of -1 is pinned to throw its exact configuration error (cannot verify payout webhook: maxFutureSkewMs must be a finite non-negative number, got -1); one timed pass is one future-skew verification of the fixed webhook. The verifyPayoutWebhook secrets-rotation x maxFutureSkewMs combination path verifies the fixed old-signed webhook on every iteration under { secrets: [new, old], maxFutureSkewMs: 300000, now } with the receiver now injected 60 seconds before signing (no network, no randomness, no wall clock): the steady-state receiver cost inside a rotation window when the sender clock runs ahead — previously invisible inside the rotation row and the future-skew row, which never exercised the pair together. The semantic anchor is order: the freshness gate runs only after a signature matches, so rotation matching comes first and the skew rejection second. Its per-iteration self-check pins the verdict to exactly true (a future skew of 60,000 ms inside the 5-minute window, matched by the second candidate, so every pass pays one failed HMAC trial before the match and then the freshness gate); outside the timed loop, a payload skewed exactly maxFutureSkewMs under rotation is pinned to still verify (the inclusive boundary, same as the single-secret row), a payload 1 ms beyond the window under rotation is pinned to return false (the skew gate rejects even a correctly rotation-matched signature), and an all-mismatch candidate pair under the same skew window is pinned to return false both inside and beyond the window (the failure is signature-driven and skew-independent: no freshness window can rescue a signature that never matched); one timed pass is one rotation x future-skew verification of the fixed webhook. The deliverPayoutWebhook maxRetryDelayMs cap path delivers the fixed webhook on every iteration through an injected fake fetch that answers 429 with a pinned Retry-After: 30 hint (30000 ms) on the first attempt and 200 on the second (no network, no randomness, no real waiting: the injected sleep only records), with backoffMs 100 and maxRetryDelayMs 5000 — the runaway-hint guard every hinted retry pays, previously invisible inside the single-target row, whose 200-immediately script never retries at all. Its per-iteration self-check pins the result to { ok: true, attempts: 2, status: 200 }, exactly two captured calls to the pinned URL with byte-identical signed bodies, and the recording sleep receiving exactly the clamped 5000 ms (Math.min(30000, 5000) — never the raw hint, never the 100 ms backoff base); outside the timed loop, a 429 without a hint is pinned to fall back to the 100 ms exponential-backoff base, a below-cap hint (Retry-After: 2) is pinned to be honored exactly at 2000 ms, and a 500 carrying a Retry-After header is pinned to be ignored as a hint (only 429 hints are read) and to fall back to the backoff base as well; one timed pass is one capped delivery of the fixed webhook. The deliverPayoutWebhook AbortSignal pre-abort path delivers the fixed webhook on every iteration through an injected counting fake fetch with an external signal that is aborted before the call (no network, no randomness, no real waiting: the injected sleep is a no-op that is never reached) — the deterministic cancellation guard every externally-signalled delivery pays before it starts, previously invisible inside the single-target row, whose script never carries a signal at all. Its per-iteration self-check pins the reported result to { ok: false, attempts: 0, error: "payout webhook delivery aborted" } with no status, and the counting fetch to exactly zero calls (the caller asked to stop before the delivery started, and the abort is reported, never thrown); outside the timed loop, a non-AbortSignal signal value is pinned to throw exactly cannot deliver payout webhook: signal must be an AbortSignal, got bench-not-a-signal before any request, and a signal that never aborts is pinned to leave the normal delivery unchanged ({ ok: true, attempts: 1, status: 200 } after exactly one fetch call); one timed pass is one pre-aborted delivery attempt of the fixed webhook. The dispatch requirePayoutRef path runs one enforced settlement dispatch on a fresh task on every iteration (no network, no randomness, no wall clock): the task is constructed with { requirePayoutRef: true } and driven through the pinned lifecycle steps to PAYOUT_PENDING under fixed timestamps, then PAYOUT_COMPLETE carries the pinned payoutRef — the settlement-reconciliation guard every enforced payout pays, previously invisible inside the plain dispatch row, whose tasks never enable the option. Because the dispatch settles the task, its numbers include the fresh-task rebuild cost. Its per-iteration self-check pins the dispatch return to PAID, the history to exactly 8 entries, and the last entry to the pinned settlement fields (seq 8, PAYOUT_COMPLETE, at BASE + 7s, carrying the pinned payoutRef verbatim); outside the timed loop, a ref-less dispatch on an enforced task is pinned to throw exactly payout reference required: PAYOUT_COMPLETE must carry a non-empty payoutRef when requirePayoutRef is enabled and to leave state and history untouched, and a default task (option unset) is pinned to still settle without a reference; one timed pass is one fresh enforced task driven to PAYOUT_PENDING plus its with-reference settlement dispatch. The dispatch requireActor path runs one enforced settlement dispatch on a fresh task on every iteration (no network, no randomness, no wall clock): the task is constructed with { requireActor: true } and driven through the pinned lifecycle steps to PAYOUT_PENDING under fixed timestamps, then PAYOUT_COMPLETE carries the pinned actor — the audit-attribution guard every enforced dispatch pays, previously invisible inside the plain dispatch row, whose tasks never enable the option. Because the dispatch settles the task, its numbers include the fresh-task rebuild cost. Its per-iteration self-check pins the dispatch return to PAID, the history to exactly 8 entries, and the last entry to the pinned settlement fields (seq 8, PAYOUT_COMPLETE, at BASE + 7s, carrying the pinned actor verbatim); outside the timed loop, an actor-less dispatch on an enforced task is pinned to throw exactly actor required: dispatch must carry a non-empty actor when requireActor is enabled and to leave state and history untouched, and a default task (option unset) is pinned to still settle without an actor; one timed pass is one fresh enforced task driven to PAYOUT_PENDING plus its with-actor settlement dispatch. The dispatch expectedSeq path runs one guarded dispatch on a fresh task on every iteration (no network, no randomness, no wall clock): the task is driven through the pinned lifecycle steps to a 2-entry history in ACCEPTED under fixed timestamps, then START_CAPTURE carries expectedSeq: 2 — the optimistic-concurrency guard every concurrent writer pays before any other dispatch check, previously invisible inside the plain dispatch row, whose dispatches never carry the guard. Because the dispatch mutates the task, its numbers include the fresh-task rebuild cost. Its per-iteration self-check pins the dispatch return to CAPTURING, the history to exactly 3 entries, and the last entry to the pinned step fields (seq 3, START_CAPTURE, at BASE + 2s); outside the timed loop, a stale expectedSeq: 1 is pinned to throw exactly dispatch conflict: expected seq 1 but task is at seq 2 and to leave state and history byte-identical, expectedSeq: 0 on an empty history is pinned to advance, illegal values (-1 and 1.5) are pinned to throw their exact configuration errors, and dispatch without expectedSeq is pinned to behave unchanged; one timed pass is one fresh task driven to the pinned 2-entry history plus its guarded dispatch. The dispatch maxDisputes path runs one budgeted dispute on a fresh task on every iteration (no network, no randomness, no wall clock): the task is constructed with maxDisputes: 1 and driven through the pinned lifecycle steps to a 6-entry history in REJECTED under fixed timestamps, then DISPUTE spends the single appeal round — the dispute budget every moderation deployment pays on a contested rejection, previously invisible inside the plain dispatch row, whose tasks carry no budget, and inside the snapshot row, where maxDisputes appears only as a fixture field. Because the dispatch mutates the task, its numbers include the fresh-task rebuild cost. Its per-iteration self-check pins the dispatch return to DISPUTED, the history to exactly 7 entries, and the last entry to the pinned step fields (seq 7, DISPUTE, at BASE + 6s); outside the timed loop, a second DISPUTE after one full appeal round is pinned to throw exactly dispute budget exhausted: 1 of 1 DISPUTEs already used and to leave state and history byte-identical, a budget of 0 is pinned to make the first DISPUTE throw dispute budget exhausted: 0 of 0 DISPUTEs already used at once, a task built without the option is pinned to run repeated appeal rounds (the pre-budget default), a snapshot round-trip is pinned to keep the budget exhausted (the used count is history-derived, so it does not reset), and illegal budgets (-1 and 1.5) are pinned to throw their exact invalid option errors, with an illegal snapshot budget pinned to its exact invalid snapshot error; one timed pass is one fresh budgeted task driven to the pinned 6-entry history plus its budgeted dispute dispatch. The dispatch maxVisitsByState path runs one budgeted RESUBMIT into CAPTURING on a fresh task on every iteration (no network, no randomness, no wall clock): the task is constructed with maxVisitsByState: { CAPTURING: 2 } and driven through the pinned lifecycle steps to a 6-entry history in REJECTED under fixed timestamps, then RESUBMIT spends the second of the 2 budgeted CAPTURING visits — the per-state visit cap every resubmission loop pays on a task that must not cycle through capture forever, previously invisible inside the plain dispatch row, whose tasks carry no budget, and inside the snapshot row, where maxVisitsByState appears only as a fixture field. Because the dispatch mutates the task, its numbers include the fresh-task rebuild cost. Its per-iteration self-check pins the dispatch return to CAPTURING, the history to exactly 7 entries, and the last entry to the pinned step fields (seq 7, RESUBMIT, at BASE + 6s); outside the timed loop, a second RESUBMIT after one full resubmit round is pinned to throw exactly state visit budget exhausted: CAPTURING already visited 2 of 2 times and to leave state and history byte-identical, a budget of 1 is pinned to make the first RESUBMIT throw state visit budget exhausted: CAPTURING already visited 1 of 1 times at once because the first pass already spent the visit, a task built without the option is pinned to run repeated resubmit rounds (the pre-budget default), a snapshot round-trip is pinned to keep the budget exhausted (the used count is history-derived, so it does not reset), and illegal budgets (0, -1 and 1.5), an unknown state, and a non-object map are pinned to throw their exact invalid option errors, with an illegal snapshot budget pinned to its exact invalid snapshot error; one timed pass is one fresh budgeted task driven to the pinned 6-entry history plus its budgeted resubmit dispatch. The dispatch rolePolicy path runs one authorized ACCEPT on a fresh task on every iteration (no network, no randomness, no wall clock): the task is constructed with rolePolicy: { ACCEPT: ["reviewer"] } and driven through PUBLISH to a pinned 1-entry history in OPEN under fixed timestamps, then ACCEPT passes the allowlist check — the event-level RBAC every dispatch into a policy-covered event pays, previously invisible inside the plain dispatch row, whose tasks carry no policy, and inside the snapshot row, where rolePolicy appears only as a fixture field. Because the dispatch mutates the task, its numbers include the fresh-task rebuild cost. Its per-iteration self-check pins the dispatch return to ACCEPTED, the history to exactly 2 entries, and the last entry to the pinned step fields (seq 2, ACCEPT, at BASE + 1s, actor "reviewer"); outside the timed loop, an unauthorized actor ("viewer") is pinned to throw exactly actor not authorized for ACCEPT: "viewer" is not in [reviewer] and to leave state and history byte-identical, a missing actor is pinned to throw exactly actor not authorized for ACCEPT: policy requires an actor in [reviewer] the same untouched way, and a task built without a policy is pinned to advance the same actorless ACCEPT with no authorization check (the pre-RBAC default); one timed pass is one fresh policy-carrying task driven to the pinned 1-entry history plus its authorized dispatch. The dispatch {at} path runs one explicit-timestamp START_CAPTURE on a fresh task on every iteration (no network, no randomness, no wall clock): the task is driven through PUBLISH and ACCEPT to a pinned 2-entry history in ACCEPTED under fixed timestamps, then START_CAPTURE carries an injected canonical ISO-8601 at that lands verbatim in the history entry — the audit-determinism path every reproducible deployment pays instead of the wall-clock read the plain dispatch rows take. Its coverage existed only as unit tests (test/dispatchAt.test.ts) before this row; the bench never gave it a number. Because the dispatch mutates the task, its numbers include the fresh-task rebuild cost. Its per-iteration self-check pins the dispatch return to CAPTURING, the history to exactly 3 entries, and the last entry to the pinned step fields (seq 3, START_CAPTURE, at byte-identical to the injected BASE + 2s value, actor "reviewer"); outside the timed loop, a non-canonical at is pinned to throw exactly invalid dispatch options: at must be canonical ISO-8601, got not-a-date and to leave state and history byte-identical; one timed pass is one fresh task driven to the pinned 2-entry history plus its timestamped dispatch. The getSlaDeadline path reads the SLA deadline of every state on every iteration (no network, no randomness, no wall clock): all 13 states in transition-table declaration order, queried on one fixed ACCEPTED task carrying a deadline for exactly two states (OPEN at 2026-02-01T09:00:00.000Z and ACCEPTED at 2026-03-01T09:00:00.000Z). Its per-iteration self-check pins every returned value to the hand-written expectation written out in the bench comments (the two set states return their fixed ISO deadlines, the other eleven states return undefined, and the no-argument form returns the current ACCEPTED state's deadline — cross-checked against the SLA tests); outside the timed loop, an exhaustive check on a separate probe task pins that overwriting a deadline reads back the new value and leaves the timed fixture untouched; one timed pass is one full state-set pass of 13 per-state calls. The MAX_NOTE_LENGTH path runs a fixed 2-case corpus on fresh tasks on every iteration (no network, no randomness, no wall clock): a dispatch whose note is exactly the limit (32768 characters) and a dispatch whose note is one character over (32769). Its per-iteration self-check pins both cases to the hand-written expectations written out in the bench comments (the at-limit dispatch succeeds — the task lands in OPEN with exactly 1 history entry whose stored note length is 32768 — and the over-limit dispatch is rejected with exactly invalid dispatch options: note must be at most 32768 characters, got 32769, leaving the fresh task in DRAFT with 0 history entries — cross-checked against the actor/note hardening tests); the notes are presented only by length and are never printed; one timed pass is one full corpus pass of 2 dispatch attempts. The setSlaDeadline + clearSlaDeadline path runs one full write cycle on a shared fixed OPEN task on every iteration (no network, no randomness, no wall clock): set the current state's deadline from a Date (2026-05-01T09:00:00.000Z), overwrite it from an ISO string (2026-06-01T09:00:00.000Z), then clear it — the clear restores the fixture's initial no-deadline state, so the same task serves every iteration. Its per-iteration self-check pins every read-back to the hand-written expectations written out in the bench comments (the set and overwritten deadlines read back exactly, the cleared state reads back undefined, an untouched state reads back undefined throughout, and the task stays in OPEN with its single PUBLISH entry — the writes are configuration, never a dispatch, cross-checked against the SLA tests); outside the timed loop, exhaustive checks on separate probe tasks pin the per-state and clear-all forms, the normalized-ISO storage of a non-ISO parseable input, the setter's one rejection (an unparseable deadline, rejected with exactly invalid SLA deadline: not-a-date, leaving the stored value untouched), and that setting a deadline on a terminal (ABANDONED) task is stored rather than rejected — the setter has no state guard; one timed pass is one full write cycle of 2 sets + 1 clear. The benchTransition path restores a fixed OPEN snapshot on every iteration and dispatches exactly one legal step (ACCEPT, carrying the same quoted amount 125 the full lifecycle's ACCEPT step carries, pinned at BASE + 1s) (no network, no randomness, no wall clock): the steady-state cost of a single dispatch — restore plus one hash-chained audit append — that the full-lifecycle path only reports as an 8-step aggregate. Its per-iteration self-check pins the hand-written expectations written out in the bench comments (the state advances exactly one step — OPEN to ACCEPTED — the history grows by exactly one entry, and the appended entry's seq continues the snapshot's seq with no gap); outside the timed loop, an exhaustive pure-transition() check pins every state/event pair (the 19 legal edges return their hand-pinned targets and every other pair throws exactly invalid transition: <event> from <state>), and the illegal PAYOUT_COMPLETE dispatch from OPEN is pinned to be rejected with that same exact message, leaving the restored task untouched; one timed pass is one restore + one dispatch. The subscribe onError path drives a fresh task through the same fixed full DRAFT → PAID lifecycle on every iteration with one throwing listener subscribed with a collecting onError hook (no network, no randomness, no wall clock; the task is rebuilt fresh per iteration because dispatch mutates it, so the number includes the fixture-rebuild cost). Its per-iteration self-check pins that every dispatch succeeded, the history count and the routed report count both equal the dispatch count (exactly 8 reports), every report carries the pinned listener message, and the first report's context matches the hand-written expectation written out in the bench comments (PUBLISH, DRAFT → OPEN); outside the timed loop, a probe pins that a throwing onError hook is swallowed too, and the non-function onError rejection is pinned to its exact message (invalid subscribe: onError must be a function, got string, registering nothing); one timed pass is one full subscribed lifecycle of 8 dispatches + 8 routed error reports. The history getter path calls get history() on a fixed task carrying a pinned 64-entry hash-chained history on every iteration (no network, no randomness, no wall clock): the O(n) copy plus per-entry Object.freeze the audit-immutability guarantee costs on every read — the price every polling UI or audit exporter pays when it looks at the trail, previously invisible inside the subscribe / replay / snapshot rows, which never isolated the getter itself. Its per-iteration self-check pins exactly 64 entries, the frozen array and frozen entries, content byte-identical to the fixture baseline, and consecutive calls never sharing entry references (detached copies); outside the timed loop, one-off probes pin that a push, a splice, and an entry-field write on the returned snapshot all throw and leave the internal history untouched — the same assertions test/historyFreeze.test.ts pins; one timed pass is one getter call over 64 entries. Immutability is not free: the getter trades read throughput for a runtime guarantee that the append-only audit log cannot be rewritten, even by accident. The nextSlaDeadline path runs one scheduler pick over a fixed 8-task batch on every iteration (no network, no randomness, no wall clock): the earliest future current-state deadline wins, the 12-hour tie keeps input order (NSL-01 over NSL-02), and the terminal PAID task, the at-now and overdue tasks, the no-deadline task, and the departed-state deadline task are all excluded. Its per-iteration self-check pins the winner task object and its deadline ISO string to the hand-written expectations written out in the bench comments; outside the timed loop, the all-excluded sub-batch is pinned to return undefined; one timed pass is one scheduler pick over 8 tasks. The nextTaskDeadline path runs one whole-map scheduler pick over a fixed 8-task batch on every iteration (no network, no randomness, no wall clock): the earliest future deadline anywhere in any task's SLA map wins — including deadlines on states the task has not reached yet — so the winner is the task whose ACCEPTED deadline 6 hours out beats the 12-hour tie (input order keeps NTD-01 over NTD-02), the at-now and overdue deadlines, the no-deadline task, the terminal-state deadline on a live task, and the terminal PAID task; the departed-state deadline counts but loses on lateness. Its per-iteration self-check pins the winner task object, its state (ACCEPTED — first in transition-table declaration order among the two tied deadlines on the winner), and its deadline ISO string to the hand-written expectations written out in the bench comments; outside the timed loop, the all-excluded sub-batch is pinned to return undefined, the departed-state-only sub-batch pins its departed-state winner, and the tie-only sub-batch pins input order; one timed pass is one scheduler pick over 8 tasks. The budget-counts path runs one read of all three budget-usage counts over one fixed REJECTED task on every iteration (no network, no randomness, no wall clock): the task carries budgets maxResubmits 2, maxDisputes 1, and maxVisitsByState { CAPTURING: 2 } and was driven through one resubmit round and one appeal round back to REJECTED, so the history-derived counts read 1 resubmit, 1 dispute, and 2 CAPTURING visits. Its per-iteration self-check pins the three counts to the hand-written expectations written out in the bench comments; outside the timed loop, a further DISPUTE is pinned to the exact budget-exhausted error with state and history byte-identical, and a fresh task is pinned to 0 resubmits / 0 disputes with the initial DRAFT visit already counted, plus the unknown-state fail-fast; one timed pass is one three-count read over a 12-entry history. The reconcilePayouts path runs one five-way reconciliation over a fixed 5-task batch against a 4-row ledger on every iteration (no network, no randomness, no wall clock): two tasks settle exactly as the ledger records, one carries a reference no ledger row carries, one agrees on the reference but not the amount, one settles with no reference at all, and one ledger row has no task. Its per-iteration self-check pins the five group counts, the matched amount 350, and the mismatch amounts (task 200 vs ledger 180) to the hand- written expectations written out in the bench comments; outside the timed loop, a duplicate ledger reference is pinned to its exact fail-fast error; one timed pass is one reconciliation over 5 tasks and 4 ledger rows. The diffTaskSnapshots path runs one snapshot comparison over a fixed 25-entry before snapshot and its 28-entry after snapshot on every iteration (no network, no randomness, no wall clock): five review cycles build the long prefix, an OPEN SLA deadline is carried over, a PAYOUT_PENDING deadline is added, and three dispatches settle the task for 900. Its per-iteration self-check pins the four changed keys, the state move IN_REVIEW -> PAID, the payout move undefined -> 900, and the appended seq range 26..28 to the hand-written expectations written out in the bench comments; outside the timed loop, an identical pair is pinned to report no change and a rewritten or truncated history is pinned to its exact fail-fast divergence error; one timed pass is one comparison over a 25-entry prefix. The payoutAging path runs one batch aging pass over a fixed 10-task batch on every iteration (no network, no randomness, no wall clock): seven unreconciled PAID tasks whose completion dates land in every bucket including both pinned endpoints (exactly 7 days lands in days7To30, exactly 30 days lands in over30Days), one PAID stand-in with no completion entry (the defensive unknown shape), one reconciled PAID task, and one PAYOUT_PENDING task, the last two excluded from every bucket. Its per-iteration self-check pins the four bucket counts and the pinned age days (2 and 6 under 7 days, 7, 22, and 29 in days7To30, 30 and 59 over 30 days, one unknown) to the hand-written expectations written out in the bench comments; outside the timed loop, an invalid now is pinned to its exact fail-fast error; one timed pass is one aging pass over 10 tasks. The tasksByPayoutRef path runs one reverse lookup over a fixed 6-task batch on every iteration (no network, no randomness, no wall clock): two PAID tasks settled under "xfer-bench-01" with a different-ref task between them, one PAID task settled under "xfer-bench-02", one PAID task whose REQUEST_PAYOUT alone names "xfer-bench-01" (its completion entry carries no ref, so it must not match), one PAID task with no ref, and one fresh DRAFT task. Its per-iteration self-check pins the two-hit slice [BENCH-REF-01, BENCH-REF-03] in input order holding the same task objects; outside the timed loop, the "xfer-bench-02" contrast finds only BENCH-REF-02, and an empty or non-string ref is pinned to its exact fail-fast error; one timed pass is one lookup over 6 tasks. The tasksByEvent path runs one event-filter lookup over a fixed 6-task batch on every iteration (no network, no randomness, no wall clock): a disputed task, a never-disputed task, a twice-resubmitted task, an expired task, a second disputed task interleaved after the resubmitted one, and a fresh task with no history. Its per-iteration self-check pins the one-hit RESUBMIT slice [BENCH-EVENT-03] in input order holding the same task object, so the twice-resubmitted task appears exactly once; outside the timed loop, the DISPUTE contrast finds [BENCH-EVENT-01, BENCH-EVENT-05], the EXPIRE contrast finds only BENCH-EVENT-04, and an unknown event is pinned to its exact fail-fast error; one timed pass is one lookup over 6 tasks. The tasksByFilters path runs one combined-filter lookup over a fixed 6-task batch on every iteration (no network, no randomness, no wall clock): a plain IN_REVIEW task the reviewer BEGIN_REVIEWed but never rejected, a disputed task with the right actor and event but the wrong state, a task that went through a full dispute round and came back to IN_REVIEW, two PAID tasks settled under different refs, and a fresh task with no history. Its per-iteration self-check pins the one-hit slice [BENCH-FILTER-03] in input order holding the same task object, so the dispute-round task is the only one matching all three conditions; outside the timed loop, dropping the event condition widens the slice to [BENCH-FILTER-01, BENCH-FILTER-03], the payoutRef axis finds only BENCH-FILTER-04, the empty filter returns the whole batch, and an unknown state / unknown event / empty actor is pinned to its exact fail-fast error; one timed pass is one lookup over 6 tasks. See the throughput row below. One real run on 2026-11-14:

path throughput
dispatch (full lifecycle, 8 dispatches) ~12,506 ops/sec (79.96 µs/op)
dispatch idempotency dedupe (keyed dispatch + duplicate) ~60,559 ops/sec (16.51 µs/op)
dispatch requirePayoutRef (with payoutRef) ~11,698 ops/sec (85.48 µs/op)
dispatch requireActor (with actor) ~18,062 ops/sec (55.36 µs/op)
dispatch expectedSeq (optimistic concurrency guard) ~43,089 ops/sec (23.21 µs/op)
dispatch maxDisputes (dispute budget, 1 DISPUTE) ~20,475 ops/sec (48.84 µs/op)
dispatch maxResubmits (retry budget, 1 RESUBMIT) ~20,160 ops/sec (49.60 µs/op)
dispatch maxVisitsByState (visit budget, 2 CAPTURING visits) ~19,049 ops/sec (52.50 µs/op)
dispatch rolePolicy (authorized ACCEPT dispatch) ~61,463 ops/sec (16.27 µs/op)
dispatch {at} (explicit timestamp) ~50,417 ops/sec (19.83 µs/op)
replay (8-entry history) ~21,347 ops/sec (46.84 µs/op)
fromHistory (8-entry history) ~5,798 ops/sec (172.48 µs/op)
verifyHistoryChain (8 entries) ~42,423 ops/sec (23.57 µs/op)
verifyHistoryChain auditSecret (8 entries, keyed) ~20,818 ops/sec (48.04 µs/op)
buildPayoutWebhook (sign) ~97,964 ops/sec (10.21 µs/op)
verifyPayoutWebhook ~173,412 ops/sec (5.77 µs/op)
verifyPayoutWebhook (secrets rotation, 2 candidates) ~103,439 ops/sec (9.67 µs/op)
verifyPayoutWebhook maxAgeMs freshness window ~129,558 ops/sec (7.72 µs/op)
verifyPayoutWebhook maxFutureSkewMs future-skew window ~149,055 ops/sec (6.71 µs/op)
verifyPayoutWebhook (secrets rotation x maxFutureSkewMs) ~88,020 ops/sec (11.36 µs/op)
verifyPayoutWebhook x PayoutEventDedupe receiver flow ~41,979 ops/sec (23.82 µs/op)
verifyPayoutWebhook freshness x PayoutEventDedupe receiver flow ~33,183 ops/sec (30.14 µs/op)
historyToNdjson (8 entries) ~20,827 ops/sec (48.01 µs/op)
historyFromNdjson (8 entries, chain re-verify) ~19,690 ops/sec (50.79 µs/op)
mismatchedPayouts (12 PAID tasks) ~40,275 ops/sec (24.83 µs/op)
unreconciledPayouts (12 PAID tasks) ~41,893 ops/sec (23.87 µs/op)
NDJSON -> fromHistory -> unreconciledPayouts (12 PAID tasks) ~329.7 ops/sec (3032.73 µs/op)
totalPaidOut (12 PAID tasks) ~41,211 ops/sec (24.27 µs/op)
totalQuoted (10 mixed tasks) ~64,761 ops/sec (15.44 µs/op)
summarizeTaskSettlements (10 mixed tasks) ~31,309 ops/sec (31.94 µs/op)
diffTaskSettlementsSummaries (10-task summaries, 4 fields moved) ~1,610,031 ops/sec (0.62 µs/op)
renderTaskSettlements (10-task summary, 9-line text) ~621,843 ops/sec (1.61 µs/op)
deliverPayoutWebhookToMany (4 endpoints) ~7,803 ops/sec (128.15 µs/op)
deliverPayoutWebhookToMany x PayoutEventDedupe fan-out receiver flow (4 endpoints) ~4,029 ops/sec (248.17 µs/op)
deliverPayoutWebhookToMany x resubmit payoutRef x PayoutEventDedupe fan-out receiver flow (4 endpoints) ~3,103 ops/sec (322.22 µs/op)
deliverPayoutWebhookToMany x secrets rotation x PayoutEventDedupe fan-out receiver flow (4 endpoints) ~3,366 ops/sec (297.07 µs/op)
deliverPayoutWebhookToMany x PayoutEventDedupe x jitterMs fan-out receiver flow (4 endpoints) ~3,480 ops/sec (287.39 µs/op)
deliverPayoutWebhookToMany limits (maxEndpoints / overallDeadlineMs) ~19.8 ops/sec (50505.16 µs/op)
deliverPayoutWebhookToMany limits x PayoutEventDedupe fan-out receiver flow (4 endpoints) ~2,042 ops/sec (489.60 µs/op)
deliverPayoutWebhook (single endpoint) ~97,554 ops/sec (10.25 µs/op)
deliverPayoutWebhook maxRetryDelayMs cap ~47,492 ops/sec (21.06 µs/op)
deliverPayoutWebhook jitterMs backoff ~23,378 ops/sec (42.78 µs/op)
deliverPayoutWebhookToMany jitterMs fan-out (4 endpoints) ~2,847 ops/sec (351.20 µs/op)
deliverPayoutWebhookToMany maxRetryDelayMs fan-out (4 endpoints) ~7,459 ops/sec (134.07 µs/op)
deliverPayoutWebhook AbortSignal pre-abort ~1,544,500 ops/sec (0.65 µs/op)
deliverPayoutWebhook timeoutMs timeout ~97.7 ops/sec (10239.35 µs/op)
deliverPayoutWebhook delivery headers (loopback, 500 then 200) ~1,351 ops/sec (740.21 µs/op)
PayoutEventDedupe checkAndRecord (hit, 1,000 recorded ids) ~3,187,136 ops/sec (0.31 µs/op)
PayoutEventDedupe.restore (1,000-entry snapshot) ~4,059 ops/sec (246.39 µs/op)
PayoutEventDedupe.exportSnapshot (5 live entries, 1 expired) ~1,139,730 ops/sec (0.88 µs/op)
PayoutEventDedupe.clear (1,000 recorded ids, fresh fixture) ~7,026 ops/sec (142.33 µs/op)
PayoutEventDedupe.stats (5 live entries, 1 expired) ~4,332,887 ops/sec (0.23 µs/op)
PayoutEventDedupe checkAndRecord (miss/evict, 250 new ids, capacity 100) ~5,127 ops/sec (195.03 µs/op)
PayoutWebhookOutbox recordAttempt (failure, fresh pair) ~1,201,500 ops/sec (0.83 µs/op)
PayoutWebhookOutbox recordAttempt (dead-letter boundary, 3 failures, 1 pair) ~966,788 ops/sec (1.03 µs/op)
PayoutWebhookOutbox pending/deadLetter (700 pending + 300 dead-lettered) ~22,600 ops/sec (44.25 µs/op)
PayoutWebhookOutbox acknowledge (pending pair, 2 failures) ~1,287,068 ops/sec (0.78 µs/op)
PayoutWebhookOutbox exportSnapshot (1,000-pair ledger) ~17,322 ops/sec (57.73 µs/op)
PayoutWebhookOutbox restore (1,000-pair snapshot, JSON round-trip) ~3,948 ops/sec (253.29 µs/op)
PayoutEventDedupe exportSignedSnapshot (1,000-entry snapshot) ~1,693 ops/sec (590.68 µs/op)
PayoutEventDedupe restoreSignedSnapshot (1,000-entry snapshot, JSON round-trip) ~817.8 ops/sec (1222.83 µs/op)
TaskLifecycle exportSignedSnapshot (8-entry history) ~26,150 ops/sec (38.24 µs/op)
TaskLifecycle restoreSignedSnapshot (8-entry history, JSON round-trip) ~3,910 ops/sec (255.77 µs/op)
PayoutWebhookOutbox exportSignedSnapshot (1,000-pair ledger) ~352.1 ops/sec (2840.22 µs/op)
PayoutWebhookOutbox restoreSignedSnapshot (1,000-pair snapshot, JSON round-trip) ~228.9 ops/sec (4369.50 µs/op)
buildPayoutWebhooks (batch of 12 PAID tasks) ~7,583 ops/sec (131.87 µs/op)
expiredTasks (12 mixed-state tasks) ~369,292 ops/sec (2.71 µs/op)
staleTasks (12 mixed-state tasks) ~112,334 ops/sec (8.90 µs/op)
expireOverdueTasks (12 mixed-state tasks, fresh fixture) ~2,150 ops/sec (465.06 µs/op)
actOnStaleTasks (12 mixed-state tasks, fresh fixture) ~1,201 ops/sec (832.94 µs/op)
actOnStaleTasks notify-only (12 mixed-state tasks, fresh fixture) ~3,494 ops/sec (286.23 µs/op)
actOnStaleTasks x subscribe (12 mixed-state tasks, fresh fixture) ~1,167 ops/sec (856.61 µs/op)
expireOverdueTasks x subscribe (12 mixed-state tasks, fresh fixture) ~2,212 ops/sec (452.17 µs/op)
parsePayoutRetryAfter (11-value corpus) ~496,524 ops/sec (2.01 µs/op)
payoutMismatch (5-task set) ~127,027 ops/sec (7.87 µs/op)
allowedEvents (13 states) ~951,051 ops/sec (1.05 µs/op)
parseHistory (8-entry history) ~27,232 ops/sec (36.72 µs/op)
isTerminal (13 states) ~1,798,135 ops/sec (0.56 µs/op)
transitionTableJson (19 edges) ~67,081 ops/sec (14.91 µs/op)
transitionTable (19 edges) ~74,721 ops/sec (13.38 µs/op)
stateDiagram (19 edges) ~89,406 ops/sec (11.18 µs/op)
readmeDiagramInSync (README file read) ~1,254 ops/sec (797.56 µs/op)
isOverdue (4-task corpus) ~529,134 ops/sec (1.89 µs/op)
subscribe (full lifecycle, 1 collecting listener) ~14,588 ops/sec (68.55 µs/op)
subscribe onError (full lifecycle, 1 throwing listener) ~579.6 ops/sec (1725.35 µs/op)
toJSON + fromJSON snapshot round-trip (8 entries) ~7,028 ops/sec (142.29 µs/op)
toJSON + fromJSON policy snapshot round-trip (10 entries, rolePolicy + budgets) ~7,234 ops/sec (138.24 µs/op)
getSlaDeadline (13 states, 2 set) ~1,322,124 ops/sec (0.76 µs/op)
MAX_NOTE_LENGTH note guard (2-case corpus) ~3,345 ops/sec (298.92 µs/op)
setSlaDeadline + clearSlaDeadline (OPEN task write cycle) ~234,627 ops/sec (4.26 µs/op)
benchTransition (single step, snapshot restore) ~82,719 ops/sec (12.09 µs/op)
history getter (defensive copy, 64 entries) ~18,861 ops/sec (53.02 µs/op)
tasksApproachingDeadline (6 mixed tasks, 24h window) ~595,408 ops/sec (1.68 µs/op)
nextSlaDeadline (8 mixed tasks, earliest future deadline) ~415,245 ops/sec (2.41 µs/op)
tasksByState (12 mixed-state tasks, OPEN slice) ~1,198,347 ops/sec (0.83 µs/op)
tasksByActor (6 mixed-actor tasks, moderator slice) ~319,621 ops/sec (3.13 µs/op)
tasksByEvent (6 mixed tasks, RESUBMIT slice) ~132,598 ops/sec (7.54 µs/op)
tasksByFilters (6 mixed tasks, reviewer + IN_REVIEW + REJECT slice) ~103,040 ops/sec (9.70 µs/op)
reconcilePayouts (5 PAID tasks, 4 ledger rows, five groups) ~62,189 ops/sec (16.08 µs/op)
diffTaskSnapshots (25-entry history, 3 appended, payout + SLA change) ~5,610 ops/sec (178.24 µs/op)
payoutAging (10-task batch, four buckets + 7d/30d boundaries) ~30,947 ops/sec (32.31 µs/op)
tasksByPayoutRef (6-task batch, 2 hits under one ref) ~82,091 ops/sec (12.18 µs/op)
formatMinorUnits (12 fixed vectors, pinned strings) ~569,574 ops/sec (1.76 µs/op)
parseMinorUnits (10 fixed vectors, pinned minor units) ~296,409 ops/sec (3.37 µs/op)
toMinorUnits/fromMinorUnits (4-vector conversion + 0.333 rounding) ~2,848,838 ops/sec (0.35 µs/op)
nextTaskDeadline (8 mixed tasks, earliest whole-map deadline) ~163,643 ops/sec (6.11 µs/op)
budget counts (resubmit/dispute/visit, 12-entry task) ~2,270,038 ops/sec (0.44 µs/op)
Environment: Node v24.22.1, linux/x64, AMD EPYC 9D25 (virtualized;
shared host under load, so numbers vary run to run — several paths
swing widely between runs on this host, so treat each figure as an
order of magnitude, not a precise rate). Machine-local
measurements for capacity planning, not guaranteed throughput —
run npm run bench on your own hardware.

License

MIT

Changes are recorded in CHANGELOG.md.

About

Task lifecycle state machine for a two-sided data marketplace (abandonment, expiration, dispute resolution) — reproduced from the DataQuest portfolio case study

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages