# breakingVersion and Migration

> When to bump breakingVersion, what a bump does to saved decks, how a changed resource file is re-registered, and worked examples of each.

- Source: https://diceytable.com/docs/plugins/breaking-changes
- Last verified: 2026-09-12

A plugin carries **two** version numbers, and they answer different questions.

| Field | Shape | Bump it |
|---|---|---|
| `version` | Semver — `1.4.2` | **Every publish.** It identifies the release. |
| `breakingVersion` | An integer, 1–1000, defaulting to `1` | **Only when your card model changes incompatibly.** |

`version` is bookkeeping. `breakingVersion` is a promise about other people's saved decks, and
that is why it is a separate field rather than the major digit of the first: a card-data plugin
releases constantly, and a deck pool that forked on every release would re-create exactly the
orphaning problem this design exists to prevent.

Migration is the author's responsibility. **The platform's floor is that we never destroy a
deck** — no bump deletes, rewrites or re-points a saved decklist. Those are two halves of one
arrangement, and both halves are load-bearing. Read them together.

## What a breaking change actually is

Ask one question: **would a decklist saved yesterday still name the same cards?**

A saved deck stores card **identity values** — whatever your `cardMapping.identityField` produced
for each card. Everything else about a card (its title, its art, its stats) is resolved fresh on
every load. So a change is breaking if, and only if, it changes what those stored strings mean.

### Bump `breakingVersion` for

- **Renaming or re-pointing the identity field.** Mapping `key` from `oracle_id` where it used to
  read `id`. Every stored value now addresses nothing.
- **Changing the id space.** The provider migrated from numeric ids to UUIDs; the values are still
  produced, but yesterday's are no longer among them.
- **Removing a mapped field a game's deck schema depends on**, so that cards can no longer be
  filtered, grouped or laid out the way saved decks assume.
- **Changing which records the card endpoint returns** such that previously-resolvable cards are
  no longer in any page — a narrower default filter, a different set scope, a different edition.
- **Switching to a different provider** whose ids are unrelated to the old one's.

### Do not bump `breakingVersion` for

- **Adding cards.** New printings, a new set, a bigger catalogue. Every stored id still resolves.
- **Adding a mapped field.** Existing decks ignore it; games that want it opt in.
- **Adding an endpoint,** or adding an optional parameter to one.
- **Adding a `fallbackPath`** so a shape you were dropping now resolves. Strictly more cards
  resolve than before, and none stops resolving.
- **Widening an `enum` parameter's `options`.**
- **Fixing a transform,** a `joinSeparator`, a `default`, or a title that read wrong.
- **Rotating your provider credential,** changing `auth.kind`, or changing an origin — a request
  going somewhere else is invisible to a decklist as long as the ids are the same.
- **Editing `summary`, `description`, `attribution` text, `tags` or `license`.**
- **Raising or lowering your declared `rateLimits`.**
- **Adding, changing or removing a `deckImport` block.** An import resolves entries to identity
  values once, at import time, and the deck stores those values — the join is never re-run. So
  changing what an import matches on cannot alter what a saved deck names. It only changes what
  the *next* import produces.

The test is not "is this a big change" — it is "does a stored identity value still mean what it
meant". A rewrite that produces byte-identical ids is not breaking. A one-character change to
`identityField.path` is.

## There is no migration hook

**A plugin runs no code on our servers.** The data half is a declarative mapping, deliberately —
no expression language, no callbacks, no `normalize.js`. There is therefore nowhere for a
migration function to live, nothing that would call it, and no shape it could receive or return.
If you came here looking for one, that is the answer: it does not exist and is not planned for
this version of the contract.

What you have instead are three real options, in order of how much they cost the people whose
decks are affected.

1. **Avoid the bump.** Usually possible, and always the best outcome. If the provider moved to a
   new id space but still returns the old id somewhere in the record, keep mapping
   `identityField` from the old one and map the new one as an ordinary field. Stored decks keep
   resolving; nobody has to do anything.
2. **Keep the old identity resolvable for a release or two.** If your card endpoint can still be
   asked for records under the old ids, leave that path mapped while both id spaces are live, bump
   only once the provider genuinely stops answering, and say so in `description` before you do.
3. **Bump, and tell people.** When the old ids are genuinely gone, bump `breakingVersion`, put the
   date and the reason in your `description`, and tell the authors of the games built on your
   plugin *before* you publish. They are the ones who have to decide what happens to their
   players' decks.

## What a bump does, concretely

Two versions matter here, and conflating them is the usual mistake.

- **Your plugin's `breakingVersion`** is a signal to the games built on you. It is published in
  your registry record, returned by the plugin catalogue endpoint, and visible to a mod script as
  `ModPluginSummary.breakingVersion`.
