<?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>My Sale Ended at the Wrong Midnight: 6 Bugs in Time-Limited Discount Codes in .NET</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Sat, 31 Oct 2026 16:19:37 +0000</pubDate>
      <link>https://dev.to/michael_maurice/my-sale-ended-at-the-wrong-midnight-6-bugs-in-time-limited-discount-codes-in-net-35d0</link>
      <guid>https://dev.to/michael_maurice/my-sale-ended-at-the-wrong-midnight-6-bugs-in-time-limited-discount-codes-in-net-35d0</guid>
      <description>&lt;p&gt;"40% off until Sunday" sounds like one &lt;code&gt;if&lt;/code&gt; statement. Then a customer in another time zone gets refused at 11 PM their time, a $9.99 item charges $5.99 on one screen and $6.00 on another, and a monthly plan keeps the discount forever because nobody decided what "until Sunday" means for a subscription.&lt;/p&gt;

&lt;p&gt;I'm running exactly this kind of promo right now, so here are the six bugs I check for whenever a time-limited code goes into a .NET checkout. 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 (normally $10). Grab it now before it goes back to $10.&lt;/strong&gt; And until Sunday, November 2 (end of day, Cairo time), the code &lt;strong&gt;HALLOWEEN40&lt;/strong&gt; takes 40% off everything in the store, including the membership. &lt;a href="https://elitesolutions1.gumroad.com/tech-skill-builder" rel="noopener noreferrer"&gt;See Tech Skill Builder&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Bug 1: "End of day" in the server's time zone
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DateTime.Now &amp;gt; promo.EndsAt&lt;/code&gt; uses whatever zone the server runs in. In a container that's usually UTC, so a Cairo "end of Sunday" ends two hours early.&lt;/p&gt;

&lt;p&gt;Store the end as an instant, computed once from the zone the business promised:&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;cairo&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;FindSystemTimeZoneById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Africa/Cairo"&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;endLocal&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;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;11&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="m"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;59&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;59&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="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;endsAt&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;endLocal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cairo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// persist this&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then compare with an injected &lt;code&gt;TimeProvider&lt;/code&gt;, never &lt;code&gt;DateTime.Now&lt;/code&gt;:&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="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;IsActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Promo&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;TimeProvider&lt;/span&gt; &lt;span class="n"&gt;clock&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;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUtcNow&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StartsAt&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUtcNow&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EndsAt&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;TimeProvider&lt;/code&gt; also makes the next bug testable with &lt;code&gt;FakeTimeProvider&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: Nobody tested the boundary
&lt;/h2&gt;

&lt;p&gt;Write tests for one second before the end, the exact end and one second after. With &lt;code&gt;FakeTimeProvider&lt;/code&gt; from &lt;code&gt;Microsoft.Extensions.TimeProvider.Testing&lt;/code&gt; that's three lines each. Also test a date across a DST change if your zone has 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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;clock&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;FakeTimeProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;promo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EndsAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSeconds&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="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;True&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;promo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&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="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;False&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;promo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bug 3: Rounding in three different places
&lt;/h2&gt;

&lt;p&gt;The cart rounds per line, the summary rounds the total, the payment provider rounds again. Pick one rule, use &lt;code&gt;decimal&lt;/code&gt;, and round once with an explicit mode:&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;static&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="nf"&gt;DiscountedCents&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;priceCents&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;percentOff&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="kt"&gt;long&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;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priceCents&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;percentOff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;100m&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;Work in minor units (cents) end to end, and show the customer the same number you charge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: The subscription keeps the discount forever
&lt;/h2&gt;

