Skip to main content

guides/turn-and-effect-contracts.md

# Turn And Effect Contracts

A Jidoka turn is a pure data pipeline. The runtime compiles a `Jidoka.Agent.Spec`
into a `Jidoka.Turn.Plan`, threads a `Jidoka.Turn.State` through Runic, and
mediates external work through `Jidoka.Effect.Intent`/`Jidoka.Effect.Result`
pairs. This guide documents every data contract on that path so that custom
harnesses, traces, and storage layers can interoperate with the runtime without
guessing.

## When To Use This

- Use this guide when you are building a custom harness, sidecar, or trace
  exporter and need the on-the-wire shape of turn state and effects.
- Use this guide when reading a snapshot or journal in tests and needing to
  decode each field.
- Do not use this guide as a runtime walkthrough. See
  [Runtime And Harness](runtime-and-harness.md) for the execution model.

## Prerequisites

- You have read [Agent Spec Contract](agent-spec-contract.md).
- You can build and run a Jidoka turn (see [Getting Started](getting-started.md)).

## Quick Example

A turn round-trip produces every contract this guide describes.

```elixir
alias Jidoka.{Agent, Turn, Effect}

spec = MyApp.TimeAgent.spec()
{:ok, plan} = Turn.Plan.new(spec)
{:ok, request} = Turn.Request.from_input("What time is it in Chicago?")

llm = fn _intent, _journal ->
  {:ok, %{type: :final, content: "Chicago time is 09:30."}}
end

{:ok, result} = MyApp.TimeAgent.run_turn(request.input, llm: llm)

result.content              #=> "Chicago time is 09:30."
result.journal              #=> %Jidoka.Effect.Journal{intents: %{...}, results: %{...}}
hd(result.events).type      #=> :turn_started (or similar)
```

`plan`, `request`, the in-flight `Turn.State`, the `Turn.Result`, and the
`Effect.Journal` are all addressable, inspectable values.

## Concepts

```diagram
╭──────────────╮     ╭───────────────╮     ╭──────────────╮
│  Agent.Spec  │────▶│   Turn.Plan   │────▶│  Turn.State  │
╰──────────────╯     ╰───────────────╯     ╰──────┬───────╯
                                                  │ pending_effects
                                          ╭───────────────╮
                                          │ Effect.Intent │
                                          ╰──────┬────────╯
                                                 │ capability
                                          ╭───────────────╮
                                          │ Effect.Result │
                                          ╰──────┬────────╯
                                                 │ journal
                                          ╭───────────────╮
                                          │ Turn.Result   │
                                          ╰───────────────╯
```

Three rules anchor the model:

1. The plan is derived from the spec; the state is derived from the plan plus a
   request.
2. The harness only ever produces effects through `Effect.Intent` and consumes
   them through `Effect.Result`. The journal records both.
3. The final `Turn.Result` is projected from the terminal `Turn.State`.

## Fields

### `Jidoka.Turn.Plan`

Compiled execution defaults for one turn.

| Field | Type | Default | Purpose |
| --- | --- | --- | --- |
| `spec` | `Agent.Spec.t()` | required | The immutable spec the plan was compiled from. |
| `workflow_profile` | `:chat \| :tool_loop \| :structured_result \| :controlled_tool_loop` | `:tool_loop` | Selects the Runic profile. |
| `max_model_turns` | positive integer | `spec.controls.max_turns` or `Jidoka.Config.default_max_model_turns/0` | Upper bound on model rounds. |
| `timeout_ms` | positive integer | `spec.controls.timeout_ms` or `Jidoka.Config.default_turn_timeout_ms/0` | Hard wall-clock limit. |
| `phases` | `[atom()]` | full phase list | Runic phase order for the turn. |
| `metadata` | map | `%{}` | Plan-level metadata. |

Built by [`Jidoka.Turn.Plan.new/1`](`Jidoka.Turn.Plan`) which also runs
`Spec.validate_operation_policies/1` before returning.

### `Jidoka.Turn.Request`

Input envelope for one turn.

