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.
Node.js ≥ 20.
npm install
npm run build
npm test # 1610 tests, all localPrefer 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.
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); // PAIDThe 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
transition(state, event)is a pure function; invalid transitions throw.TaskLifecyclewraps it with an append-only history: each entry has (seq, event, from → to, ISO timestamp), optional (actor, note, payoutRef, payoutAmount), and aprevHash/hashSHA-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.jsonis the committed snapshot (test/transitionSnapshot.test.tsfails if the table ever changes without regenerating it).
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 belowSuccessful 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
}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 gapsTaskLifecycle.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.
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 reorderingHonest 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 secretRewriting 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.
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 gapsThe 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.
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.
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.
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.5payoutAmount 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 amountsHonest 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 quotepayoutMismatch() 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 gapIt 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 summaryThe 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.
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 orderEach 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 > maxAgeMsThe 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 > maxFutureSkewMsIt 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 secretAny 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 listA 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.
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; // => 1Keys 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.
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 2The 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.
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 usedmaxResubmits 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.
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 usedmaxDisputes 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.
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 timesA 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.
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.
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() 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.
-
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
PAIDmean money actually moved? No.PAIDmeans a caller dispatchedPAYOUT_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 flagPAIDtasks whosePAYOUT_COMPLETEentry carries nopayoutRefor nopayoutAmount. -
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
EXPIREevent, so it lands in the append-only history. The intended wiring is a watchdog (cron, queue consumer) that pollsisOverdue()/expiredTasks()and dispatchesEXPIREitself —expireOverdueTasks()is that executor; see "SLA deadlines". -
Is the payout webhook a payment? No.
buildPayoutWebhookderives a compact HMAC-SHA256-signed notification from aPAIDtask'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 oneventId— 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 viafromHistory'sslaDeadlinesoption). -
Does event-level RBAC authenticate who is calling? No.
rolePolicyis 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 therequireActoropt-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.
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 sharedeventId. ThePayoutWebhookOutboxfailure 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-inretryPayoutWebhookOutboxrunner 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/verifyPayoutWebhookderive and HMAC-sign aPAYOUT_COMPLETEnotification 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.historyreturns 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/hashon every entry, genesis links to"GENESIS"), whichparseHistory()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.
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.
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. |
MIT
Changes are recorded in CHANGELOG.md.