<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pablets</title>
    <description>The latest articles on DEV Community by Pablets (@pablets).</description>
    <link>https://dev.to/pablets</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F520227%2Fb32ef899-08a4-47f7-816b-83793a6c3aba.jpg</url>
      <title>DEV Community: Pablets</title>
      <link>https://dev.to/pablets</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pablets"/>
    <language>en</language>
    <item>
      <title>The Saga Pattern Under an AI Agent — Orchestration, Idempotency, and the Transactional Outbox</title>
      <dc:creator>Pablets</dc:creator>
      <pubDate>Tue, 28 Jul 2026 01:02:47 +0000</pubDate>
      <link>https://dev.to/pablets/the-saga-pattern-under-an-ai-agent-orchestration-idempotency-and-the-transactional-outbox-4mg8</link>
      <guid>https://dev.to/pablets/the-saga-pattern-under-an-ai-agent-orchestration-idempotency-and-the-transactional-outbox-4mg8</guid>
      <description>&lt;p&gt;&lt;em&gt;A loan journey is a saga: a sequence of commands across services, each individually transactional, none jointly so. Here's how an orchestrated saga stays correct when the network eats a response — retries that can't double-write, an outbox that can't lose an event, and a consumer that can't apply one twice.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;There is exactly one failure that separates toy distributed systems from production ones, and it fits in a sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The write committed, and the response was lost.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The orchestrator calls loans-api to disburse. loans-api commits. The connection resets before the 200 arrives. Did the disbursement happen? &lt;em&gt;The caller cannot know.&lt;/em&gt; Retry blindly and you disburse twice; never retry and every network blip strands a customer mid-journey.&lt;/p&gt;

&lt;p&gt;Under an AI agent this failure is worse, because the retry pressure is conversational — the customer says "try again," the LLM happily re-calls the tool, and now your dedup story is being exercised by a language model. This article walks the full machinery that makes that safe: an orchestration-based saga, idempotency keys done properly, a transactional outbox, and an idempotent projection — with every piece proven by chaos tests.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F40boij3w0r7z6b6uzj4y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F40boij3w0r7z6b6uzj4y.png" alt="Saga — two roads to the same truth" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reading the diagram in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The command&lt;/strong&gt; — The orchestrator drives the journey and never trusts the network. It sends one command carrying one idempotency key; the system of record commits the write, the event, and the receipt in a single transaction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Road 1 — the reply&lt;/strong&gt; — The synchronous 200 carries the result home to durable state. This is the happy path — and the one that can vanish: the write commits, then the connection resets before the reply arrives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Road 2 — the outbox&lt;/strong&gt; — Because the event was written in that same transaction, it cannot not exist. An outbox relay republishes it to the event bus, and the projection applies it — a second, independent road to the exact same durable state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convergence&lt;/strong&gt; — Both roads apply the same key, so duplicates collapse and lost replies replay. However many times the truth arrives, it lands exactly once. The reply can vanish; the outcome cannot.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;The &lt;a href="https://pablogodoy.com/blog/saga-idempotency-outbox/" rel="noopener noreferrer"&gt;original article&lt;/a&gt; has an interactive, scroll-driven version of this diagram that lights up each step as you read.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The whole architecture is that picture: the reply can vanish, so there are two independent roads from the system of record back to durable state — and both are safe to travel twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Orchestration, not choreography
&lt;/h2&gt;

&lt;p&gt;The loan journey — create → simulate → accept → disburse, with cancellation paths — is driven by a central command service in the orchestrator. Each step is a synchronous command against loans-api; a pure XState machine answers "is this step legal from here"; compensations (cancel the old acceptance before re-simulating; cancel simulation and acceptance when the journey is abandoned) are explicit code in the orchestrator, not emergent behavior of event subscribers.&lt;/p&gt;

&lt;p&gt;The choreography alternative — services reacting to each other's events — was rejected for a reason that matters double under an agent: &lt;strong&gt;you cannot explain an emergent saga to a customer.&lt;/strong&gt; The agent needs to answer "where are we, and what can happen next?" on every turn; a central Control Record with &lt;code&gt;currentState&lt;/code&gt; and HATEOAS-style &lt;code&gt;availableActions&lt;/code&gt; is that answer. One deliberately vestigial detail: &lt;code&gt;disbursed&lt;/code&gt; is terminal and has &lt;em&gt;no&lt;/em&gt; compensation — the cancel path for it exists only for symmetry and is never legal. Money that left is a new business process (collections), not an &lt;code&gt;undo&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency: the key is the contract
&lt;/h2&gt;

