# Saved Cases

> A content-addressed capture of a run's inputs, history up to a fork point, and recorded call results.

Source: https://docs.pyrula.io/workflows/concepts/saved-cases/

A case is a recorded run frozen at a fork point: inputs, history up to the fork, every recorded call result. It is a content-hashed bundle, so saving the same run at the same fork twice gives one copy.

```bash
export PYRULA_FIXTURE_STORE=/srv/pyrula/cases
pyrula case save my_workflow run_abc123 --fork-after-seq 42
```

```json
{"fixture_hash": "9f2c...", "fixture_store": "file:///srv/pyrula/cases", "manifest_summary": {"format": 1, "kind": "workflow", "target_name": "my_workflow", "fork_after_seq": 42, "capture": {"complete": true, "missing": []}, "result_count": 6, "history_count": 19}}
```

`--fork-after-seq` is a `run_seq`, not an event count. Omit it to keep only the inputs.

## Fixture stores

Resolved from `--fixture-store`, then `PYRULA_FIXTURE_STORE`, then `PYRULA_POSTGRES_DSN`. With none set, every subcommand refuses with `no_fixture_store`.

| Value | Store |
|---|---|
| `memory:` | Per instance, tests only. |
| `file:///abs/path` or a bare path | A local directory, created if missing. |
| `postgres://...` | Tables in the archive schema. Run `pyrula db migrate` first. |
| `s3://bucket/prefix?region=...&endpoint_url=...` | Needs the `"pyrula[workflows,s3]"` extra. Credentials come from the AWS SDK chain, never the URL. |

## Cloud workers

Cloud-connected workers read the same `PYRULA_FIXTURE_STORE` / `PYRULA_POSTGRES_DSN`. Ordinary workers support `save_case`, `run_case` and `delete_case_bundle`; Kafka workers and API-only processes don't.

A `run_case` message names a source:

- `bundle`: a saved case from the fixture store. Needs a store.
- `journal`: a run on the worker's own journal, archived runs included. No store needed, but then `save_case` and `delete_case_bundle` are unavailable.