| Field | Type | Default | Purpose |
| --- | --- | --- | --- |
| `input` | non-empty string | required | User-facing input passed to prompt assembly. |
| `request_id` | non-empty string | generated `"turn_…"` | Stable id used by snapshots and logs. |
| `agent_state` | `Agent.State.t()` | empty agent state | Carries history across turns. |
| `context` | map | `%{}` | Per-turn context map (validated against `spec.context_schema`). |
| `metadata` | map | `%{}` | Caller metadata. |

`Turn.Request.from_input/2` accepts a string, map, or keyword list and fills in
defaults.

### `Jidoka.Turn.State`

Ephemeral value threaded through the workflow.

| Field | Type | Purpose |
| --- | --- | --- |
| `spec` / `plan` / `request` | spec, plan, request structs | Inputs to the loop. |
| `agent_state` | `Agent.State.t()` | Mutable accumulator (messages, operation results). |
| `memory` | `Memory.RecallResult.t() \| nil` | Most recent recall. |
| `prompt` | provider-neutral prompt or `nil` | Materialized prompt after assembly. |
| `llm_result` | `Effect.LLMDecision.t() \| nil` | Last decoded LLM decision. |
| `operation_plan` | `Effect.OperationRequest.t() \| nil` | Pending operation request. |
| `pending_effects` | `[Effect.Intent.t()]` | Effects awaiting interpretation. |
| `pending_interrupt` | `Review.Interrupt.t() \| nil` | Review boundary, if any. |
| `result` / `result_value` | string / term | Final assistant content and validated structured value. |
| `result_repair_count` | non-negative integer | Repair attempts so far. |
| `status` | `:running \| :waiting \| :finished` | Loop state. |
| `loop_index` | non-negative integer | Current model round. |
| `started_at_ms` | integer or `nil` | Wall-clock start. |
| `journal` | `Effect.Journal.t()` | Recorded intents and results. |
| `events` | `[Jidoka.Event.t()]` | Append-only event log. |
| `diagnostics` | list | Append-only diagnostic blobs. |

Mutations go through [`Jidoka.Turn.Transition`](`Jidoka.Turn.Transition`).

### `Jidoka.Turn.Transition`

A pure transition value: new state plus pending events and diagnostics.

| Field | Type | Purpose |
| --- | --- | --- |
| `state` | map | The next state. |
| `events` | `[Jidoka.Event.t()]` | Events to append on commit. |
| `diagnostics` | list | Diagnostics to append on commit. |

`Transition.event/3` builds a [`Jidoka.Event`](`Jidoka.Event`) with stable
sequence ordering. `Transition.commit/1` folds events and diagnostics back into
the state.

### `Jidoka.Turn.Cursor`

A pointer to the next safe resume boundary.

| Field | Type | Default | Purpose |
| --- | --- | --- | --- |
| `phase` | `:start \| :after_prompt \| :before_effect \| :review \| :wait` | `:start` | Logical phase boundary. |
| `loop_index` | non-negative integer | `0` | Loop round at hibernation time. |
| `metadata` | map | `%{}` | Boundary metadata (e.g. `effect_id`, `interrupt_id`). |

Constructors `after_prompt/0`, `before_effect/1`, `review/1` produce the
common cursor shapes.

### `Jidoka.Turn.Result`

Final app-facing value.

| Field | Type | Purpose |
| --- | --- | --- |
| `content` | string | Final assistant text. |
| `value` | term or `nil` | Validated structured value when `spec.result` is set. |
| `agent_state` | `Agent.State.t()` | Conversation state after the turn. |
| `journal` | `Effect.Journal.t()` | Effects observed during the turn. |
| `events` | `[Jidoka.Event.t()]` | Ordered event log. |
| `usage` | map | Aggregated LLM token and cost usage for the turn. |
| `metadata` | map | Caller metadata. |

Produced by [`Jidoka.Turn.Result.from_turn_state!/1`](`Jidoka.Turn.Result`)
once `status` reaches `:finished`.

When the LLM capability is backed by ReqLLM, `usage` contains normalized token
and cost fields when the provider returns them:

