# 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/).