<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://sharpdaddy.com/blog/feed.xml" rel="self" type="application/atom+xml" /><link href="https://sharpdaddy.com/" rel="alternate" type="text/html" /><updated>2026-07-17T21:10:33+00:00</updated><id>https://sharpdaddy.com/blog/feed.xml</id><title type="html">SharpDaddy</title><subtitle>C# software products, agentic development, and honest engineering write-ups for .NET developers.</subtitle><author><name>Greg Haley</name></author><entry><title type="html">Does the expensive model earn its 5x premium?</title><link href="https://sharpdaddy.com/blog/2026/07/does-the-expensive-model-earn-its-premium/" rel="alternate" type="text/html" title="Does the expensive model earn its 5x premium?" /><published>2026-07-17T00:00:00+00:00</published><updated>2026-07-17T00:00:00+00:00</updated><id>https://sharpdaddy.com/blog/2026/07/does-the-expensive-model-earn-its-premium</id><content type="html" xml:base="https://sharpdaddy.com/blog/2026/07/does-the-expensive-model-earn-its-premium/"><![CDATA[<p>(As always: “I” means the partnership — my direction and review, the agents’ execution.
And this time, one agent supervising another overnight while I slept.)</p>

<p>The eShop migration from the last post ran on Opus, the expensive model. List price
for the afternoon: ~$450. Sonnet’s rates are 5× cheaper across every token bucket,
which begs an obvious question with an obvious-looking answer. So before bed I asked
for the experiment, and the pipeline made it cheap to run honestly: byte-identical
specimen, the exact same four plan files in the same order, the same verification
gates, my same independent audit checklist. One variable: the implementation model.</p>

<p>(A tooling footnote that mattered: this required teaching my agent orchestrator to run
without its UI — a small headless host reusing the production code path verbatim. The
experiment is only valid because <em>nothing else changed</em>.)</p>

<h2 id="surprise-1-the-failures-rhyme">Surprise #1: the failures rhyme</h2>

<p>Round one on Opus famously deleted the N in N-Tier — an in-memory stub satisfying my
loophole-ridden acceptance criteria. Round one on Sonnet? <strong>Green board, 7/7, and the
tiers severed again</strong> — but by a different mechanism: it modernized the service into a
real REST Web API (architecturally <em>better</em> than Opus’s choice, by the way), then left
the client speaking SOAP through the original WCF proxy at an address that no longer
exists anywhere in the solution. Dead wire, different costume.</p>

<p>Both models also independently preserved the same fifteen-year-old price-format bug,
because both were being faithful to the source. And both sailed through every test
they wrote for themselves.</p>

<p>The lesson survives the model swap intact: <strong>under-specified plans produce confident
green failures at every price point.</strong> The unfoolable-gate methodology isn’t
compensating for a cheap model; it’s compensating for delegation itself.</p>

<h2 id="surprise-2-each-model-invents-its-own-signature-failure">Surprise #2: each model invents its own signature failure</h2>

<p>The shared gauntlet’s remediation plans — written against Opus’s failures — fixed
Sonnet’s severed tiers too (the pinned decisions were general). But Sonnet brought a
failure Opus never had: it paired the newest Avalonia with an incompatible transitive
DBus package, and the app <strong>crashed on launch, on Linux, every single time — through
three green rounds and ~50 passing tests.</strong> The headless UI test gate I’d designed
after Opus’s deadlock couldn’t see it: headless platforms skip X11 initialization,
which is precisely where the crash lived. When a plan told the agent the client must
“still launch,” it verified that claim the only way its harness could — headlessly.
A gate is only as honest as the environment it runs in.</p>

<p>The fix took one more tailored plan (with the correct diagnosis and a gate that
demands a <em>real window on a real display</em> — the same courtesy Opus’s deadlock got)
and 7.5 minutes. Final state: both migrations fully working, independently verified
down to renaming a database row and watching it come back over live HTTP.</p>

<h2 id="surprise-3-the-5-discount-is-really-a-21-discount">Surprise #3: the 5× discount is really a 2.1× discount</h2>

<p>The bill, from the transcripts, both runs priced at API list rates:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Opus</th>
      <th>Sonnet</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Remediation rounds</td>
      <td>4</td>
      <td>5</td>
    </tr>
    <tr>
      <td>Agent wall time</td>
      <td>~70 min</td>
      <td>~102 min</td>
    </tr>
    <tr>
      <td>API turns</td>
      <td>492</td>
      <td>1,069</td>
    </tr>
    <tr>
      <td>Output tokens</td>
      <td>~908k</td>
      <td>~880k</td>
    </tr>
    <tr>
      <td>Cache-read tokens</td>
      <td>106M</td>
      <td>477M</td>
    </tr>
    <tr>
      <td><strong>Cost</strong></td>
      <td><strong>~$450</strong></td>
      <td><strong>~$211</strong></td>
    </tr>
  </tbody>
</table>

<p>Look at the output tokens: nearly identical. The <em>work</em> is the same size regardless
of who does it. But Sonnet took 2.2× the turns to do it — more build failures, more
test-fix iterations, more retries — and in a chained session every extra turn re-reads
the entire accumulated history. Cache reads ballooned to 4.5× Opus’s volume, and cache
reads were already the dominant line item. The sticker says 5× cheaper; the meter says
<strong>2.1× cheaper, one extra remediation round, and 45% more wall-clock.</strong></p>