- **A game's own `breakingVersion`**, declared in its `data/cardSchema.json`, is what a saved deck
  is keyed to. A deck row records the source it belongs to and that number.

So a plugin bump does not, by itself, move anybody's decks. What it does is confront the mod
author with a decision:

| The mod author… | Result |
|---|---|
| bumps the game's `cardSchema.json` `breakingVersion` | The game's deck pool **forks**. Decks saved under the old number stay in the database, keyed to the old number. New decks are built under the new one. |
| leaves it alone | The pool does not fork. Decklist entries whose ids no longer resolve render as **visibly unresolved rows** — a marked, honest state that never silently substitutes a different card. |

Neither outcome loses data, and neither one is automatic. If you are the plugin author, assume the
first: a bump exists to be answered with a fork, and a bump that nobody answers leaves players
looking at decks full of unresolved rows.

## The never-lose-a-deck floor

Non-negotiable, and it is a platform guarantee rather than an author promise:

- **A saved decklist is never deleted, rewritten or re-pointed by a version bump.** Deck rows are
  keyed by `(source kind, source id, breaking version)` and nothing in any publish path writes to
  them.
- **A deck outlives the thing it was built for.** A deck whose format was dropped, whose plugin
  was withdrawn, or whose card model moved on still exists, still has its entries, and still
  opens from its own link.
- **A withdrawn plugin is reported as withdrawn.** The catalogue endpoint returns a blocked or
  incompatible plugin *carrying its status* rather than a 404, so the deck list can say "the
  plugin this deck's cards come from has been withdrawn" over a working list, instead of showing
  the same blank page as a mistyped link.
- **A failed card source never renders as an empty deck list.** "This game has no decks" and
  "this game's card data is unreachable" look identical to a player and lead to completely
  different actions, so they are separate states.
- **A deck built for an earlier card model is still listed, and says so.** The Decks tab's
  **My decks** pool is not scoped to the current breaking version — it returns every version of
  this game's card model — and a row whose version is behind the game's is badged *"Built for vN
  — no migration provided"*. Public browse stays scoped to one card model, deliberately: that
  list answers "what can I play today".
- **Every deck downloads as JSON.** *Download decklist (JSON)* on a deck's page writes the stored
  entries — card identity values, counts and partition ids — plus the source triple they belong
  to. It is built from the deck row alone and consults **no card data**, so it works unchanged
  when the plugin is withdrawn, the catalogue is unreachable, or the card model has moved past
  the deck. That is the whole point: the export you need is the one you cannot resolve.
- **A deck's version history carries the documents, not just the counts.** Each entry in a deck's
  history ships the decklist it recorded, so a list can actually be recovered from it. A history
  entry whose stored document is unreadable reports that, rather than reporting an empty deck.

> **Where the identity contract is enforced.** If a plugin's `identityField` and a game's
> `cardSchema.json` `roles.key` disagree, nothing the plugin returns can be keyed to what that
> game's decks store. This is refused with a named error naming both fields — shown as a banner
> over a working deck list — rather than rendering as an empty card grid. It is a mismatch
> between two documents, so it stays broken until one of them changes; retrying will not help.

## Changing a resource

