<?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: Marc</title>
    <description>The latest articles on DEV Community by Marc (@marc_kumiko).</description>
    <link>https://dev.to/marc_kumiko</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%2F4001120%2F74d319bd-196a-45cb-843d-d70b2e6a54c5.png</url>
      <title>DEV Community: Marc</title>
      <link>https://dev.to/marc_kumiko</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marc_kumiko"/>
    <language>en</language>
    <item>
      <title>Our framework docs, three models, isolated agents: 99 seconds, 50 minutes, and a timeout</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Mon, 26 Oct 2026 19:53:45 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/our-framework-docs-three-models-isolated-agents-99-seconds-50-minutes-and-a-timeout-4jlf</link>
      <guid>https://dev.to/marc_kumiko/our-framework-docs-three-models-isolated-agents-99-seconds-50-minutes-and-a-timeout-4jlf</guid>
      <description>&lt;p&gt;A week ago we wrote about an eval for the agent docs that ship inside our framework package. A fresh coding agent passed all four tasks, but the run had a hole: the agents inherited our own Claude Code config, so nothing proved that the package docs alone were enough. The fix was to run each agent in isolation. That run is done now. We also gave the first task to two local models to see how far they get on a 48 GB Mac.&lt;/p&gt;

&lt;p&gt;Kumiko is our TypeScript framework for multi-tenant apps on Bun, Hono and Postgres. Since 0.364.0 the npm package contains an &lt;code&gt;AGENTS.md&lt;/code&gt; plus generated indexes of features, widgets and sample apps. The eval checks whether an agent that has never seen the framework source can build features from that.&lt;/p&gt;

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

&lt;p&gt;Each task gets a fresh app scaffolded from the published packages, version 0.393.0. The agent runs as &lt;code&gt;claude -p&lt;/code&gt; (Claude Code 2.1.310) in the project directory with &lt;code&gt;CLAUDE_CONFIG_DIR&lt;/code&gt; pointing at an empty temp directory, so none of our skills, rules or memory leak in. It sees the app, its &lt;code&gt;node_modules&lt;/code&gt; and the task text. Afterwards a grader copies in a hidden acceptance test and runs eight checks: typecheck, lint, unit tests, boot, schema validation, the acceptance test against a real Postgres, and two guard runs.&lt;/p&gt;

&lt;p&gt;The four tasks: an entity with CRUD and a list screen, custom write handlers, an overview screen with stats, and wiring in the bundled tags feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the grader was broken
&lt;/h2&gt;

&lt;p&gt;The first isolated Sonnet run failed every task. The agents' code was fine. Our grader had rotted.&lt;/p&gt;

&lt;p&gt;The acceptance tests imported &lt;code&gt;setupAppTestStack&lt;/code&gt; and &lt;code&gt;seedTenant&lt;/code&gt; from &lt;code&gt;@cosmicdrift/kumiko-testing&lt;/code&gt;. Those helpers moved to &lt;code&gt;@cosmicdrift/kumiko-dev-server/testing&lt;/code&gt; some releases ago, and the templates never followed. The guard check ran &lt;code&gt;bunx kumiko-guards@0.393.0&lt;/code&gt;, which installs the guards package on its own. That package imports &lt;code&gt;@cosmicdrift/kumiko-types&lt;/code&gt; without declaring it, so it crashed before checking anything. Inside an app the import resolves by luck, because the framework pulls the types package in.&lt;/p&gt;