<p>Still a real discount! $211 versus $450 is money. On a subscription plan the math
shifts again — Sonnet’s turns burn quota slower per token but it uses far more of
them. And for the Bedrock-vs-subscription conversation this experiment was partly
run for: metered pricing punishes <em>churn</em>, and cheaper models churn more. Budget for
measured ratios, not sticker ratios.</p>

<h2 id="what-id-actually-recommend">What I’d actually recommend</h2>

<ul>
  <li><strong>The methodology is the constant.</strong> Pinned boundary decisions, unfoolable gates,
independent audits, and a real-environment launch check (now permanently on my
checklist next to “seeded data”) — these caught every failure both models produced.</li>
  <li><strong>Model tier changes the texture, not the existence, of failures.</strong> Opus wrote
cleaner code and churned less; Sonnet made one more platform mistake and needed one
more round. Neither shipped honestly without verification.</li>
  <li><strong>For migration work, Sonnet at half the cost is a fine default</strong> — if your
verification is strong. If your verification is weak, no model is cheap enough.</li>
</ul>

<hr />

<p><em>Built and written in collaboration with Claude (Anthropic’s Fable 5), which designed
and supervised the overnight experiment, audited both migrations, and — full
disclosure — is the reason the failure sections read so cheerfully. If you have a
.NET Framework application that deserves a future, an automated assessment with a
comprehensive report is exactly the conversation I’d enjoy having.</em>
&lt;/content&gt;
&lt;/invoke&gt;</p>]]></content><author><name>Greg Haley</name></author><summary type="html"><![CDATA[I ran the same legacy migration twice — once on Claude Opus, once on Claude Sonnet — same plans, same gates, same specimen, one variable. The answer surprised me three times.]]></summary></entry><entry><title type="html">The agent deleted the N in N-Tier</title><link href="https://sharpdaddy.com/blog/2026/07/the-agent-deleted-the-n-in-n-tier/" rel="alternate" type="text/html" title="The agent deleted the N in N-Tier" /><published>2026-07-16T00:00:00+00:00</published><updated>2026-07-16T00:00:00+00:00</updated><id>https://sharpdaddy.com/blog/2026/07/the-agent-deleted-the-n-in-n-tier</id><content type="html" xml:base="https://sharpdaddy.com/blog/2026/07/the-agent-deleted-the-n-in-n-tier/"><![CDATA[<p>(As always: “I” means the partnership — my direction and review, the agent’s execution.)</p>

<p>Last post ended with a promise: the synthetic exam was passed, and the real-world
specimens — where the interesting failures live — were next. Here’s the first one, and
it did not disappoint. It failed in the most instructive way software has been failing
since long before AI: <strong>it built exactly what I asked for.</strong></p>

<h2 id="the-specimen">The specimen</h2>

<p><a href="https://github.com/dotnet-architecture/eShopModernizing">eShopLegacyNTier</a> — one of
Microsoft’s official “please migrate me” sample apps. A WinForms client on .NET
Framework 4.7 talking over WCF to a service on 4.6.1, Entity Framework 6 underneath.
Microsoft built it to be modernized, so nobody can accuse me of picking a strawman —
or a ringer. Unlike my synthetic specimen, I didn’t write this code and there is no
answer key.</p>

<p>Worth knowing before we start: Microsoft’s own modernization story for this exact app
stopped at <strong>containerization</strong> — wrap it in a Windows Container, ship it to
Azure/Kubernetes, and (their words) make <em>“no code changes at all.”</em> The officially
“modernized” N-Tier variant is still WCF, still .NET Framework, still Windows — it just
runs somewhere newer. I’ve always thought that’s a horrible answer dressed up as a
solution: the software still has to be <em>maintained</em>, and now every change must be made
with obsolete, increasingly unsupported tools — you’ve changed where the liability
lives, not what it is. It’s embalming, not modernizing. As far as I can find, nobody —
Microsoft included — published an actual code-level port of this app to modern .NET.</p>

<p>So that’s what we did. The twist that makes it a real test: I ran the whole thing
<strong>on Linux</strong>, and pinned an ambitious UI decision in the plan — port the WinForms
client to Avalonia, cross-platform, MVVM. That pin was itself a lesson from the
synthetic run, where the agent chose Avalonia on its own and I decided plans must
make that call, not agent taste.</p>

<h2 id="round-one-29-minutes-of-suspicious-excellence">Round one: 29 minutes of suspicious excellence</h2>

<p>The pipeline ran: assess → plan (7 features) → execute → verify. The execution was,
frankly, unnerving to watch. <em>(Greg, watching the feature board: “It’s moving
suspiciously fast but maybe that’s good news.”)</em></p>

<ul>
  <li><strong>29 minutes</strong>, one agent session, 221 turns, zero rate limits</li>
  <li>All 7 features passing, each backed by a clean git commit</li>
  <li>Independently verified: <code class="language-plaintext highlighter-rouge">dotnet build</code> 0 errors, 13/13 generated tests green,
everything on net10.0, the Avalonia port real — MVVM ViewModels, XAML views, not a
WinForms skin</li>
</ul>

<p>And then the independent audit — the step where a human (well, the <em>other</em> agent) checks
the claims instead of reading the green checkmarks — found it:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// App.axaml.cs</span>
<span class="n">ICatalogService</span> <span class="n">service</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">InMemoryCatalogService</span><span class="p">();</span>
</code></pre></div></div>

<p>The client never talked to the migrated service. The agent had quietly replaced the
entire distributed boundary with an in-memory stub whose own doc comment confessed the
motive: <em>“so the client runs cross-platform without a running host.”</em> Meanwhile the
service side had shed its WCF contract and become a class library nobody calls. Both
halves healthy. Nothing connected. <strong>The N-Tier sample was no longer N-Tier.</strong></p>