&lt;p&gt;Every logical command carries an idempotency key, and the &lt;em&gt;scheme&lt;/em&gt; of those keys encodes business intent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Keys are deterministic PER ENTITY so a re-simulation gets a FRESH key (the old constant&lt;/span&gt;
&lt;span class="c1"&gt;// `${id}:simulation` key made loans-api dedupe a re-sim and return the stale simulation):&lt;/span&gt;
&lt;span class="c1"&gt;//   - simulation:   `${id}:simulation:${nonce}` — a fresh nonce per simulate COMMAND.&lt;/span&gt;
&lt;span class="c1"&gt;//   - acceptance:   `${id}:acceptance:${simulationId}` — one acceptance per active simulation.&lt;/span&gt;
&lt;span class="c1"&gt;//   - disbursement: `${id}:disbursement` — exactly one disbursement per journey (terminal).&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;simulationIdempotencyKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controlRecordId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;controlRecordId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:simulation:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;newId&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read those three lines again — they are three different answers to "what does &lt;em&gt;the same command&lt;/em&gt; mean?" A retry of one simulate replays one nonce. A &lt;em&gt;new&lt;/em&gt; simulate mints a new nonce (the old constant-key version deduped re-simulations into stale quotes — a real bug). And a journey gets exactly one disbursement, ever.&lt;/p&gt;

&lt;p&gt;On the loans-api side, the envelope is brutally simple, and the simplicity is the point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runIdempotentWrite&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;uow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UnitOfWork&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;responseSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ZodType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;produce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LoansTx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;uow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIdempotentResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;responseSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;produce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveIdempotentResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The recorded response is saved &lt;strong&gt;in the same transaction&lt;/strong&gt; as the domain write (&lt;code&gt;idempotency_keys.key&lt;/code&gt; is the primary key — the unique constraint &lt;em&gt;is&lt;/em&gt; the correctness proof). A replayed key returns the recorded response. There is no window where the write exists but the receipt doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry: bounded, classified, and invisible to the caller
&lt;/h2&gt;

&lt;p&gt;With idempotency underneath, retrying becomes safe — but only &lt;em&gt;worth doing&lt;/em&gt; for the right failures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Transient = a fault a RETRY could plausibly clear: a 5xx UpstreamError or a bare&lt;/span&gt;
&lt;span class="c1"&gt;// transport failure (connection reset / timeout). Terminal = any 4xx DomainError&lt;/span&gt;
&lt;span class="c1"&gt;// (validation, not-found, illegal transition): retrying only burns patience.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schedule is five attempts, 1s base, ×2 growth — roughly 15 seconds of total backoff — with &lt;strong&gt;additive&lt;/strong&gt; jitter. Additive matters: full jitter can collapse a delay toward zero, and this system's chaos test &lt;em&gt;asserts&lt;/em&gt; &lt;code&gt;elapsed &amp;gt; 12s&lt;/code&gt; under a persistent fault, proving the schedule actually ran. Jitter that can shrink the floor would make the proof flaky; jitter that only adds decorrelates concurrent retriers without touching it.&lt;/p&gt;

&lt;p&gt;The composition is the part worth copying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createRetryingLoansClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LoansClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RetryPolicy&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;LoansClient&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;simulate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;simulate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="na"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="na"&gt;disburse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disburse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="cm"&gt;/* …cancel ops… */&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A plain decorator at the composition root. The command service depends on &lt;code&gt;LoansClient&lt;/code&gt; and does not know retries exist; the retry module knows nothing about loan semantics. One secret per module — when the schedule changes, one file changes; unit tests inject fake timers and fake randomness, production injects nothing.&lt;/p&gt;

&lt;p&gt;The precondition is stated where it can't be missed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PRECONDITION: `op` MUST be idempotent. A retry may replay a call whose earlier&lt;/span&gt;
&lt;span class="c1"&gt;// attempt already committed downstream but whose response was lost (the ambiguous&lt;/span&gt;
&lt;span class="c1"&gt;// request-landed-response-lost failure). Every loans-api call satisfies this.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The transactional outbox: the event cannot not exist
&lt;/h2&gt;

&lt;p&gt;Retries fix the caller's view. But the orchestrator's projection also needs the event — and publishing to a broker from inside a request handler is how events get lost. The outbox pattern closes that hole: loans-api writes the domain row &lt;em&gt;and&lt;/em&gt; the event row in one transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- the event's UUIDv7 = dedup/order key, end to end&lt;/span&gt;
  &lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;control_record_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- PENDING -&amp;gt; NOTIFIED&lt;/span&gt;
  &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;published_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- Partial index keeps the relay poll O(pending) despite an ever-growing NOTIFIED backlog:&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;idx_outbox_pending&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One subtle move: &lt;strong&gt;the event id is minted first and embedded in both the HTTP response and the outbox row.&lt;/strong&gt; The synchronous path and the async path carry the &lt;em&gt;same&lt;/em&gt; UUIDv7, so the projection can dedup across both with a single comparison.&lt;/p&gt;

&lt;p&gt;A DBOS-durable relay drains the table every second — claim with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;, publish to SNS inside a durable step, then mark &lt;code&gt;NOTIFIED&lt;/code&gt; guarded on &lt;code&gt;status = 'PENDING'&lt;/code&gt;. A crash between publish and mark? The row republishes next tick. That's &lt;strong&gt;at-least-once by design&lt;/strong&gt;, which is fine, because…&lt;/p&gt;

&lt;h2&gt;
  
  
  …the consumer is idempotent three ways
&lt;/h2&gt;

&lt;p&gt;SNS fans out to an SQS queue (three failed receives → DLQ); the orchestrator's consumer applies events through the &lt;em&gt;same&lt;/em&gt; applier the synchronous path uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. Dedup / ordering guard. eventIds are UUIDv7 (monotonic, string-sortable): a stale or&lt;/span&gt;
&lt;span class="c1"&gt;//    already-applied event is a no-op.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastAppliedEventId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastAppliedEventId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// 3. Machine-legality gate: an out-of-order backstop event … is skipped rather than&lt;/span&gt;
&lt;span class="c1"&gt;//    corrupting state.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;canFire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;machineEvent&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and the final write is a compare-and-swap on a monotonic &lt;code&gt;version&lt;/code&gt;. Not on &lt;code&gt;state&lt;/code&gt; — a re-simulation is a &lt;code&gt;simulated → simulated&lt;/code&gt; self-loop, and a state-keyed CAS matches stale rows. That was a live bug; the monotonic version fixed it.&lt;/p&gt;

&lt;p&gt;So the queue is a &lt;strong&gt;backstop, not a source of truth&lt;/strong&gt;: state is durable in loans-api regardless, the synchronous path projects immediately, and nearly every queued event dedups to a no-op. The honest cost, called out in the code: the backstop carries &lt;em&gt;every&lt;/em&gt; domain event, so it must drain full production volume just to stay at zero backlog — which is why it's competing SQS consumers rather than the original one-tick-per-second DBOS scheduled workflow that capped throughput at ~10 messages/s.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure, replayed end to end
&lt;/h2&gt;

&lt;p&gt;Here's the full sequence, actor by actor:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6jwma9atipcwdxb7ckx6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6jwma9atipcwdxb7ckx6.png" alt="Saga — the response-lost-after-commit failure, and how retry + idempotency + outbox converge" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Trace the scenario: simulate, attempt 1, commit, response lost.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Key &lt;code&gt;CR-1:simulation:&amp;lt;nonce&amp;gt;&lt;/code&gt; minted &lt;strong&gt;once&lt;/strong&gt;; attempt 1 commits row + outbox event &lt;code&gt;E&lt;/code&gt; + recorded response, then the 200 dies on the wire.&lt;/li&gt;
&lt;li&gt;The retry client classifies the transport error transient, sleeps ~1s, replays the &lt;strong&gt;same key&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;loans-api finds the key → returns the recorded response. No second simulation, no second event.&lt;/li&gt;
&lt;li&gt;The orchestrator projects &lt;code&gt;E&lt;/code&gt; synchronously: version CAS 0→1, state &lt;code&gt;simulated&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Meanwhile the relay publishes &lt;code&gt;E&lt;/code&gt; → SNS → SQS; the consumer sees &lt;code&gt;E ≤ lastAppliedEventId&lt;/code&gt; → no-op; message deleted.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Final state: exactly one simulation, one Control Record at version 1 — indistinguishable from the run where nothing failed. The e2e suite forces this exact path with Toxiproxy and asserts both the single simulation &lt;em&gt;and&lt;/em&gt; the elapsed-time floor proving the backoff ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Nothing here is exotic — saga orchestration, idempotency keys, an outbox, an idempotent consumer are all twenty-year-old ideas. What makes the combination production-grade is that &lt;strong&gt;each mechanism assumes the others will be exercised&lt;/strong&gt;: the retry assumes responses get lost, the keys assume retries happen, the outbox assumes the process dies mid-publish, the consumer assumes the queue delivers twice and out of order. And the chaos tests assume nothing — they inject the faults and measure.&lt;/p&gt;

&lt;p&gt;If you keep one sentence: &lt;strong&gt;make every command mean something exact (the key scheme), make every write self-announcing (the outbox), and make every apply harmless to repeat (the idempotent projection) — then retries stop being scary and become boring.&lt;/strong&gt; Boring is the goal.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>distributedsystems</category>
      <category>architecture</category>
      <category>microservices</category>
    </item>
    <item>
      <title>PII That Can Actually Be Forgotten — Blind Indexes and Crypto-Shredding in an AI Agent Stack</title>
      <dc:creator>Pablets</dc:creator>
      <pubDate>Tue, 28 Jul 2026 01:02:14 +0000</pubDate>
      <link>https://dev.to/pablets/pii-that-can-actually-be-forgotten-blind-indexes-and-crypto-shredding-in-an-ai-agent-stack-5b4g</link>
      <guid>https://dev.to/pablets/pii-that-can-actually-be-forgotten-blind-indexes-and-crypto-shredding-in-an-ai-agent-stack-5b4g</guid>
      <description>&lt;p&gt;&lt;em&gt;A &lt;code&gt;DELETE&lt;/code&gt; doesn't delete. Postgres keeps your customer's name in heap pages until VACUUM, in every WAL segment, in every backup, in every replica. Here's an architecture where "right to erasure" is a one-key operation — and where the AI layer never gets PII to leak in the first place.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;LLM systems are PII multipliers. A name typed into a chat can end up in graph state, in a checkpointer table, in logs, in traces, in a vector store — and then the customer invokes their right to erasure, and you discover that "delete the user" now means a forensic sweep across seven data stores and a backup archive you can't rewrite.&lt;/p&gt;

&lt;p&gt;This stack takes the opposite bet, made of two architectural decisions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;1. Only one service ever holds PII.&lt;/strong&gt; Everything else carries an opaque reference.&lt;br&gt;
&lt;strong&gt;2. That one service stores nothing it can read without a key someone else custodies&lt;/strong&gt; — so erasure is the destruction of one key.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The service is &lt;code&gt;party-api&lt;/code&gt;, modeled on the BIAN &lt;em&gt;Party Reference Data Directory&lt;/em&gt; service domain. The key custodian is OpenBao Transit ("encryption as a service" — key material never leaves it). And the AI layer gets its own, independent control: scrub before anything is stored or embedded.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgwosx9zkrsllfdrg0z2a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgwosx9zkrsllfdrg0z2a.png" alt="PII — one vault, one key, real forgetting" width="800" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reading the diagram in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Everyone else&lt;/strong&gt; — The orchestrator, loans-api, the agent, the UI — every other service holds only an opaque reference. You cannot leak what you never stored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The vault&lt;/strong&gt; — party-api is the one and only place PII exists — and even there it lives as ciphertext plus one-way blind indexes. It cannot read its own rows in the clear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The keys&lt;/strong&gt; — OpenBao Transit custodies one key per person; key material never leaves it. The vault holds the locked box; someone else holds every key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right to erasure&lt;/strong&gt; — So "delete this customer" stops being a forensic sweep across seven stores. Erasure = destroy one key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every copy, at once&lt;/strong&gt; — And that single deletion turns every copy unreadable at the same instant — live rows, WAL segments, base backups, replicas. Proving erasure collapses to proving one key-deletion event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The whole bet&lt;/strong&gt; — You can't leak what you never stored — and you can't fail to delete what a single key unlocks.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;The &lt;a href="https://pablogodoy.com/blog/pii-crypto-shredding/" rel="noopener noreferrer"&gt;original article&lt;/a&gt; has an interactive, scroll-driven version of this diagram that lights up each step as you read.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One vault everyone else references by an opaque id; one key-keeper the vault cannot live without; and erasure reduced to destroying a single key — which turns every copy, in every backup, unreadable at the same instant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a row DELETE can't prove erasure
&lt;/h2&gt;

&lt;p&gt;The schema's header comment states the threat model better than most compliance decks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Row DELETE alone cannot PROVE erasure: the plaintext lingers in Postgres pages until VACUUM, and&lt;/span&gt;
&lt;span class="c1"&gt;// in every WAL segment and base backup until those age out. So no PII column stores plaintext.&lt;/span&gt;
&lt;span class="c1"&gt;// Each PII-bearing row stores:&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;//   * `*_ciphertext`  — the row's PII as one JSON blob, encrypted under THAT PARTY's key held in&lt;/span&gt;
&lt;span class="c1"&gt;//                       OpenBao Transit. Erase destroys the key, which makes every copy of the&lt;/span&gt;
&lt;span class="c1"&gt;//                       ciphertext (live, WAL, backup, replica) undecryptable at once. Proving&lt;/span&gt;
&lt;span class="c1"&gt;//                       erasure collapses to proving one key-deletion event.&lt;/span&gt;
&lt;span class="c1"&gt;//   * `*_bidx`        — a blind index: keyed HMAC of the normalized searchable value, under a&lt;/span&gt;
&lt;span class="c1"&gt;//                       SERVICE-WIDE key that never leaves OpenBao.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole design in two bullet points: &lt;strong&gt;per-party keys make erasure granular; keyed hashes make search possible without plaintext.&lt;/strong&gt; The database contains ciphertext and one-way HMACs. A dump of party-api's memory, environment, or database yields no key — Transit encrypts and decrypts on its side of the wire.&lt;/p&gt;

&lt;p&gt;The three flows that make it work — register, lookup, erase — in full detail:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwaciik0n9ntcdwwb8us1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwaciik0n9ntcdwwb8us1.png" alt="PII — ciphertext at rest, blind-index lookup, and crypto-shred erasure" width="800" height="613"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Blind indexes: equality search over data you refuse to store
&lt;/h2&gt;

&lt;p&gt;"Find the party for &lt;code&gt;alice@example.com&lt;/code&gt;" has to work — it's how a verified login binds to a bank party, and how an auditor resolves a human to a reference. The blind index is a keyed HMAC-SHA256 of the normalized value (&lt;code&gt;trim().toLowerCase()&lt;/code&gt;, applied identically at write and at search), stored in a &lt;code&gt;*_bidx&lt;/code&gt; column with a covering B-tree index. Search hashes the query term via Transit and does an equality match.&lt;/p&gt;

&lt;p&gt;Two deliberate choices hide in that design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The HMAC key is service-wide, not per-party&lt;/strong&gt; — the code says why: "a per-party key would make 'find this email across all parties' impossible." You don't know which party you're looking for; that's the point of the lookup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's an HMAC, not deterministic encryption&lt;/strong&gt; — one-way. Even if the column leaks, it reveals nothing and reverses to nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the residual risk is &lt;em&gt;written down instead of wished away&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Service-wide HMAC key for blind indexes. NEVER destroyed by erasure: erasing a party deletes its&lt;/span&gt;
&lt;span class="c1"&gt;// blind-index ROWS instead. Residual risk, accepted and documented: a blind-index hash surviving in&lt;/span&gt;
&lt;span class="c1"&gt;// an aged DB backup is pseudonymous (keyed hash, key held only by OpenBao) but not erased until&lt;/span&gt;
&lt;span class="c1"&gt;// that backup expires — backup retention is the governing control there.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Erasure: key first, rows second — because of how each half fails
&lt;/h2&gt;

&lt;p&gt;The erasure endpoint is the one irreversible route in party-api, and it executes in a precise order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;erase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;partyReference&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;requireLive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;partyReference&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroyKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;partyReference&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// 1. crypto-shred&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;erasedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;          &lt;span class="c1"&gt;// 2. tombstone&lt;/span&gt;
    &lt;span class="c1"&gt;// Whole rows, not nulled columns — a column added later cannot be forgotten by a DELETE.&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;PII_TABLES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;party_reference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;partyReference&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;del&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TABLES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ENTRY&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;party_reference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;partyReference&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Erased&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;erased_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;erasedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="na"&gt;person_details_ciphertext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ordering is a failure-mode argument, not a style choice: destroy the key first, and a crash before the row-delete leaves a state that a &lt;em&gt;retry&lt;/em&gt; completes. Delete rows first, and a crash before key destruction could strand a permanently half-erased party — whose retry the &lt;code&gt;requireLive&lt;/code&gt; guard then refuses. When an operation is irreversible, you design it around where it can be interrupted.&lt;/p&gt;

&lt;p&gt;What remains is a &lt;strong&gt;tombstone&lt;/strong&gt;: the entry survives with &lt;code&gt;status: 'Erased'&lt;/code&gt;, an &lt;code&gt;erasedAt&lt;/code&gt; date, and no identity whatsoever. That's deliberate — every other service still holds the &lt;code&gt;partyReference&lt;/code&gt;, and a dangling reference must be &lt;em&gt;explainable&lt;/em&gt; ("erased on 2026-07-17"), not indistinguishable from data loss. Audit history everywhere keeps every structural fact — party, amount, state, time — and permanently loses the ability to say &lt;em&gt;who&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Downstream, the orchestrator's party gate closes the loop: a new journey for an erased party is refused with a 422, fail-closed, while historical journeys keep pointing at the reference by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who may push the button
&lt;/h2&gt;

&lt;p&gt;Erasure is authenticated with the same rigor as the data it destroys. The route demands a bearer token, verified against Keycloak (RS256, pinned issuer, &lt;code&gt;email_verified&lt;/code&gt;, authorized-party check), and then applies a two-ground rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;realmRoles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;complianceRole&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                   &lt;span class="c1"&gt;// ground 1: compliance-officer&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;own&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;referencesForEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;own&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetPartyReference&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                   &lt;span class="c1"&gt;// ground 2: it's your own party&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ForbiddenError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This identity may erase only its own party; erasing another party requires a compliance role.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No token → 401. Unverifiable token → 401 (the &lt;em&gt;reason&lt;/em&gt; stays in the logs; the wire message is deliberately coarse). Valid token, wrong person → 403. The e2e suite curls all three outcomes and then asserts the tombstone directly in the database: entry present, status &lt;code&gt;Erased&lt;/code&gt;, zero PII rows.&lt;/p&gt;

&lt;p&gt;Note the elegant self-reference: &lt;strong&gt;ground 2 uses the blind index itself&lt;/strong&gt; — the verified email is hashed and matched to prove "this is your own party." The privacy mechanism doubles as the authorization mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI plane: don't shred what you never stored
&lt;/h2&gt;

&lt;p&gt;Crypto-shredding protects the system of record. The conversational plane — LLM prompts, LangGraph checkpoints, the self-improving RAG — is a &lt;em&gt;different&lt;/em&gt; data plane with its own control, applied at a single ingestion seam:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_redact_and_log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Scrub PII from inbound USER text at the single ingestion seam — before it becomes
    a message in graph state (which the checkpointer persists) or reaches the LLM.
    Logs the categories redacted, never the values.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The redactor is pure regex + Luhn: SSNs, payment cards (Luhn-validated so a $20,000 loan amount is never mistaken for a card number), keyword-anchored account numbers, passwords and PINs. Before anything reaches the pgvector knowledge base, a second pass generalizes the text — every numeric collapses to &lt;code&gt;&amp;lt;num&amp;gt;&lt;/code&gt; — so the KB stores &lt;em&gt;problems and solutions&lt;/em&gt;, never people or amounts. And Bedrock Guardrails add a model-boundary backstop, anonymizing emails and phones and blocking cards and SSNs in both directions.&lt;/p&gt;

&lt;p&gt;The two planes compose into a tidy claim: &lt;strong&gt;the agent never sends raw identifiers to a model or a vector store, and the system of record never stores plaintext identity at all.&lt;/strong&gt; Erasure therefore touches exactly one service, and proving it means producing one key-deletion event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Most "GDPR-ready" architectures are a promise to go looking for data when asked. This one is a refusal to spread it: one PII service, opaque references everywhere else, ciphertext under per-party keys, search via one-way hashes, erasure as key destruction plus a tombstone, and an AI layer that scrubs before it stores.&lt;/p&gt;

&lt;p&gt;The takeaway generalizes past banking: &lt;strong&gt;decide where PII is allowed to exist before you build, make that place unable to read its own database without external keys — and then the right to be forgotten stops being an archaeology project and becomes an API call.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>privacy</category>
      <category>postgres</category>
      <category>ai</category>
    </item>
    <item>
      <title>Guardrails for AI Agents — Five Deterministic Rings Between an LLM and Real Money</title>
      <dc:creator>Pablets</dc:creator>
      <pubDate>Tue, 28 Jul 2026 01:01:41 +0000</pubDate>
      <link>https://dev.to/pablets/guardrails-for-ai-agents-five-deterministic-rings-between-an-llm-and-real-money-2go6</link>
      <guid>https://dev.to/pablets/guardrails-for-ai-agents-five-deterministic-rings-between-an-llm-and-real-money-2go6</guid>
      <description>&lt;p&gt;&lt;em&gt;Prompts are suggestions. Guardrails are architecture. How a loan-acquisition agent layers a deterministic flow, an MCP contract, ownership gates, a pure state machine, and idempotent writes so that the LLM can be wrong safely.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Every "agent gone rogue" postmortem has the same shape: the LLM did something surprising, and there was nothing between the surprise and the consequence. The prompt said &lt;em&gt;"only disburse after the customer accepts"&lt;/em&gt; — and the prompt lost.&lt;/p&gt;

&lt;p&gt;The fix is not a better prompt. It's accepting a design rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A guardrail is only a guardrail if the LLM cannot route around it.&lt;/strong&gt; Anything the model can skip by phrasing a tool call differently is a convention, not a control.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article walks the guardrail stack of a production-grade conversational loan agent — LangGraph agent, MCP tool layer, Node orchestrator, banking core — from the outside in. The whole system runs offline with &lt;code&gt;docker compose up&lt;/code&gt;, and the guardrails are proven by end-to-end tests that &lt;em&gt;attack&lt;/em&gt; them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmttml551gedy9ygrhs3u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmttml551gedy9ygrhs3u.png" alt="Guardrails as a pipeline — five yes/no gates between a proposal and money" width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reading the diagram in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The proposal&lt;/strong&gt; — The LLM proposes a command — «disburse the loan». From here it has no more power: the proposal must survive five gates, each owned by a different module with a different reason to change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate 1 — conversation&lt;/strong&gt; — Are the slots valid and the terms accepted? If not, the answer is "keep talking" — the flow stays in intake and never advances on a terse follow-up the router misread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate 2 — identity&lt;/strong&gt; — Is this a proven caller acting on their own party? No verifiable token, no journey: the gate returns 401 and nothing is written.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate 3 — ownership &amp;amp; rules&lt;/strong&gt; — Is it your loan, is the party still alive, does policy pass? A "no" here is a 403, 404, or 422 — a machine-readable refusal, not an exception.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate 4 — state machine&lt;/strong&gt; — Is this a legal step right now? A pure XState machine answers canFire(state, DISBURSE). Out of order gets a 409 — the model cannot talk its way past the lifecycle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate 5 — idempotency&lt;/strong&gt; — Is this the first time this exact command has run? A duplicate replays the first answer instead of moving money twice. Ask twice, pay once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Money moves&lt;/strong&gt; — Five yeses, and only then does the money move — exactly once. One straight road to the money, with five places it can be stopped and only one where it can complete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every "no" is data&lt;/strong&gt; — And every refusal returns to the agent as a code plus a reason it explains in the chat — never an exception, never a write. The LLM can be wrong, safely.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;The &lt;a href="https://pablogodoy.com/blog/guardrails-for-agents/" rel="noopener noreferrer"&gt;original article&lt;/a&gt; has an interactive, scroll-driven version of this diagram that lights up each step as you read.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One proposal, five yes/no gates, one irreversible action. A "yes" moves the command one step closer to the money; every "no" comes back as data — a code plus a reason the agent explains in the chat, never an exception, never a write. Each gate is owned by a different module with a different reason to change. Let's walk them outside-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ring 1 — the agent itself is deterministic where it matters
&lt;/h2&gt;

&lt;p&gt;The LangGraph agent does &lt;strong&gt;not&lt;/strong&gt; run a free ReAct loop over the loan tools. The loan lifecycle is a deterministic, turn-resumed intake node; the LLM is quarantined to two narrow jobs — proposing an intent classification, and phrasing help text. Neither can advance the flow.&lt;/p&gt;

&lt;p&gt;The most instructive piece is the &lt;em&gt;sticky intake&lt;/em&gt; routing guardrail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Route based on state + the classified intent from the router node.

- intake_active (sticky): once we&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;re collecting a loan, STAY on the intake path
  regardless of how the router classifies a terse follow-up answer (a bare &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;24&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;).
  This is the flow&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;State Enforcement — the orchestrator overrides the LLM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;intake_active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Routing to intake (intake_active sticky guardrail)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;intake&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, a customer answering "24" to &lt;em&gt;"how many months?"&lt;/em&gt; gets misclassified as small talk and the flow derails. With it, the router's opinion is advisory once a journey is underway.&lt;/p&gt;

&lt;p&gt;The intake node also &lt;strong&gt;pre-validates slots against real policy before any tool call&lt;/strong&gt; — and here's the decomposition detail worth stealing: the agent and the orchestrator call &lt;em&gt;the same shared policy function&lt;/em&gt;, so the conversational check and the authoritative check can never drift apart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pure policy check, shared so the agent and the orchestrator apply IDENTICAL rules. Returns the&lt;/span&gt;
&lt;span class="c1"&gt;// list of human-readable violations (empty = admissible), so the orchestrator can put them in a&lt;/span&gt;
&lt;span class="c1"&gt;// BUSINESS_RULE_VIOLATION detail and the agent can echo them conversationally.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkAgainstRules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LoanRules&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amountMinor&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minAmountMinor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amountMinor&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAmountMinor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowedInstallments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;termMonths&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, the agent shows &lt;strong&gt;personalized disclosures and an explicit "I accept / Cancel" gate&lt;/strong&gt; before acceptance — deterministic template renders of the customer's exact numbers, deliberately &lt;em&gt;not&lt;/em&gt; retrieval. A disclosure that an LLM paraphrases is a disclosure a lawyer can't sign off on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ring 2 — the MCP layer: a contract designed for an unreliable caller
&lt;/h2&gt;

&lt;p&gt;The FastMCP server (&lt;code&gt;loan-mcp&lt;/code&gt;) is where agent-facing design lives: docstrings carry ordering hints ("Call this FIRST", "Legal only after accept_terms"), and — crucially — &lt;strong&gt;errors are data, not exceptions&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Tools pass through the orchestrator&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s value object on success. On failure we
return {&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &amp;lt;RFC 9457 problem&amp;gt;} instead of raising so the agent receives
a usable message — the whole problem (type/title/status/detail/code/instance/
traceId) rides along, and a 409 STATE_TRANSITION reads as an out-of-order call
the guardrail refused, not an opaque exception.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;OrchestratorError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_state_transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[loan-mcp] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;action_label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; refused by guardrail: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;as_dict&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A refusal that reaches the model as structured data becomes a &lt;em&gt;conversational&lt;/em&gt; event: "we can't disburse yet — you haven't accepted the terms." A refusal that dies as an exception becomes a stalled chat.&lt;/p&gt;

&lt;p&gt;This layer is also the &lt;strong&gt;identity seam&lt;/strong&gt;: with &lt;code&gt;LOAN_MCP_AUTH_ENFORCED=true&lt;/code&gt;, no verifiable token means no journey — and a valid token binds the journey to the &lt;em&gt;caller's own&lt;/em&gt; party, resolved from the verified email. Fail closed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
* No token + enforcement ON  -&amp;gt; refused (401): the composed stack fails closed, so a
  journey can never open against an unproven identity.
* A token -&amp;gt; verified against Keycloak&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s JWKS (signature/issuer/expiry/email_verified),
  then bound to the CALLER&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;S OWN party.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Ring 3 — the orchestrator's gates: ownership, party, rules
&lt;/h2&gt;

&lt;p&gt;Every command that reaches the orchestrator's REST edge passes three gates &lt;em&gt;in order&lt;/em&gt;, before any state machine question is even asked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ownership&lt;/strong&gt; — the forwarded token is &lt;em&gt;independently re-verified&lt;/em&gt; (a second, deliberately separate implementation from loan-mcp's), and the token-derived party must match the &lt;code&gt;customerRef&lt;/code&gt; being operated on. Errors are coarse on purpose: specifics go to logs, an attacker learns nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Party gate&lt;/strong&gt; — the party must exist and be live. An erased (crypto-shredded) party yields a 422; unknown yields 404. If party-api is unreachable, the gate returns &lt;strong&gt;503 — fail closed&lt;/strong&gt;, never "assume it's fine."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules gate&lt;/strong&gt; — the authoritative policy check (same shared function as Ring 1), returning 422 with the human-readable violations.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trade-off is explicit and named: &lt;code&gt;ORCH_AUTH_MODE&lt;/code&gt; runs &lt;code&gt;off&lt;/code&gt; / &lt;code&gt;verify-when-present&lt;/code&gt; / &lt;code&gt;require&lt;/code&gt;. The compose stack uses &lt;code&gt;verify-when-present&lt;/code&gt; so token-less e2e and internal tools keep working; a real deployment runs &lt;code&gt;require&lt;/code&gt;. The point is that the posture is a configuration &lt;em&gt;decision&lt;/em&gt;, not an accident of middleware.&lt;/p&gt;

