The Software GardenRules that enforce themselves ← Contents
The Software Garden

Rules that enforce themselves

At agentic speed, a rule that only lives in a document will eventually be broken.
The rules that hold are the ones the build enforces.

Scroll ↓
Advice vs. fact

A rule in a document is advice. A rule in the build is a fact.

Write “core must not import from commands” in AGENTS.md and agents will follow it… most of the time. Across thousands of changes, “most of the time” adds up to a steady leak.

Turn the same rule into a test and it holds on every change, made by any agent with any context, reviewed or not.

Documentation still matters. It explains why a rule exists and tells an agent what the check will say before it runs into it.

📜 Rule written in AGENTS.md
please don't
main
🚧 Same rule as a test in CI
layering test
main
 
The enforcement ladder

Push every rule as far up the ladder as it will go.

The higher the rung, the earlier the agent finds out and the less anyone has to remember. At the top, the mistake can't even be written.

Our codebases use every rung. One rule can sit on several: c8ctl's layering guard runs in the pre-commit hook and in CI.

1
Impossible to writeTypes. In the TS SDK, a public config type with optional fields is split from an internal type with every field required, so a missing default fails to compile.
2
Flagged as you typeLints and banned APIs. The Rust SDK's clippy.toml bans Instant::now and sleep outside one clock adapter.
3
Rejected by a testArchitecture tests. layering-import-boundary.test.ts in c8ctl, architecture_test.go in the Go SDK.
4
Blocked in CIDrift checks, commitlint, PHPStan at max, README snippets that must match the compiled examples.
5
Caught in reviewBy an agent or a human. Works, but it's probabilistic.
6
Written downAdvice. The starting point for a rule, not where it should stay.
When you can't fix everything today

Ratchet: freeze the violations, then let the list only shrink.

A new rule often has existing violators. Fixing them all first stalls the rule. Waiting means new violations keep arriving.

c8ctl's test-import guard starts with a closed allow-list of the current violators, PENDING_MIGRATION. Nothing new can be added, and every migrated file comes off the list. The debt can only go down.

A ratchet turns a big clean-up into many small, safe changes. That's exactly the kind of work agents are good at.

violators
6
 

Pattern from c8ctl tests/unit/test-import-boundary.test.ts. File names are illustrative.

Exceptions

Some exceptions are legitimate. Keep each one as small as possible.

The Rust SDK bans ambient time, but the clock adapter itself has to call Instant::now. The rule allows that, on one statement only, with a reason. Never for a whole module.

A module-level exception switches the rule off for everything written there later, including by an agent that has never heard of the rule. A statement-level one keeps guarding the rest of the file.

Module-level allow
#![allow(disallowed_methods)]
 
fn now(&self) -> Instant {
  Instant::now()
}
 
fn backoff() {
  sleep(ms(200)); // new
}
✔ compiles clean… and the test clock no longer controls it
Statement-level allow
fn now(&self) -> Instant {
  #[allow(disallowed_methods)]
  // the one real clock
  Instant::now()
}
 
fn backoff() {
  sleep(ms(200)); // new
}
✖ clippy: disallowed method sleep: use the injected Clock
Always green

A check only works as a signal if it starts green.

Every repo has the same policy: warnings are fatal, nothing gets suppressed to make a build pass, and no failure counts as “pre-existing” without explicit confirmation. Verify the baseline is green before an AI-assisted session starts.

Red baseline
✖ 47 failing, 212 warnings
… agent makes a change …
✖ 48 failing, 219 warnings
Which one is mine? Probably none of them. Ship it.
Green baseline
✔ 0 failing, 0 warnings
… agent makes a change …
✖ 1 failing: layering guard
Unmistakable. The agent fixes it before anyone sees it.

A red baseline teaches agents (and people) that red is normal. After that, no check means anything.

Where rules come from

If a review says something twice, make it a rule.

Every repeated review comment is a rule waiting to move up the ladder. The first time, fix the code. The second time, fix the system: write the check, then document why it exists.

c8ctl goes one step further. Agents copy the pattern from a nearby file, and that copying is only reliable “when every nearby file follows the canonical shape”. So the canonical command-handler shape is written down and enforced by lints.

💬
Review comment“Handlers shouldn't read c8ctl.dryRun off the global.”
↓
🔁
Same comment, different PRThat's the signal: this is a class, not an instance.
↓
🚧
An AST-based guard testno-runtime-global-reads-in-handlers.test.ts: now no reviewer has to remember.
↓
📜
AGENTS.md explains whyAnd points at the guard, so agents know before they trip it.
So

Don't ask agents to remember. Make the system remember.

Write rules down to explain them. Enforce them to keep them. Push each rule up the ladder, ratchet the old violations down, keep exceptions narrow, and keep the baseline green so every red signal means something.