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
Instructions are guidance. They influence how an agent works; they do
not reject an import.
Behavior checks are incomplete. An endpoint can return the right JSON
after bypassing every intended layer.
Architecture needs a machine-readable policy. State which internal
imports each layer may use.
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 → service
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.
✓ 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.
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.
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.mdfile 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.
NotesApi.Only repositories import
db.NotesServiceto
transport.tswas skipped
TLDR
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
Implementation
1. Start with the rule in prose
The repository can explain the intended dependency direction clearly. The
AGENTS.mdbelow 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
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
NotesApiendpoint promise
to API values
and
dbThe 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.
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()), });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.
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.mdto 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.