<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Shahin Kiassat — Blog</title><description>Shahin Kiassat — software engineer in London. Trading engines, agent orchestrators, code graphs, event pipelines: systems that have to prove what they did.</description><link>https://shaahink.github.io/</link><item><title>A live TUI in the AI era</title><link>https://shaahink.github.io/site/blog/a-live-tui-in-the-ai-era/</link><guid isPermaLink="true">https://shaahink.github.io/site/blog/a-live-tui-in-the-ai-era/</guid><description>The best AI tools ship terminal UIs, and suddenly everyone wants one. What .NET offers (Spectre.Console, praised and then audited), what Ink and Bubble Tea do differently, why Go keeps winning this niche — and the pattern I actually ship: a Go TUI attached to a .NET backend over SSE, with a working example.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Somewhere around 2025, the most advanced software interface on your machine
quietly became… a terminal app. Claude Code, opencode, gemini-cli, codex —
the AI tools everyone actually uses all ship as TUIs: live panes, streaming
transcripts, spinners, modals, keyboard everything. Not a web dashboard in
sight. The web spent fifteen years teaching us that serious UI means a
browser; the agent era spent about six months un-teaching it.&lt;/p&gt;
&lt;p&gt;The reasons are practical, not aesthetic — though the aesthetic isn&apos;t
hurting. A TUI runs where the work is: inside the SSH session, the
container, the repo. It has no port to forward, no build pipeline for its
front end, no login page. It starts in milliseconds, streams by nature, and
puts your hands where a developer&apos;s hands already are.&lt;/p&gt;
&lt;p&gt;So: you&apos;re sold, and you want one for your own tool — live, interactive,
the works. I&apos;ve now built this twice — once in C#, once in Go, for the same
system, &lt;a href=&quot;https://shaahink.github.io/site/projects#conductor&quot;&gt;my agent orchestrator&lt;/a&gt; — which qualifies me to
give you the tour with receipts. It ends with the combination I actually
ship, and a running example you can attach to.&lt;/p&gt;
&lt;h2&gt;What .NET gives you: Spectre.Console, honestly&lt;/h2&gt;
&lt;p&gt;If you live in .NET, the answer to &quot;how do I make my console output not look
like 1997&quot; is &lt;strong&gt;Spectre.Console&lt;/strong&gt;, and it deserves its reputation. Tables,
trees, panels, progress bars, prompts, markup like
&lt;code&gt;[green]pass[/]&lt;/code&gt; — output that looks designed with almost no effort. And for
live dashboards it has &lt;code&gt;Live&lt;/code&gt; rendering and a &lt;code&gt;Layout&lt;/code&gt; system:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var layout = new Layout(&quot;root&quot;).SplitRows(
    new Layout(&quot;header&quot;).Size(5),
    new Layout(&quot;body&quot;),
    new Layout(&quot;footer&quot;).Size(3));

