Closing the Loop with AI (part 3): Moving the Human to the End of the Pipeline

Closing the Loop with AI (part 3): Moving the Human to the End of the Pipeline

My last two articles have been about one idea: closing the loop with AI.

Not “AI-assisted coding.” Not “AI that helps you write functions.”
I’m talking about something else entirely.

I’m talking about building systems where the agent writes the code, tests the code, evaluates the result,
fixes the code, and repeats — without me sitting in the middle acting like a tired QA engineer.

Because honestly, that middle position is the worst place to be.

You get exhausted. You lose objectivity. And eventually you look at the project and think:
everything here is garbage.

So the goal is simple:

Remove the human from the middle of the loop.

Place the human at the end of the loop.

The human should only confirm: “Is this what I asked for?”
Not manually test every button.

The Real Question: How Do You Close the Loop?

There isn’t a single answer. It depends on the technology stack and the type of application you’re building.
So far, I’ve been experimenting with three environments:

  • Console applications
  • Web applications
  • Windows Forms applications (still a work in progress)

Each one requires a slightly different strategy.

But the core principle is always the same:

The agent must be able to observe what it did.

If the agent cannot see logs, outputs, state, or results — the loop stays open.

Console Applications: The Easiest Loop to Close

Console apps are the simplest place to start.

My setup is minimal and extremely effective:

  • Serilog writing structured logs
  • Logs written to the file system
  • Output written to the console

Why both?

Because the agent (GitHub Copilot in VS Code) can run the app, read console output, inspect log files,
decide what to fix, and repeat.

No UI. No browser. No complex state.
Just input → execution → output → evaluation.

If you want to experiment with autonomous loops, start here. Console apps are the cleanest lab environment you’ll ever get.

Web Applications: Where Things Get Interesting

Web apps are more complex, but also more powerful.

My current toolset:

  • Serilog for structured logging
  • Logs written to filesystem
  • SQLite for loop-friendly database inspection
  • Playwright for automated UI testing

Even if production uses PostgreSQL or SQL Server, I use SQLite during loop testing.
Not for production. For iteration.

The SQLite CLI makes inspection trivial.
The agent can call the API, trigger workflows, query SQLite directly, verify results, and continue fixing.

That’s a full feedback loop. No human required.

Playwright: Giving the Agent Eyes

For UI testing, Playwright is the key.

You can run it headless (fully autonomous) or with UI visible (my preferred mode).

Yes, I could remove myself completely. But I don’t.
Right now I sit outside the loop as an observer.
Not a tester. Not a debugger. Just watching.

If something goes completely off the rails, I interrupt.
Otherwise, I let the loop run.

This is an important transition:

From participant → to observer.

The Windows Forms Problem

Now comes the tricky part: Windows Forms.

Console apps are easy. Web apps have Playwright.
But desktop UI automation is messy.

Possible directions I’m exploring:

  • UI Automation APIs
  • WinAppDriver
  • Logging + state inspection hybrid approach
  • Screenshot-based verification
  • Accessibility tree inspection

The goal remains the same: the agent must be able to verify what happened without me.

Once that happens, the loop closes.

What I’ve Learned So Far

1) Logs Are Everything

If the agent cannot read what happened, it cannot improve. Structured logs > pretty logs. Always.

2) SQLite Is the Perfect Loop Database

Not for production. For iteration. The ability to query state instantly from CLI makes autonomous debugging possible.

3) Agents Need Observability, Not Prompts

Most people focus on prompt engineering. I focus on observability engineering.
Give the agent visibility into logs, state, outputs, errors, and the database. Then iteration becomes natural.

4) Humans Should Validate Outcomes — Not Steps

The human should only answer: “Is this what I asked for?” That’s what the agent is for.

My Current Loop Architecture (Simplified)

Specification → Agent writes code → Agent runs app → Agent tests → Agent reads logs/db →
Agent fixes → Repeat → Human validates outcome

If the loop works, progress becomes exponential.
If the loop is broken, everything slows down.

My Question to You

This is still evolving. I’m refining the process daily, and I’m convinced this is how development will work from now on:
agents running closed feedback loops with humans validating outcomes at the end.

So I’m curious:

  • What tooling are you using?
  • How are you creating feedback loops?
  • Are you still inside the loop — or already outside watching it run?

Because once you close the loop…
you don’t want to go back.

 

Closing the Loop (Part 2): So Far, So Good — and Yes, It’s Token Hungry

Closing the Loop (Part 2): So Far, So Good — and Yes, It’s Token Hungry

I wrote my previous article about closing the loop for agentic development earlier this week, although the ideas themselves have been evolving for several days. This new piece is simply a progress report: how the approach is working in practice, what I’ve built so far, and what I’m learning as I push deeper into this workflow.

Short version: it’s working.
Long version: it’s working really well — but it’s also incredibly token-hungry.

Let’s talk about it.

A Familiar Benchmark: The Activity Stream Problem

Whenever I want to test a new development approach, I go back to a problem I know extremely well: building an activity stream.

An activity stream is basically the engine of a social network — posts, reactions, notifications, timelines, relationships. It touches everything:

  • Backend logic
  • UI behavior
  • Realtime updates
  • State management
  • Edge cases everywhere

I’ve implemented this many times before, so I know exactly how it should behave. That makes it the perfect benchmark for agentic development. If the AI handles this correctly, I know the workflow is solid.

This time, I used it to test the closing-the-loop concept.

The Current Setup

So far, I’ve built two main pieces:

  1. An MCP-based project
  2. A Blazor application implementing the activity stream

But the real experiment isn’t the app itself — it’s the workflow.

Instead of manually testing and debugging, I fully committed to this idea:

The AI writes, tests, observes, corrects, and repeats — without me acting as the middleman.

So I told Copilot very clearly:

  • Don’t ask me to test anything
  • You run the tests
  • You fix the issues
  • You verify the results

To make that possible, I wired everything together:

  • Playwright MCP for automated UI testing
  • Serilog logging to the file system
  • Screenshot capture of the UI during tests
  • Instructions to analyze logs and fix issues automatically

So the loop becomes:

write → test → observe → fix → retest

And honestly, I love it.

My Surface Is Working. I’m Not Touching It.

Here’s the funny part.

I’m writing this article on my MacBook Air.

Why?

Because my main development machine — a Microsoft Surface laptop — is currently busy running the entire loop by itself.

