Publishing Every 3 Weeks · ASP.NET Core · Design Patterns · Architecture · 20 yrs C#/.NET · cleancsharp.com
AI & Workflow · Best Practices

📋 The Rules File: Governing AI Before It Writes a Line

Your assistant has never seen your outages. It will remove the guard that exists because of one, and the diff will look like an improvement. A rules file is where your postmortems go to live — and most of them are written wrong, because they record taste instead of scars.

Chesterton's fence goes like this. You're walking through a field and you come across a fence across the path, apparently pointless. The modern reformer says: I see no use for this, let's clear it away. Chesterton says: no. Go and find out why it's there. Come back when you know, and then we'll talk about removing it.

An AI coding assistant is the reformer. It is fast, it is genuinely good, and it has never once walked through your field.

Here's a fence it removed for me. If you read the last post, you've met this repository method; this is what came back when I asked for the repository to be tidied up:

// what was there
public async Task<IReadOnlyList<Claim>> GetOverdueAsync(DateOnly today, CancellationToken ct) =>
    await _context.Claims
        .Where(c => c.DueDate < today)
        .ToListAsync(ct);

// what came back
public IQueryable<Claim> GetOverdue(DateOnly today) =>
    _context.Claims.Where(c => c.DueDate < today);

Shorter. No async machinery. No list allocation. Composable, which is the word the explanation used — now the caller can add their own filters and the database does the work.

It is also the reason the first version looks the way it does, and it cost us a Thursday in August to learn.

Handing a caller an IQueryable hands them an unfinished query and the obligation to finish it correctly — without any of the context that would let them. Two things went wrong, weeks apart, neither in the file that was changed.

The first was a caller who added a filter of their own, exactly as invited:

var flagged = _repo.GetOverdue(today)
    .Where(c => _rules.IsSuspicious(c))   // an ordinary C# method
    .ToList();

That compiles. It reads well. At runtime EF Core cannot translate IsSuspicious into SQL and throws InvalidOperationException: The LINQ expression ... could not be translated. The data layer owns which predicates the provider can handle, and it had just given that job away to someone with no way of knowing.

The second was quieter. A service cached the query in a singleton to save a round trip. The request that built it finished, its scope disposed the DbContext, and the next request enumerated the cached query against a context that no longer existed:

ObjectDisposedException: Cannot access a disposed context instance. A common cause
of this error is disposing a context instance that was resolved from dependency
injection and then later trying to use the same context instance elsewhere in your
application. ...

A stack trace in a controller, for a fault in a repository, from a line that never mentions a database — on a different request from the one that caused it. The query had outlived the thing it depended on — and nothing about the signature says it could.

ToListAsync inside the data layer prevents both, because it executes the query where the context lives and where the provider's limits are known. It wasn't ceremony. It was a fence.

The model didn't make a mistake. It made the correct inference from the information it had, which was the code and nothing else. Nowhere in that file does it say we had an incident here.


🎯 The Shift

From: "I'll prompt it better next time"
To: "The guardrail has to outlive the conversation"

Prompting is a skill and it does help, right up until you close the window. Assistants are starting to remember — Claude Code and Copilot both keep notes on what they learn in your repository now — but those notes are the model's, not the team's. The assistant writes them about whatever it happened to notice, nobody reviews them, and they live outside version control: Claude Code's on one machine, Copilot's on GitHub's servers, where an entry nobody uses is deleted after 28 days. Switch tools, switch machines or go quiet for a month, and you're back to zero: no memory of the argument about repository return types, no memory of the Thursday. You will re-litigate the same three decisions every week for as long as the knowledge lives in your head and an assistant's private notes.