```elixir
result.usage
#=> %{
#=>   llm_calls: 2,
#=>   input_tokens: 800,
#=>   output_tokens: 240,
#=>   total_tokens: 1040,
#=>   reasoning_tokens: 0,
#=>   total_cost: 0.00048
#=> }
```

Per-call usage remains available in the journal:

```elixir
result.journal.results[effect_id].metadata.usage
```

### `Jidoka.Effect.Intent`

Data description of an external effect.

| Field | Type | Purpose |
| --- | --- | --- |
| `id` | non-empty string | Stable id (`"<kind>:<idempotency_key>"`). |
| `kind` | `:llm \| :operation` | What the capability must do. |
| `payload` | map | Payload (normalized to an `Effect.OperationRequest` for `:operation`). |
| `idempotency_key` | non-empty string | Stable key (sha256 of `{kind, payload}` by default). |
| `idempotency` | `:pure \| :idempotent \| :dedupe \| :reconcile \| :unsafe_once` | Replay safety class. |
| `metadata` | map | Caller metadata. |

Build with `Effect.Intent.new/3` (kind + payload + opts) or `Intent.new/1` (full
map).

### `Jidoka.Effect.Result`

Normalized result of one interpreted effect.

| Field | Type | Purpose |
| --- | --- | --- |
| `intent_id` | non-empty string | The intent this result answers. |
| `kind` | `:llm \| :operation` | Mirrors the intent. |
| `status` | `:ok \| :error` | Interpreter outcome. |
| `output` | term | Decoded payload (LLM decision map for `:llm`; raw operation output for `:operation`). |
| `metadata` | map | Capability metadata. |

`Effect.Result.ok/2`, `Effect.Result.ok/3`, `Effect.Result.error/2`, and
`Effect.Result.error/3` are the convenience constructors. The third argument
accepts `metadata:` for capability-owned metadata such as LLM usage.

### `Jidoka.Effect.Journal`

Replay log keyed by intent id.

| Field | Type | Purpose |
| --- | --- | --- |
| `intents` | `%{String.t() => Effect.Intent.t()}` | Recorded intents. |
| `results` | `%{String.t() => Effect.Result.t()}` | Recorded results. |

Use `Journal.put_intent/2` and `Journal.put_result/2` to extend. Use
`Journal.result_for/2` to ask "has this intent already been satisfied?" - the
basis of replay safety.

### `Jidoka.Effect.OperationRequest` And `Jidoka.Effect.OperationResult`

Typed payload/observation pair for operation effects.

| `OperationRequest` field | Type | Purpose |
| --- | --- | --- |
| `name` | non-empty string | Operation name from `Spec.Operation`. |
| `arguments` | map | Arguments decoded from the LLM decision. |
| `request_id` | string or `nil` | Source turn request id. |
| `loop_index` | non-negative integer | Loop round at planning time. |
| `metadata` | map | Caller metadata. |

| `OperationResult` field | Type | Purpose |
| --- | --- | --- |
| `operation` | non-empty string | Operation name. |
| `arguments` | map | Arguments used. |
| `output` | term | Raw observation. |
| `content` | string or `nil` | Pre-rendered message content. |
| `request_id` | string or `nil` | Turn request id. |
| `loop_index` | non-negative integer | Loop round. |
| `effect_id` | string or `nil` | Originating `Effect.Intent.id`. |
| `metadata` | map | Caller metadata. |

`OperationResult.from_effect/2` is the canonical bridge from an `Intent` +
capability output to a durable observation.

### `Jidoka.Effect.LLMDecision`

Constrained JSON decision protocol returned by every LLM capability.

| Field | Type | Purpose |
| --- | --- | --- |
| `type` | `:final \| :operation` | Branch of the decision protocol. |
| `content` | string or `nil` | Required for `:final`. Optional metadata text for `:operation`. |
| `result` | term or `nil` | Structured result for `:final` when `spec.result` is set. |
| `name` | non-empty string or `nil` | Required for `:operation`. |
| `arguments` | map | Operation arguments. Required for `:operation`. |
| `metadata` | map | Provider metadata. |

