# AGENTS.md describes the boundary; CI enforces it

How to turn an architecture instruction for coding agents into an enforceable boundary.

By Sergio Cardenas, 2026-11-04

Canonical: https://warike.tech/blog/architecture-enforcement-coding-agents

## Intro

The [first post](https://warike.tech/blog/vite-trpc-separated-concerns) 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.** The rule exists and the patch still passes: AGENTS.md says transport may call NotesApi and only repositories import db; a coding agent imports NotesService directly into transport.ts; typecheck and tests both pass; the app boundary is skipped anyway.

*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](https://github.com/warike/trpc-enforced-layers).

## 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.

```md
# 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 has five branches: the rule is prose, not a program; TypeScript accepts the import, the one branch a check can control; 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 and calls NotesApi, the app implements the endpoint promise, the service maps storage rows to API values, and the repository owns SQL and db. A direct import from transport to service skips the app and is rejected by the policy.

*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.

```text
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.

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

const service = new NotesService(createPostgresNotesRepository());

export const notesRouter = t.router({
  list: t.procedure.query(() => service.list()),
});
```

```text
✓ 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.

```ts
// 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.

```text
$ 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](https://warike.tech/blog/vite-trpc-separated-concerns) 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.

## Sources

- [Vite and tRPC: from one query to separated concerns](https://warike.tech/blog/vite-trpc-separated-concerns)
- [Oxlint](https://oxc.rs/docs/guide/usage/linter)
