<?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>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>
    <item>
      <title>We Cut Our AI Pipeline Costs 25% Without Losing Accuracy (and the fix wasn't a cheaper model)</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Sat, 01 Aug 2026 18:46:54 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/we-cut-our-ai-pipeline-costs-25-without-losing-accuracy-and-the-fix-wasnt-a-cheaper-model-4l5n</link>
      <guid>https://dev.to/marc_kumiko/we-cut-our-ai-pipeline-costs-25-without-losing-accuracy-and-the-fix-wasnt-a-cheaper-model-4l5n</guid>
      <description>&lt;p&gt;Our AI pipeline runs three step kinds (&lt;code&gt;ai.generate&lt;/code&gt;, &lt;code&gt;ai.extract&lt;/code&gt;, &lt;code&gt;ai.classify&lt;/code&gt;), each independently resolving its own provider, model, and prompt revision at run time. The default model is Sonnet, not Opus — and for a while that felt like a compromise, because Opus was the expensive-but-reliable option and Sonnet needed babysitting to hit the same pass rate.&lt;/p&gt;

&lt;p&gt;The fix that closed the gap wasn't a smarter prompt. It was &lt;code&gt;tool_choice&lt;/code&gt; plus a tightened output schema, forcing the model to commit to an answer shape instead of spending tokens hedging its way there. That alone got Sonnet to pass-parity with Opus's prior output at roughly a quarter of the cost, in our eval. A second, separate lever: Anthropic's own recommended &lt;code&gt;effort: "xhigh"&lt;/code&gt; for agentic tasks produced roughly twice the thinking tokens of &lt;code&gt;"high"&lt;/code&gt; for the same pass rate — thinking tokens bill like output tokens, so that's a straight 2x for zero accuracy gain, and it only matters when Opus is used via an explicit override (Sonnet doesn't get adaptive thinking at all). A third, independent lever: &lt;code&gt;max_tokens&lt;/code&gt; defaulted to 16000 out of caution; dropping it to 4000 (still comfortably above what any real step needed) cut effective output cost again, because Anthropic bills against the cap as an upper bound in some failure paths, not only against what the model actually emits.&lt;/p&gt;

&lt;p&gt;None of these three touched the prompt content or the provider. All three came from looking at per-step token usage, which only exists because every step writes an immutable provenance record on completion: prompt revision, provider, model, token usage. Same idea as event sourcing, applied to LLM calls instead of domain writes. You don't trust "the pipeline probably used prompt v3, on whatever model was configured," you have a row that says so — and you can audit six months of "which prompt touched this tenant" without ever loading the actual LLM payloads (also the DSGVO-friendly shape: provenance rows carry no user content, only call metadata).&lt;/p&gt;

&lt;p&gt;The other real trap, found the hard way: adaptive thinking and forced tool-use don't mix. Anthropic 400s with "Thinking may not be enabled when tool_choice forces tool use" the moment both are set, which sounds obvious in hindsight but not when you're setting both because both individually sound like "make the model try harder." The fix has to be automatic — detect a forced &lt;code&gt;tool_choice&lt;/code&gt; and disable adaptive thinking before the request goes out — because leaving it to every caller to remember means every eval run using &lt;code&gt;toolChoice: { type: "any" }&lt;/code&gt; against an Opus override breaks the same way, all at once.&lt;/p&gt;

&lt;p&gt;Separately: prompt caching only pays off if the &lt;code&gt;cache_control&lt;/code&gt; breakpoint sits on the &lt;em&gt;last&lt;/em&gt; static block, with tools and system prompt as one cacheable prefix. Past roughly 16k tokens without it, requests start hitting the SDK's HTTP timeout before the response streams back far enough to matter. Put the breakpoint on something that changes per request and you get a silent cache-miss that looks identical to a hit in the logs while you keep paying full price.&lt;/p&gt;

&lt;p&gt;Full writeup with the pipeline architecture (with diagrams), the provider-resolution code, and the provenance shape: &lt;a href="https://docs.kumiko.rocks/en/guides/ai-pipeline-provenance/" rel="noopener noreferrer"&gt;docs.kumiko.rocks/en/guides/ai-pipeline-provenance&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>llm</category>
      <category>architecture</category>
    </item>
    <item>
      <title>We Shipped a Mortgage Calculator Bug Where Every Test Was Green and the Answer Was Still Wrong</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Sat, 01 Aug 2026 11:48:07 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/we-shipped-a-mortgage-calculator-bug-where-every-test-was-green-and-the-answer-was-still-wrong-25ic</link>
      <guid>https://dev.to/marc_kumiko/we-shipped-a-mortgage-calculator-bug-where-every-test-was-green-and-the-answer-was-still-wrong-25ic</guid>
      <description>&lt;p&gt;A review pass on our credit calculator flagged something that should have been impossible: a&lt;br&gt;
finding tagged HIGH, on a panel with a full test suite, where every individual test passed and&lt;br&gt;
the answer was still wrong. Not "wrong in an edge case" wrong. Wrong for every single user who had&lt;br&gt;
ever configured a Sondertilgung (a lump-sum extra payment on their mortgage) and then looked at&lt;br&gt;
the "what if I invest the difference instead" comparison next to it.&lt;/p&gt;

&lt;p&gt;The bug: the comparison panel always ran the loan projection with an empty additionals list. The&lt;br&gt;
headline result above it, the number the user actually configured and trusted, included their&lt;br&gt;
real Sondertilgungen. So if you had a 20k lump-sum payment scheduled for month 6, the headline&lt;br&gt;
said "your loan is basically gone by year 8" and the panel right next to it, presented as&lt;br&gt;
directly comparable, was quietly running a different loan. Not a rounding difference. A different&lt;br&gt;
payoff date, a different remaining balance, a different verdict on whether investing instead&lt;br&gt;
would have won.&lt;/p&gt;

&lt;p&gt;Every test was green because every test checked one function against one input. Nothing checked&lt;br&gt;
that the two numbers on the same screen were talking about the same loan.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why "pay down debt faster vs. invest the difference" is rigged by default
&lt;/h2&gt;

&lt;p&gt;That specific bug is a symptom of a more general trap. Almost every calculator online that lets&lt;br&gt;
you compare "pay down debt faster" against "invest the difference instead" gets the comparison&lt;br&gt;
itself wrong, independent of any coding bug — because it quietly changes the monthly budget&lt;br&gt;
between the two scenarios and presents the result as a fair fight.&lt;/p&gt;

&lt;p&gt;Say you have a 300k loan at 3.5%, paying 3% annual amortization. Someone suggests: "drop to 1%&lt;br&gt;
amortization, invest the rest." The naive calculator compares:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scenario A: 3% amortization, no investing, ~1200/month total outflow&lt;/li&gt;
&lt;li&gt;Scenario B: 1% amortization, invest a fixed 200/month, ~950/month total outflow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Of course B looks better over ten years. You're comparing spending 1200/month against spending&lt;br&gt;
950/month and marveling that the cheaper option built more wealth. It didn't win on insight. It&lt;br&gt;
won because you fed it a smaller number.&lt;/p&gt;

&lt;p&gt;The fix is a structural invariant, not a footnote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;credit installment (this month) + ETF contribution (this month) = fixed monthly budget
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every month, for both scenarios, and once a loan is paid off in one scenario, its full budget&lt;br&gt;
rolls into the ETF contribution from that point on. You don't get to just stop spending because&lt;br&gt;
the mortgage happened to end early in the model. Once that holds, "total interest paid" stops&lt;br&gt;
being a meaningful metric (it no longer means the same thing across scenarios that pay&lt;br&gt;
differently), and the only honest number left is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;net worth(t) = ETF portfolio(t) - remaining loan balance(t)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;at a fixed horizon, for both scenarios, with identical cash outflow throughout.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invariant was never the bug. Something else was.
&lt;/h2&gt;

&lt;p&gt;Here's the part that made this finding interesting: our budget invariant was correctly&lt;br&gt;
implemented from day one. Every month's ETF contribution is computed as &lt;code&gt;monthlyBudget -&lt;br&gt;
loanRate&lt;/code&gt;, not hardcoded, not assumed. If you'd audited that one property in isolation, you'd have&lt;br&gt;
signed off.&lt;/p&gt;

&lt;p&gt;The actual failure mode was a level up from that: getting the fair-comparison math right and then&lt;br&gt;
silently comparing two different loans anyway. The panel and the headline agreed on the rule&lt;br&gt;
("budget minus installment goes to the ETF") but disagreed on the input ("what installment,&lt;br&gt;
exactly, on what loan"). A structurally correct comparison of the wrong pair of scenarios is still&lt;br&gt;
wrong, and none of the unit tests around the fair-comparison logic could have caught it, because&lt;br&gt;
that logic was never the part that broke.&lt;/p&gt;

&lt;p&gt;The fix threads the same &lt;code&gt;additionals&lt;/code&gt; the headline uses through the comparison panel, so both&lt;br&gt;
numbers on the screen are now guaranteed to describe the same loan.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson, and something to go check right now
&lt;/h2&gt;

&lt;p&gt;Budget-neutrality gets cited (correctly) as the thing naive pay-down-vs-invest calculators get&lt;br&gt;
wrong. It's necessary. It is not sufficient. You also have to pin every other input, identically,&lt;br&gt;
across both scenarios you're comparing, or the comparison quietly starts answering a different&lt;br&gt;
question than the one on the label, and a green test suite will not tell you.&lt;/p&gt;

&lt;p&gt;If you've got a mortgage calculator, a rent-vs-buy tool, or a pay-down-vs-invest comparison&lt;br&gt;
bookmarked somewhere: go feed it a scenario with something extra configured (a lump-sum payment,&lt;br&gt;
an irregular income month, whatever the tool supports), and check whether the comparison panel&lt;br&gt;
still uses it. Most won't tell you either way. Check anyway.&lt;/p&gt;




&lt;p&gt;This is the Tilgung-vs-ETF panel in &lt;a href="https://cashcolt.kumiko.rocks" rel="noopener noreferrer"&gt;cashcolt&lt;/a&gt;, a free&lt;br&gt;
no-signup mortgage calculator hosted in Germany. No tracking, no login, poke at it with your own&lt;br&gt;
numbers.&lt;/p&gt;

</description>
      <category>bug</category>
      <category>debugging</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
    <item>
      <title>Your event store is already your audit log</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Wed, 01 Jul 2026 07:51:25 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/your-event-store-is-already-your-audit-log-1keo</link>
      <guid>https://dev.to/marc_kumiko/your-event-store-is-already-your-audit-log-1keo</guid>
      <description>&lt;h1&gt;
  
  
  Your event store is already your audit log
&lt;/h1&gt;

&lt;p&gt;Almost every SaaS I've worked on ends up with an &lt;code&gt;audit_log&lt;/code&gt; table. Someone files a compliance ticket — "we need to know who changed what and when" — and a new table appears next to the domain tables. Then the real work starts: writing to it on every mutating endpoint, keeping it in sync, and quietly discovering six months later that three endpoints forgot to log.&lt;/p&gt;

&lt;p&gt;That table is a second source of truth. And second sources of truth drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an audit log actually needs
&lt;/h2&gt;

&lt;p&gt;Strip the compliance language away and an audit entry is five fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;who&lt;/strong&gt; did it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;when&lt;/strong&gt; they did it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;what&lt;/strong&gt; they touched (which entity)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;which action&lt;/strong&gt; it was&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the delta&lt;/strong&gt; — what actually changed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus, for a multi-tenant app: &lt;strong&gt;whose data&lt;/strong&gt; it was, so tenant A can never read tenant B's history.&lt;/p&gt;

&lt;p&gt;Now look at what an event in an event-sourced system carries. Every state change is an appended event with &lt;code&gt;createdBy&lt;/code&gt;, &lt;code&gt;createdAt&lt;/code&gt;, &lt;code&gt;tenantId&lt;/code&gt;, &lt;code&gt;aggregateType&lt;/code&gt; + &lt;code&gt;aggregateId&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, and a &lt;code&gt;payload&lt;/code&gt; holding the delta.&lt;/p&gt;

&lt;p&gt;That's the same five fields. The event log already &lt;em&gt;is&lt;/em&gt; the audit trail — append-only, ordered, and impossible to forget to write, because writing the event &lt;em&gt;is&lt;/em&gt; how state changes in the first place. There's no code path that mutates data without producing an event.&lt;/p&gt;

&lt;h2&gt;
  
  
  So don't build the table. Query the log.
&lt;/h2&gt;

&lt;p&gt;If the audit trail is already there, the whole "audit feature" collapses into one privileged read over the events table:&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;listQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineQueryHandler&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;schema&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;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;aggregateType&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;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;aggregateId&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;uuid&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;eventType&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;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;userId&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;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;from&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="nx"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&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;to&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="nx"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&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;limit&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;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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;before&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;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// cursor&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;roles&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;Admin&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;SystemAdmin&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="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&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="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;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&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;where&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&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="c1"&gt;// tenant-isolated at the WHERE&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aggregateType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aggregateType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aggregateType&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aggregateId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aggregateId&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aggregateId&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventType&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createdBy&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...time range + cursor omitted for brevity&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;selectMany&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="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eventsTable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;col&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="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limit&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;No table, no projection, no write path, no sync job. The filter surface an audit UI wants — by entity, by actor, by action, by time — is just &lt;code&gt;WHERE&lt;/code&gt; clauses over columns the events already have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two things you still owe
&lt;/h2&gt;

&lt;p&gt;Reusing the event log doesn't come completely free. Two concerns are real:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Access control.&lt;/strong&gt; The event log is the most sensitive read in the system — it's literally everything that ever happened. Gate it hard (&lt;code&gt;Admin&lt;/code&gt; / &lt;code&gt;SystemAdmin&lt;/code&gt; above) and pin tenant isolation into the &lt;code&gt;WHERE&lt;/code&gt; clause itself, not into application logic that a future refactor can bypass. Cross-tenant peeking should be structurally impossible, not politely discouraged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. PII.&lt;/strong&gt; If you dump raw event payloads into an audit view, you'll surface fields you didn't mean to. The clean fix is to strip sensitive values &lt;em&gt;at append time&lt;/em&gt; — mark them in the entity definition and never let them into the stored event. Then the audit read physically cannot leak them, because they were never written. Doing it at read time is a filter you'll eventually forget on some new field; doing it at write time is a guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this doesn't apply
&lt;/h2&gt;

&lt;p&gt;Honesty: this only works if you're actually event-sourced. If your system does in-place &lt;code&gt;UPDATE&lt;/code&gt;s, there's no historical record to query — you genuinely need to &lt;em&gt;start&lt;/em&gt; capturing one, and a dedicated table (or CDC/logical decoding off the WAL) is the pragmatic path. This isn't an argument to adopt event sourcing &lt;em&gt;for&lt;/em&gt; audit; it's an argument that if you already have it, the second table is redundant.&lt;/p&gt;

&lt;p&gt;One caveat even when it fits: event schemas evolve, so your audit reader sees heterogeneous historical payloads. For an audit log that's a feature — you want the exact shape as it was written — but don't mistake it for a clean queryable projection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;An audit log isn't a thing you build. It's a &lt;em&gt;view&lt;/em&gt; onto history you're already keeping. If you're appending events, you've been sitting on a complete, tamper-evident audit trail the whole time — the only missing piece was a gated query with the right &lt;code&gt;WHERE&lt;/code&gt; clauses.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is exactly how the &lt;code&gt;audit&lt;/code&gt; feature works in &lt;a href="https://kumiko.rocks" rel="noopener noreferrer"&gt;Kumiko&lt;/a&gt;, a Bun/TypeScript framework where multi-tenancy, GDPR, and audit are bundled features rather than boilerplate you rewrite per project — one ~40-line query handler, no separate table.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>eventsourcing</category>
      <category>postgres</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>How do you prevent AI-generated code from drifting away from your conventions over time?</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Sun, 28 Jun 2026 10:52:37 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/how-do-you-prevent-ai-generated-code-from-drifting-away-from-your-conventions-over-time-4b3l</link>
      <guid>https://dev.to/marc_kumiko/how-do-you-prevent-ai-generated-code-from-drifting-away-from-your-conventions-over-time-4b3l</guid>
      <description>&lt;p&gt;We've been generating production features with AI for a while now — auth flows, billing hooks, notification handlers. And we've hit a pattern we don't have a good answer to yet.&lt;/p&gt;

&lt;p&gt;The first feature the AI generates looks great. It reads the codebase, picks up the patterns, and the output looks like something a senior dev wrote.&lt;/p&gt;

&lt;p&gt;The tenth feature? Less so. Small inconsistencies creep in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A handler that doesn't follow the error-handling convention&lt;/li&gt;
&lt;li&gt;A schema field with a different naming pattern&lt;/li&gt;
&lt;li&gt;A test that checks existence instead of behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of it is wrong. All of it is subtly inconsistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixes we've tried
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;AGENTS.md / CLAUDE.md&lt;/strong&gt; — helps, but gets stale and doesn't scale with the codebase&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code review&lt;/strong&gt; — catches it, but defeats some of the speed advantage&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linting + formatting&lt;/strong&gt; — catches easy stuff, misses semantic drift&lt;/p&gt;

&lt;p&gt;What we haven't solved: giving the AI a "living" representation of your conventions that stays current as the codebase evolves.&lt;/p&gt;

&lt;p&gt;We're building Kumiko — an opinionated SaaS framework — partly as an answer to this. If the framework constrains what's possible, drift has less surface area. But I'm not convinced that fully solves it either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Curious what's actually working for others
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Do you just review AI output carefully and accept some drift?&lt;/li&gt;
&lt;li&gt;Custom guards / linters that encode your conventions?&lt;/li&gt;
&lt;li&gt;Something that auto-generates AGENTS.md from the codebase?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's your approach?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Multi-Tenancy in Bun/Hono Without Boilerplate</title>
      <dc:creator>Marc</dc:creator>
      <pubDate>Wed, 24 Jun 2026 19:11:12 +0000</pubDate>
      <link>https://dev.to/marc_kumiko/multi-tenancy-in-bunhono-without-boilerplate-2kgk</link>
      <guid>https://dev.to/marc_kumiko/multi-tenancy-in-bunhono-without-boilerplate-2kgk</guid>
      <description>&lt;p&gt;Every multi-tenant SaaS has the same problem: you need to make sure every query only returns data for the right tenant. Forget a &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt; once, and you have a data leak.&lt;/p&gt;

&lt;p&gt;The obvious solution — a separate database per tenant — doesn't scale. Connections are expensive, migration overhead multiplies, and you lose cross-tenant reporting.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://kumiko.rocks" rel="noopener noreferrer"&gt;Kumiko&lt;/a&gt; we went a different route: &lt;strong&gt;a single DB pool, but every query automatically gets the tenantId injected&lt;/strong&gt; — without handler code ever having to do it manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Idea: TenantDb Instead of a Raw DbRunner
&lt;/h2&gt;

&lt;p&gt;Instead of passing a raw DB connection around, we create a &lt;code&gt;TenantDb&lt;/code&gt; wrapper per request:&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;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createTenantDb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawDb&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From that point, &lt;code&gt;db&lt;/code&gt; behaves like a normal database — but with automatic isolation baked in:&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;// Handler code — no tenantId needed&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&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="nf"&gt;selectMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usersTable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// → SELECT * FROM users WHERE tenant_id IN ('tenant-123', 'system')&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="nf"&gt;insertOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usersTable&lt;/span&gt;&lt;span class="p"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// → INSERT INTO users (name, tenant_id) VALUES ('Max', 'tenant-123')&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="nf"&gt;updateMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usersTable&lt;/span&gt;&lt;span class="p"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Moritz&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// → UPDATE users SET name='Moritz' WHERE id=? AND tenant_id='tenant-123'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Handlers write plain CRUD code. Isolation happens underneath — invisible, but enforced.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Injection Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Reads: own rows + reference data
&lt;/h3&gt;

&lt;p&gt;Read queries always see two tenants: the current one and &lt;code&gt;SYSTEM_TENANT_ID&lt;/code&gt;. This allows reference data (e.g. global config) to be visible to all tenants without duplicating 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="c1"&gt;// tenantId filter === [currentTenantId, SYSTEM_TENANT_ID]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a handler passes its own &lt;code&gt;tenantId&lt;/code&gt; in the WHERE clause, it can only &lt;strong&gt;narrow&lt;/strong&gt; the scope, never widen it. A &lt;code&gt;where: { tenantId: 'other-tenant' }&lt;/code&gt; is silently dropped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Writes: own rows only
&lt;/h3&gt;

&lt;p&gt;Inserts get &lt;code&gt;tenantId&lt;/code&gt; forced in — and the value cannot be overridden by the caller:&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;// mode === "tenant": tenantId on INSERT is enforced last&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// overwrites whatever the handler passed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Updates and deletes without a WHERE clause throw an error instead of hitting all rows:&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;// Prevents accidental mass-updates&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TenantDb.updateMany without where would mass-update all tenant rows.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  System mode for operators
&lt;/h3&gt;

&lt;p&gt;For admin screens there's &lt;code&gt;r.systemScope()&lt;/code&gt; — queries run unfiltered across all tenants. Explicit opt-in only, never the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tenant Resolution in Hono
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TenantDb&lt;/code&gt; needs a &lt;code&gt;tenantId&lt;/code&gt;. It comes from middleware that resolves it from the request — either from the hostname (for custom domains) or from the JWT:&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;// Middleware (simplified)&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&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;tenant&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;resolveTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;db&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;createTenantDb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawDb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;next&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;Every handler then gets proper isolation through &lt;code&gt;ctx.db&lt;/code&gt; — without a single line of tenant logic in actual feature code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means in Practice
&lt;/h2&gt;

&lt;p&gt;Across three years of production use and several apps (CashColt, publicstatus, kumiko-studio) we've had &lt;strong&gt;zero data leak bugs&lt;/strong&gt; from forgotten tenant filters. Not because we were particularly careful, but because it's structurally impossible to forget.&lt;/p&gt;

&lt;p&gt;The overhead: nearly zero. A few extra conditions per query, no extra DB connection pool, no migration overhead multiplied per tenant.&lt;/p&gt;

&lt;p&gt;If you want to try the framework: &lt;a href="https://kumiko.rocks" rel="noopener noreferrer"&gt;kumiko.rocks&lt;/a&gt; — open source under BUSL-1.1.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>saas</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