&lt;p&gt;We fixed the templates, made the grader run the guards the project already has installed, and declared the missing dependency (kumiko-framework#4074). All results below come from that fixed grader.&lt;/p&gt;

&lt;p&gt;The funny part came from Sonnet's own report on task 02. Each agent ends with a list of things the docs didn't tell it. Sonnet wrote that &lt;code&gt;AGENTS.md&lt;/code&gt; names the wrong package for &lt;code&gt;setupAppTestStack&lt;/code&gt; and &lt;code&gt;seedTenant&lt;/code&gt;. It was right: our docs had the same stale import as our grader. The agent noticed, copied the working import from the scaffolded test, and moved on. That line is fixed in kumiko-framework#4075.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sonnet 5.5: four out of four
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Checks&lt;/th&gt;
&lt;th&gt;Wall time&lt;/th&gt;
&lt;th&gt;Responses / tool calls&lt;/th&gt;
&lt;th&gt;Median response&lt;/th&gt;
&lt;th&gt;p90 response&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;01 entity CRUD&lt;/td&gt;
&lt;td&gt;8/8&lt;/td&gt;
&lt;td&gt;99 s&lt;/td&gt;
&lt;td&gt;13 / 12&lt;/td&gt;
&lt;td&gt;4.0 s&lt;/td&gt;
&lt;td&gt;7.9 s&lt;/td&gt;
&lt;td&gt;$0.24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;02 custom handlers&lt;/td&gt;
&lt;td&gt;8/8&lt;/td&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;td&gt;8 / 7&lt;/td&gt;
&lt;td&gt;3.6 s&lt;/td&gt;
&lt;td&gt;12.2 s&lt;/td&gt;
&lt;td&gt;$0.22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;03 overview screen&lt;/td&gt;
&lt;td&gt;8/8&lt;/td&gt;
&lt;td&gt;35 s&lt;/td&gt;
&lt;td&gt;4 / 3&lt;/td&gt;
&lt;td&gt;5.8 s&lt;/td&gt;
&lt;td&gt;15.3 s&lt;/td&gt;
&lt;td&gt;$0.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;04 bundled tags&lt;/td&gt;
&lt;td&gt;8/8&lt;/td&gt;
&lt;td&gt;73 s&lt;/td&gt;
&lt;td&gt;7 / 6&lt;/td&gt;
&lt;td&gt;6.3 s&lt;/td&gt;
&lt;td&gt;21.4 s&lt;/td&gt;
&lt;td&gt;$0.20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Response time" is measured from the moment the agent gets a prompt or a tool result to the last block of the model's answer, taken from the stream timestamps. All four tasks took under five minutes and $0.82 in total. The grader also scans the diff for patterns our rules forbid. It found one hit, an &lt;code&gt;any&lt;/code&gt; inside a string literal in task 04, which is a false positive.&lt;/p&gt;

&lt;p&gt;So the claim from last week holds with isolation: the package docs are enough for a strong model to finish these four tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two local models on task 01
&lt;/h2&gt;

&lt;p&gt;The local runs used the same Claude Code client against a local Anthropic-compatible endpoint. The machine is an M4 Pro with 48 GB of unified memory, of which about 37 GiB is available to the GPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strata 0.2.6 with qwen3.8-flash-next at Q2_0 and 128K context.&lt;/strong&gt; The weights are 66 GB, so the model does not fit in GPU memory at all. We wanted to see whether it runs anyway. It did, at about 4.5 tokens per second for output and 9 tokens per second for reading the prompt. Every request carried 39k to 43k tokens of context, and almost all of that came from the prompt cache; without the cache this run would not have finished in any useful time.&lt;/p&gt;

&lt;p&gt;It passed. All eight checks green, no hits in the rule scan, in 50 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Qwen3.5 9B on Ollama 0.31.2 with 64K context.&lt;/strong&gt; At 6.6 GB it fits entirely on the GPU and answers faster per turn. We gave it a 60-minute cap. It hit the cap with two of eight checks green, and those two were the guards, which pass on an app that barely changed. The app never booted ("Export named 'contactsFeature' not found"). On the way the model wrote feature code into a &lt;code&gt;.gitignore&lt;/code&gt; file and kept rewriting the same files, and Claude Code compacted its context twice.&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;Checks&lt;/th&gt;
&lt;th&gt;Wall time&lt;/th&gt;
&lt;th&gt;Responses / tool calls&lt;/th&gt;
&lt;th&gt;First response&lt;/th&gt;
&lt;th&gt;Median response&lt;/th&gt;
&lt;th&gt;p90 response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sonnet 5.5 (API)&lt;/td&gt;
&lt;td&gt;8/8&lt;/td&gt;
&lt;td&gt;99 s&lt;/td&gt;
&lt;td&gt;13 / 12&lt;/td&gt;
&lt;td&gt;4.5 s&lt;/td&gt;
&lt;td&gt;4.0 s&lt;/td&gt;
&lt;td&gt;7.9 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strata Q2_0, 66 GB&lt;/td&gt;
&lt;td&gt;8/8&lt;/td&gt;
&lt;td&gt;50 min&lt;/td&gt;
&lt;td&gt;49 / 68&lt;/td&gt;
&lt;td&gt;276 s&lt;/td&gt;
&lt;td&gt;36.8 s&lt;/td&gt;
&lt;td&gt;141 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.5 9B, Ollama&lt;/td&gt;
&lt;td&gt;2/8&lt;/td&gt;
&lt;td&gt;60 min, stopped&lt;/td&gt;
&lt;td&gt;82 / 81&lt;/td&gt;
&lt;td&gt;67 s&lt;/td&gt;
&lt;td&gt;26.5 s&lt;/td&gt;
&lt;td&gt;66.9 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first response is the slowest one for the local models because the whole system prompt and tool list have to be read once. For Strata that took four and a half minutes; its slowest answer took five.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers don't say
&lt;/h2&gt;

&lt;p&gt;Every model ran each task once. One pass or one failure is an anecdote.&lt;/p&gt;

&lt;p&gt;The local runs had output capped at 4,096 tokens per response. Sonnet had no cap. The context windows differed too: 128K for Strata, 64K for the 9B.&lt;/p&gt;

&lt;p&gt;The local models only got task 01, the simplest one. A 50-minute pass on that says little about tasks 02 to 04.&lt;/p&gt;

&lt;p&gt;Strata's speed depends heavily on the prompt cache. A task that invalidates the cache more often would be much slower.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the docs still miss
&lt;/h2&gt;

&lt;p&gt;Each agent lists what it had to guess. These came up in the isolated runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;personalData&lt;/code&gt; is described for writes, but not what happens on reads or sorting. Sonnet asked whether a PII field can be a default sort.&lt;/li&gt;
&lt;li&gt;How a screen gets registered in &lt;code&gt;client.tsx&lt;/code&gt; isn't documented.&lt;/li&gt;
&lt;li&gt;What a create result contains, and that a &lt;code&gt;ne&lt;/code&gt; filter excludes NULL rows.&lt;/li&gt;
&lt;li&gt;Which error class fits "this is already done".&lt;/li&gt;
&lt;li&gt;How to wire a tags section into an entity edit screen, and whether &lt;code&gt;entityName&lt;/code&gt; must be set. The tags recipe also uses the framework's internal test setup instead of the app's.&lt;/li&gt;
&lt;li&gt;Strata asked which &lt;code&gt;find&lt;/code&gt; level fits and which translation key a row action label uses.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these blocked a pass, which is the uncomfortable part. A strong model fills these gaps by reading compiled code or guessing well, and the eval stays green.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we take from it
&lt;/h2&gt;

&lt;p&gt;The isolated run confirms the docs work for Sonnet. It also showed that our grader and our docs had drifted from the code in the same place, and the agent caught it before we did. An eval that nobody runs for a week rots like any other code.&lt;/p&gt;

&lt;p&gt;For local models, a heavily quantized model that doesn't fit in memory finished task 01 correctly, and a small model that fits easily didn't. In this one run, model size mattered more than speed.&lt;/p&gt;

&lt;p&gt;If you've run coding agents on local models against a real codebase: did a bigger model at 2 bits beat a smaller one at 4 or 8 bits for you, or was our run a fluke?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>typescript</category>
      <category>llm</category>
    </item>
    <item>
      <title>Changing a mail template used to need a deploy. We built an editor that refuses to publish a broken one</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Sat, 24 Oct 2026 20:50:03 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/changing-a-mail-template-used-to-need-a-deploy-we-built-an-editor-that-refuses-to-publish-a-broken-281f</link>
      <guid>https://dev.to/marc_kumiko/changing-a-mail-template-used-to-need-a-deploy-we-built-an-editor-that-refuses-to-publish-a-broken-281f</guid>
      <description>&lt;p&gt;Until recently, changing the wording of a password-reset mail in our framework meant editing a TypeScript file and shipping a release. The same went for the PDF a rental app sends as an invoice. Nobody on the operations side could touch either one.&lt;/p&gt;

&lt;p&gt;Looking at why, we found two mail systems that did not know about each other. One stored the mail as a JSON list of sections (text, button) in code, with the branding hardcoded next to it. The other stored a Markdown document in the database and rendered it through MJML, and exactly one app used it. PDFs were a third thing: HTML built in code and printed with Puppeteer. The tenant's logo and colors lived in two places, the static mail branding in app code and the branding tokens per tenant. The mail message type had no concept of attachments or inline images at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  One model for mail and PDF
&lt;/h2&gt;

&lt;p&gt;Every mail and every PDF is now a template: a slug, a locale, a document, and a small JSON meta object. The document is structured content that tiptap edits in the browser. The server rebuilds it through a sanitizer, so only known node types survive a save. Meta is validated per kind: a mail needs a subject and may have preview text, a layout, a category and a sender name. A PDF has page format, margins and a file name. Images and files are linked resources, uploaded once and referenced by key.&lt;/p&gt;

&lt;p&gt;Layout is a separate template. A mail layout (MJML) and a PDF letterhead each have exactly one required slot for the content, and they read the tenant's branding tokens instead of carrying their own copy. Changing the layout changes every mail that uses it, without touching any sender.&lt;/p&gt;

&lt;p&gt;Features no longer send sections. They declare which variables a mail needs and seed a default template at boot. This is the declaration for the waitlist notice in the framework:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WAITLIST_ADMIN_NOTICE_VARIABLES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TemplateVariables&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;templateVariable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;templateVariable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;templateVariable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The variable types are text, link, money, date and list (the schema also knows rich text and percent). Each one carries a label and an example value, and the example is what the preview fills in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scopes and the four-step lookup
&lt;/h2&gt;

&lt;p&gt;A template can exist at two scopes: the system default that the feature ships, and a tenant customisation. When a handler asks for a template, the resolver walks four steps: the tenant in the requested locale, the system in the requested locale, the tenant in the fallback locale, the system in the fallback locale. A tenant overrides a default by having its own row, and no application code changes.&lt;/p&gt;

&lt;p&gt;Who may edit sits outside the model. By default the platform admin edits the system templates, and an app can open individual templates to its tenant admins. Mails that must stay as shipped can be marked system only, and tenants neither see nor override them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The editor
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6g314yva60uz3s8j5ca.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%2Fj6g314yva60uz3s8j5ca.png" alt="Template overview with kind, origin, languages, status and where each template is used" width="799" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The overview lists mails, PDFs and layouts in one table. Each row shows where the template comes from (system or tenant customisation), which languages exist as filled chips, the status with its version, and which feature uses it. The usage column comes from the feature registry, so it is read from the code that sends the mail and nobody types it in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faw4ary40qiairby8o01h.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%2Faw4ary40qiairby8o01h.png" alt="Mail editor with the placeholder rail on the left, the paper in the middle and a blocked publish button below" width="799" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The mail editor has the placeholder rail on the left with type, required marker and a check for whether the placeholder is used. The middle holds subject, preview text and the content with placeholders as chips and blocks for buttons, images and repeated rows. The right side has layout, sender, category and attachments. In the screenshot, a required placeholder, the due date, appears nowhere in the subject, preview text or content. The footer says so and the publish button is disabled. Saving a draft still works.&lt;/p&gt;

&lt;p&gt;That rule is enforced on the server and not only in the editor. Saving rejects unknown placeholders. Saving an active template and publishing both check that every required variable is used. The editor only shows what the server would say.&lt;/p&gt;

&lt;p&gt;A draft over an active template does not replace it. The active version keeps being served while the edit sits beside it, and publish switches over. The first version got this wrong: a draft saved over an active tenant template overwrote the same row, the resolver fell back to the system text, and the tenant's active wording was gone. The draft now lives in its own column, and publish promotes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  PDFs
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa2esfol9e0f7pzbjfjte.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%2Fa2esfol9e0f7pzbjfjte.png" alt="PDF template editor with the rendered preview next to page format, margins, file name and PDF metadata" width="799" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;PDFs use the same document model, rendered through Puppeteer in a separate service that runs Chromium behind an egress guard. The preview is the real render. It shows the letterhead from the layout, the repeating rows from a list variable and the page count, and the footer line reports the last test render, here 2 pages, 67 KB, 2.3 seconds. Page format, margins, the file name pattern and the PDF metadata sit in the side panel. A generated PDF is stored with the template id and version it came from, so you can say later which wording a customer received.&lt;/p&gt;

&lt;p&gt;Without a running Puppeteer service the PDF editor is locked, because a preview that cannot render would only mislead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is not in this release
&lt;/h2&gt;

&lt;p&gt;Everything above is in the open framework, including the editors, the preview and the test mail. The AI assistant that edits templates from a sentence ("put the logo in the sign-up mail") is a separate piece that sits in our enterprise layer. It reads a template as a list of blocks and patches the draft through structured operations, and it never writes raw HTML. It shows the result as a preview first and saves it as a draft. Publishing stays a decision of the person using it. The same validation applies, so a suggestion that removes a required placeholder is rejected. Previewing a template with a real record is part of that layer too. This post is about the open part, so we only mention the assistant here.&lt;/p&gt;

&lt;p&gt;Also still missing: version history with restore, columns, PDFs without Puppeteer, and an editor control for conditional blocks. The renderer already shows or hides a paragraph or table row depending on whether a variable has a value; the editor just has no button for it yet.&lt;/p&gt;

&lt;p&gt;If you let non-developers edit transactional mail, what did you put between them and a broken invoice mail? We block publishing on a missing required placeholder and keep the active version served. We would like to hear what else you check before a template goes live.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>pdf</category>
    </item>
    <item>
      <title>Our comment section redesigned our agent permissions. Here's what we still can't solve</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Wed, 21 Oct 2026 07:08:14 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/our-comment-section-redesigned-our-agent-permissions-heres-what-we-still-cant-solve-236m</link>
      <guid>https://dev.to/marc_kumiko/our-comment-section-redesigned-our-agent-permissions-heres-what-we-still-cant-solve-236m</guid>
      <description>&lt;p&gt;In September I wrote about AI agents in our framework, Kumiko, and why a handler without a description never shows up in the agent's tool list: &lt;a href="https://dev.to/marc_kumiko/undocumented-handlers-dont-exist-to-the-agent-e71"&gt;Undocumented handlers don't exist to the agent&lt;/a&gt;. Two readers, phongdesigns and anp2network, went through the post more carefully than I had, and the thread under it ran to eleven comments over three days.&lt;/p&gt;

&lt;p&gt;Three changes to how our agent decides what it may do came out of that thread. We turned down two of the ideas, and there are three questions we haven't answered. This post covers all of it, because the open questions are the part I'd like help with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup in two sentences
&lt;/h2&gt;

&lt;p&gt;Every write handler in a Kumiko app can carry an agent risk (&lt;code&gt;low&lt;/code&gt;, &lt;code&gt;mid&lt;/code&gt; or &lt;code&gt;high&lt;/code&gt;), and the user can set mid-risk tools to "always allow" while high-risk tools ask every time. Back then, the risk was whatever the handler declared, plus two tests that pinned the risk of specific handlers by name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change 1: the risk comes from what the handler does
&lt;/h2&gt;

&lt;p&gt;anp2network pointed out that pinning by name doesn't catch the hard delete someone adds in six months. It defaults to mid, and nothing goes red. Their suggestion was to watch what a handler actually does and compare that with what it declares.&lt;/p&gt;

&lt;p&gt;That fit our code better than I expected. A hard delete in Kumiko doesn't go through the normal event append. It calls a separate forget operation on the event store, so the framework can see it. Since 0.322.0, a handler that runs forget, or a hard delete on an entity without soft delete, has to resolve to &lt;code&gt;high&lt;/code&gt;. Otherwise the executor denies the call before it reads anything. The check looks at the handler the caller dispatched directly, so a mid-risk handler can't get around it by delegating to a high-risk one internally. Standard delete handlers on those entities default to &lt;code&gt;high&lt;/code&gt;, and declaring one lower fails at boot.&lt;/p&gt;

&lt;p&gt;My follow-up comment listed after-commit hooks, jobs and event consumers as a gap. Since then, writes dispatched inside those contexts go through the same check, with a short list of deliberate exceptions such as cron cleanups and pipelines started by a high-risk handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change 2: the tool list and the dispatch table are tested against each other
&lt;/h2&gt;

&lt;p&gt;I had claimed that the model can't call a tool that isn't in its list, because the list and the dispatch table come from the same catalog. anp2network's reply was that this was only true because of how the code happened to be arranged, and no test checked it. Now a test asserts that every tool name has exactly one dispatch entry, and the other way round.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change 3: some fields are dangerous to write because of who reads them
&lt;/h2&gt;

&lt;p&gt;This one I didn't see coming. Our prompt store keeps every revision of a feature's system prompt. Editing a prompt was a high-risk action. Reverting to an older revision was mid, and a revert appends a copy of the old revision, which then becomes the live prompt. A user who set revert to "always allow" had given the agent a way to swap another feature's instructions without asking.&lt;/p&gt;

&lt;p&gt;A floor on the handler can't see that, because on the storage side the revert is an ordinary, reversible write. anp2network put it this way: the risk is a property of the destination. A later run reads that row as instructions.&lt;/p&gt;

&lt;p&gt;Since 0.324.0, a field can say so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;systemPrompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;createLongTextField&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;personal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is_business_data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;readAsInstruction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any create or update that writes such a field needs &lt;code&gt;high&lt;/code&gt; on the directly dispatched handler, through a generic update handler as well. The prompt store's content field carries the flag, so revert is high now.&lt;/p&gt;

&lt;p&gt;We also record how every agent write was approved: no approval needed, an "always" rule, or the user confirming it. "Always" isn't offered for high-risk tools, so the number of flagged writes approved by an "always" rule should be zero, and we can check that with a query.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we turned down
&lt;/h2&gt;

&lt;p&gt;anp2network noticed that a tool the user set to "never" vanishes from the model's view, so to the model "forbidden" looks exactly like "doesn't exist". The suggestion was a tombstone: keep the entry visible and have dispatch refuse it with its own reason. We didn't build it. "Never" is one of several filters that hide tools (roles, mode, an explicit deny list), so tombstones would either be inconsistent with the other filters or leak names those filters exist to hide. anp2network agreed with the reasoning. Instead, the agent's system prompt now tells it to say the action isn't available "rather than approximating it with a similar tool — a similar tool is not the same action."&lt;/p&gt;

&lt;p&gt;The other suggestion was a test that counts the high-risk handlers, so every change to that set shows up in a diff. Once the risk comes from the effect, the count adds little: a new hard delete is high whether anyone updates a number or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still open
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Irreversible effects outside our database
&lt;/h3&gt;

&lt;p&gt;The floor sees forget and hard deletes. It doesn't see a mail, a webhook or a payment. In our store, the write that queues a mail looks like any other reversible write, but the mail can't be unsent. Our webhooks now send an idempotency key, so the receiver can drop a redelivery, but that only protects against duplicates. Nothing decides whether the agent should trigger the first one without asking.&lt;/p&gt;

&lt;p&gt;Our half idea is to treat outbound channels like flagged fields: the channel declares itself irreversible, and any handler that hands work to it resolves to high. We haven't built that. I'm not sure the line is right either, because a status mail to the user themselves and a payment to a third party would land in the same class.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Who reads a field as instructions
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;readAsInstruction&lt;/code&gt; flag is set by hand next to the field, apart from the code that later puts the field into a prompt. If a new feature starts feeding an unflagged field into a system prompt, the flag set is stale and every check stays green. anp2network asked whether anything fails when that happens. Nothing does, and on the agent side the system prompt is still a plain string.&lt;/p&gt;

&lt;p&gt;The flag also has holes on the write side. A hand-written domain event whose projection writes the flagged field skips the gate. The gate checks whether the payload contains the field, not whether the value changed, which we chose deliberately so it fails closed. Reverting a prompt also doesn't undo a run that already read the bad version.&lt;/p&gt;

&lt;p&gt;Our half idea is a type. The system prompt parameter would only accept text that came from a flagged field or from code, so feeding it anything else wouldn't compile. I haven't checked whether that survives prompts assembled from several pieces.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Refusing instead of reaching for the neighbour
&lt;/h3&gt;

&lt;p&gt;When the model can't find a tool for what the user wants, it should say so. The risk is that it picks the closest-sounding tool instead, maybe one the user set to "always allow". Right now the only defence is the sentence in the system prompt quoted above.&lt;/p&gt;

&lt;p&gt;anp2network suggested a number: after a rejected call, record what the model calls next. In our loop, calling a tool that isn't in the list ends the run, so there's no "next" to count. The case we can't see at all is the model going straight to the neighbour without ever trying the missing tool.&lt;/p&gt;

&lt;p&gt;phongdesigns added the near-duplicate problem: two writes described in almost the same words, and the model confuses them. Our manifest snapshot still records only handler names, so a reworded description doesn't even show up in review. I said in the thread that adding descriptions and risk to it was the next step, and it still is.&lt;/p&gt;

&lt;p&gt;The only test I can think of for substitution is to give a real model pairs of similar tools and see where it goes wrong. That costs money on every run, and the result changes with the model.&lt;/p&gt;

&lt;p&gt;How do you test that an agent refuses instead of improvising, without paying for a model call on every pull request?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>typescript</category>
      <category>discuss</category>
    </item>
    <item>
      <title>A fresh coding agent passed all four tasks against our framework docs. Here's what that result doesn't prove</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Mon, 19 Oct 2026 21:09:58 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/a-fresh-coding-agent-passed-all-four-tasks-against-our-framework-docs-heres-what-that-result-4l58</link>
      <guid>https://dev.to/marc_kumiko/a-fresh-coding-agent-passed-all-four-tasks-against-our-framework-docs-heres-what-that-result-4l58</guid>
      <description>&lt;p&gt;Kumiko is our TypeScript framework for multi-tenant apps on Bun, Hono and Postgres. More and more of the code written against it comes from coding agents, and until last week those agents saw very little of it. An agent working in an app sees the app and its &lt;code&gt;node_modules&lt;/code&gt;. Our framework package shipped &lt;code&gt;dist&lt;/code&gt;, a README and a license. The docs site exists, but it's outside what an agent sees in an app, and the instructions written for agents lived in skills inside our own workspace, so only our own sessions ever read them.&lt;/p&gt;

&lt;p&gt;So we moved the explanation into the package and then checked whether an agent that has never seen the framework source can work from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ships in the package now
&lt;/h2&gt;

&lt;p&gt;Since 0.364.0, &lt;code&gt;@cosmicdrift/kumiko-framework&lt;/code&gt; contains an &lt;code&gt;AGENTS.md&lt;/code&gt;, today about 350 lines. It explains the model (features, entities, handlers, screens, events, tenant scoping), lists rules in the form "never this, do that", and has a table of "I need X, use Y". Next to it sits an &lt;code&gt;agents/&lt;/code&gt; folder that the build generates from the source: an index of every bundled feature from the feature manifest, an index of every UI widget export, and one file per tested sample app with its README, source and tests. A drift guard fails CI when the generated files no longer match the code.&lt;/p&gt;

&lt;p&gt;A new app from &lt;code&gt;create-kumiko-app&lt;/code&gt; gets a short &lt;code&gt;AGENTS.md&lt;/code&gt; of its own for other agents and a &lt;code&gt;CLAUDE.md&lt;/code&gt; that imports the package file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;@node_modules/@cosmicdrift/kumiko-framework/AGENTS.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs always match the installed version and work offline. Boot errors and guard messages also carry a &lt;code&gt;docsUrl&lt;/code&gt; that points at a page for that error code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The eval
&lt;/h2&gt;

&lt;p&gt;We wrote four tasks of the kind a real app needs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An entity with a list and a form.&lt;/li&gt;
&lt;li&gt;A write handler and a query handler with integration tests (complete a task, count open tasks per tenant).&lt;/li&gt;
&lt;li&gt;An overview screen built from existing widgets.&lt;/li&gt;
&lt;li&gt;Mounting the bundled &lt;code&gt;tags&lt;/code&gt; feature in an app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each task got its own fresh scaffold, installed from npm into a directory outside any workspace. Inside a workspace, bun resolves workspace links instead of the npm tarball, and the agent reads our source instead of the package. The prepare script refuses to run inside a directory that has a &lt;code&gt;package.json&lt;/code&gt; or &lt;code&gt;bun.lock&lt;/code&gt; above it.&lt;/p&gt;

&lt;p&gt;Each project got a fresh agent (Sonnet) with a prompt that contained the environment, the task text and the rules: work only in the project, read only the project and its &lt;code&gt;node_modules&lt;/code&gt;, no skills, no memory, no web search. At the end it had to report in 15 lines what it built, which docs it read and what was missing.&lt;/p&gt;

&lt;p&gt;We didn't grade on that report. A script copies a hidden acceptance test into the project that the agent never saw, then runs typecheck, lint, unit tests, boot validation, schema validation, the integration tests (the agent's own plus the hidden one) and our guards. It also scans the agent's diff for &lt;code&gt;any&lt;/code&gt;, raw SQL writes, inline styles and a test helper we don't allow in integration tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result: 4 of 4
&lt;/h2&gt;

