Skip to main content

Human in the Loop with Contracts

· 14 min read
Dave Rapin
Dave Rapin
Founder @ Curling IO
About this post

This is a technical implementation note about the AI assistant architecture in Curling IO v3. It is written for software engineers and others designing agent systems, and goes deeper into Rust, persistence, authorization, and failure handling than our usual product posts.

The usual human-in-the-loop AI agent pattern goes something like this: the model requests a tool call, the agent runtime pauses, a human approves the call, and the runtime resumes so the tool can execute.

That is a reasonable general-purpose design. It is also stricter than simply letting an agent call every tool it can see. For Curling IO, we wanted to expose the smallest possible surface to the model and put an application-owned guardrail around every path to a write. That led us to a stricter question:

If the application already has the exact call details, why hand control back to the agent at all?

By the time we ask a club manager to approve an operation, Curling IO has parsed the model's request, resolved every default, checked the current application state, produced a fixed preview, and stored the exact arguments. The model has nothing useful left to contribute to execution, so we do not let it execute the operation or resume it merely to carry out the approval.

The agent proposes. The application turns that proposal into a contract. The human approves the contract. Rust executes it.

The common approval pattern

The OpenAI Agents SDK human-in-the-loop flow can pause a run when a tool requires approval. The application serializes the RunState, records approvals or rejections for pending tool calls, and resumes the original run. The approved tool executes as the run continues.

LangGraph interrupts support a similar shape. A graph can stop before a tool node, let a person approve, edit, or reject the call, then resume toward the tool or back toward the model.

Those frameworks solve a broad problem. An agent may have many tools, nested agents, long-running work, and several points where a human needs to intervene. Keeping the pending tool call inside durable agent state is useful in that world.

Curling IO has a narrower problem. We own the application, the database, the authorization rules, the interface, and every operation an assistant may propose. We do not need a generic agent runtime to remain authoritative after a proposal has crossed into application state.

This is the difference:

A comparison of common tool-call approval, where the agent runtime resumes
after human approval, and Curling IO operation contracts, where Rust owns the
validated contract and executes it without resuming the
model.

Not resuming the model after approval has a narrow meaning. While a proposal is being prepared, safe validation errors can go back into the active model loop so it can correct its request. If a manager declines a proposal, or execution finds a recoverable state change, Curling IO records app-authored revision context for the manager's next message. That starts a new model turn with the safe reason included. A terminal internal failure is recorded and reported as non-retryable instead. The model can adapt where that is useful, but it never owns execution of the approved contract.

The first pattern can be implemented safely. It's not that resuming an agent automatically changes an approved call. A good runtime should preserve the exact call and its identity. For a first-party application, the paused model run is unnecessary operational state once the application has accepted a complete proposal.

Human approval is not enough

An approval button is only meaningful if the application can say exactly what was approved.

Suppose a model asks to refund an order. A weak approval could show:

Refund this customer?

That leaves almost every material decision hidden. Which payment? How much? Which line item? Where will the money go? Is the model using a value it calculated itself? Could it select a different destination when execution resumes?

Our refund review shows the participant, product, discount, payment, amount, destination, and resulting order total. The manager is not approving the model's general intention to fix an order. They are approving one concrete operation with one set of effects.

That requires more than a tool schema. It requires an application-owned contract that defines all of these together:

  • the fields the model must supply;
  • the defaults Rust is allowed to resolve;
  • valid and invalid combinations;
  • the typed representation stored for approval;
  • the fixed human review presentation;
  • revalidation against current state;
  • the executor;
  • the typed result and safe handback; and
  • the audit events for the whole lifecycle.

If those pieces are split between a prompt, a generic JSON schema, a hand-built review page, and an unrelated executor, they will drift. A new field can affect execution without appearing in the review. A prompt can describe a default differently from the application. A model can produce a value the ordinary interface would never allow.

In Curling IO, those are all parts of one capability.

The smallest useful surface

Our first preference is not to guard a broad agent surface. It is to avoid presenting that surface in the first place.

Each assistant is confined to one section of Curling IO and receives only the context needed for the current task. The order assistant does not receive an organization-wide database view. The email assistant does not inherit the order assistant's tools. Tenant identity, permissions, internal field names, provider payloads, and unrelated customer records stay on the application side of the boundary.

The same rule applies to operations. An assistant sees a small catalogue of things it may propose, not a generic write API. Its model-facing fields contain only the choices that genuinely require interpretation. Rust supplies resource scope, resolves defaults, calculates derived values, rejects unsupported combinations, and builds the human preview.

