Composing Specs
Once a Spec can reference its own Data and Rules, it can import another Spec with Uses and reference its members through an alias. This chapter covers composition, temporal versions, planning checks, and (last) repositories and registry imports for advanced usage.
For syntax details see Using other Specs.
Importing another Spec
A Lemma source file defines one or more Specs. Each Spec is a namespace of Data and Rules. With Uses, you import another Spec under an alias and refer to its members through that alias. Read a value with alias.field, use a dependency Rule with alias.rule_name, and reuse an imported Data declaration as a parent type: data country: iso.code.
spec premium_membership
data discount_rate: 10%
rule free_shipping_threshold: 50
rule monthly_bonus_points: 100
spec membership_benefits
uses membership: premium_membership
data monthly_spend: 150
rule discount: monthly_spend * membership.discount_rate
rule bonus_points: membership.monthly_bonus_points
uses membership: premium_membershipbinds aliasmembershipto Specpremium_membership.uses premium_membershipuses an implicit alias: last path segment of the target name.membership.discount_ratereads Data from the imported Spec.membership.monthly_bonus_pointsuses a Rule from the imported Spec.
A minimal two-Spec example:
spec base_employee
data name: "John Doe"
data salary: 5000
spec manager
uses employee: base_employee
with employee.name: "Alice Smith"
with employee.salary: 8000
rule manager_bonus: employee.salary * 0.15
uses alias: target imports a Spec under an explicit alias; a bare uses target defaults the alias to the last path segment. Read Data and Rules as alias.field or alias.rule_name.
Setting Data on an import (With)
Uses registers an import; it does not set runtime values on the dependency. Use with alias.field: … to assign a literal or reference to a Data slot declared on the imported Spec:
spec inner
data x: number
-> suggest 1
spec outer
uses i: inner
with i.x: 42
rule r: i.x
with i.x: 42sets Dataxoninnerto42.with i.x: iis an error:iis a Spec reference, not a value.with copy: i.xis a parse error: With must use an import path on the left (with i.x: …), not a local name.
The left-hand side of With must be an import path; local slots use Data. Runtime inputs to run can still override bound import paths (e.g. i.x) where planning allows.
See Setting Data on an imported Spec.
Temporal versions
The same Spec name may appear several times with different effective datetimes (spec Name 2025-05-01). Each declaration is immutable; you add a new row on the timeline instead of editing history in place.
spec pricing
data base_price: 20
data quantity: number
rule total: base_price * quantity
spec pricing 2025-01-01
data base_price: 25
data quantity: number
rule total: base_price * quantity
Run with different effective dates:
lemma run pricing --effective 2024-06-01 # uses base_price: 20
lemma run pricing --effective 2025-06-01 # uses base_price: 25
Which row applies at evaluation time is determined by the --effective instant (CLI) or Accept-Datetime (HTTP) for the Spec you run.
Unpinned vs pinned Uses
| Form | Meaning |
|---|---|
uses dep |
Unpinned. Every temporal version of dep whose range intersects this Spec's range can matter. Planning may split this Spec into temporal slices at dependency effective_from boundaries. Values resolved through the import can change when dep gains a new row. |
uses dep 2025-06-01 |
Pinned to an instant. One body of dep active at that datetime, including its transitive imports. Later rows of dep do not affect this edge. |
An unpinned import (uses pricing) follows the dependency's timeline; a pinned import (uses p: pricing 2025-06-01) freezes the dependency at that instant.
Unpinned: values follow the timeline
spec policy
rule discount: 10
spec policy 2025-05-01
rule discount: 25
spec shop 2025-01-01
uses p: policy
rule d: p.discount
Evaluating shop in early 2025 yields d = 10; from May 2025 onward, d = 25. The Uses line is unchanged; the resolved policy body changes at the slice boundary.
A consumer with no effective date on its Spec line (origin) still gets slices when a dependency's effective_from falls inside its range.
Pinned: freeze a dependency at one instant
spec finance
data money: measure
-> unit eur 1.00
spec finance 2025-07-01
data money: measure
-> unit eur 1.00
-> unit usd 0.91
spec shop 2025-01-01
uses f: finance 2025-02-01
data money: f.money
data price: money
rule doubled: price * 2
shop keeps the February 2025 finance shape (EUR only) even when you evaluate in September 2025. Pinning locks a tariff or regulation snapshot into the consumer.
Planning checks
Before run, planning validates the dependency graph. Two temporal failures authors see most often:
Temporal coverage (gaps)
Unpinned uses dep requires dep to exist for every instant in the consumer's temporal range. If the consumer starts in January but dep only exists from July, planning fails (no active version / not active at that instant).
Fix: add an earlier spec dep row, move the consumer's effective_from later, or pin uses dep 2025-08-01 so the consumer no longer requires dep to cover its whole range.
Interface compatibility (contract changes)
When unpinned imports span multiple rows of the same dependency, every row the consumer touches must expose compatible types for the same names (Rule result types, Data types, compatible measure units, etc.). New names only in a later row are fine if the consumer does not use them.
Incompatible example: money gains a usd unit in a later finance row while shop still has unpinned uses finance and data price: finance.money. Planning reports that the dependency changed its interface between temporal slices.
Fix: pin uses f: finance 2025-02-01, or add a new temporal row of shop written for the new finance body.
Coverage is presence on the timeline; interface validation is type sameness across slices the consumer needs.
Same name, different bodies
You may import an earlier temporal row that shares your base name:
spec finance 2026-01-01
data rate: 1
spec finance 2027-01-01
uses f26: finance 2026-01-01
rule ok: f26.rate
Planning rejects a Uses edge that resolves to the same Spec body (self-reference):
spec finance
uses finance
spec finance 2026-01-01
uses finance 2026-01-01
A later row must not use unpinned uses finance when that resolves to itself at that row's instant. Pin an earlier row (uses f26: finance 2026-01-01) instead.
Cycles across temporal rows (2026 → 2027 → 2026) are rejected as dependency cycles.
Repositories
When Specs in the same workspace share a name, Repo blocks namespace them so they do not collide. Cross-repo targets use a repo qualifier on the Uses line:
repo accounting
spec invoice
data total: 1
spec billing
uses inv: accounting invoice
rule out: inv.total
Cross-repo targets use a repo qualifier on the Uses line (accounting invoice). When you run a Spec from the workspace (main) repository, use its unqualified name; the CLI does not pick between two loaded Specs with the same name in different repos.
Most workspaces never need Repo blocks: files without one belong to the implicit workspace repository. Registry dependencies live in their own @owner/name repositories (see Registry below).
Registry
When Specs live outside your workspace, import them from a registry with @owner/repo qualifiers:
spec invoicing
uses @iso/countries alpha2
data price: measure
-> unit eur 1
data country: alpha2.code
rule tariff: 0 eur
unless country is "NL" then price * 5%
rule total: price + tariff
Fetch dependencies before running:
lemma fetch --all # fetch all @... dependencies into lemma_deps/
lemma fetch @iso/countries -f # force re-fetch if content changed
Importing from a registry uses @owner/name on the uses line (for example uses iso: @iso/countries alpha2). The engine does not fetch the network; load sources with lemma fetch (or your embedder) first. See Registry.
Evaluating a composed Spec
You always run (or call the API for) a named root Spec in a repository, with an effective instant:
lemma run membership_benefits --effective 2025-03-01
- The engine selects the consumer's temporal slice that contains that instant.
- Unpinned imports: paths such as
p.discountuse dependency bodies resolved for that slice. - Pinned imports: the dependency body stays at the pinned instant.
To inspect the static interface for that temporal slice (types, constraints, rules after normalize):
lemma show membership_benefits --effective 2025-03-01
Overlay-aware discovery of what a concrete run still needs comes from each rule's missing_data, not from show.
Quick decision guide
| Goal | Pattern |
|---|---|
| Track compatible dependency rows across the consumer's lifetime | uses dep (unpinned) |
| Lock a regulation / tariff / spec version at a known date | uses dep 2025-06-01 |
| Import an earlier row of the same Spec name | uses prev: finance 2026-01-01 |
| Reuse a Data shape from a library | uses iso: @iso/countries alpha2 and data x: iso.code |
| Set Data on an imported Spec | with alias.field: value |
| Read import Data in a Rule | rule r: alias.field |
If planning fails, check whether the message is coverage, interface, self-reference, or cycle. Each remedy is different (see above).
Next up
Numeric precision: exact rational arithmetic, limits, and client parsing.