&lt;p&gt;All eight checks passed in every project, including the hidden acceptance tests. In task 2 the integration run had 13 tests: 8 written by the agent and 5 from the acceptance file.&lt;/p&gt;

&lt;p&gt;Every task still showed a gap in the docs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;What the agent couldn't find in the docs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Entity with list and form&lt;/td&gt;
&lt;td&gt;The personal-data setting a write handler needs when it writes personal fields. It found it through the boot error.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Handlers with tests&lt;/td&gt;
&lt;td&gt;How to count. &lt;code&gt;list&lt;/code&gt; with &lt;code&gt;totalCount&lt;/code&gt; and a filter was only in the &lt;code&gt;.d.ts&lt;/code&gt; files.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Overview screen&lt;/td&gt;
&lt;td&gt;Any example of a dashboard screen. It worked out the data contract from the compiled renderer.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mount &lt;code&gt;tags&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;That a newly mounted bundled feature needs a migration, and how to wire it into the client and the test stack.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The boot errors did their job in task 1. The agent made the mistake, the error named the fix, and it applied it. In the other three, the docs alone weren't enough, and the agents filled the gap by reading type declarations and compiled JavaScript in &lt;code&gt;node_modules&lt;/code&gt;. The rules allowed that, and a human developer would do the same. For this eval we count it as a gap anyway, because the next agent might not look there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 4 of 4 is weaker than it looks
&lt;/h2&gt;

&lt;p&gt;The task texts fixed names, payloads and IDs, so the hidden tests could find what they test. An open request like "add a way to see how many tasks are left" is harder.&lt;/p&gt;

&lt;p&gt;Task 3 passed on its declaration and its data. Nothing rendered a screen, so we don't know whether the title key resolves.&lt;/p&gt;

&lt;p&gt;The bigger problem is how the agents ran. To save time, we started them as subagents from an existing Claude Code session instead of in a clean CLI with an empty config directory. A subagent inherits the session's user and workspace instructions, and ours state the same rules the review checks: no &lt;code&gt;any&lt;/code&gt;, the real test stack instead of a test dispatcher, no direct writes to event tables. Our results table has a column for rule breaks, and it was empty for all four runs. That says nothing about whether the package docs teach those rules. Only the columns for which docs an agent read and what it missed measure the package docs on their own.&lt;/p&gt;

&lt;p&gt;We checked the transcripts for paths outside the project. None of the four agents touched our source tree or our config folder, and none used a skill or a web search.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the eval found that wasn't documentation
&lt;/h2&gt;

&lt;p&gt;The fresh scaffolds were broken in ways we never noticed, because we never start from a fresh scaffold. The prepare script had to patch some of this before an agent could start (version pins, the &lt;code&gt;.gitignore&lt;/code&gt;, the Biome config, a random master key and admin password in &lt;code&gt;.env&lt;/code&gt;), and none of those patches help with the tasks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The scaffold wrote no &lt;code&gt;.gitignore&lt;/code&gt;. Biome is configured to read it, so &lt;code&gt;bun run lint&lt;/code&gt; failed in a brand new project.&lt;/li&gt;
&lt;li&gt;Biome was pinned with a caret range that now resolves to a newer minor version. The new project was lint-red on its first run.&lt;/li&gt;
&lt;li&gt;The generated production entry point seeded an admin user with a fixed password. Since 0.365.0 it requires &lt;code&gt;DEMO_ADMIN_PASSWORD&lt;/code&gt; (at least 12 characters) in the env schema, and the boot stops before seeding when it is missing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Upgrading the framework doesn't rewrite your &lt;code&gt;bin/main.ts&lt;/code&gt;. If you scaffolded an app with 0.364.0 or earlier and booted it in production mode, change that admin's password and add the env variable by hand.&lt;/p&gt;

&lt;p&gt;One of our own examples taught the wrong thing. The &lt;code&gt;counter:active&lt;/code&gt; query in the custom-handlers sample loaded a page with &lt;code&gt;list&lt;/code&gt; and filtered it in JavaScript. That only ever sees the first page, and it's the example an agent would copy when it needs to count. It now filters in the query and asks for &lt;code&gt;totalCount&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;While writing the error pages we also found that every &lt;code&gt;docsUrl&lt;/code&gt; led to a 404. The framework built &lt;code&gt;/errors/&amp;lt;code&amp;gt;&lt;/code&gt;, and the docs site served &lt;code&gt;/en/errors/&amp;lt;code&amp;gt;/&lt;/code&gt;. The site now redirects, and a check fails the docs build when an error code has no page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next step
&lt;/h2&gt;

&lt;p&gt;The run the eval was designed for is still open: one agent per project, started in the project directory with an empty Claude Code config directory, so nothing from our setup leaks in. The README in &lt;code&gt;eval/agent-docs/&lt;/code&gt; describes it, and that run is what tells us whether the rules in &lt;code&gt;AGENTS.md&lt;/code&gt; reach an agent that has only the package.&lt;/p&gt;

&lt;p&gt;The tasks, the hidden acceptance tests and the runner are in the public repo under &lt;code&gt;eval/agent-docs/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;How do you grade an eval like this? We count "had to read compiled code" as a docs gap even when the task passes. We're not sure that's the right line, or whether it should fail the task.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>testing</category>
      <category>documentation</category>
    </item>
    <item>
      <title>Turning on MFA took one line. Making it true took three routes and a test kit</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:38:50 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/turning-on-mfa-took-one-line-making-it-true-took-three-routes-and-a-test-kit-4a5g</link>
      <guid>https://dev.to/marc_kumiko/turning-on-mfa-took-one-line-making-it-true-took-three-routes-and-a-test-kit-4a5g</guid>
      <description>&lt;p&gt;We are preparing Veridom, a property management app built on Kumiko, for a closed launch. One of the launch requirements was simple to state: every admin signs in with a second factor. In Kumiko that is a config value on the MFA feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BRAND_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setupTokenSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;challengeTokenSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;requiredPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admins&lt;/span&gt;&lt;span class="dl"&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 policy has three values: &lt;code&gt;optional&lt;/code&gt;, &lt;code&gt;admins&lt;/code&gt; and &lt;code&gt;all&lt;/code&gt;. With &lt;code&gt;admins&lt;/code&gt;, a password login by an admin who has no factor gets a setup step instead of a session, and an admin with a factor gets a challenge. The login handler enforces that.&lt;/p&gt;

&lt;p&gt;The change was one line. Everything after it is what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  89 red tests, one message
&lt;/h2&gt;

&lt;p&gt;The first effect was that the whole end-to-end suite went red. 89 Playwright specs, all failing with 401 on their first API call.&lt;/p&gt;

