Contract versioning

Themes in Sheru are pure data, distributed separately from the app — a theme is a JSON-serializable SheruTheme object that a marketplace may ship, cache, and load long after the app that renders it was built. That separation creates a drift problem: a theme authored against one version of the token contract might be loaded by an app that has since added, removed, or repurposed tokens, parts, or chrome keys.

Contract versioning is how Sheru fails safe against that drift. A theme declares which version of the UI contract it targets; the app classifies that declaration at register time and either admits the theme, admits it with a warning, or rejects it loudly — never letting a mismatched theme degrade silently at paint time.

Single source of truth

The contract version is a two-number MAJOR.MINOR value, defined in exactly one place so that every reader shares one number and they can never drift apart.

export const UI_CONTRACT_MAJOR = 1;
export const UI_CONTRACT_MINOR = 0;
export const UI_CONTRACT_VERSION = `${UI_CONTRACT_MAJOR}.${UI_CONTRACT_MINOR}` as const;

The contract is currently "1.0". Two consumers read this value:

Because both read the same constant, the version the app advertises and the version it enforces cannot split. The MAJOR/MINOR scheme is what carries the contract through future breaking changes — bump the major and any theme pinned to the old major classifies as too-new.

The caret-only ^X.Y grammar

A theme declares its required contract through the optional engines field on SheruTheme:

engines?: { sheruUiContract: string };  // e.g. "^1.0"

The grammar is caret-only. The single supported form is ^X.Y, matched by /^\^(\d+)\.(\d+)$/. Its meaning:

^X.Y — satisfied by any X.y where y ≥ Y. It does not span majors (each major is breaking), and it tolerates any additive minor at or above its floor.

So a theme that declares ^1.0 pins itself to breaking major 1 and accepts 1.0, 1.3, 1.99 — but never 2.0. This models the contract's own bump semantics: a theme pins to one breaking major and rides any number of additive minors.

Every other form is unsupported and treated as unmet: a bare exact "1.0", a ">=1.0", an "1.x" — all return false from satisfies and classify as "too-new" at the gate. There is intentionally no other meaningful declaration, and using one grammar keeps the two evaluators from drifting.

Two functions share this grammar so they cannot diverge — satisfies(range, version):

export function satisfies(range: string, version: string): boolean {
  const ver = parseVersion(version);
  if (!ver) return false;
  const trimmed = range.trim();
  if (!trimmed.startsWith("^")) return false;
  const base = parseVersion(trimmed.slice(1));
  if (!base) return false;
  return ver.major === base.major && ver.minor >= base.minor;
}

…and classifyContract(range), which is the one authority that admits or rejects a theme.

ContractMatch polarity

When a theme is registered, its declared range is classified against the running app's version into one of four ContractMatch outcomes. The polarity matters and must not be inverted:

export type ContractMatch = "ok" | "too-old" | "too-new" | "unstated";
Outcome Meaning Fatal?
ok The range is satisfied. — admitted
too-new The theme needs a contract the app does not have (e.g. wants ^2.0 on a 1.0 app, or ^1.3 on a 1.0 app). FATAL — rejected
too-old The theme targets a lower minor than the app. It still renders, against the newer fallbacks. non-fatal — allowed
unstated No engines field at all. A free pass; never range-checked. non-fatal — allowed

The asymmetry is the whole point. A too-new theme is demanding tokens or parts the app may not implement yet, so binding it could produce a broken or unsafe render — it is refused. A too-old theme only targets a subset of what the app provides; everything it does not know about is covered by schema fallbacks, so it renders fine. And a theme with no engines field is trusted to track the current surface.

Here is the runtime gate (classifyContract) that produces those outcomes:

function classifyContract(range: string): ContractMatch {
  // Caret-only grammar (must match `satisfies` in contract.ts): a theme pins to
  // one breaking major and tolerates any minor ≥ its floor. A bare "X.Y", ">=",
  // or "X.x" is not a supported declaration.
  const m = /^\^(\d+)\.(\d+)$/.exec(range.trim());
  if (!m) return "too-new"; // unparseable / unsupported form → refuse, treat as unmet
  const major = Number(m[1]);
  const minor = Number(m[2]);
  if (major !== RUNNING_MAJOR) return "too-new"; // different major never satisfied by ^
  if (minor > RUNNING_MINOR) return "too-new"; // wants a newer minor than we ship
  if (minor < RUNNING_MINOR) return "too-old";
  return "ok";
}

Note that an unparseable range is treated as too-new (refuse), not silently ignored — a malformed declaration fails closed.

How the gate is wired into registration

The classification flows out of validateThemeTokens, which runs the token drift guard and the contract check in one pass. A theme with no engines field classifies as "unstated":

const range = engines?.sheruUiContract;
const contractKind: ContractMatch = range === undefined ? "unstated" : classifyContract(range);

return {
  ok: missing.length === 0 && unknown.length === 0 && contractKind !== "too-new",
  missing,
  unknown,
  contract: { kind: contractKind },
};

So ok requires that the theme bound every required token, invented no unknown names, and is not too-new.

registerTheme turns a too-new result into a distinct, catchable error before the theme ever enters the registry:

if (result.contract.kind === "too-new" && theme.engines) {
  throw new ThemeContractError(theme.id, theme.engines.sheruUiContract);
}

ThemeContractError is deliberately its own class (not the generic token-violation Error) so a marketplace loader can catch it and show a disabled picker entry instead of crashing the app.

What forces a bump

The version moves exactly when the public theme contract changes — the set of tokens, the data-part vocabulary, the required ThemeChrome keys, and the default control-glyph paths. The severity is decided by whether the change is breaking or purely additive: MAJOR for breaking changes, MINOR for additive ones. The contract's surface is never bumped by hand on a guess; the rules below decide it deterministically.

MAJOR (breaking) — bump the major, reset the minor

A major change means old themes can no longer be trusted to render correctly, so it forces a new breaking major and makes any theme pinned to the old major classify as too-new. It is triggered by:

MINOR (additive) — bump the minor only

A minor change is purely additive: old themes keep rendering through fallbacks, so a theme pinned to the old minor classifies as too-old (non-fatal). It is triggered by:

The closure check is what keeps an "additive" token from secretly being breaking: every fallback may only reference token names that exist in the same schema. A new fallback pointing at a missing name is reclassified to major.

none

An identical surface (modulo ordering) is no change.

Why this exists

Themes are a separately distributed artifact. When a marketplace serves third-party themes, an app and a theme are routinely built at different times against different versions of the token/part/chrome/glyph surface. Without versioning, that drift would surface as silently broken renders — a theme missing a token it never knew about, or selecting a data-part the app no longer stamps.

Contract versioning makes the drift explicit and fail-safe:

  1. One number, two readers — a single contract-version constant guarantees the advertised and enforced versions can't split.
  2. A precise bump rule — the version moves exactly when the public contract surface changes, with MAJOR for breaking changes and MINOR for additive ones.
  3. Asymmetric admission — a theme demanding a newer contract is rejected (fatal); a theme targeting an older one is admitted on fallbacks; an unstated theme is trusted. The dangerous direction is the one that fails closed.

Together with the token contract drift guard and the declarative glyph spec, contract versioning is what lets Sheru load a theme it has never seen and know, before paint, whether it is safe to render.