<?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: Avneet bansal</title>
    <description>The latest articles on DEV Community by Avneet bansal (@avneet_bansal_a65b3f31fc4).</description>
    <link>https://dev.to/avneet_bansal_a65b3f31fc4</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%2F4116498%2F405a55bb-2d18-4b5d-b61c-7fc909692a12.jpg</url>
      <title>DEV Community: Avneet bansal</title>
      <link>https://dev.to/avneet_bansal_a65b3f31fc4</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/avneet_bansal_a65b3f31fc4"/>
    <language>en</language>
    <item>
      <title>Textract vs Bedrock Data Automation vs Vision LLMs on Invoice Photos: 140 Images and a 6-Page Invoice, Real Calls</title>
      <dc:creator>Avneet bansal</dc:creator>
      <pubDate>Mon, 09 Nov 2026 15:04:19 +0000</pubDate>
      <link>https://dev.to/avneet_bansal_a65b3f31fc4/textract-vs-bedrock-data-automation-vs-vision-llms-on-invoice-photos-140-images-and-a-6-page-59n9</link>
      <guid>https://dev.to/avneet_bansal_a65b3f31fc4/textract-vs-bedrock-data-automation-vs-vision-llms-on-invoice-photos-140-images-and-a-6-page-59n9</guid>
      <description>&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%2F3gmj9cjsutx4e6p3uv8n.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%2F3gmj9cjsutx4e6p3uv8n.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Invoices from the field rarely arrive as clean PDFs. They arrive as phone photos: the invoice lying on a stack of other papers, a coffee receipt on top of it, or a sheet held against a car dashboard. I wanted to know which extraction setup holds up in those conditions, what goes wrong, and what a team can do about it in a few lines of code.&lt;/p&gt;

&lt;p&gt;This post is a small comparison. Every number comes from real API calls, and every raw response was saved so the scoring could be re-run without calling AWS again. The code snippets below are the code I used, trimmed to the parts that matter. I ran each one against the saved responses to check that it reproduces the numbers in this post. The last section explains how the benchmark is built, so you can rebuild it for your own documents.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Out of the box, no tool got the invoice total right on more than 83% of the 40 photos. For Textract and BDA, the usual failure was not reading the text. It was picking the wrong amount: "Amount paid", "Amount due" or a previous balance.&lt;/li&gt;
&lt;li&gt;Small configuration changes moved totals as much as the choice of tool. A label filter plus a confidence-gated crop took Textract from 42.5% to 85% on totals. A custom BDA blueprint took BDA from 67.5% to 100%.&lt;/li&gt;
&lt;li&gt;A subtotal + tax = total check catches most OCR mistakes but few vision-model mistakes. Nova 2 Lite passed that check on 35 of 40 photos, and 20 of those had a wrong field, mostly the date or the invoice number.&lt;/li&gt;
&lt;li&gt;Using Textract for the values and Nova 2 Lite as a cross-check auto-approved 22 of 40 invoices with zero silent errors, for about $11 per 1,000.&lt;/li&gt;
&lt;li&gt;On photographed pages of a 6-page invoice, checking each page against its own carried-forward amount flagged every page with a real error and missed none.&lt;/li&gt;
&lt;li&gt;On 95 real receipts with a labelled total, Textract read it right 97.9% of the time. BDA and Nova often read "31.000" (Indonesian rupiah) as 31.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What was tested
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Data.&lt;/strong&gt; Three sets.&lt;/p&gt;

&lt;p&gt;The first set is 5 synthetic invoices in 2 layouts, each photographed in 8 scenes: flat, angled, blur and glare, folded, low light, a stack of papers, a receipt lying on the invoice, and a dark dashboard-like background with a fuel receipt and a parking stub. That makes 40 images. The invoices are generated, so the answer key is exact, and every background paper has known values too. They contain traps that come up in accounts payable work: "Amount paid" and "Balance due" next to "Total", a "Previous balance" line, separate "Invoice to" and "Deliver to" blocks, and DD/MM/YYYY dates.&lt;br&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%2Fufbfolwnyn761jd987c0.jpg" 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%2Fufbfolwnyn761jd987c0.jpg" alt="he 40 synthetic photos: 5 invoices in 8 scenes" width="800" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The second set is the CORD v2 test split: 100 real phone photos of receipts from Indonesia (CC BY 4.0). 95 of them have a labelled total.&lt;/p&gt;