&lt;p&gt;Every spec seeds a tenant and signs in its admin through a helper from our test kit. The helper posted the password, got back &lt;code&gt;{ mfaRequired: true, challengeToken }&lt;/code&gt; instead of a session cookie, and returned as if everything was fine. The 401 came one request later, from code that had nothing to do with login.&lt;/p&gt;

&lt;p&gt;The fix for the suite was to teach the kit MFA. The more useful fix was to make the helper fail where the problem is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mfaRequired&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mfaSetupRequired&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`loginViaApi(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;): the account needs MFA but no mfaTotpSecret was given; `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="s2"&gt;`use seedTenant({ mfa: "totp" }) or addUser(roles, { mfa: "totp" })`&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;A test helper that swallows an unexpected state turns one clear failure into 89 confusing ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Teaching the kit TOTP
&lt;/h2&gt;

&lt;p&gt;The kit now enrolls a real TOTP factor through the real endpoints. Under a policy that blocks the login, that is the pre-auth enrollment flow. Without one, it is the normal enrollment from a signed-in session. &lt;code&gt;seedTenant()&lt;/code&gt; does this on its own when the admin's login answers with a setup step, so an app that switches on the policy does not have to touch every spec. Users created later can opt in with &lt;code&gt;addUser(roles, { mfa: "totp" })&lt;/code&gt;, which matters when a spec creates a member and promotes them to admin afterwards.&lt;/p&gt;

&lt;p&gt;One detail cost some thought. The server burns an accepted code for its whole validity window of one step either side, which is correct: a code seen once should not log anyone in twice. A suite that signs the same user in three times within 30 seconds then needs the next counter, and only codes up to one step ahead are valid. So the kit keeps the last counter per secret and, when it runs out of valid counters, waits until the clock catches up. That wait is tied to the TOTP window. It is not a test timeout, and we do not raise timeouts to make tests pass. In practice the full suite of 89 specs ran in 55 seconds with MFA on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The policy had three doors around it
&lt;/h2&gt;

&lt;p&gt;With the suite green, a security review of the release went through the diff. It found nothing blocking in the app. It found two problems in the framework and pointed at a third.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Switching tenants.&lt;/strong&gt; A user can belong to several tenants. Picture someone who is a plain member in tenant A and an admin in tenant B, with no second factor. They sign in to A, where the policy does not apply to them, and get a session. Then they call &lt;code&gt;POST /auth/switch-tenant&lt;/code&gt; for B. That route minted a fresh session for B without asking the MFA question. An admin session without a second factor, from a stolen password.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accepting an invitation.&lt;/strong&gt; Completing an invite signup issued the first session directly. That included admin invitations. The account holder could have enrolled a factor themselves, so this was hard to exploit, but it meant the first admin session of every invited admin had no second factor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signing up.&lt;/strong&gt; Self-service signup creates a new tenant and makes the person who signed up its admin. Confirming the signup issued that admin session directly, again without the gate. Veridom launches without self-registration, so this one did not block us, but it is the same hole.&lt;/p&gt;

&lt;p&gt;All three had the same root. The policy was enforced in the login handler, and these routes mint sessions without going through it. A policy flag is only as strong as the set of routes that create sessions, and nobody had made a list of that set.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent's first fix was worse than the bug
&lt;/h2&gt;

&lt;p&gt;I gave the fix to a coding agent with a clear description: put the same MFA check in front of the switch-tenant session.&lt;/p&gt;

&lt;p&gt;There is a layering wrinkle. The route lives in the framework core, the MFA gate lives in a bundled feature that the core cannot import. The agent solved that by creating a write handler for the gate and dispatching it from the route. Its first version took &lt;code&gt;tenantId&lt;/code&gt; and &lt;code&gt;roles&lt;/code&gt; from the request payload and was open to every signed-in user. Since all write handlers are reachable through the public write endpoint, anyone could have asked for an MFA challenge for any tenant, with any roles they liked. The second version restricted the handler to signed-in users and took the &lt;code&gt;userId&lt;/code&gt; from the payload, which made it possible to trigger the gate for other people.&lt;/p&gt;

&lt;p&gt;An automated security check flagged both versions while the agent was still working. I stopped it each time with the same rule, written out explicitly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;only the system identity may call the handler, and the route dispatches it as the system user;&lt;/li&gt;
&lt;li&gt;the payload carries &lt;code&gt;userId&lt;/code&gt; and &lt;code&gt;tenantId&lt;/code&gt;, nothing else;&lt;/li&gt;
&lt;li&gt;the handler resolves the membership and the roles itself, on the server, and returns an error when there is no membership;&lt;/li&gt;
&lt;li&gt;a test calls the handler directly as a normal user and expects a 403.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the version we kept. The useful lesson for me: "put the same check here" is not a spec for auth code. The agent did what it was asked and filled the gaps with the most convenient assumption, which in auth code is the client's word. The rules that matter (who can call this, which inputs are trusted) have to be in the task, and a reviewer has to check them in the diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests that fail when the gate is gone
&lt;/h2&gt;

&lt;p&gt;Each new test for the first two routes was checked by removing the thing it protects. Taking the gate out of the switch-tenant route turned two tests red. Taking it out of the invite signup turned one red. Opening the gate handler to normal users again turned four red, including the direct call.&lt;/p&gt;

&lt;p&gt;The new integration tests run over real HTTP against a real stack and cover: switching to an admin tenant without a factor (setup step, no session cookie), with a factor (challenge, then a session after verifying), switching to a tenant where the user is only a member (direct session), switching to a tenant without membership (error, no token), the invite signup for an admin and for a member, and the signup confirmation under the policy, including a direct call through the public write endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the list
&lt;/h2&gt;

&lt;p&gt;Each fix closes one route. To catch the next route that mints a session, the framework now keeps the list we were missing. Every production route mints its session through one function, and a guard test lists each of those routes together with how the MFA gate applies to it. A new route that mints a session without an entry fails the build, and so does a token signed anywhere else.&lt;/p&gt;

&lt;p&gt;All of this shipped in Kumiko 0.345.0. Since 0.347.0, signing up under the policy also keeps the page the user wanted to land on after they enroll their factor. If you run Kumiko with an MFA policy, upgrade to at least 0.345.0.&lt;/p&gt;

&lt;p&gt;If you run a multi-tenant app with per-tenant roles: does your MFA policy hold when a user switches tenants? We found out ours did not when a security review read the release that turned the policy on.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>testing</category>
      <category>typescript</category>
    </item>
    <item>
      <title>We moved OCR out of the app so egress could be zero</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Wed, 30 Sep 2026 08:15:00 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/we-moved-ocr-out-of-the-app-so-egress-could-be-zero-17m6</link>
      <guid>https://dev.to/marc_kumiko/we-moved-ocr-out-of-the-app-so-egress-could-be-zero-17m6</guid>
      <description>&lt;p&gt;Our document ingest path used to run OCR inside the app pods. The library wanted glibc. The app images were Alpine. Missing language packs got pulled from github.com at parse time. That last part is the one that kept me up: a PDF upload could trigger an outbound fetch from a pod that otherwise had no business talking to the public internet.&lt;/p&gt;

&lt;p&gt;So we moved parsing into its own service, baked the traineddata into the image, and closed egress entirely. Including DNS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the app was the wrong place
&lt;/h2&gt;

&lt;p&gt;Three separate failures stacked in one process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The native liteparse binary didn't load on Alpine. You discover that in the target environment, not in a glibc laptop build.&lt;/li&gt;
&lt;li&gt;Tessdata that wasn't already on disk downloaded at runtime. OCR "just worked" until the network policy, the rate limit, or the broken mirror made it not work.&lt;/li&gt;
&lt;li&gt;The parse library accepted path-shaped options (&lt;code&gt;tessdataPath&lt;/code&gt;, &lt;code&gt;imageOutputDir&lt;/code&gt;, &lt;code&gt;ocrServerUrl&lt;/code&gt;). A caller that could set those could point the parser at another filesystem or another host.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of those are model problems. They're packaging and trust-boundary problems that show up the first time you treat document upload as untrusted input, which it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  One HTTP service, three caller knobs
&lt;/h2&gt;

&lt;p&gt;The service is a small Bun HTTP app: multipart upload in, pages of text out. Callers may pass only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strictObject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;ocrEnabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;ocrLanguage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-z&lt;/span&gt;&lt;span class="se"&gt;]{3}(\+[&lt;/span&gt;&lt;span class="sr"&gt;a-z&lt;/span&gt;&lt;span class="se"&gt;]{3})&lt;/span&gt;&lt;span class="sr"&gt;*$/&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;maxPages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;positive&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;strictObject&lt;/code&gt; rejects every other field by name. &lt;code&gt;tessdataPath&lt;/code&gt; is not an option; it comes from &lt;code&gt;LITEPARSE_TESSDATA_PATH&lt;/code&gt; and nowhere else. Languages are checked against the &lt;code&gt;.traineddata&lt;/code&gt; files present at startup. Ask for &lt;code&gt;fra&lt;/code&gt; when only &lt;code&gt;eng&lt;/code&gt; and &lt;code&gt;deu&lt;/code&gt; are baked in and you get &lt;code&gt;400&lt;/code&gt;, not a download.&lt;/p&gt;

&lt;p&gt;The image pins &lt;code&gt;deu&lt;/code&gt; and &lt;code&gt;eng&lt;/code&gt; from &lt;code&gt;tesseract-ocr/tessdata_best&lt;/code&gt; at a fixed commit SHA with sha256 verification. Build time is when we fetch. Runtime is when we refuse to.&lt;/p&gt;

&lt;p&gt;Auth is a shared bearer token, sixteen characters minimum. Health checks stay unauthenticated so Kubernetes can probe without holding the secret. There is no &lt;code&gt;/metrics&lt;/code&gt; scrape path, because nothing scrapes it yet and an open metrics port would be another thing to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closed egress is the point
&lt;/h2&gt;

&lt;p&gt;In the cluster the service sits in its own namespace. The NetworkPolicy sets &lt;code&gt;egress: []&lt;/code&gt; — no outbound traffic, DNS included. Ingress is an allowlist of caller namespaces from stack config. Empty list means deny-all until the first consumer is declared.&lt;/p&gt;

&lt;p&gt;That shape only works because the container never needs the network. Puppeteer, sitting next to it in our stack, cannot do the same: it renders user HTML with external subresources. OCR can, once the models live in the image.&lt;/p&gt;

&lt;p&gt;App pods call &lt;code&gt;http://liteparse-service…:3000&lt;/code&gt; over the cluster network with the shared token. They never embed the native binary. Alpine stays Alpine. The parse blast radius is one dedicated Deployment with a memory tier and no way out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd do again
&lt;/h2&gt;

&lt;p&gt;Pin traineddata in the image and fail closed on unknown languages. Whitelist request options so a multipart field cannot redirect filesystem or network paths. Put the NetworkPolicy next to the Deployment in the same PR, with ingress empty by default so a forgotten consumer config doesn't open the service to the whole cluster.&lt;/p&gt;

&lt;p&gt;If your OCR still downloads language packs on first use, you don't have an offline parser. You have a deferred &lt;code&gt;curl&lt;/code&gt; with a PDF as the trigger.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>kubernetes</category>
      <category>security</category>
    </item>
    <item>
      <title>Undocumented handlers don't exist to the agent</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Sun, 27 Sep 2026 06:10:00 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/undocumented-handlers-dont-exist-to-the-agent-e71</link>
      <guid>https://dev.to/marc_kumiko/undocumented-handlers-dont-exist-to-the-agent-e71</guid>
      <description>&lt;p&gt;We wired an in-app AI agent into a product that already had a full write/query surface. Auth was fine. The agent had permission to call writes. Half the handlers it needed simply never showed up in its tool list.&lt;/p&gt;

&lt;p&gt;The agent said it couldn't find them. The handlers existed. What was missing was a description string.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent can see
&lt;/h2&gt;

&lt;p&gt;Our agent doesn't get raw TypeScript. It gets a manifest built from every feature the app mounts: handlers, screens, entities. Each entry needs a short description, written at the place the handler is defined. Without that string, the entry is dropped. The agent never learns the name exists.&lt;/p&gt;

