Skip to content
docs
Arxo ↗

nb-18 — Vocabulary and package composition

For LLMs10 sections
← Course mapChapter 18 / 25 · Advanced II

Northbridge course, foundations branch (needs beginner only: nb-01: First permit: facts, a rule and a question). All law is fictional; every applicant, clerk, counter, slip and coin is synthetic and unofficial. No real municipal deployment or legal-validity claims. Tool law 0.1.0, language version 0.2, semantics law.core/0.2, std 0.2.0 (from law --version, quoted below).

Talgat, the new Northbridge night clerk, inherits a drawer of loose concepts: applicants, counters, desks, shifts, payment slips, a duty roster — and three older packages (vocabulary, permits, calculations) that already define half of them. His task is to assemble one new package, demo.northbridge.composition, that reuses the shared vocabulary instead of redefining it, declares its own local kinds, states which symbols outsiders may touch, and keeps one internal note private. The auditor’s question is blunt: which name means what, who may read it, and what does the machine refuse — and is each refusal a property of our build or of the language itself?

A package is the unit of reuse: a manifest, .law files, a pinned law.lock, and deps/. It carries a namespace, its stable identity, and it reads other packages through imports — but only by qualified names, and only to declarations marked pub.

Inside the new package, each kind of thing gets the declaration that fits it. A shared sort keeps one identity through a type alias, a local nickname for the foreign type. A new sort of thing is an entity, a nominal sort: a Counter is never an Applicant. Fixed menus are enums, and a map wires two enums together as one total table. A record bundles explicitly named fields, and a role names a capacity an actor-subtype holds over an interval. Open-ended values, like payment instruments, live in the Option domain.

Two more pieces complete the picture. A pub facts group asserts package-level facts that hold in the package’s own world without any test given. And truth statuses work as in nb-01: First permit: facts, a rule and a question: TRUE_ONLY means established, NEITHER means established neither way.

nb-01: First permit: facts, a rule and a question: facts, strict rules, the four truth statuses, and law test as the way to check a claim.

New here: type X = pkg::Y (alias), pub / private visibility, and use self::{...} — the per-file declaration of cross-file names that an explicit package demands. You will also meet two refusal codes: LDC-E1105 (a read of a non-exported symbol is refused) and LDC-E1116 (an undeclared cross-file reference is refused). Both are implementation refusals of this build, not language-wide inability claims.

Excerpts 1–5 are from packs/examples/language-demo/composition/package.law (identifiers as written; rules cut where noted). Standalone A is the complete second file of the package, desk.law. Standalone B — the complete scratch consumer — appears once, as the replay heredoc in §4, and is not repeated here.

Excerpt 1 — header, import and aliases (lines 4–11). One idea: language, name, namespace, dependency, two local nicknames.

Arxo Law
language "law.core" version "0.2";
package demo.northbridge.composition version "0.1.0";
namespace "urn:law:demo:northbridge:composition";
import demo.northbridge.vocabulary version "0.1.0";
type Applicant = demo.northbridge.vocabulary::Applicant;
type Zone = demo.northbridge.vocabulary::Zone;

Look at the import line first: it names the dependency and pins its version. The two type lines then give those foreign sorts short local names. Inside this package, Applicant is vocabulary’s applicant — one identity, not a copy.

Excerpt 2 — entity, enums and map (lines 13–28). One idea: a new nominal sort plus two closed menus wired by a total table.

Arxo Law
pub entity Counter;
pub enum Desk {
open;
busy;
}
pub enum Shift {
day;
night;
}
pub map DeskShift : Desk -> Shift {
open => day;
busy => night;
}

Counter is a fresh nominal sort: no applicant fact can ever fill a counter position. The two enums list every member up front — Desk has open and busy, nothing else — and the map routes each desk state to exactly one shift. Every arm is visible in one table.

Excerpt 3 — record, role and Option (lines 30–39). One idea: named fields, an actor-subtype capacity, one named coin.

Arxo Law
pub record Slip {
who: Text;
months: Integer;
}
entity Clerk : Actor;
role OnDuty for Clerk;
const Cash: Option = entity_ref("urn:demo:northbridge:cash");

The record names both its fields — who and months — and section 7 shows what happens when one goes missing. Clerk is declared as a subtype of Actor, and the role OnDuty names a capacity a clerk can hold. Cash is one named inhabitant of the open Option domain: unlike an enum, the domain stays open to further values.

