Contract context files
Day 21: Contract context — aligning teams when every team runs coding agents
Two teams share one API, and both build with coding agents. After a Friday meeting, nobody updated the spec. By Tuesday you have forty green PRs and a broken integration environment, and not one of those PRs is wrong. An outdated agreement used to cost a few PRs and an awkward conversation at sprint review. Now it can cost dozens of PRs in a single day. This lesson covers the one small file that prevents most of that.
The Tuesday with ~40 green PRs
Here's how it fails, step by step. It's common, and after you've seen it once you'll spot it early.
- ▹Friday afternoon. The billing and checkout leads meet and agree on two changes: refund amounts move from decimal strings to integer minor units, and `refund_reason` becomes required. Someone says, "I'll update the spec." The decision ends up in a meeting summary and in four people's heads.
- ▹Monday morning. Both teams start agent tasks. Billing's agents work from the new ticket, which has the new rules. Checkout's agents work from `openapi.yaml` in their own repo, which still describes the old contract. Nobody updated it over the weekend, and nobody was ever going to.
- ▹Monday to Tuesday midday. About 40 PRs land across both teams. Every one passes unit tests and contract tests, but each side tests against its own copy of the contract. Reviewers approve because the diffs look right. And they are right for the contract each side was given.
- ▹Tuesday afternoon. The integration environment falls over. Refunds are off by a factor of 100, checkout gets 422s for a missing `refund_reason`, and a retry loop makes it worse. Three engineers spend the afternoon bisecting PRs and find nothing wrong in any of them.
Every PR was correct. The org was wrong.
The shift, in three sentences
Before agents, a stale contract cost you a few PRs and came up at sprint review. With agents, it costs dozens of PRs a day and shows up within hours. Agents don't create misalignment. They make it show up much faster.
Earlier in this series we covered clear ownership and written decision records. Back then they were good hygiene. Here they become mandatory, because an agent only knows what's written down.
Why more syncs won't fix it
The usual fix is a cross-team standup or a weekly integration sync. That helps the humans a little and the agents not at all. An agent can't attend the meeting or read the room, and it won't remember that Priya said "we're moving to cents" on Friday. It reads the files in its context window and works from those. A decision that exists only in people's heads doesn't exist for the agent. The timing doesn't work either: the sync happens once a week, and agents open PRs all day.
My PhD work on multi-agent systems points the same way. The classic multi-agent literature formalised coordination through agent communication languages such as KQML and FIPA ACL, and through social commitments. Munindar Singh's work models a commitment as an explicit record of who owes what to whom, under what condition. That literature puts coordination failures at concrete levels rather than blaming a missing shared vision, and two of those levels matter here. One is the message schema: two agents read the same field differently. The other is commitments: one agent thinks a promise is still open while the other has quietly dropped it. Recent studies of LLM multi-agent failures, such as Cemri et al.'s 2025 paper "Why Do Multi-Agent LLM Systems Fail?", report inter-agent misalignment as a major failure category. Two teams plus their agents make a multi-agent system, so fix the problem at the schema and commitment level.
What a contract context file is
A contract context file is a small, versioned markdown file for one team boundary. Both teams' agents, and their humans, must read it before touching that boundary. It doesn't replace your OpenAPI spec or your Pact tests. It sits above them and records what they can't: invariants, owners, promises, and what not to assume. It has five sections and should fit on one screen.
---
contract: billing-boundary
version: 3.1.0
file_owner: "@billing-lead"
review_by: 2026-11-15
---
# Billing <-> Checkout boundary
## 1. Interface
- Schema: billing-api/openapi.yaml @ tag billing-api-v3.1.0
- Events: refund.issued v2 (events/refund_issued.v2.json)
- Boundary paths: checkout src/payments/billing_client/**, billing api/refunds/**, api/charges/**
## 2. Invariants
- Amounts are always integer minor units (amount_minor: 1050 = 10.50). Never floats, never decimal strings.
- Currency (ISO 4217) is always sent with the amount. There is no default currency.
- Every POST carries idempotency_key. Billing dedupes for 24h.
- Billing enforces refund total <= captured amount. Checkout must not silently clamp.
## 3. Owners
| Side | Owner | Backup | Approves contract PRs |
|----------|-----------------|--------------------|-----------------------|
| Billing | @billing-lead | @billing-senior | @org/billing-leads |
| Checkout | @checkout-lead | @checkout-senior | @org/checkout-leads |
## 4. Open commitments
| ID | Who | Promised | By | If it slips |
|------|----------|----------------------------------------------|------------|-----------------------------------------------|
| C-12 | Billing | refund_reason becomes required (422 if absent)| 2026-10-27 | Stays optional; checkout sends it from today |
| C-13 | Checkout | Stop calling /v2/refunds | 2026-11-03 | Billing keeps v2 until 2026-11-30, then 410 |
## 5. Do not assume
- POST /refunds is NOT synchronous. It returns 202; final state arrives via refund.issued.
- charge_id is opaque. Do not parse it.
- Error messages are not stable. Match on error.code only.
- Do not infer behaviour from the other team's code or tests. If it isn't in this file, it isn't promised. Open a contract PR.- ▹Interface: link to the real schema and pin its version. Don't paste the schema in, because the copy will drift.
- ▹Invariants: rules that hold across every endpoint and that a schema can't express, or expresses badly. "Minor units" is the classic case, since `1050` and `10.5` are both valid numbers.
- ▹Owners: a named owner per side, a backup, and the group whose approval is required. Use handles or roles, not personal details.
- ▹Open commitments: who promised what, by when, and what happens if it slips. The fallback column is the one that counts. It lets an agent on either side act correctly before the date, after it, and if the promise is missed.
- ▹Do not assume: the traps. Agents are good at inferring plausible behaviour from whatever code they can see, and this list keeps them from inferring the wrong thing.
How to wire it so agents actually load it
If no agent loads the contract file, it's just a wiki page. You need three pieces of wiring: a reference in each team's agent instructions, a pre-task rule, and a CI check that catches the cases where the first two didn't work.
1. Reference it from each team's agent instructions: `CLAUDE.md` for Claude Code, `.cursor/rules` for Cursor, `.github/copilot-instructions.md` for Copilot, or a shared `AGENTS.md`. Both teams link the same file. If the teams are in different repos, keep contracts in a small shared repo and pull it in as a submodule or fetch it in CI. Don't keep two copies.
## Team boundaries (read before editing)
- Files under src/payments/billing_client/** are on the billing boundary.
- BEFORE editing any of them, read contracts/billing-boundary.md in full.
- In the PR description, cite the version you read, exactly as:
Contract: billing-boundary@<version>
- If your task needs behaviour the contract does not promise, STOP.
Do not code around it. Draft a change to the contract file as a separate PR
and say so in your summary.2. The pre-task instruction above does most of the work. The key part is the citation line, because it turns "did the agent actually read it?" into something a machine can check.
3. Add a CI check. It fails any PR that touches boundary paths without citing the current contract version, and it warns when the contract is past its review date.
name: contract-check
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
billing-boundary:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Verify contract citation
env:
PR_BODY: ${{ github.event.pull_request.body }}
BASE: ${{ github.event.pull_request.base.sha }}
run: scripts/check-contract.sh billing-boundary 'src/payments/billing_client/'#!/usr/bin/env bash
set -euo pipefail
name=$1; boundary=$2
file="contracts/$name.md"
# Only enforce when the PR touches the boundary
# (no grep -q: with pipefail, an early exit can SIGPIPE git diff and skip the check)
git diff --name-only "$BASE"...HEAD | grep "^$boundary" >/dev/null || exit 0
current=$(grep -m1 '^version:' "$file" | awk '{print $2}')
review_by=$(grep -m1 '^review_by:' "$file" | awk '{print $2}')
cited=$(printf '%s' "$PR_BODY" | grep -oE "Contract: $name@[0-9.]+" | head -1 | cut -d@ -f2 || true)
if [[ -z "$cited" ]]; then
echo "PR touches $boundary but does not cite 'Contract: $name@<version>'"; exit 1
fi
if [[ "$cited" != "$current" ]]; then
echo "PR cites $name@$cited but the current contract is $current. Re-read it."; exit 1
fi
if [[ "$(date +%F)" > "$review_by" ]]; then
echo "::warning::$file is past its review date ($review_by). Owner must re-confirm it."
fiGet two details right. First, the workflow passes the PR body through `env:` instead of inlining `${{ }}` into the script, because a PR body is untrusted input. Second, turn on "require branches to be up to date before merging". When the contract is bumped mid-flight, open PRs then have to rebase, and the check re-runs against the new version.
The one rule that makes it work: change by PR, approved by the other team
The contract file changes only through a PR, and the team on the other side of the boundary must approve it. Without this rule, what you have is a well-formatted doc that one side edits whenever it suits them.
The rule moves disputes out of Slack threads and meetings and into a diff everyone can see. "I thought we agreed cents" becomes a line that someone either approved or didn't. The PR history becomes the decision record for that boundary. Agents can draft these PRs, and that's often how a gap gets found. A human on each side approves.
Watch for one CODEOWNERS trap. If you list two teams on one CODEOWNERS line, GitHub accepts an approval from either team, not both. Use CODEOWNERS to request reviews from both teams. Then enforce the both-teams rule with a small required status check that reads the PR's reviews (`gh api repos/{owner}/{repo}/pulls/{n}/reviews`) and confirms an approval from each team. Trigger it on `pull_request_review` as well as `pull_request`, or it won't re-run when the second approval lands.
# .github/CODEOWNERS — requests both, but either one satisfies it on its own
contracts/billing-boundary.md @org/billing-leads @org/checkout-leadsHere's the honest cost. The first two weeks feel slower. A contract PR sits waiting on the other team's lead, and whoever is waiting complains that "this used to be a Slack message". They have a point. It was a Slack message, and that's why it broke the integration environment. Two things help: agree on a review time for contract PRs (we used "same working day"), and keep each PR to one change.
What changed after we did it
Before, integration broke at our busiest boundary almost daily, and each break cost an afternoon of cross-team bisecting. Afterwards, breaks became rare. When one did happen, it was usually an invariant missing from the file. That meant a one-line contract PR, not a blame session.
We still argued, just in a different place. Engineers still disagreed about idempotency windows and about who should validate refund limits. Now they disagreed in contract PRs, before any agent wrote code against the answer, which is where those arguments belong.
We also had a failure. A contract file for a lower-traffic boundary went stale. The engineer who wrote it moved teams, and nobody else treated it as theirs. The CI check kept passing because PRs dutifully cited the current version, and the current version was wrong. Agents faithfully followed an outdated invariant. The CI check proves the file was read. It can't prove the file is still true. Our fix was to give every contract file a named `file_owner` and a `review_by` date in the front matter, and to show the CI warning on every boundary PR once that date passes. The owner's job is small: re-read the file, confirm or update it, and bump the date.
When to use it, and when not to
- ▹Use it at boundaries between teams that both ship with agents, especially where a meeting decision has broken things before.
- ▹Use it where invariants or commitments matter more than shape: money, identity, time zones, async flows. Schemas cover shape well and the rest poorly.
- ▹Skip it inside a single team. There, one CLAUDE.md and normal code review are enough.
- ▹Skip it for stable, versioned public APIs that already publish a changelog and a deprecation policy. That documentation already does this job.
- ▹Don't let it replace consumer-driven contract tests like Pact. Tests verify the shape. This file records the invariants and promises the tests can't capture.
Common pitfalls
- ▹Letting it grow into a design doc. Once it's longer than one screen, agents skim it, and so do humans. Link out for detail.
- ▹Copying the schema into the file instead of linking and pinning it. Then you have two sources of truth.
- ▹Leaving the fallback out of open commitments. A promise with no "if it slips" just becomes the next silent mismatch.
- ▹No owner and no review date. The file rots, and agents keep following it anyway.
- ▹Letting agents merge contract changes. They can draft them, but humans on both sides approve.
- ▹Accepting one-team approval because the other lead is busy. The first time that happens, the rule stops existing.
Monday checklist
- ▹Pick the single team boundary that hurts most: the one where integration breaks or "I thought we agreed" comes up most often.
- ▹Write the first contract file in 30 minutes using the five-section template above. Imperfect is fine, because v0.1 beats a meeting summary.
- ▹Link it from both teams' agent context (CLAUDE.md, Cursor rules, Copilot instructions, or AGENTS.md) with the read-and-cite instruction.
- ▹Add the CI staleness check on the boundary paths and make it a required check.
- ▹Agree on the two-team approval rule and the review time with the other team's lead, in writing, as the first contract PR.
- ▹Don't take on every boundary at once. Run one boundary for two weeks, fix the template, then move to the next.
Today's lesson in the arc
At a team boundary, a leader's job used to be remembering what was agreed. You were the one who could say "no, we decided cents on Friday." That doesn't scale when 40 PRs land before you've opened Slack. Now the job is to make sure the agreement is written down where both humans and agents read it, that it has an owner, and that it can only change with both sides watching. Agents coordinate through shared artefacts, not conversations. In practice, so do humans.
Tomorrow, Day 22: once boundaries are stable and agents produce far more PRs, what is human code review actually for? We'll redesign review so humans spend their attention on the parts agents can't verify.
Extend your knowledge
- ▹Munindar P. Singh's work on commitment-based protocols in multi-agent systems. It's the formal version of the 'open commitments' section.
- ▹Ian Robinson's 'Consumer-Driven Contracts: A Service Evolution Pattern' on martinfowler.com, plus the Pact framework. Together they're the test-level complement to a contract context file.
- ▹Cemri et al. (2025), 'Why Do Multi-Agent LLM Systems Fail?', for a taxonomy of coordination failures that maps directly onto team boundaries.
- ▹Michael Nygard's 'Documenting Architecture Decisions' (ADRs), worth a second read. Contract PRs are boundary-scoped ADRs that agents can read.
Discussion
Chat with Chi Cong (AI) about this article. Your conversation is private to you — you can publish a summary for others when you're done.