&lt;p&gt;That surprised a few people, including me the first time. We treated descriptions as documentation polish. For the agent they are the gate. An endpoint without an OpenAPI description might still be callable if you know the path. An agent-facing handler without a description isn't callable at all, because the model never sees a tool to choose.&lt;/p&gt;

&lt;p&gt;Twenty-one handlers across our AI pipeline and prompt store features were in that state. The code ran for humans in the UI. The agent walked past them.&lt;/p&gt;

&lt;p&gt;You also can't patch this later at the composition site. The description has to live next to the handler definition, so every app that mounts the feature inherits it. We learned that the hard way: people tried to "add docs" in the app and the gap tests kept failing, because the packaged feature still shipped blank.&lt;/p&gt;

&lt;h2&gt;
  
  
  Always allow is a permission, not a mood
&lt;/h2&gt;

&lt;p&gt;Visibility is half of it. The other half is what happens after the agent picks a tool.&lt;/p&gt;

&lt;p&gt;Writes default to risk &lt;code&gt;mid&lt;/code&gt;. Queries default to &lt;code&gt;low&lt;/code&gt;. The UI can offer "always allow" for those. Two of our writes are different:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delete-golden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Permanently deletes a golden fixture, so it can no longer be used for dry-runs. This is a hard delete, not an appended revision, and cannot be undone.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;risk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;edit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Saves new content for a prompt template by appending a revision that becomes the active prompt immediately… The owning AI feature uses this content verbatim as its system prompt…&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;risk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;delete-golden&lt;/code&gt; is a hard delete. Prompt &lt;code&gt;edit&lt;/code&gt; is free text that becomes another feature's system prompt on the next run. Both are marked &lt;code&gt;high&lt;/code&gt;. The permission loop refuses &lt;code&gt;always&lt;/code&gt; for those handlers outright. You can still approve a single call. You cannot teach the agent that this shape of write is permanently fine.&lt;/p&gt;

&lt;p&gt;Most of the neighbouring writes append a restorable revision (&lt;code&gt;set-policy&lt;/code&gt;, &lt;code&gt;rollback&lt;/code&gt;, prompt &lt;code&gt;revert&lt;/code&gt;). Those stay at the default. Restorable side effects and irreversible ones share a form in the code; they don't share a permission ceiling.&lt;/p&gt;

&lt;p&gt;Phone apps already do this split. Camera access can be "always". Wipe storage can't. We needed the same ceiling for an agent that can press buttons faster than a human reviews them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the gap fail a test
&lt;/h2&gt;

&lt;p&gt;Once you decide "no description means invisible", silence becomes the failure mode. The agent looks underpowered. Nobody opens a ticket that says "missing string on handler 14".&lt;/p&gt;

&lt;p&gt;So each feature that should be agent-visible gets a gap test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;createAiPipelineFeature([]) exposes 0 doc gaps&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gaps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findAgentDocGaps&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;createAiPipelineFeature&lt;/span&gt;&lt;span class="p"&gt;([])]);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gaps&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delete-golden is high risk, set-policy is mid risk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;feature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createAiPipelineFeature&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;resolveAgentExposure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;writeHandlers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delete-golden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;write&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;risk&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;resolveAgentExposure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;writeHandlers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;set-policy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;write&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;risk&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first test is the smoke alarm for missing descriptions. The second pins the two risk decisions we actually care about, so a refactor can't quietly demote a hard delete back to "always allow"-eligible.&lt;/p&gt;

&lt;p&gt;We also run the same gap lint from a CLI against a whole app config. Shipping a new consumer feature without descriptions turns red before anyone asks the agent to do a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we tell ourselves now
&lt;/h2&gt;

