Skip to content
docs
Arxo ↗

Stable identity

For LLMs8 sections

Every compiled node carries two independent fingerprints: a StableId and a content hash (S). The StableId answers which node this is — the id field that anchors, labels, and cross-package references point at. The content hash answers what state the node is in — a digest over the node’s canonical bytes, including its display name, parameters, and labels. The two move independently: a rename that keeps the StableId still changes the content hash, because the name is part of the state.

Commands on this page use the public release of law, except one diagnostic that requires a source checkout (I); the inputs are small scratch files under /tmp/idlane/ whose relevant lines are shown. Each claim carries a status in words with a letter mark; the full legend is on the topic index.

Four everyday edits, and what each one does to the node and to the packages that use it — all available in the public release (S):

EditStableIdContent hashConsequence for consumers
Edit label textKeptMovesUnaffected: every reference still resolves
Move the declaration within its file or to another file of the packageKeptKeptUnaffected
Rename with a supported @id pinKeptMovesThe old name keeps resolving; the new name does not resolve
Rename without a pin, or with a pin the kind ignoresNew nodeNewReferences to the old name break; consumers must move to the new name

The first two rows are safe at any time. The last row is a breaking change for every consumer. The third row is the one that surprises authors, so it has its own example.

Renaming with a pin: the old name stays the public name

Section titled “Renaming with a pin: the old name stays the public name”

The shared enum ParcelKind from the lab fixtures is renamed to ParcelClass, with its old identity pinned:

Arxo Law
@id("urn:law:lab:parcels:iface#ParcelKind")
pub enum ParcelClass {

The compiled id stays urn:law:lab:parcels:iface#ParcelKind; only the display name and the content hash change. Consumers then see the following, which is often unexpected:

  • A consumer that still writes labparcels.iface::ParcelKind compiles and gets the same reference as before the rename.
  • A consumer that writes the new name labparcels.iface::ParcelClass is refused with LDC-E1105: the type is not exported by the package.

Two mechanisms explain it. A reference is built from its spelling: both ParcelKind and pkg::ParcelKind compile to namespace#ParcelKind. A dependency’s export list is built from its compiled node ids, not from display names. While the pin holds the old id, the old name is exported and the new one is not. A pin therefore keeps old spellings working and never forwards the new spelling. Moving consumers to the new name is a second, breaking migration to a new identity. The commands and the verbatim refusal are in the migrations worked example.

Authors pin identity with the @id("…") annotation, but the compiler honors the pin only on some declaration kinds (S). Where it is honored, the compiled id is the written value verbatim, whatever the declaration is named:

Arxo Law
@id("urn:law:examples:pin#grown-up")
const AdultAge: Integer = 18;
Terminal
$ law engine check /tmp/idlane/constpin.law 2>/dev/null
check OK: /tmp/idlane/constpin.law

The lowered node carries id urn:law:examples:pin#grown-up with name AdultAge (S). The kinds that honor the pin are const, rule, enum, priority, role, fiction, classification, definition, interpretation, interpretation group, revision, entrenchment, and calendar (S).

Every other kind assembles namespace#name and ignores the annotation, with a warning when the written value differs (S). A pin on a relation, for example, is not executed. The warning below requires a source checkout (I): the public build checks the same file clean and still ignores the pin, without a warning.

Terminal
$ ./law engine check /tmp/idlane/relpin.law 2>&1 | grep "IS NOT EXECUTED"
/tmp/idlane/relpin.law:8:1: warning LDC-E1372: explicit `@id("urn:law:examples:pin#grown-up")` on a `relation adult` declaration IS NOT EXECUTED: lowering will assemble the node id as `urn:law:examples:pin#adult` (§14 requires «используется без изменения», but this declaration does not use the `node_id` path — DECISION-0352 §4, errata E-0230). `@source` references and §202 anchors will take the package-derived id; remove the annotation or await the StableId support decision

The lowered id stays urn:law:examples:pin#adult (S). The kinds that ignore the pin include relation, entity, record, function, constraint, procedure, and the source-model declarations (S). Source-model blocks (source, edition, publication, fragment) take an id "…"; item (S).

Nodes without an explicit identity get one derived from their meaning: the parent identity plus a deterministic semantic suffix (S). The compiler must never use line numbers, declaration order, or file metadata as identity input — moving a declaration within a file, or to another file of the same package, changes neither the id nor the content hash (S).

Generated shapes authors will meet (S):

  • Split rule heads take the rule identity plus a short hash suffix.
  • Branches of a normalized disjunction take <id>/alt/N suffixes.
  • Norm templates take <namespace>#<name> unless an explicit @id overrides them.

References are spelling-derived: a bare AdultAge and a qualified pkg::AdultAge both compile to the reference namespace#AdultAge (S). A reference resolves when a node carries that id — which is why a pin keeps old spellings working and never forwards the new spelling.

Identity rules extend to values, not just declarations (S):

  • An entity value is the pair of its nominal type and its StableId. There are no implicit conversions between entity types.
  • An enum member is its own identity: dotted, bare, and qualified spellings of the same member are one reference.
  • Constants are dereferenced by a single pre-pass before evaluation, so every use sees the same value.

Within one world, each package is linked by name and each node keeps the identity its own package gave it (S). Two packages linking the same dependency by the same version share one copy; a StableId collision at link time is an error, not a silent merge.

A dependency’s export list is derived from its compiled node ids, not from display names (S) — the reason a pinned rename keeps the old name public, as shown above.

Practical consequences:

  • Pin the identity of a shared declaration before renaming it — but only for kinds that honor the pin. For every other kind a rename is a new node, and no annotation changes that.
  • Renaming without an honored pin creates a new node; renaming with one keeps the node while the content hash still moves (S).

Editor rename is certified only for package-private declarations with an honored explicit @id, plus local binders and parameters. The compiler index must carry the rename certificate; otherwise the editor refuses with a reason (S). The certificate is visible in the index output:

Terminal
$ law engine index /tmp/idlane/certprobe.law --json 2>/dev/null | python3 -c "import json,sys; ds=json.load(sys.stdin)['index']['declarations']; [print(x['name'],x['kind'],x['visibility'],json.dumps(x.get('rename'))) for x in ds]"
Threshold const internal {"complete": true, "preservesStableId": true}

Public names are never renamed by the editor: that would change the dependency contract. Shared-vocabulary renames stay manual and verified — follow the migration procedure. Local extension builds hand rename to the language server, while the published Marketplace build predates that handoff.

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

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