Skip to content

Runtime Execution & Reliability

Invariant separates graph decisions, state reduction, persistence, and environmental work:

text
Kernel decides progress
Reducer derives state
Store commits truth
Host performs external work

This page distinguishes guarantees implemented by the current Beta from primitives that a production host must still orchestrate.

Pure Execution Kernel

ts
export class PureExecutionEngine {
  transition(
    workflow: CompiledWorkflowIR,
    currentState: ExecutionState,
    event: IncomingRuntimeEvent,
  ): TransitionResult;
}

The kernel receives all time and environmental information through the incoming event. It does not query databases, call models, invoke capabilities, or read the clock. It returns derived events, the next state, and commands for the host.

Application callbacks used for branching and state selection must remain deterministic if exact replay is required.

RuntimeStore Contract

ts
export interface RuntimeStore {
  commitTransition(commit: TransitionCommit): Promise<void>;
  loadState(runId: string): Promise<ExecutionState | null>;
  readEventLog(runId: string): Promise<RuntimeEvent[]>;
  acquireLease(runId: string, workerId: string, ttlMs: number): Promise<Lease | null>;
  renewLease(runId: string, leaseId: string, ttlMs: number): Promise<boolean>;
  releaseLease(runId: string, leaseId: string): Promise<void>;
  findRunnableExecutions(options: { limit: number }): Promise<string[]>;
  saveSession(write: SessionWrite): Promise<void>;
  loadSession(sessionId: string): Promise<DurableSession | null>;
}

commitTransition() is the durability boundary. Before opening a database transaction, the official adapters call validateTransitionCommit() to reject an empty or malformed event batch, mismatched run identity, non-contiguous sequence, non-canonical new event ID, revision mismatch, unsupported command/update, invalid Session binding, or non-serializable durable payload. Database OCC then remains the authoritative second check against the currently persisted revisions.

A conforming store atomically checks execution expectedRevision and commits events, materialized state, new commands, command status updates, and an optional compare-and-swap SessionWrite. session.startWorkflow() includes that write in the initial transition, so a committed run cannot be exposed without its recoverable activeRunId pointer. The same transaction rejects a stale Session revision or a different userId; a losing competing start leaves no orphan execution row.

The Host/SDK creates a UUID runId before entering the pure kernel. Within that run, every newly written durable event uses the next monotonic sequence and the identity ${runId}:${sequence}. The reducer advances ExecutionState.revision once for every durable event, including kernel-derived WAIT_ENTERED and terminal events. Therefore a commit that starts at revision 7 and atomically appends sequences 8, 9, and 10 must persist nextState.revision = 10; revision measures durable event progression, not database transaction count.

This write contract applies to newly created histories. Existing histories keep their original event IDs and remain readable and replayable; adapters and replay code must not require the new ID format or backfill old events.

SQLite and PostgreSQL implement this interface. See Custom Storage for adapter requirements.

In-Process Session Command Drain & Effect Identity

Session.startWorkflow() and Session.submitInput() synchronously drain commands while the current process still owns the live handlers:

  1. The kernel derives a command.
  2. The configured store commits command intent with the state transition.
  3. The internal Session.drainToBoundary() loop invokes the step, reasoning adapter, or capability handler in that process.
  4. The completion or failure event is committed with the command acknowledgement.
  5. Newly derived commands continue until the workflow waits or terminates.

This is not a recovery dispatcher. The Beta deliberately exports no portable worker loop because the public store and Session contracts do not expose the pending-command claim/read and execution-attachment operations a correct cross-process worker would require. Runnable-run discovery and leases alone are insufficient: replaying the last durable event is not a valid substitute for claiming the pending command.

Synchronous Settlement Boundary

The Beta Host treats Agent actions as boundary-to-boundary operations. drainToBoundary() returns an internal immutable frame containing the exact ExecutionState produced by the last commit it observed plus the Session snapshot derived from that state. It does not finish and then call session.snapshot() again; another action could advance the Session between those operations.

text
commit final transition

exact committed nextState

SettledBoundaryFrame
        ├── SettledExecution
        ├── presentation
        ├── application projection
        └── current action surface

Settlement means internal Core status waiting, completed, failed, cancelled, or cancellation_failed. The public Agent contract maps only waiting to the canonical name waiting_input; the Core spelling remains an implementation detail.

If the synchronous command queue becomes empty while state remains pending, running, or cancelling, the Host throws UnsettledExecutionError with code UNSETTLED_EXECUTION. This is an invariant violation, not an ordinary execution outcome and not a deferred-work state. The current Beta does not expose deferred, scheduled, or pending_external settlement.

Infrastructure failure before a boundary commit remains an operational exception. It must never be converted into a synthetic durable failed execution.

Capability execution must be treated as at least once if store-specific code redispatches stored intent; it is never exactly once by itself.

The current Beta has two distinct identities:

  1. idempotencyKey: "payment:" becomes the durable Invariant command identity.
  2. The handler must pass an application-owned stable request key to the external provider. The resolved Invariant command key is not exposed through the public CapabilityHandler parameters today.
ts
import { invariant } from "@invariant-tech/sdk";
import { z } from "zod";

const paymentProvider = {
  charge: async (request: { amount: number; idempotencyKey: string }) => ({
    chargeId: `charge_${request.idempotencyKey}`,
  }),
};

const app = invariant();

export const paymentWorkflow = app.workflow("charge-order", {
  inputSchema: z.object({
    amount: z.number().positive(),
    paymentRequestId: z.string().min(1),
  }),
})
  .capability("charge", {
    idempotencyKey: "payment:{{runId}}",
    handler: async ({ input }) => paymentProvider.charge({
      amount: input.amount,
      idempotencyKey: input.paymentRequestId,
    }),
  });

