Skip to content

Effects, faults, inspection, and hosting

Determa State's portable core is a foreground transform. One call receives a bundle, prior aggregate state, and at most one envelope. It returns the next state, ordered emissions, a disposition, and any rejection or engine fault. It does not perform network I/O or retain hidden work.

This chapter separates that portable result from the host that stores state, queues events, calls external services, schedules time, or exposes an MCP tool.

Model external work as a request and a later result

The example has three public workflows:

  • start_payment emits a payment_requested intent. A later payment_succeeded or payment_rejected input carries the same correlation ID.
  • wait emits a schedule_requested intent. A host-provided timer extension may later deliver schedule_elapsed.
  • force_fault emits a tentative audit intent and then divides by zero. The complete RTC step rolls back, including that tentative intent.
format: 1
namespace: tutorial.effects_faults_hosting
events:
  start_payment:
    direction: input
    payload:
      request_id: { type: string, required: true }
      amount: { type: int, required: true }
  payment_requested:
    direction: output
    payload:
      amount: { type: int, required: true }
  payment_succeeded:
    direction: input
    correlates_to: payment_requested
    payload:
      receipt: { type: string, required: true }
  payment_rejected:
    direction: input
    correlates_to: payment_requested
    payload:
      reason: { type: string, required: true }
  wait:
    direction: input
    payload:
      request_id: { type: string, required: true }
      delay_seconds: { type: int, required: true }
  schedule_requested:
    direction: output
    payload:
      delay_seconds: { type: int, required: true }
  schedule_elapsed:
    direction: input
    correlates_to: schedule_requested
  audit_requested:
    direction: output
    payload:
      label: { type: string, required: true }
  force_fault: { direction: input }
  finish: { direction: input }
machines:
  - machine_id: workflow
    version: 1
    root:
      type: composite
      variables:
        attempts: { type: int, init: 0 }
        outcome: { type: string, init: pending }
        detail: { type: string, init: "" }
      initial: { transition_to: ready }
      on_events:
        force_fault:
          action:
            - send:
                event: audit_requested
                to: { external: true }
                correlation_id: "'fault-demo'"
                payload: { label: "'must-roll-back'" }
            - assign: { attempts: "attempts / 0" }
      states:
        ready:
          on_events:
            start_payment:
              transition_to: awaiting_payment
              action:
                - assign: { attempts: "attempts + 1" }
                - send:
                    event: payment_requested
                    to: { external: true }
                    correlation_id: "event.payload.request_id"
                    payload: { amount: "event.payload.amount" }
            wait:
              transition_to: awaiting_timer
              action:
                - send:
                    event: schedule_requested
                    to: { external: true }
                    correlation_id: "event.payload.request_id"
                    payload:
                      delay_seconds: "event.payload.delay_seconds"
            finish: { transition_to: completed }
        awaiting_payment:
          on_events:
            payment_succeeded:
              transition_to: completed
              action:
                - assign: { outcome: "'succeeded'" }
                - assign: { detail: "event.payload.receipt" }
            payment_rejected:
              transition_to: declined
              action:
                - assign: { outcome: "'declined'" }
                - assign: { detail: "event.payload.reason" }
        awaiting_timer:
          on_events:
            schedule_elapsed: { transition_to: completed }
        declined: {}
        completed: { type: final }

An output is an intent, not proof that payment or scheduling succeeded. The host may persist and deliver the intent. Only a later declared input changes the machine based on the external outcome.

Every send to { external: true } must name a public output event and provide a non-empty correlation ID. A correlated public input must name that output through correlates_to and arrive with a correlation ID. Correlation remains envelope metadata; CEL cannot read it.

Public payload contracts use JSON-shaped values: strings are valid Unicode scalar sequences, integers stay within signed 64-bit range, floats are finite binary64, and lists/maps are recursively checked. The host transport must preserve those typed values. Format 1 does not standardize one broker, HTTP, or MCP JSON envelope encoding.

Run the complete Python trace

The trace retries the same uncommitted payment request from the same prior state. Both attempts return the same effect identity, output sequence, state, and emission order. It then demonstrates correlation rejection, an ordinary domain rejection, timer adaptation, atomic engine-fault rollback, and terminal read-only inspection.

from pathlib import Path
import sys

import determa.state as ds


