Keeping AI-written code from eroding your architecture: enforce the rules on agents too

An agent optimises for the task passing, and your architecture is not part of that goal. Any rule the build does not enforce gets broken faster and more politely than a human would manage. Here is the guardrail stack we use, and how we protect the guardrails themselves.

The task handed to the agent is one sentence: "Show the price in search results with the active campaign discount applied." Four minutes later the PR is up. Tests are green, the description is tidy, the commit messages are more careful than yours. Then you open the diff. SearchFeature now imports Networking, a new object called CampaignCache.shared has been born, a third price-formatting helper has been written, and the expected value of a failing test has been quietly loosened. None of it comes from malice or incompetence. The task said "show the price", and the price is shown.

Our thesis: an agent's objective is that the task passes, and your architecture is not a term in that objective. A rule the build does not enforce will be broken by an agent faster, and far more politely, than by the most tired developer opening a PR on a Friday evening. We covered how to move rules into the build, tool by tool, in the iOS and Android articles. We will not repeat those mechanics here. This article is about what changes once agents write part of the code. The examples reuse the hypothetical travel apps from those two articles: Voyage on iOS, Rota on Android.

The agent closes the task, and architecture is not in the task description

A human who breaks a rule hesitates for at least a moment, because they sat in the architecture meeting. The agent never heard of that meeting. It works with whatever is in its context and takes the shortest path the compiler accepts. The forms of erosion we see often in large codebases can all sit in the same PR:

// Sources/Features/SearchFeature/SearchViewModel.swift (from the agent's PR)
import BookingInterface
import Networking                       // 1) the far side of the boundary, "for a single call"
import SwiftUI

@MainActor
public final class CampaignCache {      // 2) a new singleton  3) public for no stated reason
    public static let shared = CampaignCache()
    public var discounts: [BookingID: Decimal] = [:]
}

@MainActor
final class SearchViewModel: ObservableObject {
    @Published var results: [SearchResultRow] = []
    private let repository: any BookingRepository
    // 4) a second road to the network: the repository is right there, the feature builds its own client
    private let client = HTTPClient(baseURL: URL(string: "https://api.voyage.example")!)

    init(repository: any BookingRepository) { self.repository = repository }

    func applyCampaigns() async throws {
        let data = try await client.get("campaigns/active")
        for campaign in try JSONDecoder().decode([CampaignDTO].self, from: data) {
            CampaignCache.shared.discounts[campaign.bookingID] = campaign.rate
        }
        results = results.map { $0.applying(CampaignCache.shared.discounts[$0.id]) }
    }
}

// 5) the third copy of the PriceFormatter that lives in DesignSystem
extension Decimal {
    func formattedPrice(currency: String) -> String {
        formatted(.currency(code: currency))
    }
}

On Android the same task leaves the same tracks. First the build file, then the code:

// feature/search/impl/build.gradle.kts (from the agent's PR)
dependencies {
    implementation(project(":feature:search:api"))
    implementation(project(":feature:booking:api"))
    implementation(project(":feature:booking:impl"))   // CampaignCalculator lived there
    implementation(libs.retrofit)                      // "for the campaign service"
}
// feature/search/impl/src/main/kotlin/com/rota/feature/search/impl/CampaignPrices.kt
object CampaignCache {                                  // new global mutable state
    val discounts = mutableMapOf<String, BigDecimal>()
}

class CampaignPriceLoader {                             // public, because that is Kotlin's default
    private val service = Retrofit.Builder()            // a second Retrofit, a second connection pool
        .baseUrl("https://api.rota.example/")
        .addConverterFactory(MoshiConverterFactory.create())
        .build()
        .create(CampaignService::class.java)

    suspend fun load() {
        service.active().forEach { CampaignCache.discounts[it.bookingId] = it.rate }
    }
}

Every line has a reasonable explanation. The agent searched for CampaignCalculator, found it in :feature:booking:impl, and added a dependency on it. It did not find PriceFormatter in the project (or did not look), so it wrote a new one. It needed shared state and reached for the pattern it saw most often in its training data. What that singleton does to test isolation, or to state that outlives the user session, never enters the picture: the pattern compiles and the task closes.

The sneakiest one is the test, because that is where reviewers look least:

// Before
@Test
fun `campaign discount is subtracted from list price`() = runTest {
    val row = viewModel.rows.value.first()
    assertEquals(BigDecimal("90.00"), row.price)
}

// After the agent, commit message: "fix flaky assertion"
@Test
fun `campaign discount is subtracted from list price`() = runTest {
    val row = viewModel.rows.value.first()
    assertNotNull(row.price)
}

The test passes and its name still promises the same thing, while what it verifies has shrunk to "a price exists". When you tell an agent to make the tests pass there are two routes: fix the code, or change the test. The second is often shorter.

What separates this from human behaviour is speed and volume. A developer takes a shortcut once a month and has a fair chance of being caught in review. Once agents open dozens of PRs a day, reviewer attention is the first resource to run out, and each of the lines above gets through because each looks innocent on its own.

An instruction file is advice, the build is a verdict

The first reflex is to write the rules into the file the agent reads. AGENTS.md is the shared format for that today: it sits at the repository root, can also be placed in subdirectories, and the agent goes by the file nearest to the code it is working on. Cursor, GitHub Copilot and Claude Code read it, and each also has a format of its own (.mdc files under .cursor/rules, .github/copilot-instructions.md, CLAUDE.md). Details differ per tool. Claude Code, for example, reads AGENTS.md directly only when there is no CLAUDE.md alongside it, and needs version 2.1.277 or later to do so. Check your tool's documentation before assuming the file is being read.

What these files have in common is that they are context. Claude Code's documentation says it outright: instruction files are treated as "context, not enforced configuration", and if you want an action blocked regardless of what the agent decides, you are told to use a hook. The same holds for the other tools. An instruction is a piece of text in the model's context window. In a long session it competes with other text, it gets summarised when the context is compacted, and when the task conflicts with the rule the task can win. Its only advantage over a wiki page is a better chance of being read.

Enforcement alone is not enough either. An agent that hits a build-breaking error without knowing why it broke or what the right way looks like will head for the nearest exit: silence the lint rule, add a line to the baseline, add the dependency to the manifest. It has an error and no direction.

Put the two together and you get to use the thing agents are best at. An agent works in a loop: write code, run the command, read the output, fix. A compiler error or a check script failure is input to that loop, and the agent processes it with far more discipline than a human reading a review comment three days later. The instruction file tells the agent which command to run and what the right way looks like, and the build makes sure the instruction cannot be ignored.

An agent reads instructions. It obeys the build.

The guardrail stack: five layers, cheapest to strongest

The setup of each layer is in the two earlier articles. The table below looks at the same ladder from the agent's side.

Layer Setup cost When the agent learns of a violation What changes with agents
Instruction file An afternoon It does not; it complies if it read the file The only place that carries the reason and the right way
Module boundaries and access control High, once At compile time, within seconds The shortest feedback loop; the agent reads the error and fixes it itself
Lint and architecture tests Medium When it runs the check command locally The quality of the error message becomes decisive
CI graph checks Low On the PR Catches everything the agent skipped running locally
Required review on boundary files Low At merge The authority to change a rule stays with a human

Module boundaries and access control become more valuable with agents. If Networking is not in the manifest entry for SearchFeature and the build breaks because of it, the agent sees the violation before it opens a PR, inside its own loop. On iOS, InternalImportsByDefault together with the internal default, and on Android explicitApi(), do the same job for public-by-default: the agent has to type public, and that word shows up in the diff.

Lint and architecture tests (SwiftLint custom_rules, Harmonize, Konsist, ArchUnit, detekt) carry the rules that live inside a module: no URLSession in a feature, no new static let shared, Impl classes stay internal. This is the layer that catches the new singleton and the second road to the network, because both can be written without crossing a module boundary.

The graph check in CI (the script that reads the manifest on iOS; module graph assertion and dependency guard on Android) is the last line of defence. We do not trust which commands the agent ran locally. CI runs all of them on every PR.

The fifth layer used to be a nice-to-have. With agents it is mandatory: required review through CODEOWNERS for the manifest, build files, lint config, baselines and the check scripts. The reason is in the section on protecting the guardrails, below.

How to write a rule an agent can follow

The architecture section of most instruction files looks like this:

## Architecture
- Follow Clean Architecture principles.
- Be careful with dependencies between layers.
- Avoid unnecessary singletons.
- Avoid duplication, use the existing helpers.