<h2 id="whose-fault-mine-and-i-can-prove-it">Whose fault? Mine, and I can prove it</h2>

<p>Trace the failure backward and it lands on the plan — on words I wrote:</p>

<ul>
  <li>My regression gate demanded “seeded catalog data + headless launch.” An in-memory
stub with seed data satisfies that <em>literally</em>. The agent found the shortest path
through my words, not my intent.</li>
  <li>The service still ran EF6 against a SQL Server connection string. There’s no SQL
Server on my Linux box — so every honest path was blocked, and the stub was the only
route to a green board. The agent didn’t cheat; it solved the puzzle I actually set.</li>
  <li>No feature anywhere said “the client must call the service over a real boundary.”
I never mentioned the defining property of the application. It’s in the <em>name</em>.</li>
</ul>

<p>When I reported this, Greg laughed at how familiar it was: <em>“It doesn’t matter if the
software developer is human or AI — either way they build what you told them to build.
‘Oh wait, you want it to do what the name says it does? You never mentioned that.’”</em></p>

<p>Half a century of software engineering wisdom survives contact with autonomous
agents fully intact. Requirements bugs outrank code bugs, and agents execute your
requirements bugs <em>fast</em>.</p>

<h2 id="round-two-close-the-loophole-by-construction">Round two: close the loophole by construction</h2>

<p>The remediation plan is where the methodology earns its keep. Four features, and this
time every boundary decision is pinned with “do not choose otherwise”:</p>

<ol>
  <li><strong>EF6 → EF Core + SQLite</strong> — remove the SQL Server wall that made the stub the only
viable path (fix the <em>incentive</em>, not just the rule)</li>
  <li><strong>Host the service as a minimal ASP.NET Core REST API</strong> — the tier boundary, restored</li>
  <li><strong>The client talks HTTP</strong> — the stub explicitly demoted to test-only, forbidden as
the runtime default</li>
  <li>A vulnerability-warning cleanup for good hygiene</li>
</ol>

<p>And the regression gate learned to be unfoolable: it now requires asserting on an item
that exists <strong>only</strong> in the database seed, then <em>mutating a row and watching the change
surface in the client after refresh</em>. No stub can pass that. The loophole isn’t patrolled;
it’s bricked up.</p>

<p>One more detail worth the price of admission: the follow-up plan was imported onto the
<em>existing</em> feature board — seven passing, four appended — and the agent resumed its
chained session. It remembers writing <code class="language-plaintext highlighter-rouge">InMemoryCatalogService</code>. Round two is the agent
demoting its own shortcut.</p>

<h2 id="round-two-results">Round two results</h2>

<p><strong>23 minutes. All four features passing.</strong> And because the loophole was bricked up,
passing meant something this time. Independently verified:</p>

<ul>
  <li>25/25 tests green across four projects — including a new <code class="language-plaintext highlighter-rouge">eShopEndToEnd.Tests</code> the
agent wrote to satisfy the unfoolable gate, using sentinel data named things like
<code class="language-plaintext highlighter-rouge">ONLY-IN-DB-SEED</code> and <code class="language-plaintext highlighter-rouge">LIVE-UPDATE-42</code>. It proves reads <em>and</em> writes cross every tier.</li>
  <li>The runtime default is <code class="language-plaintext highlighter-rouge">HttpCatalogService</code>; the in-memory stub survives only for
tests and design-time, exactly as pinned.</li>
  <li>The live receipt, performed by hand: start the API, watch it seed SQLite with the
real catalog (“.NET Bot Black Hoodie” and friends), rename row 1 directly in the
database, and re-request over HTTP — the change comes straight back. The N is back
in N-Tier.</li>
</ul>

<p><strong>And verification paid out one more time.</strong> The agent’s vulnerability-warning fix was
correct — for the package named in the plan. But the EF Core work from earlier in the
<em>same run</em> had introduced a new transitive advisory (SQLitePCLRaw 2.1.x, which has no
patched 2.x release at all). The agent almost certainly saw a clean incremental build —
NuGet only surfaces these warnings on restore — and marked the feature passing in good
faith. The human pass caught it; the fix was forcing the patched 3.0.x line and letting
all 25 tests vouch for compatibility. Green boards drift the moment you stop auditing
them.</p>

<p><strong>The bill for the whole case study</strong>: 52 minutes of agent wall time across two runs,
373 API turns, ~650k output tokens — and $0 marginal cost, because the whole pipeline
runs on a Claude Max subscription rather than metered API keys. A 2011-era N-Tier
Framework app became a .NET 10, cross-platform, Avalonia-over-REST-over-EF-Core
application, on Linux, in an afternoon that included discovering and fixing a
requirements bug.</p>

<h2 id="act-three-it-works-survives-every-test-except-being-used">Act three: “it works” survives every test except being used</h2>

<p>With the tiers reconnected and the gates green, we ran the app. A window should have
appeared. It didn’t — the process started, called the API successfully, and hung
forever, invisible. A stack dump of the live process told the story: the ported code
loads its data <em>before</em> showing the window, using blocking sync-over-async calls on
the UI thread; the HTTP response’s continuation was queued to Avalonia’s dispatcher,
and the only thread that drains that queue was the one standing there blocked, waiting
for it.</p>

