<?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: MICHAEL-MAURICE</title>
    <description>The latest articles on DEV Community by MICHAEL-MAURICE (@michael_maurice).</description>
    <link>https://dev.to/michael_maurice</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%2F618103%2F9e2998c7-eed6-433e-9756-1516dffbd46c.png</url>
      <title>DEV Community: MICHAEL-MAURICE</title>
      <link>https://dev.to/michael_maurice</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/michael_maurice"/>
    <language>en</language>
    <item>
      <title>The Shadow Model Issued a Refund: 7 Mistakes That Make LLM Model Upgrades in .NET Riskier Than They Look</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Fri, 30 Oct 2026 12:56:08 +0000</pubDate>
      <link>https://dev.to/michael_maurice/the-shadow-model-issued-a-refund-7-mistakes-that-make-llm-model-upgrades-in-net-riskier-than-they-j9j</link>
      <guid>https://dev.to/michael_maurice/the-shadow-model-issued-a-refund-7-mistakes-that-make-llm-model-upgrades-in-net-riskier-than-they-j9j</guid>
      <description>&lt;p&gt;"Let's shadow the new model first, then canary it." Everyone agrees with that sentence in planning. It sounds like the careful option, and it is, as long as the shadow can't do anything and the canary is the same set of users on every server.&lt;/p&gt;

&lt;p&gt;I built a model-upgrade pipeline for a .NET 10 support assistant on &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; 10.11.0. The assistant has three tools: &lt;code&gt;get_order&lt;/code&gt;, &lt;code&gt;issue_refund&lt;/code&gt; and &lt;code&gt;escalate_to_human&lt;/code&gt;. Two of them move money or create work for people. That's on purpose, because a shadow setup that's harmless for a chatbot can be dangerous for an agent.&lt;/p&gt;

&lt;p&gt;Here are seven mistakes I made, or nearly made, on the way. The code is trimmed to the shape, and the trimmed parts are marked.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; The complete, working, tested version of this shadow and canary project is inside, with full source and an in-depth PDF guide. &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Mistake 1: Thinking you're upgrading a model id
&lt;/h2&gt;

&lt;p&gt;The pull request says "switch to the new model". It usually also changes the system prompt "while we're at it", and maybe the temperature. Any one of those can change behavior. If your metrics only know the model id, you can't tell which change caused what, and two different prompts on the same model look like one release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; release the three together and give the combination a fingerprint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ModelRelease&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ModelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PromptVersion&lt;/span&gt; &lt;span class="n"&gt;Prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Temperature&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;MaxOutputTokens&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Fingerprint&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Hashing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Short&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* model id + prompt hash + options, culture-invariant (full version in the complete project) */&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;In the project, a &lt;code&gt;ReleaseChatClient&lt;/code&gt; (a plain &lt;code&gt;DelegatingChatClient&lt;/code&gt;) pins a provider client to one release, so nothing in the app can quietly swap the prompt. The prompt registry is append-only: change a released prompt and you have a new release with a new fingerprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 2: Mirroring the whole request
&lt;/h2&gt;

&lt;p&gt;This is the one in the title. The obvious shadow design sends each request through a second pipeline and compares the final answers. With tools, that second pipeline runs its own tool calls. In my demo, the "shadow" candidate rounded a refund up, and the order book ended with two refunds: 20.50 EUR from the live model and 21.00 EUR from the shadow. The shadow wasn't watching. It acted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; put the shadow &lt;em&gt;under&lt;/em&gt; &lt;code&gt;FunctionInvokingChatClient&lt;/code&gt;, so it compares one model turn at a time, and give the candidate the tools as declarations only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;ChatOptions&lt;/span&gt; &lt;span class="nf"&gt;DeclarationsOnly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChatOptions&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;Clone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ChatOptions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// replace every AIFunction in o.Tools with f.AsDeclarationOnly(): same name and schema, not invocable&lt;/span&gt;
    &lt;span class="c1"&gt;// (full version in the complete project)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;o&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 candidate decides with the same information, and its tool calls come back as &lt;code&gt;FunctionCallContent&lt;/code&gt; that nothing executes. As a belt-and-braces check, the shadow runner asks the pipeline for &lt;code&gt;GetService&amp;lt;FunctionInvokingChatClient&amp;gt;()&lt;/code&gt; and refuses to start if it finds one.&lt;/p&gt;

&lt;p&gt;A nice side effect: comparing per turn gives you a precise, cheap signal. &lt;code&gt;issue_refund {"amount":20.5}&lt;/code&gt; against &lt;code&gt;issue_refund {"amount":21}&lt;/code&gt; doesn't need a judge to be flagged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 3: Letting the shadow job hold references
&lt;/h2&gt;

&lt;p&gt;The shadow runs later, on a background worker. If the job keeps references to the request's objects, it compares against something that has changed in the meantime.&lt;/p&gt;

&lt;p&gt;Three ways this bit me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Middleware that isn't under function invocation receives the caller's own message list. The app appends to it, so by the time the worker ran, the "input" had 3 messages instead of 1.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ChatMessage.Clone()&lt;/code&gt; is shallow. The clone and the original share one &lt;code&gt;Contents&lt;/code&gt; list.&lt;/li&gt;
&lt;li&gt;The request's cancellation token is cancelled when the request ends, which is exactly when shadow work starts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; snapshot on the request thread, with new messages and copied content lists, and give the runner its own timeout (through &lt;code&gt;TimeProvider&lt;/code&gt;, so tests run on fake time) that's linked only to host shutdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 4: Trusting the channel to count what it dropped
&lt;/h2&gt;

&lt;p&gt;The shadow must never slow down a real request, so jobs go into a bounded channel and are dropped when it's full. Fine. Then I counted the drops with the return value of &lt;code&gt;TryWrite&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;BoundedChannelFullMode.DropWrite&lt;/code&gt;, &lt;code&gt;TryWrite&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt; for items it drops. In the demo, five writes into a channel of capacity two all returned &lt;code&gt;true&lt;/code&gt;, and three of the jobs were gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; count in the &lt;code&gt;itemDropped&lt;/code&gt; callback you pass to &lt;code&gt;Channel.CreateBounded&lt;/code&gt;, which is the only honest signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;_channel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateBounded&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ShadowJob&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;BoundedChannelOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;FullMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BoundedChannelFullMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DropWrite&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;dropped&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* count it, and remember which job it was (omitted) */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why it matters beyond ops: a queue that drops under load drops at peak hours, so your shadow sample quietly turns into a sample of quiet hours. The project's shadow gate holds when drops exceed a tenth of the enqueued jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 5: Gating on "agreement"
&lt;/h2&gt;

&lt;p&gt;The first gate I wrote said: promote if the candidate agrees with the current model often enough. On one simulated day, the shadow compared 898 turns and agreement was 58.4%. Was that bad?&lt;/p&gt;

&lt;p&gt;Mostly it was style. The new prompt asked for a warmer tone, and "Good news! Your order is shipped..." scores low against "Order shipped." A terse model that gets dates wrong could score higher. Agreement measures voice as much as quality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; gate on mechanical red flags, each with its denominator, instead of on overall similarity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the candidate refused where the current model answered (2.6% that day; my limit is 1%)&lt;/li&gt;
&lt;li&gt;a different tool or different arguments on tool turns (13.0%; limit 2%)&lt;/li&gt;
&lt;li&gt;text-versus-tool decision flips, candidate failures and timeouts, latency, and the drop rate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That first candidate held on refusals and tool divergence. And a shadow gate never rolls back on its own, because no user is exposed yet. It holds and waits for a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 6: Bucketing users with &lt;code&gt;GetHashCode()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Once the shadow is clean, real users get the candidate. Each user must land on the same side on every request, every pod and every restart. &lt;code&gt;Math.Abs(userId.GetHashCode()) % 100&lt;/code&gt; looks fine and isn't: string hashing in .NET is randomized per process, so your 1% is a different 1% on every instance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; a stable hash with a per-rollout salt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;Of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// SHA-256 over salt + separator + subject, first 8 bytes as an unsigned number,&lt;/span&gt;
    &lt;span class="c1"&gt;// modulo 10,000 buckets (full version in the complete project)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;InCanary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;canaryBasisPoints&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;canaryBasisPoints&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;Of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;Math&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="n"&gt;canaryBasisPoints&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Buckets&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The salt keeps the same unlucky users from testing every model you ever ship. The "less than" makes the ramp monotonic, so going from 5% to 25% only adds users. Requests without a user stay on the current model during the canary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 7: Fallbacks that hide failures, and gates that can't say "not sure"
&lt;/h2&gt;

&lt;p&gt;When the candidate throws, retry the turn on the current model, so users on the canary get a slower answer instead of an error. Good. But if the retry also hides the failure from your metrics, you've built a slower way to promote a broken model. In the demo, a candidate that failed every third call caused 32 failed turns in 200 requests and zero user-visible failures, and the gates still saw all 32. A hard stop (at least 5 failures at a 20% rate or more) rolled it back immediately.&lt;/p&gt;

&lt;p&gt;The second half of this mistake is a gate with only two answers. With small samples, "0 violations in 400 turns" doesn't prove much. The project's rate checks use a Wilson interval and have three outcomes: clearly worse (roll back), clearly fine (pass), or not conclusive yet (hold). At a half-point margin, 400 clean turns still leave the upper bound at 0.95%; you need about 800. At 1% of traffic that can take days, which tells you how short a 1% step is allowed to be.&lt;/p&gt;

&lt;p&gt;Not every failed gate should roll back, either. Harm (failures, contract violations, p95 latency, negative feedback) rolls back automatically. A correct but slow candidate at 50% was rolled back on a p95 of 1,971 ms against 575 ms. Cost only holds for a person, because a more expensive model can be the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pre-flight list for your next model upgrade
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Model id, prompt and options ship together under one fingerprint&lt;/li&gt;
&lt;li&gt;[ ] The shadow compares single turns and can't execute tools&lt;/li&gt;
&lt;li&gt;[ ] Shadow inputs are deep-copied on the request thread&lt;/li&gt;
&lt;li&gt;[ ] Dropped shadow jobs are counted from the drop callback&lt;/li&gt;
&lt;li&gt;[ ] Gates use refusals, tool divergence and flips, not raw agreement&lt;/li&gt;
&lt;li&gt;[ ] Canary buckets come from a salted stable hash, not &lt;code&gt;GetHashCode()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Fallbacks protect users but never hide failures from the gates&lt;/li&gt;
&lt;li&gt;[ ] Every rate gate can answer "not conclusive yet"&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I tested, and what I didn't
&lt;/h2&gt;

&lt;p&gt;The project has 206 passing tests (193 unit, 13 &lt;code&gt;WebApplicationFactory&lt;/code&gt; integration tests), and none needs an API key. An offline support model has switchable bad habits (rounds refunds, refuses legal questions, fails every third call, runs slow), so each scenario above is a test, and a 10-scene CLI demo runs it all on fake time. The library behaviors I mention (the shallow &lt;code&gt;Clone()&lt;/code&gt;, the shared message list, &lt;code&gt;TryWrite&lt;/code&gt; under &lt;code&gt;DropWrite&lt;/code&gt;, randomized string hashes) were checked with spikes against 10.11.0 and .NET 10, and are pinned as tests.&lt;/p&gt;

&lt;p&gt;What I didn't do: run a rollout against real providers with real users. The traffic and every number in this post come from simulated days, so your thresholds will need tuning on your own traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the complete project
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; This project is already inside: the full .NET 10 solution with releases and the prompt registry, the shadow pipeline that never acts, the response comparer, the sticky canary router with fallback, the Wilson-bounded gates, the rollout controller with its kill switch, the offline models, a minimal API and the 206 tests, plus a PDF guide that goes much deeper than this post, including how to decide when thousands of shadow disagreements are safe enough to promote. You also get the other complete, tested .NET + AI projects in the membership, with new ones added regularly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder for $5/month&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which of these seven is hiding in your current upgrade plan?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>Review Your LLM Tool Like a Public Endpoint: 8 Questions to Ask Before You Merge an AIFunction in .NET 10</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Fri, 30 Oct 2026 12:54:51 +0000</pubDate>
      <link>https://dev.to/michael_maurice/review-your-llm-tool-like-a-public-endpoint-8-questions-to-ask-before-you-merge-an-aifunction-in-jc</link>
      <guid>https://dev.to/michael_maurice/review-your-llm-tool-like-a-public-endpoint-8-questions-to-ask-before-you-merge-an-aifunction-in-jc</guid>
      <description>&lt;p&gt;If a teammate opened a pull request that added a public HTTP endpoint, you'd ask questions. What happens with bad input? What does the caller see on error? Who is the caller, and how do we know? What happens when someone renames a field?&lt;/p&gt;

&lt;p&gt;Now look at the last pull request that added an LLM tool. In .NET 10 it's often a method with a &lt;code&gt;[Description]&lt;/code&gt; on it and one call to &lt;code&gt;AIFunctionFactory.Create&lt;/code&gt;. It probably got approved in two minutes, because there was barely anything to read.&lt;/p&gt;

&lt;p&gt;That's the trap. A tool &lt;em&gt;is&lt;/em&gt; an endpoint. The caller is a model that writes its own arguments, and it will send you strings where you wanted numbers, parameters that don't exist and times in formats you never mentioned. So here are the eight questions I now ask in review for every tool, based on a room-booking assistant I built on &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; 10.11.0. The code below is trimmed to the shape. The trimmed parts are marked.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; The complete, working, tested version of this tool-contract project is inside, with full source and an in-depth PDF guide. &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Question 1: Who chose the tool's name?
&lt;/h2&gt;

&lt;p&gt;If the answer is "the compiler", send it back. &lt;code&gt;AIFunctionFactory.Create&lt;/code&gt; on a lambda without a &lt;code&gt;Name&lt;/code&gt; gets a generated name like &lt;code&gt;RunAsync_1_0&lt;/code&gt;, and that's what the model is shown. The model will call it. It just has no idea what it does.&lt;/p&gt;

&lt;p&gt;Method names aren't much better. People rename methods freely, and the tool name is part of the wire contract. In my project the name lives in an attribute, separate from the method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"book_room"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ToolEffect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Writes&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Book a room for the current user. Use a roomId returned by find_rooms. Fails if the slot was taken meanwhile."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;BookingConfirmation&lt;/span&gt; &lt;span class="nf"&gt;BookRoom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;CallerContext&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// bound from the request, not the model (Question 6)&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Room id from find_rooms, for example R-102."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;RegularExpression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"^R-[0-9]{3}$"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// date, start, durationMinutes, attendees: described and bounded the same way (omitted)&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Short meeting title shown on the room display."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;StringLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MinimumLength&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// full implementation in the complete project&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;Tool&lt;/code&gt; is my own attribute, not part of the library. It also records whether the tool writes anything, and that flag ends up in the contract snapshot (Question 8). OpenAI only accepts function names matching &lt;code&gt;^[a-zA-Z0-9_-]{1,64}$&lt;/code&gt;, so check that too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 2: What happens when an argument breaks the schema?
&lt;/h2&gt;

