PostgreSQL quickstart¶
Use PostgreSQL when proposals must survive process restarts or multiple workers need concurrency-correct lifecycle transitions.
The official postgresql/v1 store security profile
requires host-protected private state and separate runtime and retention roles;
it does not claim storage encryption or deletion from external copies.
The action schema can live beside the application's tables or in a dedicated PostgreSQL database. A dedicated database is the maximum-isolation option: use it when action credentials, backups, ownership, and incident blast radius must be separate from business data. The adapter never joins host tables, so only the migrator, runtime, and retention DSNs change. This separation does not make an action-store transaction atomic with the host application's transaction.
Apply migrations explicitly¶
The package never discovers credentials or migrates at import time. Put the DSN in an environment variable and name that variable on the command line:
export ACTIONS_MIGRATOR_DATABASE_URL='postgresql://migrator@localhost/actions'
threvo-actions postgres inspect \
--dsn-env ACTIONS_MIGRATOR_DATABASE_URL --schema threvo_actions
threvo-actions postgres plan \
--dsn-env ACTIONS_MIGRATOR_DATABASE_URL --schema threvo_actions
Both commands are read-only. inspect reports applied and pending versions;
plan adds the exact rendered SQL and compatibility metadata for each pending
migration. Confirm that the named environment variable points to the intended
target and review the plan before allowing an operator or deployment workflow
to mutate it. Then run:
threvo-actions postgres migrate \
--dsn-env ACTIONS_MIGRATOR_DATABASE_URL --schema threvo_actions
threvo-actions postgres inspect \
--dsn-env ACTIONS_MIGRATOR_DATABASE_URL --schema threvo_actions
Never put the DSN itself after --dsn-env; the argument is an environment
variable name, so the DSN is not exposed in the threvo-actions process
arguments. The example export still enters the literal DSN into shell
history; load production credentials through your secret manager instead.
migrate uses an advisory lock and applies packaged, forward-only migrations.
The default lock wait is 30 seconds.
For a reviewed SQL artifact instead of a live package invocation, render a complete fresh-database script without credentials or a database driver:
For an existing database, inspect first and pin the exact ledger version:
threvo-actions postgres script --from-version 3 \
--schema threvo_actions --writers-quiesced \
> threvo-actions-3-to-current.sql
Review and pin the generated file with the deployment release, then apply it
once with the migrator credential while writers are actually stopped. The
script validates that the database still has the declared migration prefix
before applying DDL; it fails and rolls back if the target has drifted. Do not
use the JSON emitted by postgres plan as an executable migration because it
intentionally omits ledger operations and transaction guards.
Configure the SQL client to propagate failures to the deployment runner; for
psql, include --set ON_ERROR_STOP=1 when applying the file.
For an existing schema, the current lifecycle contract migrations require
runtime and retention writers to be drained. When the plan reports that
requirement, stop both writer lanes and rerun postgres migrate with
--writers-quiesced. The flag is an explicit acknowledgement of that external
deployment step; the command does not stop workers itself. A fresh database
bootstrap does not require the flag.
Lifecycle migrations replace the database status constraint and transition
trigger from the same closed Python contract used by the runtime. Before an
upgrade, remove or explicitly remediate rows containing retired states; the
migration transaction refuses them and rolls back without advancing migration
history. Do not edit an applied migration checksum or map an unknown state to
success. After remediation, run the same forward migration again and verify
that inspect reports no pending versions.
The migrator DSN owns the tables by design. The second inspection is expected to warn about that ownership; never reuse this environment variable in an application process. Configure and inspect the runtime and retention DSNs separately as shown below.
Run the complete PostgreSQL example¶
The repository includes a full prepare → authority → execute → verify lifecycle
using PostgresActionStore:
export DATABASE_URL='postgresql://localhost/actions'
uv run --extra postgres python -m examples.docs.postgres_runtime
Expected output:
The program applies packaged migrations explicitly and uses one database role so it is easy to run locally:
Show the complete runnable example
"""A complete PostgreSQL-backed refund lifecycle.
Run against an empty local database with:
DATABASE_URL=postgresql://localhost/actions \
uv run --extra postgres python -m examples.docs.postgres_runtime
This example uses one database role for brevity. Production deployments should
separate migration, runtime, and retention credentials as documented.
"""
from __future__ import annotations
import asyncio
import os
import uuid
from datetime import UTC, datetime, timedelta
import asyncpg
from examples.docs.quickstart import (
AGENT,
CONSUMER,
MANAGER,
REQUESTER,
TENANT,
RefundCommand,
build_demo,
)
from threvo_actions import (
ActionRuntime,
AuthorityDecision,
AuthorityEvidence,
OperationOutcome,
ReadContext,
)
from threvo_actions.migrations import migrate_postgres
from threvo_actions.stores.postgres import PostgresActionStore, PostgresRetentionStore
class Identifiers:
def new(self, prefix: str) -> str:
return f"{prefix}:{uuid.uuid4()}"
class SystemClock:
def now(self) -> datetime:
return datetime.now(UTC)
async def main() -> None:
dsn = os.environ.get("DATABASE_URL")
if dsn is None:
raise RuntimeError("set DATABASE_URL to a PostgreSQL DSN")
pool = await asyncpg.create_pool(dsn, min_size=1, max_size=4)
try:
await migrate_postgres(pool, schema="threvo_actions")
store = PostgresActionStore(pool, schema="threvo_actions")
retention_store = PostgresRetentionStore(pool, schema="threvo_actions")
clock = SystemClock()
definition = build_demo().action
runtime = ActionRuntime(
store=store,
retention_store=retention_store,
clock=clock,
identifiers=Identifiers(),
)
prepared = await runtime.prepare(
definition,
tenant_reference=TENANT,
command=RefundCommand(order_reference="ORD-PG-42"),
requesting_principal=REQUESTER,
proposing_agent=AGENT,
)
record = await store.get(TENANT, prepared.proposal_reference)
if record is None or record.commitment is None:
raise RuntimeError("prepared proposal was not persisted")
authorized = await runtime.record_authority(
definition,
evidence=AuthorityEvidence(
tenant_reference=TENANT,
action_type=definition.action_type,
proposal_instance_reference=prepared.proposal_reference,
semantic_effect_reference=record.semantic_effect_reference,
authority=MANAGER,
audience=(definition.authority_audience,),
decision=AuthorityDecision.APPROVE,
proposal_commitment=record.commitment.digest,
channel_assurance=definition.authority_channel_assurance,
issued_at=clock.now(),
expires_at=clock.now() + timedelta(minutes=5),
),
authenticated_authority=MANAGER,
)
if authorized.outcome is not OperationOutcome.AUTHORIZED:
raise RuntimeError(f"authority failed: {authorized.outcome}")
executed = await runtime.execute(
definition,
tenant_reference=TENANT,
proposal_reference=prepared.proposal_reference,
)
if executed.outcome is not OperationOutcome.VERIFICATION_PENDING:
raise RuntimeError(f"execution failed: {executed.outcome}")
verified = await runtime.reconcile(
definition,
tenant_reference=TENANT,
proposal_reference=prepared.proposal_reference,
)
if verified.outcome is not OperationOutcome.VERIFIED:
raise RuntimeError(f"verification failed: {verified.outcome}")
view = await runtime.read(
definition,
proposal_reference=prepared.proposal_reference,
context=ReadContext(tenant_reference=TENANT, consumer=CONSUMER),
)
print(verified.outcome)
print(f"stored revision: {view.revision}")
print([receipt.receipt_type for receipt in view.receipts])
finally:
await pool.close()
if __name__ == "__main__":
asyncio.run(main())
Create the stores in your application¶
import asyncpg
from threvo_actions import ActionRuntime
from threvo_actions.stores.postgres import (
PostgresActionStore,
PostgresRetentionStore,
)
runtime_pool = await asyncpg.create_pool(runtime_dsn)
retention_pool = await asyncpg.create_pool(retention_dsn)
runtime_store = PostgresActionStore(runtime_pool, schema="threvo_actions")
retention_store = PostgresRetentionStore(
retention_pool,
schema="threvo_actions",
)
runtime = ActionRuntime(
store=runtime_store,
retention_store=retention_store,
clock=clock,
identifiers=identifiers,
)
Here, clock implements Clock, identifiers implements
IdentifierProvider, and the two DSNs come from your application's secret
configuration. The complete program above supplies concrete versions.
The application creates and owns both pools. Use different database roles:
- migrator owns schema changes but is never an application login;
- runtime creates proposals and advances ordinary lifecycle state;
- retention can run constrained erasure functions but cannot execute actions.
Generate the tested grant baseline without exposing a DSN:
threvo-actions postgres grants \
--schema threvo_actions \
--runtime-role actions_runtime \
--retention-role actions_retention > actions-grants.sql
The command does not create roles or apply SQL. Review the file and apply it with the migrator after the schema is current.
After grants are applied, verify both application DSNs independently:
threvo-actions postgres inspect --dsn-env ACTIONS_RUNTIME_DATABASE_URL \
--schema threvo_actions --require-separated-role
threvo-actions postgres inspect --dsn-env ACTIONS_RETENTION_DATABASE_URL \
--schema threvo_actions --require-separated-role
Both commands must exit successfully before deployment. The migrator DSN owns the table by design and must not be used for either check or application process.
Gate application startup with the same pools or credentials:
threvo-actions postgres ready --dsn-env ACTIONS_RUNTIME_DATABASE_URL \
--schema threvo_actions --lane runtime
threvo-actions postgres ready --dsn-env ACTIONS_RETENTION_DATABASE_URL \
--schema threvo_actions --lane retention
The command is read-only and exits 3 for pending migrations, schema ownership,
missing required privileges, or dangerous cross-lane privileges. Call
check_postgres_readiness() directly when startup already owns an asyncpg
pool.
What PostgreSQL guarantees¶
- tenant-scoped proposal lookup;
- compare-and-set revisions;
- guarded lifecycle transitions;
- atomic semantic-effect admission;
- one verification lease at a time;
- append-only active evidence;
- constrained erasure through database-owned functions.
It does not provide distributed exactly-once effects. The executor still needs target-side idempotency and the verifier still needs an authoritative query.
Continue with the deployment and role guide for the complete grants, privacy boundary, and recovery assumptions.