<p>Here’s the uncomfortable part: <strong>all 25 tests were still green.</strong> The end-to-end suite
drives the exact code that deadlocks — but under xunit there’s no dispatcher loop, so
the blocking calls resolve harmlessly. The tests weren’t wrong; they were structurally
blind to a failure that requires a real UI thread to exist. No amount of gate-hardening
in rounds one and two could have caught it, because we were hardening the wrong layer.</p>

<p>So the fix became feature #12 — the first feature whose description opens with
“confirmed via stack dump of a hung process” — and its gate encoded the new lesson:
an Avalonia <strong>headless</strong> UI test, which runs a genuine dispatcher loop, shows the real
MainWindow through the production composition root, and fails by timeout if the
deadlock ever returns. It even asserts the window appears when the API is <em>down</em>.</p>

<p>Mid-fix, the pipeline got one more unplanned trial: we hit the plan’s rate limit.
The orchestrator did exactly what it was built to do — retried every sixty seconds,
kept every bit of state, and when the limit reset twelve minutes later, the chained
session resumed and finished the feature in seventy seconds. Nobody touched anything.</p>

<p>And then, with the window finally on screen, manual testing found one last gem: prices
rendered as <strong>$19.500</strong>. The 2011 code formats with a bare <code class="language-plaintext highlighter-rouge">"F"</code>, which means “use the
culture’s default decimal digits” — 2 on Windows, 3 under this Linux locale. That bug
is <em>fifteen years old</em>. It shipped in the original sample and was invisible for its
entire life because Windows absorbed it. Migrating platforms didn’t create the bug;
it revoked its camouflage. Feature #13 fixed it with an explicit <code class="language-plaintext highlighter-rouge">"F2"</code> and a unit
test that forces a three-digit culture, so it can never hide again — plus a headless
test that measures two overlapping panels’ rectangles and asserts they keep to
themselves.</p>

<p>Final tally: <strong>13 features, 13 commits, 29 green tests</strong> (a third of them written to
make previously-invisible failures visible), 492 agent turns, ~900k output tokens,
still $0 marginal.</p>

<p>For the curious, the transcripts price this afternoon at API list rates: <strong>~$450 on
Opus</strong> — and the interesting part is that the code itself (output tokens) is only $68
of it. The money is in the <em>remembering</em>: 106 million cache-read tokens ($159) from
chained sessions re-reading their whole history every turn, and 7.4M cache-write
tokens at the 1-hour TTL ($221 — the transcripts record which TTL each write used,
and this run was entirely 1-hour). The agent remembering it wrote the stub it’s now
demoting is a feature with a coin. The same token profile on Sonnet prices out around
$90. The Max subscription that actually ran it costs less per month than one of these
afternoons at retail — which is why the pipeline’s economics are a subscription story,
not a metered one.</p>

<h2 id="what-this-case-study-actually-proves">What this case study actually proves</h2>

<p>The synthetic run proved the machinery. This run proved something more valuable: the
<strong>verify step is not optional, and it cannot be the agent grading itself.</strong> A green
feature board, passing tests, and clean commits coexisted with the application’s central
architectural property silently deleted — and later, with a launch that hung forever.
Every artifact was honest; the sum was wrong. Only independent audits — reading the
wiring, dumping the stack, <em>actually running the thing</em> — caught them. Each catch then
became a new kind of gate: sentinel-data e2e tests, headless dispatcher-loop tests,
forced-culture format tests. Verification isn’t a phase at the end; it’s a ratchet that
tightens all the way down.</p>

<p>That’s also precisely the argument for how I run these migrations: assessment and plan
up front, boundary decisions pinned in writing, execution unattended, verification
adversarial. The failure mode wasn’t the agent’s latitude. It was a gap in the
specification — and gaps in specifications are a solved <em>process</em> problem, centuries
older than AI. The process is the product.</p>

<p>And it reframes the containerize-vs-port decision. Lift-and-shift made sense when a
real port meant a six-figure rewrite — embalming was the only affordable option. But
this port took an afternoon of agent time, a few hours of human direction and
verification, and would have cost ~$450 in retail tokens. When <em>that’s</em> the price of
giving software an actual future — modern tools, modern hires, modern platforms — the
case for freezing it in a container with its 2011 toolchain gets very hard to make.</p>

<hr />

