Explicit capability declarations for custom state stores, format-2 requirements, legacy compatibility and validation at Agent construction.
StateStore capabilities
StateStore is the runtime persistence protocol. Dendrux ships three implementations, all built on one base class, SQLStateStore. RunStore is the public read facade. Import all of them from dendrux.store.
from dendrux.store import (
MySQLStateStore, PostgresStateStore, SQLiteStateStore, SQLStateStore,
StateStore, StoreCapabilities, store_for_engine,
)The types the SQLStateStore dialect hooks take and return are public too, so a store for another database needs no private import: ConflictSpec, InsertPlan, SelectSource, ValuesSource, SetFields, AppendArray, PatchLoopState, JsonPatch, JsonPath, JsonReadType, JsonValue, ScalarKey, OrderedAssignments, RunLockScope, RunParentCursor, DialectFacts, DbError and DbErrorKind. See Build your own store.
Construct a store once per worker event loop and pass it as Agent(state_store=state). The application owns the engine. See Scaling workers.
Built-in stores
store_for_engine(engine, **options) picks the class from the engine's dialect name (postgresql, sqlite, mysql, or mariadb for a mariadb+asyncmy engine) and raises ValueError, naming the dialect it was given, for any other. It always returns one of the built-in classes; to use a store of your own, pass it to Agent(state_store=...) and RunStore(store) directly. Agent(database_url=), RunStore.from_database_url, RunStore.from_engine, the CLI and sweep all go through it, so no call site names a concrete class. To support another SQLAlchemy database, see Build your own store.
Capability matrix
A custom store declares a class attribute capabilities: StoreCapabilities. This frozen dataclass has version=1, five flags that default to False, and a deployment field that defaults to None. Method names alone never enable a capability.
deployment is descriptive, not a feature switch: it says how many processes may share the store's data. It is one of:
A store with an explicit capability declaration but no deployment still runs, with a warning that the deployment is undeclared. The store test kit refuses to certify a store without one.
The table lists the added requirements, not full method signatures. Implement the protocol in dendrux.runtime.state; do not copy a shortened table entry as a method definition. Signature validation requires the named keyword-only parameters. A forwarding subclass override should preserve those named parameters and pass them through; do not rely on a bare **kwargs declaration satisfying validation.
The built-in SQL stores declare all five capabilities. Live updates are not a capability; see below.
Live updates
Every SQLStateStore takes live_updates= in its constructor, and MySQLStateStore.from_url(url, *, live_updates=None, **engine_options) takes it as a named argument. store_for_engine(engine, **options) forwards it.
store.live_updates is the bound instance or None. await store.close() closes it (idempotent) and never disposes the engine; Agent.close() calls it only for a store the Agent created from database_url=. RunStore(store) listens for the instance's hints; RunStore.from_engine and RunStore.from_database_url do not. After a write's transaction issues COMMIT, the store hands the changed run ids to the instance and flushes them when the outermost store call returns. Hints are lossy: the database is the truth. The transports, the polling-only writes, the deadlines and the failure behaviour are on Scaling workers. A custom store that is not built on SQLStateStore follows the contract in Build your own store.
Optional: statement capacity
A SQL store class may define async statement_capacity(self) -> int | None: the most bytes one SQL statement may carry. Before an Agent first uses a store, it compares a worst-case estimate of the largest statement its Limits can produce with this number and refuses with a ValueError that names both values (log code store_statement_limit). The early check refuses configured limits that cannot fit; the server's own limit is the final guard, and when MySQL or MariaDB answers with its packet-too-large error (1153 or 1301) the store raises StatementTooLargeError, a non-retryable PersistenceError. MySQLStateStore reports the server's max_allowed_packet; the other built-in stores define no limit and are not checked. The method is looked up on the class.
Lost commit acknowledgements
If a connection fails during COMMIT and the store cannot prove whether the write happened, the operation raises StoreOutcomeUnknown, a subclass of PersistenceError with code store_outcome_unknown. Operations that carry a retry identity read their rows back and report the real outcome. Claims and reclaims always raise it, even when a later read shows the expected epoch: never run the claimed work or retry the claim blindly. The lease and reclaim recover the run. See Errors.
Terminal writes in a custom store
Give your store a finalize_with_event and make it atomic. The status change, the terminal event and, for a format-2 run, the final trace are one transaction, and a lost compare-and-swap changes nothing. The runtime calls it for success, a returned error, the iteration limit, a failure and cancellation whenever the store has the method. The built-in SQL stores do this in both storage formats.
Followers depend on it. A follow ends once it has seen the terminal status and drained the event log, so a status that lands before its event can end a follow early. A store without finalize_with_event gets separate status and event writes. Its followers get a bounded 100 ms grace and one more drain, which cannot catch an event written arbitrarily late. See How a follow ends.
Typed provider errors. finalize_with_event has no error_info keyword, and it does not need one. When a typed provider failure ends the run, the structured error information is in event_data["error_info"] of the terminal event. RunStore.get_run shows it as RunDetail.error_info, which it reads from the run's meta under the key dendrux.error_info. If your store keeps that projection, update it from the event payload in the same transaction that wins the status change. Do not rely on finalize_run(error_info=...): the runtime picks finalize_with_event whenever your store has it. The built-in SQL stores do this for you. A lost compare-and-swap must change neither the event log nor the projection.
Events that do not pass through save_run_event
save_run_event is not the only way an event reaches the log. Some events are written in the same transaction as the change they describe, by a combined method, and never call save_run_event:
A 0.5 subclass that overrides only save_run_event to audit every event therefore sees fewer events than the log holds. In a check on SQLite, a format-2 run with one server tool and one client tool stored 12 events and the override saw 8. Marking an expired lease, reclaiming and sweeping write their events in their own transactions as well.
What a custom writer must do:
- Audit from the log. The event log is the truth and holds every event in order. Read it with
RunStore.get_events, paging withafter_sequence_index(see Bounded list reads), or write the audit trail from a store-level hook that you place on the combined methods too. - If you override the combined methods, keep their full signatures. Copy the base signature, call
super(), and read the event from theeventargument (anEventWrite) or fromevent_typeandevent_data. A bare*args, **kwargsoverride does not pass capability validation. - If you write your own store, put every event in one log. Whichever method wrote it, an event takes its sequence index inside the same insert and lands in the same transaction as the change it belongs to. Followers read that one log.
- Do not rely on narrowing to get the old behaviour back. Declaring
atomic_completion=Falsemakes the runtime use plainsave_run_eventfor the tool events, but new runs then start at format 1, andrun.pausedand the terminal events still use their combined methods.
Format and recovery requirements
Format 2 requires fenced_writes, retry_ids, checkpointing and atomic_completion together, plus an activated database setting. If any is missing, newly created runs use format 1. A run's format never changes on claim or resume.
Recovery additionally requires recovery=True and a format-2 ReAct run. Live updates are independent of format and recovery. See Recovery for sweep and dispatch semantics.
For usage rows, retry equality covers persisted token_usage columns, including cost rounded to six decimal places. It does not compare non-persisted UsageStats fields such as cost_source.
Legacy and partial stores
A store without a declaration is a legacy store. It receives the old call signatures and uses format 1, even if it happens to expose new method names or accept arbitrary keyword arguments. reclaim() and sweep(on_stale="reclaim") refuse a store without recovery using NotImplementedError before writes.
A partial declaration enables only the flags it names. For example, a store implementing fencing and retry identities can retain format 1 while getting those protections. Declaring a flag is a promise about transaction behavior as well as a signature; validation cannot prove the implementation's concurrency semantics.
Connection requirements
Every SQL store requires real transactions. On first use, before any claim, it refuses an engine or connection in AUTOCOMMIT mode with a ValueError (isolation_level="AUTOCOMMIT", driver autocommit, or MySQL session autocommit on). It does not repair session state with SET statements on checkout. MySQL and MariaDB add further requirements; see MySQL and MariaDB.
Validation and subclass upgrades
At Agent construction Dendrux validates declared capability versions, methods and required keyword parameters. A mismatch raises StoreCapabilityError (a TypeError) before a run starts. sweep(on_stale="reclaim") also validates its store before writes.
Fixing a StoreCapabilityError
The message names the capability, the method and the parameters it lacks, and tells you the repair. There are three kinds:
Omitting deployment from a StoreCapabilities declaration, or setting it to None, is currently accepted with a DeprecationWarning.
If your store does not support a capability, turn that one flag off and keep the rest of the declaration. Do it in __init__, after super().__init__(...):
from dataclasses import replace
class MyStore(SQLStateStore):
def __init__(self, engine):
super().__init__(engine)
self.capabilities = replace(self.capabilities, fenced_writes=False)Narrowing has costs, so choose it knowingly:
- It removes the promise. Turning off
fenced_writeson amulti_serverstore removes fencing: an old owner can then write to a run it lost. - It does not adapt your methods. The other flags are still checked, so implement the base signature wherever a flag you keep requires it.
- Format 2 needs
fenced_writes,retry_ids,checkpointingandatomic_completiontogether. Narrowing any of them makes new runs start at format 1.
SQLAlchemyStateStore is removed in 0.6.0. When moving a subclass to PostgresStateStore, SQLiteStateStore or SQLStateStore, audit every overridden write method against the current protocol. The subclass inherits the base declaration unless it explicitly narrows it. Keep the named parameters, forward the epoch and caller-minted ids, and preserve the transaction boundaries. Test races and retries against the database you deploy.