docs← Back to article

Markdown for LLMs

nb-18 — Vocabulary and package composition

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

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

<details>
<summary>What the lock-pinning step does</summary>

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.

</details>

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