Build a custom action store¶
An action store can use MySQL, SQLite, a transactional document database, or an application-owned schema. The library defines behavior, not a universal table layout. The developer who builds the adapter owns its schema, migrations, backup policy, and database-specific operating guarantees.
You do not translate PostgreSQL migration 003 line by line. You implement
the same current lifecycle and concurrency contract using the primitives of
your database.
Support tiers¶
| Tier | Current adapters | Who owns schema and operations | Intended use |
|---|---|---|---|
| Production-oriented official | PostgreSQL and MySQL 8 | threvo-actions owns packaged migrations, adapter tests, and upgrade behavior |
Multi-worker production evaluation with database-specific controls |
| Bounded-use official | SQLite | threvo-actions owns packaged migrations and adapter tests |
Local development, evaluation, tests, and bounded single-writer deployments |
| Conforming custom | Your ActionStore and optional RetentionStore |
Your application owns DDL, migrations, privileges, recovery, and database qualification | Only the environments you test and operate |
| Unverified | An implementation that has not passed conformance | Adapter author | No financial-safety claim |
SQLite does not provide PostgreSQL/MySQL-style runtime and retention database roles. Passing conformance does not make it a general multi-worker financial production backend.
Implement the behavioral contract¶
Implement the five async methods in ActionStore:
from threvo_actions import ActionStore
class MyActionStore(ActionStore):
async def create(self, proposal): ...
async def get(self, tenant_reference, proposal_reference): ...
async def compare_and_set(self, *, tenant_reference, proposal_reference,
expected_revision, expected_statuses, updated): ...
async def admit_execution(self, *, tenant_reference, proposal_reference,
expected_revision, admitted_at, updated): ...
async def get_effect_claim_owner(self, *, tenant_reference, action_type,
semantic_effect_reference): ...
The abbreviated annotations above are for orientation. Copy the exact current
signatures from ActionStore when implementing an
adapter.
The store must provide all of these behaviors:
- Tenant-scoped identity. Proposal lookup and mutation use both tenant and proposal references. A reference from another tenant behaves as missing.
- Atomic compare-and-set. A transition succeeds only when the stored revision and lifecycle status match. A successful update advances the revision by exactly one.
- Immutable action identity. Tenant, proposal, action type, semantic effect, effect kind, and creation time cannot change.
- Closed lifecycle. Only current
LifecycleStatusvalues and transitions inALLOWED_LIFECYCLE_TRANSITIONSare accepted. Unknown and retired states fail closed. - Atomic semantic-effect admission. At most one proposal owns
(tenant, action type, semantic effect). Claiming that identity and moving the proposal intoexecutinghappen in one transaction or conditional operation. - Append-only active evidence. Authority evidence and receipts can be appended before erasure, never removed, reordered, or replaced.
- Verification leases. Concurrent reconciliation attempts use the same guarded revision behavior, so only one receives the current lease.
- Logical erasure workflow. If you implement
RetentionStore, erasure intent is durable before content destruction and completion leaves a content-free tombstone. Active or ambiguous effects cannot be erased. The adapter must separately document whether pages, journals, snapshots, and backups retain historical bytes.
Call validate_proposal_create() and validate_proposal_update() inside the
same critical section as the write. These validators are part of the public
store-author contract, but they do not replace database transactions or
conditional writes.
Shape the database¶
The physical schema is yours. A relational implementation commonly has:
- a proposal table keyed by
(tenant_reference, proposal_reference); - a revision and lifecycle column used by guarded updates;
- protected proposal data stored separately from query indexes;
- a unique effect-claim key over tenant, action namespace/name/version, and semantic-effect reference; and
- optional append-only evidence and receipt tables.
The official MySQL adapter implements this with InnoDB transactions, row locks, immutable migration SQL, a digest-backed unique effect key, triggers, and security-definer procedures. A document store needs conditional writes or transactions that cover both proposal admission and effect ownership. If it cannot atomically enforce that relationship, it cannot provide a conforming execution store merely by implementing the Python methods.
Run the reusable conformance suite¶
This complete example uses the official SQLite adapter as a known-safe concrete store rather than teaching an incomplete toy implementation:
"""Copy/paste store-conformance example using the bundled SQLite adapter."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from pathlib import Path
from tempfile import TemporaryDirectory
from threvo_actions import (
ActionType,
ConfirmingAuthority,
LifecycleStatus,
)
from threvo_actions.authority import AuthorityDecision, AuthorityEvidence
from threvo_actions.canonical import KeyedCommitment, ProtectedPayload
from threvo_actions.conformance import StoreConformanceCase, assert_action_store_conforms
from threvo_actions.sqlite_migrations import migrate_sqlite
from threvo_actions.stores.base import StoredProposal
from threvo_actions.stores.sqlite import SQLiteActionStore, SQLiteRetentionStore
async def check_store(database: Path) -> None:
now = datetime.now(UTC).replace(microsecond=0)
action_type = ActionType(namespace="example.billing", name="refund", version=1)
proposal = StoredProposal(
tenant_reference="tenant:conformance",
proposal_reference="proposal:conformance",
action_type=action_type,
semantic_effect_reference="refund:order-42",
effect_kind="single",
lifecycle_status=LifecycleStatus.AWAITING_AUTHORITY,
revision=0,
protected_private_snapshot=ProtectedPayload(
codec="example-v1",
key_handle="payload-key:conformance",
key_version="1",
ciphertext="opaque-ciphertext",
),
commitment=KeyedCommitment(
algorithm="hmac-sha256",
key_handle="commitment-key:conformance",
key_version="1",
digest="opaque-digest:conformance",
),
display_preview={"summary": "Refund order ORD-42"},
created_at=now,
expires_at=now + timedelta(minutes=10),
max_verification_attempts=3,
)
evidence = AuthorityEvidence(
tenant_reference=proposal.tenant_reference,
action_type=action_type,
proposal_instance_reference=proposal.proposal_reference,
semantic_effect_reference=proposal.semantic_effect_reference,
authority=ConfirmingAuthority(reference="user:manager"),
audience=("service:refunds",),
decision=AuthorityDecision.APPROVE,
proposal_commitment="opaque-digest:conformance",
channel_assurance="authenticated_session",
issued_at=now,
expires_at=now + timedelta(minutes=5),
)
await migrate_sqlite(database)
await assert_action_store_conforms(
StoreConformanceCase(
store=SQLiteActionStore(database),
retention_store=SQLiteRetentionStore(database),
original=proposal,
evidence=evidence,
observed_at=now,
)
)
async def main() -> None:
with TemporaryDirectory() as directory:
await check_store(Path(directory) / "actions.sqlite3")
print("SQLite ActionStore conformance: passed")
if __name__ == "__main__":
asyncio.run(main())
Run it from the repository:
Expected output:
For your adapter, replace SQLiteActionStore and SQLiteRetentionStore with
fresh instances backed by an isolated test database. Keep the fixture and
assert_action_store_conforms() call unchanged.
Next, construct two store adapters from separately created physical connection
sources and run assert_independent_store_connections_conform(). Publish the
returned check codes together with the exact database version, isolation
level, writer topology, and fixture design. The helper cannot prove that two
caller-supplied objects really use independent connections, so that fact must
be established by the fixture. See Store security profiles.
Add database-native evidence¶
Generic conformance is necessary, not sufficient. Also test:
- two independent physical connections racing one semantic effect;
- stale revisions and status predicates;
- rollback after a constraint, trigger, or serialization failure;
- tenant isolation at every query and mutation;
- process crash and retry around effect admission;
- migration from every supported prior version;
- exact current-state acceptance and retired/unknown-state rejection;
- corrupted or mismatched stored JSON failing closed; and
- backup, restore, lock timeout, and operational recovery for your database.
Do not describe a community adapter as production-ready based only on one in-process conformance run. State the tested database versions, isolation level, worker topology, and remaining limitations.