# Stability

**From v31: at most one major every 90 days, the previous major gets security fixes for 6 months after its successor, and a removal is deprecated for at least one minor first.** Each rule is refused by a script, not remembered by a person — the table at the bottom names the code.

**Next major window:** opens on `2027-02-08`, 90 days after 31.0.0 (2026-11-10). The bump that ships a major rewrites this line (`scripts/release.ts`).

## What is covered

A breaking change to any of these needs a major, and a major waits for its window.

| Surface | Where it is defined |
|---|---|
| the eight primitive shapes: `entity`, `policy`, `action`, `mutator`, `query`, `job`, `route`, `task` | [The eight primitives](/the-eight-primitives), `PRIMITIVE_KINDS` in `packages/core/src/registrar.ts` |
| the `x` CLI: every command, every flag, and the `--json` envelope each prints | [CLI reference](/cli-reference) |
| every shipped `X_*` code: its name and its meaning. A code is never renamed or reused | [Error codes](/error-codes) |
| the package tier table | `scripts/lib/tiers.ts`, [Project layout](/project-layout#framework-package-tiers) |
| `app.config.ts` field names: renaming or removing one | [Configuration](/configuration) |
| every export a package's `src/index.ts` names and its README documents | each package's `README.md` |
| the Bun floor | `engines` in every `package.json`; it moves only in a scheduled major, or by the security exception |

## What is not covered

| Not covered | Why |
|---|---|
| an export no README documents, and any deep import (`@ultimat3/x/src/...`) | not a public surface; it can change in a minor |
| `.x/` — the build cache, generated manifests, snapshots | regenerated by the CLI; never hand-edited, never read by an app |
| the exact wording of a `cause:` or `fix:` line | the code is the contract; the text improves in any release |
| a gate verdict getting stricter | a new `x verify` rule that reds an app waits on `next` like any break |
| performance figures and budgets | measurements, each dated in its committed JSON under `scripts/bench/results/`, never promises. `bench.yml` is meant to re-measure the first-hour and HTTP figures weekly and on each release, as a pull request; `As of 2026-11-10` its one run measured and could not open that pull request, so the committed figures are older. Budgets: [Routes and render modes](/routes-and-render-modes#budgets) |

Never "stable forever": a covered surface can change, in a scheduled major, with its entry in [Upgrading](/upgrading).

| Bump | Means | Examples |
|---|---|---|
| **major** | a covered surface changed incompatibly | a removed config field, a renamed CLI flag, a changed primitive field, a narrowed tier |
| **minor** | additive: old code still compiles and still passes `x verify` | a new optional field, a new command, a new driver behind an existing interface, a new `@deprecated` alias |
| **patch** | no surface change | a bug fix, a perf change, a corrected `fix:` line |

## Cadence

| Release | When | Branch | npm dist-tag |
|---|---|---|---|
| major | at most once per 90 days from v31, counted from the previous major's `CHANGELOG.md` heading date | `next`, merged into `main` when the window opens | `latest` |
| minor | weekly, when something shipped | `main` | `latest` |
| patch to the current major | as needed | `main` | `latest` |
| patch to the previous major | security fixes only, until 6 months after its successor shipped | `release/<major>.x` | `maint-<major>` |
| prerelease of the next major — **planned, nothing ships one yet** | `release-tag.yml` cuts only `X.Y.Z` release commits, so no workflow publishes from `next` | `next` | `next` (`scripts/dist-tag.ts` already names it) |

**v31 is the stable line**, dated 2026-11-10 by its `CHANGELOG.md` heading. It was the exception that starts the clock: it carried every break queued before the promise bound anyone (74, [Upgrading](/upgrading/31)), and `scripts/release.ts` does not refuse it (`WINDOW_OPENS_AT_MAJOR` in `scripts/lib/major-window.ts`). From it on, a `BREAKING —` entry waits on `next` and a removal ships its deprecation minor first.

Ruling 2026-11-09: the clock moved from v30 to v31. v30 shipped 2026-11-07 while only our own apps ran Ultimate in production, so the window protected no one yet; v31 carries the breaks of plan 101-final-hardening-before-v31, and the window protects adopters from there.

## The support window

| Major | Status | Security fixes until |
|---|---|---|
| the current major (`npm view @ultimat3/core version`) | every fix | its successor ships, then 6 months more |
| the previous major | security fixes, as patches on `release/<major>.x` | 6 months after the current major's `## X.0.0` date |
| anything older | none | [Upgrading](/upgrading), one major per `x upgrade` run |

**Convention, not enforced:** `release/<major>.x` is cut by hand at each major and kept at that major's newest minor; no script cuts or checks it. Once it exists, a patch is `latest` while its major is current and `maint-<major>` once its successor ships. `scripts/dist-tag.ts` decides the tag from the registry's `latest`, and `npm publish` never runs without it.

## Deprecation

A removal or a rename ships its alias, marked `@deprecated`, on `main` for at least one minor before a major takes it away. The rule compares each package's `src/index.ts` export names against the previous release tag: a name that is gone must have carried `@deprecated` there. Runtime deprecation logging is not part of this — it was deleted once, for good reason ([Upgrading](/upgrading)).

## Where a break waits

| Branch | Holds |
|---|---|
| `main` | minors and patches only while the window is closed; a `BREAKING —` changelog entry there is refused |
| `next` | every breaking change waiting for the window. **Convention, not enforced:** it merges `main` weekly, by hand, so it never falls behind a fix; `ci.yml` gates every push to it |
| `release/<major>.x` | the previous major's security patches |

A change with both kinds of edit splits into two pull requests: the non-breaking half to `main`, the break to `next`.

## The security exception

A security fix that cannot be made non-breaking may ship a major inside the window. It is the only exception — "critical bug" is not one, or it would be used every week.

```sh
bun run scripts/release.ts --bump major --security GHSA-xxxx-xxxx-xxxx --dry-run --json
```

The advisory id (a `GHSA-…` or `CVE-…`) is recorded under the section heading in `CHANGELOG.md` as `> **Security exception:** \`<id>\``, and the bump commit's body carries `Security-Exception: <id>`. The same recorded line lets a removal skip its deprecation minor.

## Who decides

The maintainer bot judges whether a release is due and which kind (`.dz/briefs/release-due.md`), inside this window: it cannot cut a major the window refuses, because `scripts/release.ts` refuses it before any file moves, `release.ts --check` refuses it again before a tag is cut, and the gate refuses a dated section that broke it.

## How each rule is enforced

| Rule | Refused by | Code |
|---|---|---|
| one major per 90 days | `scripts/release.ts --bump`, `--check`, and the gate's `boundaries` step | `X_RELEASE_MAJOR_WINDOW` |
| no break on `main` while the window is closed | `bun run changelog-check` and the gate, on the pull request's base branch | `X_BREAKING_ON_MAIN` |
| a removal was deprecated first | `bun run scripts/deprecation-window.ts` and the gate | `X_RELEASE_UNDEPRECATED_REMOVAL` |
| every release from 30.0.0 opens with Highlights, the Release body | `bun run changelog-check` and the gate | `X_CHANGELOG_HIGHLIGHTS_MISSING` |
| a maintenance publish never moves `latest` | `scripts/dist-tag.ts` in `release.yml`; `scripts/registry-audit.ts` audits `latest` and each `maint-*` tag | `X_REGISTRY_LATEST_DRIFT` |