&lt;p&gt;For a one-off product, "valid until Sunday" is clear. For a monthly plan it's three possible rules: the first payment only, N months, or every cycle for as long as they stay. All three are fine; leaving it undecided is the bug. Model it explicitly:&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;DiscountDuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;FirstPaymentOnly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Months&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Forever&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// ... the promo carries Duration + DurationMonths, and the billing job reads them&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And say in the promo text which rule applies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 5: Max-uses check that races
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;if (promo.TimesUsed &amp;lt; promo.MaxUses) { ...; promo.TimesUsed++; }&lt;/code&gt; lets two parallel checkouts both pass. Make the increment the check:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;Promos&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;TimesUsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimesUsed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MaxUses&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;TimesUsed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MaxUses&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- 0 rows affected = sold out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In EF Core 10 that's one &lt;code&gt;ExecuteUpdateAsync&lt;/code&gt; with the same &lt;code&gt;Where&lt;/code&gt;. Do it inside the same transaction as the order, or release the slot when payment fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 6: Validating the code only when it's typed
&lt;/h2&gt;

&lt;p&gt;A code is checked when the customer applies it, then the payment page sits open for 40 minutes and the sale ends. Re-validate at the moment you create the payment (or the Stripe Checkout session), not only on "Apply". The same goes for extending a sale: if you move the end date, every place that cached the old one (banners, emails, the validation service) needs the new value.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;End time stored as a UTC instant, computed from the zone you promised&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TimeProvider&lt;/code&gt; everywhere, boundary tests with &lt;code&gt;FakeTimeProvider&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;One rounding rule, in cents, applied once&lt;/li&gt;
&lt;li&gt;Subscription discount duration decided and written down&lt;/li&gt;
&lt;li&gt;Atomic max-uses increment&lt;/li&gt;
&lt;li&gt;Re-validate when the payment is created&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Want the full projects?
&lt;/h2&gt;

&lt;p&gt;The snippets above are skeletons. The complete, tested code is in the store: the &lt;a href="https://elitesolutions1.gumroad.com/l/StripeIntegrationfordotNET10" rel="noopener noreferrer"&gt;Stripe Integration for .NET 10&lt;/a&gt; kit covers checkout and webhooks end to end, and &lt;a href="https://elitesolutions1.gumroad.com/tech-skill-builder" rel="noopener noreferrer"&gt;Tech Skill Builder&lt;/a&gt; adds a new full-source .NET + AI project with a step-by-step guide every day.&lt;/p&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; Until Sunday, November 2 (end of day, Cairo time), the code &lt;strong&gt;HALLOWEEN40&lt;/strong&gt; takes 40% off everything in the &lt;a href="https://elitesolutions1.gumroad.com" rel="noopener noreferrer"&gt;Elite Solutions store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which of these six has bitten you? Tell me in the comments.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Masked the User's Message and the Email Still Left: 7 Places PII Slips Out of a .NET 10 LLM Pipeline</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Sat, 31 Oct 2026 13:00:05 +0000</pubDate>
      <link>https://dev.to/michael_maurice/i-masked-the-users-message-and-the-email-still-left-7-places-pii-slips-out-of-a-net-10-llm-3o24</link>
      <guid>https://dev.to/michael_maurice/i-masked-the-users-message-and-the-email-still-left-7-places-pii-slips-out-of-a-net-10-llm-3o24</guid>
      <description>&lt;p&gt;The idea is simple, and I liked it immediately. Before a chat message goes to the model, swap every email, phone number, IBAN and card for a typed placeholder like &lt;code&gt;[EMAIL_1]&lt;/code&gt;. Keep the mapping in a per-conversation vault on your side. When the answer comes back, swap the values back in. The model still knows "this is an email, the same one as before", which is all it needs to call &lt;code&gt;lookup_customer&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I built that as a &lt;code&gt;DelegatingChatClient&lt;/code&gt; on &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; 10.11.0 for a support assistant. Masking the user's message took an afternoon. Then I started checking what actually left the process, and found personal data in places I hadn't thought of as "the prompt" at all.&lt;/p&gt;

&lt;p&gt;Here are the seven places, roughly in the order I found them. 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 PII shield 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;
  
  
  Place 1: Round two of function calling
&lt;/h2&gt;