def root_target(state):
    return {
        "root": {
            "root_instance_id": state["root_instance_id"],
            "root_runtime_id": state["root_runtime_id"],
        }
    }


def delivery(state, event, event_id, payload=None, correlation_id=None):
    envelope = {
        "event": event,
        "event_id": event_id,
        "target": root_target(state),
        "payload": payload or {},
    }
    if correlation_id is not None:
        envelope["correlation_id"] = correlation_id
    return {"input": envelope}


def create(bundle, suffix):
    result = ds.create(
        bundle,
        machine_id="workflow",
        root_instance_id=f"tutorial:{suffix}",
        creation_id=f"tutorial:{suffix}:create",
        bindings={},
    )
    assert result["status"] == "running"
    return result["state"]


bundle = ds.load_bundle(Path(sys.argv[1]).read_text())

payment_prior = create(bundle, "payment")
payment_input = delivery(
    payment_prior,
    "start_payment",
    "tutorial:payment:start",
    {"request_id": "payment-42", "amount": 1250},
)
payment = ds.dispatch(bundle, payment_prior, payment_input)
retry = ds.dispatch(bundle, payment_prior, payment_input)
assert payment == retry
assert payment["disposition"] == "handled"
assert payment["emissions"] == retry["emissions"]
intent = payment["emissions"][0]
assert intent["event"] == "payment_requested"
assert intent["target"] == "external"
assert intent["correlation_id"] == "payment-42"
assert intent["payload"] == {"amount": 1250}
assert intent["effect_id"].startswith("sha256:")
assert intent["sequence"] == 0
payment_state = payment["state"]

missing_correlation = ds.dispatch(
    bundle,
    payment_state,
    delivery(
        payment_state,
        "payment_succeeded",
        "tutorial:payment:missing-correlation",
        {"receipt": "receipt-1"},
    ),
)
assert missing_correlation["disposition"] == "rejected"
assert missing_correlation["rejection"] == {"code": "invalid_correlation"}
assert missing_correlation["state"] == payment_state

completed = ds.dispatch(
    bundle,
    payment_state,
    delivery(
        payment_state,
        "payment_succeeded",
        "tutorial:payment:succeeded",
        {"receipt": "receipt-1"},
        "payment-42",
    ),
)
assert completed["status"] == "completed"
completed_state = completed["state"]
terminal_read = ds.dispatch(bundle, completed_state, None)
assert terminal_read["state"] == completed_state
assert terminal_read["disposition"] is None
assert terminal_read["emissions"] == []

domain_prior = create(bundle, "domain")
domain_started = ds.dispatch(
    bundle,
    domain_prior,
    delivery(
        domain_prior,
        "start_payment",
        "tutorial:domain:start",
        {"request_id": "payment-declined", "amount": 50},
    ),
)
domain_state = domain_started["state"]
declined = ds.dispatch(
    bundle,
    domain_state,
    delivery(
        domain_state,
        "payment_rejected",
        "tutorial:domain:declined",
        {"reason": "card_declined"},
        "payment-declined",
    ),
)
assert declined["status"] == "running"
assert declined["disposition"] == "handled"
assert declined["fault"] is None
declined_state = declined["state"]
declined_root = declined_state["runtimes"][declined_state["root_runtime_id"]]
assert declined_root["active"] == ["root", "declined"]
assert declined_root["scopes"]["root"]["outcome"] == "declined"

timer_prior = create(bundle, "timer")
scheduled = ds.dispatch(
    bundle,
    timer_prior,
    delivery(
        timer_prior,
        "wait",
        "tutorial:timer:wait",
        {"request_id": "timer-7", "delay_seconds": 30},
    ),
)
schedule_intent = scheduled["emissions"][0]
assert schedule_intent["event"] == "schedule_requested"
assert schedule_intent["correlation_id"] == "timer-7"
timer_state = scheduled["state"]
elapsed = ds.dispatch(
    bundle,
    timer_state,
    delivery(
        timer_state,
        "schedule_elapsed",
        "tutorial:timer:elapsed",
        correlation_id="timer-7",
    ),
)
assert elapsed["status"] == "completed"