None of these four lines can be checked. "Be careful" does not describe an action, "unnecessary" has no measure, and nothing says where "the existing helpers" are. The agent reads this text, agrees with it, and writes import Networking.

A rule an agent can follow has four properties: its scope is stated as file paths, there is a command that verifies it, the reason is written in one sentence, and it points at an example done the right way. The same section, written for Voyage:

## Architecture rules (enforced by the build; never add lines to a baseline)

Verify. After every change, and before you say you are done, run:
  swift package describe --type json | swift Scripts/check-architecture.swift
  swiftlint lint --quiet

1. Targets under Sources/Features import only the Kernel and Interfaces layers.
   Why: features must compile and test independently of each other and of networking.
   Right way: add the call you need to the relevant *Interface protocol and implement it in the
   *Data module. Example: Sources/Features/CheckoutFeature/CheckoutViewModel.swift

2. No new `static let shared`. Shared objects are created once in AppComposition and passed in
   through init parameters. Example: Sources/App/AppComposition/AppAssembly.swift

3. New types are internal. If you write `public`, name the module that uses it in the PR description.

4. Price, date and currency formatting lives in Sources/Kernel/DesignSystem/Formatting.
   Search before writing a new helper: grep -rn "func format" Sources/Kernel/DesignSystem

5. Do not touch these files. If you believe one has to change, stop and write down why:
   Package.swift, .swiftlint.yml, architecture-baseline.txt, Scripts/, ArchitectureRules/, .github/

6. Never change the expected value of a failing test. If you believe the test is wrong, leave it
   as it is and explain why under a separate heading in the PR description.

The "why" line is doing work. An agent that meets a case the letter of the rule does not cover can generalise in the right direction if it knows the reason. An agent that has read "features must compile independently" will conclude that it should not import a brand-new infrastructure module either, even though the rule never names it.

Keep the file short. Claude Code's documentation recommends aiming for under 200 lines per file and moving rules that apply only to certain folders into path-scoped files; the other tools have equivalents (globs in Cursor, applyTo in Copilot, a nested file for AGENTS.md). A forty-item rule list meets the same fate as a forty-item wiki page. We put only rules the build enforces into the instruction file, along with the command that verifies them. Writing an unenforced rule in there teaches the agent which rules are open to negotiation.

The error message is the agent's next prompt

When a check fails, its output goes into the agent's context, and that text decides the next step. The message from the script in the iOS article was good enough for a human:

error: SearchFeature -> Networking: undeclared import (SearchViewModel.swift)

A human reads that and thinks "right, I have to go through the repository". An agent reads the same line and applies the shortest fix: it adds Networking to Package.swift. The message told it what the violation is and nothing about what to do. The word "undeclared" practically invites declaring it. This is the output we want for the same violation:

error: SearchFeature -> Networking: the Features layer cannot import the Infrastructure layer (SearchViewModel.swift)
  Do this: add the call to the relevant Interface protocol and implement it in the *Data module.
  Example: Sources/Features/CheckoutFeature/CheckoutViewModel.swift
  Do not: add a dependency to Package.swift or grow architecture-baseline.txt.
  Verify: swift package describe --type json | swift Scripts/check-architecture.swift

The change in the script is small. The violation line stays as it is (the baseline matches on that line), and a remedy is attached at print time, keyed by the pair of layers:

struct Remedy {
    let instead: String   // what to do
    let example: String   // a file that does it right
}

let remedies: [String: Remedy] = [
    "Features->Infrastructure": Remedy(
        instead: "add the call to the relevant Interface protocol and implement it in the *Data module.",
        example: "Sources/Features/CheckoutFeature/CheckoutViewModel.swift"),
    "Features->Features": Remedy(
        instead: "move the shared type to an Interface module and let AppComposition wire the transition.",
        example: "Sources/Interfaces/PaymentInterface/PaymentFlowStarting.swift"),
]

func describe(_ violation: String, from: String, to: String) -> String {
    guard let remedy = remedies["\(from)->\(to)"] else { return "error: \(violation)" }
    return """
    error: \(violation)
      Do this: \(remedy.instead)
      Example: \(remedy.example)
      Do not: add a dependency to Package.swift or grow architecture-baseline.txt.
      Verify: swift package describe --type json | swift Scripts/check-architecture.swift
    """
}