Excerpt 4 — the visibility split (lines 49–54). One idea: pub surface and one exported group against a single private note.

Arxo Law
pub relation resident_seen(a: Applicant) kind institutional;
relation internal_note(a: Applicant) kind institutional;
pub facts CounterFacts {
assert "c-desk-1": counter_open(entity_ref("urn:demo:northbridge:desk-1")) { origin case_input; }
}

Compare the two relations: resident_seen is pub, while internal_note has no marker and stays private. Section 4 shows the machine enforcing that split. The pub facts group asserts that desk-1’s counter is open — a fact the package’s own world sees without any test supplying it.

Excerpt 5 — consumer rule over the import (lines 56–62). One idea: qualified vocabulary reads joined with the exported fact.

Arxo Law
rule CounterReady strict {
for a: Applicant;
for c: Counter;
when demo.northbridge.vocabulary::resident(a) and counter_open(c);
then counter_ready(a, c);
}

The when line joins two worlds: a fully qualified read of vocabulary’s resident and the local counter_open fed by the facts group above. Because the alias keeps one applicant identity, both sides join on the same a.

Standalone A — full content of desk.law (20 lines): the second file. One idea: every borrowed name declared up front.

Arxo Law
language "law.core" version "0.2";
package demo.northbridge.composition version "0.1.0";
namespace "urn:law:demo:northbridge:composition";
use self::{
Desk,
DeskShift,
Shift,
};
relation shift_posted(d: Desk) kind empirical;
relation shift_ok() kind institutional;
rule ShiftOk strict {
for d: Desk;
when shift_posted(d) and DeskShift(d) == day;
then shift_ok();
}

The use self block at the top is the point of this file: every name borrowed from package.law — Desk, DeskShift, Shift — is declared up front. The rule then reads the map as a term, comparing DeskShift(d) against day. Section 4 shows what happens when one borrowed name is missing from that list.

Run the package suite:

Terminal
law test packs/examples/language-demo/composition

Observed (tail; every one of the 10 tests reports ok above it — consumer use, silence, record pair, map pair, role pair, Option pair):

Output
итого: 10 проверено, 10 прошли, 0 не прошли, 0 не исполнены; код 0

All 10 tests pass. The summary is in Russian: 10 checked, 10 passed, 0 failed, 0 skipped, exit code 0. Each pair covers one construct from section 3 — records, the map, the role, and Option — plus the consumer rule over the import and a silence case. A passing test means the answer matched its expectation; it does not mean the roster is fair or the coin real.

Check the package statically and confirm its borrow lists are in canonical form:

Terminal
law engine check packs/examples/language-demo/composition

Observed: check OK: packs/examples/language-demo/composition.

Terminal
law fix imports ./packs/examples/language-demo/composition

Observed:

Output
./packs/examples/language-demo/composition
блоки `use self` канонические

The static check passes, and the second verdict — in Russian, “use self blocks are canonical” — means the borrow lists need no rewriting: desk.law declares exactly the names it borrows, in the expected form.

Record the pinned profile behind every verdict above:

Terminal
law --version

Observed:

Output
law 0.1.0
семантика: law.core/0.2
std для языка 0.2: 0.2.0
хэш бинаря: sha256:78dea06ce928547e87bd9875a2556cc663def37cbb78c8b04aa0da57839c95d7

The Russian lines name the semantics (law.core/0.2), the standard library for language 0.2 (0.2.0), and the binary hash. Every status in this article holds for exactly this build.

The use self file-block rule is a whole-run refusal: one missing declaration fails the entire run, not just one test. Append a probe rule that uses two undeclared names to desk.law, run, then revert, so the repo file is untouched afterwards:

Terminal
cp packs/examples/language-demo/composition/desk.law /tmp/nb18-desk.bak
Terminal
cat >> packs/examples/language-demo/composition/desk.law <<'EOF'
relation probe_e1116() kind institutional;
rule ProbeE1116 strict {
for a: Applicant;
when resident_seen(a);
then probe_e1116();
}
EOF
Terminal
law test packs/examples/language-demo/composition

Observed (frozen as packs/examples/language-demo/composition/evidence/ev-use-self.txt):

