@arxo/law-engine
The Arxo Law engine as WebAssembly: compile .law packages, check them,
and evaluate cases with byte-identical results in Node.js, browsers and
workers. The modules have zero imports — no WASI, no clock, no
environment — so the same request gives the same bytes anywhere.
Which package do you need?
| You want to | Use |
|---|---|
| ask a published canon a question and get the answer with proof | @arxo/law — it wraps this engine, assembles the case and projects the answer |
check or compile .law sources in an editor, CI or the browser | @arxo/law-engine, full module |
| evaluate already compiled packages (CLIR) with the smallest module | @arxo/law-engine, core module |
| only the types of documents and diagnostics | @arxo/law-types |
Quickstart
npm install @arxo/law-engine
Check a package with a typo in it:
import { loadEngine } from '@arxo/law-engine/node';
const engine = await loadEngine();
const source = `language "law.core" version "0.2";
package demo.age version "0.1.0";
namespace "urn:example:demo-age";
pub entity Person;
pub relation age(p: Person, years: Integer) kind empirical;
pub relation adult(p: Person) kind institutional;
rule Adult(p: Person, y: Integer) strict {
when aeg(p, y);
when y >= 18;
then adult(p);
}
`;
const { answer } = engine.call({ op: 'check', path: 'demo.law', files: { 'demo.law': source } });
answer.ok; // false
answer.diagnostics[0].code; // 'LDC-E2102'
answer.diagnostics[0].message // 'predicate "aeg" is not declared as a relation in this file'
answer.diagnostics[0].primarySpan.start; // { line: 10, character: 8, offset: 284 }
Fix aeg → age and compile the package to its canonical intermediate
representation:
const lowered = engine.call({ op: 'lower', path: 'demo.law', files: { 'demo.law': fixed } }).answer;
const ir = JSON.parse(lowered.bytes);
ir.semanticVersion; // '0.2.5'
ir.artifactHash; // 'sha256:e2c51767…'
engine.semanticHash(ir); // the freshness fingerprint
Measured on a developer laptop (Node 20, 2026-10-02): loadEngine() 75 ms,
check of this package 44 ms.
Two modules
| Module | Size | Exports | Use it when |
|---|---|---|---|
law_wasm.wasm (default) | 11.3 MB | evaluator + compiler palette + verbalizer | you parse, check or lower .law sources |
law_wasm_core.wasm | 5.4 MB | evaluator, canonical form, semantic hash, program handles | you evaluate compiled packages |
law_binary_core.wasm | 5.9 MB | evaluator over the compact binary envelope | the optional /binary entry |
Both main modules answer an evaluation request with byte-identical documents. The core module has no compiler at all: the full wrapper refuses a core module at instantiation instead of failing on the first call.
Evaluate
import { loadCoreEngine, REQUEST_SCHEMA } from '@arxo/law-engine/core/node';
const core = await loadCoreEngine();
const program = core.load(ir); // load once
const { document } = program.evaluate({ // evaluate many cases
schemaVersion: REQUEST_SCHEMA, // 'law.core.evaluation-request/0.2'
semanticVersion: ir.semanticVersion,
case: caseDocument,
query,
});
program.dispose();
document is the evaluation document: results, proof graph, issues and
hashes. The request envelope is closed — schemaVersion and
semanticVersion are required, execution options live in case.options.
Building caseDocument by hand is rarely needed: @arxo/law assembles it
from plain facts and checks them against the package first.
Run it anywhere
Browser
import { LawEngine } from '@arxo/law-engine';
const wasmUrl = new URL('@arxo/law-engine/law_wasm.wasm', import.meta.url);
const engine = await LawEngine.instantiate(await fetch(wasmUrl));
The module instantiates with an empty import object; if that ever fails,
the build is wrong — do not stub imports in. When you copy the files
yourself, copy the complete dist directory, including composition.js.
Web worker — off the main thread
import { createWorkerEngine } from '@arxo/law-engine/worker';
const worker = new Worker(new URL('@arxo/law-engine/worker-entry', import.meta.url), { type: 'module' });
const engine = createWorkerEngine(worker, new URL('@arxo/law-engine/law_wasm.wasm', import.meta.url).href);
await engine.init();
const result = await engine.call({ op: 'features' });
worker.terminate();
For the core module: @arxo/law-engine/core/worker-entry and
law_wasm_core.wasm.
Node worker thread
import { Worker } from 'node:worker_threads';
import { createRequire } from 'node:module';
import { createWorkerEngine } from '@arxo/law-engine/worker';
const resolve = createRequire(import.meta.url).resolve;
const worker = new Worker(resolve('@arxo/law-engine/worker-node-entry'));
const engine = createWorkerEngine(worker, resolve('@arxo/law-engine/law_wasm.wasm'));
await engine.init();
worker.terminate();
Node worker entries load wasm from a local path or file: URL as well as
HTTP and data URLs.
A complete package tree — manifest, lock, cases, dependencies
const { answer } = engine.call({
op: 'ask', path: 'pkg', files,
args: {
case: 'Main',
evaluate: 'evaluate truth(demo.rules::eligible("Ada"));',
queryId: 'urn:law:demo:case#case/Main/query/eligible',
},
});
files maps paths to UTF-8 text or { base64: '…' } bytes: the manifest,
law.lock, sources and cases, materialized dependencies and pinned
resources. The engine never installs dependencies or reads the network on
behalf of a compiler request.
API
Compiler palette — full module
engine.call(request) returns { answer, bytes }; bytes are the canonical
form of answer.
op | Does |
|---|---|
parse | syntax tree and syntax diagnostics |
check | all static diagnostics: { ok, diagnostics } |
lower | the compiled package (CLIR) |
layer, features | semantic layer and features a package uses |
expand | the package with templates expanded |
explain, why-not, graph | explanation, blockers and dependency graph of a compiled package |
test | conformance vectors of a package |
ask | evaluate a saved case of a complete package tree |
Engine
| Method | Returns |
|---|---|
LawEngine.instantiate(source) | an engine, from a fetch Response or bytes |
engine.evaluate(request) | { document, bytes } — one evaluation |
engine.load(ir) / engine.loadBytes(bytes) | a program handle: evaluate(request) without ir, then dispose() |
engine.verbalize({ ir, pack, labels? }) | { text, contentHash, gaps, blocks } — review text of a package under a template pack (full module) |
engine.canon(document) | canonical bytes of any JSON document |
engine.semanticHash(document) | the freshness fingerprint |
Failures
Wherever the engine itself answers, a failure is a value:
{ error: { code, message } } or an envelope with ok: false. The wrapper
throws only for misuse and for an explicit host failure, as
HostAdapterError with code and message (preserved across workers).
Match diagnostics on their stable code (LDC-E…), not on the message
text. The loader refuses a module built for another ABI version.
Types
Every document and diagnostic is typed from the normative JSON schemas in
@arxo/law-types, the
package's only dependency, and re-exported here:
import type { EvaluationDocument, Diagnostic } from '@arxo/law-engine';