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
Mark the operation as requiring approval. Jidoka pauses before the operation executes, returns a snapshot, and resumes after the application approves or denies the review request.
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,
idempotency: :unsafe_once,
approval: [
reason: :refund_requires_review,
message: "Review the refund before it is issued.",
ttl_ms: 300_000
]
end
end
llm = fn _intent, journal, _ctx ->
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, _ctx -> {:ok, %{refunded: true}} end
{:hibernate, snapshot} =
Jidoka.turn(MyApp.SupportAgent, "Refund A1001",
llm: llm,
operations: operations
)
{:ok, [review]} = Jidoka.pending_reviews(snapshot)
{:ok, %Jidoka.Turn.Result{content: "Refunded A1001."}} =
Jidoka.approve(snapshot, review,
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. - Tool-level
approval:compiles to Jidoka's built-inJidoka.Controls.RequireApprovaloperation control. Approval predicates and custom operation controls use the same interrupt, snapshot, and resume path.
How To
Step 1: Add Approval Policy
Use approval: when the policy is unconditional, can be expressed with
only: / except: filters, or can be delegated to a module predicate:
tools do
action MyApp.RefundOrder,
idempotency: :unsafe_once,
approval: true
endWhen approval depends on operation arguments or caller context, use a
module-based predicate. The predicate receives Jidoka.Context:
defmodule MyApp.LargeRefundPredicate do
use Jidoka.ApprovalPredicate
@impl true
def call(%Jidoka.Context{} = ctx) do
amount = Map.get(ctx.arguments, "amount") || 0
amount >= 100 or Jidoka.Context.get(ctx, :tenant_id) == "enterprise"
end
endAttach it to the tool's approval policy:
tools do
action MyApp.RefundOrder,
idempotency: :unsafe_once,
approval: [
when: MyApp.LargeRefundPredicate,
reason: :large_refund_review
]
endRequest-level approval is useful when the caller wants one reviewed run without changing the agent spec:
Jidoka.turn(MyApp.SupportAgent, "Refund A1001",
require_tool_approval: [only: ["refund_order"]]
)Define a custom operation control when the policy needs to block, interrupt with a custom shape, or do something broader than standard approval.
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 approval or 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.approve/3 approves a pending review and resumes the snapshot:
{:ok, [review]} = Jidoka.pending_reviews(snapshot)
{:ok, %Jidoka.Turn.Result{}} =
Jidoka.approve(snapshot,
review,
llm: llm,
operations: operations
)Use the lower-level response API when you want to persist or transform the
approval response explicitly. 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.
{:error, reason} =
Jidoka.deny(snapshot,
review,
reason: :policy_denied,
llm: llm,
operations: operations
)Jidoka.deny/3 still returns the normal resume error shape for denied
approvals. Surface the response details to the caller; they carry the reason,
responded_at_ms, and metadata the reviewer attached.
Step 5: Honor Expiration Windows
Set ttl_ms: on the approval policy when a tool has its own review window.
Pass :approval_ttl_ms at runtime when the caller should override that window
for one turn. 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 runtime 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
- Use approval sugar first. Reach for
approval: trueorapproval: [only: ...]before writing a custom control. - One control per risk class. When approval needs code, 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, _ctx ->
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, _ctx -> {: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 approval policy or operation control. | Add approval: true or 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.Policy- operation approval policy data.Jidoka.Review.Response-approve/2,deny/2, decision enum[:approved, :denied].Jidoka- facade helperspending_reviews/1,approve/3, anddeny/3.Jidoka.Runtime.Controls.OperationContext- what the operation control receives.Jidoka.approve/3andJidoka.deny/3- public approval and denial coordination.
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 approval or an operation control.