[Resources](https://diceytable.com/docs/plugins/authoring#resources-files-a-project-can-copy) are pinned harder than
card data. Card data is fetched live from your provider. A resource is a file in your repository,
and it is served **only at the commit sha you registered**.

- **New bytes need a new commit and a new registration.** Pushing a changed texture to your branch
  changes nothing anyone receives. Register the commit that contains it; that re-runs the whole scan,
  every declared file included. There is no way to update one file on the strength of a previous
  scan.
- **The registry records a git blob sha per file.** A file whose bytes did not change keeps the same
  blob sha at the new commit. A file whose bytes did change gets a new one.
- **The CDN follows the registered commit.** Once the new commit is registered, the edge admits your
  declared paths at that sha. Paths declared only at the previous commit are no longer admitted.
- **Changing a resource is not a `breakingVersion` bump.** `breakingVersion` is about the card
  identity values saved decks store, and a resource file is not one of them.

**Copies already in projects.** A project that took a copy of one of your resources keeps it in its
own files, so a new registration does not change what that project plays with. The copy's link
records where it came from — plugin id, commit sha, resource path — and the blob sha of the file at
that commit. Comparing that recorded sha with the one the registry now holds is how a copy is
detected as having an **update available**. That detection contract exists today; the asset browser
library is where authors will see and act on it.

What follows for you as a plugin author:

- **Keep a path stable when a file is revised.** The path is how a copy's link names its source.
- **Removing or renaming a path** leaves existing copies intact, but their link no longer names a
  file you declare.

## A worked non-breaking change

Your provider added a `set_name` to every card record, and you want games to be able to group by
set. You also noticed that double-faced cards were losing their art.

`diceytable.plugin.json`, before:

```json
{
  "version": "1.4.0",
  "breakingVersion": 1,
  "cardMapping": {
    "root": "data",
    "identityField": "key",
    "fields": {
      "key":   { "path": "id", "required": true },
      "title": { "path": "name", "transforms": ["trim"], "required": true },
      "art":   { "path": "image_uris.normal" }
    }
  }
}
```

After:

```json
{
  "version": "1.5.0",
  "breakingVersion": 1,
  "cardMapping": {
    "root": "data",
    "identityField": "key",
    "fields": {
      "key":      { "path": "id", "required": true },
      "title":    { "path": "name", "transforms": ["trim"], "required": true },
      "art":      { "path": "image_uris.normal", "fallbackPaths": ["card_faces[0].image_uris.normal"] },
      "set_name": { "path": "set_name", "transforms": ["trim"] }
    }
  }
}
```

`version` moved `1.4.0` → `1.5.0`. **`breakingVersion` stays `1`**, and that is the whole point:
`identityField` still reads `id`, every stored identity value still resolves, one field was added
and one shape that used to drop its art now keeps it. Strictly more resolves than before, nothing
less. Every saved deck on every game built on this plugin is unaffected and needs no attention
from anyone.

## A worked breaking change

The provider deprecated its numeric `id` in favour of a stable `oracle_id`, and after a grace
period stopped returning `id` at all. Every value your `key` field ever produced is now dead.

```json
{
  "version": "2.0.0",
  "breakingVersion": 2,
  "cardMapping": {
    "root": "data",
    "identityField": "key",
    "fields": {
      "key":   { "path": "oracle_id", "required": true },
      "title": { "path": "name", "transforms": ["trim"], "required": true },
      "art":   { "path": "image_uris.normal", "fallbackPaths": ["card_faces[0].image_uris.normal"] }
    }
  }
}
```

`version` moved `1.5.0` → `2.0.0` and **`breakingVersion` moved `1` → `2`**. What you owe alongside
the publish:

1. **Tell the authors of the games built on your plugin, before you publish it.** They decide
   whether to fork their deck pool; that decision is not yours and they cannot make it after the
   fact without their players noticing first.
2. **Say what changed, in `description`.** "From 2.0.0 card identity is the provider's
   `oracle_id`; decks built before 2026-08-16 will not resolve." A reader who finds a deck full of
   unresolved rows should be one click from the reason.
3. **Do not reuse the plugin id for something unrelated.** A bump forks the deck pool; a new
   *identity* would strand it entirely. Bumping is the gentler of the two, and it is the one this
   field is for.

Had the provider kept returning `id` alongside `oracle_id`, the right change would have been
option 1 from above — map `oracle_id` as an ordinary field, leave `identityField` on `id`, and bump
nothing.

## Testing a change before you publish

1. **Diff the identity values.** Read `GET /api/plugins/:id/cards` before and after against the
   same declared parameters, pull the `identityField` value out of every card, and compare the two
   sets. If any value present before is absent after, you have a breaking change — regardless of
   what else the diff says, and regardless of what you intended.
2. **Do it on a scratch plugin id.** Register the candidate commit under a throwaway id in a
   throwaway repository. It gets its own registry record and its own quota, and nothing about it
   touches the live plugin or any deck.
3. **Check the catalogue first.** `GET /api/plugins/:id/catalogue` reports which mapped field the
   platform will treat as the identity, without spending a request against your provider. If that
   field name is not what you expect, stop there.
4. **Build a deck against the old version, then point a scratch game at the new one.** The
   honest end-to-end test is the one a player performs: save a decklist, publish the change, and
   look at whether the entries still resolve.
5. **Remember what re-triggers review.** Registration pins an immutable commit sha, and any sha
   change re-triggers it. There is no "just this once" publish that skips the scan.

## See also

- [Writing a plugin](https://diceytable.com/docs/plugins/authoring) — the manifest fields this page bumps, including
  `cardMapping` and `identityField`.
- [Deck import](https://diceytable.com/docs/plugins/authoring#deck-import-letting-a-plugin-supply-a-deck-not-just-cards) — the one
  mapping on the manifest that a stored decklist does not depend on.
- [Plugins](https://diceytable.com/docs/plugins) — the split, and why the data half cannot run a migration function.
- [Calling a plugin from a mod](https://diceytable.com/docs/plugins/calling-from-a-mod) — where a mod script reads
  `breakingVersion`.
- [Working with decks](https://diceytable.com/docs/editor/working/decks) — a game's `cardSchema.json`, including its own
  `breakingVersion`.
- [Known limitations](https://diceytable.com/docs/reference/known-limitations) — everything else that is documented as
  incomplete.