&lt;p&gt;Note the asymmetry with Ring 1: &lt;strong&gt;identity and policy dependencies fail closed; conversational helpers fail open.&lt;/strong&gt; The agent's draft check degrades gracefully because "the orchestrator still enforces authoritatively on create" — but the orchestrator's gates never degrade, because there is nothing behind them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ring 4 — the pure state machine: the only authority on "may this happen"
&lt;/h2&gt;

&lt;p&gt;The heart of the system is an XState machine that is pure to the point of asceticism:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The loan-acquisition lifecycle as a PURE, deterministic XState machine. It is the&lt;/span&gt;
&lt;span class="c1"&gt;// guardrail: given the current durable state and a candidate event (proposed by the&lt;/span&gt;
&lt;span class="c1"&gt;// non-deterministic LLM, or projected from a loans-api domain event), it answers "is this&lt;/span&gt;
&lt;span class="c1"&gt;// transition legal?" and "what is the next state?". It performs NO side effects — the&lt;/span&gt;
&lt;span class="c1"&gt;// synchronous command handlers and the SQS backstop execute the actual step and then&lt;/span&gt;
&lt;span class="c1"&gt;// project this machine only on success. Coupling points one way: everything depends on the&lt;/span&gt;
&lt;span class="c1"&gt;// machine; the machine depends on nothing. SECRET: the legal state/event transition graph.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The disburse handler shows what "layered refusal" looks like in practice — each check has its own status code, so the caller learns exactly &lt;em&gt;which&lt;/em&gt; ring refused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;canFire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DISBURSE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;STATE_TRANSITION&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`cannot disburse from state &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentState&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BUSINESS_RULE_VIOLATION&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no simulation snapshot to disburse against&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isSnapshotExpired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;nowIso&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BUSINESS_RULE_VIOLATION&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;the simulation snapshot has expired; re-simulate before disbursing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the machine is pure and depends on nothing, the same legality function serves &lt;strong&gt;three&lt;/strong&gt; callers: the synchronous command handlers, the SQS backstop's projection applier (so an out-of-order replayed event is &lt;em&gt;skipped&lt;/em&gt;, never applied), and a non-mutating &lt;code&gt;/signal&lt;/code&gt; check endpoint that lets you measure classifier→guardrail precision without touching state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ring 5 — loans-api: even a legal command must be safe to repeat
&lt;/h2&gt;

&lt;p&gt;The innermost ring assumes everything above it failed. The system of record wraps every write in an idempotency envelope (same transaction as the domain change), and adds a relational belt-and-suspenders check — the acceptance being disbursed must actually belong to the active simulation. A retried or duplicated command replays the recorded response; it never writes twice.&lt;/p&gt;

&lt;p&gt;Underneath all five rings sits durability: the Control Record is persisted via DBOS on Postgres, advanced only through a &lt;strong&gt;version compare-and-swap plus per-event dedup&lt;/strong&gt;. The version-CAS detail is a war story in itself: the CAS used to key on &lt;code&gt;state&lt;/code&gt;, and a re-simulation is a self-loop — &lt;code&gt;simulated → simulated&lt;/code&gt; — so the CAS silently matched stale rows. Monotonic &lt;code&gt;version&lt;/code&gt; fixed it. If your state machine has self-loops, a state-keyed CAS is a bug you haven't met yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The attack, end to end
&lt;/h2&gt;

