<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
  <title>Ratifia blog</title>
  <link>https://ratifia.com/blog</link>
  <description>Policy-driven human approval for AI workflows.</description>
  <language>en-us</language>
  <atom:link href="https://ratifia.com/rss.xml" rel="self" type="application/rss+xml"/>
  <item>
    <title>Why we're writing this</title>
    <link>https://ratifia.com/blog/why-we-are-writing-this</link>
    <guid isPermaLink="true">https://ratifia.com/blog/why-we-are-writing-this</guid>
    <pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate>
    <description>A blog about the unglamorous half of AI engineering: what happens when an agent proposes an action that a person has to stand behind.</description>
    <content:encoded><![CDATA[<p>Most of what gets written about AI agents is about making them capable. Very little is written about what happens at the moment an agent proposes to do something a person has to stand behind — issue the refund, send the email, file the claim, deploy the model.</p>
<p>That moment is where we spend our days, and it turns out to be much stranger and more interesting than &quot;add an approval step.&quot; So we&#39;re going to write about it here.</p>
<h2 id="what-this-blog-is-about" class="group">What this blog is about<a href="#what-this-blog-is-about" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h2>
<p>Concretely, the problems we keep running into and the ones our design partners bring us:</p>
<ul>
<li><strong>Approval design.</strong> What a reviewer needs to see before they can honestly say yes. Why a bare approve/reject button is a liability rather than a control.</li>
<li><strong>Policy.</strong> How to express &quot;a human decides this&quot; as a rule rather than a hard-coded <code>if</code>, and how to keep an AI from quietly deciding it doesn&#39;t need one.</li>
<li><strong>Routing, quorum, and escalation.</strong> Who gets asked, how many of them, and what happens when nobody answers. Silence is a design problem, not an error case.</li>
<li><strong>Provenance.</strong> What has to be recorded so that, a year later, someone can reconstruct not just who approved something but what they were looking at when they did.</li>
<li><strong>Workflow engines.</strong> What Inngest, Temporal, Trigger.dev, and Hatchet give you, where their primitives stop, and why that boundary sits exactly where it does.</li>
</ul>
<p>Some of it will be opinionated. Some will be things we got wrong and had to change. Where we haven&#39;t shipped something yet, we&#39;ll say so — the same way we do on the rest of this site.</p>
<h2 id="why-we-think-it39s-worth-your-time" class="group">Why we think it&#39;s worth your time<a href="#why-we-think-it39s-worth-your-time" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h2>
<p>Human-in-the-loop is treated as a checkbox on a compliance form, and it deserves better. The difference between an approval system that works and one that decays into a rubber stamp within a quarter is a hundred small design decisions, almost none of which are written down anywhere.</p>
<p>We are making those decisions in public, on a product that lets agents move real money. We&#39;d rather share what we learn than pretend we arrived at it obviously.</p>
<p>If you&#39;re building something similar, disagree with us loudly. That&#39;s the point.</p>
<hr>
<p><em>Ratifia pauses an AI workflow for a human decision, gives the reviewer the full context, and resumes with the verdict. It runs on top of the workflow engine you already use and never executes your code. <a href="/#signup">Request early access</a>.</em></p>
]]></content:encoded>
  </item>
  <item>
    <title>What belongs in an approval context payload</title>
    <link>https://ratifia.com/blog/what-belongs-in-an-approval-context-payload</link>
    <guid isPermaLink="true">https://ratifia.com/blog/what-belongs-in-an-approval-context-payload</guid>
    <pubDate>Thu, 02 Jul 2026 00:00:00 GMT</pubDate>
    <description>A reviewer who clicks approve without understanding the decision is a liability, not a control. Five things every approval request should carry — and three it should never.</description>
    <content:encoded><![CDATA[<p>An approval button is a control only if the person pressing it understood what they were approving. Otherwise it is a liability generator: it produces an audit record asserting that a human reviewed something, when in fact a human dismissed a notification.</p>
<p>The difference between those two outcomes is entirely determined by what you put in front of the reviewer. So it is worth being precise about it.</p>
<h2 id="the-test" class="group">The test<a href="#the-test" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h2>
<p>Before adding a field to an approval payload, ask: <strong>would a reviewer decide differently if this were absent?</strong></p>
<p>If no, it is decoration. Decoration is not free — it costs attention, and attention is the scarce resource in a queue of forty pending approvals. Every field that does not change the decision makes the fields that do harder to find.</p>
<p>That test kills more than you would expect. Workflow run IDs, internal trace links, the model&#39;s token count, the queue name — all real, all logged elsewhere, none of them changing whether a human approves a $12,400 refund.</p>
<h2 id="the-five-that-survive" class="group">The five that survive<a href="#the-five-that-survive" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h2>
<h3 id="1-the-action-stated-as-an-action" class="group">1. The action, stated as an action<a href="#1-the-action-stated-as-an-action" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h3>
<p>Not &quot;review request #48213.&quot; The reviewer needs the verb and the object: <em>issue a $12,400 refund to account 8841.</em> Write it the way a person would say it out loud. If the action cannot be stated in one sentence, the approval is scoped wrong and you should be gating something smaller.</p>
<h3 id="2-the-exact-operation-to-be-performed" class="group">2. The exact operation to be performed<a href="#2-the-exact-operation-to-be-performed" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h3>
<p>The literal tool call, with its literal arguments:</p>
<pre class="shiki github-dark-default" style="background-color:#0d1117;color:#e6edf3" tabindex="0"><code><span class="line"><span style="color:#E6EDF3">{</span></span>
<span class="line"><span style="color:#7EE787">  "tool"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"issueRefund"</span><span style="color:#E6EDF3">,</span></span>
<span class="line"><span style="color:#7EE787">  "args"</span><span style="color:#E6EDF3">: { </span><span style="color:#7EE787">"account"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"8841"</span><span style="color:#E6EDF3">, </span><span style="color:#7EE787">"amount"</span><span style="color:#E6EDF3">: </span><span style="color:#79C0FF">12400</span><span style="color:#E6EDF3">, </span><span style="color:#7EE787">"currency"</span><span style="color:#E6EDF3">: </span><span style="color:#A5D6FF">"USD"</span><span style="color:#E6EDF3"> }</span></span>
<span class="line"><span style="color:#E6EDF3">}</span></span></code></pre>
<p>This is the thing that will actually execute. Not a summary of it, not a rendering of it — it. A reviewer who approves a prose description and gets a different call has approved nothing, and your audit trail records a consent that was never given.</p>
<h3 id="3-the-state-delta" class="group">3. The state delta<a href="#3-the-state-delta" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h3>
<p>What is true before, what is true after:</p>
<pre class="shiki github-dark-default" style="background-color:#0d1117;color:#e6edf3" tabindex="0"><code><span class="line"><span style="color:#FFA198">- account_balance: $41,900</span></span>
<span class="line"><span style="color:#7EE787">+ account_balance: $29,500</span></span></code></pre>
<p>Reviewers reason about consequences, not operations. <code>issueRefund(12400)</code> is an operation. &quot;The account drops below the $30,000 covenant floor&quot; is a consequence, and it is the one that changes the answer.</p>
<h3 id="4-the-model39s-reasoning-marked-as-the-model39s" class="group">4. The model&#39;s reasoning, marked as the model&#39;s<a href="#4-the-model39s-reasoning-marked-as-the-model39s" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h3>
<p>Include it. Label it. Never let it render like a system-generated fact.</p>
<p>An AI recommendation is <em>evidence</em>, and the reviewer should weigh it as such. The failure mode here is automation bias: a confident &quot;Approve — policy match&quot; in neutral typography reads as a verdict rather than a suggestion, and reviewers rubber-stamp it. Attribute it to the model, name the model, and put it below the delta — never above.</p>
<h3 id="5-why-this-stopped" class="group">5. Why this stopped<a href="#5-why-this-stopped" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h3>
<p>Which policy fired, and on what clause. <em><code>payouts-over-5k</code> · amount exceeds $5,000 threshold.</em></p>
<p>Reviewers who understand why a decision reached them make better decisions, and they escalate the right ones. Reviewers who see approvals arrive for no legible reason learn to treat the queue as noise. That learning is permanent, and it is how an approval system decays into a rubber stamp within a quarter.</p>
<h2 id="the-three-that-must-never-appear" class="group">The three that must never appear<a href="#the-three-that-must-never-appear" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h2>
<p><strong>Credentials, tokens, or keys.</strong> Approval payloads get forwarded to Slack, rendered in email, and retained for years under compliance holds. They are the least appropriate place in your system for a secret.</p>
<p><strong>Anything you cannot show in an audit.</strong> If a field would be redacted from the record a regulator reads, it should not be in the record the reviewer reads either. The whole value of the payload is that the reviewer&#39;s view and the auditor&#39;s view are the same view. Divergence there means nobody can reconstruct the decision.</p>
<p><strong>Unbounded data.</strong> A 40,000-row export is not context. It is an invitation to approve without looking, and it will be the exhibit in the postmortem. Summarize, link out, and let the reviewer pull the detail if they want it.</p>
<h2 id="why-this-is-not-a-form" class="group">Why this is not a form<a href="#why-this-is-not-a-form" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h2>
<p>The instinct is to define a schema — <code>amount</code>, <code>account</code>, <code>reason</code> — and render it as a table. It works for one workflow and collapses at the second, because a refund approval and a content-moderation approval and a model-deployment approval share almost no fields.</p>
<p>What they share is a <em>shape</em>: a headline action, an operation, a consequence, evidence, and a reason for stopping. So the payload should be a list of typed blocks — text, diff, code, table, key-value — that a workflow composes as it needs. The system renders blocks; the workflow decides which blocks the decision requires.</p>
<p>That is why Ratifia takes a free-form context payload rather than a fixed approval schema. The engines you already run can tell a reviewer <em>that</em> something is waiting. What they cannot tell them is what it means, because they were never given a place to put it.</p>
]]></content:encoded>
  </item>
  <item>
    <title>Why waitForEvent is not human-in-the-loop</title>
    <link>https://ratifia.com/blog/why-wait-for-event-is-not-human-in-the-loop</link>
    <guid isPermaLink="true">https://ratifia.com/blog/why-wait-for-event-is-not-human-in-the-loop</guid>
    <pubDate>Wed, 24 Jun 2026 00:00:00 GMT</pubDate>
    <description>Every workflow engine can pause. Pausing is the easy half. The hard half is everything between the pause and the resume — and that is the half you end up building yourself.</description>
    <content:encoded><![CDATA[<p>Every durable workflow engine — Inngest, Temporal, Trigger.dev, Hatchet — can suspend a run until an external event arrives. So a reasonable engineer looks at a human-approval requirement, finds <code>step.waitForEvent()</code> in the docs, and concludes the problem is solved in an afternoon.</p>
<p>The pause is real. It is also about five percent of the work. Everything that makes an approval <em>trustworthy</em> lives in the gap between the pause and the resume, and none of it is in the engine.</p>
<h2 id="what-does-waitforevent-actually-give-you" class="group">What does <code>waitForEvent</code> actually give you?<a href="#what-does-waitforevent-actually-give-you" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h2>
<p>A correlation ID and a timeout. Your workflow stops, and it will start again when something posts a matching event, or when the clock runs out.</p>
<p>That is genuinely hard infrastructure and the engines do it well. But notice what it does not say anything about:</p>
<ul>
<li><strong>Who</strong> is allowed to send that event.</li>
<li><strong>What</strong> the person saw before they sent it.</li>
<li><strong>Whether</strong> one signature was enough.</li>
<li><strong>What happens</strong> when nobody sends it at all.</li>
<li><strong>Whether</strong> any of it can be reconstructed a year later, under audit.</li>
</ul>
<p>The engine&#39;s contract ends at &quot;a matching event resumed the run.&quot; Everything above is now yours.</p>
<h2 id="the-four-things-you-end-up-building" class="group">The four things you end up building<a href="#the-four-things-you-end-up-building" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h2>
<h3 id="an-interface-that-shows-the-decision" class="group">An interface that shows the decision<a href="#an-interface-that-shows-the-decision" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h3>
<p>The moment an agent is the thing proposing the action, a yes/no button is malpractice. The reviewer needs the model&#39;s actual output, the tool call it wants to make, and the records that change if it runs. Concretely: the <code>issueRefund({ amount: 12400 })</code> call, the balance before and after, and the model&#39;s stated reasoning — on one screen, at decision time.</p>
<p>None of that is in the event payload. You will build the queue, the detail view, the rendering of arbitrary context, and the mobile layout, because approvals happen on phones.</p>
<h3 id="authorization-on-the-resume-path" class="group">Authorization on the resume path<a href="#authorization-on-the-resume-path" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h3>
<p><code>waitForEvent</code> resumes on <em>any</em> matching event. Which means your approval endpoint is a public &quot;make this workflow continue&quot; button unless you put real authorization in front of it, and bind the approver&#39;s identity to the specific decision rather than to the event name.</p>
<p>Email action-links make this sharper. A signed link that approves a $12,400 payout with one click is a bearer token for money. It needs to be single-use, scoped to one decision, expiring, and unforgeable — and you have to log which one was redeemed.</p>
<h3 id="quorum" class="group">Quorum<a href="#quorum" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h3>
<p>&quot;Two people from Finance must both approve&quot; cannot be expressed as one event. You need to track partial state — who has signed off so far — reject duplicate votes from the same person, handle one approve and one reject, and only then emit the resume.</p>
<p>That state lives somewhere. The engine will not hold it for you, because from its perspective there is exactly one event and it has not arrived yet.</p>
<h3 id="escalation" class="group">Escalation<a href="#escalation" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h3>
<p>This is the one that bites in production, because it is invisible until it happens: a reviewer is on vacation, and a claim sits in a queue for nine days.</p>
<p>A timeout is not an escalation. A timeout fails the workflow. An escalation <em>reroutes</em> — after four hours with no quorum, widen to the whole Finance role; after twelve, page the on-call manager; after twenty-four, auto-reject and notify the customer. Each of those is a policy decision with a different blast radius, and every one of them is a new timer, a new notification path, and a new audit entry.</p>
<p>Note that this also means &quot;the timeout&quot; has two entirely different meanings that teams routinely conflate. There is <em>auto-escalate in 3h 40m</em> — nobody has acted, so widen the approver pool — and there is <em>expires in 24h</em> — nobody ever acted, so take the default action. Collapsing these into one number is how you end up either paging a VP over a $40 refund or silently approving a $40,000 one.</p>
<h2 id="so-when-is-waitforevent-the-right-answer" class="group">So when is <code>waitForEvent</code> the right answer?<a href="#so-when-is-waitforevent-the-right-answer" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h2>
<p>When the human is a teammate, the stakes are low, and the audit trail is a Slack thread. Internal tools, staging gates, &quot;ping me before you deploy.&quot; Reach for the engine primitive, wire it to a Slack button, move on. Adding a system here is overhead with no return.</p>
<p>It stops being the right answer at the point where the decision is a <strong>decision of record</strong> — where someone will later ask who approved this, what they saw when they approved it, and what would have happened if they had not. That question has a <em>compliance</em> answer, not an engineering one, and a correlation ID does not supply it.</p>
<h2 id="the-actual-dividing-line" class="group">The actual dividing line<a href="#the-actual-dividing-line" aria-label="Link to this section" class="ml-2 text-muted-foreground no-underline opacity-0 transition-opacity group-hover:opacity-100">#</a></h2>
<p><code>waitForEvent</code> treats the human as a <strong>signal source</strong>. Something out there will eventually emit an event; the workflow does not care what, or who, or why.</p>
<p>Human-in-the-loop treats the human as a <strong>decision-maker</strong>. The system owes them context before the decision, owes them authority scoped to their role, and owes the organization a record afterward.</p>
<p>Those are different problems. One is solved by durable execution. The other is solved by policy, presentation, and provenance — three things that have nothing to do with how your workflow engine persists state, which is exactly why your workflow engine does not ship them.</p>
<p>Ratifia is the second layer. It runs on top of the engine you already have, it never executes your code, and it turns the pause you already know how to write into a decision you can defend.</p>
]]></content:encoded>
  </item>
</channel>
</rss>
