PostgreSQL Flow protocol v1
Source of truth
This is the normative operation and lock manifest for Durable slice 4. It specifies observable behavior, transaction boundaries, lock hierarchy, crash recovery boundaries, and execution state transitions for the PostgreSQL Flow engine. The package starts no worker or background polling service; unit and integration tests and future hosted runtime code drive each operation deterministically.
Authoritative records
PostgreSQL is the sole durable truth for Flow state. The appsurface_durable schema owns flow instances, idempotency keys,
commands, event delivery tracking, execution history, active waits, timers, and RLS security policies alongside the Work/shared protocol tables.
appsurface_durable.flow_instance: Primary state table keyed by(scope_id, flow_instance_id). Tracks current node, execution state, context/resume payload envelopes, active epoch, scope generation, lease ownership, and suspension details.appsurface_durable.flow_command: Command log keyed by(scope_id, command_id). Deduplicates incomingstart,event,cancel, andreleasecommands. Enforces start identity throughix_flow_command_start_idempotencyand single-use event delivery throughix_flow_command_event.appsurface_durable.flow_history: Append-only sequence of execution events keyed by auto-incrementingevent_id. Records state transitions, node entries/exits, inputs, outputs, context snapshots, and diagnostic details injsonb.appsurface_durable.flow_wait: Retained wait lineage keyed bywait_id. Supportseventandactivitywaits, permits at most one active/suspended wait per Flow, and links child activities through(scope_id, child_work_id).appsurface_durable.flow_timer: Scheduled timers keyed bytimer_id, tied to an exact wait and registered Flow revision.appsurface_durable.flow_dispatch: The payload-free global discovery surface for Flow and timer candidates. The dispatcher can select it but cannot mutate or read payload tables.
Flow state machine
A Flow instance transitions through the following formal states:
ready: Ready for step evaluation or initial execution.evaluating: Transition evaluation in progress under active lease/lock.waiting_event: Suspended awaiting an incoming external event or timer expiry.waiting_timer: Suspended awaiting timer expiry.waiting_activity: Suspended awaiting completion of an enqueued child Work activity.cancel_pending: Cancellation requested while an external activity or step is in progress.completed: Terminal state following successful execution to a completion node.faulted: Terminal state following an unhandled exception or failed condition.canceled: Terminal state following confirmed cancellation.suspended: Non-terminal safety state entered when definition mismatch, non-restorable child failure, or unexpected state occurs (suspended_from_statepreserves the recovery source; evaluation failures preserve their last releasable state,ready).
Global lock order
To prevent deadlocks across concurrent flow steps, event deliveries, and child activity completions, all operations adhere to the canonical lock hierarchy:
scope -> child Work (when present) -> parent Flow -> Flow command -> Flow wait -> Flow timer -> Flow dispatch -> Work dispatch -> permit/operator command -> Work history -> Flow history
Every operation acquires only the relations it needs, but always preserves this relative order when the relation row already exists; rows within one class are locked in stable primary-key order. The only creation exception is activity acceptance: it creates a previously absent deterministic child Work under its held Flow claim, when no child Work row exists to lock before the parent. It must not lock a pre-existing child after claiming the parent; later operations that lock an existing child and parent acquire the child Work before the parent Flow. Any parent-fence loss rolls the entire transaction back.
Schema management and epoch rotation take the same exclusive session advisory lock as Work operations before altering schema or rotating active epoch credentials.
Operation manifest
| Operation | Transaction and locks | Required validation | Result and durable effects |
|---|---|---|---|
| Get schema status | Read-only deployment connection | Migration hashes for 0001, 0002, 0003 |
Reports compatibility (compatible, missing, inconsistent, old/new); includes StoreId and active epoch. |
| Apply migrations | Migration owner; session advisory lock | Pre/post migration hashes for the current forward catalog, including 0001_work_shared, 0002_forced_rls, and 0003_flow_protocol |
Applies pending known migrations in sequence under lock; fails closed on SHA-256 mismatch. The Flow protocol does not claim later Schedule behavior. |
| Start Flow | Client-owned short transaction; scope -> flow_instance -> flow_command -> flow_dispatch -> flow_history | Target, StoreId, active epoch, registry, definition fingerprint, start_idempotency_key |
Atomically creates flow_instance (state ready), records flow_command, appends flow_history event, or returns exact duplicate. |
| Deliver External Event | Scoped transaction; scope -> flow_instance -> flow_command -> flow_wait -> flow_timer -> flow_dispatch -> flow_history | Target, StoreId, active epoch, unique event_id, matching active waiting_event |
Records command, resolves active wait (event_won), supersedes timer if scheduled, updates flow_instance to ready, appends history. Exact re-delivery returns the original duplicate-stable outcome. |
| Fire Timer | Payload-free discovery claim, then scoped transition; scope -> flow_instance -> flow_wait -> flow_timer -> flow_dispatch -> flow_history | StoreId, active epoch, state = 'scheduled', due_at <= clock_timestamp() |
Updates timer to fired, resolves event wait (timer_won), updates flow_instance to ready, appends history. |
| Evaluate Step | Short scoped transaction; scope -> parent Flow -> freshly created flow_wait/flow_timer/child Work (creation exception; no existing child Work row) -> flow_dispatch -> flow_history | Active lease/epoch, aggregate revision match, definition fingerprint matching registered code | Replays/evaluates transitions; advances node; creates new flow_wait/flow_timer or enqueues child Work; updates state and increments revision. |
| Accept Child Activity Work | Scoped transaction; existing child Work -> parent Flow -> flow_wait -> Work dispatch -> Work history -> Flow history | Active Flow claim, registered Work contract, active epoch | Atomically inserts/resolves Work, dispatch/history, creates an activity wait, and sets Flow state to waiting_activity; parent-fence loss rolls everything back. |
| Complete Activity Wait | Scoped transaction; scope -> child Work -> parent Flow -> flow_wait -> flow_dispatch -> Work dispatch -> Work history -> Flow history | Terminal Work fact, matching child_work_id |
Resolves the wait, records the typed activity result or suspension descriptor, transitions the parent, and appends both histories atomically. |
| Cancel Flow | Scoped transaction; scope -> flow_instance -> flow_command -> flow_wait -> flow_timer -> flow_dispatch -> flow_history | Target, active instance, authorized actor/reason | If ready/waiting, cancels active waits/timers and transitions to canceled. If activity in-flight, transitions to cancel_pending. |
| Suspend Flow | Scoped transaction; scope -> flow_instance -> flow_history | Invalid transition, non-restorable child failure, code mismatch (ASDUR200/ASDUR201/ASDUR211) |
Sets state to suspended, records suspended_from_state and the suspension reason in suspension_descriptor and Flow history, keeps terminal fields null, and appends audit history. |
| Release Suspended Flow | Scoped transaction; scope -> flow_instance -> flow_command -> flow_wait -> flow_timer -> flow_dispatch -> flow_history | Operator command, expected revision, valid epoch, authorized resolution | Clears suspension, restores original or target state, appends command and history record. |
| Disable Scope | Scoped transaction; canonical lock order | Active scope generation, actor/reason | Permanent tombstone on scope; atomically suspends non-terminal Flow instances and Work items in scope. |
11 Crash recovery boundaries
Flow persistence guarantees deterministic recovery across 11 explicit process crash points:
- Start Flow Pre-Commit: Crash before SQL commit. PostgreSQL rolls back transaction; no
flow_instance,flow_command, orflow_historyrow exists. Caller may safely retry. - Start Flow Post-Commit (
ready): Crash after commit.flow_instanceis durable inreadystate at revision 1. Recovery worker discoversreadyinstance and invokes evaluation. - Evaluation In-Flight Before Child Activity Accept: Crash during step calculation before Work enqueue transaction. Transaction rolls back; instance remains in
readystate at current revision. - Child Activity Accepted (
waiting_activity): Crash after Work item andflow_waitcommit. Work item is durable indispatch; Flow is durable inwaiting_activity. Work dispatcher processes child activity independently. - Child Activity Execution Complete / Permit Acquired: Crash after provider acquires Work effect permit and executes child activity. Work recovery or completion path handles Work item; Flow remains in
waiting_activity. - Child Activity Terminal Completion Committed, Flow Evaluation Pending: Crash after child Work completion transaction commits (
flow_waitset toactivity_completed, Flow state set toready), but before next Flow step evaluation completes. Flow recovery discovers instance inreadystate with completed activity result payload and continues step evaluation. - Event Delivery Pre-Commit: Crash during external event delivery transaction. Transaction rolls back;
event_idis unconsumed andflow_waitremainsactive. Caller may retry event delivery. - Event Delivered (
event_won), Flow Evaluation Pending: Crash after event delivery commits (flow_commandaccepted, wait stateevent_won, timer superseded, Flow stateready). Discovery finds Flow inreadystate with delivered event payload and evaluates next step. - Timer Fired (
timer_won), Flow Evaluation Pending: Crash after timer fire transaction commits (flow_timerstatefired, wait statetimer_won, Flow stateready). Discovery finds Flow inreadystate with timer-expiry signal and evaluates next step. - Flow Suspended: Crash after safety suspension commits (
state = 'suspended'). Instance remains durable insuspendedstate with preservedsuspended_from_state. Requires explicit operator release or reconciliation command. - Flow Terminal State Committed (
completed/faulted/canceled): Crash after terminal state commit.flow_instanceis permanently terminal; subsequent commands or events are safely rejected withalready_terminalorASDUR110.
Idempotency and race resolution matrix
| Identity / Key | Scope | Duplicate / Collision Behavior |
|---|---|---|
(scope_id, start_idempotency_key) |
Scope-wide | Identical fingerprint returns original FlowInstanceId and accepted_at (Duplicate). Divergent definition or payload returns ASDUR206 start conflict. |
(scope_id, flow_instance_id) |
Scope-wide | An instance is created by one coherent start only. Reusing it with different command or idempotency identities returns ASDUR206; an exact original start request returns the persisted outcome. |
(scope_id, command_id) |
Scope-wide | Identical command fingerprint returns original command outcome (accepted). Divergent command payload returns ASDUR207 command conflict. |
(scope_id, event_id) |
Scope-wide | ix_flow_command_event prevents duplicate consumption. Exact retries return the original accepted/race-lost outcome; changed semantics fail ASDUR207. |
Aggregate revision CAS |
Instance-wide | State transitions validate expected_revision. Race condition (e.g. concurrent step evaluation or event delivery) causes loser to fail CAS and return ASDUR203 race lost. |
Options reuse and configuration sharing
PostgreSqlDurableWorkOptions is reused directly by PostgreSqlDurableFlowClient and PostgreSqlDurableFlowStore, or shared via compatible options types:
ExpectedStoreId: Must match the deployment-timeStoreIdstored inappsurface_durable.store_metadata. Mismatch fails closed withASDUR115.RuntimeEpoch: Must match the currently active epoch initialized or rotated viaIDurableRuntimeSchemaManager. Stale epoch fails closed withASDUR108or epoch fence violation.WakeNotificationMode: Flow and Work engines share PostgreSQLLISTEN/NOTIFYnotification settings (default:Disabled).- Schema Compatibility: Shared schema manager checks the current forward migration catalog. Flow requires its
0001_work_shared.sql,0002_forced_rls.sql, and0003_flow_protocol.sqlprerequisites and remains compatible with later Schedule migrations that it does not interpret.
Migration order and rollback posture
PostgreSQL Flow schema requires applying migrations strictly in order:
0001_work_shared.sql: Definesstore_metadata,schema_migration,scope,work,dispatch,work_operator_command,effect_permit,scope_history,work_history.0002_forced_rls.sql: Enables and forces Row Level Security on Work entities.0003_flow_protocol.sql: Defines the six Flow relations, indexes, constraints, and forced RLS policies.
After applying any migration that adds package relations, run configure-postgresql-roles.sql again: migrations must run first, then the role recipe grants the reviewed Flow privileges to existing dispatcher and scoped-runtime roles.
Rollback posture
- Applied migrations are forward-only. The package provides no destructive down-migration scripts.
- Rolling back application binaries does not authorize rolling back database schema.
- Strict
./Durable/verify-postgresql.sh --ci --flowbuilds the pinned v2 Work binary from0e57477bab00b1951192c82ca28fdda977da2092and runs it concurrently with current Work/Flow operations against v3. The rolling claim is limited to that Work-only path; v2 cannot process Flow. - Manual DDL execution requires using
psqlwith-v ON_ERROR_STOP=1.
Security and Row-Level Security (RLS)
All six Flow tables, including payload-free flow_dispatch, have Row Level Security enabled and forced:
The dispatcher credential normally receives global flow_dispatch discovery. Run configure-postgresql-roles.sql
immediately after applying 0003 and before granting SELECT on flow_dispatch: the migration's
flow_dispatch_global_discovery policy is initially PUBLIC, because it has
no scope-restricted discovery fallback. The role recipe narrows that policy to the dispatcher credential and migration
owner; the latter is required only for the migration-owner SECURITY DEFINER aggregate-health function introduced by
0005_runtime_heartbeat.sql. The recipe also
adds the runtime-role scope predicate. The scoped runtime credential then retains SELECT and column-scoped UPDATE
privileges but sees Flow dispatch rows directly only after its transaction sets the matching
appsurface_durable.scope_id.
ALTER TABLE appsurface_durable.flow_instance ENABLE ROW LEVEL SECURITY;
ALTER TABLE appsurface_durable.flow_instance FORCE ROW LEVEL SECURITY;
CREATE POLICY flow_instance_scope_isolation ON appsurface_durable.flow_instance
USING (scope_id = nullif(current_setting('appsurface_durable.scope_id', true), ''))
WITH CHECK (scope_id = nullif(current_setting('appsurface_durable.scope_id', true), ''));
Runtime connections must set appsurface_durable.scope_id transaction-locally. Unset or empty scope settings prevent access to all rows.
Diagnostics catalog mapping
Flow operations emit append-only ASDURxxx codes. Safe error reporting excludes credentials, payloads, and parameter values.
| Diagnostic Code | Meaning | Safe Resolution Path |
|---|---|---|
ASDUR200 |
Flow definition unavailable | Register required flow_id and version before starting or resuming instance. |
ASDUR201 |
Flow history incompatible | Definition fingerprint or step code changed; suspend instance and perform explicit migration. |
ASDUR202 |
Not waiting yet | Event arrived before instance entered active waiting_event state; retry event delivery. |
ASDUR203 |
Flow race lost | Optimistic aggregate revision CAS failed; reload instance state before retrying. |
ASDUR204 |
Event duplicate | Single-use event_id was already consumed; return original delivery result. |
ASDUR205 |
Flow access denied | Scope authorization check failed or scope setting missing. |
ASDUR206 |
Flow start conflict | A start identity or target Flow instance conflicts with persisted Flow creation. |
ASDUR207 |
Flow command conflict | command_id or event_id reused with different command semantics. |
ASDUR208 |
Flow not found | Instance ID does not exist within the specified scope. |
ASDUR209 |
Event contract mismatch | Payload schema version or contract ID does not match active wait registration. |
ASDUR210 |
Release manifest mismatch | Recovery manifest registration disagrees with persisted history. |
ASDUR211 |
Release state mismatch | Suspended state and active wait/timer/work records disagree; reconcile before release. |
ASDUR400-ASDUR403 |
Schema manager errors | Apply pending schema migrations 0001-0003 using migration owner credentials. |
See the diagnostics catalog for full error details.