fault_prior = create(bundle, "fault")
faulted = ds.dispatch(
    bundle,
    fault_prior,
    delivery(fault_prior, "force_fault", "tutorial:fault:force"),
)
assert faulted["status"] == "faulted"
assert faulted["disposition"] == "faulted"
assert faulted["emissions"] == []
assert faulted["fault"]["code"] == "action_fault"
fault_state = faulted["state"]
fault_root = fault_state["runtimes"][fault_state["root_runtime_id"]]
assert fault_root["scopes"]["root"]["attempts"] == 0

fault_read = ds.dispatch(bundle, fault_state, None)
assert fault_read["state"] == fault_state
assert fault_read["fault"] == faulted["fault"]
assert fault_read["emissions"] == []
blocked = ds.dispatch(
    bundle,
    fault_state,
    delivery(
        fault_state,
        "start_payment",
        "tutorial:fault:blocked",
        {"request_id": "never", "amount": 1},
    ),
)
assert blocked["disposition"] == "rejected"
assert blocked["rejection"] == {"code": "invalid_instance_target"}
assert blocked["state"] == fault_state

print(
    "effect=deterministic; correlation=enforced; domain=handled; "
    "timer=host-event; fault=rolled-back; terminal=stable"
)

Run the same trace with Rust

The Rust program consumes the same bundle and asserts the same portable observations.

[package]
name = "determa-effects-faults-hosting"
version = "0.2.0"
edition = "2021"
publish = false

[dependencies]
determa-state = "=0.2.0"
use determa_state::{
    create, dispatch, load_bundle, Bindings, Delivery, Disposition, Envelope,
    ResultStatus, Target, Value,
};
use std::{collections::BTreeMap, env, fs};

fn root_target(state: &determa_state::AggregateState) -> Target {
    Target::Root {
        root_instance_id: state.root_instance_id.clone(),
        root_runtime_id: state.root.runtime_id.clone(),
    }
}

fn input(
    state: &determa_state::AggregateState,
    event: &str,
    event_id: &str,
    payload: BTreeMap<String, Value>,
    correlation_id: Option<&str>,
) -> Delivery {
    Delivery::Input(Envelope {
        event: event.to_string(),
        event_id: event_id.to_string(),
        target: root_target(state),
        payload,
        correlation_id: correlation_id.map(str::to_string),
    })
}

fn create_state(bundle: &determa_state::Bundle, suffix: &str) -> determa_state::AggregateState {
    create(
        bundle,
        "workflow",
        &format!("tutorial:{suffix}"),
        &format!("tutorial:{suffix}:create"),
        &Bindings::default(),
    )
    .state
    .expect("creation succeeds")
}

