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.
Versions are the migration unit
Section titled “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 <dir> --registry lab=<dir>
compares pinned versions against the registry and reports whether
each pin is up to date, without touching the lock (S).
Migration matrix
Section titled “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 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
Section titled “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:
- Read the actual old StableId from compiled output — never guess it from the spelling.
- Pin that identity with
@idwhile 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. - Rename, keeping the pin, and confirm the id is unchanged while the content hash moved.
- 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
Section titled “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
Section titled “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), keeping its StableId pinned. It walks through
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:
$ cp -r fixtures/parcels-iface /tmp/mig-a$ cp -r fixtures/parcels-iface /tmp/mig-bOld id. Read the compiled identity of the enum in world A:
$ law engine check /tmp/mig-a/package.law 2>/dev/nullcheck 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#Ownertype_decl urn:law:lab:parcels:iface#Parceltype_decl urn:law:lab:parcels:iface#ParcelKindsymbol_decl urn:law:lab:parcels:iface#ownsPin. Add the @id in world B while keeping the old name — the compiled
node must come out byte-identical:
@id("urn:law:lab:parcels:iface#ParcelKind")pub enum ParcelKind {$ law engine check /tmp/mig-b/package.law 2>/dev/nullcheck 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:652522b2bc2b2031aebb8d20e96c921f56bf010254b162101473c8f4b2574deeRename. Change the name to ParcelClass, keep the pin, and move the
version to 0.2.0 in both package.law and law.toml:
@id("urn:law:lab:parcels:iface#ParcelKind")pub enum ParcelClass {$ law engine check /tmp/mig-b/package.law 2>/dev/nullcheck 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:661db4ac3f78146a71b610d27134337c525649bf338380cb36f5d90b6b0b1853The 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:
$ 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:
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;$ 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:
$ 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:
$ 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:
$ 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-unknowntotal: 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-unknowntotal: 2 checked, 2 passed, 0 failed, 0 not run; code 0Both 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
Section titled “Checklist for a vocabulary change”Before releasing, confirm each step:
- Decide the scope: local
internalchange, or a new version of a shared dictionary. - 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.
- Re-run scenario tests over the locked world — in both worlds when a version boundary is crossed (S).
- Re-run the structural audit and clear any new lint findings. The
lint section requires a source checkout (I); the public
law auditreports it asnot_run. - If a dictionary released a new version, update consumer pins deliberately, one package at a time (S).
Where to go next
Section titled “Where to go next”- The companion page on stable identity explains the identity rules migrations rely on.
- The companion page on the audit surface shows the checks that confirm a migration.
- For version semantics, see Versions.
- For placing metadata in the manifest, see Place metadata in the manifest.
Documentation for Arxo. Writings — blog.arxo.io.
Anonymous visit counts on stats.arxo.io, no cookies.