I told Copilot to open the browser and actually execute the tests visually. So it’s navigating the UI, filling forms, clicking buttons, taking screenshots… all by itself.

And I don’t want to touch that machine while it’s working.

It feels like watching a robot doing your job. You don’t interrupt it mid-task. You just observe.

So I switched computers and thought: “Okay, this is a perfect moment to write about what’s happening.”

That alone says a lot about where this workflow is heading.

Watching the Loop Close

Once everything was wired together, I let it run.

The agent:

  • Writes code
  • Runs Playwright tests
  • Reads logs
  • Reviews screenshots
  • Detects issues
  • Fixes them
  • Runs again

Seeing the system self-correct without constant intervention is incredibly satisfying.

In traditional AI-assisted development, you often end up exhausted:

  • The AI gets stuck
  • You explain the issue
  • It half-fixes it
  • You explain again
  • Something else breaks

You become the translator and debugger for the model.

With a self-correcting loop, that burden drops dramatically. The system can fail, observe, and recover on its own.

That changes everything.

The Token Problem (Yes, It’s Real)

There is one downside: this workflow is extremely token hungry.

Last month I used roughly 700% more tokens than usual. This month, and we’re only around February 8–9, I’ve already used about 200% of my normal limits.

Why so expensive?

Because the loop never sleeps:

  • Test execution
  • Log analysis
  • Screenshot interpretation
  • Code rewriting
  • Retesting
  • Iteration

Every cycle consumes tokens. And when the system is autonomous, those cycles happen constantly.

Model Choice Matters More Than You Think

Another important detail: not all models consume tokens equally inside Copilot.

Some models count as:

  • 3× usage
  • 1× usage
  • 0.33× usage
  • 0× usage

For example:

  • Some Anthropic models are extremely good for testing and reasoning
  • But they can count as 3× token usage
  • Others are cheaper but weaker
  • Some models (like GPT-4 Mini or GPT-4o in certain Copilot tiers) count as toward limits

At some point I actually hit my token limits and Copilot basically said: “Come back later.”

It should reset in about 24 hours, but in the meantime I switched to the 0× token models just to keep the loop running.

The difference in quality is noticeable.

The heavier models are much better at:

  • Debugging
  • Understanding logs
  • Self-correcting
  • Complex reasoning

The lighter or free models can still work, but they struggle more with autonomous correction.

So model selection isn’t just about intelligence — it’s about token economics.

Why It’s Still Worth It

Yes, this approach consumes more tokens.

But compare that to the alternative:

  • Sitting there manually testing
  • Explaining the same bug five times
  • Watching the AI fail repeatedly
  • Losing mental energy on trivial fixes

That’s expensive too — just not measured in tokens.

I would rather spend tokens than spend mental fatigue.

And realistically:

  • Models get cheaper every month
  • Tooling improves weekly
  • Context handling improves
  • Local and hybrid options are evolving

What feels expensive today might feel trivial very soon.

MCP + Blazor: A Perfect Testing Ground

So far, this workflow works especially well for:

  • MCP-based systems
  • Blazor applications
  • Known benchmark problems

Using a familiar problem like an activity stream lets me clearly measure progress. If the agent can build and maintain something complex that I already understand deeply, that’s a strong signal.

Right now, the signal is positive.

The loop is closing. The system is self-correcting. And it’s actually usable.

What Comes Next

This article is just a status update.

The next one will go deeper into something very important:

How to design self-correcting mechanisms for agentic development.

Because once you see an agent test, observe, and fix itself, you don’t want to go back to manual babysitting.

For now, though:

The idea is working. The workflow feels right. It’s token hungry. But absolutely worth it.

Closing the loop isn’t theory anymore — it’s becoming a real development style.

 

Closing the Loop: Letting AI Finish the Work

Closing the Loop: Letting AI Finish the Work

Last week I was in Sochi on a ski trip. Instead of skiing, I got sick.

So I spent a few days locked in a hotel room, doing what I always do when I can’t move much: working. Or at least what looks like work. In reality, it’s my hobby.

YouTube wasn’t working well there, so I downloaded a few episodes in advance. Most of them were about OpenClaw and its creator, Peter Steinberger — also known for building PSPDFKit.

What started as passive watching turned into one of those rare moments of clarity you only get when you’re forced to slow down.

Shipping Code You Don’t Read (In the Right Context)

In one of the interviews, Peter said something that immediately caught my attention: he ships code he doesn’t review.

At first that sounds reckless. But then I realized… I sometimes do the same.

However, context matters.

Most of my daily work is research and development. I build experimental systems, prototypes, and proofs of concept — either for our internal office or for exploring ideas with clients. A lot of what I write is not production software yet. It’s exploratory. It’s about testing possibilities.

In that environment, I don’t always need to read every line of generated code.

If the use case works and the tests pass, that’s often enough.

I work mainly with C#, ASP.NET, Entity Framework, and XAF from DevExpress. I know these ecosystems extremely well. So if something breaks later, I can go in and fix it myself. But most of the time, the goal isn’t to perfect the implementation — it’s to validate the idea.

That’s a crucial distinction.

When writing production code for a customer, quality and review absolutely matter. You must inspect, verify, and ensure maintainability. But when working on experimental R&D, the priority is different: speed of validation and clarity of results.

In research mode, not every line needs to be perfect. It just needs to prove whether the idea works.

Working “Without Hands”

My real goal is to operate as much as possible without hands.

By that I mean minimizing direct human interaction with implementation. I want to express intent clearly enough so agents can execute it.

If I can describe a system precisely — especially in domains I know deeply — then the agent should be able to build, test, and refine it. My role becomes guiding and validating rather than manually constructing everything.

This is where modern development is heading.

The Problem With Vibe Coding

Peter talked about something that resonated deeply: when you’re vibe coding, you produce a lot of AI slop.

You prompt. The AI generates. You run it. It fails. You tweak. You run again. Still wrong. You tweak again.

Eventually, the human gets tired.

Even when you feel close to a solution, it’s not done until it’s actually done. And manually pushing that process forward becomes exhausting.

This is where many AI workflows break down. Not because the AI can’t generate solutions — but because the loop still depends too heavily on human intervention.

Closing the Loop

The key idea is simple and powerful: agentic development works when the agent can test and correct itself.

You must close the loop.

Instead of: human → prompt → AI → human checks → repeat

You want: AI → builds → tests → detects errors → fixes → tests again → repeat

The agent needs tools to evaluate its own output.

