Make the C# compiler reject an agent's half-finished enum change
A build setting, one test and a ten-line script turn a new enum member into a failing build everywhere it matters. About 15 minutes to set up.

The ticket says orders can now be refunded. An agent adds Refunded to the OrderStatus enum, wires up the refund endpoint, runs the tests, sees green and opens the pull request. Eleven switch expressions elsewhere in the codebase still decide what each status means: whether the order can be cancelled, which email goes out, what the dashboard shows, which rows the nightly export includes. Nobody changed them. Nothing failed.
Reviewers can’t be expected to find those eleven places by reading a diff that doesn’t touch them. The compiler can. This Playbook sets up a small gate so that adding an enum member produces a build error at every switch that hasn’t decided what to do with it, and so that the one shortcut an agent reaches for, the catch-all arm, fails a different check instead.
It works with any coding agent that can edit files and run dotnet build. The agent isn’t told anything clever. The build and the test suite say no, in text it can read and act on.
The failure, concretely
Here’s the starting point: a tiny domain with one status enum and one rule that depends on it.
namespace Orders;
public enum OrderStatus { Draft, Placed, Shipped, Cancelled }
public static class OrderRules
{
public static bool CanCancel(OrderStatus status) => status switch
{
OrderStatus.Draft => true,
OrderStatus.Placed => true,
OrderStatus.Shipped => false,
OrderStatus.Cancelled => false,
};
}
No _ => arm, and that’s deliberate. A switch expression with no catch-all has to name every member, and the C# compiler knows when one is missing. Per the Microsoft documentation, that’s diagnostic CS8509: the switch expression doesn’t handle all possible values of its input type.
By default CS8509 is a warning. Warnings scroll past in a build log, and an agent summarising “build succeeded” will cheerfully ignore them. So the first move is to make warnings fail the build.
Step 1: turn warnings into errors
In the project file (or Directory.Build.props if you want it solution-wide):
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>$(NoWarn);CS8524</NoWarn>
</PropertyGroup>
</Project>
Two settings need explaining. TreatWarningsAsErrors does the obvious thing. It’s blunt, and on an old codebase with hundreds of existing warnings you’ll want to scope it: use <WarningsAsErrors>CS8509</WarningsAsErrors> instead, which promotes only the exhaustiveness diagnostic.
The NoWarn line is there because of CS8524, and it’s the part most people trip on. With only the four named members handled, the build fails like this:
Domain.cs(25,64): error CS8524: The switch expression does not handle some values of its input type (it is not exhaustive) involving an unnamed enum value. For example, the pattern '(Orders.OrderStatus)4' is not covered.
An enum is an integer underneath, so (OrderStatus)4 is a legal value even though no member has that name. CS8524 wants an arm for it. The only way to write one is a catch-all, and a catch-all is exactly what you’re trying to keep out, because it also swallows every future member. Suppressing CS8524 and keeping CS8509 gives the behaviour you want: every named member must be handled, and a stray integer cast throws SwitchExpressionException at runtime, which the Microsoft docs describe for .NET Core 3.0 and later.
Trade-off, stated plainly: you’re accepting an exception for impossible values in exchange for a compile error on new ones. Values that arrive from outside the process (a database column, a JSON body) should be validated at the boundary with Enum.IsDefined or a parser, not by a catch-all deep in the domain.
Step 2: watch it work
Now do what the agent does. Add the member and change nothing else:
public enum OrderStatus { Draft, Placed, Shipped, Cancelled, Refunded }
Build:
Domain.cs(25,64): error CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Orders.OrderStatus.Refunded' is not covered.
Build FAILED.
The message names the missing member and the exact line. An agent can act on that without any help: it opens the file, decides what refund means for cancellation, and adds the arm. A human reviewer sees a diff where every switch now has an explicit Refunded line, which is what a reviewer wants to look at.
This is also the check that tells you whether the rule is doing its job on your own code. Run the same experiment on the enum that matters most in your codebase. The list of errors is the list of decisions the change has to make. Some of them will surprise you.
The shortcut agents take
Agents optimise for a green build. Faced with that CS8509 error, the cheapest fix is a catch-all:
OrderStatus.Cancelled => false,
_ => true,
Build succeeded. The error is gone, and so is the compiler’s ability to ever warn about this switch again. A refunded order is now cancellable, because someone picked true for “everything else” without thinking about refunds.
Nothing in the compiler stops this. The Microsoft documentation says outright that a discard arm is how you make a switch expression exhaustive. Catch-alls are legitimate in plenty of places. You need a way to say “not here”.
Step 3: fail the build on discard arms
A tiny script is enough. It flags any switch arm that’s a bare discard, unless the line says it’s deliberate:
#!/usr/bin/env python3
"""Fail if a switch arm is a bare discard. Mark a deliberate one with // allow-discard."""
import pathlib, re, sys
ARM = re.compile(r"^\s*_\s*=>")
bad = []
for path in pathlib.Path(sys.argv[1]).rglob("*.cs"):
if "obj" in path.parts or "bin" in path.parts:
continue
for n, line in enumerate(path.read_text().splitlines(), 1):
if ARM.match(line) and "allow-discard" not in line:
bad.append(f"{path}:{n}: {line.strip()}")
print("\n".join(bad) or "no discard arms")
sys.exit(1 if bad else 0)
Save it as scripts/no_discard_arms.py and run it against the source folder. With the agent’s shortcut in place:
Orders/Domain.cs:31: _ => true,
The exit code is 1. With the shortcut removed it prints no discard arms and exits 0.
It’s a regular expression, not a parser, and it has the usual limits. It only sees arms that start a line, which is how formatters lay switch expressions out, so a one-line switch slips past it. It also flags _ => in places that aren’t enum switches, such as a lambda with a discard parameter written on its own line, and the // allow-discard marker is the escape hatch for those. If you want something sturdier, a Roslyn analyzer or a Semgrep rule on the switch_expression_arm node does the same job properly (there’s a post on writing a Semgrep rule for every agent defect
if you’d rather go that way). For ten lines, the script catches the habit it’s aimed at.
Where it runs matters more than how it’s written. Put it in the same place as the build: a pre-commit hook, a CI step before dotnet build, and in the instructions you give the agent as a command to run before it says it’s finished. Agents skip prose rules. They don’t skip a command whose failure they can see.
Step 4: a test that holds the spec
The compiler checks that every member is handled. It can’t check that each one is handled correctly, and a discard arm that slips through the script (a when guard, a one-line switch) would still compile. A decision table in a test covers both.
using Orders;
public class OrderRulesTests
{
// The spec, one row per status. A new status has no row, so the first test fails.
private static readonly Dictionary<OrderStatus, bool> CanCancelSpec = new()
{
[OrderStatus.Draft] = true,
[OrderStatus.Placed] = true,
[OrderStatus.Shipped] = false,
[OrderStatus.Cancelled] = false,
};
[Fact]
public void Spec_has_a_row_for_every_status() =>
Assert.Empty(Enum.GetValues<OrderStatus>().Except(CanCancelSpec.Keys));
[Theory]
[InlineData(OrderStatus.Draft)]
[InlineData(OrderStatus.Placed)]
[InlineData(OrderStatus.Shipped)]
[InlineData(OrderStatus.Cancelled)]
public void CanCancel_matches_spec(OrderStatus status) =>
Assert.Equal(CanCancelSpec[status], OrderRules.CanCancel(status));
}
The test project references the domain project and uses the xUnit template’s packages (xunit 2.9.3 and Microsoft.NET.Test.Sdk 17.14.1 at the time of writing). The table is the spec in code: one row per status, written by a human, kept next to the rule.
Run dotnet test on the clean state and it passes:
Passed! - Failed: 0, Passed: 5, Skipped: 0, Total: 5, Duration: 47 ms - Orders.Tests.dll (net10.0)
Now replay the agent’s shortcut: add Refunded and the _ => true arm. The build succeeds, because the discard satisfied the compiler. The test run doesn’t:
Failed OrderRulesTests.Spec_has_a_row_for_every_status [17 ms]
Assert.Empty() Failure: Collection was not empty
Collection: [Refunded]
Failed! - Failed: 1, Passed: 4, Skipped: 0, Total: 5, Duration: 64 ms - Orders.Tests.dll (net10.0)
The failure names the member nobody wrote a decision for. The agent can’t make it pass by editing the production code alone, because the missing piece is a row in the table. It has to put a value there, which means it has to decide, and the decision shows up in the diff as one line a reviewer can read.
There’s a trap here too. An agent that wants to go green can add the row itself, copying the value from whatever the discard arm returned. The gate doesn’t prevent that. It does turn an invisible decision into a visible one: a new line in a spec table, labelled with the member’s name. Make the review habit “check the new row in every table”, not “check that tests pass”.
Why a class hierarchy doesn’t give you the same thing
The obvious next idea is to skip enums and model states as a closed set of records, which reads better and carries data per state:
public abstract record OrderState
{
private OrderState() { }
public sealed record Draft : OrderState;
public sealed record Placed(DateTimeOffset At) : OrderState;
public sealed record Shipped(DateTimeOffset At, string Tracking) : OrderState;
public sealed record Cancelled(DateTimeOffset At, string Reason) : OrderState;
}
The private constructor means nothing outside the type can derive from it, so the four nested records are the whole set. A human sees a closed hierarchy. The compiler, on the SDK used here, doesn’t. A switch expression over all four, with no catch-all, fails the build:
Domain.cs(33,61): error CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '_' is not covered.
The compiler can’t prove the set is closed, so it demands a catch-all, and the catch-all is the thing that hides new states. The gate then reverts to the discard-arm script and the test table, and you lose the compile-time half.
For now, an enum plus a data record keyed by it gets you the compile-time check. Union types, which the Microsoft documentation describes as exhaustive without a catch-all (and warns that a var or discard arm hides a new case), are the language-level answer to this. Check whether your SDK and language version support them before relying on that. Until it does, the enum approach is the one that fails loudly today.
Make it yours
Three variations that change what you’d write, not the idea.
- Solution-wide: move the
TreatWarningsAsErrorsandNoWarnlines intoDirectory.Build.props, and use<WarningsAsErrors>CS8509</WarningsAsErrors>on a legacy solution so you only promote the diagnostic this Playbook relies on. Fix the existing offenders in a separate commit. - Other enums: the decision table pattern is general. For any enum that drives behaviour, add one table per rule that consumes it. The
Spec_has_a_row_for_every_statustest is two lines and never changes. - CI: run
python3 scripts/no_discard_arms.py src/beforedotnet buildanddotnet testafter it, in that order. The script is the cheapest of the three, so it should fail first.
If your agent supports a pre-finish hook or a rules file, point it at the same three commands. The Playbook doesn’t depend on that, and a tool-specific hook only helps if the tool is the one running.
What goes wrong
Enums that cross a process boundary. If the integer is stored in a database and a new member gets deployed before the code that reads it, the switch throws SwitchExpressionException on the old binary. That’s a deployment ordering problem the compiler can’t see. Ship readers before writers.
Flags enums. [Flags] combinations aren’t named members, so a switch over one is a different problem and this approach doesn’t apply cleanly. Use HasFlag checks and a test that enumerates the combinations you support.
Noise. On a big codebase, the first run produces a long list of CS8509 errors, and some are in switches where the catch-all was the right call (a log formatter, say). Mark those // allow-discard and move on. The goal is that a discard is something someone chose.
Agents that edit the config. An agent in a hurry can set TreatWarningsAsErrors to false or add CS8509 to NoWarn. Treat changes to .csproj, Directory.Build.props and .editorconfig as review-hardest lines in any agent diff, and consider a CODEOWNERS entry for them.
Don’t bother if the enum is a closed set that will never change (days of the week), or if the switch is a presentation detail where “everything else” really is the right answer.
First 15 minutes
Pick the enum that most behaviour in your codebase depends on. Add a throwaway member to it, run dotnet build and count the errors with TreatWarningsAsErrors on (or WarningsAsErrors for CS8509). That count is how many places currently decide something for a status nobody has defined yet.
Then add the discard-arm script to your pre-commit or CI step, write the decision table for the one rule you care about most, and revert the throwaway member. The next time an agent adds a status, it hits the same wall, and the error message tells it what to do.