ENZH

Contracts, Not Tribal Knowledge

In the last piece I argued that your repository is now an interface an agent programs against, and that the most expensive thing in it is tribal knowledge — because the tribe forgets between every session. This piece is about the single highest-leverage fix for that, and it's almost boringly concrete: turn every boundary into a contract.

Let me start with the failure it prevents, because the failure is what makes the abstract advice click.

The Bug an Agent Can't See

Somewhere in your codebase there's a line like this:

queue.publish('user.updated', JSON.stringify(user));

And somewhere else — maybe in the same service, maybe in a repo the agent never loaded — something consumes 'user.updated' and expects a particular shape. That string is a contract. Two systems agree on it. But the agreement is written nowhere. It lives in the heads of whoever built both ends, and in the hope that nobody touches it carelessly.

Now ask an agent to rename the event, or add a field, or change user from a flat object to one with a nested profile. Watch what happens. The agent sees a string and a serialize call. It changes them. It runs the tests it can find. The producer's tests pass, because the producer is fine. The consumer lives in another service the agent never opened, so its tests never ran. The agent re-reads its own diff, sees clean code that does exactly what was asked, and reports success.

It's not wrong, exactly. It did the task. The contract it broke was invisible — nothing in the files it could see said "another system depends on the exact shape of this." A human might have remembered the mobile client. The agent has no memory to remember with. For an agent, the only contract that exists is the one written down. Everything implicit is, to it, simply not there.

This is why "be careful around user.updated" is not a strategy. It's a hope addressed to a reader that has amnesia. The strategy is to make the boundary a thing the agent cannot miss and the machine can check:

// contracts/events.ts
export const userEvents = {
  updated: 'user.updated.v1',
} as const;

export const UserUpdatedV1 = z.object({
  version: z.literal(1),
  userId: z.string().uuid(),
  profile: z.object({ /* ... */ }),
});

// producer
publishUserUpdated(UserUpdatedV1.parse(payload));

Now the event name is a constant the agent imports instead of a string it retypes slightly wrong. The shape is a schema that fails loudly if the payload drifts. The version is in the name, so a breaking change is a new contract, not a silent mutation of the old one. The boundary moved out of human memory and into the repository, where the agent can see it and a check can enforce it.

What Counts as a Boundary

Once you've felt that failure, the operating rule writes itself, and it's worth saying in one sentence because agents respond well to one-sentence rules:

If another module, process, user, service, file, database, queue, CLI, or agent depends on it, it is a contract.

That net is wider than most people's instinct, and deliberately so. The things that bite are rarely the obvious public API. They're the small stringly-typed promises scattered through the code that nobody thinks of as an interface:

  • route paths and their params
  • API request and response shapes
  • localStorage keys and the JSON stored under them
  • event and message names
  • error codes
  • feature-flag keys
  • metric and span names
  • config and environment variables
  • the columns of a file format
  • the data-testid your end-to-end tests select on

Every one of those is a place where two parts of the system agree on something. Every one of them, left as an inline literal, is a landmine an agent can step on without ever seeing it. The discipline is mechanical: never inline the literal, always import a named constant or a generated type. When the same contract string appears twice, that's not duplication you clean up later — it's a missing contract, and the second occurrence is the bug report.

A useful side effect: this also kills a class of hallucination. Agents invent plausible-looking string keys when they can't find the real one. If the real one is the only importable symbol, there's nothing to invent — the typo doesn't compile.

"Parse, Don't Cast" Is Now Load-Bearing

There's a specific version of this that deserves its own paragraph because it's so easy and so wrong:

const invoice = (await res.json()) as Invoice;

That cast is a promise to the type-checker that the bytes coming off the wire have the Invoice shape. At runtime the promise is erased — TypeScript types don't exist when the code runs, so the cast validates exactly nothing. If the API returns something else, the bad data flows inward wearing a trusted type, and it blows up three layers away from where it entered, in code that looks correct.

A human who writes that cast has usually at least eyeballed the API and is making a calculated bet. An agent will satisfy whatever the type signature demands and move on — the cast is the path of least resistance, and the type-checker is happy, so it looks done. The cast becomes a silent corruption channel that the agent is structurally inclined to reach for.

The fix is to validate at the boundary and pass parsed domain values inward:

const invoice = InvoiceSchema.parse(await res.json());

Same ergonomics, completely different runtime behavior. Now the untrusted data is checked the moment it crosses into your system, the error happens at the boundary where it's diagnosable, and everything downstream is dealing with a value that actually has the shape its type claims. The rule is small enough to enforce: anything external — HTTP bodies, route params, storage, postMessage, webhooks, config, persisted JSON — gets a runtime schema at the edge. Trusted, in-memory values created by your own code can rely on types alone. The boundary is exactly where types stop being enough.

The Strongest Contracts Aren't Code at All

The backend taught me the most aggressive version of this idea, and it's worth stealing for everything: the best place to put an invariant is the place a buggy agent can't route around.

Take a rule like "a customer can have only one active subscription." You can enforce that in application code. A human team mostly gets away with it, because they remember to check. But an agent — or a concurrent retry, or a second writer added next quarter — can violate it the moment the check is forgotten or raced. The invariant lived in code, and code is exactly what changes.

Push it into the database and it stops being negotiable:

CREATE UNIQUE INDEX one_active_subscription_per_customer
ON subscriptions (customer_id)
WHERE status = 'active';

Now the rule is enforced by the one component every writer has to go through, including future agents who never read the application logic. A not-null constraint, a foreign key, a check constraint, a unique partial index — these are contracts that survive concurrency, retries, and the agent that confidently deleted your validation because a test was in its way. The same instinct generalizes: the spec linted in CI, the migration that can't be edited once applied, the typed error envelope instead of a hand-rolled { error: string } per handler. Move the invariant as close as you can to the layer that can't be bypassed, and you've made it real instead of hoped-for.

Why This Is the Highest-Leverage Move

Of everything in the agent-maintainability playbook, contracts are where I'd spend the first day, and the reason is about blast radius rather than tidiness.

An agent's mistakes are bounded by what it can see. Inside a well-contracted boundary, a wrong change fails fast and loud — the schema rejects it, the constant doesn't exist, the database refuses the write, the contract test goes red. The damage is caught at the edge, in the diff, before it ships. Outside a contract, a wrong change is silent. It type-checks, it passes the tests the agent could reach, it looks done, and it surfaces a week later as corrupted data or a broken client nobody connected to that innocuous one-line rename.

Contracts convert the second kind of failure into the first. They don't make the agent smarter. They make the consequences of its blind spots visible, which is the only thing that lets a fast, confident, amnesiac worker operate at scale without quietly wrecking things. That's also why prose instructions can't do this job — a sentence in a markdown file telling the agent to "be careful with events" is still just tribal knowledge with extra steps. The contract has to be the kind of thing a machine enforces, which is the subject of the next piece.

Tribal knowledge was always a liability. We tolerated it because human memory subsidized it. The subsidy is gone. Write the contract down, give it a type, give it a test, and let the agent step on the landmines that don't go off.


Part two of four on agent-maintainable repositories. Previous: your codebase is an API for agents now. Next: your AGENTS.md is not a fence. Related: a coding agent is a stateful query loop, and Cortex, on compiling knowledge instead of leaving it tribal.


© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0