Then we add a guardrail at every remaining vector from model output to durable state:

  • bounded, application-written context before the model call;
  • task-specific read tools with server-owned tenant scope;
  • strict parsing and validation of the model's operation request;
  • typed arguments and previews built by Rust;
  • human approval of every material effect;
  • authorization, expiry, and state revalidation at execution time;
  • idempotency at the proposal and domain-record boundaries; and
  • typed, redacted outcomes after execution.

No one check carries the whole safety argument. Human approval does not replace authorization. A type does not prove that current state still permits the operation. Revalidation does not prevent a duplicate provider call after a lost response. The surface stays small, and every boundary still has its own job.

The model request is not the stored proposal

Our order assistant has a model-facing operation called propose_payment_refund. Its input is intentionally small. The model identifies the relevant payment, line item, discount, and requested destination from the evidence Curling IO gave it.

Rust does not store that request directly. It validates the request against the server-owned order investigation, calculates the refund using application rules, resolves a concrete destination, and creates typed arguments:

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct OrderRefundArguments {
order_id: i64,
payment_id: i64,
line_item_id: i64,
discount_id: i64,
amount_cents: i64,
refund_destination: RefundDestination,
}

The model does not choose amount_cents. It cannot say "use the original payment method" and leave that decision until later. Rust resolves that phrase to a concrete RefundDestination before the manager sees anything.

The review is typed separately from the executable arguments:

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct OrderRefundPreview {
participant_name: String,
product_name: String,
discount_name: String,
payment_method: PaymentMethod,
amount_cents: i64,
current_order_total_cents: i64,
resulting_order_total_cents: i64,
currency: String,
refund_destination: RefundDestination,
product_configuration_unchanged: bool,
}

The arguments contain what execution needs. The preview contains what a human needs to understand the consequences. Both come from the same validated domain facts, and both are frozen together.

JSON appears at the database boundary, but it is not the programming model. An operation is parsed back into its Rust type before it can be revalidated or executed. An operation kind cannot be dispatched into another operation's argument type.

A proposal is durable application state

Each proposal gets an opaque public identifier and a row containing its tenant, requesting administrator, section, resource, operation kind, exact arguments, preview, status, and expiry. It also records approval, execution, result, and failure information.

A simplified view of the Rust side looks like this:

struct OperationProposal<Arguments, Preview, Result> {
public_id: ProposalId,
interaction_id: InteractionId,
scope: OperationScope,
requested_by: UserId,
operation_kind: OperationKind,
arguments: Arguments,
preview: Preview,
status: ProposalStatus,
expires_at: DateTime,
approval: Option<Approval>,
outcome: Option<OperationOutcome<Result>>,
}

struct OperationScope {
organization_id: OrganizationId,
section: AssistantSection,
resource_id: ResourceId,
}

Arguments, Preview, and Result are the types owned by one operation contract. SQLite stores their serialized form and the proposal's audit events, but application code cannot execute those values without decoding them through the matching contract.

The conversation is not the source of truth for this operation. Neither is the model provider's stored run state. A pending proposal survives a browser disconnect, a model change, or a deployment because Curling IO can reconstruct the approval from its own records.

This also gives the approval request a very small input. The browser submits the opaque proposal identifier. It does not submit the refund amount, destination, message body, or any other approved value a second time.

Approval creates one execution contract

When the administrator selects Approve refund, Curling IO verifies all of the ordinary request boundaries again:

  • the signed-in user still has access to the organization;
  • the proposal belongs to that organization, section, and order;
  • the proposal is still pending and has not expired;
  • its stored operation kind and arguments can still be parsed; and
  • the current user is still allowed to perform the underlying operation.

Only then does the proposal move from pending to executing. The update is atomic and produces a typed execution contract:

struct ExecutionContract<Arguments> {
proposal_id: ProposalId,
operation_kind: OperationKind,
scope: OperationScope,
arguments: Arguments,
approved_by: UserId,
approved_at: DateTime,
}

match proposals.approve_for_execution(proposal_id, administrator, now)? {
Approval::Claimed(contract) => execute(contract),
Approval::AlreadyCompleted(outcome) => present(outcome),
Approval::NotApprovable(reason) => reject(reason),
}

The repository issues Approval::Claimed only when it atomically moves the matching, unexpired proposal from pending to executing. If another request already approved, declined, or completed it, the handler does not receive an execution contract and therefore does not get a second authorization to execute.

The model is not involved in any of this. Approval is an authenticated request from the administrator to Curling IO, not another message in the conversation.

Revalidation is part of execution

Freezing a proposal prevents its arguments from changing. It does not freeze the rest of the world.