&lt;p&gt;My first version masked user text and rehydrated tool arguments, so &lt;code&gt;lookup_customer&lt;/code&gt; got the real email. Correct. Then &lt;code&gt;FunctionInvokingChatClient&lt;/code&gt; built the next request to the model. It contained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the assistant's function call, with the arguments it actually ran with (the &lt;strong&gt;real&lt;/strong&gt; email)&lt;/li&gt;
&lt;li&gt;the tool result, raw: the customer's name and phone number&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the second round carried the email back to the provider inside the assistant's own tool call. Masking "the prompt" isn't enough. The shield masks every message on every round: user text, earlier assistant turns, function-call arguments, function results, the instructions, even &lt;code&gt;AuthorName&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Where the shield sits decides most of this:&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;new&lt;/span&gt; &lt;span class="nf"&gt;ChatClientBuilder&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;UseFunctionInvocation&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;CappedLoggerFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loggerFactory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;// outermost: tools get real values&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UsePiiShield&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;auditor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                                    &lt;span class="c1"&gt;// masks every round&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;// innermost: only sees placeholders&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first &lt;code&gt;Use&lt;/code&gt; is the outermost client. Tools run on your server and need the real email, so function invocation stays outside. Anything the shield can't scan, like an image in a &lt;code&gt;DataContent&lt;/code&gt;, is blocked by default, because no regex reads a photo of a passport.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place 2: Tool results, after JSON escaping
&lt;/h2&gt;

&lt;p&gt;Tool results come back as JSON. The obvious move is to serialize and run your detectors over the string. Here's default &lt;code&gt;System.Text.Json&lt;/code&gt; output for a customer called Mariam:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"name":"\u0645\u0631\u064A\u0645 \u062D\u0633\u0646","phone":"\u002B20 122 555 0199"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The name is escaped into &lt;code&gt;\u&lt;/code&gt; sequences and the plus sign too. A text scan finds &lt;code&gt;122 555 0199&lt;/code&gt; without the country code, and no name at all. The model decodes the escapes just fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; walk the JSON and mask each value on its own, then write the result with the relaxed encoder. The engine's &lt;code&gt;MaskToolResult&lt;/code&gt; turns that payload into &lt;code&gt;{"name":"[PERSON_1]","phone":"[PHONE_1]"}&lt;/code&gt;. A card sent as a JSON number becomes a token string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place 3: Your logs
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;LoggingChatClient&lt;/code&gt; logs full message contents at Trace. Put it inside the shield and Trace shows placeholders. Put it outside and it shows the customer. In the demo I ran both orders on one message: inside, no raw email in the log; outside, the email is right there.&lt;/p&gt;

&lt;p&gt;The sneaky one is &lt;code&gt;FunctionInvokingChatClient&lt;/code&gt;. It has to stay outside the shield, and at Trace it logs every tool argument and every tool result, raw. A log filter in &lt;code&gt;appsettings.json&lt;/code&gt; works until someone edits &lt;code&gt;appsettings.json&lt;/code&gt;. I'd rather make it impossible, so that client gets a logger factory that refuses anything below Debug:&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;CappedLoggerFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ILoggerFactory&lt;/span&gt; &lt;span class="n"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LogLevel&lt;/span&gt; &lt;span class="n"&gt;minimum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ILoggerFactory&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;ILogger&lt;/span&gt; &lt;span class="nf"&gt;CreateLogger&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;categoryName&lt;/span&gt;&lt;span class="p"&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="nf"&gt;Capped&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="nf"&gt;CreateLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;categoryName&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;minimum&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Capped.IsEnabled(level) =&amp;gt; level &amp;gt;= minimum &amp;amp;&amp;amp; inner.IsEnabled(level)&lt;/span&gt;
    &lt;span class="c1"&gt;// (the wrapper and the rest of ILoggerFactory 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;Telemetry is safer by default: &lt;code&gt;OpenTelemetryChatClient.EnableSensitiveData&lt;/code&gt; is off unless the &lt;code&gt;OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT&lt;/code&gt; environment variable says otherwise. Know that variable exists before somebody sets it on the cluster while debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place 4: The model's own answer
