24. August 2026
An agent decides it is done when the checks it can run come back green. Every one of those checks is a question about the repository it is standing in. The team that generates a client from your OpenAPI file is not in that repository, is not in the test suite, and is not in the agent’s context window — so nothing in the loop objects when a field quietly disappears from a response.
I’ve been building brake, a breaking-change gate for API contracts — OpenAPI, protobuf and GraphQL, one ruleset, no network. This post is about the specific hole it’s aimed at: exit criteria in the agentic loop are all self-referential, and API evolution is the place where that assumption is most obviously wrong. The most recent work is the part that closes it properly, so most of this post arrives at that.
A coding agent finishing a task runs through some version of the same list. It builds. It runs the type checker. It runs the tests. It runs the linter. If it’s disciplined, it reads the diff back before claiming completion.
Notice the shape they share, which is different from the one I wrote about with tropism. There the observation was that every signal is local and about correctness. Here it’s narrower and sharper:
Every one of those checks is a question whose entire answer lives inside the working tree.
Change customer_id to customerId in a response schema, update the handler, update the tests, and the whole list goes green. It compiles, because your own code changed with it. The tests pass, because you changed those too — the agent will happily do all three in one pass, and it will be right about each of them. Nothing anywhere in that loop is a question about the four services and the mobile client that deserialise that response into a struct with a non-optional field.
The consumer has no representative in the room. There is no test that fails on its behalf, because its tests live in its repository. There is no compile error, because it compiles against a version of the contract it fetched last month.
This is worse with agents than with people for a reason that isn’t about capability. A human changing a response schema has a background process that fires — hang on, who reads this? — even if the answer takes a Slack message to find out. That process is memory of consequences, and it’s the exact thing an agent starting from zero every session does not have. It has the contract file open in front of it and no idea that the file is a promise.
The obvious answer is to go look at the consumers. That’s what a schema registry does — Confluent’s BACKWARD_TRANSITIVE, Pact’s can-i-deploy, an internal catalogue that knows who calls what.
Those are good tools and they’re all wrong for this slot in the loop, for one reason: they need something that isn’t in the repository. A registry needs a server and a network call. can-i-deploy needs a broker that knows what’s deployed where. A catalogue needs to be populated and current. The agent’s sandbox has none of them, and a check that isn’t available at the moment the code is written isn’t an exit criterion, it’s a code review.
So the first move brake makes is to stop trying to reach the consumer at all:
The contract file is the consumer’s stand-in, and its previous version is the promise you already made.
You don’t need to know who reads customer_id to know that removing it breaks anyone who does. That question is answerable from two documents — the contract as it is now, and the contract as it was at the baseline — both already in the repository, one of them in git history. No network, no running service, no toolchain.
That constraint is the product argument, exactly as it was for tropism. brake works on a fresh checkout with nothing installed, which is what lets it run:
It’s also a weaker claim than a registry makes, and worth being precise about. A registry answers did I break our consumers. This answers would this break a consumer — a universally quantified claim over anyone honouring the contract.
That was where the tool sat until recently, and it was useful. It was also, in the loop, one step short of the thing you actually want to say to an agent.
Before the second move, the soundness story, because it’s what decides which checks may block a commit.
A check that detects the presence of something is reliable offline. A check that detects the absence of something is not, because absence requires having seen every possible channel. Tropism’s unused-dep check has a 63% false-positive rate for exactly this reason, and its architecture rules are sound for the opposite one.
Breaking-change detection sits firmly on the good side of that line:
| Question | Shape | Sound offline? |
|---|---|---|
Is customer_id still in the response? |
presence in one document, absence in the next — both read | yes |
| Did a required parameter appear? | presence in the head document | yes |
Does web-checkout read customer_id? |
presence in a file the consumer wrote | yes — if that file is in the tree |
Does anybody read customer_id? |
absence, across repositories you cannot see | no |
The first two are facts about two files. There is no hidden channel through which a field can be removed invisibly, because the removal is the diff.
The interesting row is the third, and it’s the one the rest of this post is about. “Who reads this field?” looks like the unanswerable fourth question. It isn’t — not if the consumer has written down what it uses, and not if that writing-down is a file.
The original design document ruled out “Pact-style consumer-driven verification” on the grounds that it needs both sides running. That reasoning is correct about provider verification and wrong about the artifact.
A pact is a JSON document sitting in a directory. Reading it is the same act as reading an OpenAPI file.
That distinction is the whole of the new feature, and it’s worth laying out precisely, because everything depends on where the line falls:
| Needs a running service | brake does it | |
|---|---|---|
| Replay a consumer’s recorded requests against the provider, compare real responses | yes | no |
| Check the provider’s published contract against the consumer’s recorded expectations | no | yes |
Ask a broker what is deployed where (can-i-deploy) |
needs a server | no |
| Name which declared consumers a diff would break | no | yes |
Declare what your consumers use, and the finding stops hedging:
[[consumer]]
name = "web-checkout" # optional — a pact names itself
format = "pact" # pact | graphql-operations | manifest
source = "pacts/web-checkout-*.json" # globs allowed, expanded and sorted
provider = "payments" # optional — defaults to the declared provider
error[response-field-removed]: response field `customer_id` was removed
--> api/payments-openapi.yaml:142:9
|
= note: breaks web-checkout — pacts/web-checkout-payments.json:88
“This might break somebody” became “this breaks web-checkout, and here is the line where it said so.” No network call, no subprocess, no server was added to get there.
| Format | Source | Fidelity |
|---|---|---|
pact |
Pact v2/v3/v4 HTTP interactions, JSON | High for what the consumer’s tests assert — a pact only knows what its tests exercised |
graphql-operations |
The consumer’s own .graphql query documents |
Exact. A selection set is the field list, with no inference at all |
manifest |
A hand- or codegen-written *.brake-uses.toml |
Whatever the author wrote — the fallback for gRPC, for consumers with no pact tests, and for third parties who will only tell you in prose |
The ordering matters and the tool is explicit about it. A pact records one example value, not a schema, so a field appearing in the example body is evidence the consumer reads it — good evidence, since pact’s own verification fails when the provider omits it, but evidence. A GraphQL selection set is a statement.
That difference shows up in a detail I like a lot, because it’s the sort of thing that decides whether a tool survives its first week. A demand is silent about formats, bounds, nullability and enum membership — a pact fixture just has "id": "9f01e2b7-…" in it. So brake copies those from the contract before comparing. Without that, every format: uuid in your spec becomes a false consumer-request-rejected, and the hook is uninstalled by Friday.
A pact’s paths are concrete — /payments/42. A contract’s are templates — /payments/{id}. Joining them is a separate phase with its own failure modes:
{param} value as a usage. A consumer calling /payments/abc has declared that it sends abc for id, which is what lets a later narrowing of id to integer be reported as breaking that consumer rather than in the abstract.consumer-path-ambiguous, and no guess. A guessed binding attributes a break to the wrong endpoint, which is worse than declining to attribute it.consumer-endpoint-unmet. The consumer calls something the contract doesn’t document.This is the part I’d point at if someone asked what the design is actually made of.
The type comparator already answers “does head still satisfy what base promised?” in both directions. Put the consumer’s expectation on the base side and the head contract on the head side, and the ordinary rules answer consumer questions:
| Comparison | Reads as |
|---|---|
compare(expectation, head, Response) |
Does the contract still produce everything the consumer reads? |
compare(expectation, head, Request) |
Would the contract still accept what the consumer sends? |
A response field removal on that comparison means the contract doesn’t document a field the consumer expects. A newly-required request field means the contract now demands something the consumer doesn’t send. There is one notion of “incompatible” in the tool rather than two that drift apart, and the whole feature is a projection of an engine that already existed.
No baseline is involved. Demand is compared against head only — brake doesn’t version consumer declarations, doesn’t diff a pact against its previous self, and doesn’t store which consumer expected what when. That’s the registry it deliberately isn’t building, and it stays on the right side of the line by never needing history: a consumer’s expectation is a statement about the contract as it is now.
One consequence is a genuinely new capability rather than a better version of an old one. These rules work on a brand-new contract with no history at all — the first commit of an API file, where a baseline diff can only shrug and say contract-new, can still be checked against what its consumers say they need.
There is no consumer-break rule, and its absence is deliberate. A break is already a finding; the consumers it affects are attached to it as an affects list, rendered in text, JSON and SARIF (as a SARIF relatedLocation, which is what related locations are for).
One broken field must not produce one response-field-removed plus three consumer-breaks, because a developer then has to work out that four findings are one problem.
The honest part, and the workflows differ in how much they can be trusted:
| Workflow | How the declaration arrives | Trust |
|---|---|---|
| Monorepo | The consumer’s own tests write it into services/<name>/pacts/; brake globs it |
Strongest. As fresh as the consumer’s last test run |
| Vendored | Consumer CI opens a pull request against the provider repository | Reviewed like code, and staleness is visible in git log |
| Pulled in CI | A prior step (pact-broker pull, curl) writes the directory; brake reads it |
The network stays in the pipeline, outside the tool |
brake will not pull the files itself, under any flag, and this isn’t a capability waiting for a use case. The moment a contract gate can be pointed at a URL it stops being reproducible on a laptop, in an air-gapped build, or three years from now when the broker has been decommissioned. A source that looks like a URL is refused when brake.toml is parsed. A URL inside a pact — the _links a broker stamps into every document it publishes — is data, never an instruction, and there’s a self-defence test that fails if anything ever dereferences one.
The failure mode of the third row is the one that matters: a failed pull leaves the declared file absent, which is consumer-unreachable and exit 1. Loud, not clean.
brake also does not measure freshness. A pact from eighteen months ago and one from this morning are the same bytes to a file reader, and any heuristic over mtime would break the determinism guarantees. Instead brake consumers prints every declaration it used with its path and a content digest:
payments — api/payments-openapi.yaml
web-checkout pacts/web-checkout-payments.json sha256:d2f56af7
GET /payments 200 reads: items.amount, items.id, items.status
GET /payments/{id} 200,404 reads: amount, id, status
2 of 2 endpoints have a declared consumer.
brake knows about the consumers declared in brake.toml and no others.
That last line is not decoration. Without it the inventory reads as a complete census, and it is a list of files somebody remembered to declare.
Everything above still describes a gate — something that tells you afterwards. The version that changes what an agent writes is a question it can ask first.
who_consumes { contract, endpoint?, field? } over MCP returns the declared consumers of an endpoint or field, with the interaction that declares it. An agent about to delete a response field can ask who reads it before drafting the edit.
That’s a different kind of exit criterion, and I think it’s the more valuable one. A gate converts a bad change into rework. A pre-edit query converts it into a different change — and the agent is perfectly capable of choosing expand-then-contract over a straight removal if it knows, at planning time, that two named services read the field.
The rule that has to travel with it: an empty answer means nobody declared it, not that nobody uses it. Every surface that can produce that answer says so at the point it’s produced, which turns out to matter more than it sounds — see below.
Worth keeping in view, because it’s the other place the loop’s exit criterion and the team’s differ.
[[contract]]
name = "payments"
source = "api/payments-openapi.yaml"
baseline = { git-merge-base = "origin/main" } # did *this change* break anything?
[[contract]]
name = "payments-released"
source = "api/payments-openapi.yaml"
compatibility = "surface"
baseline = { latest-tag = "v*" } # is the delta since the last release safe?
Two contracts over one artifact is the intended shape, not a workaround.
git-merge-base forgives anything already on the trunk. That’s what makes it adoptable — a repository with two hundred existing findings still passes a commit that doesn’t add a two-hundred-and-first, with no baseline file and nothing to regenerate after a refactor. Scope is a better ratchet than a snapshot, which is a lesson tropism learned first and this inherited outright.
It’s also the wrong question at release time. A break merged three weeks ago is still a break for anyone upgrading from the last tag, and the merge-base has long since forgiven it. latest-tag = "v*" resolves the newest matching tag that HEAD descends from — ancestry matters, because a tag cut on an unrelated release branch is not a version this commit evolved from, and comparing against it reports a divergence as a break.
An agent working a task is always asking the first question. It should be. But it means the agentic exit criterion and the release exit criterion are genuinely different checks, and a team running only the first has a gate permanently one merge behind reality.
The single most important thing in the design has nothing to do with the ruleset.
A human skims a warning. An agent acts on the absence of one.
A developer who runs a check and sees no output glances at the summary and notices if something looks off. An agent reads an empty findings array, concludes success, and reports the work complete. If your tool can return “I couldn’t verify this” in a shape that looks like “this is fine”, you’ve built a machine for manufacturing false confidence, and the automated consumer will hit it far more consistently than a person would.
So the verdict is structural rather than inferred:
verdict is a required field, with clean, findings and unavailable as its values. A caller cannot read the findings array and skip the caveat.unverified is a separate key, not a low-severity finding mixed into the list. An empty findings with a non-empty unverified is not a pass. When the ingester meets a construct it can’t model — a discriminator, a not, a pact matcher like arrayContains, a v4 interaction deferred to a plugin — the compared path is reported as partial, naming the construct and its pointer. A tool that silently ignores what it cannot parse is worse than no tool, because it manufactures confidence.1 and exit code 2 are different things. 1 is “your API broke”. 2 is “the gate is broken”. Conflating them trains a team to ignore both, and over MCP the same split is isError: false for a finding and isError: true only when brake could not determine an answer.0 with a note, because that’s a user who hasn’t opted in. A missing one exits 2.Consumer declarations create a new way to be dishonest, and the design is at its most careful exactly there. Three policies:
policy |
Effect |
|---|---|
annotate |
Default. Severities unchanged; affected consumers are named on the finding |
escalate |
A warning becomes an error when a declared consumer is affected |
triage |
An error no declared consumer can observe is downgraded to a warning |
escalate is the satisfying one. param-removed and security-removed are warnings precisely because brake couldn’t tell whether anyone relied on them — the undecidable cases from the honest-limits list. Given a declaration, it can, and the severity stops being a hedge.
triage is the one that can lie. Downgrading a break because no consumer declared it is the exact shape of the false clean the whole tool exists to forbid. It’s offered anyway, because a team that genuinely has every consumer in one repository is otherwise being asked to treat a break nobody can observe as a blocker — and that’s how a gate gets uninstalled. Four constraints make it honest:
completeness = "closed-world" — an explicit, reviewable assertion by a human that the declared set is exhaustive. brake cannot verify that claim and does not pretend to.operation-id-changed or path-parameter-renamed — those break generated client code, which no declaration models. A rule demand cannot see is never downgraded on the strength of demand’s silence.warning. Nothing is downgraded to nothing, and nothing is suppressed — a suppression still requires a written reason.no declared consumer uses this — 3 consumers declared, and brake cannot know that is all of them.There’s one further rule, consumer-surface-unused, that reports a suspected absence — an endpoint nobody declares. It’s excluded from the commit gate entirely and gated behind the closed-world declaration, because “nobody uses this” is not something a file reader can know. That’s the presence/absence line from earlier, applied to the tool’s own new feature rather than to somebody else’s.
If you take one thing from this post and apply it to a tool you own: the difference between “checked and clean” and “did not check” has to be visible in the type, not in the prose. Your agentic consumer will never read the prose.
A finding tells the agent it broke something. That’s half a job, and the less valuable half — the reason people ship breaking changes is almost never that they wanted to, it’s that the safe path didn’t occur to them at the moment they were blocked.
Every rule that reports a break carries an ordered list of evolution strategies, bound to the specific field, each with its cost. deprecate-then-remove, expand-then-contract, version-the-endpoint, optional-with-default, widen-dont-narrow, dual-accept-credentials, reserve-the-number. Three properties, each deliberate:
Catalogued, not generated. A strategy is a named technique with fixed text and a subject placeholder. Nothing is composed per call, which is what lets a test assert the wording and lets the tool stand behind it.
Costed. A list of options with no costs reads as a list of things that are all free, which is not a decision anyone can make. deprecate-then-remove is cheap unless you needed the removal this week. version-the-endpoint is always available and always expensive.
Not chosen between — and this is the interesting one. brake names the applicable strategies and stops, because which one fits depends on whether you control every consumer and whether you have a version scheme.
That refusal is usually where a tool gets criticised for being unhelpful. In an agentic loop it’s the opposite, because of a real inversion of who knows what: the agent is in a better position to choose than the tool is. brake sees two documents and a directory of declarations. The agent can read AGENTS.md, the deployment config, the monorepo the consumers might be sitting in, and the versioning policy in the contributing guide. Handing it a single confident recommendation would override the one participant with more context; handing it three named, costed options with an explicit note that the choice isn’t brake’s is the shape that uses what the agent knows.
There’s a smaller lesson buried in the implementation. A remediation names the field it’s about, and that subject is carried explicitly on the change record rather than recovered from the JSON pointer — because a parameter’s pointer ends in its index, so deriving it produced the instruction keep `0` optional. Confident, wrong, and arriving in the part of the output meant to help. That same explicit subject is what the consumer attribution join later reused: the field name, used a second time.
brake check runs at the moment of commit, which is the last moment the change can be stopped and the worst moment to learn about it. An agent editing a contract has the intent in hand and hasn’t written the change yet — and it is exactly the kind of consumer that will confidently ship customer_id → customerId because nothing told it not to.
claude mcp add brake -- brake mcp /path/to/your/repo
Five tools — check_change, compare_contracts, who_consumes, explain_rule, check_repository — and four resources: the rule catalogue, the evolution strategies, the resolved configuration, and the consumer inventory with each declaration’s file and digest. Three details matter more than the list:
check_change takes document text, not a path. An agent holds an unsaved draft, and asking “is what I am about to write safe?” before writing it is the entire reason the interface exists.
compare_contracts needs no configuration at all. Two documents, no brake.toml, no repository — the case an agent hits when reviewing a diff in a repository nobody has set up. Configuration is an enrichment, never a prerequisite; a gate that requires onboarding before it can answer anything gets skipped.
brake://strategies is readable before anything has broken. An agent that knows expand-then-contract while drafting is more useful than one told about it after being blocked.
The MCP server is the same ruleset consulted earlier. It adds no rule, changes no verdict and relaxes no guarantee — if a tool here would need a rule brake check doesn’t have, it doesn’t belong here either. That’s the constraint that stops it becoming a second product with a second set of opinions.
Shipping alongside are four agent skills in .claude/skills/ — plain Markdown with YAML frontmatter, so they work in Claude Code and are readable by anything else consuming that format. api-compatibility for editing a contract, brake-consumer-impact for “who uses this?”, brake-triage for a failed hook, brake-adopt for adding the gate to a repository.
The interesting thing is what they contain, which is not a CLI wrapper — an agent can read --help. They carry the things an agent gets wrong when it works the tool out for itself:
unavailable is not clean, and rounding it down to a pass manufactures exactly the confidence the tool exists to refuse.2 is not exit 1 — a broken gate and a broken API need different responses, and an agent that conflates them fixes the wrong file.Every one of those is a way to over-claim. That’s a slightly odd thing to spend a documentation budget on, and I think it’s correct: an agent handed a tool assumes the tool is right, and the failure mode of this particular tool is a confident green.
--drift runs a command out of a config file to check generated output against the committed artifact. It’s the only place brake ever executes a subprocess, it’s opt-in per contract behind an explicit flag, and it is not exposed over MCP at all.
An agent that can write brake.toml — which any agent editing a repository can — and then call a tool that honours a command field from it has arbitrary command execution, obtained through a tool whose stated purpose is reading files. There’s a test named no_tool_call_can_execute_a_declared_generator that drives the real binary over stdio and tries to smuggle the flag through every tool, consumer arguments included. Drift stays a CLI concern.
This isn’t hardening to add later. A server that exposes it is a different and much more dangerous product, and the difference is invisible from the outside.
The honest section, because it’s the part that determines whether you should trust a green result. The consumer feature moved this list rather than emptying it, and the item at the top is the same one, one level up.
It sees the consumers you declared, and cannot know that is all of them. This is the permanent limit. brake reads a directory of files; whether that directory is the whole world is a claim only a human can make, which is why closed-world is an explicit declaration and why the inventory ends with the sentence about it. An undeclared consumer is invisible, and the tool says so everywhere it could be misread rather than once in a footnote.
A green run is not a passing pact verification, and is never reported as one. brake checks that the specification still satisfies what consumers declared. Whether the implementation matches its own specification is what --drift and your test suite are for. These are easy to conflate, and conflating them is how a team ends up trusting a gate for something it never claimed.
It never measures whether a declaration is current. A stale pact is bytes. The content digest in brake consumers is what lets a reviewer notice; the tool itself will not, and pretending otherwise would manufacture the confidence it exists to refuse.
Not a broker client, not a pact generator, not a consumer registry. No can-i-deploy, no environments, no deployment state, no stored expectation timeline. brake reads demand and never writes it — generating a pact from an OpenAPI file would invert the direction that makes consumer-driven contracts worth anything.
It compares exactly two versions. No transitive compatibility mode. If your consumers skip versions, brake checked a hop they may not be making.
A pact example body probably over-declares. Consumers are told to include only what they assert on, and routinely paste whole payloads. Over-attribution errs toward blocking, which is the safe direction for a brake — but it makes escalate noisier than it looks, and it’s an open question flagged as needing measurement against a real pact directory before escalate gets recommended anywhere.
A remote $ref is refused, not fetched. No network under any flag. A gate whose verdict depends on someone else’s uptime is a flaky test with a good reputation. If your spec depends on one, brake reports it unreachable rather than guessing.
latest-tag needs the tags to be there. A shallow or --no-tags clone has none, which is the one place identical file contents can produce a different verdict on two machines. It’s a reported failure rather than a silent clean result, and CI needs fetch-depth: 0 — but it’s a real footgun, and it’s on the release gate, which is the one you’ll notice last.
Multi-file protobuf needs an import-root story that OpenAPI’s single-root model doesn’t, and it’s still an open question in the design.
The MCP server costs 33 crates and 2.9 MB of binary, because rmcp’s server feature needs tokio and brake is otherwise synchronous. It’s behind a non-default feature for that reason. The subcommand is registered on every build regardless — a build without the feature exits 2 naming the feature rather than pretending the command doesn’t exist, because a capability that silently doesn’t exist is one nobody can discover.
One footnote that isn’t really a footnote. Adding affects to the Finding struct is a breaking change to a public Rust type, and it breaks every downstream struct literal — including forge, which consumes brake as a library.
It shipped as a deliberate, announced break, with Finding marked #[non_exhaustive] in the same change so it’s the last one of its kind. Which is precisely what the tool tells everyone else to do, applied by the tool to itself, in the release that added the feature for naming who a change breaks.
One sentence: a brake on breaking API changes — one compatibility ruleset, enforced at commit time and over the whole repository, with no network, no toolchain, and no running service.
The agentic angle isn’t a feature bolted onto that; it’s what the constraint buys. A check that needs nothing installed is a check that can run inside the loop, and a check that runs inside the loop is the only kind that changes what gets written rather than what gets reverted.
The generalisation I’d defend beyond this tool: the exit criteria an agent can reach are a strict subset of the things that have to be true for the work to be done, and the gap is almost entirely made of other people. Consumers of an API. Operators of a service. The team that owns the module you imported. Every one is a party with a stake in the change and no representative in the working tree.
The move, then, is not to reach them — you can’t, not from a sandbox with no network in the second before a commit. It’s to find the artifact they already wrote down, and read it. A pact, a query document, a hand-written manifest, an architecture ruleset, a schema. Turn their interest into a file, and it becomes something the loop can check. Where no such artifact exists, no amount of prompting will substitute — and the tool’s job at that point is to say so, in the shape of an answer the agent cannot round down to a pass.
repos:
- repo: https://github.com/grahambrooks/brake
rev: v2026.8.4
hooks:
- id: brake
brew tap grahambrooks/brake https://github.com/grahambrooks/brake
brew install brake
brake init # finds your contracts by parsing them, and writes brake.toml
brake consumers # who uses what, and what of it
The documentation grew up alongside the feature: getting started, configuration, consumer demand, CI and hooks, the MCP server, the agent skills, and the rule catalogue — generated from the same source the CLI and the MCP resources read, so all three say the same thing or a test fails. design/05-consumer-demand.md carries the specification for everything above, including the argument for why triage is constrained the way it is.
The thing I’d most like to be wrong about: whether who_consumes before an edit actually changes what an agent writes, or whether it just moves the moment of being blocked half a step earlier. That’s a question about model behaviour, and one round of real use settles it better than any amount of design.