# Determinism and Replay Safety

> A workflow body re-runs on replay. Anything with a side effect goes through ctx.

Source: https://docs.pyrula.io/workflows/concepts/determinism/

Replay re-runs the body from the top and serves steps that already have `step:done` from the journal. Everything else in the body must make the same decisions from those recorded values, so route non-deterministic or side-effecting work through `ctx`. A call gets its recorded result only if its inputs match; otherwise the run stops with [`replay_input_changed`](#when-a-recorded-calls-inputs-change).

| Call | On replay |
|---|---|
| `@step` call | Recorded result, restored to its declared type |
| `ctx.step(id, fn, *args, kwargs={...})` | Recorded result; a recorded failure raises the same exception again |
| `ctx.once(key, fn, ...)` | Result stored under `key`, across runs |
| `ctx.run_child(name, params)` | Recorded child result; the child is not resubmitted |
| `ctx.now()`, `ctx.uuid()`, `ctx.random()` | Recorded value |
| `ctx.sleep()` / `ctx.sleep_until()` | Skips the wait |
| `ctx.wait_for_signal()` | Recorded signal |
| `ctx.interrupt()` | Recorded response, if the question matches |
| `ctx.emit()` | Not re-emitted. Without `id=`, repeats of one kind are counted in order |

## When a recorded call's inputs change

Each call with inputs records a fingerprint: step arguments, child params, an interrupt's question, and in agents a model request, tool input, sub-agent kwargs and each `gather_subagents` leg. Replay reuses the result only if position and fingerprint both match. Otherwise: `run:error` code `replay_input_changed`, naming the call (`step:fetch#0`).

- Step arguments are compared by parameter name with defaults applied, so `fetch("A")` and `fetch(customer_id="A")` are the same call.
- A recorded call with no fingerprint also refuses.
- `ctx.now()`, `ctx.uuid()`, `ctx.random()`, sleeps and signals take no inputs and replay by position.

Import-time defaults (`datetime.now()`, `uuid4()`) differ in the resuming process, and a mutable default the step mutates differs after the first call. Both refuse on resume. Pass them explicitly or compute inside the step.

Fingerprints stay in your store and never go to Cloud ([What leaves your runtime](https://docs.pyrula.io/getting-started/what-leaves-your-runtime.md)).

## Unsafe operations

```python
@workflow
async def unsafe(ctx):
    now = time.time()        # different every run
    uid = uuid.uuid4()       # different every run
    data = await fetch(url)  # different response on replay

@workflow
async def safe(ctx):
    now = ctx.now()
    uid = ctx.uuid()
    data = await ctx.step("fetch", fetch, url)   # runs once, replays the result
```

The engine only checks steps declared `effect="pure"`: it reads their bytecode and rejects direct `time`, `random`, `uuid` or `datetime` calls, following same-module helpers. Other-module helpers slip through and default steps aren't inspected. Keep non-deterministic work behind `ctx`.

## Idempotent side effects

A step with `step:done` never re-runs, but a crash between the side effect and that record runs it again. `@step` exposes a stable `StepContext.idempotency_key` ([Steps](https://docs.pyrula.io/workflows/guides/steps.md), [the crash window](https://docs.pyrula.io/workflows/concepts/replay-and-recovery.md#the-window-where-a-step-can-run-twice)). For effects that can't be made idempotent, gate with [`ctx.interrupt()`](https://docs.pyrula.io/workflows/guides/human-in-the-loop.md).

## Version skew

An explicit `version=` pins the run to workers on that version. Without one, new code adopts in-flight runs ([Versioning and deploys](https://docs.pyrula.io/workflows/guides/versioning.md)). If new code diverges from an old journal, the run fails rather than return a wrong result.

| Safe while runs are in flight | Not safe |
|---|---|
| Changing a step's implementation, keeping its id and position | Renaming or reordering steps |
| Adding workflows, or steps in-flight runs don't reach | Removing a step an in-flight run already recorded |
| | Changing a recorded call's arguments (`replay_input_changed`) |

## Deploy-safe versioning

Gate a structural change behind `ctx.patched`:

```python
@workflow
async def charge(ctx, amount_cents: int):
    await ctx.step("charge", lambda: payment_client.charge(amount_cents))
    if await ctx.patched("refund-flow"):
        await ctx.step("refund-hold", lambda: payment_client.hold_refund_window())
    return "done"
```

Replaying past this point without a marker takes the old branch. Reaching it live takes the new one and writes a marker.

Check it in CI with `pyrula.workflows.testing`:

```python
from pyrula.workflows.testing import assert_replay_compatible, export_run_history

history = export_run_history(store, "charge", run_id)   # from a live or archived run
assert_replay_compatible(charge, history)               # AssertionError on divergence
```

`export_run_history` returns JSON-able dicts with the six [envelope](https://docs.pyrula.io/workflows/concepts/runs-as-streams.md#the-event-envelope) keys, so a history can live in your repo as a fixture.

Once old runs have drained past the patch point, switch to `await ctx.deprecate_patch("refund-flow")` (still writes the marker, returns nothing). When nothing needs the old branch, delete it and the guard.