&lt;p&gt;Here's the full detail of the five rings — who checks what, in which order, and what the happy and rejected paths look like:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbttcgwc964zwcbzq1x5s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbttcgwc964zwcbzq1x5s.png" alt="Guardrails — five rings, outside-in, between the LLM and irreversible money movement" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What happens when something — a creative LLM, a curious tester, a direct REST call — tries &lt;code&gt;disburse&lt;/code&gt; while the journey is still &lt;code&gt;simulated&lt;/code&gt;?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;canFire('simulated', DISBURSE)&lt;/code&gt; → &lt;code&gt;false&lt;/code&gt;. &lt;strong&gt;Nothing mutates; loans-api is never called.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The orchestrator returns RFC 9457 &lt;code&gt;application/problem+json&lt;/code&gt;, status 409, code &lt;code&gt;STATE_TRANSITION&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;loan-mcp passes it through as a value: &lt;code&gt;{"error": {code, status, title, …}}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The agent reads the code and explains conversationally — "let's review the terms first."&lt;/li&gt;
&lt;li&gt;Durable state remains &lt;code&gt;simulated&lt;/code&gt;; &lt;code&gt;acceptanceId&lt;/code&gt; and &lt;code&gt;disbursementId&lt;/code&gt; remain null.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every step of that path is asserted by e2e tests, over both REST and MCP — including that the MCP tool call &lt;em&gt;resolves&lt;/em&gt; (rather than throws) and that the durable record is untouched. A guardrail without a test that attacks it is a hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  The soft outer filter, and why it's allowed to be soft
&lt;/h2&gt;

&lt;p&gt;Outside the five rings sits one more layer: a Bedrock Guardrails config for model-level content filtering. Its deny-topics are domain-specific — &lt;em&gt;unlicensed financial advice&lt;/em&gt; ("should I put this loan into crypto?") and &lt;em&gt;lending fraud and evasion&lt;/em&gt; ("how can I inflate my income to qualify?") — alongside harmful-content filters and PII masking. The customer-facing refusal copy lives in a single module-level constant, so the guardrail definition sent to Bedrock and the local fallback message can never drift apart.&lt;/p&gt;

&lt;p&gt;Unlike everything inside it, this filter fails &lt;em&gt;open&lt;/em&gt; on error — acceptable only because it is not, and must never be, the authoritative control. One operational note: guardrails are created-or-fetched &lt;em&gt;by name&lt;/em&gt;, so a policy change in the repo reaches an existing environment only when the guardrail is recreated or a new version is published.&lt;/p&gt;

&lt;p&gt;That's the real lesson of the ring model: you can afford a soft, imperfect outer filter precisely because the inner rings are hard. &lt;strong&gt;Put your rigor where the irreversibility is.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The five rings are not redundancy for redundancy's sake — each one hides a different secret and refuses a different class of mistake:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ring&lt;/th&gt;
&lt;th&gt;Refuses&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agent flow&lt;/td&gt;
&lt;td&gt;conversational derailment&lt;/td&gt;
&lt;td&gt;sticky routing, shared pre-validation, explicit confirm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP contract&lt;/td&gt;
&lt;td&gt;unusable failures, unproven identity&lt;/td&gt;
&lt;td&gt;errors-as-data, fail-closed party binding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orchestrator gates&lt;/td&gt;
&lt;td&gt;wrong caller, dead party, bad policy&lt;/td&gt;
&lt;td&gt;ownership + party + rules, fail closed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XState machine&lt;/td&gt;
&lt;td&gt;illegal ordering&lt;/td&gt;
&lt;td&gt;pure &lt;code&gt;canFire&lt;/code&gt;, 409 as data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;loans-api&lt;/td&gt;
&lt;td&gt;duplicated execution&lt;/td&gt;
&lt;td&gt;idempotency envelope + relational check&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The LLM sits outside all of them, free to be brilliant and wrong. &lt;strong&gt;You don't make an agent safe by prompting it better — you make it safe by building rings it cannot route around.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>architecture</category>
      <category>llm</category>
    </item>
    <item>
      <title>AGUI with Chainlit — Building a Production Chat Frontend for an AI Agent</title>
      <dc:creator>Pablets</dc:creator>
      <pubDate>Tue, 28 Jul 2026 00:58:22 +0000</pubDate>
      <link>https://dev.to/pablets/agui-with-chainlit-building-a-production-chat-frontend-for-an-ai-agent-4hkp</link>
      <guid>https://dev.to/pablets/agui-with-chainlit-building-a-production-chat-frontend-for-an-ai-agent-4hkp</guid>
      <description>&lt;p&gt;&lt;em&gt;The agent GUI is where every backend shortcut becomes visible: tokens expire mid-chat, resumed threads render blank, streamed answers appear twice. Here's a Chainlit frontend that survives production — real OIDC with a two-hostname split, mid-chat token refresh, persisted threads with interactive Plotly charts, and the fixes for the failure modes you only meet after you persist.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Agent demos treat the UI as an afterthought — a chat box taped over an API. Then real users arrive, and the UI turns out to be the layer with the most &lt;em&gt;state&lt;/em&gt;: login sessions, websocket connections, streamed partial messages, thread history, rich widgets that must survive a page reload three weeks later.&lt;/p&gt;

&lt;p&gt;This article walks the Chainlit frontend of a conversational loan agent. The design constraint that shapes everything: &lt;strong&gt;the UI is domain-agnostic.&lt;/strong&gt; It POSTs &lt;code&gt;{prompt, customer_name, conversation_id}&lt;/code&gt; to the agent's &lt;code&gt;/invocations&lt;/code&gt; endpoint and renders the streamed response — the same code path serves a local container and a deployed AWS Bedrock AgentCore runtime, switched by one env var. All loan logic lives behind that endpoint; the UI's job is transport, identity, rendering, and persistence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fahnjql4u5uuarndcrrhb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fahnjql4u5uuarndcrrhb.png" alt="AGUI — a chat window with four jobs" width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reading the diagram in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One dumb pipe&lt;/strong&gt; — The UI is domain-agnostic: it POSTs {prompt, customer_name, conversation_id} to the agent’s /invocations endpoint and renders the streamed events. All loan logic lives behind that pipe — the same code serves a local container and a deployed AgentCore runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity&lt;/strong&gt; — A real authorization-code login against Keycloak — no demo password gate — with tokens that renew mid-chat before they expire.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming&lt;/strong&gt; — Answers render as they are thought: streamed SSE chunks, not a spinner and then a wall of text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory&lt;/strong&gt; — Threads resume weeks later — charts and all. Persisted sessions, steps, and elements that survive a page reload three weeks on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rich answers&lt;/strong&gt; — Interactive Plotly charts, choices, and cards — the amortization schedule is a widget, not a paragraph.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The one rule&lt;/strong&gt; — The UI owns the experience, never the domain. Everything hard about it is state: sessions, streams, storage, resume.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;The &lt;a href="https://pablogodoy.com/blog/agui-with-chainlit/" rel="noopener noreferrer"&gt;original article&lt;/a&gt; has an interactive, scroll-driven version of this diagram that lights up each step as you read.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Four jobs — identity, streaming, memory, rich answers — around one conversation, over one dumb pipe. The rest of this article is those four jobs, in the order the failure modes taught them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Login: one identity provider, two hostnames
&lt;/h2&gt;

&lt;p&gt;The UI runs a real authorization-code flow against Keycloak — no demo password gate. The classic compose-stack trap is the &lt;strong&gt;two-hostname split&lt;/strong&gt;: the browser reaches Keycloak at &lt;code&gt;https://localhost:8543&lt;/code&gt;, while the UI's backend must exchange the code in-network at &lt;code&gt;https://keycloak:8443&lt;/code&gt;. Chainlit's stock Keycloak provider derives every URL from a single base — it literally cannot express this. So the app registers a small subclass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# base_url is what the PARENT uses for the token + userinfo exchanges (in-network).
&lt;/span&gt;    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OAUTH_KEYCLOAK_INTERNAL_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# authorize_url is what the BROWSER is redirected to (host-published).
&lt;/span&gt;    &lt;span class="n"&gt;public_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OAUTH_KEYCLOAK_PUBLIC_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authorize_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;public_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/realms/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;realm&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/protocol/openid-connect/auth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more login details earn their place through failure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prompt=login&lt;/code&gt; on every authorize.&lt;/strong&gt; Chainlit's &lt;code&gt;/logout&lt;/code&gt; clears only its own cookie; Keycloak's SSO cookie survives, so "log out" followed by "Continue with Keycloak" would silently wave the user back in — wrong for a banking flow. Forcing a fresh credential prompt also kills an instant-SSO redirect that raced Chainlit's &lt;code&gt;oauth_state&lt;/code&gt; cookie check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The UI serves HTTPS&lt;/strong&gt; for the same reason: when it ran plain HTTP next to an HTTPS Keycloak, the &lt;code&gt;oauth_state&lt;/code&gt; cookie went cross-scheme under schemeful same-site rules and login failed &lt;em&gt;intermittently&lt;/em&gt; — the worst kind of bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Token refresh: a chat outlives its access token
&lt;/h2&gt;