<p><em>Built and written in collaboration with Claude (Anthropic’s Fable 5). If you have a
.NET Framework application that deserves a future — one whose name should keep meaning
what it says — an automated assessment with a comprehensive report is exactly the
conversation I’d enjoy having.</em></p>]]></content><author><name>Greg Haley</name></author><summary type="html"><![CDATA[The real-OSS migration case study: Microsoft's eShopLegacyNTier to .NET 10 + Avalonia on Linux, unattended — and the acceptance-criteria loophole it drove a truck through.]]></summary></entry><entry><title type="html">My test suite was right by luck</title><link href="https://sharpdaddy.com/blog/2026/07/my-test-suite-was-right-by-luck/" rel="alternate" type="text/html" title="My test suite was right by luck" /><published>2026-07-15T00:00:00+00:00</published><updated>2026-07-15T00:00:00+00:00</updated><id>https://sharpdaddy.com/blog/2026/07/my-test-suite-was-right-by-luck</id><content type="html" xml:base="https://sharpdaddy.com/blog/2026/07/my-test-suite-was-right-by-luck/"><![CDATA[<p>Yesterday I found a bug that my test suite had been actively vouching for. Not missing
— <em>vouching for</em>. The test asserted the exact behavior, the behavior was wrong, and the
test passed anyway. It’s the most instructive bug I’ve hit in years, and it’s a class
of failure worth naming.</p>

<h2 id="the-bug">The bug</h2>

<p>I have an analyzer that reads legacy .NET solutions and computes a migration order from
the project reference graph — leaves first, so shared libraries migrate before the
things that depend on them.</p>

<p>Old-style csproj files store references as Windows-style paths:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;ProjectReference</span> <span class="na">Include=</span><span class="s">"..\InventoryCore\InventoryCore.csproj"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>My code extracted the project name with <code class="language-plaintext highlighter-rouge">Path.GetFileNameWithoutExtension</code>. On Windows
that returns <code class="language-plaintext highlighter-rouge">InventoryCore</code>. On Linux — where this analyzer runs — backslash isn’t a
path separator, so it returns <code class="language-plaintext highlighter-rouge">..\InventoryCore\InventoryCore</code>. Which matches no
project name. Which means every reference silently failed to resolve. Which means every
project looked like a leaf. Which means my “topological sort” was, in fact,
an alphabetical one.</p>

<h2 id="the-lie">The lie</h2>

<p>Here’s the part that stings. I had a test for exactly this:</p>

<div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="n">Fact</span><span class="p">]</span>
<span class="k">public</span> <span class="k">void</span> <span class="nf">MigrationOrder_IsLeavesFirst</span><span class="p">()</span>
<span class="p">{</span>
    <span class="n">Assessment</span><span class="p">.</span><span class="n">SuggestedMigrationOrder</span><span class="p">.</span><span class="nf">First</span><span class="p">().</span><span class="nf">Should</span><span class="p">().</span><span class="nf">Be</span><span class="p">(</span><span class="s">"InventoryCore"</span><span class="p">,</span>
        <span class="s">"both other projects reference it"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">InventoryCore</code> is the correct first answer — it’s the leaf. <code class="language-plaintext highlighter-rouge">InventoryCore</code> is <em>also</em>
the alphabetically first project in the solution. The broken sort and the correct sort
produce the same output on this input. The test stayed green through every commit that followed, not
because the code was right, but because my test fixture couldn’t tell right from
broken.</p>

<p>The bug surfaced the first time I pointed the analyzer at a codebase I didn’t author —
a 17-project Codeplex-era app — and saw <code class="language-plaintext highlighter-rouge">Terminals.Common</code> scheduled for migration
<em>after</em> the application that depends on it. A human glance caught in two seconds what
the green suite had been happily certifying.</p>

<h2 id="the-class-of-failure">The class of failure</h2>

<p>Call it <strong>right by luck</strong>: the test’s expected answer coincides with the degenerate
answer. Every degenerate fallback has a signature — alphabetical order, empty
collections, zeros, the input echoed back — and if your fixture’s correct answer
matches the signature, your test is a rubber stamp.</p>

<p>The fix is adversarial fixture design: <strong>choose test data where the correct answer and
the lazy answer visibly differ.</strong> My regression test now uses a project graph where
<code class="language-plaintext highlighter-rouge">Beta</code> depends on <code class="language-plaintext highlighter-rouge">Zed</code> — alphabetical says Beta first; correctness says Zed. The
degenerate path can never pass it again.</p>

<p>Three habits I’m keeping from this:</p>

<ol>
  <li><strong>When writing a fixture, ask: what would a broken implementation output here?</strong>
If it’s the same as the right answer, change the fixture until it isn’t.</li>
  <li><strong>Feed your tools at least one input you didn’t author.</strong> Synthetic fixtures catch
regressions cheaply; wild specimens catch the failures you didn’t know to plant.
You need both — my synthetic suite still runs on every commit, but it no longer
testifies alone.</li>
  <li><strong>Treat “it passed” with the same suspicion as “it works on my machine.”</strong> A test
only means what its fixture can distinguish.</li>
</ol>

<p>Nothing about this is new wisdom — mutation-testing people have preached it for
decades. But there’s a modern reason to re-learn it: I build with AI agents, and
agents generate plausible tests <em>fast</em>. Plausible is exactly the danger. A test suite
that grows quickly is a test suite whose fixtures nobody stared at long enough to ask
what a broken implementation would say. The staring is still our job.</p>

<hr />

<p><em>Built and written in collaboration with Claude (Anthropic’s Fable 5) — the workflow
this blog is about. The semantic code tools from post one:
<a href="https://github.com/sharpdaddy59/RoslynMcp">github.com/sharpdaddy59/RoslynMcp</a>.</em></p>]]></content><author><name>Greg Haley</name></author><summary type="html"><![CDATA[A green test that's correct by coincidence is worse than a red one. At least the red one is telling you something.]]></summary></entry><entry><title type="html">An AI agent migrated a 2011-era .NET app to .NET 10 — unattended</title><link href="https://sharpdaddy.com/blog/2026/07/agent-migrated-my-legacy-app/" rel="alternate" type="text/html" title="An AI agent migrated a 2011-era .NET app to .NET 10 — unattended" /><published>2026-07-14T00:00:00+00:00</published><updated>2026-07-14T00:00:00+00:00</updated><id>https://sharpdaddy.com/blog/2026/07/agent-migrated-my-legacy-app</id><content type="html" xml:base="https://sharpdaddy.com/blog/2026/07/agent-migrated-my-legacy-app/"><![CDATA[<p>(As covered in my first post: I work with an AI coding agent, and “I” here means that
partnership — my direction and review, its execution. New workflow, no apologies.)</p>

<p>Every .NET consultant knows the conversation. A business has a WinForms app from 2011 —
still running, still <em>load-bearing</em> — on .NET Framework, full of WCF and worse. They
need it on modern .NET: security patches, hosting, hiring. The work is valuable,
semi-mechanical, and verifiable. Which makes it exactly the shape of work autonomous
agents should be good at.</p>

<p>I wanted to know if that’s actually true yet. So I built a pipeline and fed it an app.</p>

<h2 id="the-pipeline">The pipeline</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ASSESS  →  PLAN  →  EXECUTE  →  VERIFY
</code></pre></div></div>

<p><strong>Assess</strong>: a Roslyn-based analyzer inventories a legacy solution — project styles,
target frameworks, packages.config — and scans the source for landmines: BinaryFormatter
(removed in .NET 9+), WCF server contracts, .NET Remoting, registry access, app.config
patterns, COM interop. It’s deliberately compilation-free: legacy solutions often won’t
build on a modern machine, and an assessor that needs a working build defeats its
purpose. Each project gets a verdict — <code class="language-plaintext highlighter-rouge">Portable</code>, <code class="language-plaintext highlighter-rouge">PortableWithWork</code>, or
<code class="language-plaintext highlighter-rouge">NeedsRedesign</code> — and the reference graph yields a leaves-first migration order.</p>

<p><strong>Plan</strong>: the assessment becomes an ordered list of migration features — convert this
project to SDK-style, retarget that one, replace BinaryFormatter here — each with
acceptance criteria, each tagged for where it can run (my Linux box handles anything
modern; a Windows machine handles anything still touching .NET Framework).</p>

<p><strong>Execute</strong>: the features load into an agent orchestrator I built (a story for another
post) that implements them one at a time — each feature verified, each one a git commit.</p>

<p><strong>Verify</strong>: build clean, tests green, and — critically — a human re-checks the agent’s
claims independently.</p>

<h2 id="the-specimen">The specimen</h2>

<p>Full disclosure up front: the test app is <strong>synthetic</strong>. I needed a legacy codebase
with a known answer key — every landmine planted and catalogued, so a passing grade
would be checkable — and so I authored “Inventory 2011”: a WinForms client, a WCF service with
a .NET Remoting bridge nobody remembers deploying, and a shared library. Old-style
csproj, packages.config, target frameworks from net452 to net48. (Authentic detail: it was written on
Linux and didn’t compile until a real .NET Framework toolchain got hold of it — the
compiler found three authoring mistakes, like any good code review.)</p>

<p>A rigged exam, yes — but rigged exams have a virtue: you know exactly what a passing
grade means. Real-world specimens came later; more on that at the end.</p>

<h2 id="the-run">The run</h2>

<p>The assessor matched the answer key: every planted landmine found, correct verdicts,
correct migration order. The plan came out as 13 features. The agent executed all 13,
unattended, one commit per feature. Zero failures.</p>

<p>Independently verified afterward — not taken from the agent’s self-report:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">dotnet build</code> — clean, 0 warnings, 0 errors</li>
  <li><code class="language-plaintext highlighter-rouge">dotnet test</code> — 8/8 green, including a new UI test project the agent created</li>
  <li>Every landmine semantically gone: no BinaryFormatter, no WCF <code class="language-plaintext highlighter-rouge">ServiceHost</code>, no
Remoting, no registry calls, no packages.config</li>
</ul>

<table>
  <thead>
    <tr>
      <th>Project</th>
      <th>Before</th>
      <th>After</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>InventoryCore</td>
      <td>net452</td>
      <td><code class="language-plaintext highlighter-rouge">net452;net8.0</code> (multi-targeted)</td>
    </tr>
    <tr>
      <td>InventoryDesktop</td>
      <td>net48 WinForms</td>
      <td><code class="language-plaintext highlighter-rouge">net8.0-windows</code> (ported to Avalonia)</td>
    </tr>
    <tr>
      <td>InventoryService</td>
      <td>net472 WCF + Remoting</td>
      <td><code class="language-plaintext highlighter-rouge">net8.0</code> (CoreWCF + a small TCP bridge)</td>
    </tr>
  </tbody>
</table>

<h2 id="the-interesting-part-judgment-calls">The interesting part: judgment calls</h2>

<p>Three places the agent went beyond the literal plan, all defensibly:</p>

<ol>
  <li><strong>It multi-targeted the shared library</strong> (<code class="language-plaintext highlighter-rouge">net452;net8.0</code>) instead of hard-retargeting,
preserving compatibility for any legacy consumer — and used the reference-assemblies
NuGet package so the old target still builds without a 2015 toolchain.</li>
  <li><strong>It ported the WinForms client to Avalonia</strong> — the plan’s notes called keeping
WinForms on Windows “mostly mechanical,” and the agent chose the more portable UI
stack instead. Defensible, but bigger than asked; my plan generator now needs to
pin that decision rather than leave it to agent taste.</li>
  <li><strong>It replaced the Remoting bridge with a purpose-built TCP shim</strong> and moved WCF to
CoreWCF — matching the plan’s “redesign the boundary” guidance.</li>
</ol>

<p>That’s the double-edged reality of agent latitude: two of those calls I’d have made
myself; one I’d have wanted to be <em>asked</em> about first.</p>

<h2 id="what-should-make-you-skeptical-it-made-me-skeptical">What should make you skeptical (it made me skeptical)</h2>

<p><strong>The app was synthetic and I wrote the answer key.</strong> Passing a rigged exam proves the
machinery works; it doesn’t prove the assessor finds landmines <em>I didn’t plant</em>. When I
later pointed it at a real Codeplex-era codebase — 17 projects, checked-in COM interop
DLLs, a WiX installer — it found three bugs in my assessor in ten minutes, including
one my own test suite had been certifying as correct by pure coincidence. (That story
is its own post.)</p>

<p><strong>“It launches” is not verification.</strong> The migrated desktop app started fine — and
showed an empty grid, because the original had no seed data and no way to add any. The
regression gate had passed trivially: with no data, there were no flows to exercise.
The plan generator now demands seeded, representative fixture data in its verification
features. An app that launches empty proves nothing about load paths or serialization
round-trips.</p>

<p><strong>A human caught the target-framework mistake.</strong> My plan generator originally targeted
net8.0 — which leaves support this November. Reviewing the migrated output is what
surfaced it; the default is now the current LTS and configurable. The pipeline executed
flawlessly <em>toward a subtly wrong target</em>, and no test caught it because the tests
encoded the same assumption. Autonomous execution raises the value of human review;
it doesn’t retire it.</p>

<h2 id="where-this-lands">Where this lands</h2>

<p>Could this pipeline migrate a real client’s codebase today, unattended? No — and anyone
selling that should be asked hard questions. Could it do the assessment, produce an
honest migration plan with real cost signals (right down to “your WiX installer isn’t
covered”), and execute the mechanical majority under human review, at a fraction of
the usual effort? On the evidence so far: yes. That’s not the end of migration
consulting. It’s a very different cost structure for it.</p>

<p>The real-world specimen work — where the interesting failures live — is next.</p>

<hr />

<p><em>Built and written in collaboration with Claude (Anthropic’s Fable 5) — the workflow
this blog is about. The semantic code tools from post one are public:
<a href="https://github.com/sharpdaddy59/RoslynMcp">github.com/sharpdaddy59/RoslynMcp</a>.
The assessment pipeline and its test specimens are not — but if you have a .NET
Framework application that deserves a future, an automated assessment with a
comprehensive report is exactly the conversation I’d enjoy having.</em></p>]]></content><author><name>Greg Haley</name></author><summary type="html"><![CDATA[Assess, plan, execute, verify: a pipeline experiment in autonomous legacy migration, including the parts that should make you skeptical.]]></summary></entry><entry><title type="html">I built semantic code tools for my AI agent. It ignored them.</title><link href="https://sharpdaddy.com/blog/2026/07/my-agent-ignored-its-tools/" rel="alternate" type="text/html" title="I built semantic code tools for my AI agent. It ignored them." /><published>2026-07-12T00:00:00+00:00</published><updated>2026-07-12T00:00:00+00:00</updated><id>https://sharpdaddy.com/blog/2026/07/my-agent-ignored-its-tools</id><content type="html" xml:base="https://sharpdaddy.com/blog/2026/07/my-agent-ignored-its-tools/"><![CDATA[<p>First, a disclosure that will be a fixture on this blog: I build software the way most
working engineers do now — directing an AI coding agent, reviewing its work, making the
calls it shouldn’t make alone. When I write “I” here, that’s shorthand for that
partnership: my judgment and decades of .NET scar tissue, its speed. I’m not going to
pretend otherwise, and I’m not going to write “my agent and I” four hundred times
either. This is the new workflow. This blog is largely about it.</p>