`LLMDecision.final/2` and `LLMDecision.operation/3` are the two builder
helpers. Capabilities may return either an `LLMDecision` struct or a map that
`LLMDecision.from_input/1` accepts.

## Common Patterns

- **Treat `Effect.Intent.id` as the only identity that matters.** The journal,
  cursor metadata, and `Effect.Result.intent_id` all key off it.
- **Decide once, observe once.** An LLM decision returns one `Intent`; that
  intent's result is the only payload the runtime trusts.
- **Use the cursor for resume boundaries, not the state.** A cursor is small,
  serializable, and stable across versions; the state can carry rich data.
- **Prefer `LLMDecision` structs in fake LLMs.** Returning a map works (the
  runtime calls `LLMDecision.from_input/1`), but a struct catches typos sooner.

## Testing

A deterministic test asserts on the journal, not on the prompt.

```elixir
test "operation effect is recorded once" do
  llm = fn _intent, journal ->
    case map_size(journal.results) do
      0 -> {:ok, Jidoka.Effect.LLMDecision.operation("local_time", %{"city" => "Chicago"})}
      _ -> {:ok, Jidoka.Effect.LLMDecision.final("Chicago time is 09:30.")}
    end
  end

  {:ok, result} = MyApp.TimeAgent.run_turn("What time is it in Chicago?", llm: llm)

  operation_results =
    result.journal.results
    |> Map.values()
    |> Enum.filter(&(&1.kind == :operation))

  assert length(operation_results) == 1
end
```

## Troubleshooting

| Symptom | Likely Cause | Fix |
| --- | --- | --- |
| `{:error, {:effect_result_mismatch, _, _}}` | A capability returned a result whose `intent_id` does not match the current pending intent. | Ensure capabilities pass through the `Intent` they were given and only call `Effect.Result.ok/error` against it. |
| `{:error, {:invalid_llm_decision_type, _}}` | LLM output is missing or has a non-`:final`/`:operation` type. | Tighten the prompt or use the deterministic LLM path. |
| `{:error, {:unknown_operation, name}}` | Decision named an operation that is not in `spec.operations`. | Add the operation or change the model's allowed tools. |
| `Turn.State.status` stays `:waiting` forever | A `pending_interrupt` was not resolved. | Resume the snapshot through the review API before continuing. |
| `Turn.Result.events` is empty | The state was committed without any `Transition.event/3` calls. | Use `Turn.Transition` instead of mutating state directly. |

## Reference

- [`Jidoka.Turn.Plan`](`Jidoka.Turn.Plan`)
- [`Jidoka.Turn.Request`](`Jidoka.Turn.Request`)
- [`Jidoka.Turn.State`](`Jidoka.Turn.State`)
- [`Jidoka.Turn.Transition`](`Jidoka.Turn.Transition`)
- [`Jidoka.Turn.Cursor`](`Jidoka.Turn.Cursor`)
- [`Jidoka.Turn.Result`](`Jidoka.Turn.Result`)
- [`Jidoka.Effect.Intent`](`Jidoka.Effect.Intent`)
- [`Jidoka.Effect.Result`](`Jidoka.Effect.Result`)
- [`Jidoka.Effect.Journal`](`Jidoka.Effect.Journal`)
- [`Jidoka.Effect.OperationRequest`](`Jidoka.Effect.OperationRequest`)
- [`Jidoka.Effect.OperationResult`](`Jidoka.Effect.OperationResult`)
- [`Jidoka.Effect.LLMDecision`](`Jidoka.Effect.LLMDecision`)
- [`Jidoka.Runtime.Capabilities`](`Jidoka.Runtime.Capabilities`)

## Related Guides

- [Agent Spec Contract](agent-spec-contract.md) - the input to the plan.
- [Operation Source Contracts](operation-source-contracts.md) - where
  operation capabilities come from.
- [Runtime And Harness](runtime-and-harness.md) - the executor of these
  contracts.
- [Import And Snapshot Contracts](import-and-snapshot-contracts.md) - durable
  shapes built on top of `Turn.State` and `Turn.Cursor`.