&lt;/h2&gt;

&lt;p&gt;This leak goes the other way, towards the user's screen. The vault knows the real card number, so a naive "put everything back" step will print it in full if the model writes &lt;code&gt;[CARD_1]&lt;/code&gt;. In my demo the offline model tried exactly that: "The card on file is [CARD_1]. You can print it as [CARD_1], written exactly like that."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; a reveal policy decided by the vault entry, never by the model's text:&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;RevealPolicy&lt;/span&gt; &lt;span class="n"&gt;Default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&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;new&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;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;Kind&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;PiiKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Card&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Reveal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Partial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                          &lt;span class="c1"&gt;// "card ending 1111"&lt;/span&gt;
    &lt;span class="n"&gt;PiiKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Iban&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Origins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HasFlag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PiiOrigin&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="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Reveal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Partial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// came from a tool, not the user&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Reveal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Full&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 user saw "card ending 1111" twice. A prompt injection can change what the model writes. It can't change what the vault says. Tool arguments are not subject to this policy; tools run on your side and get the real value.&lt;/p&gt;

&lt;p&gt;Streaming adds a twist here: the provider decides where chunks end, so &lt;code&gt;[PER&lt;/code&gt; and &lt;code&gt;SON_1]&lt;/code&gt; can arrive separately. A small &lt;code&gt;StreamingRehydrator&lt;/code&gt; holds back only the tail after the last unclosed &lt;code&gt;[&lt;/code&gt;, up to the longest placeholder (15 characters), so the user never sees half a token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place 5: Placeholders nobody issued
&lt;/h2&gt;

&lt;p&gt;Two cases I didn't plan for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The user types a token.&lt;/strong&gt; If someone types &lt;code&gt;[EMAIL_1]&lt;/code&gt;, it could collide with a real entry. The shield rewrites typed tokens to &lt;code&gt;(EMAIL_1)&lt;/code&gt;, because the token namespace belongs to the vault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model invents a token.&lt;/strong&gt; Models hallucinate placeholders too. A token the vault never issued, like &lt;code&gt;[EMAIL_99]&lt;/code&gt; or one from another conversation, renders as &lt;code&gt;[unavailable]&lt;/code&gt; and is counted, so you can alert on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lower-cased tokens like &lt;code&gt;[email_1]&lt;/code&gt; still resolve, because models do that. Bare &lt;code&gt;EMAIL_1&lt;/code&gt; without brackets doesn't. Guessing is how one customer's address ends up on another customer's screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place 6: Values the detectors refuse to mask
&lt;/h2&gt;

&lt;p&gt;Detection is deliberately strict: IBANs only when the mod-97 check passes, cards only when Luhn passes. That keeps garbage out of the vault. It also means a customer who types their IBAN with one wrong digit gets it sent to the provider unmasked. It's still somebody's bank account, give or take a digit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; a second, cruder net after masking. An &lt;code&gt;EgressScanner&lt;/code&gt; looks for the &lt;em&gt;shape&lt;/em&gt; of personal data in the outbound payload, ignoring placeholders and ISO dates:&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;IEnumerable&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;Scan&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;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// strip placeholders, ISO dates and allow-listed patterns first&lt;/span&gt;
    &lt;span class="c1"&gt;// then report "EmailLike", "IbanLike" and "LongNumber" (9+ digits) findings, names only, never values&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;If anything turns up, the request isn't sent. The demo output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;blocked: Outbound request still looks like it contains personal data (IbanLike, LongNumber); not sent.
requests that reached the model: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API answers with a 422 and finding names. The price is false positives like a 12-digit tracking number, which is what the allow-list is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place 7: The audit trail
&lt;/h2&gt;

&lt;p&gt;You'll want to know how much personal data went through and whether it's the same customer as yesterday. The careless version logs the values "for debugging", and now your audit store is the leak.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; record counts per kind and keyed fingerprints instead, using the HMAC redactor from &lt;code&gt;Microsoft.Extensions.Compliance.Redaction&lt;/code&gt;:&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;AddRedaction&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;SetHmacRedactor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;o&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;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KeyId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuditHmacKeyId&lt;/span&gt;&lt;span class="p"&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;Key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;// key from your secret store&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DataClassificationSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PiiClassifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Personal&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 email gives the same fingerprint in two conversations, and the email itself is never stored. One trap from my spike: &lt;code&gt;NullRedactor&lt;/code&gt; returns its input unchanged, so never let it become the fallback for personal data.&lt;/p&gt;

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

&lt;p&gt;It's pseudonymization, not anonymization. Under GDPR, data you can re-identify with extra information (your vault) is still personal data (Recital 26). What you get is less exposure: the provider, the logs and the traces see tokens.&lt;/p&gt;

&lt;p&gt;Placeholders also break one thing on purpose: dates are personal data too, so by default they become tokens, and then the model can't tell whether an order is inside a 30-day return window. The project includes two ways around that, and the PDF guide covers them in a chapter of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-flight checklist for a PII shield
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Every message is masked on every round, not only the user's text&lt;/li&gt;
&lt;li&gt;[ ] Function invocation is outside the shield, logging inside&lt;/li&gt;
&lt;li&gt;[ ] Tool results are masked per JSON value, not as escaped text&lt;/li&gt;
&lt;li&gt;[ ] The function-invoking client can't log below Debug&lt;/li&gt;
&lt;li&gt;[ ] The vault entry, not the model, decides how much the user sees&lt;/li&gt;
&lt;li&gt;[ ] Typed tokens are neutralized, unknown tokens resolve to nothing&lt;/li&gt;
&lt;li&gt;[ ] An egress scan blocks anything that still looks like personal data&lt;/li&gt;
&lt;li&gt;[ ] The audit trail holds counts and HMAC fingerprints, never values&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The project has 231 passing tests (207 unit, 24 &lt;code&gt;WebApplicationFactory&lt;/code&gt; integration tests), including the real OpenAI adapter against a local fake endpoint, and none needs an API key. A 12-scene CLI demo runs every place above against an offline model. The library behaviors I mention were checked with spikes against &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; 10.11.0 and .NET 10 and are pinned as tests. Names are detected from a list you already have (a CRM, for example), so strangers' names need a NER model as an extra detector.&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 detectors, the per-conversation vault, the &lt;code&gt;PseudonymizingChatClient&lt;/code&gt; with streaming, the reveal policy, token neutralization, the egress scanner, the capped function-invocation logging, HMAC audit fingerprints, a minimal API with SSE streaming, the demo and all 231 tests, plus a PDF guide that goes much deeper than this post, including the full answer to the date problem, with code. 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 would your current setup have missed?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>ai</category>
      <category>security</category>
    </item>
    <item>
      <title>Faking a Yes: 6 Attacks I Ran Against the Human Confirmation in My .NET 10 MCP Server</title>
      <dc:creator>MICHAEL-MAURICE</dc:creator>
      <pubDate>Sat, 31 Oct 2026 12:59:18 +0000</pubDate>
      <link>https://dev.to/michael_maurice/faking-a-yes-6-attacks-i-ran-against-the-human-confirmation-in-my-net-10-mcp-server-2174</link>
      <guid>https://dev.to/michael_maurice/faking-a-yes-6-attacks-i-ran-against-the-human-confirmation-in-my-net-10-mcp-server-2174</guid>
      <description>&lt;p&gt;A confirmation step is only worth something if nobody but the human can produce the "yes". That sounds obvious, and it's easy to get wrong once the "yes" travels over a protocol.&lt;/p&gt;

&lt;p&gt;I built a small refund desk as an MCP server on ASP.NET Core with the official C# SDK (&lt;code&gt;ModelContextProtocol&lt;/code&gt; 2.3.0). The &lt;code&gt;issue_refund&lt;/code&gt; tool doesn't trust the model to confirm anything. It pauses and asks the person through MCP elicitation: a form with a checkbox, a reason, and for amounts over 100 EUR a field where you type the amount back. Since the 2026-07-28 spec this works on stateless servers too, through Multi Round-Trip Requests (MRTR).&lt;/p&gt;

&lt;p&gt;Then I switched sides and tried to fake that "yes". These are the six attacks I ran, what happened, and what the server does now. The code is cut down to the shape, and the cut 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 MCP refund desk 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;
  
  
  First, the flow I was attacking
&lt;/h2&gt;

&lt;p&gt;On a stateless server the tool can't wait for an answer. It ends the call with &lt;code&gt;input_required&lt;/code&gt;, a form to show, and an opaque &lt;code&gt;requestState&lt;/code&gt;. The client shows the form, then calls the same tool again with the same arguments, the answers in &lt;code&gt;inputResponses&lt;/code&gt;, and &lt;code&gt;requestState&lt;/code&gt; echoed back. That second call can land on any instance.&lt;/p&gt;

&lt;p&gt;In C# a tool asks by throwing. The shape of my tool:&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;McpServerTool&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="s"&gt;"issue_refund"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Destructive&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;Idempotent&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="k"&gt;public&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;CallToolResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;IssueRefund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;McpServer&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RequestContext&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CallToolRequestParams&lt;/span&gt;&lt;span class="p"&gt;&amp;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;ClaimsPrincipal&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="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;orderId&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="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&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;ix&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;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;server&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="c1"&gt;// reads and verifies requestState on a retry (attacks 1 and 2)&lt;/span&gt;
    &lt;span class="c1"&gt;// ... order checks, the refusal for clients that can't show forms, building the form (omitted)&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;ix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsFirstCall&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;ix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"confirm"&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;FormInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// becomes input_required + a sealed requestState&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;outcome&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"confirm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// validated answer (attacks 3 to 5)&lt;/span&gt;
    &lt;span class="c1"&gt;// ... declined / cancelled / re-ask, then execute exactly once&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;Notice that both halves of the "yes" come from the client: the answers and the state. That's where every attack below aims.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack 1: Edit the state, keep the approval
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What I did:&lt;/strong&gt; approved a 25.00 EUR refund, then sent the retry with the same &lt;code&gt;requestState&lt;/code&gt; but &lt;code&gt;amount: 64.90&lt;/code&gt; in the arguments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happened at first:&lt;/strong&gt; my first spike kept plain JSON in &lt;code&gt;requestState&lt;/code&gt;, like most samples do. The tool read the confirmation and applied it to the new amount. The MRTR spec says servers MUST treat &lt;code&gt;requestState&lt;/code&gt; as attacker-controlled input, and this is why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What stops it now:&lt;/strong&gt; the state is sealed with ASP.NET Core Data Protection (authenticated encryption) and bound to the exact call it belongs to:&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;RequestStateEnvelope&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;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;InteractionId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Tool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ArgumentsDigest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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 the canonical arguments&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&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="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;          &lt;span class="c1"&gt;// the user who started the call&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Round&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&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="n"&gt;required&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;ExpiresAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ... version, per-round nonce, form fingerprints, carried answers&lt;/span&gt;
    &lt;span class="c1"&gt;// (JSON names and the full record 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 digest is computed over sorted keys with normalized numbers, so &lt;code&gt;25.00&lt;/code&gt; and &lt;code&gt;25&lt;/code&gt; match but &lt;code&gt;64.90&lt;/code&gt; doesn't. The retry now fails with &lt;code&gt;-32602: requestState does not match these arguments&lt;/code&gt; before any answer is read. Flipping one character of the sealed text gets &lt;code&gt;requestState failed verification&lt;/code&gt;, and a state older than ten minutes gets &lt;code&gt;requestState expired&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack 2: Replay someone else's approval
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What I did:&lt;/strong&gt; took Alice's valid, accepted state and sent it as Bob.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What stops it:&lt;/strong&gt; the &lt;code&gt;Subject&lt;/code&gt; binding. On every retry the server unseals and checks all the bindings at once:&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;check&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sealer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Unseal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;digest&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsValid&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;McpProtocolException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;RejectionMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;McpErrorCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvalidParams&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bob gets &lt;code&gt;requestState was issued to a different user&lt;/code&gt;. A related trick, sending &lt;code&gt;inputResponses&lt;/code&gt; with no state at all, is refused too, because nothing proves which question those answers belong to.&lt;/p&gt;

&lt;p&gt;One operational note: every instance that might receive a retry needs the same Data Protection key ring and application name. Otherwise a perfectly honest retry fails verification on the other box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack 3: Answer the form like a raw JSON client
&lt;/h2&gt;

&lt;p&gt;Real MCP clients render the form nicely. An attacker doesn't need a real client. I sent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;"action": "ACCEPT"&lt;/code&gt; in capitals&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;accept&lt;/code&gt; with &lt;code&gt;confirm: false&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;accept&lt;/code&gt; with an extra &lt;code&gt;amount: 9999&lt;/code&gt; field inside the form content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I learned:&lt;/strong&gt; the SDK's &lt;code&gt;ElicitResult.IsAccepted&lt;/code&gt; is case-insensitive, so &lt;code&gt;"ACCEPT"&lt;/code&gt; passes it. Don't use it as a consent check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What stops it:&lt;/strong&gt; an &lt;code&gt;ElicitResponseValidator&lt;/code&gt; that checks the answer against the same &lt;code&gt;FormSpec&lt;/code&gt; that produced the schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;only lowercase &lt;code&gt;accept&lt;/code&gt;, &lt;code&gt;decline&lt;/code&gt; and &lt;code&gt;cancel&lt;/code&gt; count as actions; anything else is treated as cancel&lt;/li&gt;
&lt;li&gt;required fields, types, lengths, ranges and enum values are checked on the server&lt;/li&gt;
&lt;li&gt;fields the server didn't ask for are ignored and reported, never bound&lt;/li&gt;
&lt;li&gt;an accepted form with &lt;code&gt;confirm: false&lt;/code&gt; is not consent, so the tool asks again with a hint&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because form and validator share one spec, they can't drift apart when someone adds a field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack 4: Click Accept on an empty form
&lt;/h2&gt;

&lt;p&gt;This one needed no hostile client at all, which makes it the scariest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I did:&lt;/strong&gt; gave the confirm checkbox &lt;code&gt;default: true&lt;/code&gt; (it looked friendly), then clicked Accept without touching anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happened:&lt;/strong&gt; the SDK client fills schema defaults into an accepted response for fields the user left empty. That's useful for a "close reason" that defaults to &lt;code&gt;completed&lt;/code&gt;. On a consent checkbox it turns an empty click into consent on the wire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What stops it:&lt;/strong&gt; consent fields are required and have no default, and a linter checks every form the server can produce when the app starts:&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;FieldSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"confirm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Issue this refund"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;required&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;consentBearing&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="c1"&gt;// at startup: lint every form, throw if any finding is an error&lt;/span&gt;
&lt;span class="c1"&gt;// (the 13 rules are in the complete project)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo run of the linter on a deliberately bad form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lint: EL002 Error [confirm] Consent-bearing field has a default. The SDK client fills defaults into an accepted response, so an empty 'accept' would read as consent.
lint: EL011 Error [confirm] Consent-bearing field must be required.
lint: EL001 Error [cardPin] Looks like a secret or credential. Form mode MUST NOT collect these; use URL mode.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Attack 5: Approve something that no longer exists
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What I did:&lt;/strong&gt; opened the form ("120.00 EUR still refundable"), refunded 10 EUR as a colleague in another session, then accepted the original form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What stops it:&lt;/strong&gt; when the answer comes back, the server rebuilds the form from current data and compares its fingerprint with the one sealed in the state. If they differ, the user agreed to a form that's out of date, so the tool asks again and says "the form changed since it was shown".&lt;/p&gt;

&lt;p&gt;For bigger refunds the typed-back amount adds one more barrier. Ticking a box and typing "150" are different acts, and a wrong number gets the form back. The server also stops after 4 rounds with a normal tool result, so the assistant has something to explain instead of an exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack 6: Get a secret into the conversation
&lt;/h2&gt;

&lt;p&gt;Changing a payout account needs an IBAN. The tempting design is a form field called &lt;code&gt;iban&lt;/code&gt;. The elicitation spec says form mode MUST NOT ask for passwords, API keys, tokens or payment credentials, and the linter above flags fields like that (EL001).&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;change_payout_account&lt;/code&gt; uses URL mode. The tool asks the client to open a page on my server, the client shows the full host and asks before opening it, and the retry carries &lt;code&gt;accept&lt;/code&gt; with no content. The link has a random reference and no personal data, and the page checks that whoever opens it is the user who started the tool call. When Bob opened Alice's link he got a 403. In the demo, a recorder on the MCP connection captured the whole flow, and none of it contained the IBAN.&lt;/p&gt;

&lt;p&gt;Two SDK details: the server doesn't check the mode you send against what the client declared, and a client with a handler registered will receive URL requests it never declared. So the server checks capabilities before asking, and a form-only client gets a plain "needs a client that supports URL-mode elicitation" instead of a form fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  The attack I'm not answering here
&lt;/h2&gt;

&lt;p&gt;All six are about forging or bending the "yes". There's one more that doesn't need forgery: the honest double submit. Alice clicks Accept, the proxy times out, her client retries with the same valid state, and the two requests land on different instances a few milliseconds apart. A sealed, bound, unexpired state is still not a &lt;strong&gt;single-use&lt;/strong&gt; state. Making one approval move money exactly once across stateless instances, including after a crash halfway through the payment call, is a chapter of its own in the PDF that comes with the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  A red-team checklist for your own confirmation tool
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;requestState&lt;/code&gt; is sealed, not plain JSON or base64&lt;/li&gt;
&lt;li&gt;[ ] It's bound to the tool, the canonical arguments, the user and an expiry&lt;/li&gt;
&lt;li&gt;[ ] Answers with no state are rejected&lt;/li&gt;
&lt;li&gt;[ ] Actions are compared exactly; &lt;code&gt;IsAccepted&lt;/code&gt; is not your consent check&lt;/li&gt;
&lt;li&gt;[ ] Unknown fields are ignored, never bound&lt;/li&gt;
&lt;li&gt;[ ] Consent fields are required and have no default, enforced by a startup lint&lt;/li&gt;
&lt;li&gt;[ ] The form is rebuilt and compared before the answer is used&lt;/li&gt;
&lt;li&gt;[ ] Secrets go through URL mode with a same-user check, never a form&lt;/li&gt;
&lt;li&gt;[ ] Clients that can't show forms get a refusal for destructive tools, not a silent fallback&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The project has 285 passing tests (236 unit, 49 &lt;code&gt;WebApplicationFactory&lt;/code&gt; integration tests) and needs no API key. A 10-scene CLI demo runs every attack above against a real Kestrel host with a raw JSON-RPC client, plus the two-instance scene. The SDK behaviors I mention (case-insensitive &lt;code&gt;IsAccepted&lt;/code&gt;, default filling, URL requests reaching clients that didn't declare URL mode) came from spikes against 2.3.0 and are pinned as tests. What I haven't done is run it against every MCP client on the market, so test yours against the checklist.&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 sealed and bound &lt;code&gt;requestState&lt;/code&gt;, the form spec, validator and 13-rule linter, five MCP tools including the two-round callback and the URL-mode payout page, the fallbacks for older clients, the client-side elicitation gate, the minimal API, the demo and all 285 tests, plus a PDF guide that goes much deeper than this post and answers the single-use question in full, with code. 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 your current confirmation step let through?&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>mcp</category>
      <category>security</category>
    </item>
    <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>
  </channel>
</rss>