fn text(value: &str) -> Value {
    Value::String(value.to_string())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let machine_path = env::args().nth(1).expect("machine path");
    let bundle = load_bundle(&fs::read_to_string(machine_path)?)?;

    let payment_prior = create_state(&bundle, "payment");
    let payment_input = input(
        &payment_prior,
        "start_payment",
        "tutorial:payment:start",
        BTreeMap::from([
            ("request_id".to_string(), text("payment-42")),
            ("amount".to_string(), Value::Int(1250)),
        ]),
        None,
    );
    let payment = dispatch(&bundle, &payment_prior, Some(payment_input.clone()));
    let retry = dispatch(&bundle, &payment_prior, Some(payment_input));
    assert_eq!(payment.disposition, Some(Disposition::Handled));
    assert_eq!(payment.emissions, retry.emissions);
    assert_eq!(payment.state, retry.state);
    let intent = &payment.emissions[0];
    assert_eq!(intent.event, "payment_requested");
    assert_eq!(intent.target, Target::External);
    assert_eq!(intent.correlation_id.as_deref(), Some("payment-42"));
    assert_eq!(intent.payload["amount"], Value::Int(1250));
    assert!(intent
        .effect_id
        .as_deref()
        .is_some_and(|value| value.starts_with("sha256:")));
    assert_eq!(intent.sequence.as_ref().map(ToString::to_string).as_deref(), Some("0"));
    let payment_state = payment.state.expect("payment state");

    let missing_correlation = dispatch(
        &bundle,
        &payment_state,
        Some(input(
            &payment_state,
            "payment_succeeded",
            "tutorial:payment:missing-correlation",
            BTreeMap::from([("receipt".to_string(), text("receipt-1"))]),
            None,
        )),
    );
    assert_eq!(missing_correlation.disposition, Some(Disposition::Rejected));
    assert_eq!(
        missing_correlation
            .rejection
            .as_ref()
            .map(|value| value.code.as_str()),
        Some("invalid_correlation")
    );
    assert_eq!(missing_correlation.state.as_ref(), Some(&payment_state));

    let completed = dispatch(
        &bundle,
        &payment_state,
        Some(input(
            &payment_state,
            "payment_succeeded",
            "tutorial:payment:succeeded",
            BTreeMap::from([("receipt".to_string(), text("receipt-1"))]),
            Some("payment-42"),
        )),
    );
    assert_eq!(completed.status, ResultStatus::Completed);
    let completed_state = completed.state.expect("completed state");
    let terminal_read = dispatch(&bundle, &completed_state, None);
    assert_eq!(terminal_read.state.as_ref(), Some(&completed_state));
    assert_eq!(terminal_read.disposition, None);
    assert!(terminal_read.emissions.is_empty());

    let domain_prior = create_state(&bundle, "domain");
    let domain_started = dispatch(
        &bundle,
        &domain_prior,
        Some(input(
            &domain_prior,
            "start_payment",
            "tutorial:domain:start",
            BTreeMap::from([
                ("request_id".to_string(), text("payment-declined")),
                ("amount".to_string(), Value::Int(50)),
            ]),
            None,
        )),
    )
    .state
    .expect("domain started");
    let declined = dispatch(
        &bundle,
        &domain_started,
        Some(input(
            &domain_started,
            "payment_rejected",
            "tutorial:domain:declined",
            BTreeMap::from([("reason".to_string(), text("card_declined"))]),
            Some("payment-declined"),
        )),
    );
    assert_eq!(declined.status, ResultStatus::Running);
    assert_eq!(declined.disposition, Some(Disposition::Handled));
    assert!(declined.fault.is_none());
    let declined_state = declined.state.expect("declined state");
    assert_eq!(declined_state.root.config(), vec!["declined"]);
    assert_eq!(
        declined_state.root.visible_variables()["outcome"],
        text("declined")
    );

    let timer_prior = create_state(&bundle, "timer");
    let scheduled = dispatch(
        &bundle,
        &timer_prior,
        Some(input(
            &timer_prior,
            "wait",
            "tutorial:timer:wait",
            BTreeMap::from([
                ("request_id".to_string(), text("timer-7")),
                ("delay_seconds".to_string(), Value::Int(30)),
            ]),
            None,
        )),
    );
    assert_eq!(scheduled.emissions[0].event, "schedule_requested");
    assert_eq!(
        scheduled.emissions[0].correlation_id.as_deref(),
        Some("timer-7")
    );
    let timer_state = scheduled.state.expect("timer state");
    let elapsed = dispatch(
        &bundle,
        &timer_state,
        Some(input(
            &timer_state,
            "schedule_elapsed",
            "tutorial:timer:elapsed",
            BTreeMap::new(),
            Some("timer-7"),
        )),
    );
    assert_eq!(elapsed.status, ResultStatus::Completed);

    let fault_prior = create_state(&bundle, "fault");
    let faulted = dispatch(
        &bundle,
        &fault_prior,
        Some(input(
            &fault_prior,
            "force_fault",
            "tutorial:fault:force",
            BTreeMap::new(),
            None,
        )),
    );
    assert_eq!(faulted.status, ResultStatus::Faulted);
    assert_eq!(faulted.disposition, Some(Disposition::Faulted));
    assert!(faulted.emissions.is_empty());
    assert_eq!(faulted.fault.as_ref().map(|fault| fault.code.as_str()), Some("action_fault"));
    let fault_state = faulted.state.expect("fault state");
    assert_eq!(fault_state.root.visible_variables()["attempts"], Value::Int(0));

    let fault_read = dispatch(&bundle, &fault_state, None);
    assert_eq!(fault_read.state.as_ref(), Some(&fault_state));
    assert_eq!(fault_read.fault, faulted.fault);
    assert!(fault_read.emissions.is_empty());
    let blocked = dispatch(
        &bundle,
        &fault_state,
        Some(input(
            &fault_state,
            "start_payment",
            "tutorial:fault:blocked",
            BTreeMap::from([
                ("request_id".to_string(), text("never")),
                ("amount".to_string(), Value::Int(1)),
            ]),
            None,
        )),
    );
    assert_eq!(blocked.disposition, Some(Disposition::Rejected));
    assert_eq!(
        blocked.rejection.as_ref().map(|value| value.code.as_str()),
        Some("invalid_instance_target")
    );
    assert_eq!(blocked.state.as_ref(), Some(&fault_state));

    println!(
        "effect=deterministic; correlation=enforced; domain=handled; \
timer=host-event; fault=rolled-back; terminal=stable"
    );
    Ok(())
}