await AnsiConsole.Live(layout).StartAsync(async ctx =&amp;gt;
{
    while (running)
    {
        layout[&quot;body&quot;].Update(RenderTranscript());
        ctx.Refresh();
        await Task.Delay(100);
    }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Conductor&apos;s built-in dashboard is exactly this, and it&apos;s genuinely good: a
full-screen live view with a stage sidebar, a streaming transcript, gate
timers, pop-out modals for thinking/output/git. Spectre rendered all of it
without complaint. If your TUI is a &lt;em&gt;dashboard with occasional keys&lt;/em&gt;, stop
reading, use Spectre, be happy.&lt;/p&gt;
&lt;p&gt;Now the audit. Because the moment your TUI becomes an &lt;em&gt;application&lt;/em&gt; —
focus, modals, scrolling, editing — you discover what Spectre is not.
Spectre is a &lt;strong&gt;rendering library&lt;/strong&gt;, not an application framework:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No component model.&lt;/strong&gt; A panel doesn&apos;t own state or handle input; it&apos;s a
thing you draw. Composition means functions that return renderables, and
every piece of interactivity lives somewhere else, by hand.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No input system.&lt;/strong&gt; There&apos;s no event loop, no focus, no &quot;the modal has
the keyboard now&quot;. Conductor&apos;s dashboard hand-rolls it: a
&lt;code&gt;ConcurrentQueue&amp;lt;ControlAction&amp;gt;&lt;/code&gt; fed by a raw key-reader thread, a
&lt;code&gt;Modal&lt;/code&gt; enum, manual scroll offsets, and a lock around the buffers so the
UI thread is the only reader. It works — but I wrote a tiny, bespoke,
undocumented UI framework and buried it in a dashboard class, which is
exactly the code smell it sounds like.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No reactivity.&lt;/strong&gt; You poll and redraw on a timer, or you build your own
invalidation. Ten-line demos redraw everything at 10 Hz and shrug;
fuller screens start to shear and flicker if you&apos;re careless.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There &lt;em&gt;is&lt;/em&gt; a retained-mode alternative — Terminal.Gui, an honest widget
toolkit with focus and events. But its native aesthetic is dialogs, menus
and text fields: a Turbo Vision descendant, forms-first, while the thing the
AI era wants is a live streaming dashboard with vim keys. You can bend it
there; you&apos;ll be bending.&lt;/p&gt;
&lt;p&gt;So .NET&apos;s real position: superb drawing, bring-your-own-architecture. Which
raises the question — what are the tools that &lt;em&gt;nailed&lt;/em&gt; the live TUI using?&lt;/p&gt;
&lt;h2&gt;How the AI tools actually do it&lt;/h2&gt;
&lt;p&gt;Two answers dominate, and neither is a coincidence.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ink&lt;/strong&gt; — React for the terminal, and the machinery under Claude Code.
Components, props, hooks, and a flexbox layout engine (Yoga) targeting
character cells. If you know React you already know Ink; &lt;code&gt;useState&lt;/code&gt; +
&lt;code&gt;useEffect&lt;/code&gt; on a streaming API and the transcript renders itself. The
snapshot-testing story (&lt;code&gt;ink-testing-library&lt;/code&gt;) is the best in the field.
The tax is the one you&apos;d guess: a Node runtime shipping inside your CLI,
and React&apos;s reconciliation overhead between you and the screen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bubble Tea&lt;/strong&gt; — Go, and the engine of an entire generation of terminal
tools (opencode among them, plus lazygit-adjacent everything from the charm
ecosystem). It implements &lt;strong&gt;the Elm architecture&lt;/strong&gt;, which is three types and
a promise:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type Model struct{ /* ALL the state, in one place */ }

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd)  // fold a message into the next state
func (m Model) View() tea.View                            // render the WHOLE screen from state
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every keypress, resize, tick and network event is a &lt;code&gt;Msg&lt;/code&gt;. &lt;code&gt;Update&lt;/code&gt; is the
only place state changes. &lt;code&gt;View&lt;/code&gt; is a pure function from model to screen —
no invalidation, no dirty flags, no &quot;who redraws this panel&quot;: the screen
&lt;em&gt;is&lt;/em&gt; a function of the state, recomputed each pass, diffed by the runtime.
Side effects (HTTP calls, timers) are &lt;code&gt;Cmd&lt;/code&gt;s that run off-loop and come back
as messages. It&apos;s the same discipline as
&lt;a href=&quot;https://shaahink.github.io/site/blog/designing-a-deterministic-kernel&quot;&gt;the reducer in my trading engine&lt;/a&gt; —
one queue, one fold, effects at the edges — which might explain why I took
to it: it&apos;s the deterministic kernel, wearing eyeliner.&lt;/p&gt;
&lt;p&gt;And Go itself keeps winning this niche for reasons that have nothing to do
with language aesthetics: &lt;code&gt;go build&lt;/code&gt; emits &lt;strong&gt;one static binary&lt;/strong&gt; — your TUI
is a 6 MB file that runs on a bare server with no runtime conversation
whatsoever; cross-compiling for the platform matrix is
&lt;code&gt;GOOS=linux go build&lt;/code&gt;; goroutines plus channels make &quot;pump three streams
into the event loop&quot; the boring case; and Lip Gloss gives Bubble Tea a
styling layer Spectre users would recognise and envy slightly.&lt;/p&gt;
&lt;h2&gt;The actual question: your backend is .NET&lt;/h2&gt;
&lt;p&gt;Here&apos;s the situation this post exists for. The system worth watching — the
orchestrator, the engine, the pipeline — is C#. It&apos;s staying C#. Nobody is
rewriting a working backend because a UI library is nicer somewhere else.
Meanwhile the best live-TUI toolchain is in Go. Choose one language and you
compromise a layer; the answer is to stop treating it as one program.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Split it.&lt;/strong&gt; The .NET process owns all state and does all work. The TUI
owns nothing: it renders events and sends commands. And the wire between
them is the most boring thing available:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Server-Sent Events&lt;/strong&gt; for backend → TUI. One direction of truth,
auto-reconnect semantics that amount to &quot;GET it again&quot;, and — this is the
killer feature over WebSockets for this job — &lt;strong&gt;&lt;code&gt;curl -N&lt;/code&gt; is a valid
client&lt;/strong&gt;. When something looks wrong you read the stream with your eyes.
No handshake upgrade, no framing, no client library.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Plain HTTP POST&lt;/strong&gt; for TUI → backend. Commands are small, rare and
request/response shaped. They were never streaming&apos;s problem to solve.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is Conductor&apos;s production shape: the C# orchestrator exposes a small
control plane; a Go companion app attaches to it over SSE — from another
terminal, another machine, or a phone over Tailscale.&lt;/p&gt;
&lt;h3&gt;The .NET half is now embarrassingly small&lt;/h3&gt;
&lt;p&gt;.NET 10 made SSE a first-class result type, which deletes what used to be a
page of hand-rolled &lt;code&gt;text/event-stream&lt;/code&gt; plumbing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app.MapGet(&quot;/run/events&quot;, (CancellationToken ct) =&amp;gt;
    TypedResults.ServerSentEvents(sim.Subscribe(ct)));

app.MapPost(&quot;/run/command&quot;, (Command cmd) =&amp;gt;
    sim.Apply(cmd.Action) ? Results.Ok() : Results.BadRequest());
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An &lt;code&gt;IAsyncEnumerable&amp;lt;SseItem&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; in, a correct event stream out. Behind
&lt;code&gt;Subscribe&lt;/code&gt; there&apos;s a broadcast hub of maybe thirty lines: a
&lt;code&gt;Channel&amp;lt;RunEvent&amp;gt;&lt;/code&gt; per subscriber, &lt;code&gt;TryWrite&lt;/code&gt; fan-out, and a ring buffer of
recent events &lt;strong&gt;replayed to every new subscriber&lt;/strong&gt; so a freshly attached TUI
paints a full screen instantly instead of starting from a blank stare. Late
joiners, reconnects after Wi-Fi blips, three TUIs at once — all the same
code path.&lt;/p&gt;
&lt;h3&gt;The Go half is a fold&lt;/h3&gt;
&lt;p&gt;The TUI&apos;s entire architecture is: one goroutine reads the SSE stream and
turns lines into messages; the Elm loop folds them.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;case eventMsg:
    m = m.apply(msg.ev)        // fold: stage board, feed, status
    return m, m.wait()         // re-arm: &quot;give me the next message&quot;

case tea.KeyPressMsg:
    switch msg.String() {
    case &quot;p&quot;: return m, sendCommand(m.url, &quot;pause&quot;)
    case &quot;r&quot;: return m, sendCommand(m.url, &quot;resume&quot;)
    case &quot;q&quot;: return m, tea.Quit
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;apply&lt;/code&gt; updates a stage board (&lt;code&gt;·&lt;/code&gt; pending, &lt;code&gt;▶&lt;/code&gt; running, &lt;code&gt;✗&lt;/code&gt; failed, &lt;code&gt;✓&lt;/code&gt;
delivered) and appends to the feed; &lt;code&gt;View&lt;/code&gt; re-renders everything from the
model; Lip Gloss joins the panels. There is no synchronisation anywhere in
the UI — the network goroutine and the render loop touch nothing shared,
because everything crosses as a message. Connection drops? The reader
reconnects with backoff and sends a &lt;code&gt;connMsg{false}&lt;/code&gt;; the header shows
&lt;code&gt;● offline&lt;/code&gt;; state keeps folding when the stream returns. The TUI is,
deliberately, the dumbest process I own — and therefore the one that never
breaks.&lt;/p&gt;
&lt;h2&gt;Try it&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/shaahink/blog-code/tree/main/go-tui-dotnet-backend&quot;&gt;companion sample&lt;/a&gt;
is the whole pattern in two small standalone halves — a .NET 10 backend
simulating a gated delivery run (agent chatter, a lying agent caught by its
gate battery, a fix session) and the Go TUI:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd backend &amp;amp;&amp;amp; dotnet run      # terminal 1 — the control plane
cd tui &amp;amp;&amp;amp; go run .            # terminal 2 — the face
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://shaahink.github.io/site/images/gated-delivery-tui.png&quot; alt=&quot;The Go TUI attached to the .NET backend over SSE: stage board on the left with all four stages delivered, live event feed on the right — agent chatter, green PASS gates, one red gate FAIL, and the fix session that recovers it.&quot; /&gt;&lt;/p&gt;
&lt;p&gt;That&apos;s it running — the red &lt;code&gt;FAIL&lt;/code&gt; line is the scripted lying agent being
caught by its gate battery, and everything below it is the fix session
cleaning up. Now be cruel to it, because the cruelty demonstrates the
architecture:
&lt;strong&gt;kill the TUI mid-run&lt;/strong&gt; — the backend doesn&apos;t notice, and reattaching
repaints the full board from the replay ring. Press &lt;code&gt;p&lt;/code&gt; and watch &lt;em&gt;the
backend&lt;/em&gt; pause while the stream stays live — pausing is backend state, not
UI state; a second attached TUI shows it too. And when you want to see the
truth without any UI at all: &lt;code&gt;curl -N http://127.0.0.1:5058/run/events&lt;/code&gt;.
The face is optional. The system is not.&lt;/p&gt;
&lt;h2&gt;What it costs, honestly&lt;/h2&gt;
&lt;p&gt;Two toolchains in one repo, with the idiom tax that implies — a .NET
engineer touching the face needs a day of Elm-architecture acclimatisation
(it&apos;s the good kind of weird). The wire contract needs the discipline any
API needs: version it, tolerate unknown fields, never rename in place — the
TUI treating unknown events as &quot;something to display verbatim&quot; has quietly
absorbed several backend changes. TUI testing is thinner than web testing —
Update-function unit tests are easy and good, &quot;does it &lt;em&gt;look&lt;/em&gt; right at
83×41&quot; is still eyeballs. And SSE buys its simplicity by being one-way; if
your UI&apos;s commands are chatty and latency-sensitive, that&apos;s the actual
argument for WebSockets, so use them with a clear conscience.&lt;/p&gt;
&lt;p&gt;None of these has made me miss the alternative — a browser dashboard for a
system whose entire audience lives in a terminal, or a C# TUI framework I&apos;d
have to invent first. The AI tools made the terminal the serious UI surface
again. Meet it with the stack that&apos;s serious about it: your backend where it
is, in .NET — and a face that&apos;s a single binary, a pure fold, and one
&lt;code&gt;curl&lt;/code&gt;-able stream away.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Runnable, attachable version:
&lt;a href=&quot;https://github.com/shaahink/blog-code/tree/main/go-tui-dotnet-backend&quot;&gt;&lt;code&gt;go-tui-dotnet-backend&lt;/code&gt;&lt;/a&gt;
in &lt;a href=&quot;https://github.com/shaahink/blog-code&quot;&gt;blog-code&lt;/a&gt; — the .NET 10 SSE
backend and the Bubble Tea face, each half standalone. The real thing:
&lt;a href=&quot;https://github.com/shaahink/conductor&quot;&gt;Conductor&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>go</category><category>dotnet</category><category>ai</category><category>tooling</category></item><item><title>Designing a deterministic kernel</title><link>https://shaahink.github.io/site/blog/designing-a-deterministic-kernel/</link><guid isPermaLink="true">https://shaahink.github.io/site/blog/designing-a-deterministic-kernel/</guid><description>Event-driven systems rot into callback soup where no two runs behave alike. Here&apos;s the cure I used in a trading engine: one queue, one pure reducer, one journal, one effect executor — and the discipline that makes two replays byte-identical.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;At 14:32 on a Tuesday, the engine sold. Nobody told it to. The strategy looked
right, the risk numbers looked right, and yet: sold. So you do what everyone
does — you re-run the day to watch it happen.&lt;/p&gt;
&lt;p&gt;It doesn&apos;t happen.&lt;/p&gt;
&lt;p&gt;Same code, same data, different behaviour. Congratulations: your system has
&lt;em&gt;interleavings&lt;/em&gt;, and one of them fired on Tuesday and a different one fired
just now. There is no bug to find because there is no execution to inspect.
The behaviour lives in the gaps between callbacks, and the gaps shuffle
themselves every run.&lt;/p&gt;
&lt;p&gt;This post is about the shape that kills that entire class of mystery. I built
it for a trading engine — an environment where &quot;we can&apos;t reproduce Tuesday&quot;
costs actual money — but nothing in it is about trading. It&apos;s about any system
that reacts to events and must be able to answer for itself afterwards.&lt;/p&gt;
&lt;h2&gt;The problem, laid out properly&lt;/h2&gt;
&lt;p&gt;An event-driven system accretes naturally into this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;market data arrives in a &lt;strong&gt;broker callback&lt;/strong&gt;, which updates some fields and
maybe fires an order,&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;timer&lt;/strong&gt; wakes up every second to check drawdown,&lt;/li&gt;
&lt;li&gt;order fills land on &lt;strong&gt;another callback&lt;/strong&gt;, on another thread,&lt;/li&gt;
&lt;li&gt;and an &lt;strong&gt;async continuation&lt;/strong&gt; from the last HTTP call resolves whenever it
feels like it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each piece is individually reasonable. Collectively they have no defined
order. The fill from bar 41 can arrive while the timer is halfway through
reading state that bar 42 is halfway through writing. Two runs over identical
data interleave differently, so testing proves little and replaying proves
nothing.&lt;/p&gt;
&lt;p&gt;The usual patch is a mutex and an apology. The actual fix is structural:
&lt;strong&gt;build the system so there is only one place where anything happens.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;The shape&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;tape event ─► queue ─► Kernel.Decide (PURE) ─► (state&apos;, effects[])
                           │                          │
                           ▼                          ▼
                      StepRecord ─► journal      effect executor (the ONLY I/O)
                                                 venue feedback (fills, account) ──┐
                                                     re-enqueued via the queue ◄───┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four parts, four jobs, no overlaps:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The tape&lt;/strong&gt; is wherever events come from — recorded bars on disk, or a live
socket feed. The kernel cannot tell the difference, and that indifference is
the whole point: the backtest and the live path run through the &lt;em&gt;same&lt;/em&gt; driver.
There is no replay-only fork of the logic to drift out of sync.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The reducer&lt;/strong&gt; is one pure function:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;EngineDecision Decide(EngineState state, EngineEvent evt);
// EngineDecision = (EngineState State, IReadOnlyList&amp;lt;Effect&amp;gt; Effects)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every decision the system will ever make goes through this signature. It does
no I/O, reads no clock, mints no ids. It takes the world as a value and
returns the next world as a value, plus a list of &lt;em&gt;intentions&lt;/em&gt; — &lt;code&gt;SubmitOrder&lt;/code&gt;,
&lt;code&gt;ClosePosition&lt;/code&gt;, &lt;code&gt;RecordDecision&lt;/code&gt; — as data.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The executor&lt;/strong&gt; is the only component allowed to touch reality. It walks the
effect list and does the dirty work: talks to the venue, writes to the event
bus, updates the progress sink. Crucially, when reality answers back — a fill,
an account update — the executor doesn&apos;t &lt;em&gt;handle&lt;/em&gt; it. It wraps the answer in
an event and puts it on the queue, where it goes through the same front door
as everything else.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The journal&lt;/strong&gt; gets one record per processed event: sequence number,
sim-time, the event itself as JSON, the effects it produced, and a snapshot of
the risk state. It is not logging. It&apos;s the single source of truth that the
report, the export, and the live monitor are all projections of.&lt;/p&gt;
&lt;h2&gt;The loop&lt;/h2&gt;
&lt;p&gt;The driver that pumps it all is small enough to hold in your head:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;foreach (var tapeEvent in tape)
{
    queue.Enqueue(tapeEvent);

    // Drain this event AND every feedback event it triggers
    // before pulling the next one from the tape.
    while (queue.TryDequeue(out var evt))
    {
        var decision = kernel.Decide(state, evt);   // pure
        state = decision.State;

        journal.Append(BuildStepRecord(++seq, evt, decision, state));

        foreach (var effect in decision.Effects)    // the only I/O
            await effects.ExecuteAsync(effect, ct);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The line that earns its keep is the inner &lt;code&gt;while&lt;/code&gt;. Call it
&lt;strong&gt;drain-before-advance&lt;/strong&gt;: when a bar produces a &lt;code&gt;SubmitOrder&lt;/code&gt;, and the venue
answers with a fill, that fill goes &lt;em&gt;back onto the same queue&lt;/em&gt; — and is fully
processed before the next bar is pulled from the tape. Consequences never
race their causes. However asynchronous the outside world is, inside the
kernel there is exactly one total order of events, every run, forever.&lt;/p&gt;
&lt;p&gt;Without that rule, bar N+1 races the fills of bar N and you&apos;re back to
interleavings. With it, the system is single-file: one event, one decision,
one journal record, next.&lt;/p&gt;
&lt;h2&gt;Determinism is a discipline, not a property&lt;/h2&gt;
&lt;p&gt;Here&apos;s the uncomfortable part. You don&apos;t get byte-identical replays by
declaring the reducer pure. You get them by hunting down every leak, and the
leaks are sneakier than you&apos;d think. A tour of the ones that actually bit me:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The id mint.&lt;/strong&gt; Deep inside position tracking, something called
&lt;code&gt;Guid.NewGuid()&lt;/code&gt; to label a new position. Perfectly innocent — and every
replay now produces different journal bytes, because the ids differ. The fix
was to stop minting: the position id &lt;em&gt;is&lt;/em&gt; the order id, which arrived on the
event, which came from the edge. Rule: if the kernel needs an id, the id
travelled in on the event.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The clock.&lt;/strong&gt; &lt;code&gt;DateTime.UtcNow&lt;/code&gt; in decision code means replays disagree with
history by definition. Time is data: every event carries the time it
occurred, and that is the only &quot;now&quot; the kernel is allowed to know. This has
a lovely side effect — a year-long backtest experiences a year of sim-time in
minutes, and nothing inside can tell.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The culture.&lt;/strong&gt; The journal serialises decimals. &lt;code&gt;12.5m.ToString()&lt;/code&gt; is
&lt;code&gt;&quot;12.5&quot;&lt;/code&gt; on my machine and &lt;code&gt;&quot;12,5&quot;&lt;/code&gt; on a machine that thinks in commas —
different bytes, broken diff. Everything the journal writes is formatted with
the invariant culture, because the determinism contract extends all the way
down to &lt;code&gt;ToString&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The impure question.&lt;/strong&gt; Some decisions genuinely need outside knowledge — is
there a news embargo right now? Is it the weekend? You can&apos;t call a news
service from a pure function. The move: the &lt;em&gt;edge&lt;/em&gt; computes those verdicts at
event time and attaches them to the event itself. The reducer applies
verdicts; it never asks questions. Purity is preserved, and so is the audit
trail — the journal shows exactly which verdicts the decision saw.&lt;/p&gt;
&lt;p&gt;Each of these is enforced by tests now, because each of them will be
reintroduced by someone (me) innocently improving something (anything). A
purity test that replays a fixed tape twice and compares hashes is the
cheapest insurance I own.&lt;/p&gt;
&lt;h2&gt;Config is not state&lt;/h2&gt;
&lt;p&gt;A subtler design decision: what &lt;em&gt;doesn&apos;t&lt;/em&gt; go in &lt;code&gt;EngineState&lt;/code&gt;. Risk limits,
sizing policy, symbol metadata — constant for the whole run — live in a
&lt;code&gt;KernelConfig&lt;/code&gt; the kernel closes over:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public sealed record KernelConfig(
    ConstraintSet Constraints,
    RiskProfile Profile,
    SizingPolicyOptions Sizing,
    Func&amp;lt;Symbol, SymbolInfo&amp;gt; ResolveSymbol,
    int Seed);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Only time-varying data — positions, drawdown, equity, protection status — is
state. The split keeps state small enough to snapshot and reason about, and it
makes the most useful operation in the whole system embarrassingly cheap:
&lt;strong&gt;same tape, different config.&lt;/strong&gt; That one sentence &lt;em&gt;is&lt;/em&gt; &quot;re-run this backtest
with a different risk profile&quot;. No new data plumbing, no export/import, one
function call with a different second argument.&lt;/p&gt;
&lt;h2&gt;Risk gets a veto&lt;/h2&gt;
&lt;p&gt;Inside the reducer, in front of every order, sits a gate. A strategy doesn&apos;t
submit orders; it &lt;em&gt;proposes&lt;/em&gt; them, and the proposal passes through a
pre-trade gate that owns sizing and the account&apos;s survival constraints —
daily, weekly, monthly and total drawdown, checked in severity order.&lt;/p&gt;
&lt;p&gt;Reject means reject: the journal records a decision event with the reason,
and no effect reaches the venue. And when equity breaches a limit, the gate&apos;s
big sibling takes over: the state enters &lt;em&gt;protection mode&lt;/em&gt;, every open
position gets a close effect, and nothing opens again until the boundary that
clears it (next day, next week) rolls past — also an event, also journaled.&lt;/p&gt;
&lt;p&gt;The gate produces one more thing while it&apos;s at it: a self-describing sizing
record — equity at gate time, drawdown scaling, raw and clamped lot size —
written into the journal. When a position is a quarter of the size you
expected, the answer is in the record, not in a debugger at 2 a.m.&lt;/p&gt;
&lt;p&gt;Risk-as-veto only works &lt;em&gt;because&lt;/em&gt; of the funnel. When every order passes
through one pure function, &quot;nothing bypasses risk&quot; is an architectural fact,
not a code-review aspiration.&lt;/p&gt;
&lt;h2&gt;What it buys&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Replay as a debugger.&lt;/strong&gt; Any surprising decision can be re-run with the
exact state that produced it. &quot;Why did it sell at 14:32?&quot; stopped being a
mystery and became a query: pull the step record, load the state, step
through the reducer. The answer takes minutes and is always there.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Diff as a regression test.&lt;/strong&gt; Same tape + same config ⇒ bit-identical
journal. So a refactor&apos;s safety check is: replay, hash, compare. If the hash
moved, behaviour moved — and the journal diff shows exactly which step
diverged first. The demo in the companion repo does this in three lines of
output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;replay 1 sha256 : 89F7787232D7CE37
replay 2 sha256 : 89F7787232D7CE37
byte-identical  : True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;A backtester that proves something.&lt;/strong&gt; Because live and backtest share the
driver, a backtest exercises the code that trades — not a parallel
implementation with parallel bugs. The only swapped part is the tape.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CI without credentials.&lt;/strong&gt; A simulated venue at the executor&apos;s edge plus
recorded tapes means the full decision path runs in CI, deterministic and
credential-free, on every push.&lt;/p&gt;
&lt;h2&gt;What it costs&lt;/h2&gt;
&lt;p&gt;The decision path is deliberately single-threaded: one event at a time, in
order. For a swing-trading engine, that&apos;s nothing — the queue idles between
bars. If you need microseconds, a funnel with a journal write per event is
not your design, and I&apos;d point you at the LMAX Disruptor literature instead —
same religion (single writer, event log), different performance class.&lt;/p&gt;
&lt;p&gt;The journal grows. One record per event, with event and effects as JSON, adds
up; you&apos;ll want retention rules and compaction eventually. I consider the
disk cheap and the answers priceless, but it&apos;s a real line item.&lt;/p&gt;
&lt;p&gt;And the purity contract needs &lt;em&gt;guarding&lt;/em&gt;. It is not self-enforcing. The type
system won&apos;t stop a colleague — or you, in eleven months — from adding an
innocent &lt;code&gt;DateTime.UtcNow&lt;/code&gt;. The replay-hash test will. Write it the same day
you write the reducer.&lt;/p&gt;
&lt;p&gt;The pure function was the idea. The queue, the journal and the discipline
around them are what made it an &lt;em&gt;engine&lt;/em&gt; — I wrote about the underlying
philosophy in &lt;a href=&quot;https://shaahink.github.io/site/blog/a-trading-engine-as-a-pure-function&quot;&gt;a trading engine as a pure
function&lt;/a&gt;, and this post is the
machinery that philosophy actually runs on.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Runnable, distilled version:
&lt;a href=&quot;https://github.com/shaahink/blog-code/tree/main/deterministic-kernel&quot;&gt;&lt;code&gt;deterministic-kernel&lt;/code&gt;&lt;/a&gt;
in &lt;a href=&quot;https://github.com/shaahink/blog-code&quot;&gt;blog-code&lt;/a&gt; — records a tape, replays
it twice, proves the journals byte-identical, then reruns it under a different
config for free.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>dotnet</category><category>architecture</category><category>trading</category></item><item><title>Fast event-driven IPC in .NET, with ZeroMQ and NetMQ</title><link>https://shaahink.github.io/site/blog/fast-event-driven-ipc-in-dotnet/</link><guid isPermaLink="true">https://shaahink.github.io/site/blog/fast-event-driven-ipc-in-dotnet/</guid><description>Two processes, one firehose of events, no broker. What ZeroMQ actually is, which socket pattern fits which job, real throughput numbers, the foot-guns that eat your messages silently — and the lock-step protocol that buys determinism over a socket.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Sooner or later two of your processes need to talk. Properly talk — not &quot;poll
a REST endpoint every second&quot;, but &lt;em&gt;events, as they happen, at rate&lt;/em&gt;. Market
ticks into a trading engine. Telemetry out of a game server. Frames between a
capture process and an encoder.&lt;/p&gt;
&lt;p&gt;The industry reflex is to reach for a broker: RabbitMQ, Kafka, Redis
pub/sub. Now you have infrastructure — something to install, monitor, patch
and explain to the on-call rota — standing between two processes that might
be &lt;em&gt;on the same machine&lt;/em&gt;. For durable, cross-team, audit-me messaging that&apos;s
a fine trade. For &quot;process A must feed process B, fast, now&quot;, it&apos;s a
mainframe to deliver a pizza.&lt;/p&gt;
&lt;p&gt;There&apos;s a forty-year-old idea sitting one abstraction lower: just sockets —
but sockets that know messaging patterns. That&apos;s ZeroMQ. This post is about
using it from .NET, where the story is unusually good, and about the sharp
edges you&apos;ll want to know about &lt;em&gt;before&lt;/em&gt; they eat your messages.&lt;/p&gt;
&lt;h2&gt;What ZeroMQ actually is&lt;/h2&gt;
&lt;p&gt;The name misleads in both halves: there&apos;s no queue &lt;em&gt;server&lt;/em&gt;, and there&apos;s no
broker of any kind. ZeroMQ is a library that gives you &lt;strong&gt;sockets with
opinions&lt;/strong&gt;. They look like BSD sockets, but:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;they carry &lt;strong&gt;messages&lt;/strong&gt; (discrete frames), never byte soup — no
length-prefix parsing, no &quot;did I get half a message?&quot;,&lt;/li&gt;
&lt;li&gt;each socket runs its own background I/O and &lt;strong&gt;reconnects forever&lt;/strong&gt; — you
can start the client before the server, unplug things mid-run, and the
socket quietly heals,&lt;/li&gt;
&lt;li&gt;each connection has an internal &lt;strong&gt;queue with a cap&lt;/strong&gt; (the high-water mark),
and — this is the part to tattoo somewhere visible — &lt;em&gt;what happens when
that queue fills is decided by the socket type&lt;/em&gt;,&lt;/li&gt;
&lt;li&gt;and each socket type implements a &lt;strong&gt;pattern&lt;/strong&gt;: pipeline, broadcast,
request-reply, async routing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And &lt;strong&gt;NetMQ&lt;/strong&gt; is the reason .NET people get to be smug about it: a complete,
mature port of ZeroMQ in pure C#. Not a P/Invoke wrapper — a port. One
&lt;code&gt;PackageReference&lt;/code&gt;, no native binaries, no platform matrix. It speaks ZMTP on
the wire, so a NetMQ process happily talks to a libzmq process written in C,
Python or Go.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dotnet add package NetMQ
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s the entire infrastructure story. Nothing to install, nothing to
monitor, nothing between your processes but TCP.&lt;/p&gt;
&lt;h2&gt;The vocabulary&lt;/h2&gt;
&lt;p&gt;Four patterns cover almost everything. The table you actually need is the
last column — what each socket does under pressure:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;Delivery&lt;/th&gt;
&lt;th&gt;When the queue is full&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PUSH → PULL&lt;/td&gt;
&lt;td&gt;pipeline&lt;/td&gt;
&lt;td&gt;round-robin to workers, fair-queue in&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;sender blocks&lt;/strong&gt; (backpressure)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PUB → SUB&lt;/td&gt;
&lt;td&gt;broadcast&lt;/td&gt;
&lt;td&gt;every subscriber gets a copy&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;drops, silently&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REQ ↔ REP&lt;/td&gt;
&lt;td&gt;rigid request/reply&lt;/td&gt;
&lt;td&gt;strict alternation&lt;/td&gt;
&lt;td&gt;blocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DEALER ↔ ROUTER&lt;/td&gt;
&lt;td&gt;async request/reply&lt;/td&gt;
&lt;td&gt;explicit addressing, any cadence&lt;/td&gt;
&lt;td&gt;configurable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two of those cells run the show. &lt;strong&gt;PUSH blocks&lt;/strong&gt; — a slow consumer slows the
producer, nothing is lost, the pipeline breathes. &lt;strong&gt;PUB drops&lt;/strong&gt; — the show
must go on, so a slow subscriber loses messages &lt;em&gt;and nobody tells you&lt;/em&gt;. Not
an exception, not a return code, not a log line. We&apos;ll watch it happen below.&lt;/p&gt;
&lt;p&gt;The rule that falls out, and the single most useful sentence in this post:
&lt;strong&gt;broadcast telemetry with PUB/SUB; move entitlements over something that
blocks.&lt;/strong&gt; A missed price tick is Tuesday; a missed fill is a lawsuit.&lt;/p&gt;
&lt;h2&gt;A pipeline, measured&lt;/h2&gt;
&lt;p&gt;The classic ZeroMQ pipeline is fan-out/fan-in:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ventilator (PUSH, bind) ──► workers ×3 (PULL → PUSH) ──► sink (PULL, bind)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/shaahink/blog-code/tree/main/netmq-event-pipeline&quot;&gt;companion sample&lt;/a&gt;
wires this up over real TCP on localhost and pushes 200,000 messages through
both hops. The ventilator is four lines:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;using var push = new PushSocket();
push.Options.Linger = TimeSpan.FromSeconds(30);   // remember this line
push.Bind(&quot;tcp://127.0.0.1:15961&quot;);

for (var i = 0; i &amp;lt; 200_000; i++)
    push.SendFrame(payload);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On my laptop, one unscientific evening:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;payload    16 B : 200,000 / 200,000 msgs through 2 hops in 0.77 s — 259,761 msg/s (4 MB/s)
payload 1,024 B : 200,000 / 200,000 msgs through 2 hops in 5.62 s — 35,566 msg/s (35 MB/s)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A quarter of a million small messages per second, through two hops and four
sockets, with zero tuning, on a laptop that was also running Spotify. For
same-box IPC you will run out of ideas before you run out of throughput.&lt;/p&gt;
&lt;p&gt;But look at the two rows together, because they teach the real lesson. At 16
bytes we&apos;re &lt;strong&gt;message-rate bound&lt;/strong&gt; — the cost is per-message bookkeeping, and
the bytes are irrelevant. At 1 KB we&apos;re shovelling 64× the payload but only
going 7× slower — the cost curve is bending towards &lt;strong&gt;bandwidth bound&lt;/strong&gt;. Your
bottleneck is almost never &quot;the transport&quot;. It&apos;s what you &lt;em&gt;put in&lt;/em&gt; the
messages — which, in a typical system, means serialization. A JSON encode per
event will dominate everything ZeroMQ does. (The trick that follows from
this: don&apos;t make it a per-message cost. Batch small events into one frame and
amortise.)&lt;/p&gt;
&lt;h2&gt;The foot-gun tour&lt;/h2&gt;
&lt;p&gt;Every one of these is a demo mode in the sample, because every one of them
got me or someone I respect.&lt;/p&gt;
&lt;h3&gt;The slow joiner&lt;/h3&gt;
&lt;p&gt;A subscription is not a local filter — it&apos;s a &lt;em&gt;message&lt;/em&gt;, sent from SUB to
PUB after connecting, and until it lands the publisher filters everything out
at the sending side. So a publisher that starts blasting immediately loses
its opening messages to every subscriber. How badly? On localhost, with the
subscriber connecting as fast as it possibly can:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;naive start    : received    0 of 1,000 events — the head of the stream is simply gone
handshake first: received 1,000 of 1,000 events
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Zero. Out of a thousand. This is the most-reported &quot;bug&quot; in ZeroMQ&apos;s history
and it is by design: PUB/SUB is a radio broadcast, not a mailbox. The fix is
a start-of-day handshake — the subscriber says &lt;em&gt;ready&lt;/em&gt; over a separate
REQ/REP pair, and the publisher holds fire until it hears it. Fifteen lines,
and part of the fifteen is a comment saying why.&lt;/p&gt;
&lt;h3&gt;The high-water mark&lt;/h3&gt;
&lt;p&gt;Each side of a connection buffers up to the HWM — 1,000 messages by default.
PUSH responds to a full queue by blocking. PUB responds by dropping, and
here&apos;s what that looks like when a publisher blasts 50,000 frames at a
subscriber that dawdles just slightly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;published      : 50,000 frames of 128 B
received       : 2,000
dropped        : 48,000 — silently, at the high-water mark
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ninety-six percent of the stream, gone, no error anywhere. If you take one
number away from this post, take that one. Raise
&lt;code&gt;Options.SendHighWatermark&lt;/code&gt; when bursts are expected, drain your subscribers
fast — and never, ever put must-deliver data on PUB/SUB.&lt;/p&gt;
&lt;h3&gt;The vanishing tail&lt;/h3&gt;
&lt;p&gt;NetMQ&apos;s default &lt;em&gt;linger&lt;/em&gt; is zero: disposing a socket discards whatever is
still queued, instantly. Your process sends its last 2,000 messages, hits the
end of &lt;code&gt;using&lt;/code&gt;, and those messages evaporate — the demo lost exactly its tail
until I set &lt;code&gt;Linger&lt;/code&gt; before dispose. Symptoms: &quot;the last batch never
arrives&quot;, but only on fast runs. Set linger, or drain before dispose.&lt;/p&gt;
&lt;h3&gt;The threading rule&lt;/h3&gt;
&lt;p&gt;ZeroMQ sockets are not thread-safe, full stop. The blessed shape for a
long-lived transport is to give the sockets a single home: a &lt;code&gt;NetMQPoller&lt;/code&gt;
owns them all and runs the loop, receive handlers fire on the poller thread,
and any &lt;em&gt;other&lt;/em&gt; thread that wants to send goes through a &lt;code&gt;NetMQQueue&amp;lt;T&amp;gt;&lt;/code&gt; —
a thread-safe funnel that the poller drains on its own thread.&lt;/p&gt;
&lt;p&gt;In the trading engine this pattern came from, the transport then hands
messages to the async world through two bounded
&lt;code&gt;System.Threading.Channels&lt;/code&gt;, and the bounds &lt;em&gt;encode the semantics&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// market data: losing the oldest tick to a stall is fine — it&apos;s telemetry
Channel.CreateBounded&amp;lt;(string Topic, string Json)&amp;gt;(
    new BoundedChannelOptions(10_000) { FullMode = BoundedChannelFullMode.DropOldest });

// commands/executions: these are entitlements — block, never drop
Channel.CreateBounded&amp;lt;(byte[] Identity, string Json)&amp;gt;(
    new BoundedChannelOptions(2_000) { FullMode = BoundedChannelFullMode.Wait });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two channels, two full-modes, and the PUB-drops/PUSH-blocks distinction from
the socket table shows up again &lt;em&gt;inside&lt;/em&gt; the process. The semantics of &quot;what
may be lost&quot; isn&apos;t a transport detail. It&apos;s the design.&lt;/p&gt;
&lt;h2&gt;Buying determinism over a socket&lt;/h2&gt;
&lt;p&gt;Speed is the advertised feature. The feature I actually came for is
stranger: &lt;strong&gt;you can build determinism on top of this&lt;/strong&gt;, and most transports
won&apos;t let you.&lt;/p&gt;
&lt;p&gt;The setup: a trading engine in one process, its venue adapter in another
(inside a broker platform I don&apos;t control). Bars flow one way, orders flow
back. If both flows are free-running, bar N+1 can arrive before the venue has
executed the orders that bar N produced — and every run of the same day
interleaves data and commands differently. For an engine whose
&lt;a href=&quot;https://shaahink.github.io/site/blog/designing-a-deterministic-kernel&quot;&gt;whole design&lt;/a&gt; rests on replaying any
day byte-for-byte, that&apos;s fatal.&lt;/p&gt;
&lt;p&gt;The fix is a protocol, not a transport feature — the &lt;strong&gt;lock-step loop&lt;/strong&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;the venue sends bar &lt;em&gt;N&lt;/em&gt; with a sequence number,&lt;/li&gt;
&lt;li&gt;then &lt;em&gt;blocks&lt;/em&gt; until the engine replies &lt;code&gt;bar_done&lt;/code&gt; echoing seq &lt;em&gt;N&lt;/em&gt;,
carrying &lt;strong&gt;every command that bar produced&lt;/strong&gt;,&lt;/li&gt;
&lt;li&gt;the venue executes those, echoes the executions, and only then sends
bar &lt;em&gt;N+1&lt;/em&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;One total order of data and commands, enforced by the conversation itself.
Same bars in, same interleaving, every run — across a process boundary.&lt;/p&gt;
&lt;p&gt;The sample implements it with DEALER/ROUTER rather than REQ/REP, and the
choice is instructive: REQ/REP hard-codes &lt;em&gt;exactly one&lt;/em&gt; reply per request,
but an engine might answer a bar with nothing, or with three commands and a
status update. DEALER/ROUTER is the polite version of the same conversation —
ROUTER prefixes each message with the sender&apos;s identity, so replies are
addressed and several venues could share one engine:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// engine side: ROUTER frames arrive as [identity][payload]
var identity = router.ReceiveFrameBytes();
var json     = router.ReceiveFrameString();
// ... decide ...
router.SendMoreFrame(identity)                      // reply to *that* venue
      .SendFrame(Wire(&quot;bar_done&quot;, new { seq, commands }));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Yes, lock-step caps throughput at one bar per round-trip. For bars — even M1
bars across every major pair — that budget is laughable. Determinism costs
latency you weren&apos;t using.&lt;/p&gt;
&lt;h2&gt;What I&apos;d use it for, and what not&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Reach for it when:&lt;/strong&gt; processes on one box or one rack need events at rate;
pipelines with natural backpressure; broadcast telemetry with many readers;
a protocol of your own design (like the lock-step above) that a broker&apos;s
opinions would fight.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don&apos;t reach for it when:&lt;/strong&gt; you need durability — ZeroMQ holds messages in
memory; a crashed process&apos;s queue is gone. No replay, no consumer offsets, no
audit log: that&apos;s Kafka&apos;s job, and the honest response to needing it is to
use Kafka. Similarly the open internet edge (you&apos;ll want TLS, auth, and
someone else&apos;s hardened listener) and org-wide integration fabric (the
broker&apos;s governance is the feature, not the overhead).&lt;/p&gt;
&lt;p&gt;One more honest cost: no broker means nobody is watching. There&apos;s no
management console showing queue depths; observability is yours to build.
The engine&apos;s transport counts bars in, commands out, executions back, and
timestamps the last message — four counters and a clock, exposed on a status
endpoint. Build them on day one; they&apos;re your only witnesses (the HWM
certainly won&apos;t testify).&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Runnable, measurable version:
&lt;a href=&quot;https://github.com/shaahink/blog-code/tree/main/netmq-event-pipeline&quot;&gt;&lt;code&gt;netmq-event-pipeline&lt;/code&gt;&lt;/a&gt;
in &lt;a href=&quot;https://github.com/shaahink/blog-code&quot;&gt;blog-code&lt;/a&gt; — the pipeline benchmark,
the slow joiner, the silent HWM drops, and the lock-step protocol, each as a
one-command demo.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>dotnet</category><category>messaging</category><category>architecture</category></item><item><title>Gated delivery for a team of agents</title><link>https://shaahink.github.io/site/blog/gated-delivery-for-a-team-of-agents/</link><guid isPermaLink="true">https://shaahink.github.io/site/blog/gated-delivery-for-a-team-of-agents/</guid><description>Coding agents will work unattended for hours — if you stop believing anything they say. The full anatomy of an autonomous delivery loop: agent CLIs as processes, a ten-outcome vocabulary, independent gate batteries, fix sessions armed with evidence, and state that survives a power cut.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Here is how a multi-stage delivery actually goes with a coding agent. You
write a beautiful plan. You feed stage one to the agent. It works; you check
it; fine. You feed it stage two. You wait. You alt-tab to something else. You
come back to find it finished twenty minutes ago — or stuck, forty minutes
ago. You check the work, you spawn the next session, you glance at the clock:
23:40. You are a highly trained engineer performing the duties of a while
loop.&lt;/p&gt;
&lt;p&gt;I built &lt;a href=&quot;https://shaahink.github.io/site/projects#conductor&quot;&gt;Conductor&lt;/a&gt; to be that while loop. It runs
agent sessions unattended, one at a time, for hours — overnight is the
normal case, not the war story. And every design decision in it flows from
one axiom, which I&apos;ll state now and spend the rest of the post cashing out:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;An agent&apos;s report of its own work is not evidence.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Not because agents lie maliciously. Because they are optimistic, occasionally
confused, and rewarded — in some deep gradient-descent sense — for sounding
finished. &quot;All tests passing, the feature is complete&quot; is a &lt;em&gt;claim&lt;/em&gt;. The
orchestrator&apos;s job is to be the auditor that never signs a claim without
receipts. I&apos;ve written before about &lt;a href=&quot;https://shaahink.github.io/site/blog/dont-trust-the-agent&quot;&gt;the trust
model&lt;/a&gt; in principle; this post is the machinery,
end to end.&lt;/p&gt;
&lt;h2&gt;The automation surface nobody advertises&lt;/h2&gt;
&lt;p&gt;First, the enabling fact: you don&apos;t need an SDK, a browser harness, or PTY
scraping to drive a coding agent. The CLIs ship a first-class headless mode:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;claude -p &quot;&amp;lt;prompt&amp;gt;&quot; --output-format stream-json
opencode run &quot;&amp;lt;prompt&amp;gt;&quot; --format json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Prompt in, newline-delimited JSON out: every assistant message, every tool
call, and a final &lt;code&gt;result&lt;/code&gt; envelope carrying cost, token counts and an error
flag. Which means an agent session is something Unix has known how to
supervise for forty years — &lt;strong&gt;a child process with structured output&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The spawning code is boring, and every boring line is load-bearing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var psi = new ProcessStartInfo(cfg.Command)
{
    WorkingDirectory = repoPath,
    UseShellExecute = false,
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    RedirectStandardInput = true,
    StandardOutputEncoding = Encoding.UTF8,
};
foreach (var a in args) psi.ArgumentList.Add(a);   // never a concatenated string
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;ArgumentList&lt;/code&gt;, not an argument string&lt;/strong&gt; — prompts contain quotes, newlines
and JSON, and hand-quoting them for a shell is a bug factory with a night
shift. &lt;strong&gt;Close stdin&lt;/strong&gt; — headless agents shouldn&apos;t wait for input; some will
anyway. &lt;strong&gt;Tee the raw stream to a file&lt;/strong&gt; before parsing a single line —
&lt;code&gt;session-042.jsonl&lt;/code&gt; is the forensic record for when something goes weird, and
parsers are lossy by design. And keep the parser &lt;em&gt;tolerant&lt;/em&gt;: unknown event
types become &lt;code&gt;raw&lt;/code&gt;, never an exception, because the stream format is
versioned by vibes.&lt;/p&gt;
&lt;p&gt;Templates finish the job: the config holds
&lt;code&gt;[&quot;-p&quot;, &quot;{prompt}&quot;, &quot;--output-format&quot;, &quot;stream-json&quot;]&lt;/code&gt;, and the driver
substitutes &lt;code&gt;{prompt}&lt;/code&gt; and &lt;code&gt;{sessionId}&lt;/code&gt;. Swapping Claude Code for opencode
is a config edit. The orchestrator has one session class and two parsers, and
genuinely does not care who&apos;s inside the process.&lt;/p&gt;
&lt;h3&gt;Kill means kill&lt;/h3&gt;
&lt;p&gt;One hard-won subclause. Agent sessions spawn children — build tools, test
runners, dev servers — and some of them detach and escape the process tree.
On Windows, even &lt;code&gt;Process.Kill(entireProcessTree: true)&lt;/code&gt; misses those.
Conductor puts every session in a &lt;strong&gt;Job Object&lt;/strong&gt; with &lt;code&gt;KILL_ON_JOB_CLOSE&lt;/code&gt;:
close one handle and everything the session ever started dies with it.&lt;/p&gt;
&lt;p&gt;The tuition for this lesson: a stray &lt;code&gt;dotnet watch&lt;/code&gt; from session 12 held a
file lock that failed the build gate in session 13, which spawned a fix
session that could not possibly fix anything, which burned the stage&apos;s
attempt budget, which stopped the run — all while the actual code was fine.
An orchestrator that can&apos;t guarantee a clean battlefield between sessions
will gaslight itself. Kill means kill.&lt;/p&gt;
&lt;h2&gt;Sessions don&apos;t pass or fail — they end ten ways&lt;/h2&gt;
&lt;p&gt;The next thing unattended operation forces on you: an exit code is nowhere
near enough vocabulary. Conductor reduces every session to one of ten
explicit outcomes, and the enum is worth reading like a poem about
everything that can go wrong:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public enum SessionOutcome
{
    Advanced,      // gates green, new commits, ≥1 checkpoint newly DONE
    Progress,      // gates green, new commits, no new DONE — multi-session stage, fine
    NoProgress,    // gates green but nothing committed
    GatesRed,      // gates failed after the session
    Stalled,       // no output for stallMinutes — killed
    TimedOut,      // exceeded sessionTimeoutMinutes — killed
    AgentError,    // agent process exited with an error result
    LimitBackoff,  // usage/rate limit detected — waiting it out
    KilledByUser,
    Interrupted,   // conductor itself died mid-session (recovered on restart)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The point of the vocabulary isn&apos;t taxonomy for its own sake — it&apos;s that
&lt;strong&gt;different outcomes get different next moves&lt;/strong&gt;, and the difference is fault
attribution:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Stalled / TimedOut&lt;/strong&gt; — the agent&apos;s fault. Kill it, resume the same
session with a bounded resume budget, and &lt;em&gt;burn an attempt&lt;/em&gt; from the
stage&apos;s allowance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LimitBackoff&lt;/strong&gt; — the world&apos;s fault. The backend said &quot;usage limit
reached&quot;; punishing the agent for that is nonsense. Wait it out, resume,
burn &lt;em&gt;nothing&lt;/em&gt;. (Detecting this one is a regex over the output tail,
because the refusal hides in free text rather than any structured field.
I&apos;m not proud of it, but it&apos;s load-bearing: misclassify a rate limit as an
agent failure and your overnight run dies at the exact moment it&apos;s most
useful — when you&apos;re asleep and the API is busy.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GatesRed&lt;/strong&gt; — actionable, and the evidence is already in hand. Spawn a fix
session; more below.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NoProgress&lt;/strong&gt; — the strangest one. Everything&apos;s green, the agent chatted
amiably, and &lt;em&gt;nothing changed in the repo&lt;/em&gt;. Counted against the stage,
because three of those in a row means the plan and the agent have agreed
to disagree, and a human should look.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A watchdog produces the first two: every output line stamps
&lt;code&gt;LastActivityUtc&lt;/code&gt;, and a supervision loop checks it against a stall limit
and a wall-clock timeout. The cost envelope feeds a second budget — the
&lt;code&gt;result&lt;/code&gt; event&apos;s &lt;code&gt;total_cost_usd&lt;/code&gt; accumulates per session, stage and run, so
the loop stops when the money&apos;s gone rather than when the credit card
notices.&lt;/p&gt;
&lt;h2&gt;The verdict: three witnesses, no testimony&lt;/h2&gt;
&lt;p&gt;Now the core of the trust model. When a session ends &quot;successfully&quot; — clean
exit, no error flag — Conductor determines what actually happened using only
things it can &lt;em&gt;independently observe&lt;/em&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The gate battery&lt;/strong&gt; — re-run the builds, tests, linters. Real commands,
real exit codes, no summaries accepted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Git&lt;/strong&gt; — &lt;code&gt;CommitsSince(startHead)&lt;/code&gt;. Did the repo change? An agent that
&quot;completed the feature&quot; without committing anything has completed
nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The tracker diff&lt;/strong&gt; — the plan&apos;s checkpoints live in a tracker file;
parse it before and after, and see which checkpoints &lt;em&gt;newly&lt;/em&gt; flipped to
DONE.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;One line from the real log, because it summarises the whole philosophy:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;verdict inputs: gates green · commits 3 · newly DONE [4.2] · dirty no
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Gates green &lt;strong&gt;and&lt;/strong&gt; commits exist &lt;strong&gt;and&lt;/strong&gt; a checkpoint flipped ⇒ &lt;code&gt;Advanced&lt;/code&gt;.
Gates green and commits but no checkpoint ⇒ &lt;code&gt;Progress&lt;/code&gt;. The agent&apos;s parting
essay about its own excellence is not among the inputs. It&apos;s stored — it
makes good reading later — but it decides nothing.&lt;/p&gt;
&lt;p&gt;Note what this makes the agent&apos;s claim of &quot;done&quot;: a &lt;em&gt;hypothesis&lt;/em&gt; the
orchestrator then tries to falsify, cheaply. Session says done → gates say
otherwise → session was wrong, and the loop knows it before the human ever
would. The wrongness gets caught in minutes, not discovered in review two
days later.&lt;/p&gt;
&lt;h2&gt;Gates are a battery, not a script&lt;/h2&gt;
&lt;p&gt;The gates themselves deserve engineering care, because per-session gating
means they run &lt;em&gt;constantly&lt;/em&gt;. Conductor&apos;s &lt;code&gt;GateRunner&lt;/code&gt; walks the configured
gates in listed order with one scheduling rule: a gate marked non-parallel
is a &lt;strong&gt;barrier&lt;/strong&gt;; consecutive parallel gates run &lt;strong&gt;as one concurrent
batch&lt;/strong&gt;. So &lt;code&gt;build&lt;/code&gt; runs alone, everyone waits — then tests, formatting and
the docs check fan out together. There are two tiers: a &lt;strong&gt;fast&lt;/strong&gt; tier after
every session (seconds — the pulse check), and the &lt;strong&gt;full battery&lt;/strong&gt; at phase
boundaries (the physical). Optional gates can fail as warnings; per-stage
filters keep irrelevant gates out of the loop.&lt;/p&gt;
&lt;p&gt;That&apos;s four sentences of feature list with one purpose: keep the
verify-everything loop cheap enough that &lt;em&gt;nobody is ever tempted to skip
it&lt;/em&gt;. A trust model you bypass under schedule pressure isn&apos;t a trust model.
It&apos;s decoration.&lt;/p&gt;
&lt;h2&gt;Failure is an input: fix sessions&lt;/h2&gt;
&lt;p&gt;When gates come back red, the orchestrator doesn&apos;t retry with a hopeful &quot;try
again, but better&quot;. It records a debt — literally a &lt;code&gt;PendingFix&lt;/code&gt; in the run
state:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public sealed class PendingFix
{
    public int FromSession { get; set; }
    public string GateFailures { get; set; } = &quot;&quot;;   // the ACTUAL failing output
    public string ProgressSummary { get; set; } = &quot;&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;— and the next session is a &lt;strong&gt;fix session&lt;/strong&gt; whose prompt embeds the real
failing gate output, the commits so far and what the last session claimed.
The difference in outcome quality between &quot;tests failed, please fix&quot; and
&lt;em&gt;here are the four failing test names and their assertion messages&lt;/em&gt; is the
difference between a coin flip and a mechanic. Evidence in, fix out.&lt;/p&gt;
&lt;p&gt;Fix isn&apos;t the only specialist. Sessions come in kinds — &lt;strong&gt;Deliver&lt;/strong&gt; for new
work, &lt;strong&gt;Fix&lt;/strong&gt; armed with failures, &lt;strong&gt;Resume&lt;/strong&gt; (via &lt;code&gt;claude --resume &amp;lt;session-id&amp;gt;&lt;/code&gt;) to continue an interrupted session with its context intact,
and &lt;strong&gt;Audit&lt;/strong&gt; at phase boundaries to review a whole stage&apos;s diff. Same
process machinery, different briefs. A team of agents, in the only sense
that currently works reliably: not five agents arguing in a group chat, but
specialists &lt;em&gt;across time&lt;/em&gt;, each handed exactly the context its job needs,
each distrusted equally.&lt;/p&gt;
&lt;p&gt;When the loop truly corners itself — resume budget exhausted, attempts gone —
it consults a cheap second-model &lt;strong&gt;advisor&lt;/strong&gt; with a compact dossier: outcome
history, tracker state, the reason it&apos;s stuck. Continue, retry differently,
or stop and ask the human. A second opinion at dead ends only; the loop&apos;s
core stays deliberately dumb and auditable. Autonomy that knows when to
stop being autonomous — the run status even has a &lt;code&gt;NeedsHuman&lt;/code&gt; state, which
is the system formally admitting you exist.&lt;/p&gt;
&lt;h2&gt;Built to be killed&lt;/h2&gt;
&lt;p&gt;Anything that runs for hours will be interrupted — Ctrl+C, reboot, power
cut, or its own bug. Conductor treats that as a feature request: the entire
run state is one JSON document, snapshotted &lt;strong&gt;atomically on every
transition&lt;/strong&gt; (write to temp file, rename over the old — the filesystem&apos;s
one free transaction). Owed work is recorded &lt;em&gt;as data&lt;/em&gt;: &lt;code&gt;PendingFix&lt;/code&gt;,
&lt;code&gt;PendingResume&lt;/code&gt;, &lt;code&gt;PendingPhaseGate&lt;/code&gt;, &lt;code&gt;PendingAudit&lt;/code&gt;. Kill the process
anywhere; &lt;code&gt;conductor run&lt;/code&gt; reads the state and does whatever is owed. There&apos;s
even an outcome for it — &lt;code&gt;Interrupted&lt;/code&gt; — because dying mid-session is just
another way a session ends, and the resume machinery treats it accordingly.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/shaahink/blog-code/tree/main/gated-agent-delivery&quot;&gt;companion sample&lt;/a&gt;
compresses this whole post into a ninety-second demo: a three-stage delivery
where the backend rate-limits stage one (backoff, no attempt burned), the
agent &lt;em&gt;lies&lt;/em&gt; about stage two (gates catch it, the fix session gets the
evidence), a power cut lands between runs (atomic state resumes it), and the
agent stalls on stage three (watchdog kills, retry succeeds). Its final
self-report — which, unlike an agent&apos;s, you may believe, because it&apos;s a grep
over verified output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;delivered 3/3 stages · sessions: 6 · lies caught by gates: 1 · stalls caught by watchdog: 1
resumed after crash: True
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What this costs, honestly&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;You&apos;re coupled to stream formats&lt;/strong&gt; that change without notice. The raw
log plus the tolerant parser is the survival kit, not a nicety.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Your gates are your actual spec.&lt;/strong&gt; The loop delivers whatever the battery
accepts — it cannot want what you meant, only what you check. A weak battery
plus a diligent agent yields confidently delivered garbage at impressive
speed. Budget real time for gate quality; it&apos;s where the engineering went.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sequential sessions are slower than a human juggling three terminals&lt;/strong&gt; —
on a good day, when the human is fresh. The orchestrator&apos;s day has no
concept of fresh. It does the loop at 03:00 exactly as it does it at 09:00,
&lt;a href=&quot;https://shaahink.github.io/site/blog/a-live-tui-in-the-ai-era&quot;&gt;watched from a phone&lt;/a&gt; if you&apos;re curious,
and it never once believes the agent because believing was never in the
loop.&lt;/p&gt;
&lt;p&gt;That last property compounds. Each session ends as a &lt;code&gt;SessionRecord&lt;/code&gt; —
outcome, commits, checkpoints, cost, tokens, duration — so every run
produces its own audit trail. After a few weeks you know things about your
agents no anecdote could tell you: which stages burn attempts, where stalls
cluster, what a stage &lt;em&gt;costs&lt;/em&gt;. The loop doesn&apos;t just deliver unattended. It
keeps the books.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Runnable, distilled version:
&lt;a href=&quot;https://github.com/shaahink/blog-code/tree/main/gated-agent-delivery&quot;&gt;&lt;code&gt;gated-agent-delivery&lt;/code&gt;&lt;/a&gt;
in &lt;a href=&quot;https://github.com/shaahink/blog-code&quot;&gt;blog-code&lt;/a&gt; — the full loop with a
bundled fake agent, so it runs with nothing installed. The real thing:
&lt;a href=&quot;https://github.com/shaahink/conductor&quot;&gt;Conductor&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>ai</category><category>agents</category><category>orchestration</category><category>dotnet</category></item><item><title>A trading engine as a pure function</title><link>https://shaahink.github.io/site/blog/a-trading-engine-as-a-pure-function/</link><guid isPermaLink="true">https://shaahink.github.io/site/blog/a-trading-engine-as-a-pure-function/</guid><description>Most backtesters are a second implementation of the strategy — so they prove nothing about the code that actually trades. Build the engine as a pure reducer and the backtest, the live path, and the debugger become the same thing.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;There&apos;s a dirty secret in most algorithmic trading setups: the backtester and the
live engine are &lt;strong&gt;two different programs&lt;/strong&gt;. The backtest loops over candles in a
&lt;code&gt;for&lt;/code&gt; loop; the live engine reacts to broker callbacks, timers, and network
weather. The strategy logic gets reimplemented — or &quot;shared&quot; through a leaky
abstraction — and the result is a backtest that validates code which never runs,
and live code that has never been validated.&lt;/p&gt;
&lt;p&gt;I&apos;m building a trading engine (&lt;a href=&quot;https://shaahink.github.io/site/projects#shamshir&quot;&gt;Shamshir&lt;/a&gt;) where that split
can&apos;t exist, because the core of the engine is a pure function:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public static (EngineState Next, IReadOnlyList&amp;lt;Intent&amp;gt; Intents)
    Reduce(EngineState state, MarketEvent evt);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;State in, event in. New state out, plus a list of &lt;em&gt;intents&lt;/em&gt; — open this
position, move this stop, close everything. That&apos;s the whole kernel. No clock,
no broker client, no database, no &lt;code&gt;DateTime.UtcNow&lt;/code&gt;, no randomness. If it needs
to know the time, the time is on the event.&lt;/p&gt;
&lt;h2&gt;Side effects live at the edges&lt;/h2&gt;
&lt;p&gt;Everything impure sits in thin adapters around the kernel:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;feed adapter&lt;/strong&gt; turns broker callbacks (ticks, bar closes, order fills)
into &lt;code&gt;MarketEvent&lt;/code&gt; records and pushes them through the reducer.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;execution adapter&lt;/strong&gt; takes the intents the reducer emits and turns them
into real broker API calls — then feeds the results (fills, rejections) back
in as new events.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;persistence adapter&lt;/strong&gt; appends every event to a log (SQLite is plenty)
before the reducer sees it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The adapters are deliberately boring. All the decisions — every indicator
update, every signal, every position-size calculation — happen inside the pure
part, where they can be tested with nothing but values.&lt;/p&gt;
&lt;h2&gt;The backtest is not a second program&lt;/h2&gt;
&lt;p&gt;Once the engine is a reducer over events, backtesting stops being a separate
implementation and becomes &lt;strong&gt;replay&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var state = EngineState.Initial(config);
foreach (var evt in recordedEvents)
    (state, _) = Kernel.Reduce(state, evt);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Feed it a year of recorded events and you have a backtest. Feed it this
morning&apos;s events and you have a reconstruction of this morning. The code path
is identical to live — same reducer, same indicators, same sizing, same risk
checks — so a green backtest actually says something about the software that
will trade.&lt;/p&gt;
&lt;p&gt;The corollary is my favourite property of the whole design: &lt;strong&gt;any trading day
can be replayed, byte for byte&lt;/strong&gt;. When the engine does something surprising at
14:32 — and it will — I don&apos;t stare at logs guessing. I replay the event log up
to 14:31, attach a debugger, and step through the exact decision with the exact
state. &quot;Why did it sell?&quot; becomes a reproducible question.&lt;/p&gt;
&lt;h2&gt;Strategies propose. The governor disposes.&lt;/h2&gt;
&lt;p&gt;Shamshir targets prop-firm accounts, which come with unforgiving rules —
FTMO-style daily drawdown and total drawdown limits. Breach them once and the
account is gone. Rules like that are too important to live inside strategy
code, which is exactly the code you rewrite most often.&lt;/p&gt;
&lt;p&gt;So the kernel is split in two layers. Strategies emit &lt;em&gt;proposals&lt;/em&gt;. A separate
&lt;strong&gt;risk governor&lt;/strong&gt; sits between proposals and intents, and its only power is
veto and reduction:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// The governor can shrink or kill a proposal — never enlarge it.
var verdict = governor.Evaluate(state.Risk, proposal);
// verdict: Approved(sized) | Reduced(sized, reason) | Rejected(reason)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Daily drawdown, max total drawdown, exposure caps, and position sizing are
first-class domain objects in the governor, not &lt;code&gt;if&lt;/code&gt; statements scattered
through strategies. A strategy can be wrong, greedy, or half-finished; the
blast radius is capped by a small component that never changes and is tested
to death. It&apos;s the same shape as a supervisor in an actor system — or, for
that matter, an orchestrator that &lt;a href=&quot;https://shaahink.github.io/site/blog/dont-trust-the-agent&quot;&gt;doesn&apos;t trust its
agents&lt;/a&gt;: the thing that enforces the rules must be
separate from the thing that does the work.&lt;/p&gt;
&lt;h2&gt;What testing looks like when the core is pure&lt;/h2&gt;
&lt;p&gt;The unit tests read like arithmetic. Build a state, apply an event, assert on
the output — no mocks, no broker sandbox, no async ceremony:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Fact]
public void Breaching_daily_drawdown_flattens_and_locks()
{
    var state = StateWith(equity: 98_950m, dailyStart: 100_000m); // -1.05%
    var (next, intents) = Kernel.Reduce(state, BarClose(...));

    intents.Should().ContainSingle(i =&amp;gt; i is CloseAll);
    next.Risk.TradingLocked.Should().BeTrue();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Above that sits a simulation tier: full engine, synthetic or recorded feeds,
no credentials required — so CI exercises realistic multi-day scenarios
without a broker account anywhere in sight.&lt;/p&gt;
&lt;h2&gt;The costs, honestly&lt;/h2&gt;
&lt;p&gt;Purity isn&apos;t free. All engine state has to be explicit and serialisable —
including indicator state, which is where third-party indicator libraries
fight you. Multi-symbol, multi-timeframe indicator windows make &lt;code&gt;EngineState&lt;/code&gt;
a genuinely large object, and you have to be disciplined about it evolving.
And this design optimises for correctness and replayability, not latency; if
you&apos;re chasing microseconds, a pure functional core with an event log is not
your architecture.&lt;/p&gt;
&lt;p&gt;For a swing engine whose failure mode is &quot;one bad day ends the account&quot;, the
trade is obvious. The reducer gives you a backtest you can believe, a debugger
that time-travels, and a risk layer that can&apos;t be bypassed — all from one
decision about where the side effects live.&lt;/p&gt;
</content:encoded><category>dotnet</category><category>architecture</category><category>trading</category></item><item><title>An agent session is not pass/fail</title><link>https://shaahink.github.io/site/blog/an-agent-session-is-not-pass-fail/</link><guid isPermaLink="true">https://shaahink.github.io/site/blog/an-agent-session-is-not-pass-fail/</guid><description>The first version of my orchestrator judged agent sessions as success or failure. Reality needed eight outcomes — and the distinction that matters most is whose fault the failure was.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The first version of &lt;a href=&quot;https://shaahink.github.io/site/projects#conductor&quot;&gt;Conductor&lt;/a&gt; — my orchestrator for
running coding agents unattended across multi-stage plans — judged every
session the obvious way: did it succeed or fail?&lt;/p&gt;
&lt;p&gt;That model survived contact with reality for about two days. What do you call
a session that ran for forty minutes, kept the build green, and committed
nothing? A session that made real commits but didn&apos;t finish the checkpoint? A
session that died because the API rate-limited it? &quot;Failure&quot; lumps together
things that demand completely different responses — and an unattended loop &lt;em&gt;is&lt;/em&gt;
its responses. Get the reaction wrong and the loop either gives up on plans it
could have finished, or burns money re-running work that was never broken.&lt;/p&gt;
&lt;h2&gt;The taxonomy&lt;/h2&gt;
&lt;p&gt;Conductor now resolves every session to one of eight outcomes. None of them
come from the agent&apos;s own report — they&apos;re computed from three independent
signals after the session exits: the gate battery (build/tests/lint, re-run by
the orchestrator, real exit codes), the git log (did commits appear?), and the
plan tracker (did a checkpoint genuinely flip to &lt;code&gt;DONE&lt;/code&gt;?).&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Observation&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gates green, new commits, checkpoint flipped&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Advanced&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Next checkpoint, attempts reset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gates green, new commits, nothing flipped&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Progress&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Another delivery session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gates green, no commits&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NoProgress&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fix session; attempt burned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any required gate red&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GatesRed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fix session with the failing output embedded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No output for N minutes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Stalled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Kill, resume the same session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hard time limit hit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TimedOut&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same as stalled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend says rate/usage limit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;LimitBackoff&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wait, resume — &lt;strong&gt;no attempt burned&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session token budget exceeded&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RolledOver&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fresh session with handoff — &lt;strong&gt;no attempt burned&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Plus one park state that outranks them all: &lt;strong&gt;NeedsHuman&lt;/strong&gt;. If the agent
writes &lt;code&gt;HUMAN:&lt;/code&gt; in its handoff, or a checkpoint flips to &lt;code&gt;BLOCKED&lt;/code&gt;, or the
loop detects two consecutive zero-output stalls, the run parks, publishes a
report, and notifies me. More on why that&apos;s a success in a moment.&lt;/p&gt;
&lt;h2&gt;Whose fault was it?&lt;/h2&gt;
&lt;p&gt;The load-bearing distinction in that table isn&apos;t green versus red — it&apos;s
&lt;strong&gt;agent-caused versus environment-caused&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Every stage has an attempt budget (expected sessions × a slack factor). Burn
through it and the stage escalates. Early on, everything decremented that
budget, and I watched a plan die overnight because the provider rate-limited
for an hour and the orchestrator dutifully recorded strike after strike
against a stage that had done nothing wrong.&lt;/p&gt;
&lt;p&gt;So the taxonomy encodes blame. &lt;code&gt;NoProgress&lt;/code&gt; and &lt;code&gt;GatesRed&lt;/code&gt; are on the agent —
they burn an attempt. &lt;code&gt;LimitBackoff&lt;/code&gt; and &lt;code&gt;RolledOver&lt;/code&gt; are weather — the loop
backs off or rolls over with a handoff, and the budget is untouched. A rate
limit is not evidence about the difficulty of the work.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;RolledOver&lt;/code&gt; deserves a note, because naive token exhaustion is nasty: the
session dies mid-edit with no summary and the next session inherits a
half-finished mystery. Conductor watches spend, and at 80% of the budget it
injects a cooperative nudge: &lt;em&gt;stop opening new work, commit what&apos;s coherent,
write the handoff.&lt;/em&gt; Exhaustion becomes a planned landing instead of a crash.&lt;/p&gt;
&lt;h2&gt;The outcome picks the next prompt&lt;/h2&gt;
&lt;p&gt;The second thing the taxonomy buys: the outcome determines what &lt;em&gt;kind&lt;/em&gt; of
session runs next, not just whether one runs.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GatesRed&lt;/code&gt; spawns a &lt;strong&gt;fix session&lt;/strong&gt; whose prompt embeds the actual failing
gate output — the agent starts from the compiler error, not from &quot;something
is wrong, please investigate&quot;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Stalled&lt;/code&gt; doesn&apos;t start over; it &lt;strong&gt;resumes the same session&lt;/strong&gt; (bounded per
session), because mid-task context is expensive to rebuild.&lt;/li&gt;
&lt;li&gt;Budget exhausted → an &lt;strong&gt;advisor&lt;/strong&gt; — a second, cheap model — reads the
history and picks from a closed vocabulary: retry, resume, skip, or human.
It&apos;s consulted only at genuine dead ends, and its answer is validated
against that vocabulary. Deterministic first, model second.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Parking is not failing&lt;/h2&gt;
&lt;p&gt;The early instinct is to treat &quot;the loop stopped and asked for me&quot; as defeat —
the whole point was autonomy. That&apos;s wrong. For an unattended system the real
failure modes are silent: retrying a doomed stage at 3am, or skipping past a
wrong assumption baked into the plan. &lt;code&gt;NeedsHuman&lt;/code&gt; is the loop recognising the
limit of its evidence and saying so loudly, with a report of everything it
knows. I&apos;ll take that over confident thrashing every time.&lt;/p&gt;
&lt;p&gt;Autonomy for coding agents doesn&apos;t come from a better model or a cleverer
prompt. It comes from the boring machinery around the model knowing exactly
what just happened — and having a specific, evidence-based reaction to each way
a session can end. Pass/fail was never going to carry that weight. A taxonomy
does.&lt;/p&gt;
</content:encoded><category>ai</category><category>agents</category><category>orchestration</category></item><item><title>Call graphs lie about modern .NET</title><link>https://shaahink.github.io/site/blog/call-graphs-lie-about-modern-dotnet/</link><guid isPermaLink="true">https://shaahink.github.io/site/blog/call-graphs-lie-about-modern-dotnet/</guid><description>In a mediator-and-DI codebase, nothing calls anything — everything is connected by convention. Honest tracing means building edges from joins, and making every edge cite the file and line it came from.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Ask the obvious question about any .NET service — &lt;em&gt;what actually happens when
&lt;code&gt;POST /orders&lt;/code&gt; is hit?&lt;/em&gt; — and try to answer it with a call graph. You&apos;ll get
exactly one hop:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[HttpPost]
public Task&amp;lt;IActionResult&amp;gt; Create(CreateOrderRequest req)
    =&amp;gt; _mediator.Send(new CreateOrderCommand(req));   // ...and the trail goes cold
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The call graph dutifully reports that the controller calls
&lt;code&gt;IMediator.Send&lt;/code&gt;. It cannot tell you that the real successor is
&lt;code&gt;CreateOrderCommandHandler&lt;/code&gt;, because &lt;strong&gt;nothing in the entire solution ever
calls the handler&lt;/strong&gt;. MediatR finds it by reflection at runtime. Statically,
it&apos;s dead code with a day job.&lt;/p&gt;
&lt;p&gt;And it&apos;s not just MediatR. Modern .NET has systematically replaced calls with
conventions: DI containers construct your implementations, so nothing &lt;code&gt;new&lt;/code&gt;s
them; MassTransit invokes your consumers when a message arrives, so nothing
calls them; domain events dispatch to handlers the same way; pipeline
behaviours wrap everything invisibly. The better your architecture — the more
decoupled — the more your call graph degenerates into disconnected islands
with &lt;code&gt;Send&lt;/code&gt; and &lt;code&gt;Publish&lt;/code&gt; at the shores.&lt;/p&gt;
&lt;p&gt;This is precisely where humans onboarding to a repo get lost, and where coding
agents hallucinate wiring that doesn&apos;t exist. The model sees &lt;code&gt;Send(new CreateOrderCommand(...))&lt;/code&gt;, can&apos;t see the handler, and invents one.&lt;/p&gt;
&lt;h2&gt;Join, don&apos;t follow&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://shaahink.github.io/site/projects#devcontext&quot;&gt;DevContext&lt;/a&gt; — my tool for turning a .NET solution into
a queryable code graph — deals with this by refusing to treat calls as the
primary source of truth. Instead, the analyser runs separate Roslyn detections
across the solution: endpoints, MediatR handlers, message consumers, EF Core
entities, DI registrations, background workers, domain-event raises.&lt;/p&gt;
&lt;p&gt;Then it builds edges by &lt;strong&gt;joining detections&lt;/strong&gt;, the way you&apos;d join tables:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;_mediator.Send(new CreateOrderCommand(...))&lt;/code&gt; is detected as a &lt;em&gt;send&lt;/em&gt; of
&lt;code&gt;CreateOrderCommand&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;class CreateOrderCommandHandler : IRequestHandler&amp;lt;CreateOrderCommand, OrderId&amp;gt;&lt;/code&gt;
is detected as a &lt;em&gt;handler&lt;/em&gt; of &lt;code&gt;CreateOrderCommand&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Same message type on both sides → a &lt;code&gt;Sends&lt;/code&gt;/&lt;code&gt;Handles&lt;/code&gt; edge connects them.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No call exists between those two classes, and no call is needed. The message
type is the foreign key. The same join works for events and their consumers,
for &lt;code&gt;AddScoped&amp;lt;IOrderRepository, OrderRepository&amp;gt;()&lt;/code&gt; connecting an interface
to what the container will actually construct, and for entities reached
through a &lt;code&gt;DbContext&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;An edge you can&apos;t cite is a guess&lt;/h2&gt;
&lt;p&gt;Joins are inference, and inference can be wrong — a message type with two
handlers, an interface with three registrations. So every edge in the graph
carries three pieces of honesty metadata:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Provenance&lt;/strong&gt; — the &lt;code&gt;file:line&lt;/code&gt; of the evidence on both sides.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resolution&lt;/strong&gt; — how the edge was derived: a &lt;code&gt;Join&lt;/code&gt;, a raw &lt;code&gt;Syntactic&lt;/code&gt;
match, or full &lt;code&gt;Semantic&lt;/code&gt; resolution.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Confidence&lt;/strong&gt; — because &quot;the only implementor of this interface&quot; is a
stronger claim than &quot;one of three implementors&quot;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This turns out to matter more for LLMs than for humans. A trace whose every
hop cites its source is something an agent (or its orchestrator) can
&lt;em&gt;verify&lt;/em&gt; — open the file, check the line. A trace without citations is just
more plausible-sounding text, and plausible-sounding text is the one thing
models never run short of.&lt;/p&gt;
&lt;h2&gt;Not all edges are equally interesting&lt;/h2&gt;
&lt;p&gt;When the tracer walks outward from an entry point, it expands edges in a
strict priority order — the semantic, intent-revealing edges first:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Priority&lt;/th&gt;
&lt;th&gt;Edge&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Sends&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;command/event dispatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Handles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;mediator handler join&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Raises&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;domain event raised&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Consumes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bus consumer join&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ReadsWrites&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;EF entity touched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Resolves&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DI interface → implementation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WrappedBy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;pipeline behaviour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Calls&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;plain method call&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Raw &lt;code&gt;Calls&lt;/code&gt; edges — the only thing a classic call graph has — come dead last.
They&apos;re the noise floor: real, but rarely what you&apos;re asking about. The
priority ladder is what makes a trace read like an explanation (&quot;the endpoint
sends a command, the handler raises an event, a consumer writes the entity&quot;)
instead of a stack dump.&lt;/p&gt;
&lt;p&gt;A few guards keep traces finite and honest: a depth limit, a fan-out cap per
node, framework-boundary detection so the walk stops at ASP.NET internals
instead of descending into them, and a revisit guard for cycles. A trace that
tried to be complete would be as unreadable as the codebase; the job is to be
&lt;em&gt;truthful within a budget&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;The point&lt;/h2&gt;
&lt;p&gt;I wrote before about giving agents &lt;a href=&quot;https://shaahink.github.io/site/blog/point-dont-re-explore&quot;&gt;a map instead of letting them re-explore
&lt;/a&gt; the repo every session. The map is half the
answer. The other half is the trace — and a trace is only worth feeding to a
model if it survives the question &quot;how do you know?&quot;. Build the edges from
joins, make them cite file and line, and rank meaning above mechanics: that&apos;s
the difference between a code graph and a very confident lie.&lt;/p&gt;
</content:encoded><category>dotnet</category><category>roslyn</category><category>ai</category><category>tooling</category></item><item><title>Point, don&apos;t re-explore: giving an LLM a map of your .NET repo</title><link>https://shaahink.github.io/site/blog/point-dont-re-explore/</link><guid isPermaLink="true">https://shaahink.github.io/site/blog/point-dont-re-explore/</guid><description>Agents burn tokens re-discovering the same codebase every session. Here&apos;s the case for handing them a structured map instead — and how DevContext builds one with Roslyn.</description><pubDate>Tue, 14 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every coding agent I&apos;ve used has the same expensive habit: it re-explores the
repository from cold at the start of every session. It greps, it opens files, it
follows references, it rebuilds a mental model — and then the session ends and all
of that context evaporates. Next session, it does it again.&lt;/p&gt;
&lt;p&gt;For a small project that&apos;s fine. For a real .NET solution — dozens of projects,
layers of dependency injection, MediatR handlers wired up by convention — it&apos;s
slow, costs a fortune in tokens, and the model still gets the wiring wrong.&lt;/p&gt;
&lt;h2&gt;The idea&lt;/h2&gt;
&lt;p&gt;What if the first thing an agent read wasn&apos;t the raw source, but a &lt;strong&gt;map&lt;/strong&gt;? A
compact, honest description of the architecture: the projects, how they depend on
each other, where the entry points are, and how a request actually flows through
the indirection.&lt;/p&gt;
&lt;p&gt;That&apos;s what &lt;a href=&quot;https://github.com/shaahink/DevContext&quot;&gt;DevContext&lt;/a&gt; does. It&apos;s a CLI
that analyses a &lt;code&gt;.sln&lt;/code&gt; and emits structured context — Markdown or JSON — designed
to be dropped straight into an LLM&apos;s context window.&lt;/p&gt;
&lt;h2&gt;Why Roslyn&lt;/h2&gt;
&lt;p&gt;You can&apos;t build an honest map with regex. The whole point is to resolve
indirection: to say &quot;this controller action ends up calling &lt;em&gt;that&lt;/em&gt; handler&quot; even
when nothing in the text links them. That needs real semantic analysis, which in
.NET means Roslyn.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;using var workspace = MSBuildWorkspace.Create();
var solution = await workspace.OpenSolutionAsync(solutionPath);

foreach (var project in solution.Projects)
{
    var compilation = await project.GetCompilationAsync();
    foreach (var tree in compilation.SyntaxTrees)
    {
        var model = compilation.GetSemanticModel(tree);
        var root = await tree.GetRootAsync();

        foreach (var invocation in root.DescendantNodes().OfType&amp;lt;InvocationExpressionSyntax&amp;gt;())
        {
            var symbol = model.GetSymbolInfo(invocation).Symbol;
            // symbol.ContainingType tells you where this call *actually* lands —
            // even across an interface or a DI registration.
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The semantic model is the difference between &quot;these two files mention the same
name&quot; and &quot;this call is dispatched to this concrete type.&quot;&lt;/p&gt;
&lt;h2&gt;The rule that keeps it useful&lt;/h2&gt;
&lt;p&gt;There&apos;s one discipline that matters more than any feature: &lt;strong&gt;never show what the
tool can&apos;t honestly answer.&lt;/strong&gt; A map that invents an edge — a confident but wrong
&quot;this calls that&quot; — is worse than no map at all, because the agent will trust it
and build on the lie.&lt;/p&gt;
&lt;p&gt;So DevContext is explicit about scope and confidence. It says &lt;em&gt;what&lt;/em&gt; it analysed
(the whole solution, or one project&apos;s closure) and &lt;em&gt;how sure&lt;/em&gt; it is about each
edge. A tool you reach for blindly has to be self-aware about its blind spots.&lt;/p&gt;
&lt;h2&gt;Where this goes&lt;/h2&gt;
&lt;p&gt;DevContext started as a one-shot context generator. The direction it&apos;s heading —
a queryable graph you can browse, trace, and expose over MCP — is the more
interesting version: one kernel, many faces. But the core bet is the same. Don&apos;t
make the model re-explore. Hand it the map.&lt;/p&gt;
</content:encoded><category>dotnet</category><category>ai</category><category>tooling</category></item><item><title>Vertical slices, not onions: structuring a .NET service for change</title><link>https://shaahink.github.io/site/blog/vertical-slices-not-onions/</link><guid isPermaLink="true">https://shaahink.github.io/site/blog/vertical-slices-not-onions/</guid><description>Layered/onion architecture optimises for a kind of reuse most services never need. Feature-first vertical slices optimise for the thing you actually do all day: change one behaviour without touching five folders.</description><pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Open a &quot;clean architecture&quot; .NET solution and you know the shape before you read a
line: &lt;code&gt;Domain&lt;/code&gt;, &lt;code&gt;Application&lt;/code&gt;, &lt;code&gt;Infrastructure&lt;/code&gt;, &lt;code&gt;Api&lt;/code&gt;. To add one endpoint you
touch a controller, a service interface, its implementation, a repository
interface, its implementation, a DTO, and a mapper — seven files across four
projects for one behaviour.&lt;/p&gt;
&lt;p&gt;That layout optimises for swapping a whole horizontal layer. In fifteen years I
have almost never done that. What I do every day is change &lt;strong&gt;one feature&lt;/strong&gt;. So I
organise around features.&lt;/p&gt;
&lt;h2&gt;Slices, not layers&lt;/h2&gt;
&lt;p&gt;A vertical slice keeps everything one feature needs in one place — the endpoint,
its validation, its handler, its data access — and lets unrelated features stay
unrelated.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Features/
  Orders/
    CreateOrder.cs        // endpoint + request + validator + handler
    GetOrder.cs
    Orders.Endpoints.cs   // maps the group
  Catalog/
    SearchCatalog.cs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;CreateOrder.cs&lt;/code&gt; holds the whole story:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public static class CreateOrder
{
    public record Request(string Sku, int Quantity);

    public class Validator : AbstractValidator&amp;lt;Request&amp;gt;
    {
        public Validator()
        {
            RuleFor(x =&amp;gt; x.Sku).NotEmpty();
            RuleFor(x =&amp;gt; x.Quantity).GreaterThan(0);
        }
    }

    public static async Task&amp;lt;Results&amp;lt;Created&amp;lt;OrderId&amp;gt;, ValidationProblem&amp;gt;&amp;gt; Handle(
        Request request, AppDbContext db, CancellationToken ct)
    {
        var order = Order.Create(request.Sku, request.Quantity);
        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);
        return TypedResults.Created($&quot;/orders/{order.Id}&quot;, order.Id);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No &lt;code&gt;IOrderService&lt;/code&gt;, no &lt;code&gt;IOrderRepository&lt;/code&gt;, no AutoMapper profile. A minimal-API
endpoint calls &lt;code&gt;Handle&lt;/code&gt; directly. Cross-cutting concerns — auth, logging,
validation — live in endpoint filters, not in a mediator pipeline.&lt;/p&gt;
&lt;h2&gt;&quot;But you&apos;re not decoupled!&quot;&lt;/h2&gt;
&lt;p&gt;Right — and that&apos;s the point. Decoupling has a cost, and you should pay it where
coupling actually hurts, not everywhere by reflex. Two rules keep this honest:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Share the domain, duplicate the plumbing.&lt;/strong&gt; Entities and invariants are
shared. A read model that happens to look like another feature&apos;s read model is
&lt;em&gt;not&lt;/em&gt; shared — small duplication beats a wrong abstraction two features fight
over.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Abstract at the real seam.&lt;/strong&gt; The database, the clock, an external API — those
are worth an interface because you genuinely swap them (tests, providers). A
handler is not a seam. Don&apos;t wrap it in one.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;When a slice grows complex, it gets a private helper &lt;em&gt;inside the slice&lt;/em&gt;. It never
graduates to a shared service that a dozen features quietly depend on.&lt;/p&gt;
&lt;h2&gt;What you get&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Reading a feature is local.&lt;/strong&gt; Everything is in one file. New joiners map a
change to a place in seconds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deleting a feature is &lt;code&gt;rm&lt;/code&gt;.&lt;/strong&gt; No dangling interfaces or half-used services.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tests target behaviour.&lt;/strong&gt; I test the handler against a real database
(Testcontainers) rather than mocking four layers to assert the mocks were called.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Onion architecture isn&apos;t wrong. It&apos;s a big up-front bet on flexibility you can
usually buy later, at the exact seam, when you actually need it. Vertical slices
bet on the thing you do constantly — changing one behaviour — and make that cheap.&lt;/p&gt;
</content:encoded><category>dotnet</category><category>architecture</category></item><item><title>Typed tools beat a raw shell: designing an MCP server for agents</title><link>https://shaahink.github.io/site/blog/typed-tools-beat-a-shell/</link><guid isPermaLink="true">https://shaahink.github.io/site/blog/typed-tools-beat-a-shell/</guid><description>Handing an agent a shell is easy and, past a toy, a mistake. The reliable path is a small set of typed, validated, least-privilege tools it can&apos;t misuse — which is an API design problem, not a prompting one.</description><pubDate>Tue, 30 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The quickest way to make an LLM &quot;agentic&quot; is to give it a shell. It works in a
demo and then betrays you: the model shells out with a subtly wrong flag, deletes
the wrong path, or pipes half a gigabyte of log into its own context and forgets
what it was doing. A raw shell is maximal capability with zero guardrails.&lt;/p&gt;
&lt;p&gt;The reliable alternative is boring: give the agent a &lt;strong&gt;small set of typed tools&lt;/strong&gt;,
each of which does exactly one thing, validates its input, and refuses everything
else. That&apos;s the whole idea behind the &lt;a href=&quot;https://modelcontextprotocol.io&quot;&gt;Model Context
Protocol&lt;/a&gt; — and designing a good MCP server is an
API design problem, not a prompt-engineering one.&lt;/p&gt;
&lt;h2&gt;A tool is a contract, not a suggestion&lt;/h2&gt;
&lt;p&gt;A tool has a name, a typed input schema, and a description the model reads to
decide when to call it. The schema is the guardrail: the arguments are validated
&lt;em&gt;before&lt;/em&gt; your code runs, so the model physically cannot call &lt;code&gt;get_order&lt;/code&gt; without a
well-formed &lt;code&gt;orderId&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[McpServerTool, Description(&quot;Fetch a single order by id. Read-only.&quot;)]
public static async Task&amp;lt;OrderDto&amp;gt; GetOrder(
    OrderService orders,
    [Description(&quot;The order&apos;s GUID, e.g. 3fa85f64-...&quot;)] Guid orderId,
    CancellationToken ct)
{
    var order = await orders.FindAsync(orderId, ct)
        ?? throw new McpException($&quot;No order found for {orderId}.&quot;);
    return OrderDto.From(order);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things are doing quiet work here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The type is the validation.&lt;/strong&gt; &lt;code&gt;Guid orderId&lt;/code&gt; means malformed ids never reach
your logic — the protocol layer rejects them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The description is the interface.&lt;/strong&gt; &quot;Read-only&quot;, the id format, the example —
the model plans against that text. Vague descriptions cause misuse the same way
vague docs cause bad API calls.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The error is structured.&lt;/strong&gt; A clear &lt;code&gt;McpException&lt;/code&gt; lets the agent recover on the
next turn instead of hallucinating what went wrong.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Design principles that survive contact with a model&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Least privilege, always.&lt;/strong&gt; Prefer &lt;code&gt;get_order&lt;/code&gt; and &lt;code&gt;refund_order&lt;/code&gt; over one
&lt;code&gt;run_sql&lt;/code&gt;. Every tool you expose is attack surface the model can stumble into.
If a tool can&apos;t be misused, you don&apos;t have to hope the prompt keeps it in line.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Return structured data, not a wall of text.&lt;/strong&gt; Hand back a typed DTO, not
dumped stdout. The model reasons far better over &lt;code&gt;{ &quot;status&quot;: &quot;shipped&quot; }&lt;/code&gt; than
over 400 lines it has to parse — and you keep its context lean.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make writes obvious and idempotent.&lt;/strong&gt; Name mutating tools like mutations,
validate hard, and design them so a retried call is safe. Agents retry.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bound every output.&lt;/strong&gt; A tool that can return unbounded data is a context
bomb. Page it, cap it, summarise it — decide the limit yourself.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Why this is the un-glamorous, correct path&lt;/h2&gt;
&lt;p&gt;Giving a model a shell feels powerful because it offloads the hard part — deciding
what the system should actually let happen — onto the model&apos;s judgement, turn by
turn. Typed tools put that decision back where it belongs: in an interface you
designed, reviewed, and tested once.&lt;/p&gt;
&lt;p&gt;It&apos;s the same instinct as everywhere else in engineering. You don&apos;t hand a caller
raw database access and hope; you give them an API. An agent is just another
caller — a fast, capable, occasionally overconfident one. Design for it like you&apos;d
design for any client you don&apos;t fully control.&lt;/p&gt;
</content:encoded><category>ai</category><category>agents</category><category>tooling</category></item><item><title>Don&apos;t trust the agent — verify it</title><link>https://shaahink.github.io/site/blog/dont-trust-the-agent/</link><guid isPermaLink="true">https://shaahink.github.io/site/blog/dont-trust-the-agent/</guid><description>Autonomous coding agents lie by omission: they report success they didn&apos;t earn. The fix isn&apos;t a better prompt — it&apos;s an orchestrator that independently checks every claim.</description><pubDate>Sat, 20 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;If you let a coding agent run unattended across a long plan, you eventually hit
the core problem: &lt;strong&gt;an agent&apos;s report of its own work is not evidence.&lt;/strong&gt; It will
tell you a stage is done. Sometimes it is. Sometimes it edited the wrong file,
sometimes the build is red, sometimes it wrote no code at all and narrated
progress anyway.&lt;/p&gt;
&lt;p&gt;You can&apos;t prompt your way out of this. The report and the work come from the same
model in the same session — there&apos;s no independent witness. So I built one.&lt;/p&gt;
&lt;h2&gt;Evidence, or it didn&apos;t happen&lt;/h2&gt;
&lt;p&gt;The orchestrator I&apos;ve been working on (&lt;a href=&quot;https://shaahink.github.io/site/projects#conductor&quot;&gt;Conductor&lt;/a&gt;) treats every
session&apos;s claims as unverified until it checks them itself, out of process. After
a session exits, it looks at three independent signals:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The gate battery&lt;/strong&gt; — it re-runs build, tests, and lint itself and reads the
real exit codes. Not the agent&apos;s summary of them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Git&lt;/strong&gt; — did new commits actually appear?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The tracker&lt;/strong&gt; — did a checkpoint&apos;s status genuinely flip to &lt;code&gt;DONE&lt;/code&gt;?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A checkpoint only counts when all three agree:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;gates green  +  new commits exist  +  a checkpoint flipped to DONE
        →  Advanced (move on)

any required gate red
        →  GatesRed (next session is a *fix* session, with the
           failing output embedded in its prompt)

gates green, no commits
        →  NoProgress (the attempt is burned)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The asymmetry is deliberate. Advancing requires &lt;em&gt;positive evidence from every
source&lt;/em&gt;. Failing requires only &lt;em&gt;one&lt;/em&gt; source to disagree.&lt;/p&gt;
&lt;h2&gt;&quot;All done&quot; is confirmed, not believed&lt;/h2&gt;
&lt;p&gt;The nicest bug this caught: an agent that marked the whole plan complete while the
final integration test was quietly failing. Because &quot;the plan is done&quot; triggers
one more &lt;strong&gt;full&lt;/strong&gt; gate battery before declaring victory, the orchestrator caught
the red test and kicked off a fix session instead of celebrating.&lt;/p&gt;
&lt;p&gt;That&apos;s the whole philosophy in one line: &lt;em&gt;deterministic first, model second.&lt;/em&gt; Use
the cheap, boring, reliable checks — exit codes, git, a status table — as the
source of truth. Only consult the model when you&apos;ve genuinely hit a dead end, and
even then, validate its answer against a fixed vocabulary of actions.&lt;/p&gt;
&lt;h2&gt;Why this generalises&lt;/h2&gt;
&lt;p&gt;You don&apos;t need an agent orchestrator to use this. Any time you&apos;re automating
something with an LLM in the loop, ask: &lt;em&gt;what&apos;s my independent witness?&lt;/em&gt; If the
only thing telling you the work succeeded is the same thing that did the work,
you don&apos;t have verification. You have a vibe.&lt;/p&gt;
</content:encoded><category>ai</category><category>agents</category><category>architecture</category></item></channel></rss>