When AI can run tests, detect failures, and iterate automatically, something shifts. The process stops being experimental prompting and starts becoming real engineering.

Spec-Driven vs Self-Correcting Systems

Spec-driven development still matters. Some people dismiss it as too close to waterfall, but every methodology has flaws.

The real evolution is combining clear specifications with self-correcting loops.

The human defines:

  • The specification
  • The expected behavior
  • The acceptance criteria

Then the AI executes, tests, and refines until those criteria are satisfied.

The human doesn’t need to babysit every iteration. The human validates the result once the loop is closed.

Engineering vs Parasitic Ideas

There’s a concept from a book about parasitic ideas.

In social sciences, parasitic ideas can spread because they’re hard to disprove. In engineering, bad ideas fail quickly.

If you design a bridge incorrectly, it collapses. Reality provides immediate feedback.

Software — especially AI-generated software — needs the same grounding in reality. Without continuous testing and validation, generated code can drift into something that looks plausible but doesn’t work.

Closing the loop forces ideas to confront reality.

Tests are that reality.

Taking the Human Out of the Repetitive Loop

The goal isn’t removing humans entirely. It’s removing humans from repetitive validation.

The human should:

  • Define the specification
  • Define what “done” means
  • Approve the final result

The AI should:

  • Implement
  • Test
  • Detect issues
  • Fix itself
  • Repeat until success

When that happens, development becomes scalable in a new way. Not because AI writes code faster — but because AI can finish what it starts.

What I Realized in That Hotel Room

Getting sick in Sochi wasn’t part of the plan. But it forced me to slow down long enough to notice something important.

Most friction in modern development isn’t writing code. It’s closing loops.

We generate faster than we validate. We start more than we finish. We rely on humans to constantly re-check work that machines could verify themselves.

In research and experimental work, it’s fine not to inspect every line — as long as the system proves its behavior. In production work, deeper review is essential. Knowing when each approach applies is part of modern engineering maturity.

The future of agentic development isn’t just better models. It’s better loops.

Because in the end, nothing is finished until the loop is closed.

 

Github Copilot for the Rest of Us

Github Copilot for the Rest of Us

How GitHub Copilot Became My Sysadmin, Writer, and Creative Partner

When people talk about GitHub Copilot, they almost always describe it the same way: an AI that writes code.
That’s true—Copilot can write code—but treating it as “just a coding tool” is like calling a smartphone
“a device for making phone calls.”

The moment you start using Copilot inside Visual Studio Code, something important changes:
it stops being a code generator and starts behaving more like a context-aware work partner.
Not because it magically knows everything—but because VS Code gives it access to the things that matter:
your files, your folders, your terminals, your scripts, your logs, and even your remote machines.

That’s why this article isn’t about code autocomplete. It’s about the other side of Copilot:
the part that’s useful for people who are building, maintaining, writing, organizing, diagnosing, or shipping
real work—especially the messy kind.

Copilot as a Linux Server Sidekick

One of my most common uses for Copilot has nothing to do with application logic.
I use it for Linux server setup and diagnostics.

If you run Copilot in VS Code and you also use Remote development (SSH), you essentially get a workspace that can:

  • Connect to Linux servers over SSH
  • Edit remote configuration files safely
  • Run commands and scripts in an integrated terminal
  • Search through logs and system files quickly
  • Manage folders like they’re local projects

That means Copilot isn’t “helping me code.” It’s helping me operate.

I often set up hosting and administration tools like Virtualmin or Webmin, or configure other infrastructure:
load balancers, web servers, SSL, firewall rules, backups—whatever the server needs to become stable and usable.
In those situations Copilot becomes the assistant that speeds up the most annoying parts:
the remembering, the searching, the cross-checking, and the “what does this error actually mean?”

What this looks like in practice

Instead of bouncing between browser tabs and old notes, I’ll use Copilot directly in the workspace:

  • “Explain what this service error means and suggest the next checks.”
  • “Read this log snippet and list the most likely causes.”
  • “Generate a safe Nginx config for this domain layout.”
  • “Create a hardening checklist for a fresh VPS.”
  • “What would you verify before assuming this is a network issue?”

The benefit isn’t that Copilot is always right. The benefit is that it helps you move faster with less friction—
and it keeps your work inside the same place where the files and commands actually live.

Copilot as an Operations Brain (Not Just a Code Brain)

Here’s the real mental shift:

Copilot doesn’t need to write code to be useful. It needs context.

In VS Code, that context includes the entire workspace: configuration files, scripts, documentation, logs,
command history, and whatever you’re currently working on. Once you realize that, Copilot becomes useful for:

  • Debugging infrastructure problems
  • Translating “error messages” into “actionable steps”
  • Drafting repeatable setup scripts
  • Creating operational runbooks and checklists
  • Turning tribal knowledge into documentation

It’s especially valuable when the work is messy and practical—when you’re not trying to invent something new,
you’re trying to make something work.

Copilot as a Writing Workspace

Now switch gears. One of the best non-coding Copilot stories I’ve seen is my cousin Alexandra.
She’s writing a small storybook.

She started the way a lot of people do: writing by hand, collecting pages, keeping ideas in scattered places.
At one point she was using Copilot through Microsoft Office, but I suggested a different approach:

Use VS Code as the creative workspace.

Not because VS Code is “a writing tool,” but because it gives you structure for free:

  • A folder becomes the book
  • Each chapter becomes a file
  • Markdown becomes a simple, readable format
  • Git (optionally) becomes version history
  • Copilot becomes the editor, brainstormer, and consistency checker

In that setup, Copilot isn’t writing the story for you. It’s helping you shape it:
rewrite a paragraph, suggest alternatives, tighten dialogue, keep a consistent voice,
summarize a scene, or generate a few options when you’re stuck.

Yes, Even Illustrations (Within Reason)

This surprises people: you can also support simple illustrations inside a VS Code workspace.
Not full-on painting, obviously—but enough for many small projects.

VS Code can handle things like vector graphics (SVG), simple diagram formats, and text-driven visuals.
If you describe a scene, Copilot can help generate a starting SVG illustration, and you can iterate from there.
It’s not about replacing professional design—it’s about making it easier to prototype, experiment,
and keep everything (text + assets) together in one organized place.

The Hidden Superpower: VS Code’s Ecosystem

Copilot is powerful on its own. But its real strength comes from where it lives.

