← Insights

AGENTS.md describes the boundary; CI enforces it

Sergio Cardenas · November 4, 2026

Intro

The first post defined a notes API with transport, app, service, and repository boundaries. This one picks up a question from Refactoring at AI speed, without the AI slop: how do rules a machine checks keep AI-written code clean? Narrowed to a single boundary, the question becomes: what keeps a coding agent from crossing it?

An AGENTS.md file helps an agent understand intent. It is not part of TypeScript's type system, the test suite, or CI. The agent can read the rule, write a plausible shortcut, and leave behind code that works but no longer follows the design.

The following figure shows how a written rule and a passing patch can coexist.

The rule exists. The patch still passes.

AGENTS.md
Transport calls NotesApi.
Only repositories import db.
Coding agent
Adds NotesService
to transport.ts
imports ./service.js
Typecheck + tests
The endpoint works
✓ typecheck✓ tests
Architecture
The app boundary
was skipped
✗ bypassed
nothing failed
Figure 1. The rule is documented, the checks pass, and the boundary is still skipped.

TLDR

  1. Instructions are guidance. They influence how an agent works; they do not reject an import.
  2. Behavior checks are incomplete. An endpoint can return the right JSON after bypassing every intended layer.
  3. Architecture needs a machine-readable policy. State which internal imports each layer may use.
  4. CI closes the loop. The same check that finds the shortcut locally prevents it from merging.

All code used in this article is available in the repository.

Objective

Take one explicit boundary from the notes API (transport calls NotesApi, not service, repository, or database) and make violations fail before merge.

Scope

Requirements

  • Keep the existing TypeScript+tRPC notes architecture unchanged.
  • Show a realistic coding-agent mistake that compiles and passes behavior tests.
  • Define one import-boundary policy and show its failure output.
  • Show the corrected transport and the CI gate that keeps it correct.

Implementation

1. Start with the rule in prose

The repository can explain the intended dependency direction clearly. The AGENTS.md below does exactly that.

# AGENTS.md

## Notes module

- Transport calls NotesApi.
- The app implements NotesApi and delegates to the service.
- The service depends on a repository interface.
- Only the repository imports db.

That is valuable context. It tells the agent why a shortcut is suspicious. It cannot make an invalid dependency a compiler error.

2. Break the failure into parts

The following tree breaks the failure into its causes.

Why the wrong patch can look healthy

Architecture drift
  • the rule is prose, not a program
  • TypeScript accepts the importa check can catch this
  • the endpoint still returns the expected JSON
  • behavior tests only check that JSON
  • CI has no boundary check yet
Figure 2. Five reasons the shortcut looks healthy. Only the import is something a check can see.

Not every branch needs the same treatment. The useful, controllable branch is the import itself: a transport module should not reach past the API boundary.

3. Choose one boundary to enforce

Here is the one direction the notes module allows.

The only allowed direction inside the notes module

transport
Validates, calls
NotesApi
validation
app
Implements the
endpoint promise
behavior
service
Maps storage rows
to API values
row mapping
repository
Owns SQL
and db
SQL
The dashed path compiles. The policy rejects it.
Figure 3. The allowed direction, and the shortcut the policy rejects.

The rule is deliberately narrow. A transport calling a service is not wrong in every architecture. It is wrong in this contract-first one because it bypasses the app and makes the chosen endpoint boundary optional.

4. Run the ordinary work plan

The following is the plan a capable agent runs. It can finish every step and still leave the wrong import behind.

1. Read AGENTS.md
2. Add notes.list
3. Run pnpm typecheck
4. Run pnpm test

The missing fifth step is not another instruction to read. It is a check with enough information to reject the dependency.

5. See the wrong patch pass

Below is the patch the agent writes.

// notes/transport.ts
import { NotesService } from "./service.js";

const service = new NotesService(createPostgresNotesRepository());

export const notesRouter = t.router({
  list: t.procedure.query(() => service.list()),
});
✓ pnpm typecheck   ✓ pnpm test   architecture bypassed

The same problem gets worse if transport creates or imports the repository directly. TypeScript sees valid symbols. The behavior test sees valid JSON. Neither check knows the team chose an app boundary.

6. Write the policy as code

The architecture check reads the TypeScript import graph and compares it with an explicit policy. This is illustrative configuration: the important part is that the allowed directions are data, not a sentence that may be forgotten. The policy for the notes module looks like this.

// architecture-policy.ts
export const allowedInternalImports = {
  "notes/transport.ts": ["notes/api.ts", "notes/contract.ts", "trpc.ts"],
  "notes/api.ts": ["notes/service.ts", "notes/contract.ts"],
  "notes/service.ts": ["notes/repository.ts", "notes/contract.ts"],
  "notes/repository.ts": ["database/**", "notes/contract.ts"],
};

An architecture enforcer follows this model: it evaluates imports against architecture policies. It is separate from Oxlint, which is still useful for fast source-level and Zod rules.

7. Make the bad patch fail, then fix it

The check below runs on the bad patch first, then on the fix.

$ pnpm architecture:lint
✗ notes/transport.ts imports ./service.js
  transport may internally import only notes/api.ts, notes/contract.ts and trpc.ts

// notes/transport.ts
import type { NotesApi } from "./api.js";

export function createNotesTransport(api: NotesApi) {
  return t.router({
    list: t.procedure.query(() => api.list()),
  });
}

$ pnpm architecture:lint
✓ import boundaries respected

Run the same command in CI. Now the agent still has the explanation in AGENTS.md, but a dependency that violates it cannot quietly merge.

Conclusions

Use AGENTS.md to describe the design, vocabulary, and reasons behind a boundary. Use contracts and folder layout to make the design legible. Use architecture lint and CI to make a violation observable and blocking.


Post 1 defines the architecture. This post keeps it from decaying. A later Go post can apply the same operating model to ports, adapters, and Go's own toolchain.

Share