sluice
Exactly-once side effects for agent tool calls
An unknown outcome is not a failure, and the distance between those two words is the whole library.
Duplicate charges · identical fault schedule
chaos/results/latest.json · pnpm chaos · read 2026-09-07
The delivery state machine
200 intents · 714 deliveries · 30.0% injected fault · one schedule
Same fault schedule, two branches. The naive loop retries every failure up to three times, including timeouts — which is exactly how an unknown outcome becomes a second charge. sluice commits 77.0% of intents exactly once and parks 20.0% as indeterminate rather than re-firing them, and it reaches that with 228 downstream attempts against 1,031.
Mechanism · honest failure states
failed means the effect provably did not happen. indeterminate means sluice does not know — a timeout after the call landed, or a crash before the result was persisted. Conflating the two is the bug this library exists to prevent.
The one edge that matters is the one drawn in the signal hue below: reaching indeterminate never auto-retries. Leaving it takes an explicit reclaim or a human decision through a gate.
Evidence · every number, and the command that regenerates it
| metric | naive retry (no sluice) | with sluice |
|---|---|---|
| intent success rate | 100.0% | 77.0% (20.0% fail closed — parked indeterminate, never silent) |
| duplicate side effects | 666 | 0 |
Baseline workload: 200 intents delivered 2–5× each under 30.0% injected failure.
Retry amplification under 30.0% injected failure: 0.88× downstream attempts per intent (CI gate ≤ 1.5).
run() latency under fault injection: p50 0 ms · p99 60,000 ms — virtual clock time, not wall clock.
Run shape: 9 scenarios × 10 seeds · 740 intents · 1,014 deliveries. Regenerate with pnpm chaos — full tables in chaos/RESULTS.md.
The gate, recorded
A pending approval survives process death and resumes exactly once, from any process. Recorded against the deployed site, not a local dev server.

Your browser is set to reduce motion, so the recording is not playing automatically. Watch the recording.
What it shows: a worker opens a real approval gate for "publish the weekly digest," then gets killed two seconds later. The pending approval survives a real reload of the page — a fresh navigation, not React state — because it was never held in memory. Approving it resumes the publish through run(), which fires the side effect once; the audit trail records every step. Try it yourself at /gate.
Verify it yourself
One command regenerates every figure on this page, including the readout at the top. CI runs it on every push and fails on drift.
pnpm chaoswrites chaos/results/latest.json and chaos/RESULTS.md
Failure modes F1 through F12
Every row is asserted by a chaos scenario and a golden fixture, not just documented. Full prose for each: /docs/failure-modes.
- F1
- Duplicate delivery (2–5×, concurrent)
- Exactly one claim wins; losers replay the winner's outcome. Ledger count is 1.
- F2
- Timeout, effect landed anyway
- Classified indeterminate; fails closed by default (E_INDETERMINATE), never reported as success, never auto-retried.
- F3
- Crash after the side effect, before the result was persisted
- Lease expires; the next claim transitions it to indeterminate — never a silent re-claim.
- F4
- Crash before the side effect
- Same indeterminate transition — indistinguishable from F3 by design; 'fail' is the default for exactly this reason.
- F5
- Crash mid-gate
- The gate row is durable; resumption via claimDecided runs post-decision work through run() — exactly-once.
- F6
- Double decision / approve-reject race
- First writer wins; the second call returns the recorded decision (idempotent, not an error).
- F7
- Retry storm
- Retry budget (10%) + full-jitter backoff + circuit breaker; exhaustion is an immediate E_RETRY_BUDGET.
- F8
- Clock skew between workers
- No double execution within leaseMs / 2; beyond it, fails closed to indeterminate rather than risk a double execution.
- F9
- Key reuse with different arguments
- fingerprint mismatch throws E_KEY_CONFLICT — never a silent wrong-result replay.
- F10
- Result too large (> maxResultBytes)
- Stored as resultOmitted; the type forces every caller to handle it.
- F11
- Store unavailable
- E_STORE; indeterminate if after the claim, retryable if before. The effect function never runs without a granted claim.
- F12
- Gate timeout
- sweepTimeouts (and any read of an expired gate) resolves it to timed_out, applying onTimeout (default reject).
Limits · what this cannot do
A reliability library that hides its own edges is not one you should trust with a charge card.
TTL is a memory horizon, not a correctness knob
retentionMs (default 7 days) controls how long a terminal effect record stays around for replay and dedup — not a promise about correctness. Set it shorter than your real redelivery window and the same key executes again from scratch. This is the one way sluice can be made to shoot you in the foot.
Roughly hundreds of events per second, per namespace
sluice_event appends are serialized per namespace by a cursor row lock — the mechanism that makes the hash chain tamper-evident in a single SQL statement. Fine for approval and audit volume; not a general-purpose event bus. Above that, use more namespaces, not a bigger one.
Breaker state is eventually consistent across instances
Circuit state is read-through cached one second in process, so two instances can disagree about whether a circuit is open for up to that long. Half-open's single-probe admission is still correctly serialized under the cache; the window only affects how fast an instance notices.
The figures above are virtual-clock time, not wall clock
The harness advances simulated time to the next due timer, which is what makes a suite spanning hours of gate timeouts finish in milliseconds and run in CI on every push. Those p50/p99 numbers describe scheduling behaviour, not network or database latency. Production p99 adds real I/O on top.
And no backend, deliberately
Zero API routes, zero database, zero writes. The playground and the gate walkthrough run the real @jamessuuu/sluice core entirely client-side against an in-memory store. No unauthenticated write path exists to defend because there is no write path; the site is blackout-safe under a function pause because there are no functions to pause. What that trades away is in the full limitations page.
Not on npm
The three packages are ESM only, Node 22 or newer, and are not published. The install below clones the monorepo; there is no npm i path yet, and saying otherwise would be the first broken claim on a page about not breaking claims.
Install · reproduce · colophon
git clone https://github.com/jamessuuu/sluice
cd sluice && pnpm install
pnpm chaos # regenerates every number on this page
pnpm test:e2e # the gate walkthrough above, run for realSet in Archivo, Commit Mono and Newsreader. Colour, depth and grid from the shared portfolio substrate; signal hue 195, lighting daylight.