&lt;p&gt;This is the question that surprised me most. The factory reads your DataAnnotations and puts them in the schema: &lt;code&gt;[Range(15, 240)]&lt;/code&gt; becomes &lt;code&gt;minimum&lt;/code&gt; and &lt;code&gt;maximum&lt;/code&gt;, &lt;code&gt;[RegularExpression]&lt;/code&gt; becomes &lt;code&gt;pattern&lt;/code&gt;. Lovely. Then I called the raw function with &lt;code&gt;durationMinutes: 300&lt;/code&gt;, and the method ran. The attributes describe the schema. Nothing checks them when the tool is invoked.&lt;/p&gt;

&lt;p&gt;"We use OpenAI strict mode" is not an answer either. Strict mode guarantees the shape (right keys, right types, no extras), but the OpenAI adapter moves keywords like &lt;code&gt;minimum&lt;/code&gt;, &lt;code&gt;maximum&lt;/code&gt; and &lt;code&gt;pattern&lt;/code&gt; into the description text to fit the supported schema subset. Your bound reaches the model as a sentence.&lt;/p&gt;

&lt;p&gt;So the review question becomes: where in &lt;em&gt;our&lt;/em&gt; code is the schema enforced? In my project, every factory-created function is wrapped in a &lt;code&gt;DelegatingAIFunction&lt;/code&gt; that validates against the same schema the model was shown, before anything runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ContractEnforcingFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AIFunction&lt;/span&gt; &lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* validator, options, audit... */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;DelegatingAIFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;ValueTask&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;InvokeCoreAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AIFunctionArguments&lt;/span&gt; &lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 1. validate `arguments` against JsonSchema (types, ranges, patterns, formats, unknown keys)&lt;/span&gt;
        &lt;span class="c1"&gt;// 2. on problems: return a structured error and do NOT call the method&lt;/span&gt;
        &lt;span class="c1"&gt;// 3. otherwise: await base.InvokeCoreAsync(...) and wrap the result&lt;/span&gt;
        &lt;span class="c1"&gt;// (full implementation in the complete project)&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 model still sees exactly the schema the factory generated. The wrapper just makes it true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 3: What happens with an argument that doesn't exist?
&lt;/h2&gt;

&lt;p&gt;With the raw factory function: nothing. Unknown keys are silently ignored. A model that sends &lt;code&gt;duration&lt;/code&gt; instead of &lt;code&gt;durationMinutes&lt;/code&gt; on a parameter with a default gets the default. A model that sends &lt;code&gt;floor: 3&lt;/code&gt; gets rooms on every floor and never learns there's no &lt;code&gt;floor&lt;/code&gt; parameter.&lt;/p&gt;

&lt;p&gt;For a public API you'd return a 400 listing the unknown field. Do the same here. My validator rejects unknown arguments and, when there's an obvious near-match, includes a suggestion (&lt;code&gt;"suggestion": "durationMinutes"&lt;/code&gt;). Also note that argument names are case-sensitive in the binder, so &lt;code&gt;DurationMinutes&lt;/code&gt; is "unknown" too unless you decide otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 4: Which near-misses do we fix, and which do we refuse?
&lt;/h2&gt;

&lt;p&gt;Rejecting everything that isn't perfect costs a round trip, and round trips cost latency and tokens. Accepting everything means guessing. You want a written policy, not whatever the serializer happens to do. Mine is roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;"6"&lt;/code&gt; for an integer: accept as 6, and tell the model in a note.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"projector"&lt;/code&gt; for the enum value &lt;code&gt;Projector&lt;/code&gt;: accept with a note. It can only mean one thing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;9:30&lt;/code&gt; for &lt;code&gt;09:30&lt;/code&gt;: accept with a note.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;9:30am&lt;/code&gt;, &lt;code&gt;"six"&lt;/code&gt;, integer enum values: &lt;strong&gt;refuse&lt;/strong&gt; and say what format is expected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The line is ambiguity. "Probably 09:30" is how a meeting ends up at 21:30. The notes go back with the successful result, and in practice the model switches to the canonical form on its next call. There's also a strict policy that refuses every coercion, handy in tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 5: What does the model read when the tool fails?
&lt;/h2&gt;

&lt;p&gt;Throw an exception from a tool, and with the default &lt;code&gt;IncludeDetailedErrors = false&lt;/code&gt;, &lt;code&gt;FunctionInvokingChatClient&lt;/code&gt; gives the model exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Function failed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That default is right, because exception messages leak internals. But the model now knows nothing about which of its arguments was wrong, so it guesses. Turning detailed errors on mostly tells it how your binder works.&lt;/p&gt;

&lt;p&gt;The review question is: are errors &lt;em&gt;results&lt;/em&gt;? In my project, validation and business-rule failures come back as a structured envelope the model reads like any other output:&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="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invalid_arguments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2 arguments need fixing; nothing was executed."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"retryable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"problems"&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;"...one entry per argument, with path, code and expected..."&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;"Nothing was executed" is there on purpose. For a write tool, the model needs to know whether retrying will book a second room. Unexpected exceptions still stay in your logs; the model gets an error id, not a stack trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 6: Can the model decide who the user is?
&lt;/h2&gt;

&lt;p&gt;If the tool has a &lt;code&gt;userId&lt;/code&gt; parameter, the answer is yes, and that's a finding. Every argument the model writes is something the prompt can influence.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AIFunctionFactoryOptions.ConfigureParameterBinding&lt;/code&gt; lets you take a parameter out of the schema and bind it yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;ConfigureParameterBinding&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParameterType&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CallerContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;AIFunctionFactoryOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParameterBindingOptions&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="n"&gt;ExcludeFromSchema&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;BindParameter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="cm"&gt;/* read CallerContext from args.Context, or fail closed */&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;default&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller comes from the authenticated request and is copied into &lt;code&gt;AIFunctionArguments.Context&lt;/code&gt; by a custom &lt;code&gt;FunctionInvoker&lt;/code&gt;. A model that adds &lt;code&gt;"userId": "admin"&lt;/code&gt; gets &lt;code&gt;unknown_argument&lt;/code&gt;, because the contract doesn't have that parameter. No signed-in user means &lt;code&gt;unauthorized&lt;/code&gt;, not a booking that belongs to nobody.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 7: How many times is the model allowed to be wrong?
&lt;/h2&gt;

&lt;p&gt;Structured errors bring a new failure mode: a model that reads the error, apologizes and sends the same bad arguments again. &lt;code&gt;FunctionInvokingChatClient&lt;/code&gt; has &lt;code&gt;MaximumIterationsPerRequest&lt;/code&gt; and &lt;code&gt;MaximumConsecutiveErrorsPerRequest&lt;/code&gt;, but a structured error isn't an exception, so the second limit never fires.&lt;/p&gt;

&lt;p&gt;So each tool gets an invalid-argument budget per request (two, by default). On the last allowed failure the error says &lt;code&gt;"retryable": false&lt;/code&gt; and tells the model to ask the user instead. If it calls the tool anyway, the custom invoker ends the loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;ficc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FunctionInvoker&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="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// copy the caller into ctx.Arguments.Context (omitted)&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;InvokeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&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="cm"&gt;/* this request has used up a tool's budget */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Terminate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&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 project's offline test model has a quirk that ignores error hints on purpose, and a test checks that it's stopped after two calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 8: What does this tool cost, and who notices when it changes?
&lt;/h2&gt;

&lt;p&gt;Tool definitions are prompt text, sent and billed on every request that carries them. Two cheap CI checks answer this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A linter.&lt;/strong&gt; Mine has 16 rules: valid names, no compiler-generated names, every tool and parameter described, no untyped &lt;code&gt;object&lt;/code&gt; parameters, no pairs of booleans that allow nonsense states, descriptions that aren't essays. On a deliberately sloppy tool class it reports 38 issues. The real tools: zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A size budget.&lt;/strong&gt; The four real tools come to 2,787 characters, about 700 tokens by a chars/4 estimate. The useful number is the &lt;em&gt;change&lt;/em&gt; when someone adds a tool with a three-paragraph description.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then the part most teams skip: the schema is generated from method signatures, so renaming a parameter changes your API and the compiler doesn't care. A snapshot test of every advertised tool catches the change. What it should &lt;em&gt;demand&lt;/em&gt; before anyone regenerates the snapshot, when old conversations still contain the old names, is the hard part, and it's a whole chapter of the guide.&lt;/p&gt;

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

&lt;p&gt;Paste this into your PR template:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Tool name is explicit, stable and matches &lt;code&gt;^[a-zA-Z0-9_-]{1,64}$&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Ranges, patterns and formats are enforced in our code, not only in the schema&lt;/li&gt;
&lt;li&gt;[ ] Unknown arguments are rejected, with a suggestion when there's a near-match&lt;/li&gt;
&lt;li&gt;[ ] There's a written coercion policy; anything ambiguous is refused&lt;/li&gt;
&lt;li&gt;[ ] Failures come back as structured results that say whether anything executed&lt;/li&gt;
&lt;li&gt;[ ] Identity comes from the request, never from a model-written argument&lt;/li&gt;
&lt;li&gt;[ ] Each tool has an invalid-call budget, and the loop actually stops&lt;/li&gt;
&lt;li&gt;[ ] Lint, size budget and schema snapshot run in CI&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I tested, and what I didn't
&lt;/h2&gt;

&lt;p&gt;The project has 201 passing tests (190 unit, 11 &lt;code&gt;WebApplicationFactory&lt;/code&gt; integration tests), and none of them needs an API key. A deterministic stand-in model with switchable bad habits (truncated names, string numbers, lowercase enums, 12-hour times, invented user ids, ignoring hints) makes every recovery path above a test. The library behaviors I describe (unenforced annotations, dropped unknown keys, "Function failed.", lambda names) were checked against &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; 10.11.0 and are pinned as tests, so a package update that changes one turns the build red.&lt;/p&gt;

&lt;p&gt;What I didn't do in the tests: call a real model. You can flip one setting to run the same pipeline against OpenAI, but the numbers in this post come from the offline runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the complete project
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; This project is already inside: the full .NET 10 solution with the contract wrapper and validator, the coercion policy, caller binding, the retry budget, the linter, size budget and snapshot tools, the offline quirky model, a minimal API and the 201 tests, plus a PDF guide that goes much deeper than this post, including how to rename a tool without breaking the conversations already in flight. You also get the other complete, tested .NET + AI projects in the membership, with new ones added regularly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder for $5/month&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which of these eight questions would your newest tool fail?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>Stripe Will Send It Again: 7 Deliveries to Replay Against Your .NET Webhook Before Launch</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:02:19 +0000</pubDate>
      <link>https://dev.to/michael_maurice/stripe-will-send-it-again-7-deliveries-to-replay-against-your-net-webhook-before-launch-508n</link>
      <guid>https://dev.to/michael_maurice/stripe-will-send-it-again-7-deliveries-to-replay-against-your-net-webhook-before-launch-508n</guid>
      <description>&lt;p&gt;Most Stripe webhook endpoints get tested exactly once. You run &lt;code&gt;stripe listen&lt;/code&gt;, trigger a checkout, see a 200, and move on.&lt;/p&gt;

&lt;p&gt;That test proves the easy part. The hard part is everything Stripe promises in its docs: events arrive &lt;strong&gt;at least once&lt;/strong&gt;, in &lt;strong&gt;no guaranteed order&lt;/strong&gt;, and anything that doesn't get a 2xx is &lt;strong&gt;retried&lt;/strong&gt; (in live mode, for up to three days). Your endpoint will meet all of that in production. It's much cheaper to meet it in a test first.&lt;/p&gt;

&lt;p&gt;So instead of a build walkthrough, here's a replay list: seven deliveries I throw at a .NET 10 webhook endpoint before I trust it, what should happen for each, and how I check it. The code is trimmed to the shape. The trimmed parts are marked.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; You get complete, working, tested .NET + AI projects with full source code and PDF guides. &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Need payments and subscriptions in your .NET 10 app?&lt;/strong&gt; &lt;a href="https://elitesolutions1.gumroad.com/l/StripeIntegrationfordotNET10" rel="noopener noreferrer"&gt;Stripe Integration for .NET 10&lt;/a&gt; is $20 on Gumroad. More about it at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup in one picture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /webhooks/stripe
  verify Stripe-Signature on the raw bytes   -&amp;gt; bad: 400
  parse the event; type we don't handle      -&amp;gt; 200 "ignored"
  insert into an inbox keyed by event id     -&amp;gt; 200 "queued" or 200 "duplicate"
                                                (database down: 503)
background worker: claim one row with a lease -&amp;gt; run the handler -&amp;gt; done / retry later / dead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint never runs business logic. It stores and answers. Keep that in mind, because most of the replays below are really testing that split.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delivery 1: The plain retry
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Replay:&lt;/strong&gt; send the same event twice. Stripe signs every attempt again, so the second copy has a new timestamp and a new signature but the &lt;em&gt;same event id&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; both get 200. The second says &lt;code&gt;duplicate&lt;/code&gt;, and the inbox has one row.&lt;/p&gt;

&lt;p&gt;Watch the status code. It's tempting to answer a duplicate with 409. Don't: any non-2xx tells Stripe to try again, so you'd get retries of an event you already have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delivery 2: Fifty copies at the same moment
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Replay:&lt;/strong&gt; fire the same event 50 times in parallel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; exactly one &lt;code&gt;queued&lt;/code&gt;, 49 &lt;code&gt;duplicate&lt;/code&gt;, and one fulfillment.&lt;/p&gt;

&lt;p&gt;This is the replay that kills "check if it exists, then insert". Two requests both check, both find nothing, both insert. The fix is to let the database decide, with a primary key on the event id and a single statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;webhook_inbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;received_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 1 row affected: first time. 0 rows: duplicate. (full column list in the complete project)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I removed the primary key and the &lt;code&gt;ON CONFLICT&lt;/code&gt; on purpose, four tests failed, including this one. That's how I know the test can actually catch the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delivery 3: Two different events for the same payment
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Replay:&lt;/strong&gt; two events with &lt;em&gt;different&lt;/em&gt; ids that describe the same Checkout Session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; one fulfillment.&lt;/p&gt;

&lt;p&gt;Stripe's docs mention that it sometimes sends two separate Event objects for one change. Your event-id inbox treats them as two events, correctly. So the inbox can't be your only line of defence. The handler also deduplicates on the business key, here the Checkout Session id, with its own unique constraint. There's another reason you need this: if the worker crashes after fulfilling but before marking the row done, the lease expires and the same event runs again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delivery 4: "Updated" arrives after "deleted"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Replay:&lt;/strong&gt; &lt;code&gt;customer.subscription.deleted&lt;/code&gt;, then a late &lt;code&gt;customer.subscription.updated&lt;/code&gt; with &lt;code&gt;status: "active"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; the subscription stays canceled.&lt;/p&gt;

&lt;p&gt;Stripe doesn't guarantee order, and its docs warn against sorting by &lt;code&gt;created&lt;/code&gt; (one-second resolution). A naive upsert brings the subscription back to life. My handler never overwrites a terminal state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- inside the upsert's DO UPDATE:&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'canceled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'incomplete_expired'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a second, optional defence: treat the event as a "something changed" signal and re-read the current subscription from Stripe. In my project that's an &lt;code&gt;ISubscriptionSource&lt;/code&gt; interface, faked in the tests. I didn't wire it to the real Stripe API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delivery 5: A completed checkout that isn't paid
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Replay:&lt;/strong&gt; &lt;code&gt;checkout.session.completed&lt;/code&gt; with &lt;code&gt;payment_status: "unpaid"&lt;/code&gt;, then &lt;code&gt;checkout.session.async_payment_succeeded&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; nothing is fulfilled on the first event; the order ships on the second.&lt;/p&gt;