A payment may have been refunded in another tab. Someone may have changed a broadcast's audience. The administrator may have lost access. A proposal may have sat open long enough to expire.

The refund executor therefore reloads the order and proves the original evidence still holds. It checks that:

  • the same line item and discount evidence still exist;
  • the calculated amount has not changed;
  • the selected payment still exists and has enough refundable value;
  • no unresolved refund attempt makes another call unsafe; and
  • the concrete destination is still compatible with the payment and account.

If a material fact changed, the old proposal fails closed. The application records a revision-required outcome, explains the changed condition in safe terms, and asks the manager to prepare a new proposal. It does not silently update the amount under an approval that showed something else.

This is optimistic concurrency in human terms. The preview is a claim about a particular state. Revalidation proves that claim is still true when approval arrives.

Idempotency has to reach the domain record

Conditional approval prevents the same pending proposal from starting twice, but that alone is not enough for operations with external effects.

Consider an online card refund. Curling IO can send the provider request and lose the HTTP response. At that point, retrying may create a second refund. The correct outcome is not a generic failure. It is OperationOutcome::NeedsReconciliation { status_path }.

The refund workflow retains stable proposal and provider identities, records the unresolved attempt, and refuses to improvise another call. Recovery checks the original attempt and eventually records the authoritative result.

For local database operations, the domain mutation and successful proposal outcome are committed in the same transaction. For external operations, the proposal stays in an executing or reconciliation state until recovery closes the uncertainty.

Repeated approval of a completed proposal returns its original stored outcome. It does not manufacture a fresh success message, and it does not execute the operation again. Replay is a delivery fact, not a new business result.

That distinction matters because HTTP responses are not durable. The operation record is.

Handing back the result without resuming the agent

The application already knows what happened, so it should not spend another model call asking for a paraphrase.

We use a small typed outcome vocabulary around each operation's own result:

enum OperationOutcome<S> {
Succeeded(S),
NeedsReconciliation {
status_path: String,
},
RevisionRequired {
reason: RevisionReason,
},
TerminalFailure {
correlation_id: String,
},
}

The operation-specific S might identify the refund and resulting order balance, or state that an email draft was copied into the editable form. The shared enum says what the user and system can safely do next.

One stored outcome drives several projections:

  • a localized browser response;
  • an app-authored follow-up in the assistant conversation;
  • bounded context supplied if the manager sends another message; and
  • eventually, the same semantic result through a machine-facing agent API.

The model does not classify the failure or decide whether retrying is safe. Rust does. On the next user turn, a recoverable outcome gives the model enough safe context to investigate again or prepare a revised proposal.

For an internal invariant failure or corrupt stored contract, the application records the operational error itself. The model and browser receive a terminal message with a correlation identifier, not a stack trace and not an invitation to keep trying. Asking the agent to submit a support ticket would add another failure-prone step while throwing away context the application already has.

Two assistants were enough to expose the boundary

We currently have two operation contracts, and they are deliberately different.

The order assistant can propose order.refund_missing_discount. Approval may lead to a financial operation with an external provider, uncertain responses, and reconciliation work.

The email broadcast assistant can propose email_broadcast.apply_draft. Approval copies frozen filters, subject, and message into an editable form. It does not create or send the broadcast. This is a local operation, but it still recounts the audience before applying the draft. If the audience changed, the manager needs a new proposal.

That difference stopped us from building a refund framework and calling it an agent framework. The shared part is small: lifecycle, approval identity, outcome semantics, audit, and handback. Argument types, previews, revalidation, and execution remain with the operation that understands them.

We expect to add assistants to a couple dozen sections. Each new operation will need a typed request, frozen arguments, a human preview, a revalidator, an executor, a typed result, and focused tests for expiry, stale state, replay, and failure classification.

That is more work than adding another function tool to a prompt. It is supposed to be.

What this pattern is, and what it is not

We did not invent human approval, durable commands, optimistic concurrency, capability security, or idempotency keys. The design borrows from all of them. Agent frameworks already support pausing tool calls for review.

The useful shift is treating the agent's requested mutation as input to an application command, not as the command itself. The application materializes a new durable object with stricter semantics than the model call that inspired it. Once that object exists, the model run is disposable.

This pattern is not necessary for every assistant response. Read-only answers do not need frozen proposals. Draft text that has no application effect can remain draft text. But if an operation changes customer data, sends something, moves money, or alters access, we want a stronger statement than "the model called a tool and a human clicked approve."

We want to know exactly what the application promised to do, exactly what the human approved, exactly which current facts were rechecked, and exactly what happened afterward.

That is the contract.