VS Code brings the infrastructure:

  • Extensions for almost any workflow
  • Remote development over SSH
  • Integrated terminals and tasks
  • Search across files and folders
  • Versioning and history
  • Cross-platform consistency

So whether you’re configuring a server, drafting a runbook, organizing a book, or building a folder-based project,
Copilot adapts because the workspace defines the context.

The Reframe

If there’s one idea worth keeping, it’s this:

GitHub Copilot is not a coding tool. It’s a general-purpose work companion that happens to be excellent at code.

Once you stop limiting it to source files, it becomes:

  • A sysadmin assistant
  • A documentation partner
  • A creative editor
  • A workflow accelerator
  • A “second brain” inside the tools you already use

And the best part is that none of this requires a new platform or a new habit.
It’s the same VS Code workspace you already know—just used for more than code.

 

The Mirage of a Memory Leak (or: why “it must be the framework” is usually wrong)

The Mirage of a Memory Leak (or: why “it must be the framework” is usually wrong)

There is a familiar moment in every developer’s life.

Memory usage keeps creeping up.
The process never really goes down.
After hours—or days—the application feels heavier, slower, tired.

And the conclusion arrives almost automatically:

“The framework has a memory leak.”
“That component library is broken.”
“The GC isn’t doing its job.”

It’s a comforting explanation.

It’s also usually wrong.

Memory Leaks vs. Memory Retention

In managed runtimes like .NET, true memory leaks are rare.
The garbage collector is extremely good at reclaiming memory.
If an object is unreachable, it will be collected.

What most developers call a “memory leak” is actually
memory retention.

  • Objects are still referenced
  • So they stay alive
  • Forever

From the GC’s point of view, nothing is wrong.

From your point of view, RAM usage keeps climbing.

Why Frameworks Are the First to Be Blamed

When you open a profiler and look at what’s alive, you often see:

  • UI controls
  • ORM sessions
  • Binding infrastructure
  • Framework services

So it’s natural to conclude:

“This thing is leaking.”

But profilers don’t answer why something is alive.
They only show that it is alive.

Framework objects are usually not the cause — they are just sitting at the
end of a reference chain that starts in your code.

The Classic Culprit: Bad Event Wiring

The most common “mirage leak” is caused by events.

The pattern

  • A long-lived publisher (static service, global event hub, application-wide manager)
  • A short-lived subscriber (view, view model, controller)
  • A subscription that is never removed

That’s it. That’s the leak.

Why it happens

Events are references.
If the publisher lives for the lifetime of the process, anything it
references also lives for the lifetime of the process.

Your object doesn’t get garbage collected.

It becomes immortal.

The Immortal Object: When Short-Lived Becomes Eternal

An immortal object is an object that should be short-lived
but can never be garbage collected because it is still reachable from a GC
root.

Not because of a GC bug.
Not because of a framework leak.
But because our code made it immortal.

Static fields, singletons, global event hubs, timers, and background services
act as anchors. Once a short-lived object is attached to one of these, it
stops aging.

GC Root
  └── static / singleton / service
        └── Event, timer, or callback
              └── Delegate or closure
                    └── Immortal object
                          └── Large object graph

From the GC’s perspective, everything is valid and reachable.
From your perspective, memory never comes back down.

A Retention Dependency Tree That Cannot Be Collected

GC Root
  └── static GlobalEventHub.Instance
        └── GlobalEventHub.DataUpdated (event)
              └── delegate → CustomerViewModel.OnDataUpdated
                    └── CustomerViewModel
                          └── ObjectSpace / DbContext
                                └── IdentityMap / ChangeTracker
                                      └── Customer, Order, Invoice, ...

What you see in the memory dump:

  • thousands of entities
  • ORM internals
  • framework objects

What actually caused it:

  • one forgotten event unsubscription

The Lambda Trap (Even Worse, Because It Looks Innocent)

The code

public CustomerViewModel(GlobalEventHub hub)
{
    hub.DataUpdated += (_, e) =>
    {
        RefreshCustomer(e.CustomerId);
    };
}

This lambda captures this implicitly.
The compiler creates a hidden closure that keeps the instance alive.

“But I Disposed the Object!”

Disposal does not save you here.

  • Dispose does not remove event handlers
  • Dispose does not break static references
  • Dispose does not stop background work automatically

IDisposable is a promise — not a magic spell.

Leak-Hunting Checklist

Reference Roots

  • Are there static fields holding objects?
  • Are singletons referencing short-lived instances?
  • Is a background service keeping references alive?

Events

  • Are subscriptions always paired with unsubscriptions?
  • Are lambdas hiding captured references?

Timers & Async

  • Are timers stopped and disposed?
  • Are async loops cancellable?

Profiling

  • Follow GC roots, not object counts
  • Inspect retention paths
  • Ask: who is holding the reference?

Final Thought

Frameworks rarely leak memory.

We do.

Follow the references.
Trust the GC.
Question your wiring.

That’s when the mirage finally disappears.

 

As an XAF Developer, What Should I Actually Test?

As an XAF Developer, What Should I Actually Test?

This is a story about testing XAF applications — and why now is finally the right time to do it properly.

With Copilot agents and AI-assisted coding, writing code has become cheaper and faster than ever. Features that used to take days now take hours. Boilerplate is almost free.

And that changes something important.

For the first time, many of us actually have time to do the things we always postponed:

  • documenting the source code,
  • writing proper user manuals,
  • and — yes — writing tests.

But that immediately raises the real question:

What kind of tests should I even write?

Most developers use “unit tests” as a synonym for “tests”. But once you move beyond trivial libraries and into real application frameworks, that definition becomes fuzzy very quickly.

And nowhere is that more obvious than in XAF.

I’ve been working with XAF for something like 15–18 years (I’ve honestly lost count). It’s my preferred application framework, and it’s incredibly productive — but testing it “as-is” can feel like wrestling a framework-shaped octopus.

So let’s clarify something first.


You don’t test the framework. You test your logic.

XAF already gives you a lot for free:

  • CRUD
  • UI generation
  • validation plumbing
  • security system
  • object lifecycle
  • persistence

DevExpress has already tested those parts — thousands of times, probably millions by now.

So you do not need to write tests like:

  • “Can ObjectSpace save an object?”
  • “Does XAF load a View?”
  • “Does the security system work?”

You assume those things work.

Your responsibility is different.

You test the decisions your application makes.

That principle applies to XAF — and honestly, to any serious application framework.