&lt;p&gt;"Completed" means the customer finished the checkout form, not that the money arrived. With delayed methods like bank debits, the payment lands later. The handler checks &lt;code&gt;payment_status&lt;/code&gt; (&lt;code&gt;paid&lt;/code&gt; or &lt;code&gt;no_payment_required&lt;/code&gt;) and returns early otherwise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paymentStatus&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nf"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"paid"&lt;/span&gt; &lt;span class="n"&gt;or&lt;/span&gt; &lt;span class="s"&gt;"no_payment_required"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// log it and wait for checkout.session.async_payment_succeeded&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="c1"&gt;// insert the fulfillment, unique on checkout_session_id (omitted)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Delivery 6: A body that was touched, and a secret mid-roll
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Replay A:&lt;/strong&gt; take a validly signed event, re-format the JSON (same data, different whitespace), and send it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; 400. The signature covers the exact bytes, so verify the raw body, never something you deserialized and serialized again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replay B:&lt;/strong&gt; sign with the old secret while you're rolling to a new one. Stripe keeps the old secret alive for up to 24 hours and sends one &lt;code&gt;v1&lt;/code&gt; per active secret.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; 200. My verifier tries every &lt;code&gt;v1&lt;/code&gt; entry against every configured secret, which is why I wrote it myself instead of using Stripe.net's &lt;code&gt;EventUtility.ConstructEvent&lt;/code&gt; (that takes one secret). The tests use Stripe.net as an oracle: seven headers, including stale, future, wrong-secret and "bad v1 before a good one", go through both Stripe.net's check and mine, and they agree on all seven.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SigningSecrets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// HMAC-SHA256 over "{t}." + raw body, keyed with the UTF-8 bytes of the whole whsec_ string&lt;/span&gt;
    &lt;span class="c1"&gt;// compare to every v1 with CryptographicOperations.FixedTimeEquals (full verifier omitted)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// then: |now - t| must be within 300 seconds, in both directions&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Delivery 7: Your side is broken
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Replay A:&lt;/strong&gt; the inbox write throws (database down).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; 503. This is the one case where you &lt;em&gt;want&lt;/em&gt; Stripe to retry, because the next attempt can succeed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replay B:&lt;/strong&gt; the event is stored fine, but the handler throws (say, the ERP times out).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expected:&lt;/strong&gt; Stripe already got its 200. The &lt;em&gt;worker&lt;/em&gt; retries, with backoff (10 s, doubling, capped at an hour), and after 8 attempts parks the row as &lt;code&gt;dead&lt;/code&gt; for a human. Stripe never hears about your ERP.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;th&gt;Will Stripe retry?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stored for the first time&lt;/td&gt;
&lt;td&gt;200 &lt;code&gt;queued&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Already stored&lt;/td&gt;
&lt;td&gt;200 &lt;code&gt;duplicate&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A type you don't handle&lt;/td&gt;
&lt;td&gt;200 &lt;code&gt;ignored&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bad, missing or stale signature; not an event&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;yes (fine: fix the secret and the retries recover)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Body over your size limit&lt;/td&gt;
&lt;td&gt;413&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inbox write failed&lt;/td&gt;
&lt;td&gt;503&lt;/td&gt;
&lt;td&gt;yes, on purpose&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule behind it: choose each code by asking "would a retry help?"&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tested, and what I didn't
&lt;/h2&gt;

&lt;p&gt;The demo project has 52 passing xUnit tests, covering every replay above, plus a smoke run against the real host and worker. Every signature was made locally, with Stripe.net's helper and with Python's &lt;code&gt;hmac&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What I didn't verify: a real Stripe account or the Stripe CLI, Stripe's real retry timing, a real &lt;code&gt;ISubscriptionSource&lt;/code&gt;, or several app instances on one database. The inbox is SQLite, so it's single-host. For more instances, move the same unique key and claim query to Postgres or SQL Server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Want the full Stripe integration?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://elitesolutions1.gumroad.com/l/StripeIntegrationfordotNET10" rel="noopener noreferrer"&gt;Stripe Integration for .NET 10&lt;/a&gt; is $20, one-time, from Elite Solutions. It's a Clean Architecture + DDD solution (Domain, Application, Infrastructure, Api) with CQRS and MediatR, EF Core and Swagger, with Stripe.net wrapped in a &lt;code&gt;StripeService&lt;/code&gt;. It covers customers, products and prices, Payment Intents, Checkout Sessions, refunds, subscriptions, invoices and webhooks with signature verification and deduplication by Stripe event ID, plus unit tests. To be clear: the replay demo from this post (the SQLite inbox, the worker and the 52 tests) is a separate project and is not part of the kit.&lt;/p&gt;

&lt;p&gt;And if you learn best from complete code you can run, &lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; You get complete, working, tested .NET + AI projects with full source code and step-by-step PDF guides, and new ones are added regularly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder for $5/month&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which of these seven would your endpoint fail today?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>stripe</category>
      <category>csharp</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Half Price Until You Pay Twice: 6 Ways an OpenAI Batch Job in .NET Quietly Costs You More</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:01:25 +0000</pubDate>
      <link>https://dev.to/michael_maurice/half-price-until-you-pay-twice-6-ways-an-openai-batch-job-in-net-quietly-costs-you-more-22g5</link>
      <guid>https://dev.to/michael_maurice/half-price-until-you-pay-twice-6-ways-an-openai-batch-job-in-net-quietly-costs-you-more-22g5</guid>
      <description>&lt;p&gt;The pitch for the OpenAI Batch API is simple. You have a pile of work nobody needs answered in two seconds, say 40,000 rows to classify overnight. You put the requests in a JSONL file, upload it, and the provider finishes within 24 hours at half the price of the normal API.&lt;/p&gt;

&lt;p&gt;The discount is real. What eats it is the code around the batch. A batch job lives for hours, and anything that lives for hours meets crashes, retries and deadlines. Each of those can quietly turn "half price" into "full price, twice", or into results that are silently missing.&lt;/p&gt;

&lt;p&gt;Here are six mistakes I made or nearly made while building a batch pipeline in .NET 10 (official &lt;code&gt;OpenAI&lt;/code&gt; 2.14.0 package, SQLite for state). The steps are general. The code is trimmed to the shape, and the trimmed parts are marked.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; The complete, working, tested version of this batch project is inside, with full source and an in-depth PDF guide. &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Mistake 1: Creating the batch again after a crash
&lt;/h2&gt;

&lt;p&gt;This is the expensive one. Your worker uploads the file, calls "create batch", and dies before it saves the batch id. On restart, your database says this shard was never sent, so you send it again. Now two batches run the same requests, and you pay for both.&lt;/p&gt;

&lt;p&gt;The fix doesn't need extra infrastructure. Batches accept metadata, so tag each one with your own shard id when you create it. On restart, before re-sending a shard that has no batch id, list the recent batches and look for that tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Dictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&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="s"&gt;"job_id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"shard_id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="c1"&gt;// create the batch with this metadata, then save the batch id (full flow in the complete project)&lt;/span&gt;

&lt;span class="c1"&gt;// on restart, for a shard with no saved batch id:&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"shard_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;shard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// match found: adopt it. none found: send it. Never both.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other half of the rule: save your state &lt;em&gt;before&lt;/em&gt; every provider call (planned, then uploaded, then created), so a restart always knows how far it got.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 2: Retrying everything that failed
&lt;/h2&gt;

&lt;p&gt;A batch comes back with failures, and the easy move is "put the failures in the next batch". Some of those failures will fail again no matter how often you send them. A 400 for a malformed request or a model refusal gets the same answer the second time, and you pay for it again.&lt;/p&gt;

&lt;p&gt;So sort the failures before you retry anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retry:&lt;/strong&gt; 408, 409, 429 and 5xx, &lt;code&gt;batch_expired&lt;/code&gt;, lines that never came back, and output that parsed but broke your schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry with more room:&lt;/strong&gt; &lt;code&gt;finish_reason: length&lt;/code&gt;. The next attempt doubles &lt;code&gt;max_completion_tokens&lt;/code&gt;, up to a cap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't retry:&lt;/strong&gt; other 4xx, refusals and content-filter stops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And give every item an attempt budget (three in my project). When it runs out, the item goes to a dead-letter state with its last reason instead of looping forever.&lt;/p&gt;

&lt;p&gt;One more thing on cost: when an attempt is truncated, you still paid for its tokens. My store adds each attempt's tokens to the item rather than replacing them, so the cost report shows what you actually paid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 3: Looping over the output file
&lt;/h2&gt;

&lt;p&gt;The docs say it plainly: output lines may not come back in input order. That part most people catch, and they join on &lt;code&gt;custom_id&lt;/code&gt;. The subtler bug is &lt;em&gt;which side you loop over&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;If you loop over the output file, items with no line at all simply never show up. Cancelled batches, expired batches and the occasional lost line all look like that. The job reports "done" and a few hundred rows were never processed.&lt;/p&gt;

&lt;p&gt;Loop over what you sent instead, and look each one up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;shard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Members&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decisions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ContainsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// a line came back for it&lt;/span&gt;
    &lt;span class="c1"&gt;// no line at all: retry, with a reason like "missing_after_cancel" or "missing_after_expiry"&lt;/span&gt;
    &lt;span class="c1"&gt;// ... (reason mapping and the rest of the reconciler are in the complete project)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also read the second file. Failed requests go to a separate error file, and that's where expired requests show up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 4: Reusing the same custom_id on a retry
&lt;/h2&gt;

&lt;p&gt;Item &lt;code&gt;r-042&lt;/code&gt; fails in batch one and goes into batch two. If its &lt;code&gt;custom_id&lt;/code&gt; is still &lt;code&gt;r-042&lt;/code&gt;, a late line from batch one is indistinguishable from the fresh answer, and it can overwrite a newer result.&lt;/p&gt;

&lt;p&gt;Put the attempt number in the id. My format is &lt;code&gt;{jobId}:{itemId}:a{attempt}&lt;/code&gt;, so the retry is &lt;code&gt;...:r-042:a2&lt;/code&gt;. Any line carrying &lt;code&gt;a1&lt;/code&gt; after that is stale, and the reconciler ignores it. Lines from another job are ignored the same way, and a second line for the same id counts as a duplicate.&lt;/p&gt;

&lt;p&gt;The database side gets the same treatment: an update only lands if the item is still at that attempt and still waiting for a result, so a late line can't overwrite newer state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 5: Letting the JSON encoder inflate your file
&lt;/h2&gt;

&lt;p&gt;Each input file has limits: 50,000 requests and 200 MB. The default &lt;code&gt;System.Text.Json&lt;/code&gt; encoder escapes every non-ASCII character as &lt;code&gt;\uXXXX&lt;/code&gt;. For English text, that changes nothing. For Arabic it hurts. In my measurement, one short Arabic ticket went from 116 bytes to 324.&lt;/p&gt;

&lt;p&gt;On long non-English rows, the byte limit starts to bind before the 50,000-request limit, so the same job needs more files and more batches. One option fixes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;JsonWriterOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Encoder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JavaScriptEncoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UnsafeRelaxedJsonEscaping&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="c1"&gt;// write custom_id, method, url, body with a Utf8JsonWriter using these options (builder omitted)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Despite the scary name, it still escapes control characters, so a newline inside a row can't split a JSONL line. I have a test for exactly that, because it's the first thing I'd worry about.&lt;/p&gt;

&lt;p&gt;While you're there, validate the file locally before uploading: unique &lt;code&gt;custom_id&lt;/code&gt;s, one model per file, &lt;code&gt;url&lt;/code&gt; equal to the batch endpoint, no &lt;code&gt;stream: true&lt;/code&gt;, and within the limits. A mistake caught locally costs nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 6: Panicking at the deadline
&lt;/h2&gt;

&lt;p&gt;The Batch API promises 24 hours. Your report is due at 08:00. At 07:40 the batch is still running and half the rows are done.&lt;/p&gt;

&lt;p&gt;The panic move is "cancel it and run everything through the normal API". That pays full price for the rows the batch already finished, and a cancelled batch still produces output for what it completed, which you're billed for too.&lt;/p&gt;

&lt;p&gt;The better question is when to stop trusting the batch, and how to finish only what's left without paying twice. My project answers that with a deadline planner. How it decides, and what it costs, is in the complete project and its PDF guide, not here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pre-flight checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] State saved before every provider call&lt;/li&gt;
&lt;li&gt;[ ] Every batch tagged with your shard id; crash recovery adopts before it re-sends&lt;/li&gt;
&lt;li&gt;[ ] Failures classified: retry, retry with more tokens, or fail for good&lt;/li&gt;
&lt;li&gt;[ ] Attempt budget and a dead-letter state&lt;/li&gt;
&lt;li&gt;[ ] Tokens added across attempts, not replaced&lt;/li&gt;
&lt;li&gt;[ ] Reconcile over what you sent, using both the output file and the error file&lt;/li&gt;
&lt;li&gt;[ ] Attempt number inside &lt;code&gt;custom_id&lt;/code&gt;; stale and foreign lines ignored&lt;/li&gt;
&lt;li&gt;[ ] Relaxed JSON encoder, and a local file validator&lt;/li&gt;
&lt;li&gt;[ ] A deadline plan that doesn't throw away finished work&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I tested, and what I didn't
&lt;/h2&gt;

&lt;p&gt;The project has 162 passing tests (146 unit, 16 integration), with no API key needed. An offline simulator covers the documented batch lifecycle, including failed, expired and cancelled batches, and a test crashes the process right after "create batch" to prove recovery adopts the existing batch instead of creating a second one.&lt;/p&gt;

&lt;p&gt;What I didn't do: run it against the live Batch API. The OpenAI adapter was tested against an in-process server that speaks the documented Files and Batches format, not against OpenAI itself. Also note that &lt;code&gt;BatchClient&lt;/code&gt; is marked experimental (&lt;code&gt;OPENAI001&lt;/code&gt;) in the 2.14.0 SDK, so expect it to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the complete project
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; This batch project is already inside: the full .NET 10 solution with the 162 tests, the crash recovery, the reconciler, the deadline planner, a minimal API with idempotent job creation, and a PDF guide that goes much deeper than this post. You also get the other complete, tested .NET + AI projects in the membership, with new ones added regularly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder for $5/month&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which of these six would hit your batch job first?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>openai</category>
      <category>csharp</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your Telegram AI Assistant Can Email Anyone as You: 6 Checks to Run Before the Model Does</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Wed, 28 Oct 2026 17:46:36 +0000</pubDate>
      <link>https://dev.to/michael_maurice/your-telegram-ai-assistant-can-email-anyone-as-you-6-checks-to-run-before-the-model-does-180n</link>
      <guid>https://dev.to/michael_maurice/your-telegram-ai-assistant-can-email-anyone-as-you-6-checks-to-run-before-the-model-does-180n</guid>
      <description>&lt;p&gt;Picture the assistant: you hold the mic in Telegram, say "find 30 minutes with Sara on Thursday and email her the options", and a minute later the email is in her inbox. n8n makes that easy. A Telegram Trigger, a Whisper transcription, an AI Agent with Gmail and Calendar tools, and you're most of the way there.&lt;/p&gt;