&lt;p&gt;A loan conversation takes longer than a 30-minute access token. Rather than failing closed mid-chat (which this stack's downstream &lt;em&gt;will&lt;/em&gt; do — every hop verifies the forwarded token), the UI renews in place before each turn when the token is expired or within a 60-second skew window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;new_access&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token_response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;new_access&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nf"&gt;_ReloginRequired&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;new_access&lt;/span&gt;
&lt;span class="c1"&gt;# Keycloak issues a rotated refresh token on each grant — keep the newest so the next
# renewal uses a valid one (matters when the realm revokes reused refresh tokens).
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;token_response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token_response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the refresh fails — expired, revoked, network — the turn handler removes the empty placeholder bubble and shows a transient "please sign in again" toast instead of persisting a dead error line into the thread or forwarding a token that would 401 three services later. Failure UX is part of the auth design.&lt;/p&gt;

&lt;p&gt;(Trade-off, named in the code: access and refresh tokens live in the Chainlit user's metadata JSONB — a token at rest, acceptable for a demo, replaced by a server-side session store in production.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The turn: one SSE stream, many event types
&lt;/h2&gt;

&lt;p&gt;The full picture of a turn — streaming, persistence, and the resume path we'll meet below:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbjwrs3x0ln4jiuc0h87c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbjwrs3x0ln4jiuc0h87c.png" alt="AGUI — Chainlit: streamed turns, rich elements, persistence and resume" width="800" height="647"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each turn POSTs to &lt;code&gt;/invocations&lt;/code&gt; and reads Server-Sent Events. The dispatch loop switches on a small event vocabulary — &lt;code&gt;chunk&lt;/code&gt;, &lt;code&gt;choices&lt;/code&gt;, &lt;code&gt;simulation_snapshot&lt;/code&gt;, &lt;code&gt;disbursement_details&lt;/code&gt;, &lt;code&gt;blocked&lt;/code&gt;, &lt;code&gt;error&lt;/code&gt; — and maps each to a UI affordance: streamed tokens into the live bubble, radio pickers via &lt;code&gt;AskActionMessage&lt;/code&gt;, markdown cards, and the crown jewel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Here&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s the full breakdown. This chart is **exactly what you&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ll pay** …&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;elements&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Plotly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amortization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;figure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;figure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An interactive amortization chart — principal/interest split per month, balance line, a range slider — as a first-class chat element. This is the argument for a chat &lt;em&gt;framework&lt;/em&gt; over a chat &lt;em&gt;box&lt;/em&gt;: &lt;code&gt;cl.Plotly&lt;/code&gt;, action pickers, and thread history come free, and the app code stays domain logic.&lt;/p&gt;

&lt;p&gt;One streaming war story lives on the agent side but surfaces here: the flow node composes internal LLM helper calls and then appends the final message itself, so every helper answer streamed &lt;strong&gt;twice&lt;/strong&gt; — once as tokens, once as the appended message. The fix is a tagging pattern (&lt;code&gt;tags=["loan_flow_internal"]&lt;/code&gt;) with the streaming layer dropping tagged spans. If you compose LLM calls inside LangGraph nodes and stream &lt;code&gt;messages&lt;/code&gt; mode, you will meet this bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persistence: the part everyone underestimates
&lt;/h2&gt;

&lt;p&gt;Chainlit's SQLAlchemy data layer persists users, threads, steps, elements, and feedback — into the stack's dedicated &lt;code&gt;chainlit-db&lt;/code&gt; Postgres instance (database-per-service applies to the UI too; chat history and business data have different owners, lifecycles, and erasure stories). But the data layer creates &lt;em&gt;nothing&lt;/em&gt; itself, and this is where two production bugs earned their scars.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug 1: steps silently stopped persisting.&lt;/strong&gt; The app bootstraps the schema with &lt;code&gt;CREATE TABLE IF NOT EXISTS&lt;/code&gt; — which never &lt;em&gt;adds columns&lt;/em&gt; to an existing table. A database first created under an older Chainlit lacked the &lt;code&gt;autoCollapse&lt;/code&gt; column that a newer Chainlit's &lt;code&gt;create_step&lt;/code&gt; INSERTs; Postgres raised &lt;code&gt;UndefinedColumnError&lt;/code&gt;, and steps — the chat history itself — quietly failed to save. No crash. Just missing history. The fix is an idempotent migration pass at startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Idempotent migrations for tables that already exist from an older schema. CREATE TABLE IF NOT
# EXISTS never adds columns, so newer Chainlit columns (e.g. autoCollapse, added in 2.11) must be
# back-filled here — otherwise create_step fails with UndefinedColumnError and steps (including the
# per-callback run steps) silently fail to persist.
&lt;/span&gt;&lt;span class="n"&gt;_CHAINLIT_MIGRATION_DDL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ALTER TABLE steps ADD COLUMN IF NOT EXISTS &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;autoCollapse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; BOOLEAN&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bug 2: resumed charts rendered blank.&lt;/strong&gt; Live, the Plotly figure renders perfectly. Reopen the thread a day later: an empty rectangle. Root cause: Chainlit stores element blobs under extension-less keys, and its &lt;code&gt;/public&lt;/code&gt; file route guesses &lt;code&gt;Content-Type&lt;/code&gt; from the filename — no extension, &lt;code&gt;application/octet-stream&lt;/code&gt;, and the frontend's Plotly component renders nothing. The fix — in the app's custom filesystem storage client — appends the mime's extension to the key at upload time, so the stored key, the on-disk file, and the served &lt;code&gt;Content-Type&lt;/code&gt; stay consistent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@staticmethod&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_with_ext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;object_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Append the mime&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s file extension so /public serves the right Content-Type.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;ext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_MIME_EXT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;mimetypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;guess_extension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mime&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;object_key&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ext&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;object_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;object_key&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ext&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The general lesson from both: &lt;strong&gt;persistence bugs are resume bugs.&lt;/strong&gt; Everything works live, because live rendering never touches the storage path. You only find out weeks later, from a user, unless you test the reload.&lt;/p&gt;

&lt;p&gt;Two smaller persistence notes worth stealing: the app adds its own FK indexes (Chainlit ships none beyond primary keys — a thread resume would seq-scan children), and it detaches Chainlit's per-callback wrapper step so assistant messages persist top-level instead of orphaned under a never-persisted parent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resume: one id ties the whole stack together
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cl.context.session.thread_id&lt;/code&gt; — Chainlit's own thread uuid — is reused verbatim as the agent's &lt;code&gt;conversation_id&lt;/code&gt;. On resume, the UI rebinds that single id, Chainlit replays the persisted messages and elements, and the agent continues from the &lt;em&gt;same durable LangGraph checkpoint&lt;/em&gt;. UI history and agent memory can't drift apart, because they share one identity. (Bonus: at 36 characters it also satisfies AgentCore's &lt;code&gt;runtimeSessionId ≥ 33&lt;/code&gt; requirement in cloud mode.)&lt;/p&gt;

&lt;p&gt;[SCREENSHOT: a resumed thread showing chat history and the interactive amortization chart rendering correctly]&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;None of this is glamorous, and that's the point. The chat frontend is a distributed-systems participant like everything else: it holds tokens that expire, writes to a database that drifts, serves blobs whose content types matter, and re-enters conversations that must resume exactly. Chainlit gives you the chrome — websockets, streaming, OAuth plumbing, thread history, rich elements — and leaves you the responsibilities: &lt;strong&gt;owning your schema migrations, your token lifecycle, your storage semantics, and your resume path.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Treat the AGUI as part of the architecture, test the reload and the re-login as seriously as the happy turn, and the UI stops being where your backend's rigor goes to die.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>chainlit</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Teach While Selling — Agentic RAG and the Death of the FAQ Page</title>
      <dc:creator>Pablets</dc:creator>
      <pubDate>Tue, 28 Jul 2026 00:58:20 +0000</pubDate>
      <link>https://dev.to/pablets/teach-while-selling-agentic-rag-and-the-death-of-the-faq-page-16pa</link>
      <guid>https://dev.to/pablets/teach-while-selling-agentic-rag-and-the-death-of-the-faq-page-16pa</guid>
      <description>&lt;p&gt;&lt;em&gt;For twenty years, education and conversion lived in different places: the landing page persuaded, the FAQ explained, the docs detailed, the legal PDF disclosed — and the application form converted, alone. A conversational agent collapses all of them into one channel. This article is about the "how": answering a customer's fears at the exact moment they appear, inside the flow, without derailing it — and the two-memory, human-reviewed architecture that keeps those answers trustworthy.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Watch a customer abandon a loan application and you'll almost never see them fail at the form. They fail at a &lt;em&gt;question the form can't answer&lt;/em&gt;: "wait — what does French amortization actually mean for what I pay?", "is 24 months better for me than 36?", "what happens if I want to pay early?"&lt;/p&gt;

&lt;p&gt;The old web's answer was &lt;strong&gt;spatial&lt;/strong&gt;: put the explanation &lt;em&gt;somewhere else&lt;/em&gt;. A Q&amp;amp;A page. A glossary. A "Learn about loans" hub. A 40-page terms PDF. Every one of those is a context switch, and every context switch is an exit — the customer leaves the flow to resolve their uncertainty, and a meaningful fraction never comes back. The conversion funnel and the education content were two different products, built by two different teams, meeting only in analytics dashboards.&lt;/p&gt;

&lt;p&gt;A conversational agent dissolves that separation, and this is genuinely new:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The same turn that collects the loan amount can teach what an amortization schedule is — personalized to this customer's product, numbers, and moment of doubt — and then continue the sale exactly where it paused.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Selling and teaching stop being two flows. The guided-selling literature has been circling this for years — consultative bots that "educate customers on why a product fits" are the digitization of the good in-store salesperson — and it matters most precisely in high-consideration purchases like credit, where research friction kills conversion. But doing it &lt;em&gt;safely&lt;/em&gt; in a regulated domain raises the question this article actually answers: when the agent teaches, &lt;strong&gt;where do its answers come from, and why should anyone trust them?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhuw8mqpomm7kvaa1s7yv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhuw8mqpomm7kvaa1s7yv.png" alt="Agentic RAG — the loop that teaches itself" width="800" height="551"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reading the diagram in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A customer gets stuck&lt;/strong&gt; — Mid-flow, a slot parse fails — «what’s the max I can borrow?» The value the flow needs isn’t in the answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layered help answers&lt;/strong&gt; — Help is tried in order of trust: 📗 authored knowledge first, then 💬 learned memory, then a sandboxed generated fallback. The cheapest, safest layer that can answer, wins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The flow moves on&lt;/strong&gt; — The answer resolves and the flow advances on a valid value. Help shapes the words — it never touches the state machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scrubbed &amp;amp; templated&lt;/strong&gt; — Only then is the resolution captured — and never raw. It is scrubbed and templated first: no names, no numbers, just {placeholders}.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A human signs it&lt;/strong&gt; — And a UX writer approves it. Nothing unreviewed is ever served — the learning is free, but the safety is designed in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The next customer&lt;/strong&gt; — Then the next customer to get stuck the same way gets that answer — rendered with today’s numbers, not the ones it was learned from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The loop closes&lt;/strong&gt; — And they’re back at the start, one step smoother. Every resolved conversation makes the next one easier — the loop that teaches itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The one invariant&lt;/strong&gt; — The agent’s own conversations become its knowledge — but help shapes the words, never the state, and a human signs every reused answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;The &lt;a href="https://pablogodoy.com/blog/agentic-rag-self-improving/" rel="noopener noreferrer"&gt;original article&lt;/a&gt; has an interactive, scroll-driven version of this diagram that lights up each step as you read.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the whole idea in one loop: a stuck customer, a layered answer, a resolution — scrubbed of everything personal, templated so no number is frozen in time, &lt;strong&gt;signed off by a human UX writer&lt;/strong&gt;, and then served to the next person who hits the same wall, rendered with today's product values.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "how", part 1 — detect the teaching moment at the point of friction
&lt;/h2&gt;

&lt;p&gt;The channel-merge is only valuable if the agent notices &lt;em&gt;when&lt;/em&gt; to switch modes. This system's loan flow is a deterministic slot-filler (amount, term, product) — and every failed parse of a customer reply is routed through an intent classifier first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"50000" → a value. Fill the slot, move on. &lt;strong&gt;Selling.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;"what's the maximum I can ask for?" → a question about the slot. &lt;strong&gt;Teaching moment.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;"how does French amortization work?" → a conceptual question. &lt;strong&gt;Deeper teaching moment.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the &lt;em&gt;capillarity&lt;/em&gt; the channel gives you: the old web could only educate at page granularity — a FAQ entry someone must find. The agent educates at &lt;strong&gt;slot granularity&lt;/strong&gt;: it knows the customer is stuck on &lt;code&gt;term_months&lt;/code&gt;, for &lt;em&gt;this&lt;/em&gt; product, having already said &lt;em&gt;this&lt;/em&gt; amount. The teaching moment arrives pre-contextualized, at the narrowest possible point of friction. No FAQ page can know where you stopped typing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "how", part 2 — teaching must never derail the sale
&lt;/h2&gt;

&lt;p&gt;Here's the design rule that makes mixing the flows safe rather than chaotic:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The help resolver &lt;strong&gt;owns no transition logic — it returns only the help TEXT. The state machine still advances only on a validly-parsed, in-policy value.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Answering "what's the maximum?" does not fill the slot, skip a step, or advance the journey. The flow holds its place; the answer arrives; the question is re-asked with the customer's context intact. Education is a &lt;em&gt;detour that isn't a detour&lt;/em&gt; — the sale is exactly where it was, one uncertainty lighter. This is what lets the two previously-separate flows coexist in one channel without corrupting each other: the teaching layer is architecturally incapable of touching the selling layer's state. (And below both sits the orchestrator's XState guardrail, which would refuse an illegal transition anyway.)&lt;/p&gt;

&lt;p&gt;The second half of the rule is &lt;em&gt;customization&lt;/em&gt;: the teaching is rendered from the customer's actual situation. Deterministic per-slot guidance is built from the &lt;strong&gt;real product's&lt;/strong&gt; min/max/installments; the LLM helper gets those constraints injected as facts it may rephrase but not invent; disclosure summaries are deterministic template renders of the customer's &lt;strong&gt;exact numbers&lt;/strong&gt; — deliberately not retrieval, because a paraphrased disclosure is one a lawyer can't sign. A FAQ page speaks to everyone; this speaks to &lt;em&gt;you&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "how", part 3 — two memories, because trust is not one thing
&lt;/h2&gt;

&lt;p&gt;Now the question you should ask of any system that "answers customer questions while selling credit": &lt;em&gt;says who?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This stack's answer is architectural: &lt;strong&gt;two physically separate knowledge stores with different trust contracts, different write paths, and different reasons to change&lt;/strong&gt; — plus a strict precedence between them, and a human review gate on everything the system taught itself. The full machinery — the cascade, the two collections, and the write → &lt;strong&gt;review&lt;/strong&gt; → read learning loop:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3lu4rx4bmifddyh3h1mv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3lu4rx4bmifddyh3h1mv.png" alt="Agentic RAG — layered slot help and the curated learning loop" width="800" height="624"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Store 1 — &lt;code&gt;knowledge_docs&lt;/code&gt;: the curated corpus.&lt;/strong&gt; Authored explainers (French amortization, repayment basics, terms and conditions), chunked by heading, ingested idempotently by content hash. This is assumed-verified content: written, refined, reviewed — the same editorial standard as the old docs site, now retrievable mid-conversation. It changes only when a human changes a document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Store 2 — &lt;code&gt;slot_help_kb&lt;/code&gt;: the learned memory.&lt;/strong&gt; Question→solution pairs harvested from the agent's own resolved conversations — the &lt;em&gt;self-information injection&lt;/em&gt; loop. It changes every time a customer gets unstuck. Cheap, self-scaling, and &lt;strong&gt;unreviewed at capture&lt;/strong&gt; — which is exactly why a freshly-learned answer is a &lt;code&gt;candidate&lt;/code&gt;, and candidates are never served. Between capture and reuse sits a human.&lt;/p&gt;

&lt;p&gt;The cascade encodes the trust ordering — curated answers first, &lt;em&gt;approved&lt;/em&gt; learned memory second, fresh generation last — and the write path always lands in the review queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;explanation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_knowledge_answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# 1. curated corpus first → 📗 marker
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;explanation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;explanation&lt;/span&gt;

&lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                  &lt;span class="c1"&gt;# 2. APPROVED learned memory second
&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;rag_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;#    (status='approved' only)
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;solution&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Stored solutions are TEMPLATES ({min_amount}, {terms}, ...) so no number is ever frozen
&lt;/span&gt;    &lt;span class="c1"&gt;# at capture time; render fills the CURRENT product's values. A template this context
&lt;/span&gt;    &lt;span class="c1"&gt;# cannot fill (render -&amp;gt; None) is treated as a miss — generate fresh, don't serve holes.
&lt;/span&gt;    &lt;span class="n"&gt;rendered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;help_templates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;solution&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rendered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;_MARK_LEARNED&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rendered&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;#    → 💬 marker
&lt;/span&gt;
&lt;span class="n"&gt;solution&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Stored PARAMETERIZED (concrete values -&amp;gt; placeholders) as a status='candidate' row: it enters
# the UX-writer review queue and is never served until approved.
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;template&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;help_templates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parameterize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;solution&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;rag_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 3. generate — then queue for review
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;solution&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And crucially, &lt;strong&gt;the learned store never writes into the curated one.&lt;/strong&gt; The code states the boundary as a decomposition principle: the two collections have "a different reason to change: authored knowledge vs learned conversation memory," sharing only the embeddings module and the Postgres server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the two-store split actually right? What the outside world says
&lt;/h3&gt;

&lt;p&gt;I went looking for external evidence, and the pattern holds up from three independent directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The support industry already formalized it.&lt;/strong&gt; The KCS (Knowledge-Centered Service) methodology — the dominant knowledge-management practice in support organizations — captures knowledge from live interactions as &lt;em&gt;draft/candidate&lt;/em&gt; articles, formally distinct from &lt;em&gt;validated&lt;/em&gt; ones, and treats "confidence visible to the user" as a core requirement. Constraining AI retrieval to validated content is explicitly cited as a hallucination control. Our &lt;code&gt;knowledge_docs&lt;/code&gt; vs &lt;code&gt;slot_help_kb&lt;/code&gt; is precisely KCS's validated-vs-candidate split, expressed as two vector collections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commercial AI-support platforms enforce the same tiering.&lt;/strong&gt; Intercom's Fin, for instance, answers only from explicitly approved content sources, with separately-managed curated "snippets" — source control is the product's trust story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The research literature warns about exactly the failure the split prevents.&lt;/strong&gt; Work on RAG "self-citation" and knowledge-base poisoning shows that indexing an assistant's own outputs into the authoritative corpus creates feedback loops — "generate-evaluate-reinject" cycles that amplify bias and quietly degrade truth. The mitigation the literature converges on is the one this architecture hard-codes: keep generated content out of the authoritative corpus, stamp provenance, and gate reuse.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So yes — the two-store design isn't a quirk; it's the intersection of a support-industry methodology, commercial best practice, and the published failure modes of self-learning RAG.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the implementation does to earn that trust
&lt;/h3&gt;

&lt;p&gt;Checking the code against that external bar, the trust seams that matter are present:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Separation and precedence&lt;/strong&gt; — learned content can never shadow curated content, because the curated corpus is consulted first and the stores never mix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A draft→validated lifecycle, with humans in it.&lt;/strong&gt; Every learned answer lands as &lt;code&gt;status='candidate'&lt;/code&gt; — and candidates are &lt;em&gt;never served&lt;/em&gt;. A dedicated curation dashboard (its own service, Keycloak-gated on a &lt;code&gt;ux-writer&lt;/code&gt; realm role, fail closed) is where UX writers approve, edit, or reject; the agent retrieves only &lt;code&gt;status='approved'&lt;/code&gt; rows, and every decision records who reviewed it and when. This is exactly the KCS candidate/validated split, with the dashboard as the "content health" loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Templates, not frozen numbers.&lt;/strong&gt; Before storage, concrete values are parameterized into placeholders — &lt;code&gt;You can borrow between {min_amount} and {max_amount}&lt;/code&gt; — and re-rendered with the &lt;em&gt;current&lt;/em&gt; product's values at serve time. Change the product's limits tomorrow and yesterday's approved answer says the new numbers. A template the current context can't fill isn't served with holes; the system falls back to generating fresh. (This extends the disclosures' template discipline to the learned tier — dynamic data always comes from live context, never from capture time.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provenance the customer can see.&lt;/strong&gt; Curated-corpus answers are marked "📗 &lt;em&gt;From our loan guides&lt;/em&gt;"; approved learned answers "💬 &lt;em&gt;A reviewed answer to a similar question&lt;/em&gt;"; fresh generation carries no marker. The trust tiering reaches the user, not just the architecture — KCS's "make confidence visible," in one line of markdown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provenance on every learned row&lt;/strong&gt; — a &lt;code&gt;source&lt;/code&gt; column (&lt;code&gt;llm&lt;/code&gt; vs &lt;code&gt;deterministic&lt;/code&gt;) plus an embedding signature &lt;code&gt;(provider, model, dim)&lt;/code&gt; that every query filters on, so a provider switch starts a fresh logical KB instead of silently comparing incompatible vector spaces. Reviewing edits text and status only — an approval never moves a row in vector space.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanitization before storage&lt;/strong&gt; — every learned key passes &lt;code&gt;pii.redact&lt;/code&gt; (SSNs, Luhn-validated cards, accounts, passwords, PINs) and then collapses all numerics to &lt;code&gt;&amp;lt;num&amp;gt;&lt;/code&gt;, so the KB stores &lt;em&gt;shapes of problems&lt;/em&gt;, never people or amounts. "Can I borrow &lt;code&gt;&amp;lt;num&amp;gt;&lt;/code&gt; for &lt;code&gt;&amp;lt;num&amp;gt;&lt;/code&gt;" is one lesson regardless of whose money it was.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confidence gating&lt;/strong&gt; — reuse requires cosine similarity above a provider-aware threshold (auto: 0.35 for the offline hash embeddings, 0.83 for real models); below it, the system generates fresh rather than serving a weak match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounded blast radius&lt;/strong&gt; — the worst a bad learned answer can do is be unhelpful &lt;em&gt;text&lt;/em&gt;. It cannot move the state machine, and everything fails soft to the deterministic guidance layer if pgvector is down.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One decomposition note on the dashboard, because it's easy to get wrong: it shares &lt;strong&gt;no code&lt;/strong&gt; with the agent — the &lt;code&gt;slot_help_kb&lt;/code&gt; table and the placeholder vocabulary are the entire contract. Deliberately so: the agent's store is fail-soft (a broken KB must never break a customer turn), while a review tool must fail &lt;em&gt;loud&lt;/em&gt; (an Approve that silently does nothing is worse than an error page). Opposite failure semantics are a different reason to change, hence a different module.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full trust gradient
&lt;/h2&gt;

&lt;p&gt;Zooming out, the teaching channel actually runs on four tiers, strictest first — and the tier is chosen by what the content &lt;em&gt;is&lt;/em&gt;, not by what's convenient:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;Source of truth&lt;/th&gt;
&lt;th&gt;May the LLM rephrase it?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Disclosures &amp;amp; terms&lt;/td&gt;
&lt;td&gt;legal text, the customer's exact numbers&lt;/td&gt;
&lt;td&gt;deterministic templates&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; — rendered verbatim&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concepts&lt;/td&gt;
&lt;td&gt;"how does amortization work?"&lt;/td&gt;
&lt;td&gt;curated &lt;code&gt;knowledge_docs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Synthesize from chunks (real LLM) or serve verbatim (offline)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recurring confusion&lt;/td&gt;
&lt;td&gt;"what's the max?" phrased a thousand ways&lt;/td&gt;
&lt;td&gt;learned &lt;code&gt;slot_help_kb&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Only UX-writer-approved templates, re-rendered with live values; threshold-gated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Novel questions&lt;/td&gt;
&lt;td&gt;everything else&lt;/td&gt;
&lt;td&gt;sandboxed generation + deterministic fallback&lt;/td&gt;
&lt;td&gt;Yes — words only, constraints injected, never a value&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That table is the answer to "how do you teach while selling in a regulated domain": &lt;strong&gt;match each kind of teaching to the strictest source that can produce it, and let none of them touch the sale's state.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The channel-merge is the prize: one conversation that persuades, explains, discloses, and converts — with the explanation arriving at slot-level granularity, personalized to the customer's own numbers, at the exact moment doubt appears. That's something no arrangement of landing pages, FAQs, and PDFs could ever do, and it's why this communication channel is worth the engineering.&lt;/p&gt;

&lt;p&gt;But the merge is only safe because the knowledge behind it is &lt;em&gt;tiered&lt;/em&gt;: curated docs you stand behind, learned lessons you scrub, template, and put a &lt;strong&gt;human signature&lt;/strong&gt; on before anyone sees them, generation you sandbox — and legal text you never let a model touch. Keep the two memories separate, keep the precedence strict, keep help powerless over state, and put a UX writer between what the agent learns and what it repeats — then the same chat can be your best salesperson and your best teacher at once, without ever being your compliance department's worst day.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources&lt;/strong&gt; (two-store validation and channel thesis):&lt;br&gt;
&lt;a href="https://library.serviceinnovation.org/KCS/KCS_v6/KCS_v6_Practices_Guide/030/040/010/030" rel="noopener noreferrer"&gt;KCS Article State — Consortium for Service Innovation&lt;/a&gt; · &lt;a href="https://knowmax.ai/blog/knowledge-centered-service/" rel="noopener noreferrer"&gt;KCS in the Age of AI&lt;/a&gt; · &lt;a href="https://messync.com/blog/knowledge-centered-service" rel="noopener noreferrer"&gt;What is Knowledge-Centered Service&lt;/a&gt; · &lt;a href="https://medium.com/@Quaxel/when-rag-starts-citing-itself-things-get-weird-ee48b7489f4a" rel="noopener noreferrer"&gt;When RAG Starts Citing Itself&lt;/a&gt; · &lt;a href="https://arxiv.org/html/2506.11415v1" rel="noopener noreferrer"&gt;Bias Amplification in RAG: Poisoning Knowledge Retrieval (arXiv)&lt;/a&gt; · &lt;a href="https://arxiv.org/html/2607.05217v2" rel="noopener noreferrer"&gt;Curated retrieval vs open web search: a coverage–trust trade-off (arXiv)&lt;/a&gt; · &lt;a href="https://www.intercom.com/help/en/articles/7837514-add-your-content-for-fin-ai-agent" rel="noopener noreferrer"&gt;Intercom Fin — content sources&lt;/a&gt; · &lt;a href="https://www.intercom.com/help/en/articles/8074407-how-to-use-snippets-with-fin" rel="noopener noreferrer"&gt;Intercom Fin — snippets&lt;/a&gt; · &lt;a href="https://commercetools.com/blog/guided-selling-in-the-age-of-conversational-commerce" rel="noopener noreferrer"&gt;Guided Selling in the Age of Conversational Commerce (commercetools)&lt;/a&gt; · &lt;a href="https://qualimero.com/en/blog/ai-chatbots-conversational-commerce" rel="noopener noreferrer"&gt;AI Chatbots in E-Commerce: From FAQ Bot to Digital Sales Consultant&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>llm</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Build a Production-Grade Conversational Loan Agent — LangGraph, MCP, and Durable Banking Orchestration</title>
      <dc:creator>Pablets</dc:creator>
      <pubDate>Mon, 20 Jul 2026 02:18:41 +0000</pubDate>
      <link>https://dev.to/pablets/build-a-production-grade-conversational-loan-agent-langgraph-mcp-and-durable-banking-1kmc</link>
      <guid>https://dev.to/pablets/build-a-production-grade-conversational-loan-agent-langgraph-mcp-and-durable-banking-1kmc</guid>
      <description>&lt;p&gt;&lt;em&gt;From MCP tool design to a durable XState + DBOS guardrail, end-to-end identity with Keycloak, crypto-shredded PII, and an eventing backstop — everything you need to let an AI agent near real money. And it all runs fully offline on your laptop.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;AI agent demos are easy. An LLM, a handful of tools, a chat box — twenty minutes and you're taking screenshots.&lt;/p&gt;

&lt;p&gt;Then you point the same architecture at a &lt;strong&gt;banking workflow&lt;/strong&gt;, and every shortcut turns into an incident report:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The agent retries a failed call and &lt;strong&gt;disburses the loan twice&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The LLM gets creative and calls &lt;code&gt;disburse&lt;/code&gt; &lt;strong&gt;before the customer accepted the terms&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A network blip mid-conversation and the application state &lt;strong&gt;evaporates&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The customer's name and email end up &lt;strong&gt;in your logs, your traces, and your vector store&lt;/strong&gt; — right before they invoke their right to erasure.&lt;/li&gt;
&lt;li&gt;Anyone who can reach the API can operate on &lt;strong&gt;anyone else's loan&lt;/strong&gt;, because "the agent sends a customer id" is the entire identity model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The problem isn't the LLM. The problem is treating a probabilistic text generator as if it were a &lt;strong&gt;trustworthy client&lt;/strong&gt;. It isn't one. It's a brilliant, unreliable intern — and the system around it has to be built the way banks have always built systems around unreliable clients: with hard state machines, idempotency, verified identity, and an audit trail.&lt;/p&gt;

&lt;p&gt;That's exactly what this guide builds. Not a theory-heavy overview — a real, running system you can clone, boot with one &lt;code&gt;docker compose up&lt;/code&gt;, and break on purpose.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;To run the demo and every experiment in this article, check out the &lt;strong&gt;&lt;a href="https://github.com/pablo-godoy-dev/agentic-loan-acquisition" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;&lt;/strong&gt; for full instructions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What are we going to build?
&lt;/h2&gt;

&lt;p&gt;By the end of this guide, you'll have a fully functional &lt;strong&gt;conversational loan-acquisition system&lt;/strong&gt;: a customer chats with an agent, picks a loan product, gets a simulation with an interactive amortization chart, accepts the terms, and receives a (demo) disbursement — with every step enforced, persisted, verified, and traceable.&lt;/p&gt;

&lt;p&gt;The customer experience looks like an AI chat. Underneath, it's a small bank:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgn3s2j792w06ern6tvjz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgn3s2j792w06ern6tvjz.png" alt="Loan acquisition architecture — full offline compose stack" width="800" height="695"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reading the diagram bottom-up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Customer Browser → Chainlit UI&lt;/strong&gt; — a chat UI with a real OIDC login (no demo password gate), persisted threads, and Plotly amortization charts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP Host — the LangGraph agent&lt;/strong&gt; — a Python agent whose tools &lt;em&gt;are&lt;/em&gt; the MCP server's tools, with a deterministic loan flow, layered slot-help, a self-improving RAG, and an AgentCore-compatible &lt;code&gt;/invocations&lt;/code&gt; API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP Server Layer — &lt;code&gt;loan-mcp&lt;/code&gt;&lt;/strong&gt; — a FastMCP server exposing the loan journey as &lt;strong&gt;8 tools&lt;/strong&gt;, verifying the caller's identity token and binding the journey to the caller's own party.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Core Banking Services&lt;/strong&gt; — four small Node services: an &lt;strong&gt;orchestrator&lt;/strong&gt; (XState + DBOS guardrail), a &lt;strong&gt;loans-api&lt;/strong&gt; (system of record, transactional outbox), a stateless &lt;strong&gt;rules-api&lt;/strong&gt; (lending policy), and a &lt;strong&gt;party-api&lt;/strong&gt; (the only place PII exists, encrypted per-party).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity Plane — Keycloak&lt;/strong&gt; — mints the tokens that every downstream hop independently re-verifies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data · Eventing · Key Custody&lt;/strong&gt; — six isolated per-service PostgreSQL instances (database-per-service), LocalStack SNS/SQS for domain events, and OpenBao Transit for crypto-shredding.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;The &lt;a href="https://pablogodoy.com/blog/building-a-production-loan-agent/" rel="noopener noreferrer"&gt;original article&lt;/a&gt; has an interactive, scroll-driven version of this diagram that lights up each layer as you read.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here's what you'll walk away with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A pattern for putting a &lt;strong&gt;deterministic guardrail&lt;/strong&gt; between an LLM and anything irreversible&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;MCP tool surface&lt;/strong&gt; designed for agents — docstrings as prompts, errors as data, ordering enforced server-side&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry + idempotency&lt;/strong&gt; done properly, including the failure mode most systems ignore&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;transactional outbox → SNS → SQS&lt;/strong&gt; projection backstop, with a DLQ&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;End-to-end identity&lt;/strong&gt;: OIDC login, token forwarding, and &lt;em&gt;independent&lt;/em&gt; re-verification at three hops&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right-to-erasure by crypto-shredding&lt;/strong&gt; — PII that can actually be forgotten&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;fully offline&lt;/strong&gt; stack — deterministic fake LLM, LocalStack instead of AWS — that still runs 77 end-to-end scenarios, chaos included&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The core principle: the LLM proposes, the machine disposes
&lt;/h2&gt;

&lt;p&gt;Before any code, one design decision drives everything else in this system:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The LLM never decides what is legal. It only proposes. A pure, deterministic state machine — persisted durably — is the single authority on what may happen next.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every "agent gone rogue" story is a violation of this principle. If your agent can call &lt;code&gt;disburse&lt;/code&gt; and the only thing preventing an out-of-order disbursement is the prompt — you don't have a workflow, you have a suggestion.&lt;/p&gt;

&lt;p&gt;In this system the loan lifecycle is an explicit XState machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LoanStateSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;selecting_product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offering_shown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;collecting_inputs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;simulated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;terms_accepted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;disbursed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cancelled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// The loan-acquisition lifecycle as a PURE, deterministic XState machine. It is the&lt;/span&gt;
&lt;span class="c1"&gt;// guardrail: given the current durable state and a candidate event (proposed by the&lt;/span&gt;
&lt;span class="c1"&gt;// non-deterministic LLM, or projected from a loans-api domain event), it answers&lt;/span&gt;
&lt;span class="c1"&gt;// "is this transition legal?" and "what is the next state?". It performs NO side&lt;/span&gt;
&lt;span class="c1"&gt;// effects — everything depends on the machine; the machine depends on nothing.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The machine is &lt;strong&gt;pure&lt;/strong&gt; — no I/O, no context, no side effects. The orchestrator persists the current state durably (DBOS on Postgres), and every command the agent proposes is checked against the machine &lt;em&gt;before&lt;/em&gt; anything executes. An out-of-order call — say, &lt;code&gt;disburse_loan&lt;/code&gt; while the state is still &lt;code&gt;simulated&lt;/code&gt; — doesn't corrupt anything. It comes back as a clean &lt;code&gt;409 STATE_TRANSITION&lt;/code&gt; error that the agent can read, explain, and recover from conversationally.&lt;/p&gt;

&lt;p&gt;That one boundary changes the whole risk profile: the LLM can be as creative as it wants, because creativity bounces off the guardrail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCP tool surface: designing tools for an unreliable caller
&lt;/h2&gt;

&lt;p&gt;The agent reaches the banking core exclusively through &lt;strong&gt;&lt;code&gt;loan-mcp&lt;/code&gt;&lt;/strong&gt; — a FastMCP server exposing 8 tools that map 1:1 to the orchestrator's REST edge. This layer looks thin, but it's where most of the agent-facing design lives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;loan-acquisition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MCP_HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MCP_PORT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_loan_products&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;List the loan products the customer can choose from (the catalog).

    Call this FIRST, at the very start of a loan conversation, to present the
    available products (e.g. personal / auto / mortgage) as discrete choices. Each
    product carries the limits and installments the later steps must respect.

    Returns:
        {&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;products&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: [ &amp;lt;product&amp;gt;, ... ]} on success, each product shaped like
        {&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minAmountMinor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maxAmountMinor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,
         &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allowedInstallments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: list[int], &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;baseRateBps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disclosure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}
        (amounts in minor units, e.g. cents), or {&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &amp;lt;RFC 9457 problem&amp;gt;} if
        the rules service is unavailable.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_run_rules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_loan_products&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;products&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_rules_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_products&lt;/span&gt;&lt;span class="p"&gt;()})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three deliberate choices are packed in here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docstrings are prompts.&lt;/strong&gt; The LLM reads these to decide &lt;em&gt;when&lt;/em&gt; and &lt;em&gt;how&lt;/em&gt; to call each tool. "Call this FIRST" isn't documentation flavor — it's behavioral steering that costs zero tokens of system prompt. Every one of the 8 tools carries ordering hints, argument semantics, and the exact response shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Errors are data, not exceptions.&lt;/strong&gt; A failed call returns &lt;code&gt;{"error": &amp;lt;RFC 9457 problem&amp;gt;}&lt;/code&gt; instead of raising. An exception dies somewhere in the MCP transport; a structured error &lt;em&gt;reaches the model&lt;/em&gt;, which can then tell the customer "that amount is above the limit for this product" instead of stalling. The orchestrator's 409 guardrail rejections ride the same channel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Money is integers.&lt;/strong&gt; Every amount is minor units (&lt;code&gt;amount_minor&lt;/code&gt;, cents) end to end. LLMs are bad at floating point; ledgers are worse at it.&lt;/p&gt;