Output
law test: ОТКАЗ LDC-E1116: /Users/rifatjumagulov/Downloads/law-dsl/packs/examples/language-demo/composition/desk.law:26:12: error LDC-E1116: `Applicant` объявлена в файле /Users/rifatjumagulov/Downloads/law-dsl/packs/examples/language-demo/composition/package.law и не заявлена в `use self` файла /Users/rifatjumagulov/Downloads/law-dsl/packs/examples/language-demo/composition/desk.law: в пакете с `local_imports = "explicit"` ссылка на декларацию другого файла заявляется (§23.2)
Terminal
cp /tmp/nb18-desk.bak packs/examples/language-demo/composition/desk.law
Terminal
law test packs/examples/language-demo/composition

Observed: 10 проверено, 10 прошли, 0 не прошли, exit 0 — the revert is byte-identical and the suite is green again. The refusal above names Applicant, the first undeclared name (resident_seen is undeclared too): one gap fails the whole run, and the diagnostic quotes only the first gap it meets. In Russian, it says that Applicant is declared in package.law but not in the use self block of desk.law, which an explicit package requires (§23.2).

The second refusal guards the package boundary: a private symbol cannot be read from outside. Replay it with a scratch consumer in /tmp; the repo tree stays untouched. First the three files — manifest, Standalone B (the complete consumer package.law: a foreign package importing composition and reaching for its private note), and a one-probe suite:

Terminal
rm -rf /tmp/nb18-consumer && mkdir -p /tmp/nb18-consumer/tests /tmp/nb18-consumer/deps && cat > /tmp/nb18-consumer/law.toml <<'EOF'
[package]
name = "probe.consumer"
version = "0.1.0"
language = "0.2"
namespace = "urn:probe:consumer"
[dependencies]
"demo.northbridge.composition" = "0.1.0"
"demo.northbridge.vocabulary" = "0.1.0"
[features]
semanticLayer = "L1"
[authoring]
local_imports = "explicit"
[[tests]]
family = "probe.consumer"
suites = ["tests/consumer.lawtest"]
world = ["probe.consumer", "demo.northbridge.composition", "demo.northbridge.vocabulary"]
EOF
cat > /tmp/nb18-consumer/package.law <<'EOF'
language "law.core" version "0.2";
package probe.consumer version "0.1.0";
namespace "urn:probe:consumer";
import demo.northbridge.composition version "0.1.0";
relation probe_hit() kind institutional;
rule ProbeHit strict {
when demo.northbridge.composition::internal_note(entity_ref("urn:demo:northbridge:ann"));
then probe_hit();
}
EOF
cat > /tmp/nb18-consumer/tests/consumer.lawtest <<'EOF'
language "law.core" version "0.2";
package probe.consumer version "0.1.0";
namespace "urn:probe:consumer";
test "probe" {
given {
context { legal_time @2026-03-01; decision_time @2026-03-01T09:00:00Z; knowledge_time @2026-03-01T09:00:00Z; timezone "UTC"; }
}
evaluate truth(probe_hit());
expect truth_status == NEITHER;
}
EOF

Then pin its world with the repo tooling (run from the repo root; only the Python standard library plus the repo’s own resolver modules — no new files):

Terminal
python3 - <<'PYEOF'
import importlib.util, sys, tomllib
from pathlib import Path
ROOT = Path.cwd()
sys.path.insert(0, str(ROOT / "engines" / "lawref"))
sys.path.insert(0, str(ROOT / "corpus"))
spec = importlib.util.spec_from_file_location("check_primitives", ROOT / "verify/ci/gates/differential/check_primitives.py")
CP = importlib.util.module_from_spec(spec)
spec.loader.exec_module(CP)
from lawref import resolver
from lawref.canon import canonical_bytes
DEMO = ROOT / "packs" / "examples" / "language-demo"
def roots():
got = CP.package_dirs(ROOT / "packs" / "primitives") + CP.package_dirs(ROOT / "packs" / "units")
r = {CP.package_name(p): p for p in got}
for toml in DEMO.glob("*/law.toml"):
r[tomllib.loads(toml.read_text(encoding="utf-8"))["package"]["name"]] = toml.parent
return r
CP.package_roots_by_name = roots
CP.dependency_roots_by_name = roots
binary = ROOT / "engines" / "lawc" / "target" / "gate" / "law-cli"
consumer = Path("/tmp/nb18-consumer")
manifest = resolver.parse_manifest((consumer / "law.toml").read_bytes())
deps = CP.consumer_deps(binary, consumer)
assert deps is not None, CP.FAILURES
registry = {(ir["package"]["name"], ir["package"]["version"]): resolver.content_descriptor(ir) for ir in deps.values()}
lock = resolver.generate_lock(manifest, registry, resources=resolver.discover_resources(consumer))
(consumer / "law.lock").write_bytes(canonical_bytes(lock, document=False) + b"\n")
for dep_name, ir in deps.items():
(consumer / "deps" / f"{dep_name}.lawir.json").write_bytes(canonical_bytes(ir, document=False))
print("consumer lock+deps regenerated")
PYEOF

