---
title: "Versioning policy"
description: "What counts as a breaking change. It includes the rendered DOM, the data-* attributes and the CSS custom properties, not only the JavaScript API."
url: "https://opsinjs.pensievelabs.org/project/versioning-policy"
source: "https://opsinjs.pensievelabs.org/project/versioning-policy.md"
section: "Project"
kind: "project"
reviewed: "2026-09-02"
reviewer: "engineering"
aliases: ["semver", "breaking change policy", "upgrade safety", "migration guide", "breaking change guide"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="project" />

## The short version [#the-short-version]

opsinjs follows semantic versioning, and the thing being versioned is **larger
than the JavaScript API**. Because you own the source of every component you
install, and because your stylesheets and tests reach into the markup, the public
surface includes what the component renders and what it exposes to CSS.

**Five surfaces are covered:**

1. The exported JavaScript and TypeScript API, which comprises components,
   hooks, functions, types, and their prop names, types and defaults.
2. The **rendered DOM**, which covers element structure, nesting order and
   the roles and accessible names it produces.
3. The **`data-*` attributes** listed in
   [Data attributes](../reference/generated/data-attributes.mdx), and the
   conditions under which they appear.
4. The **CSS custom properties** listed in
   [CSS variables](../reference/generated/css-variables.mdx), and the semantic
   token names they implement.
5. The **registry contract**, which covers item names, the URLs they are
   served from, and the files a given item installs.

Removing or renaming anything on that list, or changing the condition that
produces it, is a major version. This is a stronger promise than most component
libraries make, and it is the one that matters in practice: an upgrade that keeps
every prop and silently rewrites the markup will break somebody's stylesheet,
somebody's end-to-end test and somebody's screen-reader expectations at the same
time.

## What is not covered [#what-is-not-covered]

* **Exact token values.** A colour may be adjusted; what is promised is the
  token's *meaning* and the contrast floor it must meet. A value change that
  fixes a measured contrast failure is a patch. A value change that alters what a
  token means is breaking, and is a rename in disguise.
* **Unprefixed internal CSS variables.** Anything not beginning `--opsin-` is
  internal, may change in a patch, and is documented nowhere for that reason.
* **Class names.** The utility classes a component renders are an implementation
  detail. Select on `data-*` attributes; that is what they are for.
* **Anything marked `@internal`** in the type declarations. It is suppressed from
  the generated API pages so that it is never mistaken for surface.
* **The documentation site's own chrome.** Its lyra, shadcn and fumadocs
  variables share a cascade with ours on this site and are not part of the
  system. [ADR 0004](./decisions/0004-lyra-docs-chrome.mdx) explains why they
  look different on purpose.
* **A `shipped` component's API**, which may change in any release with a
  changelog entry rather than a deprecation cycle. See
  [Release phases](./release-phases.mdx).

## Before 1.0 [#before-10]

This project is at 0.x, and 0.x means what it says:

* A **minor** release (`0.4.0` → `0.5.0`) may contain a breaking change. It will
  be in the changelog, with a migration note, and on the
  [deprecations](./deprecations.mdx) list if something was removed.
* A **patch** release (`0.4.1` → `0.4.2`) never contains a deliberate breaking
  change to the five surfaces above.
* Token *names* are not yet stable. Token *meanings* are, and have been since
  they were written.

Pin an exact version if a broken upgrade would be expensive for you. The
[state of the system](./state-of-the-system.mdx) page names what is currently most
likely to move.

## Deprecation, and how you find out [#deprecation-and-how-you-find-out]

Nothing is removed without passing through deprecation first.

<Steps>
  ### It is marked [#it-is-marked]

  The component or export is marked `deprecated`, its documentation page states the
  replacement and the reason, and it appears on
  [Deprecations](./deprecations.mdx) with the version that deprecated it and the
  version that will remove it.

  ### It keeps working [#it-keeps-working]

  For at least one minor release before 1.0, and for a full major after it. A
  deprecated thing that stops working is a removal that lied about being a
  deprecation.

  ### It is removed [#it-is-removed]

  In the release named when it was deprecated, with a changelog entry. A change
  that a reader cannot act on from a paragraph gets a written guide as well, and
  in practice that means one of four things: the rendered DOM moved, so
  stylesheets and end-to-end selectors move with it; a `data-*` attribute or a CSS
  custom property was renamed, which no type checker will catch; a clinical
  contract changed, which is a reason to re-run a safety review rather than a code
  edit; or the upgrade cannot be done in one step and the intermediate state has
  to be described. No release has needed one yet.
</Steps>

## How a change reaches you [#how-a-change-reaches-you]

**In the code you own. This is the weak link today.** An out-of-date copy of
a component in your repository does not announce itself the way a version range
in a lockfile does. Nothing in the file records where it came from, so the only
record of which version you copied is the one you write down yourself: run
`shadcn add` in its own commit naming the item and the date, and put your own
changes in the next one. [Upgrades and
diffs](../registry/upgrades-and-diffs.mdx) sets that habit out in full.

**In the documentation.** There is one published copy of every page and it
describes the current version. The site keeps no per-version archive, so there
is no older copy to land on and nothing that warns you that you have. Changing
the URL of a page is a breaking change for programs, because the `.md` twins and
`/llms.txt` are addresses that a crawler or an agent has already stored. What
this site does about that today is nothing: `next.config.mjs` defines rewrites
and no redirects, so a page that moves or goes away returns a 404 rather than
sending you on. Five addresses have already gone that way, removed when the
release phases were cut to three: `/registry/version-stamps`,
`/project/proposals`, `/project/migration`,
`/project/decisions/0008-considered-components-resolve` and
`/project/decisions/0022-considered-roster-built-out`. Their subjects are now at
[Upgrades and diffs](../registry/upgrades-and-diffs.mdx), [Proposing a
component](../handbook/contributing/proposing-a-component.mdx), this page, and
[ADR 0023](./decisions/0023-three-release-phases.mdx) for both records. Until a
redirect map exists, re-resolve a stored address against
[`/llms.txt`](/llms.txt) when it stops answering.

<Todo>
  Add an `async redirects()` block to `apps/www/next.config.mjs` covering those
  five paths, and make adding one a step in any change that moves a page, so that
  the paragraph above can promise a redirect instead of describing a 404.
</Todo>

**In the changelog.** [Every release](./changelog/index.mdx) has an entry, written
as prose rather than as a list of commit subjects, and the breaking parts are at
the top rather than at the bottom.

<LastUpdated />

<Reviewed />