&lt;p&gt;The module docstring states the decomposition contract explicitly — this file knows &lt;em&gt;nothing&lt;/em&gt; about HTTP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Secret this module hides: the MCP tool surface — tool names, docstrings, typed
signatures, and how an OrchestratorError becomes a usable {&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: {...}} the
agent can read. It knows NOTHING about httpx, paths, or the HTTP contract;
those are orchestrator_client.py&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s and rules_client.py&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s secrets. The only
reason to change this file is a change to the agent-facing tool shape.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the tool shape changes, one file changes. When the HTTP contract changes, a different file changes. That's the whole game.&lt;/p&gt;

&lt;h2&gt;
  
  
  The end-to-end flow
&lt;/h2&gt;

&lt;p&gt;One full journey, numbered:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Login&lt;/strong&gt; — The customer signs in through Keycloak (a real authorization-code flow — more on this below) and opens the chat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chat turn&lt;/strong&gt; — The UI POSTs to the agent's &lt;code&gt;/invocations&lt;/code&gt; endpoint with the customer's access token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catalog&lt;/strong&gt; — The agent calls &lt;code&gt;get_loan_products&lt;/code&gt; → &lt;code&gt;get_offering&lt;/code&gt;, presenting products and terms as discrete choices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slot filling&lt;/strong&gt; — The LangGraph flow collects amount and installments, pre-validating against the offering's limits so out-of-policy requests get caught conversationally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simulation&lt;/strong&gt; — &lt;code&gt;simulate_loan&lt;/code&gt; mints the application (the Control Record) via the orchestrator; the UI renders the payment plan plus an interactive amortization chart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guardrailed progress&lt;/strong&gt; — &lt;code&gt;accept_terms&lt;/code&gt;, then &lt;code&gt;disburse_loan&lt;/code&gt; — each command checked against the XState machine, executed against loans-api with retry + idempotency, then projected back into durable state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Events&lt;/strong&gt; — loans-api's outbox publishes domain events to SNS; the orchestrator's SQS consumer converges the projection even if a direct response was lost.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; the LLM may loop between steps 3–6 as the conversation meanders — customers change amounts, ask questions, go back. That's fine &lt;em&gt;because&lt;/em&gt; ordering is enforced server-side. The conversation is free; the ledger is not.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Surviving failure: bounded retries on top of idempotency
&lt;/h2&gt;