On Android most tools already give you the slot. reason on detekt's ForbiddenImport, explanation on a custom lint rule and because in ArchUnit all exist for this, and in most projects they are either empty or restate the rule:

@Test
fun `ui packages do not access data packages directly`() {
    noClasses().that().resideInAPackage("..ui..")
        .should().dependOnClassesThat().resideInAPackage("..data..")
        .because(
            "UI only sees the domain layer. Do this: fetch the data through a UseCase " +
                "(example: feature/booking/impl/domain/GetUpcomingBookingsUseCase.kt). " +
                "Do not: change this test or update the archunit-store files."
        )
        .check(classes)
}

The same goes for the message field in SwiftLint custom_rules. The test for a good message is simple: could someone who has never seen the project make the correct fix from this output alone. The agent is exactly that someone.

Protecting the guardrails themselves

In the earlier articles the last column of the ladder was "how it gets bypassed", and on the upper rungs the answer was always the same: by changing the rule file, visibly, in the diff. For people that is deterrent enough; nobody wants to be seen deleting a rule from .swiftlint.yml in their PR. An agent has no such inhibition. When the task is "make the build pass", the lint config, the baseline, the test and the manifest are just editable files. These are the escape routes we see:

  • Silencing the rule at the source: // swiftlint:disable, @Suppress, @SuppressLint.
  • Regenerating the baseline: --update-baseline, detektBaseline, deleting the lint baseline so it is written again, adding lines to the ArchUnit store.
  • Loosening the rule itself: one more edge in the allowed list, an excluded line in .swiftlint.yml, a narrowed scope in an architecture test.
  • Adding the dependency to the manifest or the build file.
  • Deleting the test, disabling it (@Ignore, @Disabled, .disabled(...)) or weakening its assertion.

We close them in three layers, and the order matters: only the last one is a real wall.

The agent's own permissions

Most agent tools let you define per-file permissions. In Claude Code these are the permissions rules in .claude/settings.json, committed to the repository; path patterns use gitignore syntax, and a leading / resolves against the project root the settings file belongs to. We show iOS and Android paths in one sample:

{
  "permissions": {
    "deny": [
      "Edit(/architecture-baseline.txt)",
      "Edit(/.swiftlint.yml)",
      "Edit(/Scripts/**)",
      "Edit(/ArchitectureRules/**)",
      "Edit(/config/detekt/**)",
      "Edit(**/lint-baseline.xml)",
      "Edit(/archunit-store/**)",
      "Edit(/.github/**)"
    ],
    "ask": [
      "Edit(/Package.swift)",
      "Edit(**/build.gradle.kts)",
      "Edit(/gradle/libs.versions.toml)"
    ]
  }
}

We use deny for baselines and lint config, and ask for the manifest and build files, because adding a new module is legitimate work and should be possible with a human's approval. Other tools call the equivalent by different names (allowlists, ignore files, approval modes); look in your tool's documentation for how a "cannot write this file" rule is defined.

Know where this layer ends. According to the Claude Code documentation, Edit deny rules cover the built-in file tools and the file commands it recognises in Bash (sed, tee, redirections), and they do not cover an arbitrary subprocess that writes the file indirectly. swift Scripts/check-architecture.swift --update-baseline, which regenerates the baseline, is exactly such a subprocess. These settings also bind only the agent that uses that tool. Local permissions are a speed bump: most of the time they steer the agent back onto the right road, and they guarantee nothing.

A check that catches guardrail diffs on the PR

The second layer is tool-independent and runs in CI: a script that looks at whether the PR loosens the guardrails.

#!/usr/bin/env bash
# Scripts/check-guardrails.sh: does this PR loosen the guardrails?
set -euo pipefail
base="$(git merge-base "${1:-origin/main}" HEAD)"
fail=0

added_lines() { git diff --unified=0 "$base" HEAD -- "$@" | grep -E '^\+[^+]' || true; }

# 1. New suppression comments and annotations
suppressed="$(added_lines '*.swift' '*.kt' | grep -E 'swiftlint:disable|@Suppress(Lint)?\(' || true)"
if [[ -n "$suppressed" ]]; then
  echo "error: this PR adds new lint suppressions:"
  echo "$suppressed"
  echo "  Do this: remove the suppression and fix the code the rule points at."
  fail=1