&lt;p&gt;If a human can click it and an agent should be able to call it, write the description where the handler is defined. One sentence that says what changes and what stays reversible is enough; our pipeline descriptions are longer because the agent needs the follow-up query names (&lt;code&gt;revisions&lt;/code&gt; before &lt;code&gt;activate&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;If the write can't be undone, or it rewrites another feature's brain, mark it &lt;code&gt;high&lt;/code&gt;. Don't rely on the operator to remember which "always allow" clicks were a bad idea.&lt;/p&gt;

&lt;p&gt;If you only remember one check: ask the agent to list what it can do, then compare that list to your write handlers. The missing names are usually missing strings, not missing models.&lt;/p&gt;

&lt;p&gt;I wrote earlier about &lt;a href="https://dev.to/marc_kumiko/the-model-obeys-your-schema-not-your-description-1cml"&gt;schemas beating prose for tool outputs&lt;/a&gt;. This is the other side of the same habit. There the description was advice and the schema was the contract. Here the description &lt;em&gt;is&lt;/em&gt; the contract for whether the tool exists at all, and risk is the contract for whether "always" is even legal.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>typescript</category>
      <category>agents</category>
    </item>
    <item>
      <title>A green boot does not prove your key manager is being used</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Fri, 25 Sep 2026 08:11:00 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/a-green-boot-does-not-prove-your-key-manager-is-being-used-119h</link>
      <guid>https://dev.to/marc_kumiko/a-green-boot-does-not-prove-your-key-manager-is-being-used-119h</guid>
      <description>&lt;p&gt;Five production apps, three encryption keys each, all of them sitting in a Kubernetes secret as base64 plaintext. The plan was to move them into Scaleway's Key Manager so the cluster only ever holds ciphertext and the pod unwraps it at boot. Straightforward work, one afternoon, maybe two.&lt;/p&gt;

&lt;p&gt;It took three days, and every single failure mode was the same shape: something booted fine and was quietly wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The slot that resolves twice
&lt;/h2&gt;

&lt;p&gt;Our framework has a env schema per app. A field can be marked as a key manager slot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;KUMIKO_SECRETS_MASTER_KEY_V1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;base64Key32&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AES-256 master key (KEK) for tenant-secrets encryption.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kumiko&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At boot, the runtime walks those slots, finds the &lt;code&gt;_CIPHERTEXT&lt;/code&gt; twin for each one, calls the key manager, and hands the app an env with the plaintext filled in. One round trip, everything resolved, done.&lt;/p&gt;

&lt;p&gt;Except two of our five apps also build their own crypto provider before the framework's boot path runs, because they need it to construct a context object the framework then receives. So the same slot gets resolved twice, from two different env objects, in two different places. Our boot log made this visible only because both passes log with a prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[publicstatus] KUMIKO_SECRETS_MASTER_KEY_V1 source=key-manager keyId=441a92a0 region=fr-par
[runProdApp]   KUMIKO_SECRETS_MASTER_KEY_V1 source=key-manager keyId=441a92a0 region=fr-par
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had been reading only the second line for a week. The first one is the one that would have crashed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plaintext next to ciphertext wins, silently
&lt;/h2&gt;

&lt;p&gt;The first version of the Pulumi code set both values, on the theory that having the plaintext around was a safe fallback during the migration. It is the opposite of safe. The resolver checks for a plaintext value first, and if it finds one it never calls the key manager at all. You get a pod that boots, serves traffic, passes every health check, and has never once talked to the KMS you just spent two days wiring up.&lt;/p&gt;

&lt;p&gt;So the deployment code is either/or, with a comment that says why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Either/or, never both: a plaintext slot left beside its ciphertext&lt;/span&gt;
&lt;span class="c1"&gt;// wins silently, so the pod would boot green while still bypassing the&lt;/span&gt;
&lt;span class="c1"&gt;// Key Manager.&lt;/span&gt;
&lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;secretsMasterKeyCiphertext&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;KUMIKO_SECRETS_MASTER_KEY_V1_CIPHERTEXT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;secretsMasterKeyCiphertext&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="na"&gt;KUMIKO_SECRETS_MASTER_KEY_V1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;masterKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are doing this migration, make the two states mutually exclusive in code before you touch any environment. A fallback that can win without telling you is worse than no fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  The schema ate the key before the resolver saw it
&lt;/h2&gt;

&lt;p&gt;The second pass resolves against the &lt;em&gt;parsed&lt;/em&gt; env, and Zod drops unknown keys. The &lt;code&gt;_CIPHERTEXT&lt;/code&gt; twin is generated by the framework for the composed schema, but each app also hand builds a typed schema on top. If the twin is not declared there, the parsed env simply does not contain it, and the resolver gets an env where the ciphertext does not exist and the plaintext is empty. It throws, the pod crashloops, and the error message talks about a missing key rather than a stripped one.&lt;/p&gt;

&lt;p&gt;The fix is one line per app, and it needs a comment, because the next person will read it as redundant with the framework's generated twin and delete it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;KUMIKO_SECRETS_MASTER_KEY_V1_CIPHERTEXT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&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;optional&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Key-Manager ciphertext, wrapped by the same key as the KEK. Must stay in this schema: bin/main.ts hands the PARSED env to resolvePlatformKeks, which never sees an undeclared key.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I actually deleted it myself, mid migration, after convincing myself it was dead code. It was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zod 4 throws on extend for refined schemas
&lt;/h2&gt;

&lt;p&gt;When a slot arrives as ciphertext only, the plaintext field has to be relaxed to optional before parsing, otherwise the required check rejects exactly the deployment the feature exists to enable. The obvious implementation is &lt;code&gt;schema.extend({ [name]: field.optional() })&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That works for four of our apps. The fifth one has an object wide &lt;code&gt;superRefine&lt;/code&gt; for a cross field rule, and Zod 4 throws on &lt;code&gt;.extend()&lt;/code&gt; for any schema carrying a refinement. It failed at boot, in production, on a Friday. The version that works uses &lt;code&gt;safeExtend&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;relaxed&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&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="nx"&gt;schema&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeExtend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;relaxed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A framework helper that manipulates a user supplied schema has to be tested against a refined schema. That is the case that behaves differently, and it is never the case in your fixtures.&lt;/p&gt;

&lt;h2&gt;
  
  
  The app that did not mount the feature but needed the key
&lt;/h2&gt;

&lt;p&gt;One app never mounts the tenant secrets feature, so its schema had no reason to know about the master key slot. It does use multi factor auth, and the MFA code envelope encrypts TOTP secrets with the same provider, pulling the key straight out of &lt;code&gt;process.env&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Name based greps did not find it. The provider resolves its keys by regex over the environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^KUMIKO_SECRETS_MASTER_KEY_V&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So no consumer ever types the key name, and a grep for the name finds nothing in the one app that most needed the change. What found it was grepping for the provider's constructor instead. If you are auditing who consumes a secret, grep for the thing that reads it, not for the name of the thing being read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying that the key did not change
&lt;/h2&gt;

&lt;p&gt;The whole migration is only safe if the wrapped value decrypts back to the exact same bytes. Existing rows in the database were encrypted with that key, and a key that is merely valid will decrypt nothing.&lt;/p&gt;

&lt;p&gt;The check that proves this is narrow. Encrypt the live value, then decrypt the fresh ciphertext and compare the raw &lt;code&gt;plaintext&lt;/code&gt; field from the API response against the source string. Do not compare your own encode of the decode, which passes at any nesting depth and proves only that your codec is symmetric. We had already been burned by exactly that once, which is why the wrap script aborts unless the raw fields match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$roundtrip&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plaintext&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"!! &lt;/span&gt;&lt;span class="nv"&gt;$app&lt;/span&gt;&lt;span class="s2"&gt;: decrypt of the fresh ciphertext does NOT match the source value"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A monitor that reported green on an unchecked commit
&lt;/h2&gt;

&lt;p&gt;Small one, but it nearly cost me a bad merge. I had a poll loop watching four pull requests, filtering the GitHub check rollup for anything failing and anything pending. No failures and nothing pending reads as green.&lt;/p&gt;

&lt;p&gt;Right after a force push the rollup is momentarily empty, so for about thirty seconds a branch with zero checks run against it satisfies both conditions. My monitor announced green on a commit that CI had not looked at yet. The rewrite uses the exit code of &lt;code&gt;gh pr checks&lt;/code&gt; instead, where 0 is passed and 8 is pending, so an empty rollup cannot masquerade as success.&lt;/p&gt;

&lt;p&gt;Whenever you derive a positive state from the absence of negatives, ask what an empty input looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually went wrong on rollout day
&lt;/h2&gt;

&lt;p&gt;Two things, neither of them about keys.&lt;/p&gt;

&lt;p&gt;Four apps rolled at once and hit &lt;code&gt;remaining connection slots are reserved for roles with the SUPERUSER attribute&lt;/code&gt;, because the old pods still held their Postgres connections while the new ones started. One app restarted and came back. Worth staging your rollouts if your connection ceiling is anywhere near your replica count times your pool size.&lt;/p&gt;

&lt;p&gt;And the rollback path was not what I assumed. The Pulumi code reads the ciphertext with &lt;code&gt;config.require(...)&lt;/code&gt;, so removing the config value does not revert anything, it makes the next &lt;code&gt;pulumi up&lt;/code&gt; throw before it can do any work. The real rollback is deploying the previous revision of the infrastructure code. That is a thing to work out before you need it rather than during.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;

&lt;p&gt;All five apps now log &lt;code&gt;source=key-manager&lt;/code&gt; for all three key slots, in both resolution passes, and the plaintext slot is gone from every Kubernetes secret. The first app went out alone with a targeted apply, the other four followed once its boot log confirmed the path.&lt;/p&gt;

&lt;p&gt;The part I would repeat is the log line that names its own pass. Almost every wrong turn above was visible in that log before it was visible anywhere else, and one of them was only visible there.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>kubernetes</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Your AI eval is green because it never called the model</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Tue, 22 Sep 2026 06:57:00 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/your-ai-eval-is-green-because-it-never-called-the-model-13k8</link>
      <guid>https://dev.to/marc_kumiko/your-ai-eval-is-green-because-it-never-called-the-model-13k8</guid>
      <description>&lt;p&gt;We run an eval suite on every PR. Seven fixtures, four metrics, deterministic, no API key, no network, costs nothing. It was green for weeks.&lt;/p&gt;

&lt;p&gt;Then a code review found that two of the fixtures contained responses the live tool schema would have rejected outright. One had &lt;code&gt;access&lt;/code&gt; nested inside a &lt;code&gt;definition&lt;/code&gt; wrapper when the real pattern shape has it at the top level. Another declared a field as &lt;code&gt;"json"&lt;/code&gt;, which isn't a valid field type in our framework at all; the real one is &lt;code&gt;"jsonb"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;These are the canned responses the mock provider hands back. They were never checked against the schema the live model has to satisfy, because nothing in a mock run ever talks to a schema validator. So the suite was green on outputs that could not physically occur in production.&lt;/p&gt;

&lt;p&gt;Every "test your LLM in CI" setup makes this trade, and it's worth stating plainly: a mocked eval tests your pipeline, never your model. Once you accept that, the mock gets more useful, because you stop asking it for something it can't give.&lt;/p&gt;

&lt;h2&gt;
  
  
  One runner, two providers
&lt;/h2&gt;

&lt;p&gt;The entire trick is that the runner doesn't know which mode it's in. It takes a provider as a dependency and applies the same metric pipeline either way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RunEvalOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LLMProvider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;fixtures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;EvalFixture&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Readonly&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MetricFn&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;generatedAt&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mode&lt;/code&gt; is metadata. It lands in the report so you can tell a mock baseline from a live run later, and it changes no behavior. Mode selection lives in the script entry point, which is the only place that knows whether &lt;code&gt;--live&lt;/code&gt; was passed.&lt;/p&gt;

&lt;p&gt;For mock runs, the fixtures script themselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runMockEval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RunMockEvalOptions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;EvalReport&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createMockProvider&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// Script every fixture's mockResponse in fixture-order. Runner&lt;/span&gt;
  &lt;span class="c1"&gt;// calls provider.chat() once per fixture, also in fixture-order,&lt;/span&gt;
  &lt;span class="c1"&gt;// so FIFO matches.&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fixture&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fixtures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;script&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mockResponse&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;runEval&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each fixture carries its own &lt;code&gt;mockResponse&lt;/code&gt; next to its expected outcome, so there's no separate mock-fixture directory to keep in sync with the real one. The provider is a FIFO queue and the runner is sequential, so the ordering holds without any matching logic.&lt;/p&gt;

&lt;p&gt;Sequential is deliberate, by the way. Anthropic rate-limits, the eval set is seven fixtures, and parallelism would buy a few seconds in exchange for 429s in live mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Baseline diffs in review
&lt;/h2&gt;

&lt;p&gt;A test suite that says "still green" after a prompt change is nearly worthless for this kind of work. Prompt and schema changes rarely break things outright, they shift them, and what you want to see is which fixture moved and by how much.&lt;/p&gt;

&lt;p&gt;So the mock report goes into a checked-in JSON baseline, and a test compares a fresh mock run against it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalFixtures&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalFixtures&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passing&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passing&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;failing&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;failing&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meanScore&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeCloseTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meanScore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus per-fixture: same id set, same pass state, same aggregate score, same per-metric pass state.&lt;/p&gt;

&lt;p&gt;Edit a fixture, a metric, the system prompt, or a tool definition, and this test goes red until you regenerate the baseline. The regenerated baseline is a file in your diff, so the reviewer sees &lt;code&gt;parse-error&lt;/code&gt; flipping from true to false on one fixture, in the same PR that changed the tool schema. Quality changes become visible artifacts of review instead of something you notice three weeks later in production.&lt;/p&gt;

&lt;p&gt;It also means "regenerate the baseline" is a normal, expected step, which is the part people resist. If regenerating feels like cheating, the baseline is doing its job: you have to look at the diff and decide the change was intended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways we got this wrong
&lt;/h2&gt;

&lt;p&gt;The first was comparing the reason strings. Every metric result carries a human-readable message alongside its pass state and score. Comparing those in the drift test is tempting and wrong. The text drifts whenever someone improves an error message, and then the drift test fails for a reason that has nothing to do with model quality. After the third spurious failure people stop reading the diff, which kills the only thing the test is for. We compare structural data only: pass state, score, per-metric pass state. If a message needs to be load-bearing, it gets a dedicated metric test.&lt;/p&gt;

&lt;p&gt;The second was letting the script pick the baseline filename. Live runs and mock runs write to different files, for the obvious reason that a live run must never clobber the deterministic baseline. That selection originally lived in a side-effectful script body, untested, where a swapped ternary would quietly destroy the mock baseline on the next live run. It's now one function with three tests, the third of which exists purely to state the invariant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;baselineFileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isLive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;isLive&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;l2-eval-live-baseline.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;l2-eval-baseline.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;the two modes never resolve to the same file&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;baselineFileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;baselineFileName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A test that asserts two constants differ looks silly right up until someone refactors the ternary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pass gates, score tracks
&lt;/h2&gt;

&lt;p&gt;Every metric returns both a boolean and a 0..1 score, and the split matters more than it looks.&lt;/p&gt;

&lt;p&gt;Pass is what gates CI. Score is what shows you a metric moving before it flips. Coverage of generated pattern kinds is a good example: four of five expected kinds found is 0.8, not zero, and not a pass.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parse metric does the same, degrading instead of going binary. Clean parse is 1.0, N parse errors is &lt;code&gt;max(0, 1 - N / 10)&lt;/code&gt;, and a parser that throws is 0. So a change that takes a fixture from seven parse errors to two shows up as real movement even though it failed both times. Binary metrics hide exactly the progress you need to see while you're iterating on a prompt.&lt;/p&gt;

&lt;p&gt;That metric parses the emitted source with the framework's real parser against an in-memory &lt;code&gt;SourceFile&lt;/code&gt;. It's slower than a regex and it catches the case that matters: the model emits TypeScript that looks fine and our parser rejects, because it used factory style instead of the canonical form, or dropped the schema version header.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the mock can't do
&lt;/h2&gt;

&lt;p&gt;It cannot tell you the model got worse.&lt;/p&gt;

&lt;p&gt;Everything above validates prompt assembly, tool definitions, metric logic, and report aggregation. All of that is code, all of it breaks in ordinary code ways, and testing it for free on every PR is worth doing. But the canned responses are ground truth by fiat. If the model starts emitting different shapes tomorrow, every one of these tests still passes.&lt;/p&gt;

&lt;p&gt;That's what the live mode is for, and it's manual on purpose. A live run costs about $0.18 for the full set, which is nothing; the reason it isn't in CI is flakiness and rate limits, not money. It writes to its own baseline file and you fire it when you've changed something that could plausibly move model behavior. In &lt;a href="https://dev.to/marc_kumiko/the-model-obeys-your-schema-not-your-description-1cml"&gt;the schema-tightening post&lt;/a&gt; the before-and-after numbers all came from live runs of this harness, for about $0.18 a round.&lt;/p&gt;

&lt;p&gt;One detail that made live mode usable: a provider exception doesn't kill the run. The failing fixture gets a synthetic &lt;code&gt;provider-error&lt;/code&gt; metric and the run continues, so a 429 on fixture three costs you one fixture instead of the whole report.&lt;/p&gt;

&lt;p&gt;And the fixtures themselves need review like any other test data. Ours drifted because nobody was checking canned responses against the schema constraint that live mode enforces. That failure mode is intrinsic to mocking an LLM. You can only catch it by reading the fixtures, or by running live often enough that the gap shows up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A mocked eval tests your pipeline. It cannot test your model, and it will happily stay green on responses the real API would reject.&lt;/li&gt;
&lt;li&gt;Keep the runner mode-agnostic and pass the provider in. Both modes then share one metric pipeline, which is the thing you actually want identical.&lt;/li&gt;
&lt;li&gt;Put the value in the checked-in baseline diff, so quality changes show up in review as a file someone has to read.&lt;/li&gt;
&lt;li&gt;Compare structure, never human-readable messages, or the drift test becomes noise and people stop reading it.&lt;/li&gt;
&lt;li&gt;Report pass and score separately: one gates, the other shows movement before it flips.&lt;/li&gt;
&lt;li&gt;Review your fixtures against the constraints live mode enforces. Nothing else will.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>typescript</category>
      <category>testing</category>
    </item>
    <item>
      <title>The model obeys your schema, not your description</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Fri, 18 Sep 2026 22:40:08 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/the-model-obeys-your-schema-not-your-description-1cml</link>
      <guid>https://dev.to/marc_kumiko/the-model-obeys-your-schema-not-your-description-1cml</guid>
      <description>&lt;p&gt;Two models. Same prompt, same tool description, same request. One of them returned this:&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;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"entity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"entityName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"todo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"definition"&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;"fields"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&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="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 other returned this:&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;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"entity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"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;"todo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fields"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&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 second one is wrong, and our downstream patcher rejected it with a 422 that told nobody anything useful. What took me a while to work out was why the first model got it right, because the answer turned out to have nothing to do with being smarter.&lt;/p&gt;

&lt;p&gt;This was May 2026, Opus 4.7 and Sonnet 4.6 at the time. The story generalizes to whatever pair of models you're holding today.&lt;/p&gt;

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

&lt;p&gt;We have a tool called &lt;code&gt;apply_patches&lt;/code&gt;. An LLM reads a user request plus a source file and emits a list of structural change operations: add this entity, replace that handler, remove that metric. Each operation carries a &lt;code&gt;pattern&lt;/code&gt;, the canonical object form of the thing being changed.&lt;/p&gt;

&lt;p&gt;The tool schema for that &lt;code&gt;pattern&lt;/code&gt; parameter was, in effect:&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;"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;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"kind"&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;"string"&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;"required"&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;"kind"&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;&lt;code&gt;kind&lt;/code&gt; is a string and everything else is whatever. The actual shape lived in the tool description, a paragraph of prose with examples, the way most people write tool definitions.&lt;/p&gt;

&lt;p&gt;The big model complied anyway. The smaller one didn't. Both had read the same description.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the big model complied
&lt;/h2&gt;

&lt;p&gt;It had seen the shape before.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;entityName&lt;/code&gt; and &lt;code&gt;definition.fields&lt;/code&gt; are our field names, from our framework. To a model with training exposure to that shape, "emit an entity pattern" retrieves a memory. To a model without it, "emit an entity pattern" is a guess from the description text, and if you're guessing what an entity looks like, &lt;code&gt;{ name, fields }&lt;/code&gt; is a better guess than the truth. It's what everyone else's API would call those things.&lt;/p&gt;

&lt;p&gt;So this is an exposure gap rather than a capability gap, which matters because you can't fix an exposure gap by paying for a bigger model. It will show up for any model on any shape that isn't in its training data, which is to say on your proprietary shapes, indefinitely. The less your schema looks like the rest of the internet, the harder your description has to work. And descriptions are not what the model is validated against.&lt;/p&gt;

&lt;p&gt;The tool schema is. So we moved the contract into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tight on the common kinds, loose on the tail
&lt;/h2&gt;

&lt;p&gt;We have around twenty pattern kinds. Nine of them account for roughly 85% of everything the model emits. The other dozen (&lt;code&gt;relation&lt;/code&gt;, &lt;code&gt;workspace&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt;, &lt;code&gt;claimKey&lt;/code&gt;, &lt;code&gt;systemScope&lt;/code&gt; and friends) show up rarely.&lt;/p&gt;

&lt;p&gt;Writing strict schemas for all twenty would have been a week of work and a permanent maintenance tax, so we didn't. Each common kind became a discriminated &lt;code&gt;oneOf&lt;/code&gt; branch with a real &lt;code&gt;required&lt;/code&gt; list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EntityPattern&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;const&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;entity&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;entityName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;kind&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;entityName&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;definition&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The long tail got one fallback branch that requires nothing but &lt;code&gt;kind&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;OtherPattern&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;not&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;entity&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;requires&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;toggleable&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nav&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;writeHandler&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;queryHandler&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hook&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notification&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;metric&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="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;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;kind&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rare kinds still go through unvalidated at the schema layer, and the runtime patcher catches them. That split, tight on the discriminator values you see constantly and permissive on the ones you don't, is the part worth stealing. It costs an afternoon instead of a week and it targets the failures you actually get.&lt;/p&gt;

&lt;p&gt;We did the same for the natural keys that &lt;code&gt;replace&lt;/code&gt; and &lt;code&gt;remove&lt;/code&gt; operations use, and pinned the per-operation requirements with &lt;code&gt;allOf&lt;/code&gt; plus &lt;code&gt;if&lt;/code&gt;/&lt;code&gt;then&lt;/code&gt;, so the model can't hand us a &lt;code&gt;replace&lt;/code&gt; with nothing to replace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;allOf&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="na"&gt;if&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;const&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;replace&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pattern&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;const&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;add&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;     &lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pattern&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;const&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;remove&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="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;Both Anthropic and OpenAI honor &lt;code&gt;oneOf&lt;/code&gt; and &lt;code&gt;allOf&lt;/code&gt;/&lt;code&gt;if&lt;/code&gt;/&lt;code&gt;then&lt;/code&gt; in tool input schemas. Most people skip them because the flat version works well enough on whichever model they tested with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Did it work?
&lt;/h2&gt;

&lt;p&gt;The evidence is thinner than I'd like, and it points the right way.&lt;/p&gt;

&lt;p&gt;We ran three fixtures live, twice, for about $0.18 total. Before the change: two passed, one failed. The failure was the rename-entity case, emitting &lt;code&gt;{ kind: "entity", name, fields }&lt;/code&gt;. After: three passed, and the fixture that had been failing emitted &lt;code&gt;{ kind: "entity", entityName: "todo", definition: { fields: ... } }&lt;/code&gt;, byte for byte the shape the big model had been producing all along.&lt;/p&gt;

&lt;p&gt;Three fixtures is not a benchmark. What convinced me was which failure disappeared and what replaced it. The smaller model stopped inventing field names and started producing the canonical shape, on the exact case that had been failing.&lt;/p&gt;

&lt;p&gt;The real payoff came later. Once the smaller model could reliably emit the structured shape, it became viable as the default. That's usually the whole business case for schema work: a tight schema is what makes the cheap model good enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Footgun 1: oneOf is strict XOR
&lt;/h2&gt;

&lt;p&gt;Two weeks later, a code review caught something the tests hadn't.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;replace&lt;/code&gt; and &lt;code&gt;remove&lt;/code&gt; operations we have a parallel set of variants describing just the natural key. One of them is a singleton fallback: a &lt;code&gt;kind&lt;/code&gt; and nothing else. And &lt;code&gt;{ kind: "entity" }&lt;/code&gt; matched both the entity branch and the fallback branch.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;oneOf&lt;/code&gt; means exactly one. Two matches is a violation. Anthropic tolerated it; OpenAI's strict mode rejects the schema outright. Same schema, one provider silently fine, the other refusing to run.&lt;/p&gt;

&lt;p&gt;The fix is the &lt;code&gt;not.enum&lt;/code&gt; you saw above, where the fallback explicitly excludes every kind that has its own branch. Worth internalizing if you're building discriminated unions in JSON Schema: a fallback branch is not automatically disjoint from the specific ones. You have to make it disjoint by hand, and a test has to hold it that way, because the day someone adds a tenth specific branch and forgets the exclusion list is the day one of your providers starts 400ing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Footgun 2: field order is a token budget problem
&lt;/h2&gt;

&lt;p&gt;Different bug, same week. A &lt;code&gt;generate_feature&lt;/code&gt; call came back with &lt;code&gt;featureName&lt;/code&gt;, &lt;code&gt;packageDescription&lt;/code&gt;, &lt;code&gt;rationale&lt;/code&gt;, and no &lt;code&gt;source&lt;/code&gt;. Source being the entire point of the call.&lt;/p&gt;

&lt;p&gt;The model had emitted &lt;code&gt;rationale&lt;/code&gt; first, written about 700 tokens of thoughtful design commentary, and hit &lt;code&gt;maxTokens: 4000&lt;/code&gt; before it got to the field that mattered. Nothing errored. We just got a well-argued explanation of a file that didn't exist.&lt;/p&gt;

&lt;p&gt;Three fixes, in descending order of how much I trust them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;source.minLength: 100&lt;/code&gt; and &lt;code&gt;rationale.maxLength: 600&lt;/code&gt;, plus a blunt "BRIEF, 2-3 sentences" in the description. These are real constraints and they're what actually holds.&lt;/li&gt;
&lt;li&gt;Raised &lt;code&gt;maxTokens&lt;/code&gt; to 8000 for this one tool. The bounded tools stayed at 4000, since an unbounded budget everywhere just makes the truncation rarer and weirder.&lt;/li&gt;
&lt;li&gt;Declared &lt;code&gt;source&lt;/code&gt; first in the schema. Anthropic has been observed to emit arguments in declaration order. Observed, not documented. It costs nothing, it might help, and if a provider update changes it tomorrow nothing fails loudly. I left a comment in the source saying exactly that, and you should treat it the same way: a hint, not a contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scores on the affected fixture went from 0.50 to 0.85, on both models, which is the tell that this was never a model-quality issue. The remaining 0.15 is genuine content quality: the model doesn't reach for one of our helpers when it should. That's a prompt and few-shot problem, and no amount of schema work will fix it. Knowing which of your failures are schema-shaped saves you from tightening things that were never loose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody solves for you
&lt;/h2&gt;

&lt;p&gt;These schemas are a hand-written mirror of our framework's real pattern types. Two definitions of the same shape, in two files, with nothing but discipline between them. When someone adds a required field to the real type, the schema doesn't know.&lt;/p&gt;

&lt;p&gt;We pin what we can in a contract test: the number of variants, the required-field list per kind, the &lt;code&gt;not.enum&lt;/code&gt; exclusion list. Drift fails a test instead of confusing a model in production six weeks later. That's a smoke alarm rather than a solution. If you generate your tool schemas from your actual types, you're ahead of us. If you're hand-writing them like we are, at least pin them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;The obvious objection: you just made your prompt bigger, on every single call.&lt;/p&gt;

&lt;p&gt;About 3KB bigger, in our case. With prompt caching that's one cache write at 1.25× input rate, roughly $0.0002 for the schema chunk, and every subsequent call in the cache window reads it at 10%. The schema sits in the stable prefix, which is exactly where caching is designed to put it.&lt;/p&gt;

&lt;p&gt;Structured output used to carry a real tradeoff between schema size and bill. With caching it mostly doesn't. If token cost is what's keeping your tool definitions vague, go measure it. The number is probably smaller than the cost of one confusing 422 in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The description is advice. The schema is the contract. Models validate against one of those.&lt;/li&gt;
&lt;li&gt;A model complying with your undocumented shape may just be recognizing it from training. That's not a capability you can rely on, and your next model isn't guaranteed to have it.&lt;/li&gt;
&lt;li&gt;Tight schemas on the handful of kinds that dominate your traffic, one loose fallback for the tail. Don't schema the whole universe.&lt;/li&gt;
&lt;li&gt;Make your fallback branch explicitly disjoint, or &lt;code&gt;oneOf&lt;/code&gt; will bite you on the strictest provider you support.&lt;/li&gt;
&lt;li&gt;Length constraints hold. Field order is a hint. Know which is which.&lt;/li&gt;
&lt;li&gt;The reason to do any of this is usually that it makes the cheaper model good enough.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>typescript</category>
      <category>json</category>
    </item>
    <item>
      <title>You asked how I know what the model did. Here are the answers, with numbers.</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Thu, 06 Aug 2026 15:44:56 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/you-asked-how-i-know-what-the-model-did-here-are-the-answers-with-numbers-5576</link>
      <guid>https://dev.to/marc_kumiko/you-asked-how-i-know-what-the-model-did-here-are-the-answers-with-numbers-5576</guid>
      <description>&lt;p&gt;I wrote about &lt;a href="https://dev.to/marc_kumiko/we-cut-our-ai-pipeline-costs-25-without-losing-accuracy-and-the-fix-wasnt-a-cheaper-model-4l5n"&gt;our AI pipeline costs&lt;/a&gt; a while back. The comments were better than the post.&lt;/p&gt;

&lt;p&gt;Valentin Monteiro made the point that cache alerts should be keyed per prompt version rather than on a global ratio, because a global number moves for boring reasons. Tae Kim pointed at how prompt caching fails silently, where a breakpoint on anything per-request gives you a miss on every call that logs exactly like a hit. Both need the same thing underneath, and I said I'd get cache metrics into our provenance records.&lt;/p&gt;

&lt;p&gt;That turned into a bigger job than I expected. Here's what came out of it, what it costs, and the two parts I still haven't worked out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why store more than the output?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because the output can't tell you whether something was always broken or broke last Tuesday, whether it's one record or ten thousand, or whether you changed something or the vendor did.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;total_amount&lt;/code&gt; column has the value. Nothing about where it came from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where do you record it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At the provider, not in the feature.&lt;/p&gt;

&lt;p&gt;I did it in the feature first. It worked fine. But then every new AI feature has to remember to do the same thing, and eventually one won't. So it moved down to where providers get built:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withProvenance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;meta&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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recordAiCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;buildAiCallPayload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startedAt&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recordAiCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;buildAiCallPayload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startedAt&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing opts in because nothing gets asked.&lt;/p&gt;

&lt;p&gt;One detail: this write goes outside the handler's transaction. If the business transaction rolls back, the call still happened and you still paid for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's in the payload?&lt;/strong&gt;&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;"providerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"anthropic"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"handlerName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invoice-extract"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requestedModel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude-sonnet-5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"respondedModel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude-sonnet-5-20260514"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"promptVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3f9a1c0e77b2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inputHash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9c4e1ab77f30d552"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"latencyMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"usage"&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;"inputTokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2140&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"outputTokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;318&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cacheCreationInputTokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;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;"cacheReadInputTokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1890&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;"reportedCostUsd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.0042&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stopReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"end_turn"&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 two cache fields are there because of Valentin's comment. &lt;code&gt;cacheReadInputTokens&lt;/code&gt; against &lt;code&gt;inputTokens&lt;/code&gt; is your hit ratio, and now it's per call rather than a monthly average, so you can key an alert on it per prompt version like he suggested instead of watching one global number.&lt;/p&gt;

&lt;p&gt;Requested and responded model are separate fields. Looks pedantic until a vendor routes you somewhere else and they don't match.&lt;/p&gt;

&lt;p&gt;No prompt text, no output. Only hashes. The log lives forever and prompts are full of customer invoices.&lt;/p&gt;

&lt;p&gt;Failures get a row too, with error kind and HTTP status. Most setups only log the successes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you version a prompt?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I planned to put a version in the prompt file and bump it by hand. Then I didn't, because hand-maintained versions rot.&lt;/p&gt;

&lt;p&gt;It hashes the stable, model-visible part of the request at runtime instead: system instructions, corpus, and the tool schemas. Twelve hex characters, and nobody bumps anything. Messages stay out on purpose, because if they were in it every call would get its own version and the thing would group nothing.&lt;/p&gt;

&lt;p&gt;The "and the tool schemas" part is newer than this post. It used to hash the cacheable prefix only. Our extraction handler builds its tool from the caller's output schema, so you could change that schema, send the model a demonstrably different request, and &lt;code&gt;promptVersion&lt;/code&gt; wouldn't move. &lt;code&gt;inputHash&lt;/code&gt; saw it, but that one is unique per call, so it identifies without grouping. Which is the missed bump the runtime hash was supposed to make impossible. Found it writing this, wrote an issue, fixed it, merged it before the post went out. &lt;code&gt;toolChoice&lt;/code&gt; went in at the same time, the field Tae flagged in the last thread ;)&lt;/p&gt;

