1"use strict";(self.webpackChunkdocs=self.webpackChunkdocs||[]).push([[8237],{1923:(e,n,t)=>{t.d(n,{A:()=>a});const a="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAwIiBoZWlnaHQ9IjcyMCIgdmlld0JveD0iMCAwIDEyMDAgNzIwIiByb2xlPSJpbWciIGFyaWEtbGFiZWxsZWRieT0idGl0bGUgZGVzY3JpcHRpb24iPgogIDx0aXRsZSBpZD0idGl0bGUiPkNvbXBhcmlzb24gb2YgdG9vbC1jYWxsIGFwcHJvdmFsIGFuZCBDdXJsaW5nIElPIG9wZXJhdGlvbiBjb250cmFjdHM8L3RpdGxlPgogIDxkZXNjIGlkPSJkZXNjcmlwdGlvbiI+SW4gYSBjb21tb24gdG9vbC1jYWxsIGFwcHJvdmFsIGZsb3csIHRoZSBtb2RlbCByZXF1ZXN0cyBhIHRvb2wsIHRoZSBhZ2VudCBydW50aW1lIHBhdXNlcywgYSBodW1hbiBhcHByb3ZlcywgdGhlIHJ1bnRpbWUgcmVzdW1lcywgYW5kIHRoZSB0b29sIGV4ZWN1dGVzLiBJbiBDdXJsaW5nIElPLCB0aGUgbW9kZWwgcHJvcG9zZXMgYW4gb3BlcmF0aW9uLCBSdXN0IHZhbGlkYXRlcyBhbmQgZnJlZXplcyBhIGNvbnRyYWN0LCBhIGh1bWFuIGFwcHJvdmVzIGl0cyBleGFjdCBlZmZlY3RzLCB0aGVuIFJ1c3QgcmVsb2FkcywgcmV2YWxpZGF0ZXMsIGV4ZWN1dGVzLCBhbmQgc3RvcmVzIHRoZSBvdXRjb21lIHdpdGhvdXQgcmVzdW1pbmcgdGhlIG1vZGVsLjwvZGVzYz4KICA8ZGVmcz4KICAgIDxmaWx0ZXIgaWQ9InNoYWRvdyIgeD0iLTIwJSIgeT0iLTIwJSIgd2lkdGg9IjE0MCUiIGhlaWdodD0iMTQwJSI+CiAgICAgIDxmZURyb3BTaGFkb3cgZHg9IjAiIGR5PSIyIiBzdGREZXZpYXRpb249IjMiIGZsb29kLWNvbG9yPSIjMGYxNzJhIiBmbG9vZC1vcGFjaXR5PSIuMTIiLz4KICAgIDwvZmlsdGVyPgogICAgPG1hcmtlciBpZD0iYXJyb3ctbXV0ZWQiIHZpZXdCb3g9IjAgMCAxMCAxMCIgcmVmWD0iOCIgcmVmWT0iNSIgbWFya2VyV2lkdGg9IjciIG1hcmtlckhlaWdodD0iNyIgb3JpZW50PSJhdXRvLXN0YXJ0LXJldmVyc2UiPgogICAgICA8cGF0aCBkPSJNIDAgMCBMIDEwIDUgTCAwIDEwIHoiIGZpbGw9IiM2NDc0OGIiLz4KICAgIDwvbWFya2VyPgogICAgPG1hcmtlciBpZD0iYXJyb3ctYmx1ZSIgdmlld0JveD0iMCAwIDEwIDEwIiByZWZYPSI4IiByZWZZPSI1IiBtYXJrZXJXaWR0aD0iNyIgbWFya2VySGVpZ2h0PSI3IiBvcmllbnQ9ImF1dG8tc3RhcnQtcmV2ZXJzZSI+CiAgICAgIDxwYXRoIGQ9Ik0gMCAwIEwgMTAgNSBMIDAgMTAgeiIgZmlsbD0iIzI1NjNlYiIvPgogICAgPC9tYXJrZXI+CiAgPC9kZWZzPgoKICA8cmVjdCB3aWR0aD0iMTIwMCIgaGVpZ2h0PSI3MjAiIHJ4PSIyMCIgZmlsbD0iI2Y4ZmFmYyIvPgogIDx0ZXh0IHg9IjQ4IiB5PSI1NCIgZmlsbD0iIzBmMTcyYSIgZm9udC1mYW1pbHk9IkludGVyLCB1aS1zYW5zLXNlcmlmLCBzeXN0ZW0tdWksIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMzAiIGZvbnQtd2VpZ2h0PSI3MDAiPldobyBvd25zIHRoZSBvcGVyYXRpb24gYWZ0ZXIgYXBwcm92YWw/PC90ZXh0PgoKICA8ZyBmb250LWZhbWlseT0iSW50ZXIsIHVpLXNhbnMtc2VyaWYsIHN5c3RlbS11aSwgc2Fucy1zZXJpZiI+CiAgICA8cmVjdCB4PSIzNiIgeT0iODgiIHdpZHRoPSIxMTI4IiBoZWlnaHQ9IjI1MCIgcng9IjE2IiBmaWxsPSIjZmZmIiBzdHJva2U9IiNjYmQ1ZTEiIHN0cm9rZS13aWR0aD0iMiIvPgogICAgPHRleHQgeD0iNjQiIHk9IjEyNiIgZmlsbD0iIzBmMTcyYSIgZm9udC1zaXplPSIyMSIgZm9udC13ZWlnaHQ9IjcwMCI+Q29tbW9uIHRvb2wtY2FsbCBhcHByb3ZhbDwvdGV4dD4KICAgIDx0ZXh0IHg9IjY0IiB5PSIxNTMiIGZpbGw9IiM0NzU1NjkiIGZvbnQtc2l6ZT0iMTYiPlRoZSBwYXVzZWQgYWdlbnQgcnVuIHJlbWFpbnMgdGhlIGNvb3JkaW5hdG9yLjwvdGV4dD4KCiAgICA8ZyBmaWx0ZXI9InVybCgjc2hhZG93KSI+CiAgICAgIDxyZWN0IHg9IjY0IiB5PSIxODgiIHdpZHRoPSIxODAiIGhlaWdodD0iMTA0IiByeD0iMTIiIGZpbGw9IiNmOGZhZmMiIHN0cm9rZT0iI2NiZDVlMSIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICAgIDxyZWN0IHg9IjI4NiIgeT0iMTg4IiB3aWR0aD0iMTgwIiBoZWlnaHQ9IjEwNCIgcng9IjEyIiBmaWxsPSIjZjhmYWZjIiBzdHJva2U9IiNjYmQ1ZTEiIHN0cm9rZS13aWR0aD0iMiIvPgogICAgICA8cmVjdCB4PSI1MDgiIHk9IjE4OCIgd2lkdGg9IjE4MCIgaGVpZ2h0PSIxMDQiIHJ4PSIxMiIgZmlsbD0iI2ZmZjdlZCIgc3Ryb2tlPSIjZmI5MjNjIiBzdHJva2Utd2lkdGg9IjIiLz4KICAgICAgPHJlY3QgeD0iNzMwIiB5PSIxODgiIHdpZHRoPSIxODAiIGhlaWdodD0iMTA0IiByeD0iMTIiIGZpbGw9IiNmOGZhZmMiIHN0cm9rZT0iI2NiZDVlMSIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICAgIDxyZWN0IHg9Ijk1MiIgeT0iMTg4IiB3aWR0aD0iMTgwIiBoZWlnaHQ9IjEwNCIgcng9IjEyIiBmaWxsPSIjZjhmYWZjIiBzdHJva2U9IiNjYmQ1ZTEiIHN0cm9rZS13aWR0aD0iMiIvPgogICAgPC9nPgoKICAgIDxnIGZpbGw9IiMwZjE3MmEiIGZvbnQtc2l6ZT0iMTYiIGZvbnQtd2VpZ2h0PSI3MDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiPgogICAgICA8dGV4dCB4PSIxNTQiIHk9IjIyNCI+PHRzcGFuIHg9IjE1NCI+TW9kZWwgcmVxdWVzdHM8L3RzcGFuPjx0c3BhbiB4PSIxNTQiIGR5PSIyMyI+YSB0b29sIGNhbGw8L3RzcGFuPjwvdGV4dD4KICAgICAgPHRleHQgeD0iMzc2IiB5PSIyMjQiPjx0c3BhbiB4PSIzNzYiPkFnZW50IHJ1bnRpbWU8L3RzcGFuPjx0c3BhbiB4PSIzNzYiIGR5PSIyMyI+cGF1c2VzPC90c3Bhbj48L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjU5OCIgeT0iMjI0Ij48dHNwYW4geD0iNTk4Ij5IdW1hbiByZXZpZXdzPC90c3Bhbj48dHNwYW4geD0iNTk4IiBkeT0iMjMiPmFuZCBhcHByb3ZlczwvdHNwYW4+PC90ZXh0PgogICAgICA8dGV4dCB4PSI4MjAiIHk9IjIyNCI+PHRzcGFuIHg9IjgyMCI+QWdlbnQgcnVudGltZTwvdHNwYW4+PHRzcGFuIHg9IjgyMCIgZHk9IjIzIj5yZXN1bWVzPC90c3Bhbj48L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjEwNDIiIHk9IjIyNCI+PHRzcGFuIHg9IjEwNDIiPkFwcHJvdmVkIHRvb2w8L3RzcGFuPjx0c3BhbiB4PSIxMDQyIiBkeT0iMjMiPmV4ZWN1dGVzPC90c3Bhbj48L3RleHQ+CiAgICA8L2c+CgogICAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjQ3NDhiIiBzdHJva2Utd2lkdGg9IjMiIG1hcmtlci1lbmQ9InVybCgjYXJyb3ctbXV0ZWQpIj4KICAgICAgPHBhdGggZD0iTTI0NCAyNDBoMzEiLz4KICAgICAgPHBhdGggZD0iTTQ2NiAyNDBoMzEiLz4KICAgICAgPHBhdGggZD0iTTY4OCAyNDBoMzEiLz4KICAgICAgPHBhdGggZD0iTTkxMCAyNDBoMzEiLz4KICAgIDwvZz4KCiAgICA8cmVjdCB4PSIzNiIgeT0iMzcwIiB3aWR0aD0iMTEyOCIgaGVpZ2h0PSIzMDQiIHJ4PSIxNiIgZmlsbD0iI2VmZjZmZiIgc3Ryb2tlPSIjOTNjNWZkIiBzdHJva2Utd2lkdGg9IjIiLz4KICAgIDx0ZXh0IHg9IjY0IiB5PSI0MDgiIGZpbGw9IiMwZjE3MmEiIGZvbnQtc2l6ZT0iMjEiIGZvbnQtd2VpZ2h0PSI3MDAiPkN1cmxpbmcgSU8gb3BlcmF0aW9uIGNvbnRyYWN0PC90ZXh0PgogICAgPHRleHQgeD0iNjQiIHk9IjQzNSIgZmlsbD0iIzQ3NTU2OSIgZm9udC1zaXplPSIxNiI+VGhlI
1HZhbGlkYXRlZCBwcm9wb3NhbCBiZWNvbWVzIGR1cmFibGUgYXBwbGljYXRpb24gc3RhdGUuIFRoZSBtb2RlbCBydW4gaXMgZmluaXNoZWQuPC90ZXh0PgoKICAgIDxwYXRoIGQ9Ik0yNjMgNDUxdjE4MiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMjU2M2ViIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1kYXNoYXJyYXk9IjcgNyIvPgogICAgPHJlY3QgeD0iMjc4IiB5PSI0NDgiIHdpZHRoPSIyMjYiIGhlaWdodD0iMzAiIHJ4PSIxNSIgZmlsbD0iI2RiZWFmZSIvPgogICAgPHRleHQgeD0iMzkxIiB5PSI0NjkiIGZpbGw9IiMxZDRlZDgiIGZvbnQtc2l6ZT0iMTQiIGZvbnQtd2VpZ2h0PSI3MDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiPkFQUExJQ0FUSU9OLU9XTkVEIEZST00gSEVSRTwvdGV4dD4KCiAgICA8ZyBmaWx0ZXI9InVybCgjc2hhZG93KSI+CiAgICAgIDxyZWN0IHg9IjY0IiB5PSI1MDQiIHdpZHRoPSIxODAiIGhlaWdodD0iMTEyIiByeD0iMTIiIGZpbGw9IiNmZmYiIHN0cm9rZT0iI2NiZDVlMSIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICAgIDxyZWN0IHg9IjI4NiIgeT0iNTA0IiB3aWR0aD0iMTgwIiBoZWlnaHQ9IjExMiIgcng9IjEyIiBmaWxsPSIjZGJlYWZlIiBzdHJva2U9IiM2MGE1ZmEiIHN0cm9rZS13aWR0aD0iMiIvPgogICAgICA8cmVjdCB4PSI1MDgiIHk9IjUwNCIgd2lkdGg9IjE4MCIgaGVpZ2h0PSIxMTIiIHJ4PSIxMiIgZmlsbD0iI2ZmZjdlZCIgc3Ryb2tlPSIjZmI5MjNjIiBzdHJva2Utd2lkdGg9IjIiLz4KICAgICAgPHJlY3QgeD0iNzMwIiB5PSI1MDQiIHdpZHRoPSIxODAiIGhlaWdodD0iMTEyIiByeD0iMTIiIGZpbGw9IiNkYmVhZmUiIHN0cm9rZT0iIzYwYTVmYSIgc3Ryb2tlLXdpZHRoPSIyIi8+CiAgICAgIDxyZWN0IHg9Ijk1MiIgeT0iNTA0IiB3aWR0aD0iMTgwIiBoZWlnaHQ9IjExMiIgcng9IjEyIiBmaWxsPSIjZGJlYWZlIiBzdHJva2U9IiMyNTYzZWIiIHN0cm9rZS13aWR0aD0iMiIvPgogICAgPC9nPgoKICAgIDxnIGZpbGw9IiMwZjE3MmEiIGZvbnQtc2l6ZT0iMTYiIGZvbnQtd2VpZ2h0PSI3MDAiIHRleHQtYW5jaG9yPSJtaWRkbGUiPgogICAgICA8dGV4dCB4PSIxNTQiIHk9IjU0MCI+PHRzcGFuIHg9IjE1NCI+TW9kZWwgcHJvcG9zZXM8L3RzcGFuPjx0c3BhbiB4PSIxNTQiIGR5PSIyMyI+YW4gb3BlcmF0aW9uPC90c3Bhbj48L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjM3NiIgeT0iNTMwIj48dHNwYW4geD0iMzc2Ij5SdXN0IHZhbGlkYXRlczwvdHNwYW4+PHRzcGFuIHg9IjM3NiIgZHk9IjIzIj5hbmQgZnJlZXplczwvdHNwYW4+PHRzcGFuIHg9IjM3NiIgZHk9IjIzIj50aGUgY29udHJhY3Q8L3RzcGFuPjwvdGV4dD4KICAgICAgPHRleHQgeD0iNTk4IiB5PSI1MzAiPjx0c3BhbiB4PSI1OTgiPkh1bWFuIGFwcHJvdmVzPC90c3Bhbj48dHNwYW4geD0iNTk4IiBkeT0iMjMiPnRoZSBleGFjdDwvdHNwYW4+PHRzcGFuIHg9IjU5OCIgZHk9IjIzIj5lZmZlY3RzPC90c3Bhbj48L3RleHQ+CiAgICAgIDx0ZXh0IHg9IjgyMCIgeT0iNTMwIj48dHNwYW4geD0iODIwIj5SdXN0IHJlbG9hZHM8L3RzcGFuPjx0c3BhbiB4PSI4MjAiIGR5PSIyMyI+YW5kIHJldmFsaWRhdGVzPC90c3Bhbj48dHNwYW4geD0iODIwIiBkeT0iMjMiPmN1cnJlbnQgc3RhdGU8L3RzcGFuPjwvdGV4dD4KICAgICAgPHRleHQgeD0iMTA0MiIgeT0iNTMwIj48dHNwYW4geD0iMTA0MiI+UnVzdCBleGVjdXRlczwvdHNwYW4+PHRzcGFuIHg9IjEwNDIiIGR5PSIyMyI+YW5kIHN0b3JlcyB0aGU8L3RzcGFuPjx0c3BhbiB4PSIxMDQyIiBkeT0iMjMiPnR5cGVkIG91dGNvbWU8L3RzcGFuPjwvdGV4dD4KICAgIDwvZz4KCiAgICA8ZyBmaWxsPSJub25lIiBzdHJva2U9IiMyNTYzZWIiIHN0cm9rZS13aWR0aD0iMyIgbWFya2VyLWVuZD0idXJsKCNhcnJvdy1ibHVlKSI+CiAgICAgIDxwYXRoIGQ9Ik0yNDQgNTYwaDMxIi8+CiAgICAgIDxwYXRoIGQ9Ik00NjYgNTYwaDMxIi8+CiAgICAgIDxwYXRoIGQ9Ik02ODggNTYwaDMxIi8+CiAgICAgIDxwYXRoIGQ9Ik05MTAgNTYwaDMxIi8+CiAgICA8L2c+CgogICAgPHRleHQgeD0iMTA0MiIgeT0iNjQ3IiBmaWxsPSIjMWQ0ZWQ4IiBmb250LXNpemU9IjE0IiBmb250LXdlaWdodD0iNzAwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIj5OTyBNT0RFTCBSRVNVTUU8L3RleHQ+CiAgPC9nPgo8L3N2Zz4K"},28453:(e,n,t)=>{t.d(n,{R:()=>r,x:()=>s});var a=t(96540);const i={},o=a.createContext(i);function r(e){const n=a.useContext(o);return a.useMemo((function(){return"function"==typeof e?e(n):{...n,...e}}),[n,e])}function s(e){let n;return n=e.disableParentContext?"function"==typeof e.components?e.components(i):e.components||i:r(e.components),a.createElement(o.Provider,{value:n},e.children)}},67895:(e,n,t)=>{t.r(n),t.d(n,{assets:()=>l,contentTitle:()=>s,default:()=>h,frontMatter:()=>r,metadata:()=>a,toc:()=>d});var a=t(94716),i=t(74848),o=t(28453);const r={slug:"human-in-the-loop-with-contracts",title:"Human in the Loop with Contracts",description:"Why Curling IO freezes agent proposals into typed application contracts, asks a human to approve their exact effects, and executes them without handing control back to the model.",authors:["dave"],tags:["v3","agents","rust","architecture","security"],keywords:["AI agent approval","human in the loop","agent security","Rust","application architecture"]},s=void 0,l={authorsImageUrls:[void 0]},d=[{value:"The common approval pattern",id:"the-common-approval-pattern",level:2},{value:"Human approval is not enough",id:"human-approval-is-not-enough",level:2},{value:"The smallest useful surface",id:"the-smallest-useful-surface",level:2},{value:"The model request is not the stored proposal",id:"the-model-request-is-not-the-stored-proposal",level:2},{value:"A proposal is durable application state",id:"a-proposal-is-durable-application-state",level:2},{value:"Approval creates one execution contract",id:"approval-creates-one-execution-contract",level:2},{value:"Revalidation is part of execution",id:"revalidation-is-part-of-execution",level:2},{value:"Idempotency has to reach the domain record",id:"idempotency-has-to-reach-the-domain-record",level:2},{value:"Handing back the result without resuming the agent",id:"handing-back-the-result-without-resuming-the-agent",level:2},{value:"Two assistants were enough to expose the boundary",id:"two-assistants-were-enough-to-expose-the-boundary",level:2},{value:"What this pattern is, and what it is not",id:"what-this-pattern-is-and-what-it-is-not",level:2}];function c(e){const n={a:"a",admonition:"admonition",blockquote:"blockquote",code:"code",h2:"h2",img:"img",li:"li",p:"p",pre:"pre",strong:"strong",ul:"ul",...(0,o.R)(),...e.components};return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)(n.admonition,{title:"About this post",type:"note",children:(0,i.jsx)(n.p,{children:"This is a technical implementation note about the AI assistant architecture in\nCurling IO v3. It is written for software engineers and others designing agent\nsystems, and goe
1s deeper into Rust, persistence, authorization, and failure\nhandling than our usual product posts."})}),"\n",(0,i.jsx)(n.p,{children:"The usual human-in-the-loop AI agent pattern goes something like this: the model\nrequests a tool call, the agent runtime pauses, a human approves the call, and\nthe runtime resumes so the tool can execute."}),"\n",(0,i.jsx)(n.p,{children:"That is a reasonable general-purpose design. It is also stricter than simply\nletting an agent call every tool it can see. For Curling IO, we wanted to expose\nthe smallest possible surface to the model and put an application-owned\nguardrail around every path to a write. That led us to a stricter question:"}),"\n",(0,i.jsx)(n.p,{children:"If the application already has the exact call details, why hand control back to\nthe agent at all?"}),"\n",(0,i.jsx)(n.p,{children:"By the time we ask a club manager to approve an operation, Curling IO has parsed\nthe model's request, resolved every default, checked the current application\nstate, produced a fixed preview, and stored the exact arguments. The model has\nnothing useful left to contribute to execution, so we do not let it execute\nthe operation or resume it merely to carry out the approval."}),"\n",(0,i.jsx)(n.p,{children:"The agent proposes. The application turns that proposal into a contract. The\nhuman approves the contract. Rust executes it."}),"\n","\n",(0,i.jsx)(n.h2,{id:"the-common-approval-pattern",children:"The common approval pattern"}),"\n",(0,i.jsxs)(n.p,{children:["The ",(0,i.jsx)(n.a,{href:"https://openai.github.io/openai-agents-python/human_in_the_loop/",children:"OpenAI Agents SDK human-in-the-loop\nflow"})," can pause\na run when a tool requires approval. The application serializes the ",(0,i.jsx)(n.code,{children:"RunState"}),",\nrecords approvals or rejections for pending tool calls, and resumes the original\nrun. The approved tool executes as the run continues."]}),"\n",(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.a,{href:"https://langchain-ai.github.io/langgraph/how-tos/human_in_the_loop/review-tool-calls/",children:"LangGraph interrupts"}),"\nsupport a similar shape. A graph can stop before a tool node, let a person\napprove, edit, or reject the call, then resume toward the tool or back toward\nthe model."]}),"\n",(0,i.jsx)(n.p,{children:"Those frameworks solve a broad problem. An agent may have many tools, nested\nagents, long-running work, and several points where a human needs to intervene.\nKeeping the pending tool call inside durable agent state is useful in that\nworld."}),"\n",(0,i.jsx)(n.p,{children:"Curling IO has a narrower problem. We own the application, the database, the\nauthorization rules, the interface, and every operation an assistant may\npropose. We do not need a generic agent runtime to remain authoritative after a\nproposal has crossed into application state."}),"\n",(0,i.jsx)(n.p,{children:"This is the difference:"}),"\n",(0,i.jsx)(n.p,{children:(0,i.jsx)(n.img,{alt:"A comparison of common tool-call approval, where the agent runtime resumes\nafter human approval, and Curling IO operation contracts, where Rust owns the\nvalidated contract and executes it without resuming the\nmodel.",src:t(1923).A+"",width:"1200",height:"720"})}),"\n",(0,i.jsx)(n.p,{children:"Not resuming the model after approval has a narrow meaning. While a proposal is\nbeing prepared, safe validation errors can go back into the active model loop\nso it can correct its request. If a manager declines a proposal, or execution\nfinds a recoverable state change, Curling IO records app-authored revision\ncontext for the manager's next message. That starts a new model turn with the\nsafe reason included. A terminal internal failure is recorded and reported as\nnon-retryable instead. The model can adapt where that is useful, but it never\nowns execution of the approved contract."}),"\n",(0,i.jsx)(n.p,{children:"The first pattern can be implemented safely. It's not that resuming an agent\nautomatically changes an approved call. A good runtime should preserve the\nexact call and its identity. For a first-party application, the paused model\nrun is unnecessary operational state once the application has accepted a\ncomplete proposal."}),"\n",(0,i.jsx)(n.h2,{id:"human-approval-is-not-enough",children:"Human approval is not enough"}),"\n",(0,i.jsx)(n.p,{children:"An approval button is only meaningful if the application can say exactly what\nwas approved."}),"\n",(0,i.jsx)(n.p,{children:"Suppose a model asks to refund an order. A weak approval could show:"}),"\n",(0,i.jsxs)(n.blockquote,{children:["\n",(0,i.jsx)(n.p,{children:"Refund this customer?"}),"\n"]}),"\n",(0,i.jsx)(n.p,{children:"That leaves almost every material decision hidden. Which payment? How much?\nWhich line item? Where will the money go? Is the model using a value it\ncalculated itself? Could it select a different destination when execution\nresumes?"}),"\n",(0,i.jsx)(n.p,{children:"Our refund review shows the participant, product, discount, payment, amount,\ndestination, and resulting order total. The manager is not approving the\nmodel's general intention to fix an order. They are approving one concrete\noperation with one set of effects."}),"\n",(0,i.jsx)(n.p,{children:"That requires more than a tool schema. It requires an application-owned\ncontract that defines all of these together:"}),"\n",(0,i.jsxs)(n.ul,{children:["\n",(0,i.jsx)(n.li,{children:"the fields the model must supply;"}),"\n",(0,i.jsx)(n.li,{children:"the defaults Rust is allowed to resolve;"}
1),"\n",(0,i.jsx)(n.li,{children:"valid and invalid combinations;"}),"\n",(0,i.jsx)(n.li,{children:"the typed representation stored for approval;"}),"\n",(0,i.jsx)(n.li,{children:"the fixed human review presentation;"}),"\n",(0,i.jsx)(n.li,{children:"revalidation against current state;"}),"\n",(0,i.jsx)(n.li,{children:"the executor;"}),"\n",(0,i.jsx)(n.li,{children:"the typed result and safe handback; and"}),"\n",(0,i.jsx)(n.li,{children:"the audit events for the whole lifecycle."}),"\n"]}),"\n",(0,i.jsx)(n.p,{children:"If those pieces are split between a prompt, a generic JSON schema, a hand-built\nreview page, and an unrelated executor, they will drift. A new field can affect\nexecution without appearing in the review. A prompt can describe a default\ndifferently from the application. A model can produce a value the ordinary\ninterface would never allow."}),"\n",(0,i.jsx)(n.p,{children:"In Curling IO, those are all parts of one capability."}),"\n",(0,i.jsx)(n.h2,{id:"the-smallest-useful-surface",children:"The smallest useful surface"}),"\n",(0,i.jsx)(n.p,{children:"Our first preference is not to guard a broad agent surface. It is to avoid\npresenting that surface in the first place."}),"\n",(0,i.jsx)(n.p,{children:"Each assistant is confined to one section of Curling IO and receives only the\ncontext needed for the current task. The order assistant does not receive an\norganization-wide database view. The email assistant does not inherit the\norder assistant's tools. Tenant identity, permissions, internal field names,\nprovider payloads, and unrelated customer records stay on the application side\nof the boundary."}),"\n",(0,i.jsx)(n.p,{children:"The same rule applies to operations. An assistant sees a small catalogue of\nthings it may propose, not a generic write API. Its model-facing fields contain\nonly the choices that genuinely require interpretation. Rust supplies resource\nscope, resolves defaults, calculates derived values, rejects unsupported\ncombinations, and builds the human preview."}),"\n",(0,i.jsx)(n.p,{children:"Then we add a guardrail at every remaining vector from model output to durable\nstate:"}),"\n",(0,i.jsxs)(n.ul,{children:["\n",(0,i.jsx)(n.li,{children:"bounded, application-written context before the model call;"}),"\n",(0,i.jsx)(n.li,{children:"task-specific read tools with server-owned tenant scope;"}),"\n",(0,i.jsx)(n.li,{children:"strict parsing and validation of the model's operation request;"}),"\n",(0,i.jsx)(n.li,{children:"typed arguments and previews built by Rust;"}),"\n",(0,i.jsx)(n.li,{children:"human approval of every material effect;"}),"\n",(0,i.jsx)(n.li,{children:"authorization, expiry, and state revalidation at execution time;"}),"\n",(0,i.jsx)(n.li,{children:"idempotency at the proposal and domain-record boundaries; and"}),"\n",(0,i.jsx)(n.li,{children:"typed, redacted outcomes after execution."}),"\n"]}),"\n",(0,i.jsx)(n.p,{children:"No one check carries the whole safety argument. Human approval does not replace\nauthorization. A type does not prove that current state still permits the\noperation. Revalidation does not prevent a duplicate provider call after a\nlost response. The surface stays small, and every boundary still has its own\njob."}),"\n",(0,i.jsx)(n.h2,{id:"the-model-request-is-not-the-stored-proposal",children:"The model request is not the stored proposal"}),"\n",(0,i.jsxs)(n.p,{children:["Our order assistant has a model-facing operation called\n",(0,i.jsx)(n.code,{children:"propose_payment_refund"}),". Its input is intentionally small. The model identifies\nthe relevant payment, line item, discount, and requested destination from the\nevidence Curling IO gave it."]}),"\n",(0,i.jsx)(n.p,{children:"Rust does not store that request directly. It validates the request against the\nserver-owned order investigation, calculates the refund using application\nrules, resolves a concrete destination, and creates typed arguments:"}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-rust",children:"#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\nstruct OrderRefundArguments {\n order_id: i64,\n payment_id: i64,\n line_item_id: i64,\n discount_id: i64,\n amount_cents: i64,\n refund_destination: RefundDestination,\n}\n"})}),"\n",(0,i.jsxs)(n.p,{children:["The model does not choose ",(0,i.jsx)(n.code,{children:"amount_cents"}),'. It cannot say "use the original\npayment method" and leave that decision until later. Rust resolves that phrase\nto a concrete ',(0,i.jsx)(n.code,{children:"RefundDestination"})," before the manager sees anything."]}),"\n",(0,i.jsx)(n.p,{children:"The review is typed separately from the executable arguments:"}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-rust",children:"#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\nstruct OrderRefundPreview {\n participant_name: String,\n product_name: String,\n discount_name: String,\n payment_method: PaymentMethod,\n amount_cents: i64,\n current_order_total_cents: i64,\n resulting_order_total_cents: i64,\n currency: String,\n refund_destination: RefundDestination,\n product_configuration_unchanged: bool,\n}\n"})}),"\n",(0,i.jsx)(n.p,{children:"The arguments contain what execution needs. The preview contains what a human\nneeds to understand the consequences. Both come from the same validated domain\nfacts, and both are frozen together."}),"\n",(0,i.jsx)(n.p,{children:"JSON appears at the database boundary, but it is not the programming model. An\noperation is parsed back into its Rust type before it can be revalidated or\nexecuted. An operation kind cannot be dispatched into another operation's\nargument type."}),"\n",(0,i.jsx)(n.h2,{id:"a-proposal-is-durable-application-state",children:"A proposal is durable application state"}),"\n",(0,i.jsx)(n.p,{children:"Each proposal gets an opaque public identifier and a row containing its tenant,\nrequesting administrator, section, resource, operation kind, exact arguments,\npreview, status, and expiry. It also records approval, execution, result, and\nfailure information."}),"\n",(0,i.jsx)(n.p,{children:"A simplified view of the Rust side looks like this:"}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-rust",children:"struct OperationProposal<Arguments, Preview, Result> {\n public_id: ProposalId,\n interaction_id: InteractionId,\n scope: OperationScope,\n requested_by: UserId,\n operation_kind: OperationKind,\n arguments: Arguments,\n preview: Preview,\n status: ProposalStatus,\n expires_at: DateTime,\n approval: Option<Approval>,\n outcome: Option<OperationOutcome<Result>>,\n}\n\nstruct OperationScope {\n organization_id: OrganizationId,\n section: AssistantSection,\n resource_id: ResourceId,\n}\n"})}),"\n",(0,i.jsxs)(n.p,{children:[(0,i.jsx)(n.code,{children:"Arguments"}),", ",(0,i.jsx)(n.code,{children:"Preview"}),", and ",(0,i.jsx)(n.code,{children:"Result"})," are the types owned by one operation\ncontract. SQLite stores their serialized form and the proposal's audit events,\nbut application code cannot execute those values without decoding them through\nthe matching contract."]}),"\n",(0,i.jsx)(n.p,{children:"The conversation is not the source of truth for this operation. Neither is the\nmodel provider's stored run state. A pending proposal survives a browser\ndisconnect, a model change, or a deployment because Curling IO can reconstruct\nthe approval from its own records."}),"\n",(0,i.jsx)(n.p,{children:"This also gives the approval request a very small input. The browser submits the\nopaque proposal identifier. It does not submit the refund amount, destination,\nmessage body, or any other approved value a second time."}),"\n",(0,i.jsx)(n.h2,{id:"approval-creates-one-execution-contract",children:"Approval creates one execution contract"}),"\n",(0,i.jsxs)(n.p,{children:["When the administrator selects ",(0,i.jsx)(n.strong,{children:"Approve refund"}),", Curling IO verifies all of\nthe ordinary request boundaries again:"]}),"\n",(0,i.jsxs)(n.ul,{children:["\n",(0,i.jsx)(n.li,{children:"the signed-in user still has access to the organization;"}),"\n",(0,i.jsx)(n.li,{children:"the proposal belongs to that organization, section, and order;"}),"\n",(0,i.jsx)(n.li,{children:"the proposal is still pending and has not expired;"}),"\n",(0,i.jsx)(n.li,{children:"its stored operation kind and arguments can still be parsed; and"}),"\n",(0,i.jsx)(n.li,{children:"the current user is still allowed to perform the underlying operation."}),"\n"]}),"\n",(0,i.jsxs)(n.p,{children:["Only then does the proposal move from ",(0,i.jsx)(n.code,{children:"pending"})," to ",(0,i.jsx)(n.code,{children:"executing"}),". The update is\natomic and produces a typed execution contract:"]}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-rust",children:"struct ExecutionContract<Arguments> {\n proposal_id: ProposalId,\n operation_kind: OperationKind,\n scope: OperationScope,\n arguments: Arguments,\n approved_by: UserId,\n approved_at: DateTime,\n}\n\nmatch proposals.approve_for_execution(proposal_id, administrator, now)? {\n Approval::Claimed(contract) => execute(contract),\n Approval::AlreadyCompleted(outcome) => present(outcome),\n Approval::NotApprovable(reason) => reject(reason),\n}\n"})}),"\n",(0,i.jsxs)(n.p,{children:["The repository issues ",(0,i.jsx)(n.code,{children:"Approval::Claimed"})," only when it atomically moves the\nmatching, unexpired proposal from ",(0,i.jsx)(n.code,{children:"pending"})," to ",(0,i.jsx)(n.code,{children:"executing"}),". If another request\nalready approved, declined, or completed it, the handler does not receive an\nexecution contract and therefore does not get a second authorization to\nexecute."]}),"\n",(0,i.jsx)(n.p,{children:"The model is not involved in any of this. Approval is an authenticated request\nfrom the administrator to Curling IO, not another message in the conversation."}),"\n",(0,i.jsx)(n.h2,{id:"revalidation-is-part-of-execution",children:"Revalidation is part of execution"}),"\n",(0,i.jsx)(n.p,{children:"Freezing a proposal prevents its arguments from changing. It does not freeze\nthe rest of the world."}),"\n",(0,i.jsx)(n.p,{children:"A payment may have been refunded in another tab. Someone may have changed a\nbroadcast's audience. The administrator may have lost access. A proposal may\nhave sat open long enough to expire."}),"\n",(0,i.jsx)(n.p,{children:"The refund executor therefore reloads the order and proves the original\nevidence still holds. It checks that:"}),"\n",(0,i.jsxs)(n.ul,{children:["\n",(0,i.jsx)(n.li,{children:"the same line item and discount evidence still exist;"}),"\n",(0,i.jsx)(n.li,{children:"the calculated amount has not changed;"}),"\n",(0,i.jsx)(n.li,{children:"the selected payment still exists and has enough refundable value;"}),"\n",(0,i.jsx)(n.li,{children:"no unresolved refund attempt makes another call unsafe; and"}),"\n",(0,i.jsx)(n.li,{children:"the concrete destination is still compatible with the payment and account."}),"\n"]}),"\n",(0,i.jsx)(n.p,{children:"If a material fact changed, the old proposal fails closed. The application\nrecords a revision-required outcome, explains the changed condition in safe\nterms, and asks the manager to prepare a new proposal. It does not silently\nupdate the amount under an approval that showed something else."}),"\n",(0,i.jsx)(n.p,{children:"This is optimistic concurrency in human terms. The preview is a claim about a\nparticular state. Revalidation proves that claim is still true when approval\narrives."}),"\n",(0,i.jsx)(n.h2,{id:"idempotency-has-to-reach-the-domain-record",children:"Idempotency has to reach the domain record"}),"\n",(0,i.jsx)(n.p,{children:"Conditional approval prevents the same pending proposal from starting twice,\nbut that alone is not enough for operations with external effects."}),"\n",(0,i.jsxs)(n.p,{children:["Consider an online card refund. Curling IO can send the provider request and\nlose the HTTP response. At that point, retrying may create a second refund. The\ncorrect outcome is not a generic failure. It is\n",(0,i.jsx)(n.code,{children:"OperationOutcome::NeedsReconciliation { status_path }"}),"."]}),"\n",(0,i.jsx)(n.p,{children:"The refund workflow retains stable proposal and provider identities, records\nthe unresolved attempt, and refuses to improvise another call. Recovery checks\nthe original attempt and eventually records the authoritative result."}),"\n",(0,i.jsx)(n.p,{children:"For local database operations, the domain mutation and successful proposal\noutcome are committed in the same transaction. For external operations, the\nproposal stays in an executing or reconciliation state until recovery closes\nthe uncertainty."}),"\n",(0,i.jsx)(n.p,{children:"Repeated approval of a completed proposal returns its original stored outcome.\nIt does not manufacture a fresh success message, and it does not execute the\noperation again. Replay is a delivery fact, not a new business result."}),"\n",(0,i.jsx)(n.p,{children:"That distinction matters because HTTP responses are not durable. The operation\nrecord is."}),"\n",(0,i.jsx)(n.h2,{id:"handing-back-the-result-without-resuming-the-agent",children:"Handing back the result without resuming the agent"}),"\n",(0,i.jsx)(n.p,{children:"The application already knows what happened, so it should not spend another\nmodel call asking for a paraphrase."}),"\n",(0,i.jsx)(n.p,{children:"We use a small typed outcome vocabulary around each operation's own result:"}),"\n",(0,i.jsx)(n.pre,{children:(0,i.jsx)(n.code,{className:"language-rust",children:"enum OperationOutcome<S> {\n Succeeded(S),\n NeedsReconciliation {\n status_path: String,\n },\n RevisionRequired {\n reason: RevisionReason,\n },\n TerminalFailure {\n correlation_id: String,\n },\n}\n"})}),"\n",(0,i.jsxs)(n.p,{children:["The operation-specific ",(0,i.jsx)(n.code,{children:"S"})," might identify the refund and resulting order\nbalance, or state that an email draft was copied into the editable form. The\nshared enum says what the user and system can safely do next."]}),"\n",(0,i.jsx)(n.p,{children:"One stored outcome drives several projections:"}),"\n",(0,i.jsxs)(n.ul,{children:["\n",(0,i.jsx)(n.li,{children:"a localized browser response;"}),"\n",(0,i.jsx)(n.li,{children:"an app-authored follow-up in the assistant conversation;"}),"\n",(0,i.jsx)(n.li,{children:"bounded context supplied if the manager sends another message; and"}),"\n",(0,i.jsx)(n.li,{children:"eventually, the same semantic result through a machine-facing agent API."}),"\n"]}),"\n",(0,i.jsx)(n.p,{children:"The model does not classify the failure or decide whether retrying is safe.\nRust does. On the next user turn, a recoverable outcome gives the model enough\nsafe context to investigate again or prepare a revised proposal."}),"\n",(0,i.jsx)(n.p,{children:"For an internal invariant failure or corrupt stored contract, the application\nrecords the operational error itself. The model and browser receive a terminal\nmessage with a correlation identifier, not a stack trace and not an invitation\nto keep trying. Asking the agent to submit a support ticket would add another\nfailure-prone step while throwing away context the application already has."}),"\n",(0,i.jsx)(n.h2,{id:"two-assistants-were-enough-to-expose-the-boundary",children:"Two assistants were enough to expose the boundary"}
1),"\n",(0,i.jsx)(n.p,{children:"We currently have two operation contracts, and they are deliberately\ndifferent."}),"\n",(0,i.jsxs)(n.p,{children:["The order assistant can propose ",(0,i.jsx)(n.code,{children:"order.refund_missing_discount"}),". Approval may\nlead to a financial operation with an external provider, uncertain responses,\nand reconciliation work."]}),"\n",(0,i.jsxs)(n.p,{children:["The email broadcast assistant can propose ",(0,i.jsx)(n.code,{children:"email_broadcast.apply_draft"}),".\nApproval copies frozen filters, subject, and message into an editable form. It\ndoes not create or send the broadcast. This is a local operation, but it still\nrecounts the audience before applying the draft. If the audience changed, the\nmanager needs a new proposal."]}),"\n",(0,i.jsx)(n.p,{children:"That difference stopped us from building a refund framework and calling it an\nagent framework. The shared part is small: lifecycle, approval identity,\noutcome semantics, audit, and handback. Argument types, previews,\nrevalidation, and execution remain with the operation that understands them."}),"\n",(0,i.jsx)(n.p,{children:"We expect to add assistants to a couple dozen sections. Each new operation will\nneed a typed request, frozen arguments, a human preview, a revalidator, an\nexecutor, a typed result, and focused tests for expiry, stale state, replay,\nand failure classification."}),"\n",(0,i.jsx)(n.p,{children:"That is more work than adding another function tool to a prompt. It is supposed\nto be."}),"\n",(0,i.jsx)(n.h2,{id:"what-this-pattern-is-and-what-it-is-not",children:"What this pattern is, and what it is not"}),"\n",(0,i.jsx)(n.p,{children:"We did not invent human approval, durable commands, optimistic concurrency,\ncapability security, or idempotency keys. The design borrows from all of them.\nAgent frameworks already support pausing tool calls for review."}),"\n",(0,i.jsx)(n.p,{children:"The useful shift is treating the agent's requested mutation as input to an\napplication command, not as the command itself. The application materializes a\nnew durable object with stricter semantics than the model call that inspired\nit. Once that object exists, the model run is disposable."}),"\n",(0,i.jsx)(n.p,{children:'This pattern is not necessary for every assistant response. Read-only answers\ndo not need frozen proposals. Draft text that has no application effect can\nremain draft text. But if an operation changes customer data, sends something,\nmoves money, or alters access, we want a stronger statement than "the model\ncalled a tool and a human clicked approve."'}),"\n",(0,i.jsx)(n.p,{children:"We want to know exactly what the application promised to do, exactly what the\nhuman approved, exactly which current facts were rechecked, and exactly what\nhappened afterward."}),"\n",(0,i.jsx)(n.p,{children:"That is the contract."})]})}function h(e={}){const{wrapper:n}={...(0,o.R)(),...e.components};return n?(0,i.jsx)(n,{...e,children:(0,i.jsx)(c,{...e})}):c(e)}},94716:e=>{e.exports=JSON.parse('{"permalink":"/blog/human-in-the-loop-with-contracts","source":"@site/blog/2026-08-25-human-in-the-loop-with-contracts.mdx","title":"Human in the Loop with Contracts","description":"Why Curling IO freezes agent proposals into typed application contracts, asks a human to approve their exact effects, and executes them without handing control back to the model.","date":"2026-08-25T00:00:00.000Z","tags":[{"inline":true,"label":"v3","permalink":"/blog/tags/v-3"},{"inline":true,"label":"agents","permalink":"/blog/tags/agents"},{"inline":true,"label":"rust","permalink":"/blog/tags/rust"},{"inline":true,"label":"architecture","permalink":"/blog/tags/architecture"},{"inline":true,"label":"security","permalink":"/blog/tags/security"}],"readingTime":13.61,"hasTruncateMarker":true,"authors":[{"name":"Dave Rapin","title":"Founder @ Curling IO","imageURL":"https://avatars.githubusercontent.com/u/1202?v=4","key":"dave","page":null}],"frontMatter":{"slug":"human-in-the-loop-with-contracts","title":"Human in the Loop with Contracts","description":"Why Curling IO freezes agent proposals into typed application contracts, asks a human to approve their exact effects, and executes them without handing control back to the model.","authors":["dave"],"tags":["v3","agents","rust","architecture","security"],"keywords":["AI agent approval","human in the loop","agent security","Rust","application architecture"]},"unlisted":false,"prevItem":{"title":"Why We Built Our Own Error Tracking","permalink":"/blog/why-we-built-our-own-error-tracking"},"nextItem":{"title":"In-app Assistance in Curling IO","permalink":"/blog/in-app-assistance-in-curling-io"}}')}}]);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.