&lt;p&gt;Notice what you just built, though: a bot that can send email as you, triggered by anyone who can reach a webhook. The model is the least interesting risk here. The interesting ones happen &lt;em&gt;before&lt;/em&gt; the model runs.&lt;/p&gt;

&lt;p&gt;I built this with n8n plus a small ASP.NET Core API (.NET 10) that makes the decisions I don't want a model to make. The general steps below are the ones that matter. Code is trimmed to the shape.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tech Skill Builder is $5/month for a very limited time (normally $10) — grab it now before it goes back to $10.&lt;/strong&gt; Complete, tested .NET + AI projects with full source and PDF guides. 👉 &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Telegram Trigger -&amp;gt; guard API (allowed?) -&amp;gt; voice or text?
   voice -&amp;gt; download -&amp;gt; Whisper -&amp;gt; prompt
   text  ----------------------&amp;gt; prompt
prompt -&amp;gt; AI Agent (tools: contacts, calendar, free slots, email) -&amp;gt; reply on Telegram
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first node after the trigger asks the API one question: &lt;em&gt;may this update run at all?&lt;/em&gt; Everything that costs money or has side effects sits behind the answer. Here are six checks, in roughly the order a message meets them. The first four live in that guard; the last two protect the tools the agent calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 1: Is this chat one of mine?
&lt;/h2&gt;

&lt;p&gt;The Telegram Trigger already checks the secret token, and in my local run the webhook was registered with it. That proves the request came from Telegram. It does not prove &lt;em&gt;you&lt;/em&gt; wrote the message. Anyone on Telegram can message your bot.&lt;/p&gt;

&lt;p&gt;So: an allowlist of chat IDs, and everyone else is ignored without a reply. A reply tells a stranger the bot exists and is listening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 2: Have I seen this update before?
&lt;/h2&gt;

&lt;p&gt;Telegram retries a delivery when it doesn't get a fast 2xx (a restart, a slow node, a proxy hiccup). A retried "email Sara the report" is two emails.&lt;/p&gt;

&lt;p&gt;Remember each &lt;code&gt;update_id&lt;/code&gt; for a window and let only the first one through. Two retries can land at the same moment, so a "check, then add" pair isn't enough. It needs one atomic operation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// one atomic step, not "if (!seen.Contains(id)) seen.Add(id)"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;_seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryAdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updateId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// already there: duplicate inside the window, or expired and re-added (omitted)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test I trust here fires the same update many times in parallel and expects exactly one to pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 3: Is it stale?
&lt;/h2&gt;

&lt;p&gt;After an hour of downtime you don't want ten queued commands to fire at once. n8n's trigger can drop pending updates when you re-publish (in my local run it registered the webhook with &lt;code&gt;drop_pending_updates&lt;/code&gt;), but I still compare the message date with the clock and refuse anything older than ten minutes (a setting).&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 4: How big is this voice note?
&lt;/h2&gt;

&lt;p&gt;A 20-minute voice note isn't a command, it's a transcription bill. Read the duration from the update and refuse before downloading anything. In my setup the limit is 120 seconds, and the bot answers "please keep it under 2 minutes".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;GuardDecision&lt;/span&gt; &lt;span class="nf"&gt;Evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TelegramUpdate&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... bot check, allowlist, duplicate, age (the checks above, in this order)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Voice&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;voice&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxVoiceSeconds&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;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"voice_too_long"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... allowed: return kind, text or voice file id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters: cheap checks first, anything that costs money last.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 5: Which "Omar"?
&lt;/h2&gt;

&lt;p&gt;Ask a model to email Omar and it may produce &lt;code&gt;omar@company.com&lt;/code&gt; with total confidence. Make the contact lookup a tool, and make its answer one of three: &lt;code&gt;found&lt;/code&gt;, &lt;code&gt;ambiguous&lt;/code&gt;, &lt;code&gt;not_found&lt;/code&gt;. The system prompt then says: never guess an address; on anything but &lt;code&gt;found&lt;/code&gt;, ask which person and stop.&lt;/p&gt;

&lt;p&gt;In my run, a voice note saying "book 30 minutes with Omar" ended with the bot asking which of the two Omars I meant, and nothing was sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 6: What time did the model mean?
&lt;/h2&gt;

&lt;p&gt;Models send &lt;code&gt;"2026-10-29T10:00:00"&lt;/code&gt;, no offset. Deserialize that into a &lt;code&gt;DateTimeOffset&lt;/code&gt; and you get the &lt;em&gt;server's&lt;/em&gt; offset. I checked: on a box with &lt;code&gt;TZ=UTC&lt;/code&gt; it parsed as &lt;code&gt;10:00+00:00&lt;/code&gt;, three hours off for an assistant living in Cairo.&lt;/p&gt;

&lt;p&gt;So the API takes the time as a string, and treats an offset-less value as the assistant's own zone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SpecifyKind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTimeKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unspecified&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// handle a time that doesn't exist (spring-forward gap): omitted&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;utc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeZoneInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConvertTimeToUtc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same goes for working hours: convert them to UTC per &lt;em&gt;day&lt;/em&gt;, not once from the first day's offset. A clock change between two days is where the shortcut breaks. When I swapped the per-day conversion for the shortcut, exactly two tests failed, which is how I know the tests cover it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I checked, and what I didn't
&lt;/h2&gt;

&lt;p&gt;The API has 43 passing xUnit tests. I also ran the whole n8n workflow locally (n8n 2.43.8) against the real API, with a fake Telegram, OpenAI and Google. It covered a text request, a Telegram retry (ignored), a voice note about an ambiguous contact, a voice note that was too long, a stranger's chat (ignored) and a missing or wrong secret token (HTTP 403).&lt;/p&gt;

&lt;p&gt;What I didn't check: real Telegram, real Whisper, real Gmail and Calendar (no credentials, so those nodes were only validated against n8n's node schemas), and how well a real model picks tools. The dedup store is in memory, so with more than one instance you'd move it to Redis or a database key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Allowlist of chat IDs; strangers get silence&lt;/li&gt;
&lt;li&gt;[ ] Atomic dedup on &lt;code&gt;update_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Stale messages refused&lt;/li&gt;
&lt;li&gt;[ ] Voice duration capped before download&lt;/li&gt;
&lt;li&gt;[ ] Contact lookup is a tool with &lt;code&gt;found / ambiguous / not_found&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Offset-less times get your zone, not the server's&lt;/li&gt;
&lt;li&gt;[ ] Cheap checks first, paid steps last&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Want to skip the n8n part?
&lt;/h2&gt;

&lt;p&gt;If you'd rather start from a finished Telegram assistant than wire the nodes yourself, the &lt;a href="https://elitesolutions1.gumroad.com/l/tvbxry" rel="noopener noreferrer"&gt;n8n Workflow Vault&lt;/a&gt; is $29 one-time: the AI Phone-Calling Personal Assistant for Telegram (voice and text, Whisper, Google Sheets contacts, phone, email and calendar agents), the Ultimate n8n Workflow Pack with 2,000+ templates, the multi-agent orchestration workflow and the UGC ad video generator. You bring your own API keys. The guard API from this post is a separate demo and is not part of the Vault.&lt;/p&gt;

&lt;p&gt;And if you learn best from complete code you can run, &lt;strong&gt;Tech Skill Builder is $5/month for a very limited time (normally $10) — grab it now before it goes back to $10.&lt;/strong&gt; You get complete, working, tested .NET + AI projects with full source code and step-by-step PDF guides, and new ones are added regularly. 👉 &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which of these six would your bot fail first?&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>dotnet</category>
      <category>ai</category>
      <category>automation</category>
    </item>
    <item>
      <title>Deleted, Emptied, Half-Written: 6 Files a Nightly RAG Ingestion Job Gets Wrong in .NET 10</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Tue, 27 Oct 2026 11:59:48 +0000</pubDate>
      <link>https://dev.to/michael_maurice/deleted-emptied-half-written-6-files-a-nightly-rag-ingestion-job-gets-wrong-in-net-10-3f5f</link>
      <guid>https://dev.to/michael_maurice/deleted-emptied-half-written-6-files-a-nightly-rag-ingestion-job-gets-wrong-in-net-10-3f5f</guid>
      <description>&lt;p&gt;Most RAG ingestion demos end after the first run: a folder of Markdown goes in, the chat answers a question, done. But a knowledge base changes all the time. Pages get deleted, trimmed, moved and rewritten, and an ingestion job that runs every night has to keep the vector index in step with all of that.&lt;/p&gt;

&lt;p&gt;I built a small help-center ingestion service on .NET 10 with &lt;code&gt;Microsoft.Extensions.DataIngestion&lt;/code&gt; (10.11.0-preview.1.26513.7, still a preview package) and the in-memory vector store. The pipeline itself (read, chunk, enrich, embed, write) was the quick part. Most of my time went into six ordinary things that can happen to a &lt;em&gt;file&lt;/em&gt;. For each one there's a simple question: what should the index hold the next morning?&lt;/p&gt;

&lt;p&gt;Code below is trimmed to the shape; omitted parts are marked.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; The complete ingestion service from this post, with an in-depth PDF guide, is inside &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The one idea behind all six
&lt;/h2&gt;

&lt;p&gt;Don't treat ingestion as "process this folder". Treat it as a sync between two things: the files on disk, and a &lt;strong&gt;ledger&lt;/strong&gt; that records what the index is supposed to hold for each document (content hash, generation number, chunk count, status). Every run compares the two and decides one action per document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;PlannedAction&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Reindex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Repair&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Retry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Skip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Quarantined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Delete&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// IngestionPlanner.Plan(sources, ledger, fingerprint, liveChunkCounts): a pure function,&lt;/span&gt;
&lt;span class="c1"&gt;// full implementation in the complete project&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the planner does no I/O, every branch below has a plain unit test.&lt;/p&gt;

&lt;h2&gt;
  
  
  File 1: the page you deleted
&lt;/h2&gt;

&lt;p&gt;Somebody removes &lt;code&gt;welcome.md&lt;/code&gt;. The pipeline only processes files that exist, so it never hears about this one. Its chunks stay in the index, and the assistant keeps quoting a page that's gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; anything in the ledger but no longer on disk is a &lt;code&gt;Delete&lt;/code&gt;. The job first stops serving it, then deletes its chunks and the ledger row. In the demo that run makes zero model calls and zero embedding calls, and removes the page's one chunk.&lt;/p&gt;

&lt;p&gt;Test: &lt;code&gt;Deleted_file_loses_its_chunks_and_ledger_row&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  File 2: the page cut down to its title
&lt;/h2&gt;

&lt;p&gt;Someone trims &lt;code&gt;invoices.md&lt;/code&gt; to a single heading. It now produces zero chunks.&lt;/p&gt;

&lt;p&gt;This one surprised me. The library's writer has an incremental mode (&lt;code&gt;IncrementalIngestion&lt;/code&gt;, on by default) that replaces a document's old chunks, but it does that when the &lt;em&gt;first new chunk&lt;/em&gt; arrives. With no new chunks, nothing triggers it, and the old three chunks stay searchable. A characterization test in the project pins that behaviour: &lt;code&gt;Builtin_incremental_mode_keeps_old_chunks_when_the_new_version_is_empty&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; I turn the built-in mode off and replace by generation myself. A successful run with zero chunks still deletes the old generation, and the document is marked &lt;code&gt;Empty&lt;/code&gt; instead of quietly serving its previous version.&lt;/p&gt;

&lt;p&gt;Test: &lt;code&gt;Emptied_file_loses_its_chunks&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  File 3: the page that didn't change, in a folder that moved
&lt;/h2&gt;

&lt;p&gt;Same files, new deploy path. By default the reader uses &lt;code&gt;FileInfo.FullName&lt;/code&gt; as the document id, so &lt;code&gt;/srv/release-41/billing/refunds.md&lt;/code&gt; and &lt;code&gt;/srv/release-42/billing/refunds.md&lt;/code&gt; are two different documents, and the index ends up with two copies of everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; the id is the path relative to the source root, with forward slashes (&lt;code&gt;billing/refunds.md&lt;/code&gt;). A small reader decorator makes sure the pipeline uses that id too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StableIdReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IngestionDocumentReader&lt;/span&gt; &lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IngestionDocumentReader&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// both ReadAsync overrides map FileInfo.FullName to the root-relative id&lt;/span&gt;
    &lt;span class="c1"&gt;// full implementation in the complete project&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While you're there, hash the content after normalizing line endings and stripping a BOM. Otherwise a Windows build agent with &lt;code&gt;core.autocrlf&lt;/code&gt; makes every file look edited.&lt;/p&gt;

&lt;p&gt;Test: &lt;code&gt;Moving_the_source_folder_does_not_duplicate_anything&lt;/code&gt; (second run: 6 of 6 unchanged, same chunk count).&lt;/p&gt;

&lt;h2&gt;
  
  
  File 4: the page that grew too big
&lt;/h2&gt;

&lt;p&gt;Someone pastes a huge log into a page and it goes over the size limit (2,000,000 bytes by default in my settings). A scanner that just leaves it out of its list has a nasty side effect: next to the ledger, a missing file looks exactly like a deleted one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; oversized files are reported as skipped, and the job refuses to delete them from the index. The index keeps serving the last good version until someone fixes the file.&lt;/p&gt;

&lt;p&gt;Test: &lt;code&gt;Too_large_file_is_skipped_but_not_deleted&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  File 5: the page whose embedding call died halfway
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;close-account.md&lt;/code&gt; changes, and the embedding provider returns a 503 after two chunks of the new version are already written. Now the index holds half of the new version next to the complete old one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; every write is a new &lt;strong&gt;generation&lt;/strong&gt; (a small chunk processor stamps the generation number on each chunk). The commit order is: save the ledger, publish the new generation, then delete the old ones. On failure, delete whatever part of the new generation landed and leave the ledger pointing at the old one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Succeeded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="k"&gt;partial&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DeleteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;generation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// record the failure (hash, count, error) in the ledger: in the complete project&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo's run report says it in one line: &lt;code&gt;removed 2 partial chunk(s); previous version still served&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Test: &lt;code&gt;Mid_write_failure_removes_partial_chunks_and_keeps_serving_the_old_version&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  File 6: the page that fails every single night
&lt;/h2&gt;

&lt;p&gt;If the failure is in the content itself (or the provider keeps rejecting it), a naive job retries every night forever, paying for enrichment calls each time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; count failures per content hash. After 3 failures of the same content (&lt;code&gt;MaxAttempts&lt;/code&gt;), the document is &lt;code&gt;Quarantined&lt;/code&gt; and skipped with zero model calls. Any edit to the file releases it, and reverting the file to the version that's still live clears the failure.&lt;/p&gt;

&lt;p&gt;Tests: &lt;code&gt;Repeated_failures_quarantine_until_the_file_changes&lt;/code&gt;, &lt;code&gt;Editing_a_quarantined_file_releases_it&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheap part: nothing changed
&lt;/h2&gt;