The mental shift: what is a “unit”, really?

In classic theory, a unit is the smallest piece of code with a single responsibility — usually a method.

In real applications, that definition is often too small to be useful.

Sometimes the real “unit” is:

  • a workflow,
  • a business decision,
  • a state transition,
  • or a rule spanning multiple objects.

In XAF especially, the decision matters more than the method.

That’s why the right question is not “how do I unit test XAF?”
The right question is:

Which decisions in my app are important enough to protect?


The test pyramid for XAF

A practical, realistic test pyramid for XAF looks like this:

  1. Fast unit tests for pure logic
  2. Unit tests with thin seams around XAF-specific dependencies
  3. Integration tests with a real ObjectSpace (confidence tests)
  4. Minimal UI tests only for critical wiring

Let’s go layer by layer.


1) Push logic out of XAF into plain services (fast unit tests)

This is the biggest win you’ll ever get.

The moment you move important logic out of:

  • Controllers
  • Rules
  • ObjectSpace-heavy code

…testing becomes boring — and boring is good.

Put non-UI logic into:

  • Domain services (e.g. IInvoicePricingService)
  • Use-case handlers (CreateInvoiceHandler, PostInvoiceHandler)
  • Pure methods (no ObjectSpace, no View, no security calls)

Now you can test with plain xUnit / NUnit and simple mocks or fakes.

What is a service?

A service is code that makes business decisions.

It answers questions like:

  • “Can this invoice be posted?”
  • “Is this discount valid?”
  • “What is the total?”
  • “Is the user allowed to approve this?”

A service:

  • contains real logic
  • is framework-agnostic
  • is the thing you most want to unit test

If code decides why something happens, it belongs in a service.


2) Unit test XAF-specific logic with thin seams

Some logic will always touch XAF concepts. That’s fine.

The trick is not to eliminate XAF — it’s to isolate it.

You do that by introducing seams.

What is a seam?

A seam is a boundary where you can replace a real dependency with a fake one in a test.

A seam:

  • usually contains no business logic
  • exists mainly for testability
  • is often an interface or wrapper

Common XAF seams:

  • ICurrentUser instead of SecuritySystem.CurrentUser
  • IClock instead of DateTime.Now
  • repositories / unit-of-work instead of raw IObjectSpace
  • IUserNotifier instead of direct UI calls

Seams don’t decide anything — they just let you escape the framework in tests.

What does “adapter” mean in XAF?

An adapter is a very thin class whose job is to:

  • translate XAF concepts (View, ObjectSpace, Actions, Rules)
  • into calls to your services and use cases

Adapters:

  • contain little or no business logic
  • are allowed to be hard to unit test
  • exist to connect XAF to your code

Typical XAF adapters:

  • Controllers
  • Appearance Rules
  • Validation Rules
  • Action handlers
  • Property setters that delegate to services

The adapter is not the brain.
The brain lives in services.

What should you test here?

  • Appearance Rules
    Test the decision behind the rule (e.g. “Is this field editable now?”).
    Then confirm via integration tests that the rule is wired correctly.
  • Validation Rules
    Test the validation logic itself (conditions, edge cases).
    Optionally verify that the XAF rule triggers when expected.
  • Calculated properties / non-trivial setters
  • Controller decision logic once extracted from the Controller

3) Integration tests with a real ObjectSpace (confidence tests)

Unit tests prove your logic is correct.

Integration tests prove your XAF wiring still behaves.

They answer questions like:

  • Does persistence work?
  • Do validation and appearance rules trigger?
  • Do lifecycle hooks behave?
  • Does security configuration work as expected?

4) Minimal UI tests (only for critical wiring)

UI automation is expensive and fragile.

Keep UI tests only for:

  • Critical actions
  • Essential navigation flows
  • Known production regressions

The key mental model

A rule is not the unit.
The decision behind the rule is the unit.

Test the decision directly.
Use integration tests to confirm the glue still works.


Closing thought

Test your app’s decisions, not the framework’s behavior.

That’s the difference between a test suite that helps you move faster
and one that quietly turns into a tax.

 

Application Installers and Assembly Resolution Using the Legacy .NET Framework

Application Installers and Assembly Resolution Using the Legacy .NET Framework

Most .NET developers eventually face it.

A project that targets .NET Framework 4.7.2, uses video and audio components, depends on vendor SDKs, and mixes managed code, native DLLs, and legacy decisions.

In other words: a brownfield project.

This is the kind of system that still runs real businesses, even if it doesn’t fit neatly into modern slides about containers and self-contained deployments.

And it’s also where many developers discover — usually the hard way — that deployment is not just copying the Release folder and hoping for the best.

The Myth: “Just Copy the EXE”

I’ve seen this mindset for years:

“It works on my machine. Just copy the EXE and the DLLs to the client.”

Sometimes it works. Often it doesn’t.

And when it fails, it fails in the most frustrating ways:

  • Silent crashes
  • Missing assembly errors
  • COM exceptions that appear only on client machines
  • Video or audio features that break minutes after startup

The real issue isn’t the DLL.

The real issue is that most developers don’t understand how .NET Framework actually resolves assemblies.

How I Learned This the Hard Way (XPO + Pervasive, 2006)

The first time I truly understood this was around 2006, while writing a custom XPO provider for Pervasive 7.

At the time, the setup was fairly typical:

  • A .NET Framework application
  • Using DevExpress XPO
  • Talking to Pervasive SQL
  • The Pervasive .NET provider lived under Program Files
  • It was not registered in the GAC

On my development machine, everything worked.

On another machine? File not found. Or worse: a crash when XPO tried to initialize the provider.

The “fix” everyone used back then was almost ritual:

“Copy the Pervasive provider DLL into the same folder as the EXE.”

And suddenly… it worked.

That was my first real encounter with assembly probing — even though I didn’t know the name yet.

How Assembly Resolution Really Works in .NET Framework

.NET Framework does not scan your disk.

It does not care that a DLL exists under Program Files.

It follows a very strict resolution order.

1. Already Loaded Assemblies

If the assembly is already loaded in the current AppDomain, the CLR reuses it.

Simple.

2. Application Base Directory

Next, the CLR looks in the directory where the EXE lives.

This single rule explains years of “just copy the DLL next to the EXE” folklore.

In the Pervasive case, copying the provider locally worked because it entered the application base probing path.

3. Private Probing Paths

This is where things get interesting.

In app.config, you can extend the probing logic:

<runtime>
  <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
    <probing privatePath="lib;providers;drivers" />
  </assemblyBinding>
</runtime>

This tells the runtime:

“If you don’t find the assembly in the EXE folder, also look in these subfolders.”

Important details many developers miss:

  • Paths are relative to the EXE
  • No recursive search
  • Every folder must be explicitly listed

4. Global Assembly Cache (GAC)

Only after probing the application paths does the CLR look in the GAC, and only if:

  • The assembly is strong-named
  • The reference includes that strong name

Two common misconceptions:

  • A DLL being “installed on the system” does not matter
  • Non–strong-named assemblies are never loaded from the GAC

5. AssemblyResolve: The Last-Chance Hook

If the CLR cannot find an assembly using any of the rules above, it fires:

AppDomain.CurrentDomain.AssemblyResolve

This happens at runtime, exactly when the assembly is needed.

That’s why:

  • The app may start fine
  • The crash happens later
  • Video or database features fail “randomly”

Why Video and Audio Projects Amplify the Pain

Projects that deal with video codecs, audio pipelines, hardware acceleration, and vendor SDKs are especially vulnerable because:

  • Assemblies load late
  • Managed code pulls native DLLs
  • Bitness matters (x86 vs x64)
  • Licensing logic often lives outside managed code

The failure doesn’t happen at startup. It happens when the feature is first used.

The Final Step: Building a Real Installer

Eventually, I stopped pretending that copying files was enough.

I built a proper installer.

Even though today I often use the Visual Studio Installer Projects extension, for this legacy application I went with a WiX-based installer. Not because it was fashionable — but because it forced me to be explicit.

An installer asks uncomfortable questions:

  • Which assemblies belong in the GAC?
  • Which must live next to the EXE?
  • Which native DLLs must be deployed together?
  • Which dependencies only worked because Visual Studio copied them silently?

I had to inspect every file I was adding and make a conscious decision:

  • Shared, strong-named → GAC
  • App-local or version-sensitive → EXE folder
  • Native dependencies → exact placement matters

The installer didn’t magically fix the application.

It revealed the truth about it.

The Real Lesson of Brownfield Work

Legacy projects don’t fail because they’re old.

They fail because nobody understands them anymore.

Once you understand assembly probing, GAC rules, runtime loading, and deployment boundaries, brownfield systems stop being mysterious.

They become predictable.

What’s Next: COM (Yes, That COM)

This application doesn’t stop at managed assemblies.

It also depends heavily on COM components.

The next article will focus entirely on that world: what COM components really are, why they survived for decades, and how to work with them safely as a .NET developer.

If assembly probing was the first reality check, COM is the one that separates “it runs on my machine” from “this can be deployed.”

 

Greenfield vs Brownfield: How AI Changed the Way I Build and Rescue Software

Greenfield vs Brownfield: How AI Changed the Way I Build and Rescue Software

I recently listened to an episode of the Merge Conflict podcast by James Montemagno and Frank Krueger where a topic came up that, surprisingly, I had never explicitly framed before: greenfield vs brownfield projects.

That surprised me—not because the ideas were new, but because I’ve spent years deep in software architecture and AI, and yet I had never put a name to something I deal with almost daily.

Once I did a bit of research (and yes, asked ChatGPT too), everything clicked.


Greenfield and Brownfield, in Simple Terms

  • Greenfield projects are built from scratch. No legacy code, no historical baggage, no technical debt.
  • Brownfield projects already exist. They carry history: multiple teams, different styles, shortcuts, and decisions made under pressure.

If that sounds abstract, here’s the practical version:

Greenfield is what we want.

Brownfield is what we usually get.


Greenfield Projects: Architecture Paradise

In a greenfield project, everything feels right.

You can choose your architecture and actually stick to it. If you’re building a .NET MAUI application, you can start with proper MVVM, SOLID principles, clean boundaries, and consistent conventions from day one.

As developers, we know how things should be done. Greenfield projects give us permission to do exactly that.

They’re also extremely friendly to AI tools.

When the rules are clear and consistent, Copilot and AI agents perform beautifully. You can define specs, outline patterns, and let the tooling do a lot of the repetitive work for you.

That’s why I often use AI for greenfield projects as internal tools or side projects—things I’ve always known how to build, but never had the time to prioritize. Today, time is no longer the constraint. Tokens are.


Brownfield Projects: Welcome to Reality

Then there’s the real world.

At the office, we work with applications that have been touched by many hands over many years—sometimes 10 different teams, sometimes freelancers, sometimes “someone’s cousin who fixed it once.”

Each left behind a different style, different patterns, and different assumptions.

Customers often describe their systems like this:

“One team built it, another modified it, then my cousin fixed a bug, then my cousin got married and stopped helping, and then someone else took over.”

And yet—the system works.

That’s an important reminder.

The main job of software is not to be beautiful. It’s to do the job.

A lot of brownfield systems are ugly, fragile, and terrifying to touch—but they deliver real business value every single day.


Why AI Is Even More Powerful in Brownfield Projects

Here’s my honest opinion, based on experience:

AI is even more valuable in brownfield projects than in greenfield ones.

I’ve modernized six or seven legacy applications so far—codebases that everyone was afraid to touch. AI made that possible.

Legacy systems are mentally expensive. Reading spaghetti code drains energy. Understanding implicit behavior takes time. Humans get tired.

AI doesn’t.

It will patiently analyze a 2,000-line class without complaining.

Take Windows Forms applications as an example. It’s old technology, easy to forget, and full of quirks. Copilot can generate code that I know how to write—but much faster than I could after years away from WinForms.

Even more importantly, AI makes it far easier to introduce tests into systems that never had them:

  • Add tests class by class
  • Mock dependencies safely
  • Lock in existing behavior before refactoring

Historically, this was painful enough that many teams preferred a full rewrite.

But rewrites have a hidden cost: every rewritten line introduces new bugs.

AI allows us to modernize in place—incrementally and safely.


Clean Code and Business Value

This is the real win.

With AI, we no longer have to choose between:

  • “The code works, but don’t touch it”
  • “The code is beautiful, but nothing works yet”

We can improve structure, readability, and testability without breaking what already delivers value.

Greenfield projects are still fun. They’re great for experimentation and clean design.

But brownfield projects? That’s where AI feels like a superpower.


Final Thoughts

