Skip to content
sluice

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

200 INTENTS20 x 10 seeds, 2-5 deliveries eachNAIVE RETRY1,031 downstream attempts666DUPLICATE CHARGESWITH SLUICE228 downstream attempts0DUPLICATE CHARGES

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.

Read the full argument

sluice's exactly-once state machine: in_flight to succeeded, failed, or indeterminate A directed diagram. One box, in_flight, has three outgoing arrows, drawn as a single stem that forks into three branches. The first branch leads to succeeded: the effect ran and its result was durably recorded, so every future caller replays that result instead of running the effect again. The second branch leads to failed: the effect provably did not happen, so it is safe to know the outcome and act on it. The third branch, drawn in the signal colour because it is the only one that matters for this argument, leads to indeterminate: sluice could not determine whether the effect happened, for example a timeout after the call was sent, or a crash after the effect ran but before its result was persisted. Unlike the other two branches, indeterminate has no automatic path back to in_flight. By default sluice fails closed: the effect is parked as indeterminate and never silently retried. Leaving that state requires an explicit reclaim call, where the caller has declared the downstream operation safe to repeat, or a human decision recorded through an approval gate. Retries happen inside in_flight, under a single lease, before any terminal state is reached — they never turn a failed or indeterminate outcome into a fresh attempt. in_flightretries happen here,under one lease succeededresult persisted;replayed to callers failedprovably did nothappen indeterminatewe do not know fails closed — never auto-retried in_flight is the only non-terminal state here; the other three are final once reached. Leaving indeterminate needs an explicit reclaim or a gate decision — never a retry.

Evidence · every number, and the command that regenerates it

golden 24/24 · fuzz 200 seeds · 0 invariant violations · git debf2ed
metricnaive retry (no sluice)with sluice
intent success rate100.0%77.0% (20.0% fail closed — parked indeterminate, never silent)
duplicate side effects6660

Baseline workload: 200 intents delivered 25× 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.

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 chaos

writes 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 real

Set in Archivo, Commit Mono and Newsreader. Colour, depth and grid from the shared portfolio substrate; signal hue 195, lighting daylight.