Jidoka treats human review as a durable pause, not as a callback or a
side-channel. An operation control returns {:interrupt, reason} before the
operation runs, the turn hibernates at a review cursor, and an application
later resumes the same snapshot with an approval or a denial.
When To Use This
- Use durable approvals for any action a model can request that you do not want to execute automatically: refunds, deletes, deploys, sends to external systems.
- Use durable approvals for compliance flows where the reviewer is a different process or even a different deployment than the runtime.
- Do not use durable approvals as a substitute for input/output controls. Those run at different boundaries; see Controls.
Prerequisites
- A Jidoka agent with at least one operation whose call you want to gate.
- A persistent session, or a place to store the hibernation snapshot. See Sessions And Stores and Snapshots And Resume.
- Familiarity with the operation control surface in Controls.
mix deps.get
mix test
Quick Example
A refund operation, an approval control, and an end-to-end approve path.
defmodule MyApp.RequireRefundApproval do
use Jidoka.Control, name: "require_refund_approval"
@impl true
def call(%Jidoka.Runtime.Controls.OperationContext{} = operation) do
if operation.operation == "refund_order" do
{:interrupt, :approval_required}
else
:cont
end
end
end
defmodule MyApp.SupportAgent do
use Jidoka.Agent
agent :support_agent do
instructions "Use refund_order when the customer asks for a refund."
end
tools do
action MyApp.RefundOrder
end
controls do
operation MyApp.RequireRefundApproval, when: [name: :refund_order]
end
end
llm = fn _intent, journal ->
case map_size(journal.results) do
0 -> {:ok, %{type: :operation, name: "refund_order",
arguments: %{"order_id" => "A1001"}}}
_ -> {:ok, %{type: :final, content: "Refunded A1001."}}
end
end
operations = fn _intent, _journal -> {:ok, %{refunded: true}} end
{:hibernate, snapshot} =
Jidoka.turn(MyApp.SupportAgent, "Refund A1001",
llm: llm,
operations: operations
)
review = snapshot.metadata["pending_review"]
approval = Jidoka.Review.Response.approve(review.interrupt_id)
{:ok, %Jidoka.Turn.Result{content: "Refunded A1001."}} =
Jidoka.resume(snapshot,
approval: approval,
llm: llm,
operations: operations
)Concepts
The data path is simple. Every stage is just data.
╭───────────────────────╮ ╭──────────────────────╮
│ Operation control │────▶│ {:interrupt, reason} │
╰───────────────────────╯ ╰──────┬───────────────╯
│
▼
╭──────────────────────╮
│ Review.Interrupt │
│ snapshot.cursor=:review │
╰──────┬───────────────╯
│
▼
╭──────────────────────╮
│ Review.Request │
│ (snapshot.metadata) │
╰──────┬───────────────╯
│
▼
╭──────────────────────╮
│ Review.Response │
│ approve / deny │
╰──────┬───────────────╯
│
▼
╭──────────────────────╮
│ Jidoka.resume/2 │
│ approval: response │
╰──────────────────────╯Jidoka.Review.Interruptis the runtime pause. It records the agent, request, effect id, operation, arguments, idempotency, and an optionalexpires_at_ms.Jidoka.Review.Requestis the application-facing view. It is built from the interrupt and stored onsnapshot.metadata["pending_review"]andsession.pending_reviews.Jidoka.Review.Responseis the small struct the application creates to resume: either:approvedor:denied, always targeting one interrupt id.- The interrupt only fires for
:operationboundaries today; input and output controls cannot durably hibernate yet and must block or fail.
How To
Step 1: Define The Operation Control
Operation controls receive a
Jidoka.Runtime.Controls.OperationContext. Returning {:interrupt, reason} flips the turn into the review path.
defmodule MyApp.RequireRefundApproval do
use Jidoka.Control, name: "require_refund_approval"
@impl true
def call(%Jidoka.Runtime.Controls.OperationContext{} = operation) do
if operation.operation == "refund_order" do
{:interrupt, :approval_required}
else
:cont
end
end
endMatch the control as narrowly as possible. Common keys are kind, name,
source, idempotency, and any metadata. Risky operations declared with
idempotency: :unsafe_once must have an operation control before the spec
can compile. See Idempotency And Safety.
Step 2: Observe The Hibernation
Running the turn returns {:hibernate, snapshot} instead of {:ok, result}. Read the pending review off the snapshot.
{:hibernate, snapshot} =
Jidoka.turn(MyApp.SupportAgent, "Refund A1001",
llm: llm,
operations: operations
)
snapshot.cursor.phase
#=> :review
%Jidoka.Review.Request{} = review = snapshot.metadata["pending_review"]
review.operation
#=> "refund_order"
review.arguments
#=> %{"order_id" => "A1001"}When the turn runs inside a session, the same request is mirrored on the session struct:
{:ok, [^review]} = Jidoka.Session.pending_reviews(session)Step 3: Approve And Resume
Jidoka.Review.Response.approve/2 builds the response targeted at the
pending interrupt.
approval = Jidoka.Review.Response.approve(review.interrupt_id)
{:ok, %Jidoka.Turn.Result{}} =
Jidoka.resume(snapshot,
approval: approval,
llm: llm,
operations: operations
)The runner does not re-run the operation control for the approved interrupt. The journal still prevents duplicate effects on later resumes: once the operation result is in the journal, replaying the snapshot reuses the recorded result instead of calling the capability again.
Step 4: Deny For A Deterministic Non-Execution
A denial resumes the same snapshot but never calls the operation capability.
denial = Jidoka.Review.Response.deny(review.interrupt_id, reason: :policy_denied)
{:error, {:approval_denied, ^denial}} =
Jidoka.resume(snapshot,
approval: denial,
llm: llm,
operations: operations
){:error, {:approval_denied, response}} is the contractual outcome of a
denial. Surface the response struct to the caller; it carries the
reason, responded_at_ms, and metadata the reviewer attached.
Step 5: Honor Expiration Windows
Pass :approval_ttl_ms when you want approvals to expire. The runtime
stamps expires_at_ms on the interrupt at hibernation time.
{:hibernate, snapshot} =
Jidoka.turn(MyApp.SupportAgent, "Refund A1001",
llm: llm,
operations: operations,
approval_ttl_ms: 60_000
)
review = snapshot.metadata["pending_review"]
review.expires_at_ms
#=> 1717250000000When resume runs after expiry, validation rejects the response:
late = Jidoka.Review.Response.approve(review.interrupt_id,
responded_at_ms: review.expires_at_ms + 1)
{:error, {:approval_expired, _id, _now, _expires}} =
Jidoka.resume(snapshot,
approval: late,
llm: llm,
operations: operations
)Application code should set responded_at_ms from the same clock it uses
for created_at_ms; the harness fills it in with the current system clock
if you leave it nil.
Step 6: List Pending Reviews Across A Store
A session keeps its own pending requests. A store can flatten across sessions for an operator dashboard.
{:ok, [_review | _rest]} = Jidoka.Session.pending_reviews(store)Each entry is a Jidoka.Review.Request struct. It carries everything an
operator needs to render the decision: agent id, operation, arguments,
reason, and the expiration timestamp.
Common Patterns
- One control per risk class. Keep the control logic to a simple
match on
operationandkind. Push richer policy into the application-side approval workflow. - Persist before showing to a reviewer. Always save the snapshot (or its session) before exposing the pending review. The reviewer's decision must be able to find the same snapshot later.
- Pass
:approval_ttl_ms. Even a long TTL is safer than none. Expired approvals fail deterministically. - Treat denials as expected. A
{:error, {:approval_denied, _}}return value should be logged but is not a runtime fault. - Do not retry approvals. Once an interrupt is resolved its effect
result is journaled. A second
Jidoka.resume/2against the same snapshot will reuse that result, not call the operation again.
Testing
Use deterministic fakes for both the LLM and the operations capability.
test "approval resumes the pending refund" do
llm = fn _intent, journal ->
case map_size(journal.results) do
0 -> {:ok, %{type: :operation, name: "refund_order",
arguments: %{"order_id" => "A1001"}}}
_ -> {:ok, %{type: :final, content: "Refunded A1001."}}
end
end
operations = fn _intent, _journal -> {:ok, %{refunded: true}} end
assert {:hibernate, snapshot} =
Jidoka.turn(MyApp.SupportAgent, "Refund A1001",
llm: llm,
operations: operations
)
review = snapshot.metadata["pending_review"]
approval = Jidoka.Review.Response.approve(review.interrupt_id)
assert {:ok, %Jidoka.Turn.Result{content: "Refunded A1001."}} =
Jidoka.resume(snapshot,
approval: approval,
llm: llm,
operations: operations
)
end
A denial test mirrors the approval test: build the snapshot, call
Jidoka.Review.Response.deny/2, assert on
{:error, {:approval_denied, _response}}, and assert the operations
capability was never invoked.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
{:error, {:approval_interrupt_mismatch, expected, actual}} | Approval was built for a different interrupt id. | Always read review.interrupt_id from the live snapshot. |
{:error, {:approval_expired, _, _, _}} | Response came in after expires_at_ms. | Build a fresh review request, or extend :approval_ttl_ms. |
{:error, {:approval_denied, response}} | Reviewer denied the action. | Surface the response to the caller; do not retry. |
| Operation is called twice after approval | Snapshot was resumed twice without checking the result. | Resume each snapshot once; persist Turn.Result after success. |
{:error, {:unsafe_once_requires_control, name, kind}} at compile | An :unsafe_once operation has no operation control. | Add a matching operation control before compiling the plan. |
snapshot.metadata["pending_review"] is nil | The hibernation came from :after_prompt/:before_each_effect, not a review. | Inspect snapshot.cursor.phase; only :review produces a pending request. |
Reference
Key modules touched in this guide:
Jidoka.Review- umbrella alias module.Jidoka.Review.Interrupt- durable pause data withwith_review_window/3andexpired?/2.Jidoka.Review.Request- application-facing request built from an interrupt.Jidoka.Review.Response-approve/2,deny/2, decision enum[:approved, :denied].Jidoka.Runtime.Controls.OperationContext- what the operation control receives.Jidoka.Harness- resume and approval normalization via:approval/:approval_ttl_msoptions.
Related Guides
- Controls - the full control surface, including input and output controls.
- Sessions And Stores - listing pending reviews across sessions.
- Snapshots And Resume - the snapshot lifecycle beneath the approval flow.
- Idempotency And Safety - why
:unsafe_onceoperations must have an approval control.