Today, I happily use AI in both worlds:

  • Greenfield projects for fast experimentation and internal tooling
  • Brownfield projects for rescuing legacy systems, adding tests, and reducing technical debt

AI doesn’t replace experience—it amplifies it.

Especially when dealing with systems held together by history, habits, and just enough hope to keep running.

And honestly?

Those are the projects where the impact feels the most real.

The DLL Registration Trap in Legacy .NET Framework Applications

The DLL Registration Trap in Legacy .NET Framework Applications

If you’ve ever worked on a traditional .NET Framework application — the kind that predates .NET Core and .NET 5+ — this story may feel painfully familiar.

I’m talking about classic .NET Framework 4.x applications (4.0, 4.5, 4.5.1, 4.5.2, 4.6, 4.6.1, 4.6.2, 4.7, 4.7.1, 4.7.2, 4.8, and the final release 4.8.1). These systems often live long, productive lives… and accumulate interesting technical debt along the way.

This particular system is written in C# and relies heavily on COM components to render video, audio, and PDF content. Under the hood, many of these components are based on technologies like DirectShow filters, ActiveX controls, or other native COM DLLs.

And that’s where the story begins.


The Setup: COM, DirectShow, and Registration

Unlike managed .NET assemblies, COM components don’t just live quietly next to your executable. They need to be registered in the system registry so Windows knows:

  • What CLSID they expose
  • Which DLL implements that CLSID
  • Whether it’s 32-bit or 64-bit
  • How it should be activated

For DirectShow-based components (very common for video/audio playback in legacy apps), registration is usually done manually during development using regsvr32.

Example:

regsvr32 MyVideoFilter.dll

To unregister:

regsvr32 /u MyVideoFilter.dll

Important detail that bites a lot of people:

  • 32-bit DLLs must be registered using:
C:\Windows\SysWOW64\regsvr32.exe My32BitFilter.dll
  • 64-bit DLLs must be registered using:
C:\Windows\System32\regsvr32.exe My64BitFilter.dll

Yes — the folder names are historically confusing.


Development Works… Until It Doesn’t

So here’s the usual development flow:

  1. You register all required COM DLLs on your development machine
  2. Visual Studio runs the app
  3. Video plays, audio works, PDFs render
  4. Everyone is happy

Then comes the next step.

“Let’s build an installer.”


The Installer Paradox

This is where the real battle story begins.

Your application installer (MSI, InstallShield, WiX, Inno Setup — pick your poison) now needs to:

  • Copy the COM DLLs
  • Register them during installation
  • Unregister them during uninstall

This seems reasonable… until you test it.

The Loop From Hell

Here’s what happens in practice:

  • You install your app for testing
  • The installer registers its own copies of the COM DLLs
  • Your development environment was using different copies (maybe newer, maybe local builds)
  • Suddenly:
    • Your source build stops working
    • Visual Studio debugging breaks
    • Another app on your machine mysteriously fails

Then you:

  • Uninstall the app
  • The installer unregisters the DLLs
  • Now nothing works anymore

So you re-register the DLLs manually for development…

…and the cycle repeats.


The Battle Story: It Only Worked… Until It Didn’t

For a long time, this system appeared to work just fine.

Video played. Audio rendered. PDFs opened. No obvious errors.

What we didn’t realize at first was a dangerous hidden assumption:

The system only worked on machines where a previous version had already been installed.

Those older installations had left COM DLLs registered in the system — quietly, globally, and invisibly.

So when we deployed a new version without removing the old one:

  • Everything looked fine
  • No one suspected missing registrations
  • The system passed casual testing

The illusion broke the moment we tried a clean installation.

On a fresh machine — no previous version, no leftover registry entries — the application suddenly failed:

  • Components didn’t initialize
  • Media rendering silently broke
  • COM activation errors appeared only in Event Viewer

The installer claimed it was registering the DLLs.

In reality, it wasn’t doing it correctly — or at least not in the way the application actually needed.

That’s when we realized we were standing on years of accidental state.


Why This Happens

The core problem is simple but brutal:

COM registration is global and mutable.

There is:

  • One registry
  • One CLSID mapping
  • One “active” DLL per COM component

Your development environment, your installed application, and your installer are all fighting over the same global state.

.NET Framework itself isn’t the villain here — it’s just sitting on top of an old Windows integration model that predates modern isolation concepts.


A New Player Enters: ARM64

Just when we thought the problem space was limited to x86 vs x64, another variable entered the scene.

One of the development machines was ARM64.

Modern Windows on ARM adds a new layer of complexity:

  • ARM64 native processes
  • x64 emulation
  • x86 emulation on top of ARM64

From the outside, everything looks like it’s running on x64.

Under the hood, it’s not that simple.

Why This Makes COM Registration Worse

COM registration is architecture-specific:

  • x86 DLLs register under one view of the registry
  • x64 DLLs register under another
  • ARM64 introduces yet another execution context

On Windows ARM:

  • System32 contains ARM64 binaries
  • SysWOW64 contains x86 binaries
  • x64 binaries often run through emulation layers

So now the questions multiply:

  • Which regsvr32 did the installer call?
  • Was it ARM64, x64, or x86?
  • Did the app run natively, or under emulation?
  • Did the COM DLL match the process architecture?

The result is a system where:

  • Some things work on Intel machines
  • Some things work on ARM machines
  • Some things only work if another version was installed first

At this point, debugging stops being logical and starts being archaeological.


Why This Is So Common in .NET Framework 4.x Apps

Many enterprise and media-heavy applications built on:

  • .NET Framework 4.0–4.8.1
  • WinForms or WPF
  • DirectShow or ActiveX components

were designed in an era where:

  • Global COM registration was normal
  • Side-by-side isolation was rare
  • “Just register the DLL” was accepted practice

These systems work, but they’re fragile — especially on developer machines.


Where the Article Is Going Next

In the rest of this article series, we’ll look at:

  • Why install-time registration is often a mistake
  • How to isolate development vs runtime environments
  • Techniques like:
    • Dedicated dev VMs
    • Registration-free COM (where possible)
    • App-local COM deployment
    • Clear ownership rules for installers
  • How to survive (and maintain) legacy .NET Framework systems without losing your sanity

If you’ve ever broken your own development environment just by testing your installer — you’re not alone.

This is the cost of living at the intersection of managed code and unmanaged history.

ConfigureAwait(false): Why It Exists, What It Solves, and When Context Is the Real Bug