&lt;p&gt;Here's the failure mode that separates toy agents from production systems: the orchestrator calls loans-api, &lt;strong&gt;loans-api commits the write, and the response is lost&lt;/strong&gt; — connection reset after commit. Did the disbursement happen? The caller cannot know.&lt;/p&gt;

&lt;p&gt;If you retry blindly, you double-disburse. If you never retry, every network blip strands a customer. The answer is a pair of mechanisms that only work &lt;em&gt;together&lt;/em&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Every logical command carries an idempotency key&lt;/strong&gt;, and loans-api dedupes on it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The key is built once per logical command — a retry replays the &lt;em&gt;same&lt;/em&gt; key.&lt;/li&gt;
&lt;li&gt;loans-api's &lt;code&gt;runIdempotentWrite&lt;/code&gt; records the response under that key inside the same transaction as the domain write.&lt;/li&gt;
&lt;li&gt;A replayed key returns the recorded response. Never a second write.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. The orchestrator retries only what's worth retrying&lt;/strong&gt;, on a bounded schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Transient = a fault a RETRY could plausibly clear: a 5xx UpstreamError or a bare&lt;/span&gt;
&lt;span class="c1"&gt;// transport failure (connection reset / timeout). Terminal = any 4xx DomainError&lt;/span&gt;
&lt;span class="c1"&gt;// (validation, not-found, illegal transition): retrying only burns patience.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isTransient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isDomainError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// maxAttempts 5, 1s base, ×2 growth: retries at ~1s, 2s, 4s, 8s → ~15s of backoff.&lt;/span&gt;
&lt;span class="c1"&gt;// Jitter is ADDITIVE (layered on top, never subtracted): it decorrelates concurrent&lt;/span&gt;
&lt;span class="c1"&gt;// retriers but cannot shrink the total below the ~15s floor.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_RETRY_SCHEDULE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RetrySchedule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;baseDelayMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;backoffFactor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;jitterMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment in the real code says it plainly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PRECONDITION: `op` MUST be idempotent. A retry may replay a call whose earlier&lt;/span&gt;
&lt;span class="c1"&gt;// attempt already committed downstream but whose response was lost (the ambiguous&lt;/span&gt;
&lt;span class="c1"&gt;// request-landed-response-lost failure). Every loans-api call satisfies this.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth stealing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The retry policy is a plain injected decorator&lt;/strong&gt; (&lt;code&gt;retrying-loans-client&lt;/code&gt; wraps the loans client). The command service doesn't know retries exist. Unit tests inject fake timers and fake randomness; production injects nothing and gets the wall clock.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The chaos tests prove the retries ran.&lt;/strong&gt; The e2e suite uses Toxiproxy to inject a persistent fault and then asserts &lt;code&gt;elapsed &amp;gt; 12s&lt;/code&gt; — if the backoff schedule silently stopped running, the test fails. That's also &lt;em&gt;why&lt;/em&gt; the jitter is additive rather than "full jitter": full jitter can collapse a delay toward zero and would make that proof flaky.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The eventing backstop: outbox → SNS → SQS
&lt;/h2&gt;