The application must reuse paymentRequestId for every provider attempt representing the same logical payment. The provider—not Invariant—must enforce deduplication.

Failure and Fallback Edges

Step, reasoning, and capability failures become typed STEP_FAILED, REASON_FAILED, or CAPABILITY_FAILED events. A throwing .step() is never converted into an empty successful result. If the node declares a valid fallback, the kernel routes to that node deterministically; otherwise it derives WORKFLOW_FAILED.

ts
workflow.capability("charge", {
  idempotencyKey: "payment:{{runId}}",
  fallback: "manual-review",
  handler: chargePayment,
});

The current Beta does not expose configurable automatic retry policies. Retry timing and attempt budgets must be implemented by the host or modeled explicitly in a bounded workflow.

Control-flow callbacks also fail closed. .branch() uses only its authored selector—never inferred state fields—and an exception or unmatched key fails the run durably. .repeat() evaluates its optional while predicate before entering the body and always enforces its required positive maxIterations cap; predicate exceptions are durable workflow failures.

Rejection recovery is not execution retry

Semantic rejection recovery (rejectionRecovery: { mode: 'explain' | 'repair' }) at the Agent boundary is a single-shot conversational/repair pass. It does not perform background retry loops, advance unrelated workflows, or retry failed capability steps. In mode: 'repair', authority is strictly narrowed to the same semantic action target and verified against the session's pinned revision before execution.

Canonical Recovery Contract

Use these terms precisely:

  • Durable fact survival means committed rows remain readable after process loss.
  • In-process continuation means the same live Session drains commands until the workflow waits or terminates.
  • Cross-process work continuation means a new process can claim pending commands and continue environmental work through public APIs.
  • Wait-boundary reattachment means a new process reconstructs a committed .wait() and accepts the next validated input.

The current Beta implements durable fact survival, in-process continuation, and wait-boundary reattachment when their prerequisites are present. It does not provide cross-process work continuation.

Canonical release boundary

A file-backed SQLite or PostgreSQL store preserves committed execution facts. It does not turn the public SDK into a recovery worker. There is no public pending-command read/claim API, packaged scanner, retry scheduler, or timer worker. Session restoration reattaches committed state; it does not redispatch interrupted environmental work.

Recovery Contract Matrix

BoundaryDurable after process loss?Continuation modePublic Beta contract
No configured RuntimeStoreNoNoProcess memory only.
sqlite(":memory:")NoNoTest-local durability only; the database disappears with the process.
File-backed SQLite committed state/events/session contextYesRequest-driven at .wait()Reopen, restore the registered graph, and continue validated input.
PostgreSQL committed state/events/session contextYesRequest-driven at .wait()Restore the registered graph and continue with OCC.
Outbox command intent and acknowledgementYes, with a durable storeIn process onlySession commits intent before handler invocation and drains it in the live process.
loadOrCreateSession() / restoreSession() after restartContext + activeRunId + committed stateBoundary reattachmentRequires the same workflow ID to be registered in the new process.
loadState() / readEventLog()YesNoRead APIs expose committed truth but do not attach a Session or dispatch work.
findRunnableExecutions()Runnable runId discoveryNoDiscovery does not expose the pending command payload or claim it.
acquireLease() / renewLease() / releaseLease()Lease rows are durableNoMutual-exclusion primitives; not a worker implementation.
Workflow paused at .wait()YesRequest-driven Session restorationThe reconstructed boundary keeps the committed run ID and revision; submitInput() validates and commits normally.
Capability accepted externally before completion commitIntent survives; provider outcome may be ambiguousNoAny non-public redispatch is at-least-once; provider idempotency is required.
Cancellation/compensation interrupted by process lossCommitted cancellation facts surviveNoNo packaged cross-process compensation continuation loop.
Automatic retry schedulingN/ANoNot included. Model bounded retries explicitly or provide application scheduling.
Automatic timer schedulingN/ANoNot included.
Child workflow executionN/ANoRejected by Beta preflight.
Portable cross-process wait-boundary recoveryYesRequest-drivenAvailable through loadOrCreateSession() and restoreSession(); this is not automatic work redispatch.

Evidence Boundary

The repository tests atomic commits, OCC, leases, runnable-run discovery, file-backed SQLite reopening, Session-to-run restoration at .wait(), in-process intent-before-invocation, and stable kernel command identities. Manual engine/store recovery tests remain evidence for lower-level primitives; they do not imply a packaged command redispatch loop.

Concurrency

Stores serialize transitions with expectedRevision. PostgreSQL locks the current execution row and rejects a stale writer with STALE_TRANSITION; SQLite performs the same check inside its local transaction.

The authoritative PostgreSQL row is workflow_executions, keyed by run_id. Event identity is additionally protected by UNIQUE (run_id, seq).

Within one live Session, action authority checks and mutations are serialized. Each result keeps the causal frame from its own final commit. If Action A settles at revision 8 and Action B immediately advances the Session to revision 9, Action A still returns revision 8 and presentation/projection derived from revision 8.

Session authority uses a separate CAS revision from execution event progression. contextRevision advances only for application context changes; executionRevision advances only for durable run events. The initial run commit also advances sessionRevision and binds activeRunId. A second process with the same stale Session revision loses the entire transaction, which prevents two non-terminal runs from being admitted for one Session.

Next Steps

Invariant Durable Execution Engine.