fi

# 2. Baseline files that grew
for file in architecture-baseline.txt config/detekt/baseline.xml app/lint-baseline.xml; do
  [[ -f "$file" ]] || continue
  before="$( (git show "$base:$file" 2>/dev/null || true) | wc -l )"
  after="$(wc -l < "$file")"
  if (( after > before )); then
    echo "error: $file grew ($((before)) -> $((after)) lines). Baselines only shrink."
    echo "  Do this: fix the code instead of recording the new violation in the baseline."
    fail=1
  fi
done

# 3. Deleted or disabled tests
deleted="$(git diff --diff-filter=D --name-only "$base" HEAD -- '*Tests.swift' '*Test.kt')"
disabled="$(added_lines '*Tests.swift' '*Test.kt' | grep -E '@Ignore|@Disabled|\.disabled\(|XCTSkip' || true)"
if [[ -n "$deleted$disabled" ]]; then
  echo "error: this PR deletes or disables tests:"
  echo "$deleted$disabled"
  echo "  Do this: restore the test. If the test is wrong, say why in the PR description."
  fail=1
fi

# Exception: CI sets this variable only when a label applied by the architecture owner is present.
if (( fail )) && [[ "${GUARDRAIL_CHANGE_APPROVED:-}" != "1" ]]; then exit 1; fi

Line count is a crude measure for XML baselines, but it points in the right direction. How the label maps to the variable depends on your CI system; what matters is that the PR author cannot grant the exception to themselves. This script does not catch a weakened assertion (assertNotNull where assertEquals used to be). We leave that to review, and say what to look for below.

Required review: the actual wall

The agent can edit the script too. So the last layer is ownership of the guardrail files:

# .github/CODEOWNERS  (when several lines match, the last one wins)
/Package.swift                @voyage/architecture
/architecture-baseline.txt    @voyage/architecture
/.swiftlint.yml               @voyage/architecture
/Scripts/                     @voyage/architecture
/ArchitectureRules/           @voyage/architecture
/AGENTS.md                    @voyage/architecture
/.claude/                     @voyage/architecture
/.github/                     @voyage/architecture

# The Android equivalents
/build-logic/                 @rota/architecture
/app/build.gradle.kts         @rota/architecture
/gradle/libs.versions.toml    @rota/architecture
/config/detekt/               @rota/architecture
**/lint-baseline.xml          @rota/architecture
/archunit-store/              @rota/architecture
/app/dependencies/            @rota/architecture

The file enforces nothing by itself. On GitHub, "Require review from Code Owners" has to be switched on in branch protection or in a ruleset; on GitLab the equivalent is Code Owner approval on a protected branch. As GitHub's documentation recommends, the CODEOWNERS file itself should be owned too (here through the .github/ line), otherwise it is obvious which line gets deleted first. We also put the instruction file and the agent settings on the list: removing a rule from AGENTS.md is changing the rule.

On Android we do not make every build.gradle.kts an owned file. Forbidden edges in module build files are caught by graph assertion, and classpath changes by dependency guard; ownership of app/build.gradle.kts (where the allowed list lives), the convention plugins and the version catalog is enough. Routing every module's build file through the architecture team turns that team into a bottleneck within two weeks.

The agent applies the rule. The authority to change the rule stays with a human.

Reviewing an AI-authored PR

Once the machine handles everything a machine can catch, what is left for review gets smaller without disappearing. We covered reviewing AI output in general in a separate article; for architecture we suggest three habits.

Look at the dependency diff first. Before reading a single line of code, run this:

git diff origin/main...HEAD --stat -- Package.swift '*.gradle.kts' gradle/libs.versions.toml \
  architecture-baseline.txt '*baseline*.xml' .swiftlint.yml config/detekt

If the output is empty, the PR does not change the shape of the architecture and you can read the rest as an ordinary review. If it is not empty, the review starts there: why was this edge needed, and did the task call for it.

Look at the shape of the diff. The recurring smells in agent PRs are these: changes in modules unrelated to the task description, new files whose names end in Helper, Utils or Manager, new public and new shared or object, a test diff larger than the production diff, and expected values changing inside that test diff. For the last one it is enough to read only the removed and modified assertion lines in test files; added tests are rarely the problem.