If the run aged out of Valkey (`completed_ttl`, a day by default) with no archive, the new run ends `whatif_source_missing`; a changed rebuild ends `whatif_source_changed`. Budgets, labels and codes: [What-if runs](https://docs.pyrula.io/workflows/guides/what-if.md). A `run_case` ack means only that the run was durably submitted; the outcome arrives in the run's events.

## Save, export, import

`pyrula case save TARGET RUN_ID` reads the run from Valkey (`--namespace`) or the Postgres archive and writes the bundle to the store. It refuses a capture replay could not use (`capture_incomplete`). `--kind` is detected from the run's model and tool steps; `--kind workflow` on a run with model or tool calls refuses with `kind_mismatch`.

```bash
pyrula case export 9f2c... --out run.json --json   # readable, diffable
pyrula case import run.json                        # same hash back
pyrula case list
pyrula case show 9f2c... [--values]
pyrula case delete 9f2c...
```

`export` writes the compressed blob by default; `--json` gives a diffable file. `import` accepts either.

## Bundle format

Bundles hold raw inputs and outputs, unredacted. Treat a fixture store like a copy of the data the run touched; `export` and `show --values` warn on stderr.

Format 1 is JSON with three keys:

- `manifest`: `format`, `kind`, `target_name`, `code_version`, `registration`, `recorded_config`, `pyrula_version`, `source`, `fork_after_seq`, `capture` and `inputs`.
- `history`: events with `run_seq <= fork_after_seq`, terminal kinds dropped.
- `results`: every recorded call result, including after the fork, as `{key, kind, fingerprint, result}` or `{..., error}`.

The hash is SHA-256 of the uncompressed bytes (sorted keys, compact separators, UTF-8, `results` sorted by `(kind, key)`). An unknown manifest `kind` or unreadable `format` refuses with `format_unsupported`. Bundles over 512 MiB are refused at save.

For agents, `registration.tools` lists every tool offered with its `writes` flag, and `recorded_config.model` is the turn's first model call.

## Cases in version control

```bash
pyrula case save my_workflow run_abc123 --fork-after-seq 42 --fixture-store tests/cases
pyrula case export 9f2c... --out tests/cases/9f2c....json --json
```

## Garbage collection

```bash
pyrula case gc --keep 9f2c... --keep-file keep-hashes.txt [--yes]
```

Without `--keep` every bundle is a candidate. Without `--yes`, `gc` only lists.

## Running a case

`pyrula case run` starts a local run from a bundle, named by its hash in the fixture store or by a `.case` file such as the ones `pyrula dev` keeps in `.pyrula/last/`. It first replays the recorded prefix in verification mode: every prefix call must be consumed with its recorded identity and fingerprint, or it refuses with `prefix_diverged`.

| Flag | Effect |
|---|---|
| `--from-start` | Reruns the whole recorded run instead of only what follows the fork. |
| `--model`, `--prompt-file` | Run an agent's model calls live on another model or system prompt ([Record once, iterate free](https://docs.pyrula.io/workflows/guides/record-once-iterate-free.md)). |
| `--input KEY=JSON` | Changes an input; only for inputs that don't change the prefix. |

Each call then resolves down a ladder:

1. Reuse the recorded result (`reused`).
2. Use a supplied answer (`simulated`).
3. Run live if permitted (`live` for a new input, `changed` when it replaces a recorded result).
4. Stop with a reason code.

```bash
pyrula case run CASE_HASH --target my_app.workflows:checkout \
  --fixture-store tests/cases --input sku='"SKU-42"'
```

Agent cases also need `--llm-ref MODULE:ATTR`. `--deps-ref MODULE:ATTR` supplies dependencies, `--app-dir DIR` extends the import path, `--run-id ID` sets the run id.

### Live calls and effects

New model and read calls run live by default, within `--live-budget N` (`max_live_calls`). Writes stop with `write_not_allowed` unless allowed with `--allow-write TOKEN`, repeated per token. Steps declare `effect="read" | "write" | "pure"`; undeclared is `"write"`. A tool is a read only with `@tool(read_only=True)` or as a sub-agent. Pure calls do not count against the budget.

Tokens: `tool:<name>` for a tool the model calls, `step:<name>` for one the agent body calls, and `step:<id>` using ids from `registration.steps` (`step:child:0`, `step:sigsend:0`, `step:gather:<id>`). A signal send is a write.

### Replay only

`--replay-only` removes the live rung: every non-pure call must be answered by the recording, so no model client is needed and `--supply` does not apply. An unanswerable call stops with `replay_diverged`, with `reason` one of `input_changed`, `unexpected_call` or `recorded_error`. A recorded post-fork call the run never makes is listed in `unconsumed_keys` and the command exits 3.

A pass means the changed code made the same calls with the same inputs, not that a live model would answer the same. [Replay checks in CI](https://docs.pyrula.io/workflows/guides/replay-ci.md) runs a Cloud suite this way.

### Supplied results and budgets

`--supply FILE` is a JSON object with `calls` (keyed `"<kind>:<key>"`) and `tools` (keyed by tool name), at most 200 entries each and 64 KiB total. A wrong-shaped answer stops with `missing_context`. Model answers are a string or `{"role": "assistant", "content": [...]}`; interrupt answers are `{"action": "...", "value": ...}`; signal answers require `payload`.

`--max-cost-usd X` limits spend using the [default price table](https://docs.pyrula.io/agents/authoring/agents.md#default-prices). `--prices FILE` adds or overrides models (at most 100, cache rates default to the input rate; not stored in the bundle). An entry may add `"long_context": {"above_input_tokens": N, "input_per_mtok": ..., "output_per_mtok": ...}` (cache rates optional, defaulting to that tier's input rate): a call whose prompt (`input + cache creation + cache read` tokens) is above N is priced entirely at the tier's rates:

```json
{"model-id": {"input_per_mtok": 2.5, "output_per_mtok": 10, "cache_write_per_mtok": 2.5, "cache_read_per_mtok": 0.5}}
```

Under a cost limit, a live model call with no price or unobservable spend stops with `live_budget_exceeded`.

### Waits and tool ids

A case run never waits. A post-fork durable sleep advances the run's clock (and `ctx.now()`) to its wake time. Interrupt, signal and external stream reads with no recorded or supplied answer stop with `missing_context`.

A live model call gets new tool-use ids, so recorded tool results keyed by the old ids won't match. Supply them under `tools`, keyed by tool name.

### Output and exit status

`--values` includes call values. The command prints one `{"type":"call", ...}` line per new post-fork call, then a `{"type":"outcome", ...}` line with status, provenance counts, `changed_calls`, live-call count, cost, code versions and fixture hash. Exit 0 is complete, 2 a refusal before start (JSON `code` and `message` on stdout), 3 a started run that failed, was cancelled or suspended, 1 invalid input or operational error (redacted `error:` on stderr).

### Which call stopped a run

A `missing_context`, `live_budget_exceeded`, `write_not_allowed` or `replay_diverged` stop puts `stop_key` (such as `tool:toolu_01`, the key `supplied_results.calls` uses) and `stop_token` (the `--allow-write` token for a refused write, else `null`) on the `run:error` event.

## Reason codes

Refusals exit 2 with `{"code", "message"}` on stdout; other failures exit 1.

| Code | Meaning |
|---|---|
| `capture_incomplete` | The message lists what is missing, including a call in flight at the fork. Pick another `--fork-after-seq` or save after the run finishes. |
| `no_fixture_store`, `bundle_missing`, `bundle_corrupt` | No store configured; hash not in the store; bytes fail decompression, hashing or validation (re-export). |
| `format_unsupported`, `kind_mismatch` | Bundle format not readable by this version; `--kind workflow` on a run with model or tool calls. |
| `invalid_fixture_hash`, `invalid_keep_hash` | Not a 64-character lowercase hex hash. |
| `prefix_diverged` | The target no longer consumes the recorded prefix with matching inputs. |
| `replay_input_changed` | Resuming finds changed input in the case run's own journal. |
| `missing_context` | A call has no live rung, or a supplied answer has the wrong shape. |
| `live_budget_exceeded` | Call or cost ceiling reached, or spend unobservable under a cost limit. |
| `write_not_allowed` | A write has no allow token. |
| `replay_diverged` | A `--replay-only` call the recording cannot answer. |
| `binding_mismatch`, `invalid_overrides` | Expected fixture store identity differs from the opened store; runtime settings missing or invalid. |
| `whatif_source_missing`, `whatif_source_changed` | See [What-if runs](https://docs.pyrula.io/workflows/guides/what-if.md). |
| `runtime_error`, `cancelled` | The runtime failed, or the run was cancelled. |
| `no_eligible_runtime`, `runtime_lost`, `runs_quota_exceeded` | Cloud only. |