Then:

Terminal
law test /tmp/nb18-consumer

Observed (frozen as packs/examples/language-demo/composition/evidence/ev-private.txt):

Output
law test: ОТКАЗ LDC-E1105: /private/tmp/nb18-consumer/package.law:10:10: error LDC-E1105: demo.northbridge.composition::internal_note: символ не экспортирован пакетом (§24: межпакетные ссылки — только на `pub`-декларации; экспортировано: 19 символов)

Exit code 2. In Russian, the refusal says that demo.northbridge.composition::internal_note is not exported by its package: cross-package references may only reach pub declarations (§24), and the package exports 19 symbols. The probe never runs — the read is refused before evaluation.

The same consumer rewritten over the pub counter_open builds and passes 1/1, which proves the boundary is the only gate. But note what its probe answers: NEITHER. The exported CounterFacts feed the composition suite world — the first suite test asserts no counter fact in given and still sees it — not the foreign consumer world. Exported facts travel with their own world; visibility lets the consumer read the relation, not the facts.

What the lock-pinning step does

The Python step regenerates the scratch consumer’s law.lock and deps/ with the repository’s own resolver modules, so the consumer’s world is pinned exactly like a real package build. It prints consumer lock+deps regenerated and writes only under /tmp/nb18-consumer. The refusal itself comes from law test, not from the pinning.

Which name means what. Talgat’s drawer stops being loose concepts once each declaration is read for what it says. Counter is an entity, so no Applicant fact can ever fill a counter position — the no resident, no readiness test stays NEITHER on the counter side alone. Desk and Shift are closed menus: open and busy, nothing else. Slip carries its fields explicitly (who, months). And Cash is one named inhabitant of the open Option domain, which stays open to further values.

Why an alias. Applicant inside composition is vocabulary’s applicant, so resident(a) and counter_ready(a, c) join on the same a and the consumer test answers TRUE_ONLY. Redefining the sort locally would compile but silently fork identity: vocabulary facts would never join with composition rules, and no refusal would warn you.

Why a map. One total table replaces a rule per arm. ShiftOk reads DeskShift as a term instead of branching on desk states. Two members work today; twenty would rot as rules, while the map keeps every arm in one place.

Why a role. The person is not the capacity. Filing a roster entry alone yields nothing; with has_role(... OnDuty ...) the rule fires. The role test pair pins both halves.

Why pub. Nineteen symbols are touchable from outside, and internal_note is not one of them: it is derived inside the package but invisible across the boundary (LDC-E1105 above), while the counter_open mirror over a pub relation builds and passes.

Why use self. desk.law borrows three names and declares three; the fourth, undeclared name fails the whole run (LDC-E1116). Merging both files into one would dodge the borrow list — and the guarantee with it.

What proves all this: the 10/10 suite, check OK, the canonical borrow-list verdict, and the two quoted refusals — each executed in section 4. What is not proven: that the roster is fair or the coin real. The machine proves which names resolve and which reads are allowed; policy stays with the office, and the office is fiction.

Change one token in the desk test’s given: shift_posted(open) becomes shift_posted(busy). The rule is the same, the map is the same — but shift_ok() flips from TRUE_ONLY to NEITHER with COMPUTED. Both suite tests pass in section 4, so both answers are pinned.

Why the flip? ShiftOk requires DeskShift(d) == day, and the map routes busy to night. A total map leaves no member unrouted: busy is decisively not a day shift, so the negative case is as decisive as the positive one. The answer is silence (NEITHER), not denial — no rule derives not shift_ok().

The mistake is dropping a record field and trusting the rest to carry the meaning. Take the suite’s record assert and remove months: 12:

Sketch — the mutated line for a /tmp scratch copy of the suite (repo untouched); observed FAIL with TYPE_ERROR, exit 1, quoted below (reproduced 2026-10-02).

