ADR-0005: One effect model — guarantees, the dedupe record and the commit pipeline¶
- Status: Accepted (amended 2026-10-24: durable HTTP guard, ambiguous
COMMIT, guarantee windows, at-most-once wording — after the best-practice
review
docs/design/effect-model-best-practices-2026-10-24.md) - Date: 2026-10-24
- Deciders: Sandloom maintainers
- Supersedes: — (consolidates the guarantee prose scattered over
AGENTS.md,docs/SERVICE_RPC.mdand the module docstrings ofsandloom.core.unit_of_work,sandloom.core.idempotency,sandloom.core.tasks.enqueue)
Context¶
Sandloom's write-side guarantees grew one mechanism at a time: the transactional
outbox and its relay, the durable consumer inbox, the cache consumer inbox,
the durable command log of command contracts (and its cache fallback), the
HTTP idempotency_guard, the task-queue enqueue dedupe, on_commit,
before_commit and revocation_at_commit. Each is sound on its own, but the
model they implement existed only as prose spread over AGENTS.md, and
several of them re-implemented the same idea:
durable_inbox(consumer_inboxtable) and the contract command log (contract_command_logtable) are the same primitive — "claim a key withINSERT … ON CONFLICTin the effect's own transaction" — written twice, with two prebuilt-statement caches, two purge paths and two fallbacks for other dialects.- The bounds of every replay record (256 KiB of replayed body, 24 h of
lifetime) were copied into four modules until
sandloom.core.config.replay_limitsunified them; command dedupe ran for a while in two namespaces (local and RPC) that disagreed; a publish path diverged from the outbox path. - The post-commit work (outbox dispatch,
on_commit) was reached from four call sites, each with its own ordering.
An external review (GLM, 2026-10) argued that the framework's guarantees are an accumulation of mechanisms rather than one model. For the dedupe / effect layer that is right, and this ADR fixes it. It is not right for the security state (revocation and block marks) or for leases: those are not effects of a business operation and keep their own models (see "Out of scope").
Decision¶
1. Vocabulary¶
Effect — a state change a caller asked for and must be able to rely on: a row written, an event made deliverable, a command executed in another module, a task handed to the broker, a response the client acts on.
Boundary — the place the effect crosses; it decides which guarantee is even possible:
| Boundary | Meaning |
|---|---|
| in-UoW | the effect lives in the same database transaction as the business change (one COMMIT) |
| cross-database | another database of the same process — a second UoW, no shared COMMIT (no distributed transaction, ever) |
| cross-service RPC | a command contract in another module / process; it runs in the provider's own UoW |
| HTTP client | the client of an endpoint retries over an unreliable network |
| event consumer | an event delivered (at least once) to a consumer that applies it in its own UoW |
| post-commit | work that can only start once the UoW committed (broker publish, cache write, notification) |
Guarantee levels (strongest first):
| Level | Promise | How it is obtained |
|---|---|---|
| transactional | the effect commits iff the business change commits | written in the same transaction |
| exactly-once-effect | a retried / redelivered operation applies its effect on the state of the database holding the record once (while the record lives) | a durable dedupe record claimed in the effect's own transaction (§2) |
| at-least-once | the effect eventually happens, possibly more than once | a durable intent (outbox row) plus a retrying delivery |
| replay-only | response deduplication: a duplicate request gets the first response back instead of re-running — while the record lives; the effect itself stays at-least-once | a cache dedupe record (§2, cache form) |
| at-most-once | attempted once, never repeated, may be lost (crash, outage); failure is logged / counted, never raised | explicit opt-in (required=False + the # sandloom: best-effort <reason> marker), or post-commit callbacks |
Scope of exactly-once-effect (the end-to-end argument): the durable
record lives in ONE database and commits with the changes of THAT
transaction. Everything else a handler does — a PSP call, an email, a write
to another database, a command contract in another service — is outside the
record and stays at-least-once (it may run again when the transaction
that would have recorded it rolls back and is retried). Key such calls on the
operation's own id at the receiving system (the PSP idempotency key, the
command's idempotency_key), so the receiver dedupes them.
A mechanism never upgrades a guarantee: a replay-only record in front of an at-least-once effect is still at-least-once for the effect. Money, orders and provisioning need exactly-once-effect, i.e. a durable dedupe record.
Ordering is a separate dimension, and no mechanism here provides it: the
outbox, the relay and every consumer form give no order across events,
not even within one channel or one aggregate (concurrent UoWs, relay
redeliveries and retries interleave). Consumers order by a per-aggregate
version in the payload and apply with version guards (monotonic_upsert /
monotonic_update, sandloom.core.projections). This is a deliberate choice over
keyed / partitioned outboxes: a version guard also survives redelivery and
replay, which a partition order does not.
Implementation detail, not a guarantee: the relay does not let a channel's
later rows overtake an earlier row of that channel that is waiting to retry
(head-of-line blocking per channel; other channels are not held). Consumers
must still never rely on order — concurrent UoWs and post-commit dispatch
reorder events anyway. Operational consequence: one poison row delays its
whole channel until it is dead-lettered (OUTBOX_RELAY_MAX_ATTEMPTS; watch
outbox_dead_total and the outbox age metrics, sandloom outbox list-dead).
2. The dedupe record — one primitive¶
Every "this already ran" store in Sandloom is a dedupe record:
| Field | Meaning |
|---|---|
| scope | who dedupes: a consumer group, <contract>.<method> + bound tenant (+ idempotency_scope arguments), an endpoint scope + caller + tenant, a task namespace + tenant |
| key | the operation's identity within the scope: envelope id, idempotency / operation key, Idempotency-Key header |
| args digest | canonical digest of the operation's inputs; the same key with another digest is a conflict (client misuse → 409/422), never a replay |
| status | pending (claimed, in flight) → succeeded (outcome replayable) / not_replayable (outcome larger than the cap: never re-run, body not kept) / processed (no outcome to replay — an inbox claim) |
| result | the encoded outcome kept for replay, at most REPLAY_BODY_MAX_BYTES (256 KiB) |
| TTL | how long a duplicate is recognised; REPLAY_RECORD_TTL_SECONDS (24 h) by default; the consumer inbox has no TTL (purged by age) |
Its operations are the same everywhere: claim (atomically create the record or learn it exists), complete (store status + result), replay (return the stored outcome), conflict (same key, other digest), expire / purge (forget it after the TTL).
The shared semantics live in sandloom.core.effects: the guarantee levels,
the record statuses, the bounds (re-exported from sandloom.core.config.replay_limits),
the key digest, the argument digest, the replay-size decision and the
paradigm-agnostic description of a durable record table
(DedupeRecordSpec). It has two forms:
- Durable form (exactly-once-effect) —
sandloom.core.sql_alchemy.dedupe_logimplements claim / complete / replay / purge ONCE for the relational paradigm: one prebuiltINSERT … ON CONFLICT … RETURNINGclaim in the caller's UoW with autoflush disabled (only this record's conflict reads as a duplicate), an expired record taken over byON CONFLICT DO UPDATE … WHERE expires_at <= now(never a running one — its claim is uncommitted), the completionUPDATEthat restarts the TTL at completion, arelease(DELETEof the claim inside the same transaction), a bounded purge that re-checks its cutoff on the outerDELETE. Three adapters map it onto their tables (one table per retention rule, never merged: an inbox claim must outlive every redelivery of its event, a log record the caller's retry horizon): durable_inbox(consumer)→consumer_inbox, scope = consumer, key = envelope id, statusprocessed, no digest / result, age-purged;- the contract command log (
DatabaseProvider.command_log()) →contract_command_log, key = digest of (contract.method, tenant + scope arguments, idempotency key), digest + status + result + TTL; - the durable HTTP idempotency log (
DatabaseProvider.request_log(),idempotency_guard(..., durable=True)) →http_idempotency_log(core migration0009_http_idempotency_log), key = digest of (route scope, caller, tenant,Idempotency-Key), args digest = the request fingerprint, status + stored response + TTL (the guard'sttl_seconds, 24 h). Claimed in the request'sDbSessionUnit of Work after a transaction-scoped advisory try-lock (an in-flight duplicate gets409at once, never a wait on the first request's uncommitted claim); completed in stage 1 of the commit pipeline (§3) with the response the endpoint built (status, headers, body ≤ 256 KiB; larger, streamed or anonymous → status only). A returned5xxis released, never replayed. - Cache form (replay-only, or fail-closed enqueue dedupe) — the HTTP
idempotency_guard(withoutdurable=True), the cache fallback of command contracts, the legacy RPC dedupe, the cache consumer inbox and the task-queue enqueue dedupe keep their own owner-token / CAS state machines (they differ for good reasons: an HTTP request can die without a terminal write, a broker send can be ambiguous), but take their TTL, size cap, key tags and (for wire-valued inputs: command arguments, task payloads) argument digests fromsandloom.core.effects, so a key, a digest or a bound means the same thing in every store. The HTTP guard keeps its request fingerprint (method, path, query, caller, tenant, body) as its args digest.
3. The commit pipeline — one stage list¶
Every Unit of Work ends through the same documented pipeline
(sandloom.core.unit_of_work.COMMIT_PIPELINE):
- before_commit — the queued
before_commitchecks (skipping those of a rolled-back savepoint) and the request's captured commit checks (revocation_at_commit), each under its registration-time tenant. The first failure rolls the UoW back and propagates. The durable HTTP guard completes its record here (the endpoint's response is already built: FastAPI serialises it inside the function-scoped dependency stack whose exit commits). - COMMIT — owned by the
UnitOfWorkContract; nothing else commits. Failure mode — ambiguous COMMIT (connection lost while committing): the outcome is unknown to the process; stages 3–4 are skipped and the caller sees an error, but the transaction may be durable. See §4a. - outbox dispatch — the committed outbox rows are handed to the bus.
On the
wait=Falsepaths (requests, consumers, contract UoWs) this is a hand-off to the per-loop background coalescer, not a publish: stage 4 may run before the event reaches the broker, so anon_commitcallback must not assume the event is on the bus. Withwait=True(db_session(), Celery, scripts) it publishes in line. A failure is logged, counted (outbox_post_commit_dispatch_failures_total) and left to the relay — it never fails the committed operation. - on_commit — the queued callbacks in registration order, each under the tenant bound when it was queued, skipping those of a rolled-back savepoint. A failing callback is logged, never raised.
Stage 1 runs inside the UnitOfWorkContract (it must precede its
COMMIT); stages 3–4 run in ONE function,
sandloom.core.unit_of_work._dispatch_outbox_after_commit, called by every commit
path: open_uow (request get_db, Database(name), db_session(),
consumer_db_session()), the contract runtime's own UoW, and the test seams
that stand in for a commit (the get_db override path and
sandloom.core.testing.run_on_commit, which pass dispatch=False because the
test owns the transaction and its outbox rows never commit). The contract
runtime's cache-fallback command dedupe records its outcome between stages 2
and 3 (the durable form records it inside the transaction).
tests/architecture/test_commit_pipeline.py pins that no other module
reaches the individual stages and that only the UoW contract awaits a
COMMIT.
4. Mechanism matrix¶
| Mechanism | Boundary | Guarantee | Record / state | Failure modes |
|---|---|---|---|---|
publish_event (in a UoW) + outbox + relay |
in-UoW → post-commit → event consumer | transactional intent, at-least-once delivery | outbox row in the UoW's database | broker down → relay retries with backoff + circuit; a row exhausting OUTBOX_RELAY_MAX_ATTEMPTS is dead-lettered (dead_at, outbox_dead_total, sandloom outbox requeue); duplicates possible (post-commit dispatch vs relay race) — the stable envelope id dedupes; no ordering guarantee; with the relay off (OUTBOX_RELAY_ENABLED=off, refused in prod unless the relay_disabled risk is accepted, §6a) a failed post-commit dispatch is never re-sent: at-most-once |
publish_event(required=False) outside a UoW |
post-commit | at-most-once | none | lost on a bus error, never repeated; needs # sandloom: best-effort <reason> in module code |
durable consumer (register_event_consumer, durable=True) + durable_inbox |
event consumer | exactly-once-effect (handler database) | durable dedupe record (consumer_inbox) claimed in the handler's UoW |
handler error → rollback + redelivery (dead-letter after the bus's limit unless dead_letter=False); a claim older than the inbox purge cutoff no longer dedupes a very late redelivery — sandloom outbox purge --inbox-days refuses a cutoff inside EVENT_STREAM_RETENTION_SECONDS + 1 day (§6a); a requeue / dlq-replay of an event older than the purge cutoff applies it again; streams trimmed after EVENT_STREAM_RETENTION_SECONDS |
cache consumer inbox (ConsumerInbox, durable=False consumers) |
event consumer | replay-only (once per TTL, fail-open) → effect is at-least-once | cache record, TTL 24 h | cache outage → no dedupe; mark lost after the effect → re-run |
| contract command dedupe (durable command log) | cross-service RPC / local call | exactly-once-effect (provider database) | durable dedupe record (contract_command_log) in the provider's UoW |
concurrent duplicate waits ≤ CONTRACT_DB_LOCK_TIMEOUT_MS → 503 ContractCallInProgress; same key + other args → 409; result > 256 KiB → 409 ContractResultNotReplayable (never re-run); record expires after CONTRACT_COMMAND_DEDUPE_TTL_SECONDS (the key runs afresh — checked against the relay's redelivery horizon, §6a); NOT atomic with the caller's transaction (saga step) |
contract command dedupe (cache fallback; backend without command_idempotency) |
cross-service RPC / local call | replay-only, fail-closed | cache dedupe record | crash between commit and the cache write → a retry re-runs; cache outage → 503 |
| legacy RPC dedupe (session-taking methods, deprecated) | cross-service RPC | replay-only, fail-closed | cache dedupe record | as above; removed with the legacy methods |
idempotency_guard(durable=True) |
HTTP client | exactly-once-effect on the request database + replay of the stored response | durable dedupe record (http_idempotency_log) claimed in the request's DbSession UoW, completed in stage 1 |
in flight → 409 at once (advisory try-lock); same key + other request → 422; rollback / raised error / failing before-commit check → no record (retry runs afresh); returned 5xx → released; returned 4xx → replayed (the committed outcome); body > 256 KiB / streamed / anonymous → status-only replay; ambiguous COMMIT → replay or afresh, never twice (§4a); record expires after the guard's TTL (24 h); effects outside the request database stay at-least-once. Required on an operation_class("F") route that stores responses (startup check, §6a) |
idempotency_guard (cache form) |
HTTP client | replay-only (in-flight 409, replay of the stored response, 422 on reuse); fail_closed=True refuses on outage |
cache dedupe record with owner token; pending TTL 5 min (renewed while the request runs), terminal TTL 24 h | NOT an effect guarantee: a committed effect answered >= 400 is recorded failed and a retry re-runs; crash or ambiguous COMMIT before the terminal write → re-run; fail-open by default; refused on F routes unless store_response=False + fail_closed=True (in-flight dedupe in front of an endpoint-owned durable record) |
task-queue enqueue dedupe (enqueue(..., idempotency_key=)) |
post-commit / broker | replay-only for the enqueue (task id replayed), fail-closed; the task effect is at-least-once | cache dedupe record; pending never expires (ambiguous send), sent lives dedupe_ttl_seconds |
cache outage → 503; ambiguous publish → 503 task_enqueue_uncertain until reconciled; broker redelivery not deduped (the task needs its own durable record) |
on_commit |
post-commit | at-most-once, ordered after the commit | session queue | never runs on rollback, never twice; a failing callback is logged; a process crash or an ambiguous COMMIT loses it — make it retryable or move it behind an event; may run before the committed events reach the broker (stage 3 is a hand-off) |
before_commit |
in-UoW (pre-commit) | check, not an effect: failure aborts the UoW | session queue | narrows a race to final-read → COMMIT; does not close it |
revocation_at_commit |
in-UoW (pre-commit), authority in another service | check (serializable, not linearizable — ADR-0003) | before-commit check | revocation store fail-open (dev) accepts during an outage |
record_audit (required=True) |
in-UoW | transactional | audit row in the UoW | outside a UoW → AuditUnavailableError; required=False is at-most-once and needs the marker |
revoke_user_tokens_on_commit |
post-commit (+ pre-commit write with require_immediate) |
security state, see "Out of scope" | revocation mark + outbox event | — |
saga runtime (start_saga, step protocol — ADR-0008) |
in-UoW (start, local steps, transitions) → cross-service RPC (command steps) | start and transitions transactional with the owner's change; command steps exactly-once-effect at the provider (command log, derived key saga:<type>:<id>:<step>:do\|undo); local steps transactional; progress at-least-once (outbox nudge + lease-guarded timer sweep) |
instance row (saga_instances, version-guarded state — not a dedupe record) + the existing outbox and command log; no consumer inbox (nudges are versioned, the row lock is the guard) |
see ADR-0008 §6/§7; a retry or compensation later than CONTRACT_COMMAND_DEDUPE_TTL_SECONDS after the step's first attempt would run it afresh — refused by the saga_horizon_beyond_command_dedupe window and, for operator retries, by sandloom sagas retry's post-TTL rule |
4a. Ambiguous COMMIT¶
The connection can fail while COMMIT is in flight (network, failover,
a killed backend): the process cannot tell whether the transaction is
durable. The request answers 5xx, stages 3–4 never run, and the client
retries. What each mechanism does with that retry:
| Mechanism | If the commit happened | If it did not |
|---|---|---|
idempotency_guard(durable=True) |
the record committed with the effect → the retry replays the stored response | no record, no effect → the retry runs afresh |
idempotency_guard (cache form) |
the terminal record was never written; the pending reservation is released (5xx) → the retry re-runs a committed effect | the retry runs afresh |
outbox rows (publish_event) |
durable → the relay delivers them (stage 3 was skipped) | none → nothing to deliver |
durable_inbox / durable consumer |
claim + effect committed → the redelivery is a duplicate, skipped | redelivery claims afresh |
| command log (durable) | the caller's retry replays the stored result | runs afresh |
on_commit callbacks |
lost (at-most-once) | never due |
| task-queue enqueue dedupe | unaffected (the enqueue is not part of the transaction) | — |
The durable records are the only forms that turn an unknown outcome into a known one; that is why money routes need the durable HTTP guard (or an endpoint-owned durable record, §6a).
5. Out of scope¶
Not effects of a business operation, so not dedupe records and not governed by this ADR (they keep their own documented models):
- Revocation and block marks (
sandloom:auth:*) — monotonic security state (set_max), seeAGENTS.mdanddocs/reference/security-model.md. Their delivery reuses the outbox (core.auth.tokens_revoked.v1). - Leases and ownership — the beat singleton lease, Streams consumer leases and consumer-group ownership fingerprints: concurrency control with fencing, not "already ran".
- One-time security state — SSO state / 2FA challenge consumption, the
RPC nonce replay guard (
RPC_REPLAY_GUARD) — and rate limits: security state with its own TTL semantics.
6. Rule for new mechanisms (complexity throttle)¶
A new way to remember "this already ran" or to order work around a commit is a change to this model, not a local implementation detail:
- Reuse first. An effect needing dedupe uses an existing form: the
durable dedupe record (
durable_inbox, a command contract), aUNIQUEoperation-key column on the effect's own row, the durable HTTP guard (idempotency_guard(..., durable=True)), or — for replay only — the cacheidempotency_guard/ task enqueue dedupe. Post-commit work useson_commitor an event; pre-commit checks usebefore_commit. - A new mechanism needs an ADR amendment: its row in the matrix (§4)
— boundary, guarantee level, record, failure modes — and, if it stores a
dedupe record, an adapter over
sandloom.core.effects(durable form) or the shared helpers (cache form). - Enforced in review, not by a linter. Whether a claim or a
SET NXis a new dedupe mechanism, a create-or-get, aUNIQUEoperation key on the effect's own row or out-of-scope security state is a judgement a pattern rule cannot make reliably;sandloom checkkeeps only deterministic rules. (A heuristiceffect-primitivesrule was tried and removed.)
6a. Declared requirements and guarantee windows (startup checks)¶
Requirements are declared next to the mechanism and verified at startup — deterministic and structural, never a pattern lint:
- Class-F routes (
create_app,sandloom.core.idempotency.durable.validate_financial_guards): anoperation_class("F")route whose idempotency guard is the response- storing cache form, or fail-open, refuses to start (FinancialGuardError). Accepted:durable=True; orstore_response=False+fail_closed=True(in-flight dedupe in front of a durable record the endpoint owns — billing's operation intents, whose checkout / portal URL is a bearer credential that must not be stored or replayed). An F route without a guard is not refused: its dedupe is its own (the PSP webhook by event id, the operator credit adjustment by theoperation_keyof its body, aUNIQUEledger key). - Guarantee windows (
sandloom.core.guarantee_windows.guarantee_window_problems; production refuses withGuaranteeWindowError, other environments warn; each problem carries a stable risk id an accepted-risks list can waive): command_dedupe_below_retry_horizon—CONTRACT_COMMAND_DEDUPE_TTL_SECONDS≥ the relay's redelivery horizon (OUTBOX_RELAY_MAX_ATTEMPTS× capped backoff, ~18.5 min by default). A saga retrying longer (or across a broker outage, or an operator replay) must raise the TTL itself;inbox_purge_below_redelivery—sandloom outbox purge --inbox-days DrefusesD<EVENT_STREAM_RETENTION_SECONDS+ 1 day (anyDwhen the retention is unbounded) unless--force;saga_horizon_beyond_command_dedupe(ADR-0008 §9) — for every command step of every registered saga,(step deadline, else saga deadline) + compensation_deadline≤CONTRACT_COMMAND_DEDUPE_TTL_SECONDS.
The third window of the review, OUTBOX_RELAY_ENABLED=off in a production
app process (the outbox loses its retry: at-least-once → at-most-once), is
refused through the accepted-risks list (SANDLOOM_ACCEPTED_RISKS, risk
relay_disabled), whose check knows the gunicorn relay-leader election.
Consequences¶
- One relational claim implementation instead of two: a fix to the claim
(autoflush, lock-timeout mapping, expiry takeover, purge re-check) lands
in every store at once. The consumer inbox and command log tables are
unchanged; the adapters keep their public surfaces
(
DurableConsumerInbox,CommandLogStore,durable_inbox). The durable HTTP guard adds one additive core table (http_idempotency_log) with its own retention, purged by the relay housekeeping and copied bysandloom-migrate extract-module(scope prefix<module>.). - A new dedupe store or post-commit variant is visible at review time (analyzer + ADR amendment) instead of being discovered after it drifted.
- The cache forms deliberately stay separate state machines; only their vocabulary and bounds are shared. Merging them would weaken one of them (the HTTP guard's pending self-healing vs the enqueue's never-expiring ambiguous record).
- Cost: the generic claim adds a few Python calls per claim. Cachegrind
(
CG_K=200, instr/op; base 742bc604 = mean of 6 runs, branch = 2 runs interleaved with 2 of them): HW7 7.568M -> 7.585M (+0.23 %), L6W3 6.410M -> 6.420M (+0.16 %), HEp 2.798M -> 2.811M (+0.46 %), HEc 4.908M -> 4.907M (-0.02 %); the base runs alone spread 0.46 / 0.30 / 0.61 / 1.7 %. - Cost of the durable HTTP guard (amendment). Cachegrind
CG_K=200, instr/op, base main 88d8aa1a (cache fail-closed guard on/dw/7) vs this change (durable guard), 2 interleaved runs each: HW7 (class-F write: strict revocation + guard + insert +publish_event+record_audit) 7.607M / 7.621M -> 8.153M / 8.137M, mean +0.53M (+7.0 %); control L6W3 (the same write without a guard) 6.430M / 6.420M -> 6.398M / 6.406M, flat. The durable form runs three statements inside the request transaction (advisory try-lockSELECT, claimINSERT … ON CONFLICT, completionUPDATEwith the response) where the cache form runs one RedisSET NX GETbefore and one CAS after the response. That is the price of a record that commits with the effect; non-F routes keep the cheaper cache form.