0198 — Stable is earned, and kept on a budget
Status: Accepted Date: 2026-09-23
Amends ADR 0103 and is read together with 0111. Invariant I14.
Context
ADR 0103 split the entrypoints into stable and evolving so a consumer can tell how often a surface moves. It then detached the word from any consequence:
The level changes no versioning policy. A minor still carries breaking changes and a patch is still additive, uniformly, for every entrypoint.
The changelog shows what that costs. In the thirty days before this decision
there were 83 releases, and 34 of them carried a ### ⚠️ Breaking changes
section. Several of those broke entrypoints the table calls stable. Every
breaking minor is a manual migration in every consuming project, and the
consuming projects this repository’s owner can see sit on five different
minors, the furthest about forty minors behind. At this pace “stable” describes
how a surface was meant to behave, not how it behaves, and the road to 1.0 —
which the roadmap defines as API stability — does not get shorter.
Two things were missing. What makes an entrypoint stable was never stated: a new entrypoint could be declared stable on its first day, with nobody outside this repository using it. And what stable costs the maintainer was nothing, so nothing slowed a break down.
The tempting fix — count who imports each entrypoint and demote or remove the unused ones — is closed twice over. ADR 0103: “Demotion does not exist.” ADR 0111: “no consumer depends on this yet” is never an argument, because a package on a public registry cannot enumerate who installed it. A usage count can justify not yet promoting; it can never justify taking a promise back.
Decision
Stable is earned
- A new entrypoint starts evolving. Declaring it stable on arrival is no longer possible.
- Promotion to stable requires two independent consumers — two consuming applications with their own owners and release cycles, each importing the entrypoint in production code. This repository’s own tests, examples and starter template do not count, and neither do two services of one application. The promoting ADR (ADR 0103 already requires one) records the count and what was measured. It does not record names: consuming projects are not named in this repository.
- No demotion and no removal on a usage count. A stable entrypoint with one known consumer, or none, stays stable. The census that feeds rule 2 is only ever read in the direction of promotion. This keeps ADR 0103 and ADR 0111 as they are.
- Runtime mirrors are exempt from rule 2.
stitchkit/nodeis the Node face ofstitchkit/server: the same handler, hooks and contract served through another runtime. Its shape is not found on its own — it follows/server, which earns stability on its own terms. Requiring separate Node consumers would give an application on Node a weaker promise than the identical surface on Bun, for reasons that have nothing to do with the shape. A mirror is stable exactly when the entrypoint it mirrors is.
The entrypoints that are stable today stay stable. The maturity table in
docs/guide/getting-started.md remains the one place a level is declared, and
the budget below reads it from there.
Stable is kept on a budget
-
A stable entrypoint may be broken in at most one minor per rolling seven days. The window runs over release dates, taken from the changelog headings (
## [x.y.z] — YYYY-MM-DD), and is anchored on the date of the release being judged, not on the clock — a tag re-checked a month later gets the answer it got when it was cut. One minor that breaks several stable entrypoints spends the budget once: the budget is on migrations, and a consumer moves across a minor in one pass. So the cheap way to stay inside it is the right one — batch the breaks. -
Every breaking entry that touches a stable entrypoint cites an ADR (
→ ADR NNNN). One consolidating ADR may authorise several entries. -
Evolving entrypoints break freely, as ADR 0103 allows, and spend no budget.
-
Every new breaking entry starts with the entrypoint name(s) it breaks, backticked, before anything else:
- `stitchkit/tools`, `stitchkit/contract` — **`createMcpHandler` no longer accepts `foo`** — it moved to `bar` because … → ADR NNNNThis is what makes the feed separable — a reader of one entrypoint greps for its name — and it is the only thing the budget classifies by. The words of an entry are not matched against anything; the prefix is.
This amends ADR 0103
ADR 0103’s sentence “The level changes no versioning policy” no longer holds for stable entrypoints: from this decision, stable carries the cadence limit of rule 5 and the citation rule of rule 6. The rest of that paragraph still holds — a minor carries breaking changes and a patch is additive, uniformly, for every entrypoint. ADRs are not edited; the index row for 0103 points here.
Effective from 0.94.0
The rules above bind releases from 0.94.0 on. Earlier releases are not counted and their entries are not reformatted: they were written under a rule that did not exist, and counting them would refuse the very release that adopts this one. 0.94.0 itself counts, so the first release the budget can refuse is the one after it.
Held mechanically
bun run release:check prints, for the core version in the release train,
stable breaking: N in 30 days, M in 7 days (budget 1)
and the same metadata gate — release:check, the pre-push hook on a release
commit or tag, and the publishing workflow — refuses a release from 0.94.0 on
when:
- it breaks a stable entrypoint and more than one minor, itself included, did so within seven days of its date;
- one of its breaking entries does not start with an entrypoint from the maturity table;
- one of its entries for a stable entrypoint cites no ADR;
- it breaks a stable entrypoint and its heading carries no date, so the window cannot be measured.
The count, the classification and the effective version live in
scripts/surface-cadence.ts, beside the cadence figures the maturity table
already derives from the changelog.
Consequences
- A stable break now waits for a free week or joins the minor that already spent it. That is the intended pressure: fewer, larger migrations.
- A new entrypoint pays for its early freedom with a promotion ADR later. It keeps that freedom until real consumers exist, which is when a stable promise starts to matter.
- A breaking section reads as a list of entrypoints, and one consumer can skip the entries that are not theirs.
- The count trusts the prefix. An entry that names an evolving entrypoint while
in fact breaking a stable one escapes the budget. That is a review question,
as the no-
asrule is: the gate makes the claim explicit and checkable, it cannot make it true. - The budget can be spent by an urgent fix. A security fix that must break a stable entrypoint in a spent week is a reason for a new ADR, not for a silent exception in the gate.
Alternatives considered
Demote or remove what nobody imports. Closed by ADR 0103 and ADR 0111, and unfalsifiable for a public package.
Leave the policy unwritten and rely on restraint. That is the arrangement the thirty-day figures above measure.
Budget by release count instead of by date. A release count shrinks when releases slow down and grows when they speed up. A consumer migrates by the calendar, so the window is a calendar one.
Match breaking entries to entrypoints by their vocabulary, as the cadence figures do. It works for a figure a person reads, and it fails silently when the vocabulary is too narrow: the agent-runtime term list has been widened four times for exactly that reason. A refusal needs a classification that cannot miss, so the entry states it.