&lt;p&gt;What's left after that is key order, and it's a real one. &lt;code&gt;JSON.stringify&lt;/code&gt; hashes the serialisation, so reordering properties in a tool schema flips the version without changing anything semantically.&lt;/p&gt;

&lt;p&gt;The obvious move is to sort keys and hash a canonical form. We decided against it, and the reason is the interesting bit: the model sees the prompt as serialised text, and property order in a JSON schema affects the order an LLM generates fields in. Two differently sorted schemas are genuinely two different prompts. Sorting them into one bucket would rebuild exactly the missed bump we just removed, only invisibly. So the trade isn't false splits against churn. It's a visible false split against an invisible missed bump, and visible wins.&lt;/p&gt;

&lt;p&gt;The other thing is on purpose. The hash is one-way, so you get &lt;code&gt;3f9a1c0e77b2&lt;/code&gt; and no route back to the prompt. You can tell which calls are affected but not what changed in them. Reconstructing means git plus rehashing against the corpus from that day. The hand-bumped file version would have handled that with &lt;code&gt;git blame&lt;/code&gt;. I didn't think about it until after I'd shipped the hash.&lt;/p&gt;

&lt;p&gt;We have a prompt store with proper revision history sitting in the same codebase, connected to none of this. That's probably the answer and I haven't wired it up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does it cost?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Postgres 16, synthetic events, shape checked against real ones from the provider path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;513 bytes per payload at the median, 517 at p95&lt;/li&gt;
&lt;li&gt;about 1,036 bytes per call with row overhead and the event store's three indexes&lt;/li&gt;
&lt;li&gt;at 10k calls a day that's roughly 296 MB a month, 3.5 GB a year&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same per-call number at 10k, 100k and 1M rows. Storage isn't where the money goes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you query it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the part where measuring changed my mind.&lt;/p&gt;