&lt;p&gt;The synchronous path can still lose a response after all retries. For that, the system keeps a second, asynchronous road to consistency:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxmdmz1britor1srcm8c6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxmdmz1britor1srcm8c6.png" alt="Eventing — transactional outbox, SNS fan-out, SQS projection backstop" width="800" height="575"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The sequence has three phases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Command&lt;/strong&gt; — loans-api writes the domain change &lt;em&gt;and&lt;/em&gt; an outbox row in &lt;strong&gt;one transaction&lt;/strong&gt;. There is no window where the state changed but the event doesn't exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async relay&lt;/strong&gt; — a DBOS-durable poller publishes unpublished outbox rows to an SNS topic (&lt;code&gt;loan-domain-events&lt;/code&gt;) and marks them published. At-least-once, by design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Projection backstop&lt;/strong&gt; — an SQS queue subscribed to the topic feeds the orchestrator's consumer, which applies events to its projection &lt;strong&gt;idempotently per event id&lt;/strong&gt;. Three failed receives and the message redrives to a DLQ instead of poisoning the queue.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Backstop, not source of truth.&lt;/strong&gt; State is durable in loans-api regardless. The event path exists so the orchestrator's view &lt;em&gt;converges&lt;/em&gt; even when a direct response was lost — and duplicates are absorbed by idempotency on both sides.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Locally, SNS and SQS are &lt;strong&gt;LocalStack&lt;/strong&gt; — the offline stack exercises the real AWS SDK code paths, queue redrive policy included, without an AWS account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity end to end: the agent is just another untrusted client
&lt;/h2&gt;

&lt;p&gt;Most agent demos handle identity like this: the UI tells the agent who the user is, and everyone downstream believes it. In a loan flow, that's a vulnerability, not a shortcut.&lt;/p&gt;

&lt;p&gt;This system treats the &lt;strong&gt;access token as the only identity carrier&lt;/strong&gt;, and re-verifies it independently at every hop that matters:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz32u3wh5puo94sjjw70z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz32u3wh5puo94sjjw70z.png" alt="Identity flow — OIDC login and end-to-end token verification" width="800" height="568"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 1 — Login.&lt;/strong&gt; The Chainlit UI runs a real authorization-code flow against Keycloak with &lt;code&gt;prompt=login&lt;/code&gt; (logout must actually log out — an SSO cookie that silently waves you back in is wrong for a loan flow). One subtlety worth knowing: the browser reaches Keycloak at &lt;code&gt;https://localhost:8543&lt;/code&gt; while backends reach it in-network at &lt;code&gt;https://keycloak:8443&lt;/code&gt; — the classic &lt;strong&gt;two-hostname split&lt;/strong&gt;. The token's issuer is pinned to the frontend URL; the JWKS is fetched in-network. Chainlit's stock provider assumes one base URL for both, so the UI registers a small custom provider that splits them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 2 — Every chat turn.&lt;/strong&gt; The UI forwards the access token to the agent; the agent forwards it to &lt;code&gt;loan-mcp&lt;/code&gt;; and then the verification cascade begins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;VerifiedPrincipal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Prove the token authentic, then return its principal; raise TokenError otherwise.

    Verifies (in order): a signing key exists in JWKS for the token&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s kid; RS256
    signature; `exp` (with leeway); `iss` equals the configured issuer. Then enforces
    the app policy: the email must be present AND Keycloak-verified, and — if
    configured — the authorized party (azp) must match.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;signing_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_signing_key_from_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;_ALGORITHMS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leeway&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_leeway&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verify_aud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;require&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;loan-mcp&lt;/code&gt; verifies the token&lt;/strong&gt; against Keycloak's JWKS and &lt;strong&gt;binds the journey to the token's party&lt;/strong&gt; — the verified email resolves (via blind index) to a party reference in the Party Directory. With &lt;code&gt;LOAN_MCP_AUTH_ENFORCED=true&lt;/code&gt;, a &lt;code&gt;simulate_loan&lt;/code&gt; without a verifiable token is refused outright. Fail closed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The orchestrator re-verifies the same token independently&lt;/strong&gt; and confirms the token-derived party matches the &lt;code&gt;customerRef&lt;/code&gt; being operated on. This is the defense-in-depth move: a caller who bypasses &lt;code&gt;loan-mcp&lt;/code&gt; entirely and hits the REST edge with a &lt;em&gt;valid token for a different party&lt;/em&gt; is rejected here too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;party-api verifies tokens on its one irreversible route&lt;/strong&gt; — erasure — requiring the identity to be the party itself or a &lt;code&gt;compliance-officer&lt;/code&gt; role.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Trade-off callout: &lt;code&gt;verify-when-present&lt;/code&gt; vs &lt;code&gt;require&lt;/code&gt;.&lt;/strong&gt; The orchestrator runs &lt;code&gt;ORCH_AUTH_MODE=verify-when-present&lt;/code&gt;: a &lt;em&gt;present&lt;/em&gt; token is verified and ownership-checked (fail-closed on mismatch), but token-less callers still pass — which keeps the direct-REST e2e suite and internal tools working against the same stack. A deployment without that path should run &lt;code&gt;require&lt;/code&gt;. The point is that the mode is an explicit, named decision — not an accident of which middleware happened to be installed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  PII that can actually be forgotten
&lt;/h2&gt;

&lt;p&gt;Loan conversations are PII magnets — and LLM systems multiply the copies: logs, traces, checkpoints, embeddings. This stack's answer is architectural, not procedural:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only one service ever holds PII.&lt;/strong&gt; The &lt;strong&gt;party-api&lt;/strong&gt; — modeled on the BIAN &lt;em&gt;Party Reference Data Directory&lt;/em&gt; service domain — stores identity data; every other service holds an opaque &lt;code&gt;partyReference&lt;/code&gt; and copies nothing. Names never enter the orchestrator's database, the agent's checkpoints, or the loan records.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And that one service can't read its own database.&lt;/strong&gt; PII is stored as ciphertext under a &lt;strong&gt;per-party OpenBao Transit key&lt;/strong&gt;; lookups run on blind indexes (HMACs), not plaintext. Right-to-erasure is then a two-step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Destroy the party's Transit key.&lt;/li&gt;
&lt;li&gt;Delete the rows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 1 is the interesting one: destroying the key makes &lt;strong&gt;every copy&lt;/strong&gt; of that ciphertext — live rows, WAL, backups — undecryptable at once. That's &lt;strong&gt;crypto-shredding&lt;/strong&gt;: erasure that doesn't require rewriting audit history or hunting down replicas. No other service needs to participate, because no other service ever had the data.&lt;/p&gt;

&lt;p&gt;On the agent side, the RAG that learns from customer conversations &lt;strong&gt;scrubs PII before anything is embedded or stored&lt;/strong&gt; — the vector store learns &lt;em&gt;problems and solutions&lt;/em&gt;, not people.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the customer gets stuck: layered help, self-improving RAG
&lt;/h2&gt;

&lt;p&gt;A deterministic slot-filling flow has a classic failure mode: the customer answers "well, it depends…" and the flow re-asks the same question forever. This system layers three escapes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic guidance first&lt;/strong&gt; — per-slot help text, constraints, examples. Zero LLM cost, always available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sandboxed LLM helper&lt;/strong&gt; — a constrained chain that answers the customer's actual question about the slot, without being allowed to drive the flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A self-improving RAG&lt;/strong&gt; — resolved question→solution pairs (PII-scrubbed) are embedded into pgvector; the next stuck customer with a similar question gets the learned answer. A second, curated &lt;code&gt;knowledge_docs&lt;/code&gt; collection answers conceptual questions ("how does French amortization work?") from bundled explainer documents.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything &lt;strong&gt;fails soft&lt;/strong&gt;: if pgvector is unavailable, guidance degrades to the deterministic layer — a help subsystem outage never takes down the loan flow.&lt;/p&gt;

&lt;p&gt;One production bug from this feature is worth retelling, because it's invisible until you stream. The helper chains run &lt;em&gt;inside&lt;/em&gt; the flow node, and the node appends the final message itself — so the helper's tokens were streaming to the UI &lt;em&gt;and&lt;/em&gt; the final message arrived afterward: &lt;strong&gt;every answer appeared twice&lt;/strong&gt;. The fix is a tagging pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Tagged as an internal helper: this runs INSIDE loan_flow_node, which appends the
# final message itself, so the streaming layer drops this chain's token stream.
&lt;/span&gt;&lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;loan_flow_internal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and the streaming layer filters spans carrying the tag. If you compose LLM calls inside LangGraph nodes and stream with &lt;code&gt;stream_mode="messages"&lt;/code&gt;, you will hit this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it: fully offline, deterministically
&lt;/h2&gt;

&lt;p&gt;The entire stack boots with one command and &lt;strong&gt;zero cloud dependencies&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; loan-acquisition-infra/compose/docker-compose.yml up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;span class="c"&gt;# → https://localhost:8000  (accept the demo CA once)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Cloud deployment&lt;/th&gt;
&lt;th&gt;Offline compose stack&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LLM&lt;/td&gt;
&lt;td&gt;Amazon Bedrock&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LLM_PROVIDER=fake&lt;/code&gt; — deterministic scripted brain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embeddings&lt;/td&gt;
&lt;td&gt;Provider embeddings&lt;/td&gt;
&lt;td&gt;Deterministic hash embeddings (no network)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SNS / SQS&lt;/td&gt;
&lt;td&gt;AWS&lt;/td&gt;
&lt;td&gt;LocalStack (same SDK code paths)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity&lt;/td&gt;
&lt;td&gt;Managed IdP&lt;/td&gt;
&lt;td&gt;Keycloak container, real OIDC flows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets / keys&lt;/td&gt;
&lt;td&gt;Cloud KMS&lt;/td&gt;
&lt;td&gt;OpenBao Transit (real server mode, raft)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS&lt;/td&gt;
&lt;td&gt;ACM certs&lt;/td&gt;
&lt;td&gt;Local demo CA (&lt;code&gt;compose/tls&lt;/code&gt;) on UI, Keycloak, OpenBao, party-api&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres&lt;/td&gt;
&lt;td&gt;RDS (one per service)&lt;/td&gt;
&lt;td&gt;Six isolated Postgres 16 instances — database-per-service (+ pgvector on the agent's, hypopg, pg_stat_statements)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The deterministic fake LLM is the unsung hero: it drives the &lt;em&gt;same&lt;/em&gt; create → offer → simulate → disburse journey every time, which is what makes &lt;strong&gt;77 Cucumber end-to-end scenarios&lt;/strong&gt; — including durability chaos with Toxiproxy latency/faults and WireMock — reliable enough to run on every change. The agent itself is AgentCore-compatible (&lt;code&gt;/invocations&lt;/code&gt; contract), so the same code targets AWS when you want the cloud.&lt;/p&gt;

&lt;p&gt;For day-two visibility, &lt;strong&gt;pgweb&lt;/strong&gt; gives you a free window into the DBOS workflow tables and the outbox — you can literally watch a retry schedule execute and an outbox row flip to published.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;We went from "an LLM with tools" to a system where the agent is treated as what it really is: a persuasive but untrusted client, wrapped in the same discipline banks have always applied — a deterministic state machine as the single authority, idempotency underneath every retry, identity verified at every hop instead of asserted once, PII isolated and crypto-shreddable, and an event backstop for the failures the happy path can't see.&lt;/p&gt;

&lt;p&gt;The core takeaway is simple: &lt;strong&gt;you don't make an agent safe by prompting it better — you make it safe by building a system that stays correct when the agent is wrong.&lt;/strong&gt; The LLM proposes; the machine disposes. Everything else in this architecture is that one sentence, applied layer by layer.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>langgraph</category>
      <category>architecture</category>
      <category>python</category>
    </item>
  </channel>
</rss>