Arxo Law
assert "s1": filed_slip(Slip { who: "ann" }) { origin case_input; }

This failing result is the expected demonstration: run the mutated line on a scratch copy in /tmp (repo suite untouched) and the record test FAILs — truth_status is NEITHER, but evaluation_status is TYPE_ERROR, not COMPUTED, and the suite exits 1.

An incomplete Slip is ill-typed at evaluation, so the rule never opens: the assert contributes no usable record. The fix is to name every field, always — a record literal with all fields is well-typed, and only then can the rule read it.

Verified profile only. Every status above holds for tool law 0.1.0, language 0.2, semantics law.core/0.2, std 0.2.0. Newer builds and other versions have their own visibility and authoring rules; re-run, do not assume.

Refusals are profile facts. E1105 (only pub crosses a package boundary) and E1116 (only declared names cross a file boundary in an explicit package) describe this build’s §23–§24 enforcement. Neither states that private notes or undeclared borrows are inexpressible in general.

Exported facts feed their own world. CounterFacts fires the suite with no given, while the foreign probe stays NEITHER — an observed boundary of this profile, not a cross-version rule.

E1116 names the first gap (Applicant at 26:12), not every gap. Fix one, re-run, and read the next diagnostic.

Predict each answer without running the engine, then check with the commands from section 4:

  1. counter_ready(ann, desk-1) with resident(ann) asserted but the CounterFacts group removed: which truth status, and which half of the CounterReady conjunction fails?
  2. shift_posted(open) asserted twice (two identical asserts): does shift_ok() change status? What does that say about facts versus derivations?
  3. The E1116 probe fixed by adding Applicant to use self but leaving resident_seen undeclared: pass or refuse, and which name does the next diagnostic quote?
  4. The consumer probe rewritten over demo.northbridge.composition::resident_seen (a pub relation): refuse or build? And if it builds, what makes its rule fire?
  5. Slip { who: "ann", months: "12" } (text instead of integer): parse refusal, TYPE_ERROR, or silent NEITHER — and which of the three would still exit the suite nonzero?

Write down each prediction first; run the commands; explain any miss in one sentence. Check your work against the full solution with replayed commands.

  • Source: packs/examples/language-demo/composition/package.law (aliases, entity, enums, map, record, role, Option const, visibility split, exported facts, consumer rule) and packs/examples/language-demo/composition/desk.law (two-file explicit package, use self borrow list, map-reading rule)
  • Tests: packs/examples/language-demo/composition/tests/composition.lawtest (10 tests: consumer use, silence, record pair, map pair, role pair, Option pair)
  • Manifest: packs/examples/language-demo/composition/law.toml (local_imports = "explicit", vocabulary dependency), law.lock + deps/ (pinned world)
  • Refusal exhibits: packs/examples/language-demo/composition/evidence/ev-use-self.txt (E1116) and evidence/ev-private.txt (E1105); package tour in packs/examples/language-demo/composition/README.md
  • Prerequisite: nb-01: First permit: facts, a rule and a question

Three levels:

  1. Northbridge use (this article): the shared applicant is aliased, desks are routed by a total table, slips are fully named, and one note stays private — verified by the 10/10 suite plus the two quoted refusals.
  2. Domain template: alias shared sorts instead of redefining them; close menus as enums and route them with total maps; name every record field; mark the touchable surface pub; declare cross-file borrows; freeze every refusal with its code; never read a profile refusal as language-wide inability.
  3. Confirmed example elsewhere: the same use self rule guards boundaries (tariff.law, see boundaries/evidence/ev-use-enforcement.txt), and the same §24 boundary fires in the vectors (ERR-E1105-HIDDEN-NAME-02) — package-system-wide, not one roster’s quirk.
  4. Confirmed external formalization (corpus): budget-linked benefit parameters — the Kazakhstan Social Code reads the monthly calculation index from the budget package. Package kz.corpus.socialcode (imports kz.corpus.budget), corpus/laws/kz/codes/social-code/00-package.law:4987: when kz.corpus.budget::mrp(2026, amount) — live cross-package composition where the Code consumes another package’s predicate (import rationale in the comment at line 4958), with intra-package composition via use self::{...} blocks and law.toml deps. Evidence: package header verified by inspection (no construct note covers imports). Limit of verification: presence of the named construct at the cited line only, confirmed by direct file read; no claim about deployment, runtime behaviour, or legal correctness.

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

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