Skip to content
docs
Arxo ↗

Vocabulary migrations

For LLMs7 sections

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.

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

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):

ChangeIdentityConsumersChecks
Label added or editedId kept, hash movesUnaffectedlower, package tests
Rename with an honored pinId kept, hash movesOld spellings resolve; new spelling refusedId equality, exports, consumer lower, tests in both worlds
Rename with no pin, or a pin the kind ignoresNew idOld spellings break; move to the new spellingExports compare, consumer errors, tests
Namespace changeNamespace-derived ids move; honored absolute @id pins can stayCompatibility follows exports and name resolution, not the namespace aloneId compare, exports, consumer lower in both worlds, tests
Move between filesId and hash keptUnaffectedlower compare
Signature changeId kept, hash movesRe-check every callerlower, package tests
Enum growsEnum id kept, hash movesNew member addressablelower, package tests
Enum member removedMember id retiredMember consumers breakConsumer errors, tests
Split or mergeNew ids; one successor may keep the pinRetired ids breakPin 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.

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.

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.

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:

Terminal
$ 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:

Terminal
$ 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:

Arxo Law
@id("urn:law:lab:parcels:iface#ParcelKind")
pub enum ParcelKind {
Terminal
$ 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:

Arxo Law
@id("urn:law:lab:parcels:iface#ParcelKind")
pub enum ParcelClass {
Terminal
$ 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:

Terminal
$ 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:

Arxo 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;
Terminal
$ 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:

Terminal
$ 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:

Terminal
$ 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:

Terminal
$ 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.

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

Documentation for Arxo. Writings — blog.arxo.io.

Anonymous visit counts on stats.arxo.io, no cookies.