&lt;p&gt;"Every call with prompt version X" is what you run when something is wrong. An event store indexes tenant, aggregate type and time. Not the inside of a JSONB payload.&lt;/p&gt;

&lt;p&gt;Scoped to one tenant and a time window: 162 ms over a million events. The index picks ~67,000 candidate rows and the payload filter narrows those to 8,490, so you pay for hauling 67,000 rows out of the heap.&lt;/p&gt;

&lt;p&gt;Globally it's a seq scan, so you add an expression index on the payload field. At a million rows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;distinct prompt versions&lt;/th&gt;
&lt;th&gt;no index&lt;/th&gt;
&lt;th&gt;with index&lt;/th&gt;
&lt;th&gt;plan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;115 ms&lt;/td&gt;
&lt;td&gt;281 ms&lt;/td&gt;
&lt;td&gt;ignored it, seq scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;107 ms&lt;/td&gt;
&lt;td&gt;61 ms&lt;/td&gt;
&lt;td&gt;bitmap heap scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;107 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.02 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;bitmap heap scan&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Index costs 6.9 MB in all three.&lt;/p&gt;

&lt;p&gt;With eight versions one version is 12.5% of the table, so Postgres scans and is right to. The index just sits there.&lt;/p&gt;

&lt;p&gt;The problem is that day one is when you benchmark this, see nothing, and decide the index isn't worth it. Six months in you've edited prompts a few hundred times, one version is 0.2% of the table, and that same index is a hundred times faster. Add it once you're past single digits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need event sourcing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. An append-only &lt;code&gt;ai_calls&lt;/code&gt; table gets you nearly all of it. We already had the event log so it came out of that for free.&lt;/p&gt;

&lt;p&gt;The bit worth copying either way is where you put the recording.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What would you change before this goes live?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Asking for real. It's built and merged but not out to a live tenant yet, so this is still a good moment to hear that it's wrong.&lt;/p&gt;

&lt;p&gt;Which is also why there are no cost or latency numbers from real traffic in here. We don't have them yet. In two months I will.&lt;/p&gt;

&lt;p&gt;If you've run something like this for a while: what's in your call log that isn't in mine? And where does this fall apart at a volume I haven't hit?&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/reference/ai-call-provenance-benchmark.sql" rel="noopener noreferrer"&gt;benchmark SQL&lt;/a&gt; is in the repo if you'd rather measure your own database than trust mine. It's self-contained, so &lt;code&gt;psql -v rows=1000000 -v prompt_versions=500 -f ai-call-provenance-benchmark.sql&lt;/code&gt; against a throwaway database reproduces the table above.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://kumiko.rocks" rel="noopener noreferrer"&gt;Kumiko&lt;/a&gt;, an event sourced framework for multi-tenant B2B systems in TypeScript.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>monitoring</category>
      <category>performance</category>
    </item>
    <item>
      <title>Multi-Tenant AI Chat: From Hardcoded Config to BYOK in 4 Steps</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Mon, 03 Aug 2026 08:35:50 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/multi-tenant-ai-chat-from-hardcoded-config-to-byok-in-4-steps-51aa</link>
      <guid>https://dev.to/marc_kumiko/multi-tenant-ai-chat-from-hardcoded-config-to-byok-in-4-steps-51aa</guid>
      <description>&lt;p&gt;Two tenants, two AI providers, two prompts. Sounds simple, and on day one it is. That's the trap. It stays simple right up until customer number two sends their first "quick question," and eighteen months later you're running a small distributed system to answer it. Here's the honest version of that slide, four stages, each one caused by a real human typing a real request into Slack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 1: it just works (he says, foolishly) 😅
&lt;/h2&gt;

&lt;p&gt;Tenant A wants OpenAI. Tenant B wants Claude. Both want their own system prompt. The obvious first version: one config object, one row per tenant. What could possibly go wrong. (Everything. Everything could go wrong. But not yet.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────┐     ┌──────────────────┐     ┌─────────────┐
│  Request    │ →   │  TENANT_CONFIG   │ →   │  Provider   │
│  (tenantId) │     │  (hardcoded obj) │     │  SDK call   │
└─────────────┘     └──────────────────┘     └─────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TENANT_AI_CONFIG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;tenantA&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are terse and technical.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;tenantB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anthropic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude-sonnet-5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are friendly. Antworte auf Deutsch.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleChat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TENANT_AI_CONFIG&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userMessage&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;Ships in an afternoon. Two tenants, two rows, demo goes great, everyone claps 👏. Put this moment in a frame, it's the calmest the codebase will ever be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evolution 1: Tenant B wants their own twist
&lt;/h2&gt;

&lt;p&gt;A week in (a week, we didn't even get a full sprint), tenant B messages: "can we change the prompt ourselves, without waiting for a deploy?" Fair ask, they know their users, we don't, and also nobody wants to be the on-call engineer who gets paged to edit a string literal. A hardcoded object can't answer that, it needs a rebuild to change a comma.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────┐     ┌──────────────────┐     ┌─────────────┐
│  Request    │ →   │  tenant_settings │ →   │  Provider   │
│  (tenantId) │     │  (DB row, admin  │     │  SDK call   │
│             │     │   editable)      │     │             │
└─────────────┘     └──────────────────┘     └─────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Config moves from a code constant to a table the tenant's own admin UI can write to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAiConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantSettings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aiConfig&lt;/span&gt; &lt;span class="c1"&gt;// { provider, model, prompt }&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same shape as before, different source. &lt;code&gt;handleChat&lt;/code&gt; doesn't change at all, it has no idea any of this happened, which is exactly the point of putting the lookup behind one function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evolution 2: "wait, who changed the prompt?" 🕵️
&lt;/h2&gt;

&lt;p&gt;Self-service is great until it isn't: tenant B's prompt quietly changed last Tuesday, their bot started answering in pirate-speak for reasons nobody can reconstruct, support gets a ticket, and the honest answer is "we have no idea, the database doesn't remember either." A plain DB row just gets overwritten, the past has no representation, it's Ctrl+Z with no undo history.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────┐     ┌────────────────┐     ┌──────────────────┐
│  Admin edits │ →   │  ConfigChanged │ →   │  current config  │
│  the prompt  │     │  event (who,   │     │  = fold(events)  │
│              │     │  when, diff)   │     │                  │
└──────────────┘     └────────────────┘     └──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The config becomes event-sourced instead of a mutable row: every change is an event, the current value is a projection over them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateAiConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AiConfig&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AiConfigChanged&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAiConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AiConfig&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&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="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AiConfigChanged&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;patch&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_AI_CONFIG&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;Now "who changed it and when" is a query, not a seance 🔮. &lt;code&gt;handleChat&lt;/code&gt; still hasn't changed, it just calls &lt;code&gt;getAiConfig&lt;/code&gt;, blissfully unaware it's now talking to an event log instead of a table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evolution 3: BYOK and a usage cap 💸
&lt;/h2&gt;

&lt;p&gt;A bigger tenant shows up, the kind that gets its own Slack channel, with two demands: they want to use &lt;em&gt;their own&lt;/em&gt; OpenAI key (cost control, their own rate limits, their own finance team breathing down their neck), and they want a hard cap on monthly spend so an over-caffeinated intern's script can't turn into a five-figure invoice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────┐     ┌───────────────────────┐     ┌──────────────┐
│  Request     │ →   │  config.apiKey?       │ →   │  usage &amp;lt; cap?│
│              │     │  (BYOK, encrypted)    │     │  → call      │
│              │     │  else our shared key  │     │  → else 429  │
└──────────────┘     └───────────────────────┘     └──────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;callProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getAiConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getMonthlyUsage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usageCap&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usageCap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UsageCapExceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byokApiKey&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SHARED_API_KEY&lt;/span&gt; &lt;span class="c1"&gt;// BYOK overrides shared key&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recordUsage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalTokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two additive fields on the same config, &lt;code&gt;byokApiKey&lt;/code&gt; and &lt;code&gt;usageCap&lt;/code&gt;, and one counter check before the call. No new architecture, no rewrite of the first three stages, no "sorry, we need a full quarter to redesign this."&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern behind the pattern 🧵
&lt;/h2&gt;

&lt;p&gt;Every stage kept &lt;code&gt;getAiConfig(tenantId) → { provider, model, prompt, ... }&lt;/code&gt; as the seam. Storage changed underneath it four times (constant, DB row, event-sourced projection, projection with encrypted secrets) and the call site never noticed, never cared, never even asked. That's the actual lesson: don't design the multi-tenant AI system upfront, design one seam that can absorb whatever the next Slack message throws at it.&lt;/p&gt;

&lt;p&gt;Skipped on purpose: provider fallback, streaming, per-model cost tables. Add those when a tenant actually asks, same as everything above. If a tenant asks for a fifth provider before you've read this sentence, that's not a counterexample, that's Tuesday. 🙃&lt;/p&gt;

</description>
      <category>ai</category>
      <category>multitenancy</category>
      <category>typescript</category>
      <category>prisma</category>
    </item>
  </channel>
</rss>