&lt;p&gt;All six rules rely on the planner, and the planner gives you the best one for free. In the offline demo, the first run over six files made 18 model calls and embedded 17 texts. The second run, with no changes, made 0 model calls and embedded 0 texts, in 4 ms. Unchanged files never reach the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Ids relative to the source root, never absolute paths&lt;/li&gt;
&lt;li&gt;[ ] Content hashes that ignore line endings and a BOM&lt;/li&gt;
&lt;li&gt;[ ] Deleted files are purged, with a test&lt;/li&gt;
&lt;li&gt;[ ] Emptied files are purged, with a test (the built-in incremental mode won't do it)&lt;/li&gt;
&lt;li&gt;[ ] Oversized files are skipped and reported, never treated as deleted&lt;/li&gt;
&lt;li&gt;[ ] A failed write removes its partial chunks and keeps the old version live&lt;/li&gt;
&lt;li&gt;[ ] Repeated failures of the same content are quarantined&lt;/li&gt;
&lt;li&gt;[ ] Unchanged files cost zero model calls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's one problem I haven't covered here: while a document is being replaced, a search can briefly see chunks from two versions. How search avoids that, with one live generation per document, is covered in the project and the PDF.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the complete project
&lt;/h2&gt;

&lt;p&gt;The snippets above are the outline. The complete .NET 10 solution, &lt;strong&gt;Production RAG Ingestion for .NET 10&lt;/strong&gt;, is in Tech Skill Builder: the scanner and pure planner, generation-based commits, quarantine, store-loss repair, an orphan sweep after crashes, redaction before chunking, an enrichment contract on top of the library's best-effort enrichers, live-generation search, an ASP.NET Core minimal API and a 15-scene CLI demo. It has 131 offline tests (118 unit, 13 integration), including 10 characterization tests that pin the preview library's behaviour, plus an in-depth PDF guide. No API key needed to run it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt; gives you complete, working, tested .NET projects with full source code and step-by-step PDF guides, with new ones added regularly. &lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which of these six has your ingestion job already got wrong?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>rag</category>
      <category>ai</category>
      <category>csharp</category>
    </item>
    <item>
      <title>One Clarifying Question, One Line of C#: 5 Interview Bugs I Planted on Purpose</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Tue, 27 Oct 2026 11:58:54 +0000</pubDate>
      <link>https://dev.to/michael_maurice/one-clarifying-question-one-line-of-c-5-interview-bugs-i-planted-on-purpose-27jp</link>
      <guid>https://dev.to/michael_maurice/one-clarifying-question-one-line-of-c-5-interview-bugs-i-planted-on-purpose-27jp</guid>
      <description>&lt;p&gt;Most advice about coding interviews says "ask clarifying questions". It rarely says why the interviewer cares. My answer: every good clarifying question ends up as one specific line of code. If you can point at that line while you talk, you've shown you write code from requirements, not from memory.&lt;/p&gt;

&lt;p&gt;To check that idea, I took a small practice project (four classic problems in C# on .NET 10, each with a brute force and an optimized version, 34 xUnit v3 tests) and broke one line at a time. Each line is the answer to a question you should ask out loud. Then I counted how many tests failed.&lt;/p&gt;

&lt;p&gt;Four of the five bugs were caught. One got through all 34 tests, and that one taught me the most.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; Complete, working, tested .NET projects with full source code and step-by-step PDF guides: &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you're preparing for .NET backend interviews in general, my &lt;a href="https://elitesolutions1.gumroad.com/l/dotnet-interview-2026" rel="noopener noreferrer"&gt;.NET Backend Interview Masterclass (2026 Edition)&lt;/a&gt; goes much wider than algorithms. More on it at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the experiment works
&lt;/h2&gt;

&lt;p&gt;Every problem has one extra test besides the hand-picked cases: run the brute force and the optimized version on 500 random small inputs with a fixed seed, and require the same answer. The brute force is slow, but it's easy to trust, so it works as the reference for the fast one.&lt;/p&gt;

&lt;p&gt;For each bug below I changed one line, ran &lt;code&gt;dotnet test&lt;/code&gt;, wrote down what failed, and reverted the change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: "Can I use the same element twice?"
&lt;/h2&gt;

&lt;p&gt;Two Sum, the hash map version. The answer "no" is enforced by the &lt;em&gt;order&lt;/em&gt; of two lines: look up the complement first, insert the current value second.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;complement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&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="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;complement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 1. look up&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryAdd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                                    &lt;span class="c1"&gt;// 2. then insert&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// null check, doc comment and brute force: in the complete project&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I broke:&lt;/strong&gt; I moved the insert above the lookup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What noticed:&lt;/strong&gt; 3 tests. &lt;code&gt;[3]&lt;/code&gt; with target 6 now "finds" a pair (index 0 with itself), &lt;code&gt;[3, 3]&lt;/code&gt; returns &lt;code&gt;(0, 0)&lt;/code&gt; instead of two different indices, and the random comparison failed too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Say it out loud:&lt;/strong&gt; "I check before I insert, so an element can't pair with itself."&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: "How big can the numbers get?" (the one that got through)
&lt;/h2&gt;

&lt;p&gt;The same method does its math in &lt;code&gt;long&lt;/code&gt;, because &lt;code&gt;int.MaxValue + 1&lt;/code&gt; silently wraps around in an &lt;code&gt;int&lt;/code&gt;. The project even has a test row for it: &lt;code&gt;[int.MaxValue, 1, int.MinValue]&lt;/code&gt; with target -1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I broke:&lt;/strong&gt; I switched both the brute force and the optimized version to plain &lt;code&gt;int&lt;/code&gt; arithmetic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What noticed:&lt;/strong&gt; nothing. 34 of 34 passed.&lt;/p&gt;

&lt;p&gt;The reason is a little embarrassing. In that test row, the correct pair (&lt;code&gt;int.MaxValue + int.MinValue = -1&lt;/code&gt;) doesn't overflow at all, so &lt;code&gt;int&lt;/code&gt; math still finds it. The row &lt;em&gt;looks&lt;/em&gt; like overflow coverage, but it can't fail. And the random test couldn't help: it uses small values, and I had broken both versions in the same way, so they still agreed.&lt;/p&gt;

&lt;p&gt;The case that does catch it is one where wrapping creates a &lt;em&gt;false&lt;/em&gt; match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MinValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// int math: MaxValue + 1 wraps to MinValue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I added that row in a scratch copy, and the &lt;code&gt;int&lt;/code&gt; version failed it straight away (the real &lt;code&gt;long&lt;/code&gt; version passes). It isn't in the project yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Say it out loud:&lt;/strong&gt; "If the values can be near &lt;code&gt;int.MaxValue&lt;/code&gt;, I'll add in &lt;code&gt;long&lt;/code&gt;. And my test for that needs a case where wrapping would produce a wrong &lt;em&gt;yes&lt;/em&gt;, not just one where it happens to work."&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: "What if the repeat is already outside my window?"
&lt;/h2&gt;

&lt;p&gt;Longest substring without repeating characters, sliding window version. This one isn't a question for the interviewer. It's a question you ask yourself while you trace an example.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lastIndex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;left&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Math&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="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the left edge never moves backwards&lt;/span&gt;
&lt;span class="c1"&gt;// rest of the loop (update lastIndex, track best): in the complete project&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I broke:&lt;/strong&gt; &lt;code&gt;left = previous + 1;&lt;/code&gt; without the &lt;code&gt;Math.Max&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What noticed:&lt;/strong&gt; 2 tests: the &lt;code&gt;"abba"&lt;/code&gt; row (the broken version returns 3, the right answer is 2) and the random comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Say it out loud:&lt;/strong&gt; "Let me trace &lt;code&gt;abba&lt;/code&gt;. At the last &lt;code&gt;a&lt;/code&gt;, the previous &lt;code&gt;a&lt;/code&gt; is at index 0, which is already outside the window, so &lt;code&gt;left&lt;/code&gt; must stay where it is."&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: "Must the subarray be non-empty?"
&lt;/h2&gt;

&lt;p&gt;Maximum subarray (Kadane). If the answer is "yes, take at least one element", then an all-negative array returns its largest single value, not 0. That decision lives in the seed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;endingHere&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// not 0&lt;/span&gt;
&lt;span class="c1"&gt;// the loop and the empty-input guard: in the complete project&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I broke:&lt;/strong&gt; seeded both with 0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What noticed:&lt;/strong&gt; 2 tests: &lt;code&gt;[-3, -1, -2]&lt;/code&gt; (expected -1) and the random comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Say it out loud:&lt;/strong&gt; "You said non-empty, so I seed with the first element. Otherwise an all-negative input would return 0, which isn't the sum of any subarray."&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 5: "If one meeting ends at 10 and the next starts at 10, can they share a room?"
&lt;/h2&gt;

&lt;p&gt;Meeting rooms, sort-and-sweep version. If the end time is exclusive, a room is freed when the next start is &lt;em&gt;at or after&lt;/em&gt; the earliest end. That's one character:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;ends&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ends&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;++;&lt;/span&gt; &lt;span class="n"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;--;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// sorting starts and ends, and counting rooms: in the complete project&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I broke:&lt;/strong&gt; &lt;code&gt;&amp;lt;=&lt;/code&gt; to &lt;code&gt;&amp;lt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What noticed:&lt;/strong&gt; 2 tests: back-to-back meetings &lt;code&gt;9-10, 10-11, 11-12&lt;/code&gt; (expected 1 room) and the random comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Say it out loud:&lt;/strong&gt; "That &lt;code&gt;&amp;lt;=&lt;/code&gt; is your answer from earlier. Back-to-back meetings can share a room."&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scoreboard says
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bug&lt;/th&gt;
&lt;th&gt;Clarifying question&lt;/th&gt;
&lt;th&gt;Tests that failed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Insert before lookup&lt;/td&gt;
&lt;td&gt;Same element twice?&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;int&lt;/code&gt; instead of &lt;code&gt;long&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;How big are the values?&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;Math.Max&lt;/code&gt; on &lt;code&gt;left&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Repeat outside the window?&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kadane seeded with 0&lt;/td&gt;
&lt;td&gt;Non-empty subarray?&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;&lt;/code&gt; instead of &lt;code&gt;&amp;lt;=&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Exclusive end time?&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things I took from it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The random brute-force comparison was in every catch.&lt;/strong&gt; It failed on its own for all four bugs that were caught. In an interview you won't have time to write it, but one sentence earns credit: "With more time, I'd check this against the brute force on random small inputs."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A test named after an edge case isn't proof that the edge case is covered.&lt;/strong&gt; The overflow row looked right and couldn't fail. Breaking the line on purpose is the quickest way to find out.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Your practice checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Ask the question, then write the answer as a comment or a test case&lt;/li&gt;
&lt;li&gt;[ ] Point at the line that implements each answer&lt;/li&gt;
&lt;li&gt;[ ] Trace one awkward input by hand (&lt;code&gt;abba&lt;/code&gt;, all negative, back-to-back)&lt;/li&gt;
&lt;li&gt;[ ] For overflow, test a case where wrapping gives a false yes&lt;/li&gt;
&lt;li&gt;[ ] Mention the brute-force cross-check, even if you can't write it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Want to go further?
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://elitesolutions1.gumroad.com/l/dotnet-interview-2026" rel="noopener noreferrer"&gt;.NET Backend Interview Masterclass (2026 Edition)&lt;/a&gt; is a 142-page guide ($20) with 200+ C# questions with full code, a system design playbook with 7 high-frequency scenarios (webhooks, payments, feed pagination and more) and a 6-box thinking script, EF Core and SQL performance, the ASP.NET Core runtime, security and reliability, a 14-day prep plan, a STAR+L behavioral bank and 8 printable cheat sheets. To be upfront: the small kata project from this post is my own and isn't part of the guide.&lt;/p&gt;

&lt;p&gt;And if you learn best from complete code you can run, &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt; gives you complete, working, tested .NET projects with full source code and step-by-step PDF guides, with new ones added regularly. &lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which clarifying question has saved you in a real interview?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>interview</category>
      <category>career</category>
    </item>
    <item>
      <title>Your First AI Feature in .NET 10 in 15 Minutes</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:56:12 +0000</pubDate>
      <link>https://dev.to/michael_maurice/your-first-ai-feature-in-net-10-in-15-minutes-534m</link>
      <guid>https://dev.to/michael_maurice/your-first-ai-feature-in-net-10-in-15-minutes-534m</guid>
      <description>&lt;p&gt;You don't need a vector database, an agent framework or a weekend to ship your first AI feature in .NET. You need one interface, &lt;code&gt;IChatClient&lt;/code&gt;, and about fifteen minutes.&lt;/p&gt;

&lt;p&gt;In this post we'll build a small but real feature: a &lt;strong&gt;support ticket triage endpoint&lt;/strong&gt;. You POST a customer email, and you get back a category, a priority and a one-line summary as a typed C# object. Then we'll stream a reply draft and make the model swappable with one line.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📄 &lt;strong&gt;Want the full version of every snippet here?&lt;/strong&gt; Grab the free &lt;strong&gt;&lt;a href="https://elitesolutions1.gumroad.com/l/dotnet-ai-cheat-sheet" rel="noopener noreferrer"&gt;.NET AI Starter Cheat Sheet&lt;/a&gt;&lt;/strong&gt;. Enter your email and get it free: 8 pages of copy-paste C# covering the whole .NET AI stack, tool calling, structured output, RAG, MCP and a production checklist.&lt;/p&gt;

&lt;p&gt;🔥 &lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; Complete, tested .NET + AI projects with full source and step-by-step PDF guides: &lt;strong&gt;&lt;a href="https://elitesolutions1.gumroad.com/tech-skill-builder" rel="noopener noreferrer"&gt;Join Tech Skill Builder&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The plan (and the clock)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Minutes&lt;/th&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0 to 2&lt;/td&gt;
&lt;td&gt;Create the project and add two packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 to 5&lt;/td&gt;
&lt;td&gt;Register &lt;code&gt;IChatClient&lt;/code&gt; once, in DI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 to 9&lt;/td&gt;
&lt;td&gt;Typed triage with structured output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9 to 12&lt;/td&gt;
&lt;td&gt;Stream a reply draft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12 to 15&lt;/td&gt;
&lt;td&gt;Guardrails, and swapping the model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The code below shows the shape of each step. Some parts are trimmed and marked as such.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 0 to 2: project and packages
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet new webapi &lt;span class="nt"&gt;-n&lt;/span&gt; TicketTriage &lt;span class="nt"&gt;--use-minimal-apis&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;TicketTriage
dotnet add package Microsoft.Extensions.AI
dotnet add package Microsoft.Extensions.AI.OpenAI
dotnet user-secrets init
dotnet user-secrets &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="s2"&gt;"OpenAI:Key"&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;your-key&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; gives you the abstractions (&lt;code&gt;IChatClient&lt;/code&gt;, &lt;code&gt;ChatMessage&lt;/code&gt;, &lt;code&gt;ChatOptions&lt;/code&gt;) and the middleware. &lt;code&gt;Microsoft.Extensions.AI.OpenAI&lt;/code&gt; is the adapter that turns the OpenAI SDK client into an &lt;code&gt;IChatClient&lt;/code&gt;. Your app code will only ever see the interface.&lt;/p&gt;

&lt;p&gt;Keep the key in user secrets (or environment variables in production). Never in &lt;code&gt;appsettings.json&lt;/code&gt;, and never in Git.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 2 to 5: one registration, used everywhere
&lt;/h2&gt;

&lt;p&gt;The mistake I see most often in first AI features is &lt;code&gt;new OpenAIClient(...)&lt;/code&gt; inside a controller. Register the client once and inject the interface instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.AI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sp&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OpenAIClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"OpenAI:Key"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"gpt-4o-mini"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsIChatClient&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseLogging&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// more middleware (telemetry, caching, tool calls): see the cheat sheet&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That builder is a pipeline. Every call goes through the middleware you add, in order, before it reaches the model. Logging is enough for today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 5 to 9: typed triage, not string parsing
&lt;/h2&gt;

&lt;p&gt;Here's the part that makes it feel like a real feature. Don't ask the model for "JSON please" and then fight &lt;code&gt;JsonSerializer&lt;/code&gt;. Ask for a C# type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Low&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Normal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;High&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Urgent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;Triage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Summary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;TicketRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/tickets/triage"&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="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TicketRequest&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ChatResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Triage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetResponseAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Triage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChatRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"You triage support emails for an online shop. ..."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChatRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="c1"&gt;// ... validate res.Result before trusting it (step 5)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&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;GetResponseAsync&amp;lt;T&amp;gt;&lt;/code&gt; generates a JSON schema from your record, sends it with the request and deserializes the answer into &lt;code&gt;Triage&lt;/code&gt;. Using an enum for &lt;code&gt;Priority&lt;/code&gt; means the model can't invent a "Kinda urgent" level.&lt;/p&gt;

&lt;p&gt;The system prompt matters more than the code here. Mine lists the allowed categories and says what counts as urgent. I've cut it short above because the wording is where most of the tuning goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 9 to 12: stream the reply draft
&lt;/h2&gt;

&lt;p&gt;A triage result is small, so waiting for it is fine. A reply draft is longer, and a spinner for six seconds feels broken. Stream it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/tickets/draft"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TicketRequest&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;StreamDraft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;IAsyncEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;StreamDraft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TicketRequest&lt;/span&gt; &lt;span class="n"&gt;req&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="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetStreamingResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* system + user messages */&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning &lt;code&gt;IAsyncEnumerable&amp;lt;string&amp;gt;&lt;/code&gt; from a minimal API endpoint sends the chunks as they arrive. The front end shows the text as the model writes it. Cancellation, retries and Server-Sent Events are the next layer up, and you can skip them for a first version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minute 12 to 15: guardrails and the one-line swap
&lt;/h2&gt;

&lt;p&gt;Three things I'd add before anyone else uses this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cap the cost.&lt;/strong&gt; Pass &lt;code&gt;new ChatOptions { MaxOutputTokens = 300, Temperature = 0.1f }&lt;/code&gt; for triage. Low temperature for classification, a little higher for drafts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust the result blindly.&lt;/strong&gt; If &lt;code&gt;Summary&lt;/code&gt; is empty or the category isn't in your list, fall back to "Needs a human". The model will be wrong sometimes, and your code should expect that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap the input.&lt;/strong&gt; Truncate the email body before sending it. A 40-page pasted log shouldn't cost you a dollar.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the payoff for coding against the interface: switching to a local model for development is one registration change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// dev: a local model through Ollama (OllamaSharp implements IChatClient)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OllamaApiClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:11434"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"llama3.2"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your endpoints, prompts and records don't change at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we skipped on purpose
&lt;/h2&gt;

&lt;p&gt;To keep it to fifteen minutes I left out tool calling (letting the model look up the customer's order), RAG (answering from your own help-center docs), telemetry and evals. Those are the steps that turn a demo into something you'd put in front of customers, and each one has a few traps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the full version, free
&lt;/h2&gt;

&lt;p&gt;Every snippet in this post, completed, plus tool calling, RAG in six steps, an MCP server in C# and a production checklist, is in the free &lt;strong&gt;&lt;a href="https://elitesolutions1.gumroad.com/l/dotnet-ai-cheat-sheet" rel="noopener noreferrer"&gt;.NET AI Starter Cheat Sheet&lt;/a&gt;&lt;/strong&gt;. Enter your email and get it free. 📄&lt;/p&gt;

&lt;p&gt;When you're ready for complete projects instead of snippets, &lt;a href="https://elitesolutions1.gumroad.com/tech-skill-builder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt; gives you working, tested .NET + AI solutions with full source and step-by-step PDF guides. &lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; &lt;a href="https://elitesolutions1.gumroad.com/tech-skill-builder" rel="noopener noreferrer"&gt;Join Tech Skill Builder&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What was the first AI feature you shipped in .NET, or the one you're planning? Tell me in the comments.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>ai</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Parameterized Queries Can't Help Here: 6 SQLite Traps When an LLM Writes Your SQL</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:02:16 +0000</pubDate>
      <link>https://dev.to/michael_maurice/parameterized-queries-cant-help-here-6-sqlite-traps-when-an-llm-writes-your-sql-10f4</link>
      <guid>https://dev.to/michael_maurice/parameterized-queries-cant-help-here-6-sqlite-traps-when-an-llm-writes-your-sql-10f4</guid>
      <description>&lt;p&gt;The standard answer to SQL injection is "use parameterized queries". It's good advice, and it doesn't help at all once a language model writes the &lt;em&gt;entire&lt;/em&gt; statement. There's no user value to bind. The query itself is the untrusted input.&lt;/p&gt;

&lt;p&gt;I spent a week building the guard for an "ask your shop data" assistant on .NET 10 with SQLite. The big design decision came early: don't parse SQL in C#. SQLite already has a full parser for its own dialect, so the guard compiles each statement and asks the engine what it would do. The surprises came later, in the details. Here are six SQLite and Microsoft.Data.Sqlite behaviours that would each have let something through.&lt;/p&gt;

&lt;p&gt;The project uses Microsoft.Data.Sqlite 10.0.13 (SQLite 3.53 via SQLitePCLRaw) and Microsoft.Extensions.AI 10.11.0 for the structured model output. Code below is trimmed to the shape; omitted parts are marked.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; The complete text-to-SQL guard from this post, with a step-by-step PDF, is inside &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Trap 1: you read one row, and the DELETE still runs
&lt;/h2&gt;

&lt;p&gt;I ran &lt;code&gt;SELECT id FROM orders LIMIT 1; DELETE FROM orders&lt;/code&gt; through a plain &lt;code&gt;SqliteCommand&lt;/code&gt;, read a single row, and let the &lt;code&gt;using&lt;/code&gt; block dispose the reader. Afterwards the table had zero rows.&lt;/p&gt;

&lt;p&gt;That's documented behaviour: Microsoft.Data.Sqlite supports batches, and disposing the reader executes any statements that haven't been consumed yet. So a guard that only looks at the first statement passes, and the second one runs anyway.&lt;/p&gt;

&lt;p&gt;The fix is to compile the statement yourself with &lt;code&gt;sqlite3_prepare_v2&lt;/code&gt; and then compile the &lt;em&gt;tail&lt;/em&gt;. If the tail produces a real statement handle, refuse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqlite3_prepare_v2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="n"&gt;sqlite3_stmt&lt;/span&gt; &lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;tail&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rc&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SQLITE_OK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* a SQL error, not a violation: eligible for repair */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;HasAnotherStatement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tail&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* refuse: multiple_statements */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// read-only, EXPLAIN, bound parameters and result-column checks: in the complete project&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't settle for "the tail isn't empty", by the way. Models love ending with &lt;code&gt;; -- done&lt;/code&gt;, and a comment-only tail compiles to an empty handle, which is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: "read-only" is narrower than it sounds
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sqlite3_stmt_readonly&lt;/code&gt; looks like the perfect check. But per the SQLite docs, it reports true for &lt;code&gt;BEGIN&lt;/code&gt;, &lt;code&gt;ATTACH&lt;/code&gt; and &lt;code&gt;DETACH&lt;/code&gt;, because they don't write to the database file. That's correct from SQLite's point of view, and it's not what you want for model output.&lt;/p&gt;

&lt;p&gt;So I still use it, but it isn't the main lock. The main lock is the next one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: SQLITE_IGNORE turns a refusal into wrong data
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sqlite3_set_authorizer&lt;/code&gt; registers a callback that SQLite calls while compiling: once per table/column read, per function, per statement type. That's the real allowlist. My callback allows &lt;code&gt;SELECT&lt;/code&gt; and &lt;code&gt;RECURSIVE&lt;/code&gt;, allows reads only of columns in the catalog, allows about 30 listed functions, and denies everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="n"&gt;PolicyViolation&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;Decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;actionCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;p0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;actionCode&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SQLITE_SELECT&lt;/span&gt; &lt;span class="n"&gt;or&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SQLITE_RECURSIVE&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SQLITE_FUNCTION&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;DecideFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;       &lt;span class="c1"&gt;// allowlisted names only&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SQLITE_READ&lt;/span&gt;     &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;DecideRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p0&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// catalog tables and columns only&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"statement_not_allowed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;ActionName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actionCode&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="c1"&gt;// DecideFunction, DecideRead and ActionName: full implementation in the complete project&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap is the return value. For a column read, &lt;code&gt;SQLITE_IGNORE&lt;/code&gt; makes SQLite return NULL instead of failing. Nothing errors, and that's exactly the problem: "show me customer emails" answers with a column of NULLs, and the user concludes you have no emails on file. I return &lt;code&gt;SQLITE_DENY&lt;/code&gt;, so the statement fails to compile and the user gets an honest refusal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: count(*) reads a column with no name
&lt;/h2&gt;

&lt;p&gt;Hiding a table from the catalog isn't enough if &lt;code&gt;SELECT count(*) FROM payout_accounts&lt;/code&gt; still works. It tells an attacker how many rows exist.&lt;/p&gt;

&lt;p&gt;The authorizer does fire for that query, just with an empty string as the column name (the SQLite docs describe exactly this). It's easy to write a rule that treats "no column" as "nothing to check". Mine allows the empty column only for catalog tables and denies it for any real table that isn't in the catalog. A test named &lt;code&gt;Denies_hidden_tables_even_for_count_star&lt;/code&gt; keeps it that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 5: pooled connections keep your hooks
&lt;/h2&gt;

&lt;p&gt;Since version 6.0, Microsoft.Data.Sqlite pools connections by default. The authorizer and the progress handler (which I use for query deadlines) live on the native &lt;code&gt;sqlite3&lt;/code&gt; handle, not on your &lt;code&gt;SqliteConnection&lt;/code&gt; object.&lt;/p&gt;

&lt;p&gt;In a spike I installed a deny-all-reads authorizer, closed the connection, opened a new one with the same connection string, and ran a simple &lt;code&gt;SELECT&lt;/code&gt;. It failed with "access to orders.id is prohibited". The hook had survived through the pool. With &lt;code&gt;Pooling=False&lt;/code&gt;, the same second query worked.&lt;/p&gt;

&lt;p&gt;Either direction is bad: unrelated code can inherit your guard, or your guarded path can get a handle without it. Every guarded connection in the project is unpooled and opened read-only, and both hooks are removed in a &lt;code&gt;finally&lt;/code&gt;. There's a test for it, &lt;code&gt;Hooks_do_not_leak_between_runs&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 6: the repair loop becomes a search engine
&lt;/h2&gt;

&lt;p&gt;Models get column names wrong, and feeding SQLite's error back ("no such column: category_name") usually fixes it on the next try. So a repair loop is worth having.&lt;/p&gt;

&lt;p&gt;But only for honest mistakes. If you also send back "access to customers.email is prohibited", you've told the model what's off limits and asked it to try another way in. In my service, SQL errors get up to two repair rounds (&lt;code&gt;MaxRepairAttempts = 2&lt;/code&gt;). Policy violations end the request as &lt;code&gt;Refused&lt;/code&gt; immediately, the model never sees why, and timeouts aren't repaired either:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kind&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;QueryRunKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SqlError&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;maxCalls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// add the draft and PromptBuilder.RepairMessage(run.Error) to the conversation, try again&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// violations: Refused, codes only, no repair (full flow in the complete project)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Compile the SQL and the tail; exactly one statement or refuse&lt;/li&gt;
&lt;li&gt;[ ] Don't treat &lt;code&gt;sqlite3_stmt_readonly&lt;/code&gt; as the main lock&lt;/li&gt;
&lt;li&gt;[ ] An authorizer allowlist for tables, columns, functions and statement types&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;SQLITE_DENY&lt;/code&gt;, never &lt;code&gt;SQLITE_IGNORE&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Hidden tables denied even for &lt;code&gt;count(*)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Unpooled, read-only connections, hooks removed in &lt;code&gt;finally&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] A deadline (progress handler) plus row and cell caps&lt;/li&gt;
&lt;li&gt;[ ] Repair SQL errors, never policy violations, with a hard limit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project has 134 offline tests (121 unit, 13 integration with &lt;code&gt;WebApplicationFactory&lt;/code&gt;) and a fake model that misbehaves on purpose: it writes DELETEs, stacks statements, goes after hidden columns and writes a recursive CTE that never ends.&lt;/p&gt;

&lt;p&gt;All of this still assumes one shop per database. Put 40 shops in the same tables and a perfectly "allowed" &lt;code&gt;SELECT SUM(total) FROM orders&lt;/code&gt; reads everyone's revenue. Where the tenant boundary should live is the hardest part, and I cover it in the project and the PDF.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the complete project
&lt;/h2&gt;

&lt;p&gt;The snippets above are the outline. The complete .NET 10 solution is in Tech Skill Builder: the semantic catalog that drives both the prompt and the allowlist, the statement inspector, the authorizer, a per-tenant sandbox, deadlines and caps, the bounded repair loop, a privacy-aware audit trail, an ASP.NET Core minimal API, a 21-scene CLI demo and all 134 tests, plus an in-depth PDF guide. No API key needed to run it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt; gives you complete, working, tested .NET projects with full source code and step-by-step PDF guides, with new ones added regularly. &lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which of these would your current text-to-SQL feature fall for?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>sql</category>
      <category>sqlite</category>
      <category>ai</category>
    </item>
    <item>
      <title>5 Traps When One IChatClient Fronts OpenAI, Azure, Gemini and Anthropic (.NET 10)</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:01:18 +0000</pubDate>
      <link>https://dev.to/michael_maurice/5-traps-when-one-ichatclient-fronts-openai-azure-gemini-and-anthropic-net-10-2lke</link>
      <guid>https://dev.to/michael_maurice/5-traps-when-one-ichatclient-fronts-openai-azure-gemini-and-anthropic-net-10-2lke</guid>
      <description>&lt;p&gt;"Just put an interface in front of the LLM" is advice everyone agrees with. In .NET the interface even exists already: &lt;code&gt;IChatClient&lt;/code&gt; from Microsoft.Extensions.AI, with adapters for OpenAI (which also covers Azure OpenAI), and official SDKs from Anthropic and Google that expose &lt;code&gt;AsIChatClient()&lt;/code&gt; themselves.&lt;/p&gt;

&lt;p&gt;So I built a small support-ticket triage API on .NET 10 that can run on any of the four, switched by one config value. Getting the interface in place took an afternoon. Making it actually &lt;em&gt;hold&lt;/em&gt; took longer. Here are the five traps I hit, and the check I now use for each one.&lt;/p&gt;

&lt;p&gt;Packages, as used in the project: Microsoft.Extensions.AI 10.11.0, OpenAI 2.14.0, Anthropic 13.3.0, Google.GenAI 1.27.0. Code below is trimmed to the shape; omitted parts are marked.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; Complete, working, tested .NET projects with full source code and PDF guides: &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you want a ready-made starter instead of wiring this yourself, my &lt;a href="https://elitesolutions1.gumroad.com/l/CleanArchitecture" rel="noopener noreferrer"&gt;Clean Architecture .NET 9 Web API with Unified AI Integration&lt;/a&gt; covers six providers out of the box. More on it at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: the interface is there, but the SDK leaked anyway
&lt;/h2&gt;

&lt;p&gt;The whole point is that the Application layer knows &lt;code&gt;IChatClient&lt;/code&gt; and nothing else. In practice, someone adds &lt;code&gt;using OpenAI;&lt;/code&gt; to a handler "just for one option", and the abstraction is gone without anyone noticing in review.&lt;/p&gt;

&lt;p&gt;A rule you can't see is a rule you don't have. So I don't rely on discipline; a test reads the compiled Application assembly's references and fails if any vendor SDK shows up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Application_knows_only_the_AI_abstractions_never_a_vendor_SDK&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;refs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TriageTicketHandler&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Assembly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetReferencedAssemblies&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;!).&lt;/span&gt;&lt;span class="nf"&gt;ToArray&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Microsoft.Extensions.AI.Abstractions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// assert none of: OpenAI, Anthropic, Google.GenAI, Microsoft.Extensions.AI(.OpenAI)&lt;/span&gt;
    &lt;span class="c1"&gt;// (full list in the complete project)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Application project only references &lt;code&gt;Microsoft.Extensions.AI.Abstractions&lt;/code&gt;. Everything vendor-specific lives in one factory in Infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: treating Azure OpenAI as a fifth SDK
&lt;/h2&gt;

&lt;p&gt;My first instinct was "Azure needs its own client". It doesn't anymore. Azure OpenAI's v1 API is OpenAI-compatible, so the factory uses the plain OpenAI SDK pointed at your resource's &lt;code&gt;/openai/v1/&lt;/code&gt; base URL, with the deployment name as the model and no &lt;code&gt;api-version&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That makes the endpoint the fragile part. Forget the &lt;code&gt;/openai/v1/&lt;/code&gt; suffix and you get requests that look almost right. So the Azure branch always goes through one small helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;AiProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AzureOpenAI&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CreateOpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;AzureV1Endpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Endpoint&lt;/span&gt;&lt;span class="p"&gt;!)),&lt;/span&gt;

&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Uri&lt;/span&gt; &lt;span class="nf"&gt;AzureV1Endpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;resourceEndpoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// normalizes https://your-resource.openai.azure.com/ to .../openai/v1/&lt;/span&gt;
    &lt;span class="c1"&gt;// (handles trailing slashes and an already-present suffix; see the complete project)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked that a test actually guards this: when I swapped &lt;code&gt;AzureV1Endpoint(...)&lt;/code&gt; for the raw endpoint, exactly one test went red, the Azure adapter test. Then I reverted it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: failover works, and now you don't know who answered
&lt;/h2&gt;

&lt;p&gt;Microsoft.Extensions.AI 10.11.0 ships &lt;code&gt;OrderedFailoverChatClient&lt;/code&gt;. It tries clients in order, fails over only before any streaming output was exposed, and doesn't treat cancellation as a failure. It's marked experimental (&lt;code&gt;MEAI001&lt;/code&gt;), so I suppress that warning locally, in the one registration that uses it.&lt;/p&gt;

&lt;p&gt;The catch: after a failover, asking the outer client for its &lt;code&gt;ChatClientMetadata&lt;/code&gt; doesn't tell you which vendor produced &lt;em&gt;this&lt;/em&gt; response. For logs, cost tracking and support tickets, that matters.&lt;/p&gt;

&lt;p&gt;My fix is a tiny &lt;code&gt;DelegatingChatClient&lt;/code&gt; around each provider that stamps the configured provider name onto every response, under a key the Application owns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProviderNameChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;providerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;DelegatingChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;IEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChatOptions&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&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="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AdditionalProperties&lt;/span&gt; &lt;span class="p"&gt;??=&lt;/span&gt; &lt;span class="p"&gt;[])[&lt;/span&gt;&lt;span class="n"&gt;AiResponseKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;providerName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// streaming override and GetService(ChatClientMetadata): in the complete project&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The use case reads a plain string. It never sees a vendor type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: the missing key shows up on the first customer request
&lt;/h2&gt;

&lt;p&gt;With one config value choosing the provider, it's very easy to deploy &lt;code&gt;Provider = Anthropic&lt;/code&gt; to an environment where only the OpenAI key exists. Without validation, you find out when the first real request fails.&lt;/p&gt;

&lt;p&gt;I bind the settings to an options class, register an &lt;code&gt;IValidateOptions&amp;lt;AiOptions&amp;gt;&lt;/code&gt; and call &lt;code&gt;ValidateOnStart()&lt;/code&gt;. The validator only checks the providers you actually selected (primary and fallback), and it also rejects a fallback that's the same as the primary. Running with &lt;code&gt;AI__Provider=OpenAI&lt;/code&gt; and no key now stops the app at startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Microsoft.Extensions.Options.OptionsValidationException: AI:OpenAI:ApiKey is required.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also a &lt;code&gt;Fake&lt;/code&gt; provider that needs no settings at all, so the app and its tests run offline by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 5: mocking IChatClient tests your code, not your wiring
&lt;/h2&gt;

&lt;p&gt;The obvious test double is a fake &lt;code&gt;IChatClient&lt;/code&gt;. It's useful for the use case, but it skips everything that breaks in real life: the endpoint, the auth header, how &lt;code&gt;ChatOptions&lt;/code&gt; gets translated into each vendor's request body.&lt;/p&gt;

&lt;p&gt;Because every SDK gets its &lt;code&gt;HttpClient&lt;/code&gt; from &lt;code&gt;IHttpClientFactory&lt;/code&gt;, the tests can build the real registration and replace only the network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddUnifiedAi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureHttpClientDefaults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigurePrimaryHttpMessageHandler&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stub&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// stub records each request and returns a hand-written response in that vendor's format&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now each provider test asserts what the real SDK put on the wire: the URL, the auth header, and JSON mode and the system prompt mapped into that vendor's shape. This caught one wrong assumption on the first run. I expected Anthropic's SDK to send an &lt;code&gt;x-api-key&lt;/code&gt; header, but Anthropic 13.3.0 sends the key as &lt;code&gt;Authorization: Bearer&lt;/code&gt;. The SDK's XML docs confirm it, so the test now pins that.&lt;/p&gt;

&lt;p&gt;The same stub makes failover testable too. Answer Anthropic with a 529 &lt;code&gt;overloaded_error&lt;/code&gt;, and the test checks there was one request to Anthropic, then one to Gemini, and that the result reports &lt;code&gt;Gemini&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To be clear about limits: these tests never hit a live vendor. The responses are written by hand in each documented format, so real model output, rate limits and streaming from the actual services aren't covered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I'd use on any multi-vendor setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] A test fails if the Application layer references a vendor SDK&lt;/li&gt;
&lt;li&gt;[ ] Azure OpenAI goes through one endpoint helper, and a test breaks if you bypass it&lt;/li&gt;
&lt;li&gt;[ ] Every response says which provider answered, even after failover&lt;/li&gt;
&lt;li&gt;[ ] Missing keys or models for the selected providers stop the app at startup&lt;/li&gt;
&lt;li&gt;[ ] Adapter tests check the real HTTP request, not a mocked interface&lt;/li&gt;
&lt;li&gt;[ ] Business rules (like "a data breach is always Urgent") live in the domain, so no model can override them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project behind this has 42 offline xUnit tests: 9 provider-adapter, 12 failover and configuration, 10 use-case, 5 domain, 2 architecture and 4 API tests. No API key needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Want the ready-made version?
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://elitesolutions1.gumroad.com/l/CleanArchitecture" rel="noopener noreferrer"&gt;Clean Architecture .NET 9 Web API - Unified AI Integration&lt;/a&gt; starter kit ($8) is the bigger, production-style take on this idea: Domain / Application / Infrastructure / API layers with CQRS and MediatR, six providers (OpenAI, Azure OpenAI, Gemini, Anthropic, Cohere, Hugging Face), endpoints for text generation, image generation, image analysis, provider listing, model discovery and provider health, plus Serilog, HealthChecks, Swagger and an MIT license. It targets .NET 9. To be upfront: the small .NET 10 triage demo from this post is my own and isn't part of the kit.&lt;/p&gt;