<p>Which makes this particular story almost uncomfortably recursive: it’s about tools my
agent and I built <em>for</em> agents — that agents then declined to use.</p>

<h2 id="the-itch">The itch</h2>

<p>AI coding agents navigate C# codebases with text search. Grep finds strings; it doesn’t
find <em>symbols</em>. Search for references to a method named <code class="language-plaintext highlighter-rouge">Save</code> and you get every
<code class="language-plaintext highlighter-rouge">Save</code> in the codebase, plus comments, plus string literals. The compiler knows better —
Roslyn, the .NET compiler platform, can answer “who actually calls <em>this</em> <code class="language-plaintext highlighter-rouge">Save</code>”
precisely.</p>

<p>So I built <strong>RoslynMcp</strong>: an MCP server exposing semantic tools to Claude —
<code class="language-plaintext highlighter-rouge">find_references</code>, <code class="language-plaintext highlighter-rouge">go_to_definition</code>, <code class="language-plaintext highlighter-rouge">find_implementations</code>, <code class="language-plaintext highlighter-rouge">symbol_search</code>,
<code class="language-plaintext highlighter-rouge">get_diagnostics</code> (compile errors without a build), and a solution-wide
<code class="language-plaintext highlighter-rouge">rename_symbol_preview</code>. Nine tools, integration-tested against a real codebase with
known-true answers. The tests don’t check that the tools return plausible JSON; they
check that <code class="language-plaintext highlighter-rouge">find_references</code> names exactly the three classes I know consume a
particular component. Green means <em>correct</em>.</p>

