# nb-18 — Vocabulary and package composition *Northbridge course, foundations branch (needs beginner only: [nb-01: First permit: facts, a rule and a question](/tutorials/northbridge/nb-01-first-permit/)). 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).* ## 1. Situation 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](/tutorials/northbridge/nb-01-first-permit/): `TRUE_ONLY` means established, `NEITHER` means established neither way. ## 2. Prerequisites [nb-01: First permit: facts, a rule and a question](/tutorials/northbridge/nb-01-first-permit/): 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. ## 3. Minimal example 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](/tutorials/northbridge/nb-01-first-permit/#4-command-and-result), and is not repeated here. Excerpt 1 — header, import and aliases (lines 4–11). One idea: language, name, namespace, dependency, two local nicknames. ```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. ```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. ```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. ```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. ```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. ```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. ## 4. Command and result Run the package suite: ```sh 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): ```text итого: 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: ```sh law engine check packs/examples/language-demo/composition ``` Observed: `check OK: packs/examples/language-demo/composition`. ```sh law fix imports ./packs/examples/language-demo/composition ``` Observed: ```text ./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: ```sh law --version ``` Observed: ```text 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: ```sh cp packs/examples/language-demo/composition/desk.law /tmp/nb18-desk.bak ``` ```sh 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 ``` ```sh law test packs/examples/language-demo/composition ``` Observed (frozen as `packs/examples/language-demo/composition/evidence/ev-use-self.txt`): ```text 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) ``` ```sh cp /tmp/nb18-desk.bak packs/examples/language-demo/composition/desk.law ``` ```sh 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](https://github.com/arxohq/law/blob/master/spec/SPEC.ru/06-part-vi-package-and-module-system.ru.md#232-внутрипакетные-зависимости-файла-аддитивно-decision-0386)). 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: ```sh 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): ```sh 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: ```sh law test /tmp/nb18-consumer ``` Observed (frozen as `packs/examples/language-demo/composition/evidence/ev-private.txt`): ```text 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](https://github.com/arxohq/law/blob/master/spec/SPEC.ru/06-part-vi-package-and-module-system.ru.md#24-visibility)), 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.
## 5. Why this construct **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. ## 6. Changed condition 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()`. ## 7. Typical mistake 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). ```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. ## 8. Limits **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](https://github.com/arxohq/law/blob/master/spec/SPEC.ru/06-part-vi-package-and-module-system.ru.md#23-import)–[§24](https://github.com/arxohq/law/blob/master/spec/SPEC.ru/06-part-vi-package-and-module-system.ru.md#24-visibility) 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. ## 9. Exercise 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](/tutorials/northbridge/solutions/nb-18-solutions/). ## 10. Sources - 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](/tutorials/northbridge/nb-01-first-permit/) 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](https://github.com/arxohq/law/blob/master/spec/SPEC.ru/06-part-vi-package-and-module-system.ru.md#24-visibility) 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.