Chapter 3 of 7 · 9 min read
A change consumers cannot see is still breaking
On this page
By the third quarter svc-scaffold was installed by four teams, and Marta had a complaint she agreed with. The skill's description said it applied to "creating or modifying Python services," and the "modifying" half was producing bad work: asked to add one endpoint to an existing service, the agent would load the skill, follow its scaffolding instructions, and rewrite files that did not need touching. She had seen three pull requests where a two-line change arrived as a 400-line diff. So she narrowed the description to cover new service creation only, ran the skill's evals, saw them pass, and released 0.9.3 with a changelog line reading "narrow trigger to avoid over-applying on edits."
Eleven days later the payments team's health checks started failing readiness in staging. Their engineers had been adding endpoints to an existing service and, without the skill firing, had written the readiness handler themselves — returning 200 as soon as the process was up, rather than after the database pool and the feature-flag client had connected, which is what the scaffold's template did and what the platform's readiness contract requires. Nobody on that team had noticed that anything changed. There was no error, no deprecation warning, and no diff in their repository. Guidance that used to arrive simply stopped arriving.
The surface of a skill is the trigger, not the body
Semantic versioning — the convention in which a three-part release number carries a promise, so that a patch bump asserts nothing a consumer can observe has changed, a minor bump asserts that anything new is additive, and a major bump warns that existing consumers can break — was built for a surface a compiler can check. When you rename a function or change its return type, every caller fails at build time or at import time, and the version number is a courtesy that tells people how much breakage to expect before they find out mechanically. The failure mode it protects against is loud by construction.
A skill's public surface has two parts, and only one of them behaves that way. The body — the instructions, the templates, the scripts — changes what happens once the skill is in play, and a consumer can at least observe the difference in output. The description decides whether the skill is in play at all, which means an edit there does not change what a consumer gets; it changes whether they get anything. Nothing in the consumer's repository references the skill, nothing imports it, and no call site exists to break. The narrowing that Marta shipped removed a capability from four teams' agents with the same mechanical footprint as fixing a typo.
That is why "it was doing a bad job on those requests, so removing them is a fix" does not survive contact with the consumers. The judgement that the output was bad is the maintainer's, formed by looking at three pull requests from one team. The dependence is the consumers', formed over months of the skill being present. Some of those teams had built around the mediocre behaviour — the payments team's habit was to let the skill do the endpoint and then delete the files it had over-rewritten, which is ugly and worked, and it carried the readiness template along with it. Classifying the change as a fix is what allowed it to ship in a patch release with no announcement, and the absence of the announcement is the actual harm rather than the narrowing itself.
Classifying from the consumer's side
The useful question is not "did I improve this" but "what does a consumer have to change, and will anything tell them." Ranked that way, the changes to a shared skill or server fall out cleanly:
| Change | What the consumer loses | How they find out | Release |
|---|---|---|---|
| Add a new template to the body | Nothing | n/a | Minor |
| Broaden the description | Nothing; may now fire where it did not | Unexpected activation | Minor, worth noting |
| Reword the body, same behaviour | Nothing | n/a | Patch |
| Change output format the body produces | Their downstream parsing or review conventions | Failed review, days later | Major |
| Narrow the description | The capability, for the requests dropped | Nothing. Silence | Major |
| Add a required environment variable | The ability to run at all | Preflight failure, immediate | Major, but loud |
| Change an MCP tool's response shape | Their agents' handling of the response | Malformed behaviour, sometimes silent | Major |
| Remove an MCP tool | The tool | Tool-not-found at call time | Major, loud |
The rows worth staring at are the ones where the "how they find out" column says nothing or says something with a lag measured in days. A required new environment variable is technically a bigger change than a narrowed description, and it is far safer, because it fails on the first run with a message naming the variable. The narrowing failed on the eleventh day, in a different team's staging environment, as a readiness bug with no traceable cause. Severity for shared agent components tracks the delay and the silence more than it tracks the size of the diff.
The version number's job is to let a consumer distinguish those cases before they upgrade. A patch release should mean the maintainer is asserting that no behaviour a consumer could be relying on has changed, which is a strong claim and is exactly why the narrowing did not belong in one. Marta's 0.9.3 should have been 1.0.0 with a changelog entry naming the requests that would stop matching, and the entry should have listed them as example phrasings rather than as a description of the new scope, because "narrowed to new-service creation" is a sentence a reader can agree with while failing to notice it covers the thing they do every day.
Shipping a response-shape change to a shared server
The same problem has a sharper edge on a shared MCP server, because there the consumers are agents rather than people, and agents do not read changelogs. Suppose the platform team's service-registry server exposes a lookup_service tool that has always returned a flat object:
{
"name": "billing-api",
"owner": "payments",
"oncall": "payments-primary",
"tier": "1"
}They want to support services owned jointly, so owner becomes a list and gains structure:
{
"name": "billing-api",
"owners": [
{ "team": "payments", "role": "primary" },
{ "team": "ledger", "role": "secondary" }
],
"oncall": "payments-primary",
"tier": "1"
}Deployed as a replacement, this is a flag day — a cutover with no overlap period, where every consumer has to have moved by the instant it happens. At the moment of the deploy, every agent that had been reading owner gets undefined instead. What makes it worse than a flag day on an HTTP API is that model-driven consumers rarely crash on it. An agent that asked for the owner of a service and received an object without an owner key will frequently carry on and produce a plausible answer from context — the team name that appeared in the conversation, or the on-call rota string, which is right often enough that the failures look like ordinary model error rather than like a broken contract. You do not get a spike in 500s to tell you the deploy was the cause.
The survivable path is additive, dated and gated:
Every transition except the last is cheap and reversible. The step that costs something is the one from Deprecated to NewOnly, and it should be gated on evidence rather than on the calendar, which is why the diagram has a self-loop: the removal date slips if consumers are still reading the old field. A date with no measurement behind it produces the flag day you were trying to avoid, one quarter later.
While both shapes are live, the response can carry its own deprecation notice, which is the one channel that reaches an agent:
{
"name": "billing-api",
"owner": "payments",
"owners": [
{ "team": "payments", "role": "primary" },
{ "team": "ledger", "role": "secondary" }
],
"oncall": "payments-primary",
"tier": "1",
"_deprecated": {
"owner": "Removed after 2026-03-01. Use owners[]. See go/registry-owners."
}
}The field costs about 90 bytes per response, and it is read by the model in the same turn as the data, which makes it the only deprecation notice in the system that arrives at the point of use. It does not replace an announcement to the humans who maintain those agents; it covers the gap between the announcement and the moment anyone acts on it.
Announcing a date and then holding it also requires that somebody is watching the old field's call volume. The registry team instrumented it and found that reads of owner fell from about 1,400 per day to under 20 within three weeks of the announcement, then flattened. The remaining 20 came from two scheduled agents nobody had thought to tell, which is a small enough number to chase individually and a large enough number that removing the field on schedule would have broken both of them.
What versioning is not
A version number is a label on a change that somebody already classified correctly. It does not do the classifying, and a repository where every release is a patch bump because the maintainer felt the change was small is running the same risk with more ceremony. The discipline that matters is asking what a consumer loses; the number just records the answer in a place they can act on.
Nor is a version a delivery mechanism. Marta's changelog entry existed, and the payments team did not read it, because they had never had a reason to — the package updated when they reinstalled, which was whenever someone rebuilt a machine. Communicating a breaking change means the consumers who are affected hear about it before it lands, and a changelog is a record for people who are already looking. The registry team's date held because they sent it to named owners of named agents, not because it was in a file.
There is a further asymmetry that makes shared skills harder than shared libraries here, and it is worth naming because it is the reason "just pin it" is not available as an answer. A service that depends on httpx records the version it wants in a lock file it controls, so a maintainer's release is an offer the consumer chooses to accept. A team whose agents load svc-scaffold from a shared location has no such record: the skill is present in an environment, at whatever revision the last install put there, and there is usually no per-consumer manifest saying which revision that is. The upgrade therefore happens on the maintainer's schedule rather than the consumer's, which inverts the normal relationship — the consumer cannot defer a release they are not ready for, and cannot roll back to the previous one without knowing what it was. Distribution formats that give each consumer a declared, pinnable reference to a versioned artefact are what restore the ordinary relationship, and the AutoScout24 account in Designing a Coding Agent Skills Marketplace for Your Organisation is a fair description of what a company looks like once it has one and what the year before that looked like. Until that machinery exists, the maintainer holds a power a library author does not, and the only compensating control is telling people first.
Which is where both of these stories run out of road. The registry team could count reads of owner because a server sees its own traffic and can attribute a call to a client. Marta could not do the equivalent, because nobody ever called her skill — it fired, or it did not, inside conversations she has no access to, in four teams' sessions. Before she can tell the payments team what they are about to lose, she has to be able to answer a question that her repository cannot: who depends on this.
Distribution and reuse — read the current version at https://daviddominguez.dev/learn/en/distribution-and-reuse/a-change-consumers-cannot-see-is-still-breaking