&lt;p&gt;The third set is one synthetic 6-page invoice with 149 line items. Each page ends with "Page subtotal (carried forward)" and the next page starts with "Brought forward". The header is only on page 1 and the totals are only on page 6. It was tested twice: as a clean PDF, and with each page photographed in a different scene and combined into one PDF, the way a phone scanner app would.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools.&lt;/strong&gt; All run in us-west-2.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Amazon Textract AnalyzeExpense (synchronous for single images, StartExpenseAnalysis for the 6-page PDF)&lt;/li&gt;
&lt;li&gt;Amazon Bedrock Data Automation (BDA) with the AWS public Invoice and Receipt blueprints, and with a custom blueprint&lt;/li&gt;
&lt;li&gt;Claude Sonnet 4.5 and Amazon Nova 2 Lite on Amazon Bedrock, through the &lt;code&gt;us.&lt;/code&gt; inference profiles, with one JSON prompt that asks for each value plus a 0 to 1000 bounding box (the prompt follows the pattern in &lt;a href="https://github.com/aws-samples/sample-document-information-localization" rel="noopener noreferrer"&gt;aws-samples/sample-document-information-localization&lt;/a&gt;), temperature 0&lt;/li&gt;
&lt;li&gt;Pre-processing before Textract: Apple Vision document detection with perspective correction, and PaddleOCR's DocPreprocessor (orientation plus UVDoc unwarping)&lt;/li&gt;
&lt;li&gt;A spatial check after Textract: &lt;a href="https://github.com/aws-samples/sample-textract-field-memory" rel="noopener noreferrer"&gt;textract-field-memory&lt;/a&gt;, an open-source library I wrote that learns where fields sit on a known layout (&lt;a href="https://dev.to/avneet_bansal_a65b3f31fc4/give-your-document-ocr-pipeline-a-memory-59l1"&gt;earlier post&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Two modes for Textract and BDA.&lt;/strong&gt; Out of the box means defaults and the highest-confidence answer. Configured means the change a team would make after reading the output once. The vision models ran with one prompt and no tuning. The configured changes are shown as code below, so you can judge whether they are fair.&lt;/p&gt;
&lt;h2&gt;
  
  
  Result 1: accuracy on 40 photos
&lt;/h2&gt;

&lt;p&gt;Brackets are 95% intervals. With 40 images they are wide, and that is the honest size of this study.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Invoice total right&lt;/th&gt;
&lt;th&gt;All 6 key fields right&lt;/th&gt;
&lt;th&gt;Median latency&lt;/th&gt;
&lt;th&gt;Cost / 1,000&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Textract AnalyzeExpense&lt;/td&gt;
&lt;td&gt;default&lt;/td&gt;
&lt;td&gt;42.5% (29 to 58)&lt;/td&gt;
&lt;td&gt;42.5%&lt;/td&gt;
&lt;td&gt;3.7 s&lt;/td&gt;
&lt;td&gt;$10.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BDA, public Invoice blueprint&lt;/td&gt;
&lt;td&gt;default&lt;/td&gt;
&lt;td&gt;67.5% (52 to 80)&lt;/td&gt;
&lt;td&gt;65.0%&lt;/td&gt;
&lt;td&gt;25.9 s&lt;/td&gt;
&lt;td&gt;$40.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 4.5&lt;/td&gt;
&lt;td&gt;default&lt;/td&gt;
&lt;td&gt;70.0% (55 to 82)&lt;/td&gt;
&lt;td&gt;50.0%&lt;/td&gt;
&lt;td&gt;6.8 s&lt;/td&gt;
&lt;td&gt;$10.70&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Nova 2 Lite&lt;/td&gt;
&lt;td&gt;default&lt;/td&gt;
&lt;td&gt;82.5% (68 to 91)&lt;/td&gt;
&lt;td&gt;37.5%&lt;/td&gt;
&lt;td&gt;3.7 s&lt;/td&gt;
&lt;td&gt;$1.07&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Textract + label filter + confident crop&lt;/td&gt;
&lt;td&gt;configured&lt;/td&gt;
&lt;td&gt;85.0% (71 to 93)&lt;/td&gt;
&lt;td&gt;82.5%&lt;/td&gt;
&lt;td&gt;4.0 s&lt;/td&gt;
&lt;td&gt;$10.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BDA, custom blueprint&lt;/td&gt;
&lt;td&gt;configured&lt;/td&gt;
&lt;td&gt;100.0% (91 to 100)&lt;/td&gt;
&lt;td&gt;67.5%&lt;/td&gt;
&lt;td&gt;25.2 s&lt;/td&gt;
&lt;td&gt;$40.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The six key fields are vendor, invoice number, invoice date, subtotal, tax and total. BDA latency includes the S3 upload and polling for the asynchronous job.&lt;/p&gt;
&lt;h3&gt;
  
  
  Textract picks the wrong amount
&lt;/h3&gt;

&lt;p&gt;Textract read the text well. The problem was which amount it labelled TOTAL. On Layout A it took the "Amount paid" line, which is printed as a negative number such as -$100.00, on 7 of 24 photos. On Layout B it was worse: 14 of 16 photos returned the amount due or the previous balance.&lt;br&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%2F60awqwq19nslywukaep1.jpg" 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%2F60awqwq19nslywukaep1.jpg" alt="Textract returns the Amount paid line as the total" width="800" height="700"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every candidate in the AnalyzeExpense response carries the label printed next to it, so the fix is a few lines:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;

&lt;span class="n"&gt;FIELDS&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;VENDOR_NAME&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;vendor_name&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;INVOICE_RECEIPT_ID&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;invoice_number&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;INVOICE_RECEIPT_DATE&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;invoice_date&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;DUE_DATE&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;due_date&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;RECEIVER_NAME&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;customer_name&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;SUBTOTAL&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;subtotal&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;TAX&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;tax&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;TOTAL&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;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;NOT_THE_TOTAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;paid|balance|previous|due&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;SKIP_LABEL&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;TOTAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;NOT_THE_TOTAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SUBTOTAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;NOT_THE_TOTAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TAX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;NOT_THE_TOTAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RECEIVER_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deliver|ship&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;span class="n"&gt;MONEY&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;SUBTOTAL&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;TAX&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;TOTAL&lt;/span&gt;&lt;span class="sh"&gt;"&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;pick_fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;configured&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Highest-confidence candidate per field, skipping amounts that are not the invoice total.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;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;ExpenseDocuments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SummaryFields&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Type&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;Text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&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;ValueDetection&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="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;Text&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="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&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;LabelDetection&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="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;Text&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="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;FIELDS&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;configured&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;MONEY&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startswith&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="k"&gt;continue&lt;/span&gt;  &lt;span class="c1"&gt;# "Amount paid" is printed as a negative number
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;configured&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SKIP_LABEL&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;SKIP_LABEL&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Type&lt;/span&gt;&lt;span class="sh"&gt;"&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;Confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ValueDetection&lt;/span&gt;&lt;span class="sh"&gt;"&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;Confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&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;kind&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;][&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;best&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;kind&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="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&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="n"&gt;FIELDS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On real receipts the same filter cost 3.2 points (97.9% to 94.7%), so it is a per-document-type choice, not a free win.&lt;/p&gt;

&lt;h3&gt;
  
  
  BDA's public blueprint defines TOTAL as the amount due
&lt;/h3&gt;

&lt;p&gt;The public Invoice blueprint defines TOTAL as "total amount due including subtotal and tax". On the two invoices that print a previous balance, it returned the amount due on 13 of 16 photos.&lt;br&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%2Fow7jds9oeh62eiiw83b9.jpg" 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%2Fow7jds9oeh62eiiw83b9.jpg" alt="BDA public blueprint returns the amount due as TOTAL" width="800" height="700"&gt;&lt;/a&gt;&lt;br&gt;
A custom blueprint that asks for the two amounts separately got every total right. This is the full schema (9 fields):&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;

&lt;span class="n"&gt;S&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&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;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inferenceType&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;explicit&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;instruction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;SCHEMA&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;$schema&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;http://json-schema.org/draft-07/schema#&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;description&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;A single invoice photographed on a desk or dashboard. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Other papers may be visible; use only the invoice.&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;class&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;Invoice&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;type&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;object&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;properties&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;VENDOR_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;S&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&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;Name of the company that issued the invoice, from the invoice header&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;INVOICE_NUMBER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;S&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&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;The invoice number exactly as printed&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;INVOICE_DATE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;S&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&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;Invoice issue date as YYYY-MM-DD. If the invoice says dates &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;are DD/MM/YYYY, convert accordingly&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;DUE_DATE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;S&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&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;Payment due date as YYYY-MM-DD, same date-format rule as INVOICE_DATE&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;CUSTOMER_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;S&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&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;Name in the Bill to or Invoice to block. Not the Deliver to or &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                                     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ship to block&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;SUBTOTAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;S&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;number&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;Subtotal or net amount before tax&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;TAX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;S&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;number&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;Total tax or VAT amount on this invoice&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;INVOICE_TOTAL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;S&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;number&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;Total of this invoice: subtotal plus tax. Not the balance due, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                                     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount due, amount paid or previous balance&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;AMOUNT_DUE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;S&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;number&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;Amount due or balance due, if printed separately from the &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                                  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice total&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="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;bda&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bedrock-data-automation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;region_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;us-west-2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;bda&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_blueprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blueprintName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice-benchmark-total-vs-due&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DOCUMENT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;blueprintStage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LIVE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SCHEMA&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One date problem remained. Both Layout B invoices state "Dates are shown as DD/MM/YYYY". The custom blueprint converted 07/10/2026 correctly on all 8 photos of one invoice, and read 11/08/2026 as November 8 on all 8 photos of the other. Instructions guide the model; they do not guarantee the result.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cropping helps on clutter, fails on the dashboard
&lt;/h3&gt;

&lt;p&gt;Apple Vision's document detection helped on stacks of paper (Textract fields went from 60% to 87.5% right on that scene) and failed on the dashboard scene, where it detected the wrong region with a confidence of about 0.41.&lt;br&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%2Fgnb2uwuu8fvplf6ck5dl.jpg" 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%2Fgnb2uwuu8fvplf6ck5dl.jpg" alt="Apple Vision detects the wrong region on the dashboard scene" width="800" height="812"&gt;&lt;/a&gt;&lt;br&gt;
Using the crop only when the detector is confident kept the gain and removed the loss:&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;CROP_MIN_CONFIDENCE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;image_to_send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cropped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;crop_confidence&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;cropped&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;crop_confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;CROP_MIN_CONFIDENCE&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;original&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this set every dashboard crop scored about 0.41 and every other crop scored 0.855 or more, so the threshold was not tested on borderline cases.&lt;/p&gt;

&lt;p&gt;PaddleOCR's unwarping helped Textract on the cluttered scenes (stack 60% to 70%, receipt on top 72.5% to 82.5%, dashboard 40% to 50% of fields) and made no difference on angled photos. Its orientation step turned 5 upright pages by 90 degrees, and totals stayed at 42.5%.&lt;/p&gt;

&lt;p&gt;textract-field-memory did not help on uncropped photos: no learned template matched any of them. My reading is that the page sits in a different spot in every photo, but I did not test that directly. After cropping, it flagged 46 of 63 wrong fields for review, and also flagged 43 of 257 correct ones. That makes it a review signal, not an extractor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 2: what this means in production
&lt;/h2&gt;

&lt;p&gt;Accuracy does not tell you how much work is left. Two numbers do: how many invoices can be approved without a person, and how many errors slip through.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Auto-approved&lt;/th&gt;
&lt;th&gt;Silent errors&lt;/th&gt;
&lt;th&gt;Cost / 1,000&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nova 2 Lite, validation only&lt;/td&gt;
&lt;td&gt;35 of 40&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;$1.07&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 4.5, validation only&lt;/td&gt;
&lt;td&gt;34 of 40&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;$10.70&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BDA custom blueprint, validation only&lt;/td&gt;
&lt;td&gt;35 of 40&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;$40.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Textract configured, validation only&lt;/td&gt;
&lt;td&gt;34 of 40&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;$10.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Textract configured values, Nova 2 Lite as cross-check&lt;/td&gt;
&lt;td&gt;22 of 40&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;$11.07&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Textract configured values, BDA custom as cross-check&lt;/td&gt;
&lt;td&gt;30 of 40&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;$50.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A silent error is an approved invoice with any of the six key fields wrong. Validation and the cross-check look like this:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;money&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="sh"&gt;"""'&lt;/span&gt;&lt;span class="s"&gt;$1,141.60&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; 1141.6, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-$100.00&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; -100.0, 412.68 -&amp;gt; 412.68.&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;text&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="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&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="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(-?)[$£€]?(\d+(?:\.\d+)?)&lt;/span&gt;&lt;span class="sh"&gt;"&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;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&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="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&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="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="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&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="mi"&gt;2&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;m&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Key fields present and subtotal + tax = total, to the cent.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&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;subtotal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&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;tax&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&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;total&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="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ow"&gt;not&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;f&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;k&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;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendor_name&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;invoice_number&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;invoice_date&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="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.011&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;iso_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Only unambiguous formats. 07/10/2026 could be July or October, so it returns None.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%d&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;%b %d, %Y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&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;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strptime&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;value&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;strip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;pass&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;same&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&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;field&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;da&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;iso_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;iso_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;da&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&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;da&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subtotal&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;tax&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;total&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="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;norm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^a-z0-9]&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="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;x&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;lower&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;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&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;auto_approve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cross_check&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Approve with the first tool&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s values only when both tools pass validation and agree.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;validates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;validates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cross_check&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt;
            &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;same&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&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;k&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;cross_check&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;k&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invoice_number&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;invoice_date&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;subtotal&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;tax&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;total&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;The arithmetic check works well for Textract: none of the 23 Textract photos with a wrong amount passed it. It works poorly for the vision models, for two reasons. Most of their silent errors are in fields the check never looks at: of Nova's 20, the date was wrong on 15 and the invoice number on 7. Some of their money errors also add up. Sonnet read 381.23 + 31.45 = 412.68 on a blurred photo as 391.23 + 31.40 = 422.63, and 6 of the 12 Sonnet photos with a wrong amount passed the check this way.&lt;br&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%2Fdefz6yb6dbl2t90tg90o.jpg" 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%2Fdefz6yb6dbl2t90tg90o.jpg" alt="Sonnet misreads digits and the wrong numbers still add up" width="800" height="728"&gt;&lt;/a&gt;&lt;br&gt;
Agreement routing works, and the order matters. With Textract supplying the values and Nova checking them, 22 invoices were approved and none were wrong. Swapping the roles (Nova values, Textract check) approved the same 22 with 9 silent errors, all of them dates. Textract returns the date as printed, so a DD/MM/YYYY date cannot be compared safely, and Nova's conversion of those dates was its weak spot.&lt;/p&gt;

&lt;p&gt;All 8 of BDA custom's silent errors are the 11/08/2026 date from Result 1.&lt;/p&gt;
&lt;h2&gt;
  
  
  Result 3: real receipts
&lt;/h2&gt;

&lt;p&gt;95 of the 100 CORD receipts have a labelled total.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Total right&lt;/th&gt;
&lt;th&gt;Read 31.000 as 31&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Textract AnalyzeExpense&lt;/td&gt;
&lt;td&gt;97.9% (93 to 99)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 4.5&lt;/td&gt;
&lt;td&gt;91.6% (84 to 96)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Nova 2 Lite&lt;/td&gt;
&lt;td&gt;83.2% (74 to 89)&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BDA, public Receipt blueprint&lt;/td&gt;
&lt;td&gt;76.8% (67 to 84)&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your documents use a dot as the thousands separator, test that case first. It accounts for 20 of BDA's 22 misses and 12 of Nova's 16.&lt;br&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%2Feitt06uq679os2wwbj5u.jpg" 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%2Feitt06uq679os2wwbj5u.jpg" alt="BDA reads 31.000 rupiah as 31" width="800" height="780"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Result 4: a 6-page invoice with 149 line items
&lt;/h2&gt;

&lt;p&gt;Long invoices add problems that single pages do not have: the header and totals sit on different pages, every page has a running subtotal that looks like a total, and a model has to return about 150 rows without dropping or repeating one.&lt;/p&gt;

&lt;p&gt;Textract and BDA read the whole PDF natively, so they need no chunking. For the vision models I tested two ways. The first sends all 6 pages in one call. The second is structure-aware chunking: page 1 is read first, then pages 2 to 6 in parallel, each told the vendor, invoice number and date that page 1 returned. Each page is then checked against its own printed running totals, and each page's brought-forward amount must equal the previous page's carried-forward amount:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decimal&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;x&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;quantize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pages_to_review&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subtotal&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;pages: the model&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s JSON for each page, in order, with brought_forward, carried_forward and
    line_items. subtotal: from the totals block on the last page. Returns the page numbers to review.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nf"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;li&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;amount&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="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;li&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;p&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;line_items&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="p"&gt;[])&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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;brought_forward&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="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&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;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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;carried_forward&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="n"&gt;end&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subtotal&lt;/span&gt;
        &lt;span class="n"&gt;chained&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;chained&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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;carried_forward&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="n"&gt;flagged&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A flagged page is read once more by the other model at 1.5x size. Code, not the model, merges the pages and checks that all lines add up to the subtotal.&lt;/p&gt;

&lt;p&gt;For Textract, the fix is different. StartExpenseAnalysis returns results per page and labels each page's "Page subtotal (carried forward)" as TOTAL with a confidence above 99.9. Take totals from the last page only:&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;last_page_only&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&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;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&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;max&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;f&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;PageNumber&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SummaryFields&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&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;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;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;ExpenseDocuments&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;ExpenseDocuments&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="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;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;ExpenseDocuments&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="nf"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;last&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;pick_fields_multipage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pick_fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;totals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pick_fields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;last_page_only&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;fields&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="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;totals&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;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subtotal&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;tax&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;total&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="n"&gt;fields&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Clean PDF:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;th&gt;Line items right&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Textract async, default&lt;/td&gt;
&lt;td&gt;wrong (balance due)&lt;/td&gt;
&lt;td&gt;149 of 149, plus 5 running-total rows taken as items&lt;/td&gt;
&lt;td&gt;18 s&lt;/td&gt;
&lt;td&gt;$0.06&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Textract async, label filter only&lt;/td&gt;
&lt;td&gt;wrong (a page 3 carried-forward amount)&lt;/td&gt;
&lt;td&gt;149 of 149&lt;/td&gt;
&lt;td&gt;18 s&lt;/td&gt;
&lt;td&gt;$0.06&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Textract async, label filter + totals from last page&lt;/td&gt;
&lt;td&gt;right&lt;/td&gt;
&lt;td&gt;149 of 149&lt;/td&gt;
&lt;td&gt;18 s&lt;/td&gt;
&lt;td&gt;$0.06&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BDA, public Invoice blueprint&lt;/td&gt;
&lt;td&gt;right&lt;/td&gt;
&lt;td&gt;107 of 149&lt;/td&gt;
&lt;td&gt;135 s&lt;/td&gt;
&lt;td&gt;$0.24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BDA, custom blueprint&lt;/td&gt;
&lt;td&gt;right&lt;/td&gt;
&lt;td&gt;not in schema&lt;/td&gt;
&lt;td&gt;37 s&lt;/td&gt;
&lt;td&gt;$0.24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 4.5, whole document&lt;/td&gt;
&lt;td&gt;right&lt;/td&gt;
&lt;td&gt;149 of 149&lt;/td&gt;
&lt;td&gt;87 s&lt;/td&gt;
&lt;td&gt;$0.16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 4.5, page chunks + checksum&lt;/td&gt;
&lt;td&gt;right&lt;/td&gt;
&lt;td&gt;149 of 149&lt;/td&gt;
&lt;td&gt;48 s&lt;/td&gt;
&lt;td&gt;$0.18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nova 2 Lite, whole document&lt;/td&gt;
&lt;td&gt;right&lt;/td&gt;
&lt;td&gt;149 of 149&lt;/td&gt;
&lt;td&gt;38 s&lt;/td&gt;
&lt;td&gt;$0.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nova 2 Lite, page chunks + checksum&lt;/td&gt;
&lt;td&gt;right&lt;/td&gt;
&lt;td&gt;149 of 149&lt;/td&gt;
&lt;td&gt;21 s&lt;/td&gt;
&lt;td&gt;$0.03&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;BDA's public blueprint got every line amount right but returned the wrong quantity on 42 of them, mostly the quantity of a nearby row. Those amounts still add up to the subtotal, so a sum check cannot catch it. A quantity times unit price check per row would. On the clean PDF, chunking did not change accuracy, and it roughly halved latency because the pages run in parallel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Photographed pages&lt;/strong&gt; (flat, angled, blur, folded, low light, and page 6 on a stack of other papers):&lt;br&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%2Fgqqckbgk7dqee0c1y28k.jpg" 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%2Fgqqckbgk7dqee0c1y28k.jpg" alt="he 6 photographed pages" width="800" height="178"&gt;&lt;/a&gt;&lt;br&gt;
| Setup | Total | Line items right | Lines add up to subtotal |&lt;br&gt;
|---|---|---|---|&lt;br&gt;
| Textract async, label filter + totals from last page | right | 144 of 149 | no |&lt;br&gt;
| BDA, public Invoice blueprint | right | 105 of 149 | yes |&lt;br&gt;
| Sonnet 4.5, whole document | wrong (tax misread) | 129 of 149 | no |&lt;br&gt;
| Sonnet 4.5, page chunks + checksum | right | 123 of 149 | no |&lt;br&gt;
| Nova 2 Lite, whole document | right | 127 of 149 | no |&lt;br&gt;
| Nova 2 Lite, page chunks + checksum | right | 136 of 149 | no |&lt;/p&gt;

&lt;p&gt;No setup got every line right on the photos. That is the real result, and it is why the checks matter. Textract and every vision-model setup failed the final "lines add up to subtotal" check, so none of them would be approved silently. BDA's public blueprint passed it while 44 rows did not match on amount and quantity.&lt;/p&gt;

&lt;p&gt;The page checksum is the part I would reuse. It flagged every page that had a wrong line, for both models, and missed none:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Pages with a real error&lt;/th&gt;
&lt;th&gt;Flagged&lt;/th&gt;
&lt;th&gt;Flagged but fine&lt;/th&gt;
&lt;th&gt;Missed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 4.5&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nova 2 Lite&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one false flag per model was page 4. Its lines were right, but the carried-forward amount on the blurred photo of page 3 was misread, so the chain check failed on the next page. The retry was less useful: reading a flagged page again with the other model at 1.5x size fixed 1 of the 7 pages with real errors. On photos, chunking tells you which pages a person should look at. It does not repair them. One more thing to watch: Sonnet's chunked run returned the customer as "Eastside Manufacturing Co.", a name that is not on the invoice ("Example Manufacturing Co." is). The whole-document call read it correctly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Other things worth knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run-to-run variance.&lt;/strong&gt; At temperature 0, both Bedrock models gave the same answer in all 3 runs for about 94% of fields. Total accuracy moved by up to 2.5 points between runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounding boxes from vision models.&lt;/strong&gt; Nova 2 Lite's boxes overlapped the printed value 91.6% of the time. Sonnet 4.5's boxes overlapped 0% as returned. Its y values ran about 1.27 times too large, and dividing y by 4/3 brought 64.4% into overlap. If you need boxes for an audit trail, verify them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Malformed output.&lt;/strong&gt; Sonnet returned JSON with an extra closing brace on 2 of 100 receipts and 1 of 120 invoice calls. Nova returned a malformed reply on 1 receipt and 2 invoice calls. Decode the first JSON object and log when you had to:
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;first_json&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="k"&gt;try&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;JSONDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;raw_decode&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;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Line items on single pages.&lt;/strong&gt; The BDA public blueprint got every line item amount and quantity right on all 40 photos. Textract found 82% of them (88% with the confident crop).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Which setup for which job
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If you need&lt;/th&gt;
&lt;th&gt;Start with&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lowest cost, with a person reviewing&lt;/td&gt;
&lt;td&gt;Nova 2 Lite, around $1 per 1,000. Do not auto-approve on an arithmetic check alone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-approval with few silent errors&lt;/td&gt;
&lt;td&gt;Textract with a label filter and confident crop, cross-checked by a cheap vision model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Line items on single-page invoices&lt;/td&gt;
&lt;td&gt;BDA public Invoice blueprint. On long invoices, check quantity times unit price per row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Line items on long or multi-page invoices&lt;/td&gt;
&lt;td&gt;Textract async with totals from the last page had the most lines right here. Add a page-level running-total checksum to whichever tool you use, so a person reviews only the flagged pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bounding boxes you can show an auditor&lt;/td&gt;
&lt;td&gt;Textract and BDA return a box for every value. I did not measure the accuracy of either; verify any vision-model box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Photos with lots of other papers&lt;/td&gt;
&lt;td&gt;A document crop gated on the detector's confidence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;Prices are on-demand list prices in us-west-2, checked on the &lt;a href="https://aws.amazon.com/textract/pricing/" rel="noopener noreferrer"&gt;Textract&lt;/a&gt; and &lt;a href="https://aws.amazon.com/bedrock/pricing/" rel="noopener noreferrer"&gt;Bedrock&lt;/a&gt; pricing pages: Textract AnalyzeExpense $0.010 per page, BDA custom output $0.040 per page (the rate for blueprints with up to 30 fields; the public Invoice blueprint has 19, the Receipt blueprint 16 and my custom blueprint 9), Claude Sonnet 4.5 $3.30 and $16.50 per million input and output tokens, and Nova 2 Lite $0.33 and $2.75. The model prices are for the &lt;code&gt;us.&lt;/code&gt; profiles used here. The &lt;code&gt;global.&lt;/code&gt; profiles are about 10% cheaper ($3 and $15, $0.30 and $2.50).&lt;/p&gt;

&lt;p&gt;The whole study, including the 6-page runs and three runs I later replaced, cost about $14.18 at list price.&lt;/p&gt;
&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;This is a small study: 5 invoice designs in 2 layouts photographed 40 ways, 100 real receipts from one country, and one 6-page invoice run once per setup. The photo scenes are generated; the dashboard is a drawn background, not a car. ML Kit and VisionKit phone scanners were not tested. The vision models used one prompt with no tuning, while Textract and BDA got one round of configuration.&lt;/p&gt;
&lt;h2&gt;
  
  
  How the benchmark is built
&lt;/h2&gt;

&lt;p&gt;If you want to run the same comparison on your own documents, this is the structure. It has four stages, and each stage writes files the next one reads.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Generate&lt;/td&gt;
&lt;td&gt;Render each invoice from HTML with headless Chrome, and write the exact answer key from the same data&lt;/td&gt;
&lt;td&gt;Invoice PNG and PDF, &lt;code&gt;answer_key.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Photograph&lt;/td&gt;
&lt;td&gt;Warp each invoice onto a background with Pillow (perspective, blur, glare, folds, noise, other papers on top or behind) and record which background papers are in each shot&lt;/td&gt;
&lt;td&gt;40 JPEGs, a per-photo key with the background values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Call&lt;/td&gt;
&lt;td&gt;Send each image to each tool once and save the full raw response with its measured latency&lt;/td&gt;
&lt;td&gt;One JSON file per image per tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Score&lt;/td&gt;
&lt;td&gt;Read the saved responses, apply the configured rules, compare with the answer key&lt;/td&gt;
&lt;td&gt;Tables, per-field outcomes, failure images&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The answer key carries the traps.&lt;/strong&gt; Generating the invoices means the key is exact, and it can list every other amount printed on the page, so a wrong answer can be classified instead of just counted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inv-01"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"layout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"vendor_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bluefin Office Supply Co."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"invoice_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BOS-24817"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"invoice_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-14"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"due_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-13"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"customer_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example Corp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"subtotal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"381.23"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"31.45"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"412.68"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"line_items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Copy paper, A4, 80 gsm (box of 5 reams)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                    &lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unit_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"38.50"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"154.00"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"traps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"amount_paid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"100.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"balance_due"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"312.68"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The background papers (another invoice, a bank statement, receipts, a parking stub) get their own key, and a check at generation time makes sure none of their amounts equals an amount on a main invoice. Otherwise a background value could not be told apart from a trap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every call is made once and saved.&lt;/strong&gt; Scoring then runs from disk, so you can change a rule and re-score in seconds without paying again:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cached_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;case_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Run fn() once and save {&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;latency_s&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;called_at&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;response&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;}; later runs read the file.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results/raw&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;folder&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&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="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="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;record&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;latency_s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
              &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;called_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%dT%H:%M:%SZ&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gmtime&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
              &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;str&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;record&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Each money field gets one of five outcomes.&lt;/strong&gt; This is what turns "79% accurate" into "it picked the amount-paid line":&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;classify_money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pred&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;traps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;background_values&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;correct, trap (another amount on the same invoice), background (an amount on another
    paper in the photo), wrong, or missing.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pred&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;value&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="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;missing&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;value&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;truth&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;correct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="c1"&gt;# "Amount paid" is printed as a negative number, so compare magnitudes for traps.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;traps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&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;trap&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;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;background_values&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;background&lt;/span&gt;&lt;span class="sh"&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;wrong&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dates follow the tool's format. A tool that returns ISO dates must convert correctly, and swapping day and month counts as a trap. A tool that returns the printed text must match the printed text. Line items match only when amount and quantity are both right.&lt;/p&gt;

&lt;p&gt;The real receipts come from the CORD v2 test split on Hugging Face (&lt;code&gt;naver-clova-ix/cord-v2&lt;/code&gt;, CC BY 4.0). Parse the labels and the predictions with the same function, because the labels mix formats ("60.000", "63,000.00", "9.500,00").&lt;/p&gt;

&lt;p&gt;If you run invoices through any of these tools, I would like to hear which failure you hit most: the wrong amount, the wrong date, or the wrong row.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>ocr</category>
    </item>
    <item>
      <title>Spatial Memory vs Correction Memory: I Benchmarked Both Against a Plain LLM</title>
      <dc:creator>Avneet bansal</dc:creator>
      <pubDate>Thu, 05 Nov 2026 04:16:24 +0000</pubDate>
      <link>https://dev.to/avneet_bansal_a65b3f31fc4/spatial-memory-vs-correction-memory-i-benchmarked-both-against-a-plain-llm-3c39</link>
      <guid>https://dev.to/avneet_bansal_a65b3f31fc4/spatial-memory-vs-correction-memory-i-benchmarked-both-against-a-plain-llm-3c39</guid>
      <description>&lt;p&gt;Earlier this year I wrote about two small open-source libraries for document extraction. The first, &lt;a href="https://dev.to/avneet_bansal_a65b3f31fc4/give-your-document-ocr-pipeline-a-memory-59l1"&gt;Give Your Document OCR Pipeline a Memory&lt;/a&gt;, introduced &lt;a href="https://github.com/aws-samples/sample-textract-field-memory" rel="noopener noreferrer"&gt;&lt;code&gt;textract-field-memory&lt;/code&gt;&lt;/a&gt;, which remembers where fields sit on a recurring layout. The second, &lt;a href="https://dev.to/avneet_bansal_a65b3f31fc4/your-llm-keeps-making-the-same-extraction-mistake-heres-how-to-make-it-learn-5bjb"&gt;Your LLM Keeps Making the Same Extraction Mistake&lt;/a&gt;, introduced &lt;a href="https://github.com/aws-samples/sample-prompt-correction-memory" rel="noopener noreferrer"&gt;&lt;code&gt;sample-prompt-correction-memory&lt;/code&gt;&lt;/a&gt;, which turns a human correction into something the pipeline keeps.&lt;/p&gt;

&lt;p&gt;Each post made its own case. Neither one answered the question readers kept asking me: if you can only add one, which one, and what does each actually buy you against a plain LLM?&lt;/p&gt;

&lt;p&gt;So I built a small benchmark that runs both mechanisms against the same document stream, next to a no-memory baseline, and measured them on the same five metrics. The short answer is that they are not competitors. One is a cost lever and the other is a quality lever.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; On 120 synthetic invoices and forms with Claude Haiku 4.5 on Amazon Bedrock: spatial memory cut LLM calls from &lt;strong&gt;600 to 30&lt;/strong&gt; (about 95%) and p50 latency from &lt;strong&gt;3.3 s to a memory read&lt;/strong&gt;. Correction memory left the call count alone but cut the repeated-error rate from &lt;strong&gt;0.185 to 0.012&lt;/strong&gt;. Both raised field accuracy from &lt;strong&gt;0.815 to about 0.99&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Three arms process the identical seeded stream, in the same order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Baseline.&lt;/strong&gt; Plain LLM extraction on every field of every document. No memory. This is the reference for cost and for how often a mistake comes back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;+Spatial memory.&lt;/strong&gt; Models &lt;code&gt;textract-field-memory&lt;/code&gt;. For the first three documents of a type, it calls the model and records where each field sits. Once a field's position is stable within tolerance, it reads the value from the learned region and skips the model call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;+Correction memory.&lt;/strong&gt; Models &lt;code&gt;sample-prompt-correction-memory&lt;/code&gt;. It always calls the model. The first time a field comes back wrong, a simulated reviewer corrects it, and the arm stores the correction as a generalizing transform (for example, "undo the day/month swap"), not as a frozen value. Later documents of that type get the transform applied.&lt;/p&gt;

&lt;p&gt;The dataset is 120 synthetic documents, 60 invoices and 60 forms, five fields each (invoice number, date, vendor, total, tax). Two fields per type carry a deterministic, systematic error, the kind a stateless pipeline repeats forever: a date with day and month swapped, and an amount with a stray leading digit (&lt;code&gt;12.34&lt;/code&gt; read as &lt;code&gt;112.34&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The five metrics were written down in a test plan before any run: field accuracy, LLM calls, token cost, latency, and repeated-error rate. That last one is the share of later occurrences of a field that still show the same error after it was first corrected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;p&gt;Real-model run: Amazon Bedrock, &lt;code&gt;us.anthropic.claude-haiku-4-5-20251001-v1:0&lt;/code&gt;, &lt;code&gt;us-west-2&lt;/code&gt;, seed 42.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Arm&lt;/th&gt;
&lt;th&gt;Field accuracy&lt;/th&gt;
&lt;th&gt;LLM calls&lt;/th&gt;
&lt;th&gt;Calls/doc&lt;/th&gt;
&lt;th&gt;Token cost (modeled)&lt;/th&gt;
&lt;th&gt;p50 latency&lt;/th&gt;
&lt;th&gt;p95 latency&lt;/th&gt;
&lt;th&gt;Repeated-error rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;0.815&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;5.00&lt;/td&gt;
&lt;td&gt;$0.0556&lt;/td&gt;
&lt;td&gt;3,331 ms&lt;/td&gt;
&lt;td&gt;5,592 ms&lt;/td&gt;
&lt;td&gt;0.185&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+Spatial memory&lt;/td&gt;
&lt;td&gt;0.990&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;0.25&lt;/td&gt;
&lt;td&gt;$0.0028&lt;/td&gt;
&lt;td&gt;~0 ms&lt;/td&gt;
&lt;td&gt;156 ms&lt;/td&gt;
&lt;td&gt;0.007&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+Correction memory&lt;/td&gt;
&lt;td&gt;0.988&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;5.00&lt;/td&gt;
&lt;td&gt;$0.0556&lt;/td&gt;
&lt;td&gt;3,218 ms&lt;/td&gt;
&lt;td&gt;4,243 ms&lt;/td&gt;
&lt;td&gt;0.012&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By document type, the baseline scored 0.80 on invoices and 0.83 on forms. Spatial memory reached 0.99 on both. Correction memory reached 1.00 on invoices and 0.98 on forms.&lt;/p&gt;

&lt;p&gt;The cost column is small because the run is small. The ratio is what matters: spatial memory ran at about 5% of the baseline's modeled token cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each number is telling you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Spatial memory is the cost story.&lt;/strong&gt; The 30 calls it made are exactly the warmup: 3 documents, times 5 fields, times 2 document types. After that, every field on every document was a lookup. That is why p50 latency collapses to a memory read and p95 drops to 156 ms (the tail is the warmup documents). On a stable, high-volume layout, the model is doing work the layout already answered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correction memory is the quality story.&lt;/strong&gt; It does not save a single call, because it still runs the model on every field. What it changes is what happens after the first mistake. In the baseline, the date swap and the stray digit come back on every later document, which is where the 0.185 repeated-error rate comes from. With correction memory, each systematic error is paid for once, by one review, and then it stops. The rate drops to 0.012.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They fix different failure modes.&lt;/strong&gt; Spatial memory removes redundant calls on fields the layout pins down. Correction memory removes errors the model keeps making on calls that still happen. A pipeline that has both problems needs both layers, and a combined arm is the obvious next configuration to measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this connects the two earlier posts
&lt;/h2&gt;

&lt;p&gt;The OCR memory post ended with a direct ask: the stress tests there were operational numbers, with no labeled benchmark and no comparison against a baseline. This benchmark is a first step on that. It has ground-truth values, a fixed baseline, and pre-registered metrics.&lt;/p&gt;

&lt;p&gt;The correction memory post described a three-tier design: graduated rules at zero cost, a confidence-gated LLM tier, and few-shot self-healing for low-confidence fields. This benchmark isolates one property of that design, that a correction applied once stops the same mistake from recurring, and measures it on a stream.&lt;/p&gt;

&lt;p&gt;Put together, the two posts describe two separate places a pipeline wastes effort. This post puts numbers on both, side by side.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this benchmark does not show
&lt;/h2&gt;

&lt;p&gt;These caveats matter more than the headline, so read them before quoting a number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The arms model the libraries, they do not import them.&lt;/strong&gt; Each arm is a self-contained reimplementation of the library's core mechanism, so the benchmark runs with no install and no network. The published libraries do more (IoU and center-distance scoring, template identification, drift detection, rule graduation, few-shot retry). Swapping the real libraries into the arms is the next step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The spatial arm's accuracy is close to an upper bound.&lt;/strong&gt; When spatial confidence is high, the arm returns the value at the learned position, and on synthetic data that value is the ground truth. In a real pipeline, that read still depends on the OCR being right in that box. The defensible claim from this arm is the 95% call reduction. The accuracy gain says that on a stable layout, a positional read avoids the model's occasional misread, assuming the OCR is clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The correction arm knows two error patterns.&lt;/strong&gt; Its transform learner recognizes the day/month swap and the stray leading digit, because those are the errors the generator injects. A real correction log is messier, and the &lt;a href="https://dev.to/avneet_bansal_a65b3f31fc4/your-llm-keeps-making-the-same-extraction-mistake-heres-how-to-make-it-learn-5bjb"&gt;first post&lt;/a&gt; covers how the library handles that with few-shot retrieval instead of hand-written transforms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The errors are synthetic.&lt;/strong&gt; In Bedrock mode, the model reads a pre-noised OCR span, so the baseline's errors come from the injected noise model, not from Haiku misreading a clean document. The accuracy and repeated-error deltas are properties of that noise model on this set. Treat them as directional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost is modeled.&lt;/strong&gt; Measured input and output tokens from the Bedrock usage block, times the published Haiku 4.5 rate ($1.00 per 1M input tokens, $5.00 per 1M output tokens). It is not a bill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is small.&lt;/strong&gt; 120 documents, two types, one seed for the real-model run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two mechanisms, in code
&lt;/h2&gt;

&lt;p&gt;The harness is small enough that the core of each arm fits on screen. This is the decision the spatial arm makes for every field:&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;confident&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;memory&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;bbox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;warmup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;True once a (doc_type, field) has a stable learned position.&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;key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;learned_bbox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;memory&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;warmup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;tol&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;learned_bbox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# per field:
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;confident&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field_name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;field_bbox&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;read_region&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field_bbox&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# no LLM call
&lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;# warmup or position moved
&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field_name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;field_bbox&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And this is the correction arm. The important part is that the stored correction is a transform that inverts the systematic error, not the corrected value itself, so it applies to the next document regardless of its specific date or amount:&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;learn_transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Day/month swap: 2026-07-03 read as 2026-03-07
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&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="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&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="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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;ty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;td&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ry&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ty&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;rm&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;td&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;rd&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;tm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;v&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
    &lt;span class="c1"&gt;# Stray leading digit: 12.34 read as 112.34
&lt;/span&gt;    &lt;span class="k"&gt;if&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="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;   &lt;span class="c1"&gt;# not systematic, do not transform
&lt;/span&gt;
&lt;span class="c1"&gt;# per field:
&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_llm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&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;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;corrections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;corrections&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;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# apply what review taught us
&lt;/span&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;truth&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;reviewed&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;corrections&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="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;learn_transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;truth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# one human review
&lt;/span&gt;    &lt;span class="n"&gt;reviewed&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="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;truth&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Bedrock call itself is a single-field Converse request at temperature 0 with &lt;code&gt;maxTokens&lt;/code&gt; 64, asking for the value only. Token counts come from the response &lt;code&gt;usage&lt;/code&gt; block, which is what drives the cost column.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebuilding it yourself
&lt;/h2&gt;

&lt;p&gt;The harness is not public, but nothing in it is hard to rebuild, and the method is the useful part. Generate a few hundred synthetic documents from two or three fixed templates, with ground-truth values and bounding boxes per field. Inject one or two systematic errors per type (a date swap and a stray digit are enough to start). Run every arm over the same seeded stream in the same order, log every field, and compute the metrics from the logs afterwards rather than inside the arms. That last step keeps the arms simple and makes each number auditable against the raw records.&lt;/p&gt;

&lt;p&gt;I also ran the same harness with a deterministic stub in place of the model, with no AWS account, to check the metric math. It showed the same structure as the Bedrock run: the baseline at 0.803 accuracy and a 0.197 repeated-error rate, spatial memory at 30 calls and 0.990 accuracy, and correction memory at 1.000 accuracy with no repeated errors. When the stub and the real model agree on the shape, the shape comes from the mechanism and not from one model's behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this goes next
&lt;/h2&gt;

&lt;p&gt;Three things are on the list. A combined arm, spatial memory first and correction memory on whatever still reaches the model. The real libraries swapped in for the reimplemented mechanisms. And document types where layouts drift, which is where spatial memory should hand back to the model and where &lt;code&gt;textract-field-memory&lt;/code&gt;'s drift detection earns its place.&lt;/p&gt;

&lt;p&gt;If you run document pipelines and have a layout-heavy, correction-heavy workload, I would like to hear which of the two problems costs you more. That answer decides which layer to add first.&lt;/p&gt;

&lt;p&gt;Libraries: &lt;a href="https://github.com/aws-samples/sample-textract-field-memory" rel="noopener noreferrer"&gt;&lt;code&gt;textract-field-memory&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/aws-samples/sample-prompt-correction-memory" rel="noopener noreferrer"&gt;&lt;code&gt;sample-prompt-correction-memory&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>benchmark</category>
    </item>
    <item>
      <title>From Writing the Spec to Approving It: What AI-DLC Looks Like on a Real Build</title>
      <dc:creator>Avneet bansal</dc:creator>
      <pubDate>Fri, 30 Oct 2026 10:19:29 +0000</pubDate>
      <link>https://dev.to/avneet_bansal_a65b3f31fc4/from-writing-the-spec-to-approving-it-what-ai-dlc-looks-like-on-a-real-build-58j3</link>
      <guid>https://dev.to/avneet_bansal_a65b3f31fc4/from-writing-the-spec-to-approving-it-what-ai-dlc-looks-like-on-a-real-build-58j3</guid>
      <description>&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%2Ffa0446039chfscftzjbf.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%2Ffa0446039chfscftzjbf.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;br&gt;
Most AI-assisted development still follows the old split. A human writes the spec, a human writes the code, and the AI helps with autocomplete or generates tests at the end. AI-DLC (the AI-Driven Development Life Cycle) reverses that. The AI drafts the requirements, the design, and the code. The human answers short questions and approves each stage before the next one starts.&lt;/p&gt;

&lt;p&gt;To see what that looks like in practice, I built a small command-line tool with it and then read the record the workflow left behind. This post walks through that record, folder by folder.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI-DLC is public and open source: &lt;a href="https://github.com/awslabs/aidlc-workflows" rel="noopener noreferrer"&gt;awslabs/aidlc-workflows&lt;/a&gt;. The method comes from this &lt;a href="https://aws.amazon.com/blogs/devops/ai-driven-development-life-cycle/" rel="noopener noreferrer"&gt;AWS DevOps blog post&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;My only written input was one sentence. Everything else I did was pick options and approve gates.&lt;/li&gt;
&lt;li&gt;The run produced requirements, four architecture decisions, a unit plan, a traceability file, and 12 tests that passed when I ran them, in about 44 minutes from start to the code approval.&lt;/li&gt;
&lt;li&gt;The human role moves from author to decision-maker. The decisions are still yours, and the record shows where they changed the outcome.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The app
&lt;/h2&gt;

&lt;p&gt;The tool is a Spooky Date Fact Generator. It picks one of 10 spooky dates (or takes &lt;code&gt;--date MM-DD&lt;/code&gt;) and prints a spooky event for that day from Wikipedia's "On this day" feed. It is small on purpose.&lt;/p&gt;

&lt;p&gt;The whole request was this sentence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/aidlc build random date generator with fact around that number
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The flip, in one table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Traditional with AI help&lt;/th&gt;
&lt;th&gt;AI-DLC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Requirements&lt;/td&gt;
&lt;td&gt;Human writes them&lt;/td&gt;
&lt;td&gt;AI drafts them from multiple-choice answers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Design&lt;/td&gt;
&lt;td&gt;Human sketches it&lt;/td&gt;
&lt;td&gt;AI proposes, human picks between options&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code&lt;/td&gt;
&lt;td&gt;Human writes, AI assists&lt;/td&gt;
&lt;td&gt;AI writes against an approved plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;Human or AI at the end&lt;/td&gt;
&lt;td&gt;AI writes them to the stated requirements&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human job&lt;/td&gt;
&lt;td&gt;Author&lt;/td&gt;
&lt;td&gt;Decide, validate, approve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evidence&lt;/td&gt;
&lt;td&gt;Pull request diff&lt;/td&gt;
&lt;td&gt;Audit log, decisions, traceability file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where everything was written
&lt;/h2&gt;

&lt;p&gt;Two locations matter. The generated app sits in its own folder, and the workflow record sits in an &lt;code&gt;aidlc/&lt;/code&gt; folder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/                                  &amp;lt;- the generated tool
  src/spooky-facts.ts      core: dates, fetch, keyword filter, nearest-date search
  src/cli.ts               command line: options, output, exit codes
  test/                    12 tests, offline, with a fake fetch
  package.json, biome.json, tsconfig.json, README.md

aidlc/
  codekb/                             &amp;lt;- 9 docs about the existing repo
  intents/&amp;lt;date&amp;gt;-date-fact-generator/ &amp;lt;- the record for this build
    aidlc-state.md                    stage checklist and settings
    audit/                            every event, decision, and approval
    inception/
      reverse-engineering/            scan of the existing repo
      requirements-analysis/          questions + requirements.md
      domain-design/                  questions + components.md + decisions.md
      units-generation/               unit-of-work files
    construction/u1-spooky-date-cli/code-generation/
      code-generation-plan.md, code-summary.md,
      unit-test-instructions.md, traceability.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each stage writes a &lt;code&gt;-questions.md&lt;/code&gt; file, and your answers sit in the same file as the AI's questions. That makes the decision trail readable without any tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage by stage: what it did and why it helps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Initialization.&lt;/strong&gt; The workflow scaffolded the record folder, scanned the workspace, and classified it as brownfield TypeScript on Bun. This is automatic. It helps because every later stage starts from facts about the repo instead of guesses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reverse Engineering.&lt;/strong&gt; A developer agent scanned the existing repo and wrote nine knowledge-base documents (architecture, technology stack, code structure and others). This matters most in a brownfield repo. The later code followed the repo's Bun, TypeScript, and Biome conventions because the AI had read them first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Requirements Analysis.&lt;/strong&gt; The AI asked six questions, such as where facts come from, what to do when a date has no spooky event, and whether this should be a web page or a command line. Each had lettered options. I answered, and one answer included a free-text note: generate dates that have spooky facts, from 10 such scenarios. The result is &lt;code&gt;requirements.md&lt;/code&gt; with 15 functional and 5 non-functional requirements, each with acceptance criteria and a tag pointing back to the answer that produced it, such as &lt;code&gt;[Q5]&lt;/code&gt;. This stage turns one vague sentence into testable statements before any code exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Domain Design.&lt;/strong&gt; Four more questions produced &lt;code&gt;components.md&lt;/code&gt; and four architecture decision records. The AI recommended splitting the tool into four parts. I chose two. The decision record states that the four-part split was rejected as more structure than needed. This is the clearest example of the validator role, because the human overrode the AI's recommendation and the reason is recorded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Units Generation.&lt;/strong&gt; One question: build as one unit or two. I chose one, approved the plan, and got &lt;code&gt;unit-of-work.md&lt;/code&gt; with boundaries, responsibilities, and constraints. It fixes the blast radius. The unit is limited to &lt;code&gt;apps/date-fact-generator/&lt;/code&gt;, and nothing in the framework folders may change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code Generation.&lt;/strong&gt; The AI wrote a plan, then the code, then the tests. It asked one thing here: Bun was not installed, so should it install Bun or switch to Node. I chose Bun. The &lt;code&gt;code-summary.md&lt;/code&gt; lists files created, decisions made, and deviations from the plan. One deviation worth noting: the AI added word forms such as "dies" and "sank" to the keyword list, because whole-word matching would otherwise miss "Houdini dies". The summary also states that some planned checks were grouped into fewer tests to match the 12-test count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build and Test.&lt;/strong&gt; In the record I read, this stage was still marked in progress, so its summary was not written yet. I ran the checks myself from the app folder: 12 tests passed with 93 assertions, and lint and typecheck were clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the human actually did
&lt;/h2&gt;

&lt;p&gt;Reading the audit log, the human work was this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Typed one sentence to start.&lt;/li&gt;
&lt;li&gt;Approved a recomposed plan. The workflow's planner found the default express plan was missing inputs for later stages, and proposed adding Domain Design and Units Generation while dropping deployment and monitoring.&lt;/li&gt;
&lt;li&gt;Answered 11 multiple-choice questions across three stages, plus two more decisions: build inside the existing repo, and install Bun.&lt;/li&gt;
&lt;li&gt;Approved 5 stage gates: reverse engineering, requirements, domain design, units, and code generation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the flip. Nothing in that list is writing a spec or writing code. All of it is deciding and checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this holds up better than a prompt
&lt;/h2&gt;

&lt;p&gt;Two details make the validation real instead of a rubber stamp.&lt;/p&gt;

&lt;p&gt;First, traceability. &lt;code&gt;traceability.json&lt;/code&gt; maps every requirement ID (FR1.1 through NFR5, 20 in total) to the file that satisfies it. If a requirement has no target, it shows up.&lt;/p&gt;

&lt;p&gt;Second, the record is complete. The audit log holds the timestamp of each question, answer, and approval. A reviewer six months later can see why the design has two components and not four.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this run does not show
&lt;/h2&gt;

&lt;p&gt;This was an express-scope build of a 658-line app, with plan approval, sensors, and learnings switched off by the scope. It shows the shape of the method, not how it behaves on a large system with many units and teams. The Build and Test stage had not completed in the record, and the tests are offline, so only one manual live smoke run, recorded in the code summary, exercised the real Wikipedia feed.&lt;/p&gt;

&lt;p&gt;The questions are only as good as the options offered. A weak option list can steer a hurried approver, so reading the alternatives still matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The useful change is not that the AI writes more code. It is that the human's time moves to the points where judgment counts: what the tool should do, which design to accept, and whether the result meets the requirements. If you want to try it, start with a small brownfield repo and read the &lt;code&gt;-questions.md&lt;/code&gt; files first. They show the method faster than any description.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>aws</category>
      <category>softwaredevelopment</category>
      <category>devops</category>
    </item>
    <item>
      <title>Organization-Wide EC2 DNS Automation with EventBridge and AWS Config Aggregator: One Rule Per Account</title>
      <dc:creator>Avneet bansal</dc:creator>
      <pubDate>Mon, 26 Oct 2026 14:24:48 +0000</pubDate>
      <link>https://dev.to/avneet_bansal_a65b3f31fc4/organization-wide-ec2-dns-automation-with-eventbridge-and-aws-config-aggregator-one-rule-per-2p75</link>
      <guid>https://dev.to/avneet_bansal_a65b3f31fc4/organization-wide-ec2-dns-automation-with-eventbridge-and-aws-config-aggregator-one-rule-per-2p75</guid>
      <description>&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%2F50wivddg0wn8sy9s6wk5.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%2F50wivddg0wn8sy9s6wk5.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://medium.com/@avneetbansal9815/organization-wide-ec2-dns-automation-with-eventbridge-and-aws-config-aggregator-one-rule-per-ad334523f5d8" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;How to detect every EC2 launch and termination across an entire AWS Organization, and turn it into automated DNS registration, without deploying repeated infrastructure in every account.&lt;/p&gt;

&lt;p&gt;I spent longer than I would like to admit staring at this problem before the answer got simple. So if you are wiring up DNS across an AWS Organization right now, maybe this saves you a weekend.&lt;/p&gt;

&lt;p&gt;Here is the short version of what I landed on. I needed near real-time DNS registration for every EC2 instance across a multi-account, multi-region org, feeding an on-prem enterprise DDI system (think Infoblox). The two obvious ways to do it both annoyed me: one puts infrastructure in every account, the other leans on a fragile cross-account API-scraping job.&lt;/p&gt;

&lt;p&gt;What I used instead is a hub-and-spoke event setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each spoke account gets exactly one EventBridge rule. That is the whole footprint.&lt;/li&gt;
&lt;li&gt;One hub account does everything else: a custom event bus, an SQS queue, one Lambda.&lt;/li&gt;
&lt;li&gt;The Lambda fills in the missing details by querying the AWS Config Aggregator, which, and this is the part I almost missed, already has every instance's IP, tags, and state for the entire org. No new data pipeline required.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Onboarding a new account came down to a one-line deploy. Below is the whole thing: the architecture, why I made the calls I made, and the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem (why the single-account version does not scale)
&lt;/h2&gt;

&lt;p&gt;In one account this is a five-minute job. EC2 fires a state-change event, you point an EventBridge rule at a Lambda, done. I have written that Lambda a dozen times.&lt;/p&gt;

&lt;p&gt;Across an organization it falls apart for a few reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Instances come up in dozens of accounts and several regions.&lt;/li&gt;
&lt;li&gt;The people who run DNS are not the people who own the workload accounts, so "just add it to the account" is not really on the table.&lt;/li&gt;
&lt;li&gt;The EC2 state-change event does not include the IP address. You get an instance ID and a state. That is it. Useless for DNS on its own.&lt;/li&gt;
&lt;li&gt;Nobody wants to deploy, version, and babysit a Lambda plus queue plus IAM role in every account just to catch these events.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The usual reason you end up here is a move to an enterprise DDI platform. Every instance that boots needs a DNS record, every instance that dies needs its record cleaned up, org-wide, in near real-time, with as little junk in each account as you can manage.&lt;/p&gt;

&lt;p&gt;So two things really drove the design:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Adding an account should be almost free.&lt;/li&gt;
&lt;li&gt;The IP is not in the event, so I have to look it up, and that lookup cannot be flaky.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The approaches I threw out
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Option A: put the whole stack in every account.&lt;/strong&gt; Rule, queue, Lambda, IAM role, shipped everywhere with StackSets. It works. But now you own N copies of the same code, and every bug fix or IAM tweak is an N-account rollout. I have maintained fleets like that. No thanks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B: a central poller that assumes a role into the source account&lt;/strong&gt; and calls DescribeInstances for the IP. Logic is centralized, which is nice, but you need an assume-role trust in every account and you are paying a cross-account API hop on every single event. More IAM surface, more things to break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option C: hub-and-spoke, enriched from the Config Aggregator.&lt;/strong&gt; This is the one. All the logic sits in the hub. Spokes just forward. And instead of building anything to fetch the IP, the hub asks the Config Aggregator, which is already sitting there aggregating EC2 config (IP, tags, state) from every account and region.&lt;/p&gt;

&lt;p&gt;Option C won for a boring but decisive reason: the hard part, the enrichment data, already existed. I did not have to build it. I just had to notice it was there.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it fits together
&lt;/h2&gt;

&lt;p&gt;Spokes forward EC2 state changes to the hub's custom bus; the hub enriches via the org Config Aggregator, then calls the DDI system. Spokes are dumb, the hub is smart, and the data already exists. That is the whole idea in one line.&lt;/p&gt;

&lt;p&gt;That second hub rule is easy to forget, by the way. If you only listen on the custom bus, the hub account's own instances never get DNS records, because they never leave home to reach the custom bus. Ask me how I know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filling in the blanks
&lt;/h2&gt;

&lt;p&gt;Most single-account tutorials skip this bit, which is exactly the bit that matters at scale. Here is what the event actually gives you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aws.ec2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"detail-type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EC2 Instance State-change Notification"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"instance-id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"i-1234567890"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"running"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An ID and a state. No IP, no tags, no hostname. So the Lambda goes and gets the rest, from the aggregator, not from the source account.&lt;/p&gt;

&lt;p&gt;The nice thing: the aggregator is org-level, so one query resolves an instance no matter which account or region it lives in. No assume-role, no per-account permissions to manage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;Everything here is vendor-neutral and anonymized. Swap the Infoblox call for whatever you actually talk to: Route 53, a CMDB, an IPAM API.&lt;/p&gt;

&lt;h3&gt;
  
  
  The spoke's one and only rule (Terraform)
&lt;/h3&gt;

&lt;p&gt;This is the entire per-account footprint. Ship it with StackSets or a Terraform module.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# --- Deployed in EVERY spoke account/region ---&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_event_rule"&lt;/span&gt; &lt;span class="s2"&gt;"forward_ec2_state"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"forward-ec2-state-to-hub"&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Forward EC2 running/terminated events to the hub account"&lt;/span&gt;
  &lt;span class="nx"&gt;event_bus_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt;

  &lt;span class="nx"&gt;event_pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;source&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"aws.ec2"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="s2"&gt;"detail-type"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"EC2 Instance State-change Notification"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;detail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"running"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"terminated"&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;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_event_target"&lt;/span&gt; &lt;span class="s2"&gt;"to_hub_bus"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudwatch_event_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forward_ec2_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;event_bus_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt;
  &lt;span class="nx"&gt;arn&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:events:${var.region}:${var.hub_account_id}:event-bus/ec2-dns-hub-bus"&lt;/span&gt;
  &lt;span class="nx"&gt;role_arn&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventbridge_cross_account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Minimal role that lets EventBridge PutEvents to the hub bus&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"eventbridge_cross_account"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eventbridge-forward-to-hub"&lt;/span&gt;
  &lt;span class="nx"&gt;assume_role_policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Principal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"events.amazonaws.com"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:AssumeRole"&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="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy"&lt;/span&gt; &lt;span class="s2"&gt;"put_events"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventbridge_cross_account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"events:PutEvents"&lt;/span&gt;
      &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:events:${var.region}:${var.hub_account_id}:event-bus/ec2-dns-hub-bus"&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;h3&gt;
  
  
  The hub's custom bus, with an org-scoped resource policy
&lt;/h3&gt;

&lt;p&gt;This policy is the thing that makes "one rule per spoke" actually work: any account in the org can publish, and the hub needs zero per-account config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# --- Deployed ONCE in the hub account ---&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_event_bus"&lt;/span&gt; &lt;span class="s2"&gt;"hub"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ec2-dns-hub-bus"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_event_bus_policy"&lt;/span&gt; &lt;span class="s2"&gt;"allow_org"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;event_bus_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudwatch_event_bus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Sid&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AllowOrgAccountsToPutEvents"&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Principal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"events:PutEvents"&lt;/span&gt;
      &lt;span class="nx"&gt;Resource&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudwatch_event_bus&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
      &lt;span class="nx"&gt;Condition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;StringEquals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s2"&gt;"aws:PrincipalOrgID"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_org_id&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not skip the &lt;code&gt;aws:PrincipalOrgID&lt;/code&gt; condition. It scopes that &lt;code&gt;Principal = "*"&lt;/code&gt; down to your organization instead of, you know, the entire internet. It is the one line standing between "elegant pattern" and "open event bus."&lt;/p&gt;

&lt;h3&gt;
  
  
  Rules, queue, and Lambda wiring (hub)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_sqs_queue"&lt;/span&gt; &lt;span class="s2"&gt;"events"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ec2-dns-events"&lt;/span&gt;
  &lt;span class="nx"&gt;visibility_timeout_seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;
  &lt;span class="nx"&gt;redrive_policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;deadLetterTargetArn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sqs_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dlq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
    &lt;span class="nx"&gt;maxReceiveCount&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_sqs_queue"&lt;/span&gt; &lt;span class="s2"&gt;"dlq"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ec2-dns-events-dlq"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Rule on the custom bus (events from spokes)&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_event_rule"&lt;/span&gt; &lt;span class="s2"&gt;"hub_custom"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ec2-state-custom-bus"&lt;/span&gt;
  &lt;span class="nx"&gt;event_bus_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudwatch_event_bus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;event_pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;source&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"aws.ec2"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="s2"&gt;"detail-type"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"EC2 Instance State-change Notification"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;detail&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"running"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"terminated"&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;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_event_target"&lt;/span&gt; &lt;span class="s2"&gt;"custom_to_sqs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudwatch_event_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hub_custom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;event_bus_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudwatch_event_bus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;arn&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sqs_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Second rule on the default bus catches the hub account's OWN instances&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_event_rule"&lt;/span&gt; &lt;span class="s2"&gt;"hub_default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ec2-state-default-bus"&lt;/span&gt;
  &lt;span class="nx"&gt;event_bus_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt;
  &lt;span class="nx"&gt;event_pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;source&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"aws.ec2"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="s2"&gt;"detail-type"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"EC2 Instance State-change Notification"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;detail&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"running"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"terminated"&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;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_event_target"&lt;/span&gt; &lt;span class="s2"&gt;"default_to_sqs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudwatch_event_rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hub_default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;arn&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sqs_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lambda_event_source_mapping"&lt;/span&gt; &lt;span class="s2"&gt;"sqs_to_lambda"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;event_source_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sqs_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;function_name&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lambda_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dns_registrar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;batch_size&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The enrichment Lambda (Python)
&lt;/h3&gt;

&lt;p&gt;This is the heart of it: resolve the instance against the Config Aggregator, then act on the state.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;

&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;AGGREGATOR_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;OrganizationConfigAggregator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_instance_details&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance_id&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;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Resolve IP, tags, account, and region from the org Config Aggregator.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;expression&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;SELECT resourceId, accountId, awsRegion, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;configuration.privateIpAddress, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;configuration.publicIpAddress, tags &lt;/span&gt;&lt;span class="sh"&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;WHERE resourceId = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="si"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AND resourceType = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AWS::EC2::Instance&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_aggregate_resource_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Expression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;expression&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ConfigurationAggregatorName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;AGGREGATOR_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&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;Results&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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;results&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;detail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;detail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;instance_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instance-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_instance_details&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance_id&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;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Terminated instances may already be gone from the aggregator;
&lt;/span&gt;            &lt;span class="c1"&gt;# fall back to event data for deregistration.
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;terminated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;deregister_dns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;details&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;configuration&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;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;privateIpAddress&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="n"&gt;details&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;configuration&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;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;publicIpAddress&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;running&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;register_dns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;details&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;tags&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="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;terminated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;deregister_dns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance_id&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;register_dns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Replace with your Infoblox / Route 53 / IPAM call.
&lt;/span&gt;    &lt;span class="c1"&gt;# Infoblox example: POST to the WAPI record:a endpoint.
&lt;/span&gt;    &lt;span class="nf"&gt;print&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;REGISTER &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ip&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;def&lt;/span&gt; &lt;span class="nf"&gt;deregister_dns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&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;DEREGISTER &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;instance_id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing worth calling out: that &lt;code&gt;SELECT&lt;/code&gt; against the aggregator resolves a resource across every account and region in a single call. That is the capability that collapses the whole cross-account enrichment headache into a one-liner. I keep coming back to it for other problems now.&lt;/p&gt;

&lt;p&gt;And notice the fallback in the handler. A terminated instance has often already dropped out of the aggregator by the time you look, so you cannot rely on the lookup for deregistration. You fall back to the instance ID from the event. Small thing, but it will bite you in testing if you miss it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why there is an SQS queue in the middle
&lt;/h2&gt;

&lt;p&gt;You can target the Lambda straight from the rule, and for a demo I would. In production I put SQS in between on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A bulk launch, a scale-out, a StackSet rollout, can throw hundreds of events at you at once. SQS soaks that up and lets the Lambda drain at its own pace instead of getting hammered.&lt;/li&gt;
&lt;li&gt;When Infoblox or the aggregator has a bad minute, messages retry and eventually land in a DLQ you can actually look at. Nothing quietly disappears.&lt;/li&gt;
&lt;li&gt;Batching. Ten events per invocation, fewer cold starts, lower cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Decoupling ingest from processing is most of what separates "worked in the demo" from "still working at 2am during a mass launch."&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually came out of it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Detection and DNS updates happened within seconds, across the whole org.&lt;/li&gt;
&lt;li&gt;Adding an account or region was a single forwarding rule. No Lambda, no queue, no Config setup per account.&lt;/li&gt;
&lt;li&gt;Registration and cleanup were fully automated across the instance lifecycle, nobody touching DNS by hand.&lt;/li&gt;
&lt;li&gt;Cost tracked instance activity, not the clock, because there is no idle compute.&lt;/li&gt;
&lt;li&gt;The shape is reusable. Same hub-spoke plus aggregator-enrichment works for CMDB sync, security tagging, IPAM updates, anything org-wide and event-driven.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part worth stealing
&lt;/h2&gt;

&lt;p&gt;Take Infoblox out of the picture and you are left with a pattern I now reach for often:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Fan org-wide events into one hub, then enrich them from a data source the organization already has, instead of building a new one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two moves do the work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A custom event bus with an &lt;code&gt;aws:PrincipalOrgID&lt;/code&gt; resource policy gives you cross-account fan-in and a one-rule spoke footprint.&lt;/li&gt;
&lt;li&gt;The Config Aggregator, used as an enrichment lookup, resolves any resource in any account or region without an assume-role fan-out.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the next time you are about to copy the same Lambda into 40 accounts, stop and ask whether a fan-in plus an aggregator you already run could do it with one rule per account instead. Usually it can.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Honestly, none of the individual pieces here are clever. Cross-account EventBridge is documented. Config aggregators are documented. The win was in composing them so the per-account cost dropped to near zero and the enrichment data was something we already had lying around. That is where most of the good architecture calls seem to live, not in the shiny new component, but in the one you talked yourself out of building.&lt;/p&gt;

&lt;p&gt;If you end up adapting the enrichment step to Route 53, a CMDB, or your own IPAM, I am happy to talk through it in the comments.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>serverless</category>
      <category>automation</category>
    </item>
    <item>
      <title>Kubernetes Security Is Three Problems, Not One: Admission, Identity, Runtime</title>
      <dc:creator>Avneet bansal</dc:creator>
      <pubDate>Thu, 22 Oct 2026 03:11:34 +0000</pubDate>
      <link>https://dev.to/avneet_bansal_a65b3f31fc4/kubernetes-security-is-three-problems-not-one-admission-identity-runtime-22f7</link>
      <guid>https://dev.to/avneet_bansal_a65b3f31fc4/kubernetes-security-is-three-problems-not-one-admission-identity-runtime-22f7</guid>
      <description>&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%2Fwe13j7s8q81m2v4uqw4k.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%2Fwe13j7s8q81m2v4uqw4k.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;br&gt;
Kubernetes security failures usually do not look like network intrusions. An attacker who gets code execution inside a container, through an application bug, a poisoned dependency, or a policy gap that let a privileged pod schedule, is already inside the perimeter the firewall was watching. From there the firewall verified nothing: which secrets the process can reach, which syscalls it can make, whether the running image is even the one you signed.&lt;/p&gt;

&lt;p&gt;That is the useful reframe. Kubernetes security is not one problem at the boundary. It is three problems inside it, and each one needs its own control that keeps working when the others fail:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Admission&lt;/strong&gt; decides what is allowed to deploy, and with what configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity&lt;/strong&gt; decides what each running pod is allowed to reach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime&lt;/strong&gt; watches what a pod actually does, which is often not what its manifest claimed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of the individual tools below are new, and none of this is a new technique. What is worth sharing is how the layers fit together, so a failure in any one of them does not open an exploitable gap. The model maps cleanly onto NIST SP 800-207 (zero trust) and NIST SP 800-190 (container security), and you can hold it to the CIS Kubernetes Benchmark as a measurable floor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: admission, decide what gets in
&lt;/h2&gt;

&lt;p&gt;Start with what Kubernetes gives you for free. Pod Security Admission (PSA) enforces security profiles per namespace with no external tooling. The Restricted profile blocks host namespace sharing, privilege escalation, root contexts, and unsafe seccomp settings. A practical split is Restricted for externally facing namespaces and Baseline for internal platform namespaces with documented exceptions.&lt;/p&gt;

&lt;p&gt;PSA is a floor, not a scalpel. It enforces at the namespace level, so it cannot say "this specific Deployment must run as UID 1000 with this seccomp profile." That is where &lt;strong&gt;Kyverno&lt;/strong&gt; comes in. Its policies are plain YAML, they live in the same GitOps repo as your manifests, and they go through the same pull request review as any other change. Four rule types cover most needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;validate&lt;/code&gt; rejects non-compliant resources (for example, require every image to come from an approved ECR registry).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mutate&lt;/code&gt; corrects omissions automatically, which turns "you forgot a security context" from an admission failure into a silent fix.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;generate&lt;/code&gt; creates dependent resources on creation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;verifyImages&lt;/code&gt; enforces signature checks at admission time (more on that below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For constraint logic that depends on the state of &lt;em&gt;other&lt;/em&gt; cluster resources, or cross-namespace rules, &lt;strong&gt;OPA Gatekeeper&lt;/strong&gt; and Rego express things Kyverno's YAML cannot. Running both is a reasonable choice: Kyverno for the common case, Gatekeeper where you need Rego's relational power.&lt;/p&gt;

&lt;p&gt;One rollout lesson worth stating plainly: both tools have an audit mode, and you should use it first. Measure violations with enforcement off, remediate systematically, then switch to enforce. Flipping straight to enforcement is how a security rollout turns into a production incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: identity, kill the static credential
&lt;/h2&gt;

&lt;p&gt;A static AWS key in a ConfigMap or an environment variable is a persistent risk with a wide blast radius. &lt;strong&gt;IRSA&lt;/strong&gt; (IAM Roles for Service Accounts) removes the category entirely. The AWS SDK in a pod annotated with an IRSA service account exchanges a short-lived, audience-scoped JWT for temporary IAM credentials through STS. Nothing static is created, stored, or rotated. The IAM trust policy validates the token's subject claim against a specific service account and namespace, so a compromised pod in one namespace cannot assume another namespace's role.&lt;/p&gt;

&lt;p&gt;The payoff is blast-radius reduction you can describe concretely. Without IRSA, a compromised pod inherits whatever the node's instance profile can reach. With IRSA, it is limited to that one pod's scoped role.&lt;/p&gt;

&lt;p&gt;Application secrets need a parallel mechanism, and that is the &lt;strong&gt;ExternalSecrets Operator&lt;/strong&gt;. It syncs values from AWS Secrets Manager or Parameter Store into Kubernetes Secrets and refreshes them on the source's rotation schedule. When Secrets Manager rotates a database password, the new value propagates without a deployment. IRSA plus ExternalSecrets together removes every static credential: cloud access through IRSA, application secrets through ESO, nothing hardcoded in a Helm values file.&lt;/p&gt;

&lt;p&gt;Scaling per-workload IAM roles across hundreds of workloads is the part teams tend to defer. Attribute-based access control with IAM permission boundaries makes it tractable: a role that expresses "this service account, in this namespace, for this environment" can be generated from workload metadata instead of hand-authored each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: runtime, watch what actually happens
&lt;/h2&gt;

&lt;p&gt;Admission decides what can deploy. Identity decides what it can reach. Neither sees what a pod does after it is running. &lt;strong&gt;Falco&lt;/strong&gt; does, using eBPF probes at the kernel level to observe every syscall from every container on the node. A shell spawned in a container whose image has no shell in its approved process list raises an alert, regardless of how the compromise happened or whether the attacker edited the deployment manifest. Because eBPF sits below the container runtime, it keeps working even if the runtime is tampered with, and it needs no change to the image or the application.&lt;/p&gt;

&lt;p&gt;Two practical notes. First, uniform rules across every workload generate false positives that erode trust in the alerts. A data-processing pod that legitimately reads many files will trip rules tuned for an application server. Per-workload rule sets, stored in Git next to the manifests, let you tighten baselines for payment or secret-handling services and loosen them for CI runners. Second, route Falco alerts into a SIEM so individual detections become correlated events. A shell-spawn alert followed within seconds by an outbound connection to an unknown IP is a pattern a correlation engine can act on, including automated network-policy quarantine of the affected pod.&lt;/p&gt;

&lt;p&gt;Plan for a calibration period. Default Falco rules produce false-positive volume that takes iterative tuning, with both security and application knowledge, before the layer runs at production-grade sensitivity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layer most teams skip: supply chain
&lt;/h2&gt;

&lt;p&gt;An image that passes admission is trusted to be what its manifest says, but only if the reference is cryptographically verified. Tags are mutable. Someone can overwrite the content behind a trusted tag and the cluster will deploy it under the same name, which bypasses every admission policy that checks the image's &lt;em&gt;properties&lt;/em&gt; rather than its &lt;em&gt;identity&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cosign&lt;/strong&gt; signs images at the registry, and Kyverno &lt;code&gt;verifyImages&lt;/code&gt; rules reject anything at admission that is not signed by an approved identity. That closes the tag-mutability gap. &lt;strong&gt;SLSA&lt;/strong&gt; provenance is the next step up: signing proves an image came from your build system, provenance proves what went into that build (source commit, build platform, resolved dependencies). A Software Bill of Materials attached as an attestation then lets you answer "which running images contain this newly disclosed CVE?" by querying the SBOM store instead of inspecting containers one by one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;The point of three independent layers is that no single failure is fatal. A policy gap that lets a bad pod through is still caught by scoped identity and runtime monitoring. A runtime compromise still cannot reach credentials the pod's IAM role never granted. A swapped image is rejected before it schedules.&lt;/p&gt;

&lt;p&gt;If you are starting from scratch, a reasonable order is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Turn on Pod Security Admission at Baseline or Restricted. Free, immediate.&lt;/li&gt;
&lt;li&gt;Add Kyverno in audit mode, learn your violations, then enforce.&lt;/li&gt;
&lt;li&gt;Move workloads to IRSA and ExternalSecrets to delete static credentials.&lt;/li&gt;
&lt;li&gt;Deploy Falco, then budget real time to tune per-workload rules.&lt;/li&gt;
&lt;li&gt;Add Cosign signing and verifyImages, then SLSA provenance and SBOMs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The honest tradeoff is maintenance. Policy-as-code needs upkeep as Kubernetes APIs and workloads change, and Falco rule tuning is ongoing work. The reason to pay that cost is coverage no single control gives you: admission, identity, and runtime failures are different problems, and defending only one of them leaves the other two open.&lt;/p&gt;

&lt;h3&gt;
  
  
  Further reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;NIST SP 800-207, Zero Trust Architecture.&lt;/li&gt;
&lt;li&gt;NIST SP 800-190, Application Container Security Guide.&lt;/li&gt;
&lt;li&gt;CIS Kubernetes Benchmark.&lt;/li&gt;
&lt;li&gt;Kyverno, OPA Gatekeeper, Falco, and Sigstore/Cosign project docs.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;If you run this in production, I would be interested in which layer gave you the most trouble to operationalize. For me it is runtime rule tuning. Drop a comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>aws</category>
      <category>devops</category>
    </item>
    <item>
      <title>A Field Guide to Beating Amazon Bedrock Throttling</title>
      <dc:creator>Avneet bansal</dc:creator>
      <pubDate>Tue, 20 Oct 2026 20:45:01 +0000</pubDate>
      <link>https://dev.to/avneet_bansal_a65b3f31fc4/a-field-guide-to-beating-amazon-bedrock-throttling-1b9g</link>
      <guid>https://dev.to/avneet_bansal_a65b3f31fc4/a-field-guide-to-beating-amazon-bedrock-throttling-1b9g</guid>
      <description>&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%2Fdnku1c3qv1ochqkmp4qk.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%2Fdnku1c3qv1ochqkmp4qk.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you run real LLM traffic through Amazon Bedrock, throttling is not an edge case. It is the normal operating condition at scale. A &lt;code&gt;ThrottlingException&lt;/code&gt; or &lt;code&gt;TooManyRequestsException&lt;/code&gt; is Bedrock telling you that your request rate or token rate crossed a quota for that model in that region. The fix is rarely one setting. It is a layered defense: understand the quota, retry intelligently, cap concurrency, degrade gracefully, handle timeouts, and know what to escalate to AWS.&lt;/p&gt;

&lt;p&gt;This is the set of strategies I run across production LLM workloads, written as a generic guide. No workload specifics, just the patterns and when each one earns its place.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Know your quota first: check Service Quotas, learn which limits are adjustable (soft) and which are not (hard).&lt;/li&gt;
&lt;li&gt;Retry with exponential backoff and full jitter on throttling errors. This is the floor, not the ceiling.&lt;/li&gt;
&lt;li&gt;Cap concurrency so you stop generating load the quota cannot absorb.&lt;/li&gt;
&lt;li&gt;Add a circuit breaker so a throttle storm backs off instead of hammering.&lt;/li&gt;
&lt;li&gt;Handle timeouts separately: shrink the batch for model timeouts, tune and retry for client read timeouts.&lt;/li&gt;
&lt;li&gt;Use cross-region inference profiles to spread token demand across regions.&lt;/li&gt;
&lt;li&gt;Reserve provisioned throughput only when on-demand plus the above is not enough.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step one: know the quota you are hitting
&lt;/h2&gt;

&lt;p&gt;Before writing a line of retry code, find the actual limit. Bedrock quotas are per model, per region, and split by type: tokens per minute (TPM), requests per minute (RPM), and for some models separate input/output token quotas.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/quotas.html" rel="noopener noreferrer"&gt;Quotas for Amazon Bedrock&lt;/a&gt; — the overview, including that default quotas vary by account age, usage history, and region.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/quotas-runtime.html" rel="noopener noreferrer"&gt;Quotas for the bedrock-runtime endpoint&lt;/a&gt; — the runtime invoke limits you hit at inference time. View them in the Service Quotas console under Amazon Bedrock.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/scaling-throughput-best-practices.html" rel="noopener noreferrer"&gt;Scaling and throughput best practices&lt;/a&gt; — how per-model TPM and RPM quotas interact.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Soft limits vs hard limits
&lt;/h3&gt;

&lt;p&gt;This distinction decides your whole strategy.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Soft limits (adjustable).&lt;/strong&gt; In the &lt;a href="https://docs.aws.amazon.com/general/latest/gr/bedrock.html" rel="noopener noreferrer"&gt;Amazon Bedrock endpoints and quotas&lt;/a&gt; table these are marked &lt;code&gt;Adjustable: Yes&lt;/code&gt;. You can raise them. Request an increase through &lt;a href="https://docs.aws.amazon.com/servicequotas/latest/userguide/request-quota-increase.html" rel="noopener noreferrer"&gt;Service Quotas&lt;/a&gt;; smaller increases often auto-approve, larger ones route to AWS Support for review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard limits (not adjustable).&lt;/strong&gt; Marked &lt;code&gt;Adjustable: No&lt;/code&gt;. No amount of asking moves these. Your only lever is architectural: reduce demand, spread it across regions, or change the access pattern.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical rule: if the limit is soft and you are consistently near it, file the increase and also build the defenses below, because the increase takes time and never makes throttling impossible. If the limit is hard, skip the request entirely and go straight to architecture.&lt;/p&gt;

&lt;p&gt;One often-missed detail: Bedrock reserves TPM quota at the start of a request based on input tokens plus &lt;code&gt;maxTokens&lt;/code&gt;. If you leave &lt;code&gt;maxTokens&lt;/code&gt; unset, it can default to the model maximum and reserve far more quota than your call actually needs, so you throttle earlier than your real token use suggests. Set &lt;code&gt;maxTokens&lt;/code&gt; to a realistic ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer one: retry with backoff and jitter
&lt;/h2&gt;

&lt;p&gt;The baseline defense. Catch the throttling error codes (&lt;code&gt;ThrottlingException&lt;/code&gt;, &lt;code&gt;TooManyRequestsException&lt;/code&gt;), wait, and try again. Two details separate a good retry from a harmful one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exponential backoff.&lt;/strong&gt; Each attempt waits longer: &lt;code&gt;base * 2^attempt&lt;/code&gt;, capped at a ceiling. This gives the quota window time to refill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full jitter.&lt;/strong&gt; Randomize the delay inside that window. Without jitter, every throttled caller retries at the same instant and you get a synchronized stampede that throttles again. With jitter, retries spread out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A compact 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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;botocore.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ClientError&lt;/span&gt;

&lt;span class="n"&gt;THROTTLE&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;ThrottlingException&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;TooManyRequestsException&lt;/span&gt;&lt;span class="sh"&gt;"&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;call_with_backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&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="n"&gt;base&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;15.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_retries&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&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;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ClientError&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="n"&gt;code&lt;/span&gt; &lt;span class="o"&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;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;Error&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;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;Code&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="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;THROTTLE&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt;
            &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&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;delay&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# full jitter
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A note on the boto3 client's own retries. Boto3 has a built-in retry mode (&lt;code&gt;standard&lt;/code&gt; or &lt;code&gt;adaptive&lt;/code&gt;) you set via &lt;code&gt;botocore.config.Config(retries=...)&lt;/code&gt;. You can lean on it, or disable it (&lt;code&gt;max_attempts=0&lt;/code&gt;) and own the retry loop yourself. Owning it is worth the effort when you want the backoff, jitter, and error classification to match the rest of your system rather than two retry layers fighting each other.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same retry in a Step Functions workflow
&lt;/h3&gt;

&lt;p&gt;If your Bedrock calls run inside a Step Functions state machine, you do not write the backoff loop by hand. You declare it on the task's &lt;code&gt;Retry&lt;/code&gt; block and the service applies exponential backoff with jitter for you. &lt;a href="https://docs.aws.amazon.com/step-functions/latest/dg/concepts-error-handling.html" rel="noopener noreferrer"&gt;Step Functions error handling&lt;/a&gt; added a &lt;code&gt;JitterStrategy&lt;/code&gt; field so wait times are spread instead of synchronized, the same reason full jitter matters in code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"InvokeBedrock"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Task"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:states:::bedrock:invokeModel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Retry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ErrorEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ThrottlingException"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TooManyRequestsException"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"IntervalSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"MaxAttempts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"BackoffRate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"MaxDelaySeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"JitterStrategy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FULL"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Catch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ErrorEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"States.ALL"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Next"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HumanReview"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Next"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NextStep"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fields map onto the code version: &lt;code&gt;IntervalSeconds&lt;/code&gt; is the base delay, &lt;code&gt;BackoffRate&lt;/code&gt; is the exponential multiplier, &lt;code&gt;MaxDelaySeconds&lt;/code&gt; is the cap, &lt;code&gt;MaxAttempts&lt;/code&gt; is the retry count, and &lt;code&gt;JitterStrategy: FULL&lt;/code&gt; randomizes each wait. The &lt;code&gt;Catch&lt;/code&gt; routes to a fallback once retries are exhausted, for example a human-review step, so an exhausted retry degrades gracefully instead of failing the whole execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer two: cap concurrency
&lt;/h2&gt;

&lt;p&gt;Retries handle the request that already failed. Concurrency caps stop you from generating more load than the quota can absorb in the first place. If 50 workers all call the same model at once, you will throttle no matter how clever the backoff is.&lt;/p&gt;

&lt;p&gt;Put a ceiling on in-flight Bedrock calls. In async Python a semaphore does it:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="n"&gt;semaphore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# max concurrent Bedrock calls
&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;bounded_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;semaphore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upstream of the application, cap concurrency at the infrastructure layer too. If documents arrive as events, put them on an SQS queue and let the SQS-to-Lambda event source mapping enforce a &lt;code&gt;maximum_concurrency&lt;/code&gt;. Work waits in the queue until a slot frees, with no polling and no custom counters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lambda_event_source_mapping"&lt;/span&gt; &lt;span class="s2"&gt;"sqs_to_worker"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;event_source_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sqs_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;work&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;function_name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lambda_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;batch_size&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;scaling_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;maximum_concurrency&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Match the concurrency ceiling to the quota, not to how many workers you could theoretically run. The right number is the one that keeps steady-state token rate under the TPM limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer three: a circuit breaker for throttle storms
&lt;/h2&gt;

&lt;p&gt;Backoff and concurrency caps handle normal pressure. A sustained throttle storm needs something that steps back harder. A circuit breaker watches the throttle rate and, once it crosses a threshold inside a time window, pauses all calls for a cooldown, then resumes at reduced concurrency before restoring full throughput.&lt;/p&gt;

&lt;p&gt;The state machine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Closed&lt;/strong&gt; (normal): calls flow, throttles are counted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open&lt;/strong&gt; (tripped): after N throttles in the window, pause all calls for a cooldown and halve the concurrency limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Half-open&lt;/strong&gt; (recovering): after the cooldown, resume at the reduced limit; if clean for a while, restore full concurrency; if it throttles again, reopen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This converts a feedback loop that makes throttling worse (retry, throttle, retry, throttle) into one that actively sheds load until Bedrock recovers. It pairs with the concurrency limiter: the breaker is what turns the limit down and back up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer four: handle timeouts, they are not throttling
&lt;/h2&gt;

&lt;p&gt;Throttling rarely travels alone, and the loudest neighbor is the timeout. There are two distinct kinds, and they want opposite fixes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Model timeout: shrink the work
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;ModelTimeoutException&lt;/code&gt; means the request itself was too large to finish in the model's time budget. It is not a rate problem, so retrying the same payload will time it out again. The fix is to reduce the work: halve the batch or chunk the input, then retry smaller.&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call_shrinking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&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="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_retries&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&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="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&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;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;Error&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;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;Code&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="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ModelTimeoutException&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt;
            &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;  &lt;span class="c1"&gt;# halve and retry
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Client timeout: tune the budget, then retry the transient ones
&lt;/h3&gt;

&lt;p&gt;A client-side &lt;code&gt;ReadTimeoutError&lt;/code&gt; or &lt;code&gt;ConnectTimeoutError&lt;/code&gt; is different again: the SDK gave up waiting before the model answered. Large prompts and long generations routinely exceed the boto3 default read timeout, which produces timeouts that look like failures but are really an impatient client.&lt;/p&gt;

&lt;p&gt;Two moves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Set a realistic &lt;code&gt;read_timeout&lt;/code&gt;&lt;/strong&gt; on the boto3 client so a slow-but-valid response is not cut off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the transient connection timeouts as retryable&lt;/strong&gt;, alongside throttling, rather than letting them bubble up as hard failures.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;botocore.config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bedrock-runtime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retries&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;max_attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;RETRYABLE&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;ThrottlingException&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;TooManyRequestsException&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;# also retry ReadTimeoutError / ConnectTimeoutError by exception class name
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Classify before reacting. A throttle wants backoff, a model timeout wants a smaller batch, a client timeout wants a longer budget plus a retry, and a &lt;code&gt;ValidationException&lt;/code&gt; wants no retry at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer five: spread demand with cross-region inference profiles
&lt;/h2&gt;

&lt;p&gt;Quotas are per region. A &lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles.html" rel="noopener noreferrer"&gt;cross-region inference profile&lt;/a&gt; lets a single on-demand call be served from one of several regions, so your token demand is spread across each region's quota instead of piling onto one. For on-demand workloads this is often the highest-leverage change: no capacity to buy, and the effective ceiling goes up because you are drawing from multiple regional pools.&lt;/p&gt;

&lt;p&gt;It is not infinite headroom, and it does not change a hard per-model limit in a single region, but for spiky on-demand traffic it buys real room.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer six: provisioned throughput, only when you need it
&lt;/h2&gt;

&lt;p&gt;If on-demand plus everything above still throttles under predictable heavy load, Bedrock offers &lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/prov-throughput.html" rel="noopener noreferrer"&gt;Provisioned Throughput&lt;/a&gt;: reserved capacity (model units) with a guaranteed token rate, bought by commitment. It removes throttling for that reserved slice because the capacity is yours.&lt;/p&gt;

&lt;p&gt;Treat it as the last layer, not the first. It costs whether or not you use it, so it only makes sense for steady, predictable volume. For bursty or unpredictable traffic, retries, concurrency caps, and cross-region profiles on on-demand are usually the better economics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning in practice
&lt;/h2&gt;

&lt;p&gt;The layers above are the toolbox. How hard you turn each knob depends on the workload, and a couple of patterns are worth stating plainly.&lt;/p&gt;

&lt;p&gt;Match the aggressiveness of retries to the cost of a lost call. Heavy, expensive calls justify more retry attempts, a higher backoff ceiling, and tighter concurrency, because losing one is costly. High-volume, cheaper work is better served by an upstream queue that caps concurrency and a workflow-level self-retry that ends in a manual-review fallback, keeping the per-call retries light. There is no single correct number; the right setting is the one that keeps steady-state token rate under the quota for that workload.&lt;/p&gt;

&lt;p&gt;Two caveats that are easy to get wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A "fallback model" can quietly resolve to the same model.&lt;/strong&gt; If both the primary and fallback point at the same model id through shared config, the fallback functions as extra retries against one model, not as model diversity. Calling it a fallback overstates what it does until the config points at a genuinely different model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry, backoff, and concurrency are not the same as reserved capacity.&lt;/strong&gt; A defense built entirely from retries, backoff, concurrency caps, and on-demand cross-region inference profiles has no reserved-capacity floor under a true spike. That can be a deliberate on-demand posture, but name it as one, because on-demand plus retries still has a ceiling.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How to decide what to reach for
&lt;/h2&gt;

&lt;p&gt;A short decision order when you see throttling:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check the quota.&lt;/strong&gt; Is the limit soft or hard? If soft and you are near it, file the increase now, in parallel with the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm retries have backoff and jitter.&lt;/strong&gt; If not, fix that first. It is the cheapest win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap concurrency&lt;/strong&gt; to keep steady-state rate under the TPM limit, at both the app and the infrastructure layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a circuit breaker&lt;/strong&gt; if throttle storms are recurring rather than occasional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate timeouts from throttles.&lt;/strong&gt; Shrink the batch for model timeouts, tune the read timeout and retry for client timeouts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spread across regions&lt;/strong&gt; with a cross-region inference profile for on-demand traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buy provisioned throughput&lt;/strong&gt; only for steady, predictable, high volume the above cannot cover.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Throttling is not a wall you hit once and clear. It is a pressure you manage continuously. The systems that stay up are the ones that treat the quota as a first-class design constraint, not an exception to catch.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>bedrock</category>
      <category>llm</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Moved My Prompts Into DynamoDB and the Throttling Went Away</title>
      <dc:creator>Avneet bansal</dc:creator>
      <pubDate>Sun, 18 Oct 2026 23:03:50 +0000</pubDate>
      <link>https://dev.to/avneet_bansal_a65b3f31fc4/i-moved-my-prompts-into-dynamodb-and-the-throttling-went-away-3h5o</link>
      <guid>https://dev.to/avneet_bansal_a65b3f31fc4/i-moved-my-prompts-into-dynamodb-and-the-throttling-went-away-3h5o</guid>
      <description>&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%2F309p7u33h98257y0elqw.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%2F309p7u33h98257y0elqw.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;br&gt;
Prompts feel like configuration, so it is tempting to park them in whatever managed prompt-management service your platform hands you. You get versioning, a console, and a clean API, and for a while that is the right call. The trouble shows up later, under load, when prompt retrieval sits on the hot path of every inference call and the service starts pushing back.&lt;/p&gt;

&lt;p&gt;That is the wall I hit. The prompt-management layer was throttling requests. Because every call had to fetch its prompt before doing any real work, that throttle became a ceiling on the whole pipeline. The model was not the bottleneck. Prompt lookup was.&lt;/p&gt;

&lt;p&gt;I moved prompt serving into DynamoDB and kept the managed service as the authoring source of truth. The throttling went away, and the design has been running well since.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a prompt-management service becomes the bottleneck
&lt;/h2&gt;

&lt;p&gt;Prompt-management services are built for authoring and governance, not for being read thousands of times a minute on a latency-sensitive path. They come with request quotas, and those quotas are usually shared across the account rather than scaled to one workload's read traffic. When read volume climbs, you hit the quota long before you hit any limit that has to do with the prompts themselves.&lt;/p&gt;

&lt;p&gt;The tell is subtle at first. You start seeing throttling and too-many-requests errors on prompt retrieval, so you add retries with exponential backoff and maybe a fallback for when the service says no. That is reasonable hygiene, but once you are wrapping a plain config lookup in retry logic, the store has quietly become the throughput limit of the application. You can optimize the model, batch the work, and tune everything downstream, and none of it matters, because the request is already stalled waiting for its prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why DynamoDB fits this access pattern
&lt;/h2&gt;

&lt;p&gt;A prompt lookup is about the simplest access pattern there is. You have a key, and you want the item back. That is a single-item read by primary key, which is exactly what DynamoDB is good at.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read throughput scales with the table, not a fixed service quota.&lt;/strong&gt; On-demand capacity absorbs spikes and provisioned capacity with auto scaling handles steady load. The shared quota that was throttling me no longer sat on the path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The access pattern is a key lookup.&lt;/strong&gt; Partition on the prompt identifier and every fetch is a single &lt;code&gt;GetItem&lt;/code&gt;. No scans, no filtering, predictable single-digit-millisecond reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reads and writes separate cleanly.&lt;/strong&gt; High-volume reads hit DynamoDB. The rare authoring writes stay in the managed service where review and governance already live, and a sync step pushes changes down into DynamoDB.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part that makes it safe: checksum-based sync
&lt;/h2&gt;

&lt;p&gt;Caching prompts is easy. Keeping the cache honest is the part worth getting right.&lt;/p&gt;

&lt;p&gt;The managed service stays the source of truth for prompt content. DynamoDB holds the serving copy that the hot path reads. To keep them in agreement without re-uploading everything on every deploy, I sync on a checksum.&lt;/p&gt;

&lt;p&gt;The sync is write-through. When it detects a change at the source, it writes the new prompt into DynamoDB first, and readers pick it up from there afterward. The flow is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;For each prompt in the source of truth, compute a checksum of its content.&lt;/li&gt;
&lt;li&gt;Compare that checksum against what is already stored for that prompt.&lt;/li&gt;
&lt;li&gt;If they match, do nothing. If they differ, the prompt changed, so write the new content into DynamoDB and record the new checksum.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This turns every sync into a cheap compare instead of a blind rewrite. Unchanged prompts cost a comparison and no write. Only a genuine edit at the source triggers a write. Because the write lands before any read needs it, a new prompt propagates cleanly: the serving copy is already updated by the time the hot path asks for it.&lt;/p&gt;

&lt;p&gt;The nice property is that the checksum is the change signal. You do not need a separate "is this dirty" flag or a manual version bump. If the content is identical, the checksum is identical, and the write is skipped. If someone edits a prompt, the checksum moves and the new text is written on the next sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you need rollback or audit: versioning in DynamoDB
&lt;/h2&gt;

&lt;p&gt;DynamoDB does not hand you versioning the way a prompt-management service does, but you can model it, and it is worth saying so plainly because "DynamoDB has no versioning" is a common misread.&lt;/p&gt;

&lt;p&gt;If a single current copy per prompt is all you need, the checksum layout above is enough. If you want history, rollback, or an audit trail, add a version to the key:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Partition key:&lt;/strong&gt; the prompt identifier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sort key:&lt;/strong&gt; the version. A monotonic integer, an ISO timestamp, or the checksum itself all work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A current pointer:&lt;/strong&gt; one extra item per prompt (for example sort key &lt;code&gt;CURRENT&lt;/code&gt;) that names the active version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hot path reads the current pointer by key, so serving stays a single keyed read. History stays queryable with a &lt;code&gt;Query&lt;/code&gt; on the partition key. The checksum still decides when a new version row gets written, so you are not stacking a new version for an unchanged prompt. If concurrent syncs are possible, a conditional write (&lt;code&gt;ConditionExpression&lt;/code&gt; on the stored checksum) keeps a stale sync from overwriting a newer one.&lt;/p&gt;

&lt;p&gt;The tradeoff is simple: the single-copy layout is less to manage, the versioned layout buys rollback and audit. Both are key reads on the hot path, so neither reintroduces the throttling problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the change
&lt;/h2&gt;

&lt;p&gt;The migration itself was small, which is part of why it was the right call.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Model the table around the lookup.&lt;/strong&gt; Partition key on the prompt identifier. That is the whole schema, because the access pattern was known up front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read by key on the hot path.&lt;/strong&gt; Each inference call does a single keyed read. No scan runs against this table in production, so cost stays proportional to the reads you actually make.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync by checksum, not on a timer or on every deploy.&lt;/strong&gt; The source of truth drives updates, and only real changes cross into DynamoDB.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is that prompt retrieval stopped being a shared, rate-limited dependency and became a key read against a store that scales with the traffic hitting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell someone before they copy this
&lt;/h2&gt;

&lt;p&gt;This is not an argument against prompt-management services. They earn their place for authoring, review, and governance. The mistake is letting that same service sit on a high-volume read path it was never sized for.&lt;/p&gt;

&lt;p&gt;A few things worth keeping in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The symptom is a throughput ceiling that tracks request volume, not model latency.&lt;/strong&gt; If you are wrapping prompt retrieval in throttling retries, that is the tell. No downstream tuning will move it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design the table around the access pattern first.&lt;/strong&gt; A key lookup wants a key schema. Pick the partition key from the query you actually run, and keep scans out of production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the cache self-correcting.&lt;/strong&gt; A checksum compare against the source of truth means the write-through sync updates the serving copy only when content actually changes, without manual version juggling and without rewriting unchanged data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model versioning if you need it, do not assume it is missing.&lt;/strong&gt; A sort-key version plus a current pointer gives you rollback and audit while keeping the hot path a single keyed read.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Moving prompts into DynamoDB was a small change with a disproportionate payoff. The throttling that was capping the pipeline is gone, retrieval is a clean key lookup, and the checksum sync keeps the serving copy honest against the source of truth. It has held up in production since.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>dynamodb</category>
      <category>llm</category>
      <category>ai</category>
    </item>
    <item>
      <title>The GitOps Bridge: Keep Terraform and ArgoCD Out of Each Other's Way</title>
      <dc:creator>Avneet bansal</dc:creator>
      <pubDate>Mon, 12 Oct 2026 04:18:15 +0000</pubDate>
      <link>https://dev.to/avneet_bansal_a65b3f31fc4/the-gitops-bridge-keep-terraform-and-argocd-out-of-each-others-way-3721</link>
      <guid>https://dev.to/avneet_bansal_a65b3f31fc4/the-gitops-bridge-keep-terraform-and-argocd-out-of-each-others-way-3721</guid>
      <description>&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%2F7gycz6ve8i4o2igmoawn.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%2F7gycz6ve8i4o2igmoawn.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;br&gt;
Two tools, two clocks. Terraform provisions the cluster, the node groups, the networking, the IAM. It changes rarely and under heavy approval. ArgoCD deploys the applications onto that cluster. It changes many times a day under light approval. The trouble starts when you let them reach into each other, because then a routine app deploy can be blocked behind an infrastructure change, and an infrastructure change can take applications down with it. The fix is not a better tool. It is a thin contract between the two, so each stays in its own lane. I used this to compress a three-environment EKS migration from a planned six months to about three. Here is the contract, and what it actually bought.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Infrastructure (Terraform) and applications (ArgoCD/Flux) have different change frequencies, approval flows, and blast radii. Coupling them is the root problem.&lt;/li&gt;
&lt;li&gt;The GitOps Bridge is a narrow handoff: Terraform writes cluster facts into a well-defined interface (ConfigMap/Secret labels and annotations), and GitOps controllers read that interface to deploy.&lt;/li&gt;
&lt;li&gt;Neither side imports the other's state. The interface is the only shared surface.&lt;/li&gt;
&lt;li&gt;On a real migration this let three environments come up reproducibly, with the customer executing the IaC themselves under a zero-access constraint, and cut the timeline roughly in half.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The coupling you do not notice until it hurts
&lt;/h2&gt;

&lt;p&gt;The common anti-pattern is to let Terraform deploy applications (via the Helm or kubectl provider) or to let application manifests carry infrastructure facts hard-coded inside them. Both feel convenient. Both create coupling that only shows its cost later.&lt;/p&gt;

&lt;p&gt;When Terraform deploys apps, every application change rides the Terraform lifecycle: a &lt;code&gt;terraform apply&lt;/code&gt; that should only touch a node group now also reconciles your workloads, and a drift in one app can fail a plan that was supposed to be about infrastructure. When app manifests hard-code infrastructure facts (the cluster's OIDC issuer, a role ARN, a VPC ID, the region), those manifests are no longer portable across environments, and every environment promotion becomes a hand-edit. The blast radius of each change grows to cover both layers, and the audit trail blurs, because you can no longer tell from the change history whether a given commit was an infrastructure decision or an application one.&lt;/p&gt;

&lt;p&gt;The two layers genuinely have different natures. Infrastructure changes are infrequent, high-risk, and want slow, careful approval. Application changes are frequent, lower-risk, and want to flow. Forcing them through one pipeline makes the fast side as slow as the slow side, and the slow side as risky as the fast side.&lt;/p&gt;
&lt;h2&gt;
  
  
  The contract
&lt;/h2&gt;

&lt;p&gt;The bridge is deliberately small. Terraform's job ends at provisioning the cluster and then publishing a set of facts about it into a known location. In practice that location is a ConfigMap and a Secret in a known namespace, carrying labels and annotations that describe the cluster: its name, region, the OIDC provider URL, role ARNs for workloads, networking identifiers, and any environment-specific values the apps will need. Terraform owns writing these. It never installs an application.&lt;/p&gt;

&lt;p&gt;ArgoCD's job begins by reading that interface. A root application points at the known ConfigMap/Secret, pulls the cluster facts, and uses them to parameterize everything it deploys. The app manifests themselves carry no hard-coded infrastructure values. They carry references into the bridge. ArgoCD owns reading these. It never provisions infrastructure.&lt;/p&gt;

&lt;p&gt;The important property is what is absent. Terraform does not read ArgoCD's state, and ArgoCD does not read Terraform's state. There is no shared state file, no circular dependency where each waits on the other. The only thing they share is the interface, and the interface is data, not control. Terraform writes facts; ArgoCD reads facts. That one-directional, data-only handoff is the whole design, and it is what lets each side keep its own clock.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this made a hard migration tractable
&lt;/h2&gt;

&lt;p&gt;The migration that proved it out had a constraint that is more common than people admit: I could not directly touch the target environments. The work had to be delivered as infrastructure-as-code that the customer ran themselves, across three environments, an internal lab, a sandbox, and production, with no interactive access to any of them.&lt;/p&gt;

&lt;p&gt;That constraint is exactly where the bridge pays off. Because the apps consume cluster facts through the interface rather than embedding them, the same application definitions deploy unchanged to all three environments. Each environment's Terraform writes its own values into its own bridge, and the GitOps layer adapts automatically. There was no per-environment hand-editing of manifests, which is where this kind of migration usually loses weeks and acquires drift. The customer could run the IaC in each environment and get a reproducible result, because the only thing that varied between environments lived in the bridge, generated by that environment's own provisioning.&lt;/p&gt;

&lt;p&gt;For one customer, the combination (Terraform plus the bridge plus GitOps, with Karpenter handling node provisioning on the cluster side) took a migration planned for about six months down to roughly three. The halving did not come from any single tool being fast. It came from removing the coupling that would otherwise have forced serial, hand-held, per-environment work. When each environment comes up the same way from the same definitions, you stop paying the integration tax three times.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to adopt it
&lt;/h2&gt;

&lt;p&gt;Start by drawing the line. List every value your application manifests currently hard-code that is really an infrastructure fact: cluster name, region, OIDC issuer, IAM role ARNs, subnet or security-group IDs, DNS zone names. Those are the contents of your bridge.&lt;/p&gt;

&lt;p&gt;Have Terraform write them. After it provisions the cluster, have it create a ConfigMap (for non-secret facts) and a Secret (for anything sensitive) in a known namespace, populated from its outputs. Decide the key names once and treat them as the contract; renaming a bridge key is now a breaking change, which is the right level of ceremony for it.&lt;/p&gt;

&lt;p&gt;Have GitOps read them. Point a root ArgoCD (or Flux) application at that namespace, and parameterize your app set from the bridge values rather than from literals. Each environment gets its own bridge from its own Terraform, and the same app definitions follow.&lt;/p&gt;

&lt;p&gt;A minimal, generic version of both sides looks like this. First, Terraform publishes the facts after it provisions the cluster (the values are illustrative placeholders, not from any real environment):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Terraform: write the bridge after the cluster exists.&lt;/span&gt;
&lt;span class="c1"&gt;# The labels are what ArgoCD's ApplicationSet selects on; the data is the facts.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes_config_map"&lt;/span&gt; &lt;span class="s2"&gt;"cluster_bridge"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cluster-bridge"&lt;/span&gt;
    &lt;span class="nx"&gt;namespace&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"argocd"&lt;/span&gt;
    &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="s2"&gt;"bridge.example.com/environment"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;   &lt;span class="c1"&gt;# dev | staging | prod&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cluster_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_name&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;
    &lt;span class="nx"&gt;oidc_issuer&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;oidc_provider&lt;/span&gt;          &lt;span class="c1"&gt;# for IRSA&lt;/span&gt;
    &lt;span class="nx"&gt;account_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_caller_identity&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;account_id&lt;/span&gt;
    &lt;span class="c1"&gt;# a workload's IAM role ARN, resolved per environment:&lt;/span&gt;
    &lt;span class="nx"&gt;app_irsa_role&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_irsa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iam_role_arn&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;Then an ArgoCD ApplicationSet reads that ConfigMap and templates every app from it, so the app definitions themselves carry references, never literals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ApplicationSet&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;platform-addons&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;generators&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;clusters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                      &lt;span class="c1"&gt;# or a configMap/plugin generator reading the bridge&lt;/span&gt;
        &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;bridge.example.com/environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{name}}-addons'&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/your-org/platform-addons&lt;/span&gt;
        &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;addons&lt;/span&gt;
        &lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="c1"&gt;# bridge facts flow in as values; no hard-coded ARNs or issuers in the chart&lt;/span&gt;
          &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;region&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{metadata.annotations.region}}'&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oidcIssuer&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{metadata.annotations.oidc_issuer}}'&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appIrsaRole&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{metadata.annotations.app_irsa_role}}'&lt;/span&gt;
      &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{{server}}'&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact generator you use (cluster, configMap, or a plugin generator) depends on your setup; the invariant is the one that matters: Terraform owns the left side and writes facts, ArgoCD owns the right side and reads them, and the app charts reference bridge values instead of embedding them. Swap &lt;code&gt;prod&lt;/code&gt; for &lt;code&gt;dev&lt;/code&gt; or &lt;code&gt;staging&lt;/code&gt; and nothing in the app definitions changes, only the bridge each environment's Terraform wrote.&lt;/p&gt;

&lt;p&gt;Hold the boundary. The one discipline that keeps this working is refusing to cross the line later under deadline pressure: no Helm-provider app installs sneaking into Terraform, no infrastructure ARNs sneaking back into manifests. The value is entirely in the separation, so the separation is the thing to protect.&lt;/p&gt;

&lt;p&gt;The reward is that your fast layer stays fast and your slow layer stays safe. An application deploy no longer waits on an infrastructure plan. An infrastructure change no longer reconciles your workloads as a side effect. And a new environment is a new bridge, not a new round of manual edits. Keep the two clocks separate, and let a thin, one-way, data-only contract be the only thing between them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you run Terraform and a GitOps controller together, it is worth asking which infrastructure facts your app manifests still hard-code. Those are the ones the bridge is for.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>terraform</category>
      <category>gitops</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your Readiness Probe Is Lying to You</title>
      <dc:creator>Avneet bansal</dc:creator>
      <pubDate>Sat, 10 Oct 2026 21:14:36 +0000</pubDate>
      <link>https://dev.to/avneet_bansal_a65b3f31fc4/your-readiness-probe-is-lying-to-you-5e1g</link>
      <guid>https://dev.to/avneet_bansal_a65b3f31fc4/your-readiness-probe-is-lying-to-you-5e1g</guid>
      <description>&lt;p&gt;A readiness probe is a promise. It tells Kubernetes "route traffic to me, I can handle it." Most of the time the promise is kept because the probe checks the thing that actually matters. The failures worth writing about are the ones where the probe keeps saying Ready while the component has quietly lost the one connection it needs to do any useful work. The traffic keeps arriving. None of it can be served. Nothing looks broken on the dashboard.&lt;/p&gt;

&lt;p&gt;I hit exactly this in KEDA, the CNCF event-driven autoscaler, and the fix merged as &lt;a href="https://github.com/kedacore/keda/pull/8226" rel="noopener noreferrer"&gt;kedacore/keda #8226&lt;/a&gt;. The bug is a good teaching case on its own. The part I did not expect was the second bug hiding inside the fix, which a maintainer caught in review.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;KEDA's Metrics Server reported Ready even when its gRPC connection to the keda-operator was down.&lt;/li&gt;
&lt;li&gt;In that state the HPA silently stops receiving scale signals, while the replica still looks healthy.&lt;/li&gt;
&lt;li&gt;The fix makes the readiness probe observe the real gRPC connection state and report NotReady when the connection is unusable.&lt;/li&gt;
&lt;li&gt;A naive version of that fix has a trap: once a replica goes NotReady it stops receiving RPCs, so an Idle connection never gets woken and the replica can stay NotReady forever.&lt;/li&gt;
&lt;li&gt;The merged fix nudges an Idle connection to reconnect from inside the readiness check itself.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The setup: who talks to whom
&lt;/h2&gt;

&lt;p&gt;KEDA has two moving parts here. The keda-operator does the actual work of talking to scalers and computing metrics. The Metrics Server (a custom metrics API server) is what the Kubernetes HPA queries when it wants to know "how many of these should be running." The Metrics Server does not compute metrics itself. It forwards the request to the operator over a gRPC connection and relays the answer.&lt;/p&gt;

&lt;p&gt;That gRPC connection is the Metrics Server's entire reason to exist. If it is down, the Metrics Server is a shell. It can accept an HPA query and has nothing to answer it with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lie
&lt;/h2&gt;

&lt;p&gt;The Metrics Server's readiness probe did not check that connection. It reported Ready as long as the process was up and its HTTP server was listening. So when the gRPC connection to the operator dropped, the following happened, all at once and all invisible: the replica stayed Ready, Kubernetes kept it in the APIService endpoints, the HPA kept sending it metric queries, and every one of those queries failed or hung because there was no live operator connection behind them. The autoscaler stops scaling, and the one signal you would look at, pod readiness, is green.&lt;/p&gt;

&lt;p&gt;This is the specific shape of outage that is worst to debug. A crash is loud. A readiness probe that passes while the component cannot serve is silent, and it points you away from the real cause, because the first thing anyone checks is "are the pods ready," and they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, version one
&lt;/h2&gt;

&lt;p&gt;The fix is conceptually simple. Make readiness reflect the dependency. gRPC already tracks connection state through &lt;code&gt;grpc.ClientConn.GetState()&lt;/code&gt;, which returns one of &lt;code&gt;Idle&lt;/code&gt;, &lt;code&gt;Connecting&lt;/code&gt;, &lt;code&gt;Ready&lt;/code&gt;, &lt;code&gt;TransientFailure&lt;/code&gt;, or &lt;code&gt;Shutdown&lt;/code&gt;. Crucially, &lt;code&gt;GetState()&lt;/code&gt; is a pure observer. It reports the current state and never initiates a connection, so it is safe to call from a probe with no side effects.&lt;/p&gt;

&lt;p&gt;So version one added a small accessor on the metrics-service client that returned the connection state, and wired a &lt;code&gt;readyz&lt;/code&gt; check in the adapter that reported NotReady unless the connection was &lt;code&gt;Ready&lt;/code&gt;. When the operator connection drops, the probe fails, Kubernetes pulls the replica from the APIService endpoints, and the HPA stops sending it queries it cannot answer. When the connection recovers, the probe passes again. That is the behavior you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug, caught in review
&lt;/h2&gt;

&lt;p&gt;Here is the part I like. A maintainer looked at version one and pointed out a failure mode that only exists because the fix works.&lt;/p&gt;

&lt;p&gt;Think through what happens after the probe correctly reports NotReady. The replica gets removed from the APIService endpoints. Now no RPCs flow through that client at all, because nothing is routed to it. And gRPC, by default, parks a connection in the &lt;code&gt;Idle&lt;/code&gt; state after a period with no RPCs (&lt;code&gt;GRPC_CLIENT_IDLE_TIMEOUT_MS&lt;/code&gt;, 30 minutes by default). An idle gRPC connection does not reconnect on its own. It waits for someone to use it.&lt;/p&gt;

&lt;p&gt;So the naive fix can deadlock the recovery. The connection goes bad, the probe reports NotReady, the replica stops getting RPCs, the connection drifts to Idle, and nothing ever wakes it, because the only thing that would wake it is an RPC, and RPCs only resume once the probe reports Ready again, which requires the connection to be awake. The replica can sit NotReady permanently, needing a manual restart to recover. The fix for a silent hang would have introduced a silent hang.&lt;/p&gt;

&lt;p&gt;The resolution is to make the readiness check active, not passive, in exactly one narrow case. When the accessor observes an &lt;code&gt;Idle&lt;/code&gt; connection, it triggers a non-blocking reconnect before returning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// GetConnectionState returns the current connectivity state of the underlying&lt;/span&gt;
&lt;span class="c"&gt;// gRPC connection to the KEDA metrics service.&lt;/span&gt;
&lt;span class="c"&gt;//&lt;/span&gt;
&lt;span class="c"&gt;// When the connection is Idle it also triggers a non-blocking reconnection&lt;/span&gt;
&lt;span class="c"&gt;// attempt (moving it towards Connecting) before returning. Once a non-Ready&lt;/span&gt;
&lt;span class="c"&gt;// state removes the replica from the APIService endpoints, no RPCs flow through&lt;/span&gt;
&lt;span class="c"&gt;// this client, so nothing else would ever wake an Idle connection.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;GrpcClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;GetConnectionState&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;connectivity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetState&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;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;connectivity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Idle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Connect&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="n"&gt;state&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Connect()&lt;/code&gt; is non-blocking and a no-op when the connection is not Idle, so it is safe to call from a probe. The probe still returns the pre-nudge state (so a just-nudged Idle connection reports NotReady that cycle), but the connection is now moving toward &lt;code&gt;Connecting&lt;/code&gt; and then &lt;code&gt;Ready&lt;/code&gt; on its own. The replica recovers without a human. The probe went from a pure observer to an observer-with-one-side-effect, and that single side effect is what closes the recovery loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it yourself
&lt;/h2&gt;

&lt;p&gt;The regression test does not need a cluster or a running operator. It exercises the connection-state logic directly with an in-process gRPC client, so you can run it from a clean checkout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--depth&lt;/span&gt; 20 https://github.com/kedacore/keda.git
&lt;span class="nb"&gt;cd &lt;/span&gt;keda
go &lt;span class="nb"&gt;test&lt;/span&gt; ./pkg/metricsservice/... &lt;span class="nt"&gt;-run&lt;/span&gt; &lt;span class="s1"&gt;'TestGetConnectionState'&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two tests cover the two behaviors above. One asserts that a freshly created client (gRPC does not dial until first use) is not &lt;code&gt;Ready&lt;/code&gt;, so a probe built on it correctly reports NotReady for a replica that has not connected yet. The other asserts the review fix: calling the accessor on an &lt;code&gt;Idle&lt;/code&gt; connection moves it off Idle within a couple of seconds without any RPC being issued, so a sidelined replica can recover on its own.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Expected: both subtests pass. (If you are on a network that cannot reach the Go module proxy, the clone's large dependency tree may need &lt;code&gt;go clean -modcache&lt;/code&gt; or a working &lt;code&gt;GOPROXY&lt;/code&gt; first.)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How to adopt the pattern
&lt;/h2&gt;

&lt;p&gt;The KEDA-specific takeaway is small: upgrade to a release that includes #8226 and your Metrics Server will stop advertising readiness while blind to the operator.&lt;/p&gt;

&lt;p&gt;The portable takeaway is the one worth internalizing. A readiness probe should check the dependency that the component cannot work without, not just that the process is up. For a forwarding service, that dependency is the downstream connection. And when you make a probe gate traffic on a connection's health, check whether removing traffic from an unhealthy replica also removes the thing that would let it recover. If recovery depends on activity, and your probe stops activity, you need the probe itself to drive recovery, carefully and without blocking. Check the real dependency, and make sure the healthy path back exists.&lt;/p&gt;

&lt;p&gt;A probe that only ever reports the process is alive is answering a question nobody asked. The question Kubernetes is actually asking is "can you serve." Answer that one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you run forwarding or adapter services behind readiness probes, it is worth auditing what they actually check. The gap between "process is up" and "can serve" is where the quiet outages live.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>go</category>
      <category>devops</category>
      <category>grpc</category>
    </item>
    <item>
      <title>A Prompt-Injection Firewall for AWS: What I'd Build, and What I Measured</title>
      <dc:creator>Avneet bansal</dc:creator>
      <pubDate>Sat, 10 Oct 2026 20:10:28 +0000</pubDate>
      <link>https://dev.to/avneet_bansal_a65b3f31fc4/a-prompt-injection-firewall-for-aws-what-id-build-and-what-i-measured-14b4</link>
      <guid>https://dev.to/avneet_bansal_a65b3f31fc4/a-prompt-injection-firewall-for-aws-what-id-build-and-what-i-measured-14b4</guid>
      <description>&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%2Fhmw44kpu3yjhskg3ekeh.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%2Fhmw44kpu3yjhskg3ekeh.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Prompt injection has sat at the top of the OWASP Top 10 for LLM Applications since the list began in 2023, and it is still number one in the 2025 and 2026 editions. That is unusual. Most vulnerability classes get a real fix and slide down the list. SQL injection had parameterized queries. XSS had output encoding and CSP. Prompt injection has no equivalent, because the flaw is architectural: an LLM reads the system prompt, the user's input, and any retrieved documents or tool output as one undifferentiated stream of text, with no built-in boundary between trusted instructions and untrusted data.&lt;/p&gt;

&lt;p&gt;I work on LLM systems on AWS, and this is the problem I keep coming back to. This post is not a product announcement. It is the design I would build if I were building a prompt-injection firewall today, grounded in what the research actually says and an honest account of what already exists. I will be clear throughout about which parts are established fact and which are my proposed approach.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Prompt injection is OWASP's #1 LLM risk across every edition, and a 2026 proof shows pure input-wrapper defenses face a hard trilemma: they cannot be continuous, utility-preserving, and complete at the same time.&lt;/li&gt;
&lt;li&gt;The space is no longer empty. NeuralTrust ships a commercial "Generative Application Firewall," Check Point acquired Lakera, Cloudflare has Firewall for AI, and open-source projects exist. The honest gap is a cloud-native, AWS-reference, agent-aware design, not "nobody has done this."&lt;/li&gt;
&lt;li&gt;My proposal: a multi-layer ensemble with a weighted decision engine, output and tool-call enforcement, and a feedback loop, built the trilemma into the design rather than pretending to beat it.&lt;/li&gt;
&lt;li&gt;I built the offline core (heuristic + vector detectors + decision engine) and benchmarked it on 285 labeled prompts. The measured trade-off is stark: at the threshold that catches 92% of attacks, 25% of legitimate prompts get flagged; tighten it until false positives are near zero and detection falls to 39%. No setting gives both. That is the trilemma, measured, and the argument for the semantic layer.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why this problem resists a clean fix
&lt;/h2&gt;

&lt;p&gt;A 2026 paper, &lt;a href="https://arxiv.org/abs/2604.06436" rel="noopener noreferrer"&gt;"Why Prompt Injection Defense Wrappers Fail?"&lt;/a&gt;, formalizes something practitioners had felt for a while. It argues that a defense that wraps the input faces a trilemma of three properties that cannot all hold at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Continuity&lt;/strong&gt; — a small change to the input should not flip the defense decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Utility preservation&lt;/strong&gt; — legitimate prompts must still get through and work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Completeness&lt;/strong&gt; — every injection attempt must be caught.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can get two. Not three. The paper is explicit that this bounds &lt;em&gt;input-wrapper&lt;/em&gt; style defenses specifically, and that it does not rule out training-time alignment, architectural changes, or defenses that deliberately trade away some utility. That nuance matters, and I will come back to it, because it is the single most important design constraint.&lt;/p&gt;

&lt;p&gt;The practical consequence is that any honest firewall should stop promising completeness. It should instead be explicit about where on the continuity/utility/completeness surface it chooses to sit, and make that choice configurable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What already exists (the part the hype usually skips)
&lt;/h2&gt;

&lt;p&gt;When I first sketched this idea, my note to myself said "no one has built this." That was wrong, and it is worth correcting in public, because anyone who works in LLM security will know the landscape.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lakera Guard&lt;/strong&gt; — commercial, API-first prompt-injection and jailbreak detection. &lt;a href="https://www.csoonline.com/article/4058653/check-point-acquires-lakera-to-build-a-unified-ai-security-stack.html" rel="noopener noreferrer"&gt;Check Point acquired Lakera in September 2025&lt;/a&gt; and folded it into its platform. Its headline accuracy and sub-50ms latency are the vendor's own figures, not something I measured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NeuralTrust&lt;/strong&gt; — ships a commercial product it literally calls a Generative Application Firewall, an inline enforcement layer for LLM apps and agents. So the "GAF" concept is not paper-only; it is a shipping product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Firewall for AI&lt;/strong&gt; — a commercial layer that blocks unsafe prompts and addresses several OWASP LLM risks at the edge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open-source&lt;/strong&gt; — several GitHub projects named &lt;code&gt;prompt-shield&lt;/code&gt; (and the one-word &lt;code&gt;promptshield&lt;/code&gt;) implement detector ensembles, reverse-proxy enforcement, and threat scoring. &lt;strong&gt;LLM Guard&lt;/strong&gt; is an active rule-based scanner set, and &lt;strong&gt;NeMo Guardrails&lt;/strong&gt; (NVIDIA) handles conversation-flow control. &lt;strong&gt;Rebuff&lt;/strong&gt;, an earlier multi-layer project, is archived.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "GAF" framing itself comes from a 2026 paper, &lt;a href="https://arxiv.org/abs/2601.15824" rel="noopener noreferrer"&gt;"Introducing the Generative Application Firewall (GAF)"&lt;/a&gt;, which proposes a single enforcement point that unifies prompt filters, output validators, and data masking, maintains session context, and covers agents and their tool calls, by analogy to a WAF for web traffic.&lt;/p&gt;

&lt;p&gt;So the honest gap is narrower and more specific than "nobody has built a firewall." It is: an open, cloud-native &lt;strong&gt;reference architecture on AWS primitives&lt;/strong&gt; that is agent-aware (intercepts tool calls, not just prompts), uses a multi-detector ensemble rather than a single technique, and is explicit about the trilemma trade-off it makes. That is a real and useful gap. It is not a claim of inventing the category.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture I'd build
&lt;/h2&gt;

&lt;p&gt;The design is a pipeline: detect, decide, enforce, learn. Each stage maps cleanly onto AWS primitives, which is the point, it should be deployable without inventing new infrastructure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;          ┌───────────────────────────────────────────────┐
 request  │  1. DETECTION  (ensemble, run in parallel)      │
 ───────► │   heuristic · ML classifier · LLM-as-judge      │
          │   vector similarity to known attacks            │
          └───────────────────────┬───────────────────────┘
                                   ▼
          ┌───────────────────────────────────────────────┐
          │  2. DECISION  (weighted vote + session context) │
          │   configurable threshold → the trilemma dial    │
          └───────────────────────┬───────────────────────┘
                                   ▼
          ┌───────────────────────────────────────────────┐
          │  3. ENFORCE   allow / block / sanitize / review │
          │   also: scan the OUTPUT, gate TOOL CALLS         │
          └───────────────────────┬───────────────────────┘
                                   ▼
          ┌───────────────────────────────────────────────┐
          │  4. LEARN   log verdicts, feed back FPs,         │
          │   grow the attack-vector store                   │
          └───────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On AWS, I'd map it like this: API Gateway as the entry point; the detector ensemble as Lambda functions, with the ML classifier on SageMaker and the LLM-as-judge on Bedrock; the attack-vector store on OpenSearch; the decision engine as a small Step Functions or Lambda orchestration reading session history from DynamoDB; and CloudWatch plus S3 for the audit trail. None of that is exotic, which is deliberate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why an ensemble, and what each layer is actually good at
&lt;/h3&gt;

&lt;p&gt;No single detector is enough, and the reasons are well documented. The &lt;a href="https://arxiv.org/abs/2608.27990v1" rel="noopener noreferrer"&gt;CAITLYN paper&lt;/a&gt; puts the trade-off bluntly: deterministic signature and regex filters run at sub-millisecond speed with zero token cost but fall to trivial obfuscation and semantic rewrites. So the ensemble is not padding; each layer covers a different failure mode of the others.&lt;/p&gt;

&lt;p&gt;A heuristic layer is the cheap first pass. Illustrative, not a complete ruleset:&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;SUSPICIOUS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore (all )?previous instructions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disregard the (system|above) prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;you are now [a-z ]+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# role-override attempts
&lt;/span&gt;    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reveal (your )?(system )?prompt&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;heuristic_score&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;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&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;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SUSPICIOUS&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# saturate; this is a signal, not a verdict
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That catches the lazy attacks for free and catches nothing clever, which is exactly why it only contributes a signal. A semantic layer (an ML classifier, or an LLM-as-judge on Bedrock) catches the paraphrased and indirect attacks the regex misses, at real token cost and latency. A vector-similarity layer compares the input against an embedding store of known attack patterns, so a variant of something you have seen scores high even if the wording is new.&lt;/p&gt;

&lt;h3&gt;
  
  
  The decision engine is where the trilemma becomes a dial
&lt;/h3&gt;

&lt;p&gt;This is the heart of the honest version. Instead of a single detector shouting "block," the engine combines the signals into one score and compares it to a threshold you set per tenant or per use case:&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;WEIGHTS&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;heuristic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classifier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;judge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vector&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&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;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signals&lt;/span&gt;&lt;span class="p"&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="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&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="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WEIGHTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&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;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&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;block&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;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.6&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;flag_for_review&lt;/span&gt;&lt;span class="sh"&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;allow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The threshold &lt;em&gt;is&lt;/em&gt; the trilemma choice, made explicit. A bank's internal agent can run it high and tolerate more false positives, because a missed injection is expensive. A low-stakes public chatbot runs it lower to protect utility. The firewall does not pretend to be complete; it hands you the knob and logs where you set it. That framing is the design taking the arXiv result seriously instead of marketing past it.&lt;/p&gt;

&lt;p&gt;The weights above show the full four-detector design. The benchmark in the next section only exercises the two detectors I actually built offline (heuristic and vector), so it runs with two weights rather than four. The semantic detectors in this snippet are the layer I have not built yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  I built the offline core and measured it
&lt;/h2&gt;

&lt;p&gt;Design claims are cheap, so I implemented the two offline layers (the heuristic detector and the vector-similarity detector) plus the decision engine, and ran them against a labeled corpus of 285 prompts: 192 injection and 93 benign. The injection set is deliberately &lt;em&gt;held out&lt;/em&gt;, paraphrases, obfuscations (spaced-out and hyphenated triggers), and indirect-via-document payloads that are &lt;strong&gt;not&lt;/strong&gt; the examples the vector detector was fitted on, so the catch rate reflects generalisation rather than memorisation. The benign set includes hard negatives that mention "instructions," "system prompt," "override," and "ignore" innocently. Everything is pure standard-library Python with a fixed seed, so the run is deterministic and reproducible.&lt;/p&gt;

&lt;p&gt;Here is the actual output, sweeping the decision threshold (weights: heuristic 0.4, vector 0.6):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; thresh  detect%    FP%  precision     F1
--------------------------------------------
   0.15      98%    52%       0.80   0.88
   0.18      92%    25%       0.88   0.90
   0.20      84%    14%       0.93   0.88
   0.25      60%     8%       0.94   0.74
   0.30      39%     1%       0.99   0.55
   0.40      10%     0%       1.00   0.18
   0.60       5%     0%       1.00   0.09
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that top to bottom and the trilemma stops being an abstraction. At a threshold of 0.18 the firewall catches &lt;strong&gt;92%&lt;/strong&gt; of the injections, but it also flags &lt;strong&gt;a quarter of legitimate prompts&lt;/strong&gt;, the hard benign negatives about writing system prompts and documenting team instructions get swept up. Tighten the threshold to 0.30 and false positives drop to &lt;strong&gt;1%&lt;/strong&gt; (precision 0.99), but detection collapses to &lt;strong&gt;39%&lt;/strong&gt;: most attacks walk straight through. There is no row where detection is high and false positives are low at the same time. The best F1 (0.90) sits near the aggressive end, where you have already accepted a real false-positive cost.&lt;/p&gt;

&lt;p&gt;That is not a tuning failure I can polish away with better weights. It is the shape the 2604.06436 result predicts for this class of defense, and watching it fall out of real numbers is more convincing than any single headline accuracy figure. The honest reading is specific: a cheap offline ensemble reliably catches the obvious and the lexically similar, and genuinely struggles to separate a &lt;em&gt;subtle paraphrase&lt;/em&gt; of an attack from a &lt;em&gt;legitimate request that happens to talk about prompts&lt;/em&gt;. That overlap is exactly the region a semantic layer, an ML classifier or an LLM-as-judge on Bedrock, is supposed to resolve, and it is why I treat that layer as the next build step rather than a nice-to-have.&lt;/p&gt;

&lt;p&gt;A note on honesty, because it is the whole point of this post: the corpus is synthetic and template-generated, so its diversity is bounded by the templates; the numbers are operational rather than a formal benchmark against published baselines; and the implementation is not open-sourced. I would not quote these figures without having run the code, and I would not dress them up as production-grade. They are a measured illustration of a trade-off, nothing more and nothing less.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two things that make it agent-aware
&lt;/h3&gt;

&lt;p&gt;Most of the field still treats this as "scan the user's prompt." For modern agentic systems that is half the surface. Two additions matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Output scanning.&lt;/strong&gt; The model's response can carry an injected instruction or leak a system prompt. Scanning the output, not just the input, is where several designs (including NeuralTrust's and the GAF paper's) converge, and I'd treat it as mandatory rather than optional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool-call gating.&lt;/strong&gt; This is the one I care most about, and where indirect injection lives. When an agent decides to call a tool, that decision can be the payload: a poisoned document retrieved via RAG tells the agent to call &lt;code&gt;send_email&lt;/code&gt; or &lt;code&gt;delete_record&lt;/code&gt;. Gating the tool call, checking the proposed action and arguments against policy before execution, is a different and arguably more important checkpoint than filtering the prompt:&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;gate_tool_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&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;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;denied&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;block&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;tool&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requires_human&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;args_risky&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&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;human_review&lt;/span&gt;&lt;span class="sh"&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;allow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://arxiv.org/abs/2502.01822" rel="noopener noreferrer"&gt;"Firewalls to Secure Dynamic LLM Agentic Networks"&lt;/a&gt; work takes this further, projecting cross-agent messages onto a structured, task-scoped protocol so that persuasive framing and embedded instructions are stripped by construction. That is a stronger idea than scoring, and it is the direction I would want the tool-call layer to grow toward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations, up front
&lt;/h2&gt;

&lt;p&gt;Because a firewall that oversells itself is worse than none:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It &lt;strong&gt;cannot be complete.&lt;/strong&gt; The 2604.06436 result makes that a theorem for input-wrapper defenses, not a matter of effort. Anyone claiming 100% is wrong or redefining the problem.&lt;/li&gt;
&lt;li&gt;The semantic layers &lt;strong&gt;cost latency and tokens.&lt;/strong&gt; The sub-50ms figures you see are vendor numbers for narrower, single-layer detectors; a multi-layer ensemble with an LLM-judge step will cost more, and I would not quote a latency number I had not measured.&lt;/li&gt;
&lt;li&gt;It is &lt;strong&gt;defense in depth, not a boundary.&lt;/strong&gt; The durable lesson from the evaluation literature is that security boundaries belong in application code and permissions. A firewall lowers the hit rate; least-privilege tool scoping and human approval on destructive actions are what contain the ones that get through.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  On the compliance angle
&lt;/h2&gt;

&lt;p&gt;It is tempting to attach urgency to regulation, so here is the accurate version. The EU AI Act's &lt;strong&gt;high-risk&lt;/strong&gt; obligations were pushed back by the 2026 Digital Omnibus to December 2027 and August 2028, not August 2026 as early drafts implied. What did take effect on August 2, 2026 is the transparency regime (Article 50) and penalty powers for general-purpose models. So the regulatory pressure is real but phased, and the honest pitch is that security controls for LLM apps are becoming table stakes over the next two years, not that a deadline hits tomorrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The path forward
&lt;/h2&gt;

&lt;p&gt;The offline core and the trade-off measurement above are done. From here the order would be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add the semantic layer&lt;/strong&gt; (an ML classifier, or an LLM-as-judge on Bedrock) specifically to attack the overlap region the offline ensemble could not separate, and re-measure whether it buys real detection without pushing false positives back up. That is the hypothesis the current numbers set up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Harden output scanning and tool-call gating&lt;/strong&gt;, and benchmark against indirect-injection scenarios (poisoned RAG content, malicious tool results), since that is the underserved part.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish it as an AWS reference architecture&lt;/strong&gt; with the numbers attached, honest about where it sits relative to NeuralTrust, Lakera, and the OSS projects, rather than claiming to replace them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Close the loop&lt;/strong&gt;: feed false positives back to retune thresholds and grow the attack-vector store, and treat that feedback data as the real moat over time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is a build I would stand behind, because every claim in it is either cited or measured. The gap it fills is specific: an open, agent-aware, AWS-native reference design that treats the defense trilemma as a dial instead of a thing to lie about.&lt;/p&gt;

&lt;p&gt;If you are working on LLM security, I'd genuinely like to hear which layer you think earns its keep, and whether tool-call gating or output scanning has mattered more in your own systems.&lt;/p&gt;




&lt;h3&gt;
  
  
  Sources
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;OWASP Top 10 for LLM Applications (2025, 2026) — prompt injection ranked #1 across editions. &lt;a href="https://genai.owasp.org/llm-top-10/" rel="noopener noreferrer"&gt;https://genai.owasp.org/llm-top-10/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;arXiv 2604.06436 — "Why Prompt Injection Defense Wrappers Fail?" (the defense trilemma)&lt;/li&gt;
&lt;li&gt;arXiv 2601.15824 — "Introducing the Generative Application Firewall (GAF)"&lt;/li&gt;
&lt;li&gt;arXiv 2608.27990 — "CAITLYN: Can LLM Agents Autonomously Synthesize Defenses against Emerging Injection Attacks?"&lt;/li&gt;
&lt;li&gt;arXiv 2502.01822 — "Firewalls to Secure Dynamic LLM Agentic Networks"&lt;/li&gt;
&lt;li&gt;Check Point acquires Lakera (Sept 2025) — csoonline.com&lt;/li&gt;
&lt;li&gt;NeuralTrust Generative Application Firewall; Cloudflare Firewall for AI (vendor sources)&lt;/li&gt;
&lt;li&gt;EU AI Act timeline after the 2026 Digital Omnibus (high-risk obligations deferred to Dec 2027 / Aug 2028)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;The benchmark figures come from my own offline reference implementation (pure standard-library Python: regex heuristic + character tri-gram cosine detectors + a weighted decision engine), run against a synthetic, template-generated labeled corpus of 192 injection and 93 benign prompts (285 total), with seed attacks held disjoint from the test set. The implementation is not open-sourced; the numbers are operational, not a formal benchmark.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>aws</category>
      <category>security</category>
      <category>llm</category>
    </item>
    <item>
      <title>Cut LLM Document-Extraction Cost by 85% Without Losing Accuracy</title>
      <dc:creator>Avneet bansal</dc:creator>
      <pubDate>Wed, 07 Oct 2026 02:23:09 +0000</pubDate>
      <link>https://dev.to/avneet_bansal_a65b3f31fc4/cut-llm-document-extraction-cost-by-85-without-losing-accuracy-34ip</link>
      <guid>https://dev.to/avneet_bansal_a65b3f31fc4/cut-llm-document-extraction-cost-by-85-without-losing-accuracy-34ip</guid>
      <description>&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%2Fcwd23fnsikaq17lqr2kh.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%2Fcwd23fnsikaq17lqr2kh.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;br&gt;
Most production LLM extraction pipelines route every field through a frontier model, every time. It works, and for a while nobody questions the bill. Then volume grows, or someone runs the per-document math, and the real question shows up. How much of this spend is buying accuracy, and how much is just habit.&lt;/p&gt;

&lt;p&gt;In practice, most of it is habit. The majority of fields in a typical extraction job do not need a frontier model at all. A large share of the model calls answer questions the document never asked. A surprising fraction of calls re-extract data that has not changed since the last run. None of that is a model problem. It is an architecture problem, and architecture problems are the good kind, because you can fix them without waiting on anyone's roadmap.&lt;/p&gt;

&lt;p&gt;I build cost-optimized inference for document-extraction workloads with 100+ fields. This is the practitioner's version of what actually moves the number. The moves below, applied in sequence, take per-document cost down by &lt;strong&gt;85 to 93 percent&lt;/strong&gt; with negligible accuracy impact. The interesting part is not any single technique. It is why they compound instead of overlap.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set a trusted baseline with the expensive model first, then prove a cheaper model can match it.&lt;/li&gt;
&lt;li&gt;Skip fields a document type cannot contain (adaptive selection).&lt;/li&gt;
&lt;li&gt;Route each field to the cheapest model that can do the job (tiered routing + distillation).&lt;/li&gt;
&lt;li&gt;Stop re-extracting unchanged fields (field-level caching).&lt;/li&gt;
&lt;li&gt;Stop paying for idle (serverless scale-to-zero).&lt;/li&gt;
&lt;li&gt;The savings multiply, not add, because each move attacks a different kind of waste.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  🧭 Set the baseline with the expensive model first
&lt;/h2&gt;

&lt;p&gt;Before optimizing anything, run the workload on a frontier model and treat those results as the baseline. Early on the goal is not low cost. It is output you trust and a confidence signal you believe. Once that baseline exists, it becomes the yardstick for everything cheaper.&lt;/p&gt;

&lt;p&gt;Then, field by field, test whether a smaller and cheaper model can match it. Run the cheap model over the same documents, compare against the frontier baseline, and keep the fields where agreement is high. The fields that match move to the cheaper tier. The fields that do not stay on the frontier model.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is how the tier percentages below get decided in practice. You are not guessing which fields a lightweight model can handle. You are measuring it against output you already trust.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same comparison runs later as the quality-floor monitor at the end of this post. It starts here, as the step that earns each downgrade.&lt;/p&gt;
&lt;h2&gt;
  
  
  ✂️ Start by not doing unnecessary work: adaptive field selection
&lt;/h2&gt;

&lt;p&gt;The cheapest extraction call is the one you never make.&lt;/p&gt;

&lt;p&gt;A contract schema might define 100+ fields, but a given document, say a simple service agreement, only contains 40 to 50 of them. Extracting the rest produces "not found" responses that cost tokens and return nothing. Classify the document type first, then extract only the fields that can actually appear in it.&lt;/p&gt;

&lt;p&gt;This one move eliminates 30 to 40 percent of calls per document. It is the right thing to do first, because every later optimization then operates on a smaller base.&lt;/p&gt;
&lt;h2&gt;
  
  
  🎯 Right-size the model to the field: tiered routing
&lt;/h2&gt;

&lt;p&gt;Not every field needs the same horsepower. In a typical extraction job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~65 to 70 percent of fields are structurally simple:&lt;/strong&gt; dates, amounts, party names, reference numbers, categorical flags. Predictable formats, consistent locations. A lightweight model handles these at a fraction of frontier cost with equivalent accuracy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~20 percent need moderate reasoning:&lt;/strong&gt; interpreting indirect phrasing, resolving ambiguous references, synthesizing across sections. A mid-tier model (for example a Haiku or Nova Lite class model) is plenty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~10 percent genuinely need a frontier model:&lt;/strong&gt; obligation summaries, risk assessment, termination-condition analysis, cross-document context.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Route each field to the cheapest tier that can do the job. The routing decision is nearly free. It is a field-type to tier lookup table plus a confidence-based fallback, so a tier that returns low confidence escalates to the next one. No ML is needed for routing, because the mapping is stable for a given document corpus. Blended per-document cost drops 60 to 75 percent, and quality holds because the hard fields still get frontier attention. The split above is not assumed. It comes straight out of the baseline comparison from the previous section.&lt;/p&gt;
&lt;h3&gt;
  
  
  Distillation: earn your way to a cheaper Tier 1
&lt;/h3&gt;

&lt;p&gt;Tiered routing gets better when Tier 1 is a model you distilled for your own documents. Run a frontier model (the teacher) over 200 to 500 representative documents to produce gold-standard extractions, then train a smaller student model to reproduce that behavior on your specific fields. Managed services such as Amazon Bedrock Model Distillation handle the training without ML expertise.&lt;/p&gt;

&lt;p&gt;The student model learns your narrow task, the dates, amounts, party names, and term lengths you see every day, and it runs much cheaper and faster with a small accuracy gap (about 2 percent) on the tasks it was trained for. Fields outside its training distribution fall through to the mid-tier or frontier model.&lt;/p&gt;

&lt;p&gt;The bootstrap problem is obvious. You do not have 200 documents on day one, so distill progressively. Months one and two run routing-only on general-purpose models (40 to 50 percent savings) while storing every validated result as future training data. In month three, once you have accumulated enough examples, distillation runs and Tier 1 becomes your student model (another 30 to 40 percent off). Each quarter you re-distill with more data, and the share of fields handled by the cheapest tier climbs from about 70 percent toward 85 percent. The cost curve bends down the longer the system runs.&lt;/p&gt;
&lt;h2&gt;
  
  
  ♻️ Stop paying twice: field-level caching
&lt;/h2&gt;

&lt;p&gt;Documents get reprocessed when schemas are updated, prompts improve, or a quality review flags systematic errors. Without caching, reprocessing 200 documents costs exactly what processing them the first time did.&lt;/p&gt;

&lt;p&gt;Key each result by the document-section SHA-256, the field name, the prompt version, and the model version. If all four match, return the cached value with zero inference. When you update a prompt for one field type, only that field's cache entries invalidate, and everything else serves from cache. A prompt update typically touches three to five field types, so 85 to 90 percent of fields serve from cache on reprocessing. For a 200-document corpus reprocessed after a four-field prompt change, that is 800 extractions instead of 7,000, an 88 percent reduction on that cycle.&lt;/p&gt;
&lt;h2&gt;
  
  
  🛑 Stop paying for idle: serverless scale-to-zero
&lt;/h2&gt;

&lt;p&gt;A 200-document-per-month workload is idle most of the time. Documents land in object storage, a lightweight function writes metadata that triggers a stream event, and a containerized extraction agent runs only when work exists. During idle periods the system costs nothing beyond storage. For bursty, low-volume workloads this removes the fixed infrastructure floor that usually makes small deployments uneconomical. KEDA is a clean way to get scale-to-zero and event-driven scaling for this on Kubernetes.&lt;/p&gt;
&lt;h2&gt;
  
  
  🧮 Why these compound instead of add
&lt;/h2&gt;

&lt;p&gt;A tempting mistake is to add the savings together. Distillation saves 75 percent, adaptive selection 35 percent, caching 30 percent, so the total must be 140 percent. That is impossible. The techniques do not each operate on the original cost. Each operates on the residual the previous stage left behind. They address different sources of waste, so they multiply:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;Operates on&lt;/th&gt;
&lt;th&gt;Residual&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Adaptive field selection&lt;/td&gt;
&lt;td&gt;Total field count&lt;/td&gt;
&lt;td&gt;60 to 70 percent of baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Distillation + tiered routing&lt;/td&gt;
&lt;td&gt;Remaining fields&lt;/td&gt;
&lt;td&gt;20 to 25 percent of baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Field-level caching&lt;/td&gt;
&lt;td&gt;Reprocessed documents&lt;/td&gt;
&lt;td&gt;10 to 15 percent on reprocessing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Serverless scale-to-zero&lt;/td&gt;
&lt;td&gt;Infrastructure&lt;/td&gt;
&lt;td&gt;Zero idle cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Result&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Compound effect&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;End to end&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5 to 15 percent of naive baseline&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Roughly &lt;code&gt;(1 - 0.35) x (1 - 0.65) x (1 - 0.25)&lt;/code&gt;, which lands around 0.07 to 0.15 of the original, an &lt;strong&gt;85 to 93 percent reduction&lt;/strong&gt;. Each technique matters most at a different stage. Adaptive selection eliminates unnecessary work, distillation reduces the cost of necessary work, caching eliminates repeated work, and serverless eliminates idle overhead. No one of them gets you there alone.&lt;/p&gt;
&lt;h2&gt;
  
  
  🛡️ Making it safe: the quality-floor monitor
&lt;/h2&gt;

&lt;p&gt;Aggressive cost optimization earns an obvious objection. If 70 percent of fields go to a cheaper model, you need a way to know it is not silently getting worse, or drifting as document patterns change.&lt;/p&gt;

&lt;p&gt;Build the check in. Each week, randomly re-run 5 to 10 percent of Tier-1 fields through the frontier model as a shadow extraction and compare. This is the same baseline comparison from the start of the post, now running continuously. Agreement at or above 98 percent means the floor is holding. Below 95 percent raises an alert. Below 90 percent auto-triggers re-distillation on the last three months of data. Shadow validation adds only about 3 to 5 percent to total cost, and it means the system monitors itself, with no dedicated QA team required.&lt;/p&gt;

&lt;p&gt;The same monitor also spots promotions. If the mid-tier and frontier models consistently agree on a field currently routed to Tier 2, that field is a candidate to fold into the next distillation cycle, so the system keeps shifting work to the cheapest tier on its own.&lt;/p&gt;
&lt;h2&gt;
  
  
  💡 The payoff beyond the bill
&lt;/h2&gt;

&lt;p&gt;The reason this is worth doing well is not only the savings on an existing workload. Every percentage point off per-unit cost widens the set of use cases where automation is worth building at all. A workload that only penciled out for one of your five document types at naive pricing might clear the bar for three or four once the architecture is right. Cost optimization is not a late-stage cleanup. Treat it as a first-class design requirement alongside accuracy, latency, and reliability, and more of what you want to automate becomes viable.&lt;/p&gt;

&lt;p&gt;Five moves, five kinds of waste:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expensive-model baseline, then prove the cheap model&lt;/strong&gt; turns every downgrade into a measured decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adaptive field selection&lt;/strong&gt; removes unnecessary work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tiered routing + distillation&lt;/strong&gt; reduces the cost of necessary work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field-level caching&lt;/strong&gt; removes repeated work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serverless scale-to-zero&lt;/strong&gt; removes idle overhead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Apply them in sequence, guard them with a self-checking quality floor, and you get frontier-grade extraction at a small fraction of the naive cost, without betting accuracy on it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two patterns I open-sourced
&lt;/h2&gt;

&lt;p&gt;Two of the ideas above are available as small, dependency-light libraries under AWS Samples (MIT-0), if you want a reference to build from:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/aws-samples" rel="noopener noreferrer"&gt;
        aws-samples
      &lt;/a&gt; / &lt;a href="https://github.com/aws-samples/sample-textract-field-memory" rel="noopener noreferrer"&gt;
        sample-textract-field-memory
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Spatial field location memory for document processing pipelines. Learns field positions, validates extractions, identifies document types by layout, detects drift, and monitors template health. Zero dependencies, pure Python.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;textract-field-memory&lt;/h1&gt;
&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Disclaimer&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;This is sample code, for non-production usage. You should work with your security
and legal teams to meet your organizational security, regulatory, and compliance
requirements before deployment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; When processing documents containing PII (e.g. SSNs), PHI, or payment data, ensure your implementation meets applicable compliance requirements (HIPAA, PCI-DSS, GDPR, etc.). This library stores only field names and bounding-box coordinates — never field values or document content — but you remain responsible for securing the underlying documents.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The Problem&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Every system that processes recurring structured data starts from scratch each time. It has no memory of what it saw yesterday.&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;                    WITHOUT spatial memory
┌──────────────┐
│  Document 1  │──► OCR/Extract ──► "Employee Name" at (0.05, 0.10) ──► ✓ stored nowhere
│  Document 2  │──► OCR/Extract ──► "Employee Name" at (0.05, 0.10) ──► ✓ re-discovered
│  Document 3  │──► OCR/Extract ──► "Employee Name" at (0.05, 0.10) ──► ✓ re-discovered again
│&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/aws-samples/sample-textract-field-memory" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/aws-samples" rel="noopener noreferrer"&gt;
        aws-samples
      &lt;/a&gt; / &lt;a href="https://github.com/aws-samples/sample-prompt-correction-memory" rel="noopener noreferrer"&gt;
        sample-prompt-correction-memory
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Self-improving LLM document extraction on AWS. Each human QA correction improves future extractions via few-shot self-healing and deterministic rule graduation — no retraining, no redeployment. Costs decrease over time.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;sample-prompt-correction-memory&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;A sample project showing automated prompt self-correction for LLM document extraction on AWS. Each human correction improves future extractions — no retraining, no redeployment.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/5266ef63d0fec1ad4cee104c7aee0ad0f084d6c1388d39adfe556c420a0a28d2/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f707974686f6e2d332e392532422d626c7565"&gt;&lt;img src="https://camo.githubusercontent.com/5266ef63d0fec1ad4cee104c7aee0ad0f084d6c1388d39adfe556c420a0a28d2/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f707974686f6e2d332e392532422d626c7565" alt="Python 3.9+"&gt;&lt;/a&gt; &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/e63a95ba96d478a9121e4719c337752da4f4cbd273c68ca5bbb73a1af276dfb5/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d2d302d626c7565"&gt;&lt;img src="https://camo.githubusercontent.com/e63a95ba96d478a9121e4719c337752da4f4cbd273c68ca5bbb73a1af276dfb5/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d2d302d626c7565" alt="License"&gt;&lt;/a&gt; &lt;a rel="noopener noreferrer nofollow" href="https://camo.githubusercontent.com/37fe70d0c1cfa6589fb7f88ed2367f10070c3f228370fc72224e11210ef61571/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7374617475732d73616d706c652d6f72616e6765"&gt;&lt;img src="https://camo.githubusercontent.com/37fe70d0c1cfa6589fb7f88ed2367f10070c3f228370fc72224e11210ef61571/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7374617475732d73616d706c652d6f72616e6765" alt="Status"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; This is sample code, for non-production usage. You should work with your security and legal teams to meet your organizational security, regulatory and compliance requirements before deployment. Do not use this sample with personal data, health data or other regulated data.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/aws-samples/sample-prompt-correction-memory/docs/flywheel.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Faws-samples%2Fsample-prompt-correction-memory%2FHEAD%2Fdocs%2Fflywheel.svg" alt="Correction memory flywheel: rules, cloud LLM, and self-healing tiers feeding a growing correction log" width="720"&gt;&lt;/a&gt;
&lt;/p&gt;




&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;The Problem&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;Every LLM-based extraction pipeline makes the same mistakes repeatedly. Your QA team corrects an error today, but the model makes the identical error on tomorrow's document. The correction evaporates.&lt;/p&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;                    WITHOUT correction memory
┌──────────────┐
│  Document 1  │──► LLM Extract ──► "quarterly" for payment_terms  ──► QA corrects to "30"
│  Document 2  │──► LLM Extract ──► "quarterly" for payment_terms  ──► QA corrects AGAIN
│  Document 3  │──► LLM Extract ──► "quarterly" for payment_terms  ──► QA corrects AGAIN
│     ...      │
└──────────────┘
    Same&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/aws-samples/sample-prompt-correction-memory" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;






&lt;p&gt;&lt;em&gt;If you are optimizing an extraction pipeline, I would be curious where your biggest cost sink turned out to be: unnecessary calls, over-powered models, reprocessing, or idle. Drop a comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>aws</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Your LLM Keeps Making the Same Extraction Mistake. Here's How to Make It Learn.</title>
      <dc:creator>Avneet bansal</dc:creator>
      <pubDate>Sat, 03 Oct 2026 01:32:58 +0000</pubDate>
      <link>https://dev.to/avneet_bansal_a65b3f31fc4/your-llm-keeps-making-the-same-extraction-mistake-heres-how-to-make-it-learn-5bjb</link>
      <guid>https://dev.to/avneet_bansal_a65b3f31fc4/your-llm-keeps-making-the-same-extraction-mistake-heres-how-to-make-it-learn-5bjb</guid>
      <description>&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%2Fqhubyrft3pyfvanjeee2.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%2Fqhubyrft3pyfvanjeee2.png" alt=" " width="800" height="419"&gt;&lt;/a&gt;&amp;gt; &lt;strong&gt;TL;DR&lt;/strong&gt; — I open-sourced an AWS sample that makes LLM document extraction &lt;em&gt;learn from human corrections&lt;/em&gt;. Each QA fix is captured once and reused, so the pipeline gets &lt;strong&gt;more accurate and cheaper over time&lt;/strong&gt; — with no retraining and no redeployment. Repo: &lt;a href="https://github.com/aws-samples/sample-prompt-correction-memory" rel="noopener noreferrer"&gt;github.com/aws-samples/sample-prompt-correction-memory&lt;/a&gt;. You can run the whole self-healing loop locally in 30 seconds, no AWS account needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: corrections that evaporate
&lt;/h2&gt;

&lt;p&gt;If you've built an LLM extraction pipeline, you know this pain.&lt;/p&gt;

&lt;p&gt;Your QA analyst opens today's invoice. The model extracted &lt;code&gt;payment_terms&lt;/code&gt; as &lt;strong&gt;"quarterly."&lt;/strong&gt; That's wrong — "quarterly" is the &lt;em&gt;billing frequency&lt;/em&gt;; the actual payment term is &lt;strong&gt;Net 30&lt;/strong&gt;. The analyst corrects it. Done.&lt;/p&gt;

&lt;p&gt;Tomorrow, an identical invoice from the same vendor arrives. The model extracts... &lt;strong&gt;"quarterly"&lt;/strong&gt; again. Same mistake. The analyst corrects it again.&lt;/p&gt;

&lt;p&gt;The correction &lt;em&gt;evaporated.&lt;/em&gt; It fixed one document and taught the system nothing.&lt;/p&gt;

&lt;p&gt;Multiply that across thousands of documents and dozens of recurring error patterns, and you get the two options most teams settle for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pay analysts to re-fix the same patterns forever&lt;/strong&gt; (QA cost never goes down), or&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fine-tune a model&lt;/strong&gt; (expensive, slow, needs ML ops, and you redo it every time the patterns shift).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's a third way that needs neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: treat corrections as durable memory
&lt;/h2&gt;

&lt;p&gt;What if every human correction became a permanent, reusable asset?&lt;/p&gt;

&lt;p&gt;That's the pattern behind &lt;a href="https://github.com/aws-samples/sample-prompt-correction-memory" rel="noopener noreferrer"&gt;&lt;code&gt;sample-prompt-correction-memory&lt;/code&gt;&lt;/a&gt;. Instead of throwing corrections away, it stores them and feeds them back into future extractions — so the same mistake is never made twice.&lt;/p&gt;

&lt;p&gt;It works as &lt;strong&gt;three tiers&lt;/strong&gt;, and the magic is that traffic gradually shifts from the expensive tier to the free one:&lt;/p&gt;

&lt;h3&gt;
  
  
  🟢 Tier 1 — Graduated rules ($0, no LLM call)
&lt;/h3&gt;

&lt;p&gt;When the same correction pattern recurs enough times, the system synthesizes a &lt;strong&gt;deterministic rule&lt;/strong&gt; (a regex, a lookup, a normalization) that handles that field with zero LLM cost and sub-millisecond latency. "Net 30" → &lt;code&gt;30&lt;/code&gt; becomes a rule; it never needs a model again.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔵 Tier 2 — Cloud LLM extraction (confidence-gated)
&lt;/h3&gt;

&lt;p&gt;Fields with no matching rule go to &lt;strong&gt;Amazon Bedrock (Claude Haiku 4.5)&lt;/strong&gt;. Every field comes back with a &lt;strong&gt;confidence score&lt;/strong&gt;. If confidence clears the threshold, accept it and move on.&lt;/p&gt;

&lt;h3&gt;
  
  
  🟠 Tier 3 — Self-healing (only when needed)
&lt;/h3&gt;

&lt;p&gt;If confidence is &lt;em&gt;below&lt;/em&gt; threshold, the system retrieves the most relevant past corrections, formats them as &lt;strong&gt;few-shot examples&lt;/strong&gt;, and re-extracts with a stronger model (&lt;strong&gt;Claude Sonnet 4.5&lt;/strong&gt;). The model learns from the specific mistakes it made before — in-context, no training.&lt;/p&gt;

&lt;p&gt;Every QA correction flows into a &lt;strong&gt;correction log&lt;/strong&gt; (DynamoDB) that powers both the self-healing retrieval &lt;em&gt;and&lt;/em&gt; the rule graduation. So the more the system is used, the smarter and cheaper it gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters (the practical payoff)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No ML ops.&lt;/strong&gt; No retraining, no GPUs, no deployment cycles. A correction takes effect on the &lt;em&gt;very next&lt;/em&gt; document.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost drops as quality rises.&lt;/strong&gt; Normally you trade one for the other. Here, recurring fixes graduate to $0 rules — so accuracy climbs while per-document cost falls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QA effort compounds instead of repeating.&lt;/strong&gt; Fix it once → fixed forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's a QA action, not an ML project.&lt;/strong&gt; Improving the pipeline no longer requires the ML team. An analyst uploads a correction; the system improves.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The clearest fit is &lt;strong&gt;high-repetition, back-office document processing&lt;/strong&gt; — accounts payable, contracts, compliance filings, supply-chain docs — where the same field errors recur and analyst time is the real expense.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;Fully serverless, deployed with AWS SAM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Document ──► S3 ──► EventBridge ──► SQS ──► Lambda (extract)
                                              │
                          ┌───────────────────┼───────────────────┐
                          ▼                    ▼                   ▼
                   Tier 1: Rules       Tier 2: Bedrock      Tier 3: Self-heal
                   (deterministic)     (Claude Haiku 4.5)   (Claude Sonnet 4.5)
                          ▲                                        │
                          │                                        ▼
                          └──────── Correction Log (DynamoDB) ◄────┘
                                    (grows from QA feedback)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;QA corrections are uploaded as JSON to an S3 &lt;code&gt;corrections/&lt;/code&gt; prefix; a second Lambda validates and ingests them into the correction log. Everything is encrypted with a customer-managed KMS key, and IAM is scoped to the specific Bedrock model ARNs the sample uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  See it in 30 seconds (no AWS account)
&lt;/h2&gt;

&lt;p&gt;The quickstart runs the entire self-healing loop with a mocked Bedrock client, so you can watch the mechanism without deploying anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/aws-samples/sample-prompt-correction-memory
&lt;span class="nb"&gt;cd &lt;/span&gt;sample-prompt-correction-memory
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;".[dev]"&lt;/span&gt;
python examples/quickstart.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see output like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Step 1: Initial extraction (no correction memory)
  Field:      effective_date
  Value:      March 2024        Confidence: 0.55   ❌ NO

Step 2: Self-healing triggers (0.55 &amp;lt; threshold 0.70)
  Retrieving corrections for 'effective_date'...
  Found: "March 2024" → "2024-03-01"

Step 3: Re-extraction with correction memory
  Value:       2024-03-01       Confidence: 0.95   ✓ YES
  Self-Healed: True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Low-confidence extraction → retrieve past correction → re-extract → correct answer. No retraining. No redeployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying to AWS
&lt;/h2&gt;

&lt;p&gt;With the AWS CLI, SAM CLI, and Bedrock model access for Claude Haiku 4.5 + Sonnet 4.5:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make deploy    &lt;span class="c"&gt;# S3, DynamoDB, Lambda, EventBridge, SQS, KMS&lt;/span&gt;
make seed      &lt;span class="c"&gt;# load sample corrections into the correction log&lt;/span&gt;
make trigger   &lt;span class="c"&gt;# upload a sample document → triggers real extraction&lt;/span&gt;
make verify    &lt;span class="c"&gt;# read back results (fields, confidence, self_healed)&lt;/span&gt;
make destroy   &lt;span class="c"&gt;# empty buckets + delete the stack&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;make verify&lt;/code&gt; prints each extracted field with its confidence and whether self-healing kicked in — your proof it works end-to-end.&lt;/p&gt;

&lt;h2&gt;
  
  
  A companion: spatial memory
&lt;/h2&gt;

&lt;p&gt;This sample provides &lt;strong&gt;semantic&lt;/strong&gt; correction memory (what was extracted wrong, and why). Its companion, &lt;a href="https://github.com/aws-samples/sample-textract-field-memory" rel="noopener noreferrer"&gt;&lt;code&gt;sample-textract-field-memory&lt;/code&gt;&lt;/a&gt;, provides &lt;strong&gt;spatial&lt;/strong&gt; memory (where fields appear on a document layout). Together they form a dual memory for document pipelines: use the cheap spatial lookup when you're confident where a field is, and fall back to self-healing extraction when you're not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;p&gt;Because it's more useful when it's used well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's &lt;strong&gt;sample/reference code&lt;/strong&gt;, not a production library. Harden IAM, add monitoring, and put a human in the loop for high-stakes fields before real use.&lt;/li&gt;
&lt;li&gt;It helps most where the &lt;strong&gt;same field errors recur&lt;/strong&gt;. For wildly heterogeneous, one-off documents, there's less pattern to learn.&lt;/li&gt;
&lt;li&gt;Graduated rules currently take effect &lt;strong&gt;without a human approval step&lt;/strong&gt; — in production you'd add a review gate before a synthesized rule starts answering on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't use it with regulated data&lt;/strong&gt; (PII/PHI/PCI) as-is; that path needs a proper compliance review.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it and tell me
&lt;/h2&gt;

&lt;p&gt;The repo is open source under &lt;code&gt;aws-samples&lt;/code&gt;, MIT-0 licensed, with a benchmark suite, an interactive dashboard, and an offline test harness.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://github.com/aws-samples/sample-prompt-correction-memory" rel="noopener noreferrer"&gt;github.com/aws-samples/sample-prompt-correction-memory&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What repetitive extraction error is your team still fixing by hand? I'd love to hear which patterns you'd want a system like this to learn.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is an open-source AWS sample intended for demonstration and non-production use.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>bedrock</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