&lt;p&gt;And if you learn best from complete code you can run, &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt; gives you complete, working, tested .NET projects with full source code and step-by-step PDF guides, with new ones added regularly. &lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which of these five has bitten you when switching LLM vendors?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>ai</category>
      <category>csharp</category>
      <category>architecture</category>
    </item>
    <item>
      <title>When the Guard Itself Breaks: Making .NET AI Agent Guardrails Fail Closed</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Sun, 25 Oct 2026 11:56:46 +0000</pubDate>
      <link>https://dev.to/michael_maurice/when-the-guard-itself-breaks-making-net-ai-agent-guardrails-fail-closed-5dpj</link>
      <guid>https://dev.to/michael_maurice/when-the-guard-itself-breaks-making-net-ai-agent-guardrails-fail-closed-5dpj</guid>
      <description>&lt;p&gt;We test guardrails by sending them bad input: an injection, a card number, a refund that's too big. That's necessary, but it only tests the guard when the guard is working. The more uncomfortable question is: &lt;strong&gt;what does your agent do when the guard itself breaks?&lt;/strong&gt; It throws, its dependency is down, it gets wired into the pipeline wrong, or it runs in a code path you didn't expect.&lt;/p&gt;

&lt;p&gt;In my experience the answer is usually "it allows the request", and nobody notices until much later. So I went through the guard layers of a .NET helpdesk agent and broke each one on purpose. Below is every place one of them wanted to fail open, and what I changed so it fails closed instead.&lt;/p&gt;

