Commit Envelopes
A commit envelope is Causet’s declarative pattern for coordinating writes across multiple entity streams atomically. Think of it as a two-phase commit (2PC) — but expressed entirely in the DSL without any distributed transaction manager.
The most common use cases are wallet transfers (debit sender, credit receiver) and inventory reserves (hold stock, capture payment, confirm shipment).
┌─────────────────────────────────────────────────────────────┐
│ Commit Envelope │
│ │
│ start_action ──► PREPARE ──► all participants ACK │
│ │ │
│ ┌──────┴──────┐ │
│ ▼ ▼ │
│ COMMIT ABORT │
│ (all apply) (all rollback) │
│ │ │
│ timeout / failure │
└─────────────────────────────────────────────────────────────┘When to use commit envelopes
Use a commit envelope when:
- You need an atomic debit + credit across two entity streams
- You need to reserve inventory and capture payment as a unit
- A multi-step operation must either fully complete or fully abort
- You want the coordination logic declared in the DSL, not hand-coded in service code
Do not use a commit envelope for simple single-entity state updates — those are just normal actions. Commit envelopes are for cross-stream atomicity.
DSL shape
commit_envelopes:
wallet_transfer:
# Action that kicks off the envelope
start_action: INITIATE_TRANSFER
# Entity stream and field path used to track envelope state
envelope_state: transfer
envelope_state_path: /transfer_state
# The four events that drive the lifecycle
lifecycle:
prepare_event: TRANSFER_PREPARED
prepared_event: TRANSFER_PREPARE_CONFIRMED
commit_event: TRANSFER_COMMITTED
abort_event: TRANSFER_ABORTED
# Auto-abort if participants don't all ACK within this window
timeout:
abort_after_seconds: 30
tick_event: CLOCK_TICK # a kind: system event that fires periodically
# Optional: sequence ordering across participants
causal_ordering:
mode: strict
cursor_field: cursor
seq_source: envelope
# Who participates in the envelope
participants:
- name: sender
state: wallet
entity_expr: event.sender_id
- name: receiver
state: wallet
entity_expr: event.receiver_idTop-level fields
| Field | Required | Description |
|---|---|---|
start_action | yes | The action name that initiates the envelope |
envelope_state | yes | Entity type used for the envelope ledger record |
envelope_state_path | yes | Field path on envelope_state entities that stores envelope state |
lifecycle | yes | The four event types that drive the prepare/commit/abort cycle |
timeout | yes | Auto-abort configuration |
participants | yes | The entity streams that participate in the coordination |
causal_ordering | no | Optional strict sequencing across participants |
Lifecycle
A commit envelope always moves through four phases driven by four events:
| Event field | Description |
|---|---|
prepare_event | Emitted by start_action. Tells all participants to stage their changes. |
prepared_event | Emitted when all participants have ACK’d their staging phase. Triggers commit. |
commit_event | Emitted to all participants to apply staged changes permanently. |
abort_event | Emitted when a participant rejects, or when timeout fires. Triggers rollback. |
See the Lifecycle & Timeout page for a complete state diagram and timeout configuration.
Participants
Each participant declares which entity stream it touches and how to route the envelope events to the right entity.
participants:
- name: sender
state: wallet
entity_expr: event.sender_id
- name: receiver
state: wallet
entity_expr: event.receiver_idParticipants can also declare:
visibility_gate— expression that must be true for this participant to join (e.g. conditional enrollment)delta_expr— the value being staged (for audit / abort reconciliation)participant_type— optional tag for specialized runtime routingstaging/apply/abort_cleanup— advanced rule blocks for full control over each phase
See the Participants page for the full field reference.
Timeout
If any participant fails to ACK the prepare_event within abort_after_seconds, the runtime emits the abort_event and all participants execute their abort_cleanup rules.
timeout:
abort_after_seconds: 30
tick_event: CLOCK_TICKtick_event must be a kind: system event that fires on a regular schedule (typically every few seconds). The runtime uses it to check elapsed time and trigger the abort path.
Make sure your tick_event interval is shorter than abort_after_seconds. If the tick fires every 30 seconds and your timeout is 30 seconds, you may get a 2× window in practice.
Causal ordering
Optional strict sequencing ensures that participant events are processed in order, preventing race conditions on high-throughput streams.
causal_ordering:
mode: strict
cursor_field: cursor
seq_source: envelope| Field | Description |
|---|---|
mode | strict — the runtime enforces monotonic sequence ordering |
cursor_field | The field name in the event payload that carries the sequence number |
seq_source | envelope — the envelope assigns sequence numbers to participant events |
See Causal Ordering for details.
Minimal example: wallet transfer
This example debits a sender and credits a receiver atomically.
commit_envelopes:
wallet_transfer:
start_action: INITIATE_TRANSFER
envelope_state: transfer
envelope_state_path: /transfer_state
lifecycle:
prepare_event: TRANSFER_PREPARED
prepared_event: TRANSFER_PREPARE_CONFIRMED
commit_event: TRANSFER_COMMITTED
abort_event: TRANSFER_ABORTED
timeout:
abort_after_seconds: 30
tick_event: CLOCK_TICK
participants:
- name: sender
state: wallet
entity_expr: event.sender_id
delta_expr: "-event.amount"
- name: receiver
state: wallet
entity_expr: event.receiver_id
delta_expr: "event.amount"The matching action and events look like this:
events:
TRANSFER_PREPARED:
state: transfer
entity_expr: event.transfer_id
payload:
transfer_id: string
sender_id: string
receiver_id: string
amount: number
TRANSFER_PREPARE_CONFIRMED:
state: transfer
entity_expr: event.transfer_id
payload:
transfer_id: string
TRANSFER_COMMITTED:
state: transfer
entity_expr: event.transfer_id
payload:
transfer_id: string
TRANSFER_ABORTED:
state: transfer
entity_expr: event.transfer_id
payload:
transfer_id: string
reason: string
actions:
INITIATE_TRANSFER:
state: transfer
entity_id_expr: intent.transfer_id
input:
transfer_id: { type: string, required: true }
sender_id: { type: string, required: true }
receiver_id: { type: string, required: true }
amount: { type: number, required: true }
preflight:
rules:
- name: reject_self_transfer
when:
expr: "event.sender_id == event.receiver_id"
then:
- op: reject
code: SELF_TRANSFER
- name: reject_zero_amount
when:
expr: "event.amount <= 0"
then:
- op: reject
code: INVALID_AMOUNT
core:
rules:
- name: emit_prepared
when: {}
then:
- op: set
path: /status
value: preparing
- op: emit
event_type: TRANSFER_PREPARED
payload:
transfer_id: event.transfer_id
sender_id: event.sender_id
receiver_id: event.receiver_id
amount: event.amountSee the Examples page for a full inventory reserve pattern with staging and abort cleanup.
Compiler output
The compiler expands a commit_envelope block into:
- Rules on the envelope entity stream — tracking prepare/ACK/commit/abort state transitions
- Rules on each participant stream — staging, applying, and cleaning up deltas
- A timeout check rule — triggered by the
tick_event, emitsabort_eventif elapsed >abort_after_seconds
You do not write these rules by hand — the compiler generates them from your commit_envelope declaration.