ConfigureAwait(false): Why It Exists, What It Solves, and When Context Is the Real Bug

Async/await in C# is often described as “non-blocking,” but that description hides an important detail:

await is not just about waiting — it is about where execution continues afterward.

Understanding that single idea explains:

  • why deadlocks happen,
  • why ConfigureAwait(false) exists,
  • and why it *reduces* damage without fixing the root cause.

This article is not just theory. It’s written because this exact class of problem showed up again in real production code during the first week of 2026 — and it took a context-level fix to resolve it.

The Hidden Mechanism: Context Capture

When you await a task, C# does two things:

  1. It pauses the current method until the awaited task completes.
  2. It captures the current execution context (if one exists) so the continuation can resume there.

That context might be:

  • a UI thread (WPF, WinForms, MAUI),
  • a request context (classic ASP.NET),
  • or no special context at all (ASP.NET Core, console apps).

This default behavior is intentional. It allows code like this to work safely:

var data = await LoadAsync();
MyLabel.Text = data.Name; // UI-safe continuation

But that same mechanism becomes dangerous when async code is blocked synchronously.

The Root Problem: Blocking on Async

Deadlocks typically appear when async code is forced into a synchronous shape:

var result = GetDataAsync().Result; // or .Wait()

What happens next:

  1. The calling thread blocks, waiting for the async method to finish.
  2. The async method completes its awaited operation.
  3. The continuation tries to resume on the original context.
  4. That context is blocked.
  5. Nothing can proceed.

💥 Deadlock.

This is not an async bug. This is a context dependency cycle.

The Blast Radius Concept

Blocking on async is the explosion.

The blast radius is how much of the system is taken down with it.

Full blast (default await)

  • Continuation *requires* the blocked context
  • The async operation cannot complete
  • The caller never unblocks
  • Everything stops

Reduced blast (ConfigureAwait(false))

  • Continuation does not require the original context
  • It resumes on a thread pool thread
  • The async operation completes
  • The blocking call unblocks

The original mistake still exists — but the damage is contained.

The real fix is “don’t block on async,”
but ConfigureAwait(false) reduces the blast radius when someone does.

What ConfigureAwait(false) Actually Does

await SomeAsyncOperation().ConfigureAwait(false);

This tells the runtime:

“I don’t need to resume on the captured context. Continue wherever it’s safe to do so.”

Important clarifications:

  • It does not make code faster by default
  • It does not make code parallel
  • It does not remove the need for proper async flow
  • It only removes context dependency

Why This Matters in Real Code

Async code rarely exists in isolation.

A method often awaits another method, which awaits another:

await AAsync();
    await BAsync();
        await CAsync();

If any method in that chain requires a specific context, the entire chain becomes context-bound.

That is why:

  • library code must be careful,
  • deep infrastructure layers must avoid context assumptions,
  • and UI layers must be explicit about where context is required.

When ConfigureAwait(false) Is the Right Tool

Use it when all of the following are true:

  • The method does not interact with UI state
  • The method does not depend on a request context
  • The method is infrastructure, library, or backend logic
  • The continuation does not care which thread resumes it

This is especially true for:

  • NuGet packages
  • shared libraries
  • data access layers
  • network and IO pipelines

What It Is Not

ConfigureAwait(false) is not:

  • a fix for bad async usage
  • a substitute for proper async flow
  • a reason to block on tasks
  • something to blindly apply everywhere

It is a damage-control tool, not a cure.

A Real Incident: When None of the Usual Fixes Worked

First week of 2026.

The first task I had with the programmers in my office was to investigate a problem in a trading block. The symptoms looked like a classic async issue: timing bugs, inconsistent behavior, and freezes that felt “await-shaped.”

We did what experienced .NET teams typically do when async gets weird:

  • Reviewed the full async/await chain end-to-end
  • Double-checked the source code carefully (everything looked fine)
  • Tried the usual “tools people reach for” under pressure:
  • .Wait()
  • .GetAwaiter().GetResult()
  • wrapping in Task.Run(...)
  • adding ConfigureAwait(false)
  • mixing combinations of those approaches

None of it reliably fixed the problem.

At that point it stopped being a “missing await” story. It became a “the model is right but reality disagrees” story.

One of the programmers, Daniel, and I went deeper. I found myself mentally replaying every async pattern I know — especially because I’ve written async-heavy code myself, including library work like SyncFramework, where I synchronize databases and deal with long-running operations.

That’s the moment where this mental model matters: it forces you to stop treating await like syntax and start treating it like mechanics.

The Actual Root Cause: It Was the Context

In the end, the culprit wasn’t which pattern we used — it was where the continuation was allowed to run.

This application was built on DevExpress XAF. In this environment, the “correct” continuation behavior is often tied to XAF’s own scheduling and application lifecycle rules. XAF provides a mechanism to run code in its synchronization context — for example using BlazorApplication.InvokeAsync, which ensures that continuations run where the framework expects.

Once we executed the problematic pipeline through XAF’s synchronization context, the issue was solved.

No clever pattern. No magical await. No extra parallelism.

Just: the right context.

And this is not unique to XAF. Similar ideas exist in:

  • Windows Forms (UI thread affinity + SynchronizationContext)
  • WPF (Dispatcher context)
  • Any framework that requires work to resume on a specific thread/context

Why I’m Writing This

What I wanted from this experience is simple: don’t forget it.

Because what makes this kind of incident dangerous is that it looks like a normal async bug — and the internet is full of “four fixes” people cycle through:

  1. add/restore missing await
  2. use .Wait() / .Result
  3. wrap in Task.Run()
  4. use ConfigureAwait(false)

Sometimes those are relevant. Sometimes they’re harmful. And sometimes… they’re all beside the point.

In our case, the missing piece was framework context — and once you see that, you realize why the “blast radius” framing is so useful:

  • Blocking is the explosion.
  • ConfigureAwait(false) contains damage when someone blocks.
  • If a framework requires a specific synchronization context, the fix may be to supply the correct context explicitly.

That’s what happened here. And that’s why I’m capturing it as live knowledge, not just documentation.

The Mental Model to Keep

  • Async bugs are often context bugs
  • Blocking creates the explosion
  • Context capture determines the blast radius
  • ConfigureAwait(false) limits the damage
  • Proper async flow prevents the explosion entirely
  • Frameworks may require their own synchronization context
  • Correct async code can still fail in the wrong context

Async is not just about tasks. It’s about where your code is allowed to continue.