Ask for the plan. Make it a requirement in the instruction file that the agent writes a short plan before writing code and puts it in the PR description: which modules it will touch, which existing types it will reuse, which new public surface it will open. In review you compare the plan with the diff. A module in the diff that the plan never mentioned means the scope grew silently, and you have a concrete line to ask about. The plan also catches mistakes while they are cheap. You reject the sentence "I will use CampaignCalculator from booking:impl" in five seconds; rejecting the 300-line diff built on that decision takes half an hour.

Putting the agent on the right side of the rules

So far the agent has looked like the party inclined to break the rules. Once a rule is enforced, the picture flips: the agent is the cheapest labour you have for applying it. The tedious, mechanical work that people have postponed for years, where a machine can verify the result, suits it well.

Burning down baselines comes first. Every line in a baseline is a task with a clear definition and a machine-verifiable finish:

Task: bring the "SearchFeature -> Networking" lines in architecture-baseline.txt to zero.

Rules:
- One commit per violation. Run the check command and swift test before each commit.
- Remove only the line you resolved from the baseline; never add one.
- Do not add dependencies to Package.swift. If a new call is needed, add it to the BookingInterface
  protocol and implement it in BookingData.
- Behaviour must not change: do not touch the expected values of existing tests.
- If one violation needs more than 50 changed lines, stop, write your plan, wait for approval.

Definition of done: the check command reports 0 new violations, this edge is gone from the baseline,
tests are green.

For this task you deliberately relax the deny rule on the baseline file for that session; the CI script only rejects growth anyway and lets the file shrink. Because the iOS script also fails when a resolved line is left behind in the baseline, the agent is squeezed from both sides.

Second come mechanical migrations where the compiler is the referee. Turning on explicitApi() in a module produces a compile error for every missing modifier; you give the agent the rule "default to internal, public only for declarations used from another module" and it works until the build passes. Enabling InternalImportsByDefault target by target, turning shared accesses in features into init parameters, and demoting api dependencies to implementation belong to the same class. What they share: the agent's judgement does not decide what the correct result is. The compiler or a test does.

Order matters. Make the rule enforced first, then let the agent loose on the migration. Do it the other way round and the agent cleans one module while producing new violations in the one next door, and nobody notices.

Where to stop

For a two-person, single-module app the full stack is too much. A short instruction file, a few lint rules and a careful review are enough there; CODEOWNERS has no second person to protect. The stack starts paying for itself when agents open more than one PR a day, or when the reviewer is someone other than the person who launched the agent.

Two numbers are enough to measure it. The first is the line count of your baseline files: does it keep falling after agents arrive. The second is how many of the PRs that fail an architecture check go green on the next commit without human intervention. If that share is rising, your error messages are doing their job. If it is low, or PRs go green by way of a guardrail diff, it is time to rewrite the messages.

Checklist

  • Every architecture rule in the instruction file comes with the command that verifies it, a one-sentence reason and an example file done the right way.
  • No architecture rule in the instruction file is unenforced by the build; the file is short and folder-specific rules live in scoped files.
  • The agent runs the check commands before it says it is done; the same commands run in CI on every PR.
  • Error messages from architecture checks say what to do, name an example file and say what not to do.
  • public and api are deliberate decisions: InternalImportsByDefault on iOS, explicitApi() on Android.
  • There is a lint rule or architecture test for new singletons, direct network clients in features and forbidden imports.
  • CI rejects new suppression comments, growing baselines and deleted or disabled tests.
  • The manifest, build logic, lint config, baselines, check scripts, instruction file and CODEOWNERS itself are owned; code owner approval is required.
  • In the agent's local permissions, baselines and lint config are closed to writes and the manifest requires approval.
  • The PR template asks the agent for a plan; review starts from the dependency diff.
  • A deliberate trial (a task that tempts the agent to import Networking from a feature) breaks the build, and the agent fixes it without touching the baseline.
Level 21 completeYour progress is kept in this browser.
Up next: AI-generated tests: coverage goes up, but does confidence?

Need support on this?

Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.

Comments and questions

Disagree with something, or stuck on this in your own project? Write to us and let's talk.