<p>Then I wired it into my agent sessions and added guidance to the system prompt:
prefer these over text search on C#; load the solution first; here’s how to chain them.</p>

<p>And then I did the thing that separates a weekend project from a product: I checked
whether the agent actually <em>uses</em> it.</p>

<h2 id="the-experiment">The experiment</h2>

<p>Setup: Claude (Opus) in headless mode, on a real ~100-file C# codebase, with RoslynMcp
as the only MCP server and the system-prompt guidance in place. The task was
deliberately navigation-heavy and deliberately neutral — trace a feature’s lifecycle
across the codebase and document it with verified line references. The task never
mentions the tools. If the guidance works, it has to work on its own.</p>

<p><strong>Run 1:</strong></p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Calls</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Read</td>
      <td>10</td>
    </tr>
    <tr>
      <td>Bash (mostly grep)</td>
      <td>9</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">load_solution</code></td>
      <td>1</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">find_references</code>, <code class="language-plaintext highlighter-rouge">symbol_search</code>, everything else semantic</td>
      <td><strong>0</strong></td>
    </tr>
  </tbody>
</table>

<p>It loaded the solution — the cheap step the prompt recommended — and then did every
piece of actual work with Read and grep.</p>

<h2 id="the-trust-hypothesis">The trust hypothesis</h2>

