# Vocabulary migrations
Vocabulary evolves: dictionaries gain members, acts get revised, modeling
mistakes get fixed. This page explains how vocabulary change stays safe —
through versions, pins, and explicit identity — and what to do when a
rename or a revision crosses package boundaries.
Everything on this page works with the public release of `law`. The
worked example needs only scratch copies of the lab fixture
`fixtures/parcels-iface`. Each claim carries a status in words with a
letter mark; the full legend is on the
[topic index](/corpus/#how-this-topic-marks-confidence).
## Versions are the migration unit
Packages are versioned, and every consumer pins an exact version in its
lockfile (**S**). A dictionary can release a new version without moving
its consumers: existing packages keep linking the version they tested
against until their authors deliberately update the pin. There is no
in-place mutation of a released version — a changed meaning ships as a
new version with a new content hash.
Previewing newer versions without touching the lock is a supported
operation: `law outdated --project
--registry lab=`
compares pinned versions against the registry and reports whether
each pin is up to date, without touching the lock (**S**).
## Migration matrix
Every vocabulary change falls into one of the rows below. The row says
what the change does to identity, what it means for consumers, and
which checks confirm it — all **available in the public release** (S):
| Change | Identity | Consumers | Checks |
|---|---|---|---|
| Label added or edited | Id kept, hash moves | Unaffected | `lower`, package tests |
| Rename with an honored pin | Id kept, hash moves | Old spellings resolve; new spelling refused | Id equality, exports, consumer `lower`, tests in both worlds |
| Rename with no pin, or a pin the kind ignores | New id | Old spellings break; move to the new spelling | Exports compare, consumer errors, tests |
| Namespace change | Namespace-derived ids move; honored absolute `@id` pins can stay | Compatibility follows exports and name resolution, not the namespace alone | Id compare, exports, consumer `lower` in both worlds, tests |
| Move between files | Id and hash kept | Unaffected | `lower` compare |
| Signature change | Id kept, hash moves | Re-check every caller | `lower`, package tests |
| Enum grows | Enum id kept, hash moves | New member addressable | `lower`, package tests |
| Enum member removed | Member id retired | Member consumers break | Consumer errors, tests |
| Split or merge | New ids; one successor may keep the pin | Retired ids break | Pin review, tests |
Which procedure to pick:
- Identity kept and consumers unaffected (label edit, move between
files, enum grows): release a new version; consumers move their pins
when they choose.
- Rename of a kind that honors the pin: follow
[the rename procedure](#the-rename-procedure) below; consumers keep
the old spelling and only move their version pins.
- New or retired ids (rename without a pin, member removed, split or
merge): a breaking release. Every consumer must update its spellings
together with its version pin.
- Signature or namespace change: re-check every caller in both worlds
before release.
## The rename procedure
A rename without an explicit identity creates a new node; a rename with an
honored explicit `@id` keeps the node (**S**). The safe order is:
1. Read the actual old StableId from compiled output — never guess it
from the spelling.
2. Pin that identity with `@id` while keeping the old name, and confirm
the compiled node is unchanged: same id, same content hash. This step
exists only for kinds that honor the pin.
3. Rename, keeping the pin, and confirm the id is unchanged while the
content hash moved.
4. Settle every consumer. With the pin in place the old spellings keep
resolving, so consumers only move their version pins. The new spelling
does not resolve while the pin holds the old identity; if consumers
must eventually use the new spelling, that is a second, breaking
migration to a new identity, with all spellings updated together.
## Revisions of sources
When a source act is revised, the corpus models the revision as a new
edition with its own fragments — the old edition stays pinned and
addressable (**S**). Rules anchored to the old fragments keep their
anchors; migrating them to the new edition is a deliberate authoring
step, reviewed like any other change, not an automatic rewrite.
One revision in two languages remains two editions even after migration:
never merge them into one edition with two texts.
## Worked example: a pinned enum rename
The migration below renames the shared enum `ParcelKind` to
`ParcelClass` in scratch copies of the parcels lab fixture (see
[the lab tour](/corpus/lab/)), keeping its StableId pinned. It walks through
[the rename procedure](#the-rename-procedure) step by step. Every
command runs from the bundle root; output filters only drop launcher
chatter.
Copy the fixture twice — world A stays pristine, world B migrates:
```bash
$ cp -r fixtures/parcels-iface /tmp/mig-a
$ cp -r fixtures/parcels-iface /tmp/mig-b
```
Old id. Read the compiled identity of the enum in world A:
```bash
$ law engine check /tmp/mig-a/package.law 2>/dev/null
check OK: /tmp/mig-a/package.law
$ law engine lower /tmp/mig-a/package.law 2>/dev/null | python3 -c "import json,sys; [print(n.get('kind'),n.get('id')) for n in json.load(sys.stdin)['nodes']]"
type_decl urn:law:lab:parcels:iface#Owner
type_decl urn:law:lab:parcels:iface#Parcel
type_decl urn:law:lab:parcels:iface#ParcelKind
symbol_decl urn:law:lab:parcels:iface#owns
```
Pin. Add the `@id` in world B while keeping the old name — the compiled
node must come out byte-identical:
```law
@id("urn:law:lab:parcels:iface#ParcelKind")
pub enum ParcelKind {
```
```bash
$ law engine check /tmp/mig-b/package.law 2>/dev/null
check OK: /tmp/mig-b/package.law
$ law engine lower /tmp/mig-b/package.law 2>/dev/null | python3 -c "import json,sys; [print(n.get('id'),n.get('name'),n.get('contentHash')) for n in json.load(sys.stdin)['nodes'] if n.get('typeKind')=='enum']"
urn:law:lab:parcels:iface#ParcelKind ParcelKind sha256:652522b2bc2b2031aebb8d20e96c921f56bf010254b162101473c8f4b2574dee
$ law engine lower /tmp/mig-a/package.law 2>/dev/null | python3 -c "import json,sys; [print(n.get('id'),n.get('name'),n.get('contentHash')) for n in json.load(sys.stdin)['nodes'] if n.get('typeKind')=='enum']"
urn:law:lab:parcels:iface#ParcelKind ParcelKind sha256:652522b2bc2b2031aebb8d20e96c921f56bf010254b162101473c8f4b2574dee
```
Rename. Change the name to `ParcelClass`, keep the pin, and move the
version to `0.2.0` in both `package.law` and `law.toml`:
```law
@id("urn:law:lab:parcels:iface#ParcelKind")
pub enum ParcelClass {
```
```bash
$ law engine check /tmp/mig-b/package.law 2>/dev/null
check OK: /tmp/mig-b/package.law
$ law engine lower /tmp/mig-b/package.law 2>/dev/null | python3 -c "import json,sys; [print(n.get('id'),n.get('name'),n.get('contentHash')) for n in json.load(sys.stdin)['nodes'] if n.get('typeKind')=='enum']"
urn:law:lab:parcels:iface#ParcelKind ParcelClass sha256:661db4ac3f78146a71b610d27134337c525649bf338380cb36f5d90b6b0b1853
```
The id is unchanged; the content hash moved from `6525…` to `661d…`
because the name is part of the state (**S**).
Exported names. Compile both worlds and compare what each one exports:
```bash
$ mkdir -p /tmp/mig-a/clir /tmp/mig-b/clir /tmp/mig-cons-a /tmp/mig-cons-b
$ law engine lower /tmp/mig-a/package.law 2>/dev/null > /tmp/mig-a/clir/labparcels.iface.lawir.json
$ law engine lower /tmp/mig-b/package.law 2>/dev/null > /tmp/mig-b/clir/labparcels.iface.lawir.json
$ law engine imports-context /tmp/mig-cons-a --clir /tmp/mig-a/clir 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(sorted(d[0]['exports']))"
['Owner', 'Parcel', 'ParcelKind', 'commercial', 'garden', 'owns', 'residential']
$ law engine imports-context /tmp/mig-cons-b --clir /tmp/mig-b/clir 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(sorted(d[0]['exports']))"
['Owner', 'Parcel', 'ParcelKind', 'commercial', 'garden', 'owns', 'residential']
```
Exports derive from node ids, so both worlds export the old spelling
`ParcelKind` (**S**).
Consumers. Each world gets a small consumer that keeps the old spelling
and only moves its version pin:
```law
language "law.core" version "0.2";
package examples.shed version "0.2.0";
namespace "urn:law:examples:shed";
import labparcels.iface version "0.2.0";
const ShedKind: labparcels.iface::ParcelKind = labparcels.iface::garden;
```
```bash
$ law engine imports-context /tmp/mig-cons-a --clir /tmp/mig-a/clir 2>/dev/null > /tmp/mig-a/ctx.json
$ law engine imports-context /tmp/mig-cons-b --clir /tmp/mig-b/clir 2>/dev/null > /tmp/mig-b/ctx.json
$ law engine lower /tmp/mig-cons-a/shed.law --imports /tmp/mig-a/ctx.json 2>/dev/null | python3 -c "import json,sys; n=json.load(sys.stdin)['nodes'][0]; print(n['id'],json.dumps(n['value']))"
urn:law:examples:shed#ShedKind {"id": "urn:law:lab:parcels:iface#garden", "kind": "const_ref"}
$ law engine lower /tmp/mig-cons-b/shed.law --imports /tmp/mig-b/ctx.json 2>/dev/null | python3 -c "import json,sys; n=json.load(sys.stdin)['nodes'][0]; print(n['id'],json.dumps(n['value']))"
urn:law:examples:shed#ShedKind {"id": "urn:law:lab:parcels:iface#garden", "kind": "const_ref"}
```
Both consumers compile to the identical reference (**S**). The two
failure modes prove the boundary. A consumer rewritten to the new
spelling does not resolve against world B:
```bash
$ law engine lower /tmp/mig-cons-b/newspell.law --imports /tmp/mig-b/ctx.json 2>&1 | grep "error" | head -1
/tmp/mig-cons-b/newspell.law:7:17: error LDC-E1105: labparcels.iface::ParcelClass: type is not exported by the package (§24: cross-package references target only `pub` declarations; exported: Owner, Parcel, ParcelKind, commercial, garden, owns, residential)
```
And a consumer that keeps the old version pin is refused against the new
world until the pin moves:
```bash
$ law engine lower /tmp/mig-cons-a/shed.law --imports /tmp/mig-b/ctx.json 2>&1 | grep "error" | head -1
/tmp/mig-cons-a/shed.law:5:1: error LDC-E1110: dependency "labparcels.iface" is declared with version "0.1.0", but the resolve context presented "0.2.0" (§23: canonical build uses the resolver exact version, and the exact version in the law text must match it)
```
Both worlds checked. The package scenarios pass on each side:
```bash
$ law test /tmp/mig-a 2>&1 | grep -E "^(law test| ok|total)"
law test labparcels.iface: world labparcels.iface
ok [labparcels.iface#authored] tests/01-owns-ok.lawtest / urn:lab:parcels:iface:owns-ok
ok [labparcels.iface#authored] tests/02-owns-unknown.lawtest / urn:lab:parcels:iface:owns-unknown
total: 2 checked, 2 passed, 0 failed, 0 not run; code 0
$ law test /tmp/mig-b 2>&1 | grep -E "^(law test| ok|total)"
law test labparcels.iface: world labparcels.iface
ok [labparcels.iface#authored] tests/01-owns-ok.lawtest / urn:lab:parcels:iface:owns-ok
ok [labparcels.iface#authored] tests/02-owns-unknown.lawtest / urn:lab:parcels:iface:owns-unknown
total: 2 checked, 2 passed, 0 failed, 0 not run; code 0
```
Both worlds pass, and both consumers compile to the same reference.
Version `0.2.0` can therefore be released: a consumer only has to move
its version pin, and none of its spellings change.
## Checklist for a vocabulary change
Before releasing, confirm each step:
1. Decide the scope: local `internal` change, or a new version of a
shared dictionary.
2. For a rename: read the old id from compiled output, pin it where the
kind honors the pin, and verify id equality before and after.
3. Re-run scenario tests over the locked world — in both worlds when a
version boundary is crossed (**S**).
4. Re-run the structural audit and clear any new lint findings. The
lint section **requires a source checkout** (I); the public
`law audit` reports it as `not_run`.
5. If a dictionary released a new version, update consumer pins
deliberately, one package at a time (**S**).
## Where to go next
- The companion page on [stable identity](/corpus/vocabulary/identifiers/) explains the identity rules
migrations rely on.
- The companion page on the [audit surface](/corpus/vocabulary/audit/) shows the checks that confirm
a migration.
- For version semantics, see [Versions](/protocols/versions/).
- For placing metadata in the manifest, see
[Place metadata in the manifest](/recipes/n-package/package-metadata/).