A rules file — AGENTS.md, CLAUDE.md, .github/copilot-instructions.md, .cursor/rules/*.mdc, whatever your tool reads — is the persistence layer for that, reviewed and versioned like the code it governs, instead of kept in an assistant's private notes. Check what your tool actually loads before you write one, because no single filename covers everything. AGENTS.md at the root reaches the most tools — Codex, Cursor, and Copilot's agent, CLI and VS Code integration — but Copilot in Visual Studio doesn't read it at all (it wants .github/copilot-instructions.md), and Claude Code, by default, ignores AGENTS.md whenever a CLAUDE.md exists. If your team uses more than one, keep one file and point the others at it: a CLAUDE.md containing just @AGENTS.md imports it. And most of them are written badly, because people fill them with what they like instead of what has already gone wrong.


📓 1. A Rules File Is Not a Style Guide

Open a random repository's AI rules file and you will find something like this:

- Use meaningful variable names
- Follow SOLID principles
- Write unit tests for new code
- Prefer async/await over blocking calls
- Handle errors appropriately

Every line is true. Every line is also already in the model's weights, several million times over, from every C# tutorial ever written. You have spent your entire rules budget telling a very well-read assistant things it already believes.

Worse, it dilutes. A file of forty platitudes with three load-bearing rules buried in it is a file where the three get the same weight as the forty. Attention is finite — the model's and the next developer's.

The test for whether a rule belongs is blunt:

If someone broke this rule, would the result still look fine?
If it would look obviously wrong, the model already catches it. Delete the rule.
If it would look completely reasonable, that's a rule worth its line.

"Use meaningful variable names" — break it and x1 and tmp2 are right there in the diff, and every reviewer and every model flags them. Delete. "Never return IQueryable<T> from a repository method" — break it and you get the shorter, cleaner-looking version at the top of this post, which compiles, reads better and passes review. That one stays.

Some rules fail the test only because they're vague. "Prefer async/await" is a platitude — the model already prefers it. But "no synchronous I/O inside an async method" passes easily, because File.ReadAllText inside an async method compiles, runs, and gets through review. When a platitude fails the test, look for the specific scar underneath it before you delete it.


🩹 2. Every Rule Is a Scar

Here is the reframe the rest of this post rests on.

A rules file is where your postmortems go to live. Not a description of good practice — a record of what has already gone wrong in this repository, written down so it can't happen twice.

That reframe changes how the file is written, because a scar has four parts and taste has one. Compare:

<!-- taste -->
- Don't commit directly to main.
<!-- a scar -->
- **Never commit directly to `main`.** All changes go on a branch and merge via PR.
  **Why:** a deploy workflow fires on every push to `main`. A commit straight to
  the branch ships to production with no review and no way to stage it.
  **How to apply:** branch first, even for a one-line fix, even when you're sure.
  **Enforced by:** nothing yet — it's a convention, and the file says so.

Same instruction. The second one survives contact with an assistant that is trying to be helpful, because it can tell the difference between a rule it can satisfy another way and a rule that exists because of a specific mechanism. Give it the why and it will generalize correctly to situations you never wrote down. Give it only the what and it will find the nearest clever workaround.

It also survives contact with you, in eight months, when the rule looks arbitrary and you're in a hurry.

The corollary is uncomfortable but freeing: you cannot write a good rules file up front. You can write the skeleton, but the content arrives one incident at a time. Every genuinely valuable line in ours — the file that governs the repository behind this blog: a Ghost theme, a deploy workflow and a newsletter — was bought:

The rule What bought it
A process change applies to work already scheduled, not just to the docs The send format changed on a Tuesday. A post that had been on the calendar for weeks went out under the old one two weeks later, because nothing re-checked what was already scheduled
Send a link, never the full article — and prove it before every send The rule was already written when a post went out the old way. A fix landed seven minutes later; the web page is correct and 33 inboxes are permanently wrong. The incident bought the check, not the rule
These homepage sections are hardcoded and go stale silently Three separate releases shipped with last month's post advertised as current
Don't trust your own inbox as the delivery check The real send threaded underneath the test send, and read as "never arrived"

Nobody can copy that table, which is exactly the point. It isn't best practice. It's our field, and those are our fences.


🚧 3. The Fence the Model Will Remove

Back to the repository method. The failure mode there has a shape, and once you see it you find it everywhere: load-bearing code that looks redundant.

An assistant optimizing for clean, idiomatic output will reliably reach for:

  • the .ToList() that exists because the source is enumerated more than once
  • the ConfigureAwait(false) that looks like noise in a library
  • the seemingly pointless intermediate variable that exists because a for counter is one variable shared by every iteration (foreach was fixed in C# 5; for was not)
  • the retry wrapper around one call that looks like over-engineering next to nine bare calls
  • the explicit transaction that spans two SaveChanges calls, or a write EF does not own — one SaveChanges really is already transactional, which is what makes the redundant-looking case so easy to delete along with the load-bearing one

Each of those is a fence. Each one gets removed by a diff that reads like an improvement, passes review because the reviewer also can't see the fence, and fails in production under a condition nobody reproduced locally.

The defense is not "tell the AI to be careful." It's to write the why at the site of the fence and in the rules file, so the reason travels with the code:

// Materialized deliberately: callers enumerate this three times (any + count + iterate)
// and the predicate hits the eligibility service. Lazy, that was 801 calls for 400 claims.
var eligible = claims.Where(c => _eligibility.IsCovered(c)).ToList();

That comment is not for the model. It's for anyone — human or otherwise — who arrives with a tidying instinct and no history. The model just happens to be the most prolific such visitor you will ever have.


📍 4. Rules Go Where the Work Is

A single rules file at the repository root works until the repository has more than one kind of code in it. Then it becomes a document where two thirds of the rules are irrelevant to whatever you're currently touching, and relevance decays into noise.

Most tools now read nested files — a root file for things that are true everywhere, and directory-level files for things that are only true there. A nested AGENTS.md is the closest thing to a cross-tool answer: Cursor and Copilot's agent and CLI all scope it to its directory, and Codex picks it up when you start a session in or below that directory. Claude Code reads nested CLAUDE.md instead (the same one-line @AGENTS.md import bridges it), Copilot in Visual Studio scopes with .github/instructions/*.instructions.md and an applyTo glob, and Cursor's own rules can take a globs: pattern. Check yours; the layout is the same idea either way:

AGENTS.md                      → the whole repo: branching, deploys, what never runs locally
src/Data/AGENTS.md             → the data layer: nothing returns IQueryable past this line
src/Api/AGENTS.md              → controllers: DTOs at the edge, no entities in responses
src/Workers/AGENTS.md          → long-running: cancellation tokens, no fire-and-forget
tests/AGENTS.md                → the mocking policy, and what not to assert on

If you already split rules by concern, as in Be the Architect, this doesn't replace that — concern files decide where a rule is written; directory files decide when it is loaded, and a directory file can simply point at the concern files that apply there.

Note where the IQueryable rule sits. It belongs beside the repository that would break it, not in the layer that suffers when it does — a rule the offending edit never loads is a rule that isn't there.

The root file should be short enough that you'd actually re-read it. If it has grown past a couple of screens, that's the signal to push rules down to the directory that owns them — not to keep appending.

One rule deserves the root file more than any other, and it is the one people leave out: say what the repository is, in two sentences, and what it is not. An assistant that knows the repository behind this blog is a Ghost theme and not an application will stop suggesting a service layer for it.


⚖️ 5. A Rule That Can't Fail Is a Suggestion

Prose rots. Not because anyone disagrees with it, but because prose cannot stop anything — it can only ask nicely, and it asks at exactly the moment when the person reading is least inclined to listen.

The rules that hold are the ones with teeth behind them. For a rule you actually care about, ask what would happen if someone broke it right now, and make sure the answer isn't "nothing."

The cheapest teeth are the ones already in the SDK. The multiple-enumeration rule from the last post is a prose rule until you turn the analyzer on — and then it is still only a suggestion, because a warning does not stop anything:

# .editorconfig
[*.cs]
dotnet_diagnostic.CA1851.severity = error

warning leaves dotnet build exiting 0, which is the whole failure this section is about. error is what makes the rule enforceable. If a clean sweep is too big to do at once, set the repo-wide line to warning instead and escalate only where you're ready — in each project you've cleaned:

<PropertyGroup>
  <WarningsAsErrors>CA1851</WarningsAsErrors>
</PropertyGroup>

or in CI only, with dotnet build -warnaserror:CA1851. Not bare -warnaserror: that turns every warning in the build into an error, every PR goes red, and the flag is gone by Friday.

Beyond that: a CI job, a pre-commit hook, a test that asserts on the thing the rule protects. The form doesn't matter. What matters is that the rule can fail.

Two of ours failed this test, in different ways.

The first we found by reading the code rather than by being burned. A pre-release script verified a post's publish time and printed a ✗ when it was wrong — and then exited zero. It had run green for six weeks, and it would have run green on a wrong time too; every post in that window happened to be scheduled correctly, so the broken branch was never reached. The documentation said it exited non-zero. It never could.

The second we found the expensive way. Deploys ran on merge, which meant "it deployed" was an assumption with nothing able to contradict it. One merge produced no deploy at all — not a failed run, no run — and the site sat a release behind until somebody happened to look. Nothing was watching, because the failure was the absence of an event and every check we had was aimed at events that happen.

A check that reports a problem without failing is worse than no check, because it buys you the confidence of a guardrail while providing none of the stopping power. And a rule with no check at all is a hope. If it can't exit non-zero, it's a log line wearing a uniform.


🧹 6. The File Rots Unless You Prune It

A stale rule is not neutral. It is actively worse than a missing one, because both you and the assistant will reason from it with total confidence.

Ours accumulated all of these:

  • Paths to a directory the repository was moved out of months earlier, so every instruction about "the drafts folder" pointed at nothing
  • A note that the mailing list had no subscribers at all — "1 member (you, unsubscribed)" — written before launch, still being reasoned from at the August 4 send, by which point it was thirty-two
  • A reference to a domain that had been retired, with no redirect, still being used to build links

None of those were wrong when written. All of them were wrong when read, and the difference between those two moments is where the damage lives.

So the file needs the same treatment as the code:

  • It changes in the same commit as the thing it describes. A rule about a workflow and the workflow itself move together or they drift.
  • Date the facts that expire. "33 subscribers as of 2026-09-08" ages honestly. "33 subscribers" quietly becomes a lie.
  • Delete rules whose incident can no longer happen. If the footgun was removed, the rule protecting you from it is now just noise competing with live rules for attention.

The prune is the part everyone skips, and it's the reason most rules files are ignored by month six. Not because they were wrong — because enough of them went stale that the whole file stopped being trustworthy, and a document you don't trust is one you stop reading.


🎓 Why These Patterns Matter

Team A has a rules file. It was written in an afternoon, it contains thirty-one bullets about naming and SOLID and writing tests, and no one has opened it since. Their assistant produces confident, idiomatic code that keeps re-introducing the same three bugs, because nothing in the repository records that they are bugs. Every developer re-explains the repository return-type rule in review, roughly every couple of weeks, forever.

Team B has a shorter file. Most of its rules carry a date and a sentence explaining what went wrong. The ones that matter are backed by an analyzer or a CI job that exits non-zero. It gets pruned when something turns out to be stale, and appended when something breaks. Their assistant still doesn't know anything — but it is briefed, and it stops trying to remove the fences.

The difference isn't that Team B prompts better. It's that Team B keeps the hard-won knowledge where it gets reviewed, versioned, and read by every tool that touches the code.


🧭 Key Takeaways

  • A rules file is where your postmortems go to live — not a style guide, not a wish list, a record of what has already gone wrong here
  • Keep the rules whose breach still looks fine — if breaking it produces an obviously wrong diff, the model already catches it; every platitude dilutes the rules that matter
  • Write the why, not just the what — a rule with its incident attached generalizes correctly; a bare instruction gets cleverly worked around
  • The fences that get removed are the ones that look redundant — ToList(), ConfigureAwait(false), the copy of a for counter, the lone retry
  • Comment the reason at the site of the fence, because the reason has to travel with the code
  • Push rules down to the directory that owns them and keep the root file short enough to re-read
  • A rule that can't fail is a suggestion — back the ones you mean with an analyzer, a hook or a CI check that exits non-zero
  • A check that prints a problem and exits zero is worse than no check — it sells you confidence and delivers nothing
  • Stale rules are worse than missing ones — date anything that expires, and prune in the same commit as the change

🚀 Next Steps

  1. Open your rules file and delete every line the model already knows. Naming, SOLID, "handle errors appropriately." Sharpen the vague ones that hide a real scar. Whatever survives is the actual file.
  2. Take your last three incidents and write one rule each, with the date and one sentence of what happened. If you can't name three, you haven't been reading your own postmortems.
  3. Pick the rule you'd be angriest to see broken. Give it teeth this week — an analyzer severity, a CI step, a test. If that's impossible, admit in the file that it's a convention and not a guarantee.
  4. Grep your rules file for paths and counts. Every one is a fact with an expiry date. Date them or delete them.
  5. Add the two-sentence description of what this repository is — and what it isn't. It's the highest-leverage paragraph in the file.

Start with the fence you've already had to rebuild once. You know the one.


Related Posts:

In the next post: What .NET 11 Deletes From Your Codebase — the controllers from Building Professional WebAPI Controllers, rebuilt one framework version later. The interesting part isn't the new APIs. It's the code you get to delete, and the rules in your file that go with it.

// KEEP READING