breakingVersion and Migration
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
keyfromoracle_idwhere it used to readid. 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
fallbackPathso a shape you were dropping now resolves. Strictly more cards resolve than before, and none stops resolving. - Widening an
enumparameter'soptions. - Fixing a transform, a
joinSeparator, adefault, 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,attributiontext,tagsorlicense. - Raising or lowering your declared
rateLimits. - Adding, changing or removing a
deckImportblock. 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.
- 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
identityFieldfrom the old one and map the new one as an ordinary field. Stored decks keep resolving; nobody has to do anything. - 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
descriptionbefore you do. - Bump, and tell people. When the old ids are genuinely gone, bump
breakingVersion, put the date and the reason in yourdescription, 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
breakingVersionis 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 asModPluginSummary.breakingVersion. - A game's own
breakingVersion, declared in itsdata/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
identityFieldand a game'scardSchema.jsonroles.keydisagree, 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.
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:
{
"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:
{
"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.
{
"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:
- 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.
- Say what changed, in
description. "From 2.0.0 card identity is the provider'soracle_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. - 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#
- Diff the identity values. Read
GET /api/plugins/:id/cardsbefore and after against the same declared parameters, pull theidentityFieldvalue 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. - 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.
- Check the catalogue first.
GET /api/plugins/:id/cataloguereports 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. - 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.
- 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 — the manifest fields this page bumps, including
cardMappingandidentityField. - Deck import — the one mapping on the manifest that a stored decklist does not depend on.
- Plugins — the split, and why the data half cannot run a migration function.
- Calling a plugin from a mod — where a mod script reads
breakingVersion. - Working with decks — a game's
cardSchema.json, including its ownbreakingVersion. - Known limitations — everything else that is documented as incomplete.
