docs← Back to article

Markdown for LLMs

Vocabulary migrations

The source Markdown for this article. Copy it into your assistant or download it as a text file.

Download this articlePlain text ↗
# 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 <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

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