&lt;p&gt;The stack is .NET 10 with &lt;code&gt;Microsoft.Agents.AI&lt;/code&gt; 1.25.0 and &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; 10.11.0. The model in the tests is an offline fake that behaves badly on purpose: it calls tools it was never offered, loops, and ignores refusals. Code below is trimmed to the shape; omitted parts are marked.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; The complete guarded agent from this post, with a step-by-step PDF, is inside &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Quick map: where the guards sit
&lt;/h2&gt;

&lt;p&gt;Agent Framework gives you three middleware hooks, and I use all of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;agent run middleware&lt;/strong&gt;: sees the incoming messages and the final response (input screen, caller check)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;function calling middleware&lt;/strong&gt;: sees every tool call the model makes (the tool policy)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;IChatClient&lt;/code&gt; middleware&lt;/strong&gt;: sees every model call (budgets, output scrubbing)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of the failures below happened at one of these layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break 1: the guard throws, and the agent carries on
&lt;/h2&gt;

&lt;p&gt;My first "halt this run" was an exception thrown from the function middleware. It didn't halt anything. &lt;code&gt;FunctionInvokingChatClient&lt;/code&gt; caught it, sent &lt;code&gt;"Error: Function failed."&lt;/code&gt; back to the model as the tool result, and the loop kept going. That's the documented behaviour of &lt;code&gt;MaximumConsecutiveErrorsPerRequest&lt;/code&gt;: exceptions are reported to the model and only rethrown once the limit (3 by default) is reached.&lt;/p&gt;

&lt;p&gt;So a guard that "stops" a run by throwing actually gives the model up to three more tries. The fix is to set it to zero, so exceptions propagate immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseFunctionInvocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaximumConsecutiveErrorsPerRequest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// guard halts must propagate&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IncludeDetailedErrors&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// no exception text to the model&lt;/span&gt;
    &lt;span class="c1"&gt;// iteration limit set from the model-call budget (complete project)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But that creates the opposite problem: now a flaky database aborts runs too. So the tool guard catches real tool bugs itself and lets only its own halts escape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;result&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="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&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="n"&gt;OperationCanceledException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsCancellationRequested&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="p"&gt;;&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="n"&gt;GuardHaltException&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="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;            &lt;span class="c1"&gt;// ours: end the run&lt;/span&gt;
&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// audit as "tool_error", then return a generic result&lt;/span&gt;
    &lt;span class="c1"&gt;// (exception text never goes back into the context window)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ToolErrorResult&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;h2&gt;
  
  
  Break 2: the classifier is down
&lt;/h2&gt;

&lt;p&gt;The input screen calls an &lt;code&gt;IInputClassifier&lt;/code&gt; to score prompt-injection risk. If that classifier is a remote service, at some point it will time out. The tempting code is &lt;code&gt;catch { verdict = Safe; }&lt;/code&gt; so users aren't blocked by an outage.&lt;/p&gt;

&lt;p&gt;That's a guard that quietly becomes "allow" exactly when you can't see what's happening. Mine blocks instead, with its own rule id so it's visible on a dashboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;classifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ClassifyAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&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="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;OperationCanceledException&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;Block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"classifier_unavailable"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// fail closed&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user gets "temporarily unavailable, try again shortly", not an agent with no screen. A test (&lt;code&gt;Classifier_outage_fails_closed&lt;/code&gt;) plugs in a classifier that always throws and checks that the rule fired and the model was called zero times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break 3: nobody said who's asking
&lt;/h2&gt;

&lt;p&gt;Every policy decision depends on the caller: a customer can refund up to $50, staff up to $500, and customers only see their own orders. The caller comes from &lt;code&gt;AgentRunOptions.AdditionalProperties&lt;/code&gt;, set by the host, never by the model.&lt;/p&gt;

&lt;p&gt;So what happens when the host forgets to set it? The easy default is an anonymous caller with some role, and the run continues. Mine blocks the run as &lt;code&gt;no_caller&lt;/code&gt; before the model is called at all. The test asserts exactly that: outcome &lt;code&gt;Blocked&lt;/code&gt;, zero model calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break 4: a layer runs outside the pipeline you designed
&lt;/h2&gt;

&lt;p&gt;The function and chat middleware need per-run state (the caller, counters, rules that fired). I pass it through an &lt;code&gt;AsyncLocal&lt;/code&gt; scope opened by the run guard. The question is what a layer should do if that scope is missing, for example because someone registered the tool middleware on a different agent, or called the chat client directly.&lt;/p&gt;

&lt;p&gt;Skipping the check ("no scope, nothing to enforce") is failing open. Mine treats it as a wiring bug and halts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;GuardRun&lt;/span&gt; &lt;span class="nf"&gt;Require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;CurrentRun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;GuardHaltException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"no_guard_scope"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ran outside a guarded agent run"&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;Outside_a_guarded_run_the_guard_fails_closed&lt;/code&gt; pins it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break 5: streaming takes a different path
&lt;/h2&gt;