Run either extracted trace directly, or let make check run both:

python .cache/examples/python/effects_faults_hosting.py \
  .cache/examples/machines/effects-faults-hosting.yaml
cargo run \
  --manifest-path .cache/examples/rust/effects-faults-hosting/Cargo.toml \
  -- .cache/examples/machines/effects-faults-hosting.yaml

Expected output:

effect=deterministic; correlation=enforced; domain=handled; timer=host-event; fault=rolled-back; terminal=stable

Read the result before choosing host policy

The core result tells the host what happened:

Field Meaning
status Existing aggregate is running, completed, or faulted; rejected creation has no aggregate.
disposition Delivery was handled, unhandled, rejected, or faulted; a read-only call uses null.
state New aggregate state, or the exact prior state on rejection/unhandled/read-only processing.
emissions Ordered internal envelopes and external effect intents produced by this call.
fault Committed engine-fault record when this call exposes one.
rejection Pre-step validation code; rejection is not an engine fault.

unhandled is ordinary statechart behavior. A declared event such as payment_rejected is an ordinary domain outcome. An engine fault means a valid, accepted RTC step could not obey the machine contract. The engine rolls that step back, commits one diagnostic fault record, consumes one logical step sequence, and returns no author intent from the failed step.

After the root is faulted, ordinary delivery is rejected without changing the aggregate. After it is completed, ordinary delivery is likewise terminal. A null delivery is a read-only inspection call for either terminal state and returns unchanged state, no disposition, and no new emissions.

The in-memory aggregate can be inspected for identities, status, active configuration, variables, ownership, history, counters, and fault records. This is an abstract logical-state API. Determa State 0.2.0 also defines portable aggregate serialization, restoration, definition migration, and optional durable execution checkpoints. Start with the durable checkpoint host, build the lower-level database-backed host in the persistence and migration tutorial, then run packages, complete transforms, terminal maintenance, and security limits in the persistence reference lab.

Keep host responsibilities outside the bundle

Boundary Portable core Host or plugin
Queue Returns ordered immutable emissions. Chooses ordering across calls, delivery attempts, retry, acknowledgement, deduplication, backpressure, and dead-letter policy.
Timer Emits a declared scheduling request and later accepts a declared correlated input. Owns the clock, scheduler, durability, cancellation, lateness, and duplicate policy.
Database Produces deterministic state and output intents for one foreground call. Chooses storage representation and may transactionally combine its own inbox, aggregate storage, business data, and outbox.
Broker Gives every internal envelope an event ID and every external intent an effect ID. Owns publishing, acknowledgement, redelivery, deduplication, and broker-native dead letters.
MCP Public input declarations can define adapter-visible requests. Owns tool naming, authentication, authorization, tenancy, transport, and presentation.

The deterministic IDs make idempotent host designs possible; they do not promise delivery exactly once. A database transaction is local host behavior, not distributed ACID with a broker or payment provider. A scheduling intent is not a portable timer, and a delivered intent is not remote success.

Know the format-1 completeness boundary

Format 1 deliberately has no native clocks, timers, sleeps, queues, retries, acknowledgements, deferrals, dead-letter store, implicit parallel broadcast, shared runtime variables, direct host-to-component delivery, cross-runtime transitions, remote or detached spawn, package imports, implicit definition hot-swap, standardized cross-language store/CLI protocol, root-fault recovery, root re-entry, distributed exactly-once transaction, hard real-time guarantee, or standardized plugin configuration.

Those names are not reserved extension fields. A host may provide applicable behavior outside the core through declared events and plugins, but a format-1 machine cannot inspect it or rely on an undeclared portable guarantee. Existing portable alternatives remain explicit: isolated components instead of regions, public events instead of shared runtime variables/state, scheduling requests instead of native timers, and read-only aggregate inspection instead of executable observers.

Normative coverage

The exact released specification sections explained here are:

Released conformance examples: