ADR-0059: Durable dispatch outbox¶
- Status: Accepted
- Kind: Retrospective
- Area: persistence-state
- Date: 2026-07-09
- Relations: supersedes v0-0092
Context¶
Outbound notifications cannot rely on a consumer being alive when work completes or a schedule fires. A transport process may be absent, fail, time out, or report success before the destination consumer has durably processed anything. A notification that exists only in memory disappears when the producer restarts.
This ADR answers six concrete problems in the shipped dispatch path:
P1 — Enqueue must survive producer restart. The producer needs to commit routing, payload, attempt bounds, acknowledgement policy, and expiry before any transport execution begins.
P2 — Repeated production needs an idempotency option. A missed-fire recovery or repeated heartbeat can submit the same logical notification more than once. When a producer supplies a deduplication key, it needs the previously committed identity rather than another row.
P3 — Scans must claim without holding a transport transaction. The notify command can take up to ten seconds. Keeping a database transaction open across the subprocess would extend write locks and still would not make the external effect transactional. A short durable claim plus guarded attempt counter must establish one claimant for each attempt.
P4 — Failure and crash recovery need bounded scheduling. A transport can fail repeatedly, a claimant can crash, and an expiry can pass. The row needs an inspectable lifecycle, backoff, maximum attempt count, and a recoverable delivering state.
P5 — Transport success is not consumer acknowledgement. Exit code zero proves only that the configured command reported success. Consumers that need end-to-end confirmation require an optional token and a bounded resend loop.
P6 — Operators and a surviving scheduler need direct durable control. Delivery is driven from the Studio scheduler tick, while inspection, acknowledgement, forced retry, and purge operate directly on StateDB so a daemon outage does not strand administrative action.
The guarantee is producer-driven at-least-once transport attempt, not exactly-once delivery or consumer processing. A crash after an external send and before the final status write can cause the same stored payload to be sent again after the claim lease expires.
| Concern | Decision |
|---|---|
| Stored request and payload | D1: Persist a six-state outbox row carrying a versioned DispatchSignal JSON envelope. |
| Enqueue and deduplication | D2: Insert pending row and initial transition atomically; deduplicate only when a key is supplied. |
| Claim and transport execution | D3: Claim by status-plus-attempt CAS, lease briefly, then execute argv outside the transaction. |
| Retry, expiry, and terminal outcomes | D4: Use bounded exponential backoff, maximum attempts, expiry, and dead letter. |
| Optional consumer acknowledgement | D5: Loop successful sends back to pending until token acknowledgement or a bound wins. |
| Scheduler and operator integration | D6: Scan each scheduler tick and expose direct list/show/ack/retry/purge operations. |
This ADR deliberately does not decide:
- The delivery guarantees of a configured destination. A notify command is an adapter; its exit status cannot prove destination durability.
- A built-in messaging vendor, broker, or protocol. The transport is a configured argv template.
- A separate dispatch database. The outbox participates in existing state transactions and references sessions and schedule runs.
- Universal lifecycle policy. ADR-0058 owns consolidation of the temporary transition adapter.
- Retention duration or purge-history policy. The current purge behavior is recorded, and delta 3 requires the missing decision.
Decision¶
D1 — Durable row and versioned signal payload¶
dispatch_outbox persists the delivery request independently of consumer and producer process liveness. Its status is a transport lifecycle, not a session or schedule-run status axis.
The contract.
dispatch_outbox
id TEXT PRIMARY KEY
kind TEXT NOT NULL
deliver_to TEXT NOT NULL
payload JSON NOT NULL
dedup_key TEXT NULL
status TEXT NOT NULL DEFAULT 'pending'
CHECK status IN (
'pending', 'delivering', 'delivered',
'acked', 'dead_letter', 'expired'
)
attempt INTEGER NOT NULL DEFAULT 0
max_attempts INTEGER NOT NULL DEFAULT 8
next_attempt_at FLOAT NOT NULL
ack_required INTEGER NOT NULL DEFAULT 0
ack_token TEXT NULL
session_id TEXT NULL REFERENCES sessions(id)
schedule_run_id TEXT NULL REFERENCES schedule_runs(id)
last_error TEXT NULL
created_at FLOAT NOT NULL
expires_at FLOAT NULL
updated_at FLOAT NULL
UNIQUE(dedup_key) WHERE dedup_key IS NOT NULL
INDEX(status, next_attempt_at) WHERE status IN ('pending', 'delivering')
The payload model is:
SIGNAL_SCHEMA_VERSION: int = 1
class Signal(Element):
data: Any = None
emitter_role: str | None = None
schema_version: int = SIGNAL_SCHEMA_VERSION
class DispatchSignal(Signal):
dispatch_id: str = ""
kind: str = ""
deliver_to: str = ""
attempt: int = 0
ack_token: str | None = None
body: dict = {}
Because Signal extends Element, JSON mode also includes a generated element id, created_at, and metadata with the concrete lion_class. A representative stored payload is:
{
"id": "<signal UUID>",
"created_at": 0.0,
"metadata": {"lion_class": "lionagi.session.signal.DispatchSignal"},
"data": null,
"emitter_role": null,
"schema_version": 1,
"dispatch_id": "<outbox id>",
"kind": "terminal_notify",
"deliver_to": "seat-1",
"attempt": 0,
"ack_token": null,
"body": {}
}
The timestamp is generated at enqueue in real payloads; 0.0 above is a shape placeholder, not a default.
Code anchors: lionagi/state/schema_meta.py; lionagi/session/signal.py; lionagi/dispatch/outbox.py.
Exact semantics.
- Status is constrained by the database to the six listed values.
ack_requiredis stored as integer 0/1; the current table has no explicit CHECK on that column.attemptandmax_attemptshave defaults but no non-negative database CHECK.expires_at=NULLmeans no time expiry; maximum attempts still bounds delivery.session_idandschedule_run_idare optional provenance links. An ad-hoc dispatch may have neither.payloadis produced once at enqueue and is not rewritten when the row'sattemptincrements. The serializedDispatchSignal.attempttherefore remains0on every retry in the current implementation; current attempt truth is the outbox column.schema_version=1changes only on breaking signal field removal or rename; adding nullable fields is treated as non-breaking by the signal module's policy.- A stored payload and route survive process restart as database state. No delivery occurs unless a producer process later scans the row.
Why this way. The row contains enough state to resume attempts without the original producer. One stable signal envelope prevents the transport template from changing for each dispatch kind. Keeping row attempt separate makes claim CAS cheap, though the stale payload attempt is a current contract limitation maintainers must not overlook.
D2 — Atomic enqueue with optional deduplication¶
enqueue_dispatch() inserts the pending row and its initial reason-bearing transition in one transaction. A non-null deduplication key is unique, and re-enqueue returns the existing row id.
The contract.
DEFAULT_MAX_ATTEMPTS = 8
async def enqueue_dispatch(
db: Any,
*,
kind: str,
deliver_to: str,
body: dict | None = None,
dedup_key: str | None = None,
ack_required: bool = False,
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
expires_at: float | None = None,
session_id: str | None = None,
schedule_run_id: str | None = None,
) -> str: ...
The initial transition is:
entity_type: dispatch
entity_id: <dispatch id>
previous_status: NULL
status: pending
reason_code: dispatch.pending.enqueued
reason_summary: "enqueued kind=<kind>"
source: system
actor: enqueue_dispatch
evidence_refs: []
metadata: {}
Code anchors: lionagi/dispatch/outbox.py; lionagi/state/reasons.py; lionagi/state/schema_meta.py.
Exact semantics.
- The dispatch id is a full 32-hex-character UUID value. The optional ack token uses the same hex representation and is generated only when
ack_required=True. body=Noneand an empty body both serialize as{}.- The signal is serialized with
to_dict(mode="json")before the transaction. - With
dedup_key=None, every call creates a new row even when all other arguments match. - With a key, the transaction first selects an existing id. A hit returns immediately and writes no new row or initial transition.
- A dedup hit does not compare or update kind, route, body, acknowledgement policy, attempt bound, expiry, or provenance. The first committed row wins the meaning of that key.
- A miss inserts row and initial transition in one transaction; either both commit or neither does.
- The partial unique index is the final authority for concurrent same-key inserts. The function has no explicit integrity-error-to-existing-id retry path if separate PostgreSQL transactions race after both miss the preliminary select.
- The function does not reject empty
kindordeliver_tostrings and does not validate thatmax_attemptsis positive. Non-null database constraints reject onlyNone/SQL null. - Default
max_attempts=8is inherited. The repository records the boundedness purpose but no workload-derived rationale for exactly eight sends.
Why this way. Enqueue must be durable before execution, and the initial history row makes the creation reason auditable. Optional deduplication supports producers with a stable logical identity without forcing unrelated notifications with identical payloads to collapse.
D3 — Guarded claim, leased recovery, argv-safe execution¶
Each due row is claimed with a guarded status-and-attempt compare-and-set and a short lease. The configured process executes after that transaction commits.
The contract.
NOTIFY_TIMEOUT_SECONDS = 10.0
_CLAIM_LEASE_SECONDS = NOTIFY_TIMEOUT_SECONDS + 5.0 # 15 seconds
def resolve_notify_template(
project_dir: str | Path | None = None,
) -> list[str] | None: ...
async def deliver_due_dispatches(
db: Any,
*,
now: float | None = None,
notify_template: list[str] | None = None,
) -> dict[str, int]: ...
Settings shape:
dispatch:
notify_template:
- command
- "{deliver_to}"
- "{payload}"
Claim writes atomically:
from status: pending or delivering
to status: delivering
guard: attempt == value read by scan
patch: attempt = old + 1
next_attempt_at = scan_now + 15 seconds
reason: dispatch.delivering.attempt
Code anchors: lionagi/dispatch/outbox.py; lionagi/state/transitions.py.
Exact semantics.
- A scan selects rows whose status is
pendingordeliveringand whosenext_attempt_at <= now. There is no row limit or explicit ordering in the current query. counts["attempted"]increments for every selected row before expiry or claim CAS. Two overlapping scans can both count a row as attempted even though only one executes the transport.- Expiry is checked before claim. An expired due row attempts a guarded transition from its scanned status to
expiredand never executes transport in that scan. - Claiming a stale
deliveringrow is a same-status recovery transition. The attempt guard and atomic increment ensure two overlapping claimants cannot both win the same attempt. - A claim conflict skips the row without raising and without transport execution.
- Missing or invalid
dispatch.notify_templateresolves toNoneand is handled as a transport failure, not a scheduler crash. - Template configuration must be a non-empty list of strings. Otherwise it is treated as absent.
{payload}and{deliver_to}are substituted only when an entire argv element exactly equals the token. Partial-string interpolation is not performed.- Execution uses
asyncio.create_subprocess_exec, never a shell. Shell metacharacters in route or payload are inert argv/stdin data. - If
{payload}is absent, JSON payload is sent on stdin. If{deliver_to}is absent, the route is not supplied by any fallback mechanism. - Stdout and stderr are captured. Exit code zero is success. A non-zero result uses stripped stderr or
exit <code>and truncates the stored error to 2,000 characters; no rationale is recorded for that exact cap. - A ten-second timeout kills the subprocess and reports a timeout error. The timeout value is inherited without a recorded transport benchmark. The additional five-second claim margin gives the live attempt a short window beyond the configured timeout before crash recovery may reclaim.
- No database transaction spans subprocess execution.
Why this way. The claim makes attempt ownership durable while avoiding a database lock around an external command. Whole-argument substitution prevents payload data from becoming shell syntax. The lease makes a crashed claim recoverable; it cannot make the external effect exactly once.
D4 — Bounded retry, expiry, delivery, and dead letter¶
Transport failure retries with bounded exponential backoff. max_attempts bounds ordinary failures and successful-but-unacknowledged sends; expires_at is an optional independent bound.
The contract.
_BASE_BACKOFF_SECONDS = 30
_MAX_BACKOFF_SECONDS = 1800
def backoff_seconds(attempt: int) -> float:
return min(30 * (2**attempt), 1800)
Delivery returns this exact counter mapping:
{
"attempted": int,
"delivered": int,
"dead_letter": int,
"expired": int,
"retried": int,
}
The ordinary outcome matrix is:
| Condition after claim | Target | Reason |
|---|---|---|
| command exits 0, no ack required | delivered | dispatch.delivered.transport_ok |
| command fails, attempts remain | pending | dispatch.pending.retry_backoff |
| command fails, attempts exhausted | dead_letter | dispatch.dead_letter.max_attempts |
| expiry reached before claim | expired | dispatch.expired.deadline |
| no template configured | same as command failure | retry or dead letter by attempt bound |
Code anchors: lionagi/dispatch/outbox.py; lionagi/state/reasons.py.
Exact semantics.
- Attempt increments on claim before transport runs. With the standard flow, the first failure uses
backoff_seconds(1) = 60, followed by 120, 240, 480, 960, then the 1,800-second cap. The function's 30-second value at attempt zero is not used as the ordinary post-first-attempt delay. - The 30-second base, 1,800-second cap, and no-jitter choice are inherited; no measured rationale is recorded alongside them. The cap bounds delay growth but can align retries from many rows.
- Expiry wins before a new attempt even if attempts remain.
- A row dead-letters when
next_attempt >= max_attempts. Because positive validation is absent, a non-positivemax_attemptsstill allows the first claim and then immediately satisfies the bound. - On failure,
last_erroris updated in its own transaction before the target status transition. - When attempts remain,
delivering -> pendingcommits before a separate transaction updatesnext_attempt_atto the full backoff. A crash in between leaves the 15-second claim lease as the pending row's due time, allowing an earlier retry than intended. - A crash after
last_errorbut before status transition leavesdelivering; after the lease it is eligible for guarded recovery. - A crash after external success but before
deliveredcan cause a repeated transport execution after lease expiry. This is the core at-least-once window. - Successful transition counters increment only when the guarded transition applies. Transport success followed by a transition conflict does not increment
delivered. - A historical
last_erroris not cleared by ordinary later success in the current implementation.
Why this way. Maximum attempts and expiry make the work finite and observable. Exponential backoff reduces repeated pressure on a failing adapter. The small multi-transaction bookkeeping windows are current limitations and are not described as atomic; ADR-0058 plus delta 2 define the correction.
D5 — Optional acknowledgement tier¶
The default tier stops after transport success at delivered. The acknowledgement tier returns a successful send to pending until the generated token is presented, expiry wins, or the send bound is exhausted.
The contract.
async def ack_dispatch(
db: Any,
dispatch_id: str,
ack_token: str,
) -> bool: ...
Acknowledgement-tier outcomes are:
| Condition | Target | Reason |
|---|---|---|
| successful send, attempts remain, no ack yet | pending with atomic next backoff | dispatch.delivered.transport_ok |
| successful send reaches max attempts without ack | dead_letter | dispatch.dead_letter.ack_timeout |
| consumer presents matching token | acked | dispatch.acked.consumer |
| expiry reached while pending/delivering | expired | dispatch.expired.deadline |
Code anchors: lionagi/dispatch/outbox.py; lionagi/state/transitions.py.
Exact semantics.
- An ack-required enqueue stores one generated token in both the row and payload envelope.
- Successful sends awaiting acknowledgement transition atomically to
pendingand patchnext_attempt_atusing the same backoff formula. Unlike the failure retry path, this due-time patch is in the transition transaction. - A successful send counts under
deliveredeven when its target state ispending; the counter means successful transport calls, not rows currently in thedeliveredstate. - Maximum attempts applies to successful unacknowledged sends as well as failures.
expires_atis not required for boundedness. ack_dispatch()raisesLookupErrorfor a missing row,ValueErrorwhen acknowledgement was not requested, andValueErrorfor a token mismatch.- After token validation, acknowledgement uses the status read from the row as its CAS source. A concurrent status change returns
False. - The dispatch adapter enforces the registered closed edge graph (
lionagi/state/lifecycle/policy.py:360-399):ackedis reachable only frompendinganddelivering,dead_letterandexpiredre-enterpendingonly through an operator-gated edge, anddelivered/ackedhave no outgoing edges. An ack attempt against any other current status raises before the CAS runs. - Tokens are stored in clear text in the state database and shown by the full-row inspection path. This ADR treats them as capability values for local operational use, not as hashed credentials.
Why this way. Most notifications only need transport acceptance and should not pay an acknowledgement protocol cost. Opt-in tokens close the consumer-confirmation gap for callers that need it, while the attempt/expiry bounds prevent a dead consumer from causing infinite sends.
D6 — Scheduler-driven delivery and direct operator controls¶
The Studio scheduler invokes the due scan on every tick. li dispatch reads and performs guarded acknowledgement, retry, and purge directly against StateDB; no transaction spans user input or transport execution.
The contract.
async def get_dispatch(db: Any, dispatch_id: str) -> dict[str, Any] | None: ...
async def list_dispatches(
db: Any,
*,
status: str | None = None,
limit: int = 50,
) -> list[dict[str, Any]]: ...
async def retry_dispatch(db: Any, dispatch_id: str) -> bool: ...
async def purge_dispatch(db: Any, dispatch_id: str) -> bool: ...
async def enqueue_revival_heartbeat(
db: Any,
seats: list[str],
*,
reset_epoch: float,
ttl_seconds: float = 3600.0,
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
) -> list[str]: ...
CLI surface:
li dispatch ls [--status STATUS] [--limit 50]
li dispatch show ID
li dispatch ack ID TOKEN
li dispatch retry ID
li dispatch purge ID
sequenceDiagram
participant P as Producer
participant O as StateDB outbox
participant S as Scheduler tick
participant T as Configured transport
participant C as Consumer or operator
P->>O: enqueue row + initial transition
S->>O: select due and claim by status + attempt
O-->>S: claim applied or conflict
S->>T: argv-exec payload outside transaction
T-->>S: exit 0, error, or timeout
S->>O: delivered, pending, dead-letter, or expired
opt acknowledgement tier
C->>O: present ack token
O-->>C: acked or conflict
end Code anchors: lionagi/studio/scheduler/engine.py; lionagi/cli/dispatch.py; lionagi/dispatch/revival.py; lionagi/dispatch/outbox.py.
Exact semantics.
- The scheduler tick interval is 30 seconds. Dispatch scanning is not separately interval-gated; the tick is its idle-row latency floor, and
next_attempt_atlimits per-row retries. The 30-second value is inherited from the scheduler rather than selected specifically for dispatch. - A scan error is logged by the scheduler and does not prevent later tick work; the next tick may retry.
get_dispatch()returnsNoneon miss and decodes payload JSON when the driver returns text.list_dispatches()orders newest created rows first, optionally filters status, and applies the supplied limit. It does not validate status against the six-value set before querying.- Forced retry accepts only
dead_letterorexpired. It atomically transitions topendingand resetsattempt=0,next_attempt_at=now, andlast_error=None. A concurrent change returnsFalse. - Purge issues a guarded single-row delete and returns whether a row was deleted. It accepts any status and writes no purge transition or admin event.
status_transitionshas no foreign key todispatch_outbox; purging the outbox row does not automatically delete its transition history.- The CLI has no enqueue command. Producers call the library API.
- Revival heartbeat enqueues one
revival_pingper seat withdedup_key="revival:<seat>:<reset_epoch>",ack_required=False, and expiry at reset epoch plus the TTL when reset epoch is truthy. A zero reset epoch produces no expiry. - Revival TTL defaults to 3,600 seconds. No tuning rationale for exactly one hour is recorded.
- Revival enqueue is sequential over the seat list; duplicate seat/key combinations return the first row id under ordinary dedup semantics.
Why this way. A surviving scheduler is the producer responsible for recovery; a dead consumer cannot recover the notification intended to revive it. Direct database administration preserves operability during daemon downtime. Keeping enqueue as a library operation prevents a generic CLI from becoming an ungoverned notification producer.
Consequences¶
- A dispatch survives consumer absence and producer restart as database state.
- Delivery attempts are bounded, reason-bearing, and inspectable; acknowledgement is opt-in.
- The transport remains configuration-driven and argv-safe rather than hard-coded to one tool.
- Delivery latency for a newly due idle row follows the 30-second scheduler tick, plus any database or process scheduling delay.
- End-to-end delivery is only as durable as the configured destination. Exit zero means the adapter reported success, not that a consumer committed the payload.
- At-least-once transport means consumers or destinations should tolerate duplicates, especially across producer crashes after external success.
- Current failure bookkeeping has multi-transaction windows, and the transition adapter lacks a closed dispatch edge graph. Both are maintenance costs, not hidden guarantees.
- Reversing D1/D2 requires migrating durable pending work. The transport adapter and retry numbers are more easily replaceable so long as row and lifecycle compatibility is preserved.
Current-vs-ideal delta¶
| # | Delta | Size | Issue |
|---|---|---|---|
| 1 | Route dispatch lifecycle through the unified service in ADR-0058; acceptance requires an explicit dispatch edge graph, the existing attempt guard and lease, and unchanged acknowledgement and retry behavior. | M | (filled at issue-open time) |
| 2 | Make failure bookkeeping atomic with each dispatch transition by applying last_error, next_attempt_at, attempt counters, and target status in one guarded transaction; acceptance requires crash-injection tests that cannot leave pending rows with stale retry data. | S | (filled at issue-open time) |
| 3 | Define dispatch retention and purge audit semantics; acceptance requires a bounded retention policy, preservation or deliberate removal of transition history, and an auditable operator purge result. | S | (filled at issue-open time) |
| 4 | Verify and document the durability contract required of configured transport destinations; acceptance requires configuration validation and an integration test that distinguishes transport-command success from consumer acknowledgement. | M | (filled at issue-open time) |
Alternatives considered¶
In-memory notification queue¶
Queue work inside the producer process and retry while it remains alive. This would avoid schema and polling overhead. It lost because restart loses queued work and a dead producer cannot recover the notification. Durability is the primary requirement.
Consumer polling without a producer outbox¶
Make each consumer poll operational state and infer notifications. This would eliminate outbound delivery and use consumer-specific recovery. It lost because a dead consumer cannot be responsible for discovering the notification intended to revive it, and every consumer would duplicate query and checkpoint logic.
Extend schedule_runs¶
Represent every dispatch as a schedule run. This would reuse status and scheduler machinery. It lost because ad-hoc dispatches do not necessarily have a schedule or executable action, and transport acknowledgement is not a schedule-run lifecycle.
Separate dispatch database¶
Put outbox rows in an isolated store. This could reduce contention and permit independent retention. It lost because enqueue can depend on existing state transactions and references sessions/schedule runs; another database would introduce cross-store atomicity and operational overhead.
Hold the database transaction across transport execution¶
Claim, execute, and finalize inside one transaction. This would appear to make each attempt atomic. It lost because the external command cannot participate in the database transaction: rollback cannot unsend a payload. The long transaction would only extend locks and worsen recovery.
Shell command-string templates¶
Interpolate route and JSON into one shell string. This would make templates concise and allow pipes or redirection. It lost because payload-controlled characters would become shell syntax and quoting would be fragile. Whole argv tokens plus optional stdin are the shipped safety boundary.
Exactly-once delivery¶
Guarantee one consumer-visible effect. This would simplify downstream deduplication. It lost because the configured transport and destination do not share a transaction with StateDB; a crash between external success and local finalization is fundamentally ambiguous. A destination-level idempotency key can improve a specific adapter but is not implemented generically here.
Mandatory consumer acknowledgement¶
Require every dispatch to reach acked. This would make delivery semantics uniform and stronger. It lost because many notifications only need best-effort transport acceptance, and requiring every destination to implement token callbacks would add unnecessary coupling. The tier remains opt-in.
Unbounded retries¶
Keep retrying until success or optional expiry. This would maximize eventual delivery for transient failures. It lost because no-expiry rows could consume resources forever, especially when transport succeeds but the consumer never acknowledges. max_attempts bounds every path.
Fixed retry interval¶
Retry at one constant delay. This would be easy to reason about and avoid long waits. It lost because repeated failures would maintain constant pressure. Exponential backoff reduces that pressure, though the current no-jitter schedule may still synchronize rows.
First-class revival schedule action¶
Add a dedicated scheduler action kind for heartbeat dispatch. This would make the reference flow directly configurable. It lost for the current slice because adding an action kind changes the schedule vocabulary and SQLite compatibility migration. The library call proves the outbox without expanding scheduler schema.
Notes¶
An earlier revision of this record transcribed the pre-registry validator behavior; the unified lifecycle policy registry (lionagi/state/lifecycle/policy.py) reconciled the schedule-run and dispatch vocabularies, terminal sets, and edge graphs, and the corrected text above reflects that registry.
DispatchSignal.attempt and the row's attempt currently diverge after the first claim. Consumers must use outbox inspection for current attempt count. A future payload rewrite or per-attempt envelope is a wire-contract change and should not be introduced as an incidental bug fix.