&lt;p&gt;Two separate streaming surprises:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you register agent run middleware with only the non-streaming function, the docs warn that streaming calls "will only run in non-streaming mode". I checked: the stream came back as one update. Register both delegates.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;AsyncLocal&lt;/code&gt; value set inside an async iterator doesn't survive across &lt;code&gt;yield return&lt;/code&gt;. So a scope opened once at the top of the streaming run guard is gone by the time the inner stream reaches a tool call. Because of Break 4, the result is a halt with &lt;code&gt;no_guard_scope&lt;/code&gt;, not an unguarded tool call, and a streaming test with a tool call shows exactly that without the fix. The fix is to re-establish the scope around every &lt;code&gt;MoveNextAsync&lt;/code&gt; of the inner stream:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// inside RunStreamingAsync&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ValueTask&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GuardScope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Begin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MoveNextAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// await step, turn GuardHaltException into a refusal, yield e.Current&lt;/span&gt;
    &lt;span class="c1"&gt;// (full loop, hold-back redaction and final outcome in the complete project)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the best argument I know for Break 4: a guard that halts on missing state turned a silent bypass into a failing test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break 6: the model just keeps asking
&lt;/h2&gt;

&lt;p&gt;A denial is not an error. The tool guard returns a short reason-only result (&lt;code&gt;DENIED by policy (rule id)&lt;/code&gt;, never the arguments) and the model is free to try again. A stubborn or manipulated model will. Two limits stop that from turning into a loop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a tool-call budget per run (6 by default), halted as &lt;code&gt;tool_budget_exceeded&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a denial budget: after 3 denied calls in one run, the next one halts the run as &lt;code&gt;repeated_policy_denials&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A halted turn isn't persisted, and a test checks that the session's stored history afterwards is identical to what it was before the halted turn.&lt;/p&gt;

&lt;h2&gt;
  
  
  One I chose not to use
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FunctionInvocationContext.Terminate = true&lt;/code&gt; ends the loop after the current call. It sounds like a halt, but the Learn docs warn it can leave the history inconsistent, and in my spike the run ended on a function result with empty text. Fine for "we're done", not something I'd hang a security decision on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Guard exceptions end the run (&lt;code&gt;MaximumConsecutiveErrorsPerRequest = 0&lt;/code&gt;), tool bugs don't&lt;/li&gt;
&lt;li&gt;[ ] A broken dependency of the guard blocks, with its own rule id&lt;/li&gt;
&lt;li&gt;[ ] A missing caller blocks before the first model call&lt;/li&gt;
&lt;li&gt;[ ] A layer with no run state halts instead of skipping its checks&lt;/li&gt;
&lt;li&gt;[ ] Both streaming and non-streaming paths are registered and tested&lt;/li&gt;
&lt;li&gt;[ ] Denials and tool calls have per-run budgets&lt;/li&gt;
&lt;li&gt;[ ] Every fail-closed path has a test that forces it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project behind this has 94 offline tests (85 unit, 9 integration with &lt;code&gt;WebApplicationFactory&lt;/code&gt;), and a good share of them exist only to force a guard into one of the failures above.&lt;/p&gt;

&lt;p&gt;There's also one case none of this covers: every call the model makes passes every policy, and the action is still wrong, because the instruction came from the data the agent read, not from the user. The project handles that too, and the PDF walks through how.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the complete project
&lt;/h2&gt;

&lt;p&gt;The snippets here are the outline. The complete .NET 10 solution is in Tech Skill Builder: all three guard layers, the per-tool policy catalog, the secret redactor, the streaming hold-back redactor, the text-free audit trail, an ASP.NET Core minimal API with SSE streaming, a CLI demo with 13 scenes and all 94 tests, plus an in-depth PDF guide. No API key needed to run it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt; gives you complete, working, tested .NET projects with full source code and step-by-step PDF guides, with new ones added regularly. &lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What does your agent do today when its guard throws?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>ai</category>
      <category>security</category>
      <category>llm</category>
    </item>
    <item>
      <title>Write the Boring Scenarios First: A Spec Checklist Before Your AI Coder Writes C#</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Sun, 25 Oct 2026 11:55:27 +0000</pubDate>
      <link>https://dev.to/michael_maurice/write-the-boring-scenarios-first-a-spec-checklist-before-your-ai-coder-writes-c-4m2b</link>
      <guid>https://dev.to/michael_maurice/write-the-boring-scenarios-first-a-spec-checklist-before-your-ai-coder-writes-c-4m2b</guid>
      <description>&lt;p&gt;The scenario I almost didn't write was the dull one: a customer tries a coupon on a 50.00 cart, gets rejected because the minimum is 100.00, then adds items and tries again at 150.00. Should the second attempt work? Obviously yes. But "obviously" is not in any prompt, and code that records a redemption &lt;em&gt;before&lt;/em&gt; checking the rules will happily use up the coupon on the failed attempt.&lt;/p&gt;

&lt;p&gt;That's the part of spec-driven development I want to focus on here. Not the tooling or the commands, just the spec itself: &lt;strong&gt;what does a set of acceptance scenarios need before you hand it to an AI coding agent, so the agent has nothing left to guess?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I worked through this on a small .NET 10 feature, coupon redemption at checkout, with the spec written in GitHub Spec Kit's file layout and every scenario running as an xUnit test (37 tests in total). Code below is trimmed to the shape; omitted parts are marked.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; Complete, working, tested .NET projects with full source and PDF guides are inside &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you want the whole spec-driven workflow explained end to end, I also have a PDF guide on it: &lt;a href="https://elitesolutions1.gumroad.com/l/SpecDriven" rel="noopener noreferrer"&gt;Spec Driven Ai Development&lt;/a&gt; ($10). More at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Every scenario has an ID a test can find&lt;/li&gt;
&lt;li&gt;Every boundary is written from both sides&lt;/li&gt;
&lt;li&gt;The clock and the calendar are named&lt;/li&gt;
&lt;li&gt;Rounding is stated, once&lt;/li&gt;
&lt;li&gt;Failures say what they do NOT change&lt;/li&gt;
&lt;li&gt;Every rejection has a name&lt;/li&gt;
&lt;li&gt;No open question is left in the file&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's why each one is on the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Every scenario has an ID a test can find
&lt;/h2&gt;

&lt;p&gt;A scenario without an ID is a paragraph. A scenario with an ID like &lt;code&gt;AS-2.3&lt;/code&gt;, citing requirements like &lt;code&gt;FR-006&lt;/code&gt;, is something a test can be matched against. I keep each scenario as one row of data, Given / When / Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;Scenario&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;Covers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                       &lt;span class="c1"&gt;// "AS-2.3", ["FR-006", "FR-009"]&lt;/span&gt;
    &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PriorAttempt&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// Given&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Subtotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// When&lt;/span&gt;
    &lt;span class="n"&gt;RedemptionResult&lt;/span&gt; &lt;span class="n"&gt;Expected&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                       &lt;span class="c1"&gt;// Then&lt;/span&gt;

&lt;span class="c1"&gt;// AcceptanceScenarios.All = [ ...one row per AS-x.y in spec.md... ]&lt;/span&gt;
&lt;span class="c1"&gt;// (all 14 rows in the complete project)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a test reads &lt;code&gt;spec.md&lt;/code&gt; itself and fails if the IDs in the file and the IDs in the test data drift apart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Every_scenario_in_the_spec_has_a_test_case_and_vice_versa&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// parse **AS-x.y** markers out of spec.md, compare with AcceptanceScenarios.All&lt;/span&gt;
    &lt;span class="c1"&gt;// (regexes and the FR coverage checks are in the complete project)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one test is what turns the spec from documentation into a contract. If the agent (or you) adds a scenario to the spec and forgets the test, the build goes red.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Every boundary is written from both sides
&lt;/h2&gt;

&lt;p&gt;"Orders of 100.00 or more qualify" sounds complete. It isn't, until you write the two rows that pin the line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gs"&gt;**AS-2.3**&lt;/span&gt; Given a 10% coupon MIN100 with a 100.00 minimum, When it is applied to
&lt;span class="p"&gt;99.&lt;/span&gt;99, Then it is rejected with BelowMinimumSubtotal.
&lt;span class="gs"&gt;**AS-2.4**&lt;/span&gt; Given the same coupon, When it is applied to exactly 100.00,
Then it is accepted with a 10.00 discount.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first row alone passes with &lt;code&gt;&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;gt;=&lt;/code&gt;. Only the pair catches the wrong one. I checked that by flipping &lt;code&gt;&amp;gt;=&lt;/code&gt; to &lt;code&gt;&amp;gt;&lt;/code&gt; in the implementation: exactly one test failed, AS-2.4, and everything else stayed green.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The clock and the calendar are named
&lt;/h2&gt;

&lt;p&gt;"Valid until October 23" has three hidden questions: inclusive or not, in which time zone, and whose clock. My spec answers them in a clarification (inclusive, until the end of that date in the store's time zone, Africa/Cairo) and then pins it with another boundary pair:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2026-10-23 20:59:59 UTC (23:59:59 in Cairo): accepted&lt;/li&gt;
&lt;li&gt;2026-10-23 21:00:00 UTC (midnight in Cairo): rejected as &lt;code&gt;Expired&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The constitution adds the rule that makes this testable at all: domain code reads time from an injected &lt;code&gt;TimeProvider&lt;/code&gt;, never &lt;code&gt;DateTime.UtcNow&lt;/code&gt;. That also lets a test cover daylight saving. Egypt leaves summer time on Oct 29, 2026, so a coupon valid until Oct 31 must still work at 21:59:59 UTC and be expired at 22:00 UTC that day. If the spec never names the time zone, no agent will write that test for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Rounding is stated, once
&lt;/h2&gt;

&lt;p&gt;A 15% coupon on 10.30 is 1.545 off. Is that 1.54 or 1.55? In .NET, &lt;code&gt;Math.Round(1.545m, 2)&lt;/code&gt; returns &lt;code&gt;1.54&lt;/code&gt;, because the default is round-half-to-even (banker's rounding). If the business expects 1.55, the spec has to say "2 decimals, midpoint away from zero", and the constitution says where rounding is allowed to happen: one &lt;code&gt;Money.Round&lt;/code&gt; method, nowhere else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="nf"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="kt"&gt;decimal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MidpointRounding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AwayFromZero&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one is complete; it really is that small. The hard part was writing the sentence in the spec, not the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Failures say what they do NOT change
&lt;/h2&gt;

&lt;p&gt;This is the scenario from the intro, and it's the one I see missing most often. Specs describe what success does. They rarely describe what a failure must leave untouched. My spec has two rules for that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a rejection never changes the total (it equals the subtotal)&lt;/li&gt;
&lt;li&gt;a failed attempt does not use up the coupon (AS-3.3: rejected at 50.00, then accepted at 150.00)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A domain test backs it up: two rejected attempts, zero entries in the redemption log; one valid attempt, one entry. And because "once per customer" is also a concurrency rule, another test fires 50 parallel redemptions for the same customer and expects exactly one applied and 49 &lt;code&gt;AlreadyRedeemed&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Every rejection has a name
&lt;/h2&gt;

&lt;p&gt;"Return an error if the coupon can't be used" lets the agent pick one generic failure. Naming the reasons (&lt;code&gt;UnknownCode&lt;/code&gt;, &lt;code&gt;Expired&lt;/code&gt;, &lt;code&gt;BelowMinimumSubtotal&lt;/code&gt;, &lt;code&gt;AlreadyRedeemed&lt;/code&gt;) forces two useful decisions: the UI can tell the customer what actually happened, and someone has to decide the order of the checks (an expired coupon on a small cart: which reason wins?). The plan fixes that order; the spec only has to demand a specific reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. No open question is left in the file
&lt;/h2&gt;

&lt;p&gt;Spec Kit's template lets a first draft mark gaps with &lt;code&gt;[NEEDS CLARIFICATION: ...]&lt;/code&gt;, and its clarify step asks about them and writes the answers back into the spec. The cheap trick is to make leftovers fail the build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Spec_has_no_open_clarifications&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DoesNotContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"[NEEDS CLARIFICATION"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Spec = spec.md copied to the test output (setup in the complete project)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Does the checklist actually catch anything?
&lt;/h2&gt;

&lt;p&gt;To find out, I kept a second, "just prompted" implementation next to the real one. To be clear, I wrote it by hand to mirror what a one-sentence prompt leaves open (exact string match, UTC dates, &lt;code&gt;&amp;gt;&lt;/code&gt;, default rounding, no cap, no clamp, no redemption log); it is not captured AI output. Run against the same 14 scenarios, it missed 7 of them. Every miss is a decision the one-sentence prompt never made: case and spaces in the code, rounding, the cap, a negative total, the minimum boundary, the time zone, and the second redemption. Three of them (boundary, time zone, rounding) are exactly the items from this checklist that the clarification step answered.&lt;/p&gt;

&lt;p&gt;The implementation written from the finished spec passed all 14 scenarios on its first test run. The full suite is 37 tests: 14 acceptance scenarios, 5 traceability checks, 2 comparison tests and 16 domain tests, on .NET SDK 10.0.401 with xUnit v3, with &lt;code&gt;-warnaserror&lt;/code&gt; on and 0 warnings.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't verify
&lt;/h2&gt;

&lt;p&gt;I wrote the spec, plan and tasks by hand in Spec Kit's layout and template format. I did not run the Specify CLI or an AI agent through the commands for this example. I also didn't test the &lt;code&gt;Africa/Cairo&lt;/code&gt; time zone lookup on Windows, or any persistence beyond in-memory stores.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Every scenario has an ID, and a test fails if spec and tests drift&lt;/li&gt;
&lt;li&gt;[ ] Every "at least", "until" and "up to" has a row on each side of the line&lt;/li&gt;
&lt;li&gt;[ ] The spec names the time zone, and the code reads an injected clock&lt;/li&gt;
&lt;li&gt;[ ] Rounding mode is written down, and lives in one method&lt;/li&gt;
&lt;li&gt;[ ] Failures state what they leave unchanged&lt;/li&gt;
&lt;li&gt;[ ] Every rejection reason has a name&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;[NEEDS CLARIFICATION]&lt;/code&gt; can't survive into a green build&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Go further
&lt;/h2&gt;

&lt;p&gt;If you want the full spec-driven workflow (Constitution, Specify, Clarify, Plan, Tasks, Analyze, Implement) explained step by step, &lt;strong&gt;&lt;a href="https://elitesolutions1.gumroad.com/l/SpecDriven" rel="noopener noreferrer"&gt;Spec Driven Ai Development&lt;/a&gt;&lt;/strong&gt; is my $10 PDF guide on it, with review checklists, reusable prompt patterns and guidance for existing projects. It's an independent guide, not an official GitHub publication, and the coupon example from this post isn't part of it.&lt;/p&gt;

&lt;p&gt;And if you learn best from complete code, &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt; is where I put complete, working, tested .NET + AI projects with full source code and step-by-step PDF guides, with new ones added regularly. &lt;strong&gt;Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; &lt;a href="https://elitesolutions1.gumroad.com/l/TechSkillBuilder" rel="noopener noreferrer"&gt;Join Tech Skill Builder&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which "obvious" scenario did your AI coder get wrong because nobody wrote it down?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>ai</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