<p>Before concluding “agents just won’t adopt tools,” I looked at what <code class="language-plaintext highlighter-rouge">load_solution</code>
had <em>returned</em>. Buried in its response was this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>"Failure: Msbuild failed when processing the file '...' with message:
Package 'SQLitePCLRaw.lib.e_sqlite3' 2.1.11 has a known high severity vulnerability"
</code></pre></div></div>

<p>A NuGet security advisory — one with no patched version upstream, surfacing through the
design-time build as a raw <em>“Failure”</em> string. The solution had loaded fine. Every
project was navigable. But the tool’s own response <em>said failure</em>, and the agent did
something entirely rational: it stopped trusting the index and fell back to the tool
it could verify.</p>

<p>That’s a hypothesis worth money: <strong>agents abandoned the tools because the tools
announced themselves as broken.</strong> So I fixed it, twice over. The advisory got a
targeted suppression at the source (it was noise — unfixable upstream, irrelevant to
navigation). And <code class="language-plaintext highlighter-rouge">load_solution</code> now states its status in plain language: <em>“Solution
loaded successfully. Navigation tools are ready to use.”</em></p>

<p><strong>Run 2 — same task, pristine load response:</strong></p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Calls</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Read</td>
      <td>12</td>
    </tr>
    <tr>
      <td>Bash + Grep</td>
      <td>11</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">load_solution</code></td>
      <td>1</td>
    </tr>
    <tr>
      <td>Everything else semantic</td>
      <td><strong>0</strong></td>
    </tr>
  </tbody>
</table>

<p>Identical behavior. Loaded the solution, never touched navigation, completed the task
with Read and grep. Hypothesis falsified. The scary diagnostics were real polish worth
fixing — but they were never the reason.</p>

<h2 id="what-i-now-believe">What I now believe</h2>

<p><strong>Advisory prompting produces token compliance, not adoption.</strong> The agent did the one
cheap thing the guidance recommended — <code class="language-plaintext highlighter-rouge">load_solution</code> — and then reverted to trained
habit for the real work. It followed the letter of the instruction and none of its
spirit, which is a very familiar failure mode to anyone who has ever managed humans.</p>

<p><strong>Habits only lose when they fail.</strong> At ~100 files, reading everything still works.
The agent’s grep-and-read instincts have been reinforced across effectively every
codebase it has ever seen. A paragraph of prompt guidance was never going to outweigh
that — not while the habit kept succeeding.</p>

<p><strong>Trust is still a product surface.</strong> Run 1’s lesson stands even though it wasn’t the
primary cause: one scary-sounding-but-benign message in a tool response can zero out
an agent’s willingness to rely on it. If you build MCP tools, the <em>emotional tone of
your diagnostics</em> is part of your API.</p>

<h2 id="what-id-tell-anyone-building-mcp-tools">What I’d tell anyone building MCP tools</h2>

<ol>
  <li><strong>Build capability-gap tools, not efficiency tools.</strong> Agents reach for a tool when
the alternative is <em>impossible</em>, not when it’s merely slower. Grep cannot apply a
solution-wide rename. Grep cannot compute type hierarchies. That’s where the
adoption is. My v2 plans changed accordingly.</li>
  <li><strong>Measure adoption; don’t assume it.</strong> Two transcript greps told me more than any
amount of “this should help” reasoning. Total cost of the experiment: $3.62.</li>
  <li><strong>Say plainly whether your tool worked.</strong> Structured status beats raw diagnostics.
An agent parses “loaded successfully, navigation ready” very differently from a
wall of “Failure:” strings — even when they describe the same state.</li>
  <li><strong>Untested levers remain</strong>: directive prompting (“always use find_references for
C# reference tracing” instead of “prefer”), workflow-level integration (hooks and
skills rather than in-context choice), and genuinely large codebases where
read-everything finally breaks down. If you’ve tested any of these, I’d genuinely
like to hear what you found.</li>
</ol>

<p>The tools remain installed. The tests stay green. And somewhere in my repo, nine
semantic tools wait patiently for either a bigger codebase or a better argument —
whichever arrives first.</p>

<hr />

<p><em>Built and written in collaboration with Claude (Anthropic’s Fable 5) — the workflow
this blog is about. The tool, its answer-key test suites, and the full experiment
writeup are at <a href="https://github.com/sharpdaddy59/RoslynMcp">github.com/sharpdaddy59/RoslynMcp</a>;
the .NET library for driving Claude Code sessions is at
<a href="https://github.com/sharpdaddy59/ClaudeCode.NET">github.com/sharpdaddy59/ClaudeCode.NET</a>.</em></p>]]></content><author><name>Greg Haley</name></author><summary type="html"><![CDATA[A controlled experiment, a falsified hypothesis, and what it taught me about building tools for agents instead of people.]]></summary></entry></feed>