Lace / docs

Your first interlace

This tutorial demonstrates Lace’s central operation: two participants converge the checked records selected by an Interlang policy. Two records move, one valid but unrelated record stays behind, and public Lace operations verify the result.

Both participants start in memory so the boundary is easy to inspect. A local or connected participant occupies the same interlace argument; the final section shows how the second participant becomes a remote endpoint.

Runtime and policy have different jobs

Concern Responsibility here
Runtime Own participants and stores, construct and store validated records, drive interlace, and list the result.
Policy Select checked records for convergence and determine which local records peer-authored rules may inspect.

Runtime choice answers where records live and how participants meet. Policy selects checked records for convergence. Changing language, store, or transport does not require another policy model.

Start with the shared policy

The policy selects records under one Group/App/Name shape:

converge {
  Item in //first-interlace//demo/item//selected/{*} => include Item
}

It matches records whose fields are:

Group = first-interlace
App   = demo/item
Name  = selected/<one or more path segments>

converge contributes those records to active exchange and exposes those same checked local records to peer-origin rules for this interlace. Exposure permits inspection of record facts; it does not itself copy a record. A record moves only where both participants’ policy operands select it and the receiver validates its bytes.

Interlang is the normal human authoring surface. Lace lowers it to canonical Datalog. Raw Datalog is the advanced layer for lower-level behavior that Interlang cannot express; it is not a prerequisite here.

The runnable companions load this exact text from policy/items.interlang.

Run the same operation in your language

Clone the source checkout first:

git clone https://codeberg.org/rs0/lace.git
cd lace

Choose one panel. Each complete program creates the same records, stores them on the source participant, runs bounded interlace, and checks both the selected and unrelated destination views.

JavaScript

Build the source-checkout JS/WASM bundle, then run the companion:

rustup target add wasm32-unknown-unknown
npm --prefix js/crates/lace-js run build
node examples/first-interlace/js/first-interlace.mjs
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';

import {
  Lace,
  PolicyAuthoringSource,
  Record,
  boundedInterlace,
  list,
  store,
} from '../../../js/crates/lace-js/dist/lace.bundle.js';

const TAI = '0000000001:000000000';
const policy = PolicyAuthoringSource.interlang(
  (await readFile(new URL('../policy/items.interlang', import.meta.url), 'utf8')).trim(),
);
const unrelatedPolicy = PolicyAuthoringSource.interlang(`converge {
  Item in //first-interlace//demo/item//unrelated/{*} => include Item
}`);

const item = (name, data) => Record.create({
  group: 'first-interlace',
  app: 'demo/item',
  name,
  tai: TAI,
  data,
});

const source = await Lace.memory();
const destination = await Lace.memory();
try {
  const first = await item('selected/one', 'first selected record');
  const second = await item('selected/two', 'second selected record');
  const unrelated = await item('unrelated/not-selected', 'this record must stay at the source');

  const stored = await store(source, [first, second, unrelated]);
  console.log(`source stored: ${stored.records.length}`);

  const outcome = await boundedInterlace(source, destination, { policy });
  assert.equal(outcome.terminal.type, 'complete');
  console.log('interlace outcome: complete');

  const selected = await list(destination, policy);
  const selectedNames = selected.records.map(record => record.name().text()).sort();
  assert.deepEqual(selectedNames, ['selected/one', 'selected/two']);
  console.log(`destination selected: ${selectedNames.join(', ')}`);

  const unrelatedResult = await list(destination, unrelatedPolicy);
  assert.equal(unrelatedResult.records.length, 0);
  console.log(`destination unrelated: ${unrelatedResult.records.length}`);
} finally {
  await Promise.all([source.close(), destination.close()]);
}

The JavaScript package lazily initializes one shared WASM runtime per JavaScript realm, so independently opened local Laces can participate in one direct in-process interlace.

Runnable source: js/first-interlace.mjs.

Python

Build the repository-local extension in its uv environment, then run the companion:

uv --directory python sync --extra test
uv --directory python run --with 'maturin>=1.7,<2' maturin develop
uv --directory python run python ../examples/first-interlace/python/first_interlace.py
from pathlib import Path

from lace import Complete, Lace, PolicyAuthoringSource, Record, bounded_interlace, list_records, store

TAI = "0000000001:000000000"
POLICY_PATH = Path(__file__).resolve().parent.parent / "policy" / "items.interlang"
UNRELATED_POLICY = (
    "converge {\n"
    "  Item in //first-interlace//demo/item//unrelated/{*} => include Item\n"
    "}"
)


def item(name: str, data: bytes) -> Record:
    return Record.create(
        group="first-interlace",
        app="demo/item",
        name=name,
        tai=TAI,
        data=data,
    )


first = item("selected/one", b"first selected record")
second = item("selected/two", b"second selected record")
unrelated = item("unrelated/not-selected", b"this record must stay at the source")

with Lace.memory() as source, Lace.memory() as destination:
    stored = store(source, [first, second, unrelated])
    assert isinstance(stored.terminal, Complete)
    print(f"source stored: {len(stored.records)}")

    policy = PolicyAuthoringSource.interlang(POLICY_PATH.read_text().strip())

    outcome = bounded_interlace(source, destination, policy)
    assert isinstance(outcome.terminal, Complete)
    print("interlace outcome: complete")

    selected = list_records(destination, policy)
    selected_names = sorted(record.name for record in selected.records)
    assert selected_names == ["selected/one", "selected/two"]
    print(f"destination selected: {', '.join(selected_names)}")

    unrelated_policy = PolicyAuthoringSource.interlang(UNRELATED_POLICY)
    unrelated_list = list_records(destination, unrelated_policy)
    assert isinstance(unrelated_list.terminal, Complete)
    assert not unrelated_list.records
    print(f"destination unrelated: {len(unrelated_list.records)}")

Runnable source: python/first_interlace.py.

Rust

The Rust companion uses the synchronous lace-single runtime and requires no daemon or network:

cargo run --manifest-path examples/first-interlace/Cargo.toml
use lace_single::prelude::*;

const TAI: &str = "0000000001:000000000";
const POLICY: &str = include_str!("../policy/items.interlang");
const UNRELATED_POLICY: &str = r#"
converge {
  Item in //first-interlace//demo/item//unrelated/{*} => include Item
}
"#;

fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
    let source = Lace::empty_memory();
    let destination = Lace::empty_memory();

    let item = spec!({
        group: "first-interlace",
        app: "demo/item",
        tai: TAI,
    });

    let first = record!(item, {
        name: "selected/one",
        data: "first selected record",
    })?;
    let second = record!(item, {
        name: "selected/two",
        data: "second selected record",
    })?;
    let unrelated = record!(item, {
        name: "unrelated/not-selected",
        data: "this record must stay at the source",
    })?;

    let stored = store(source.handle(), [first, second, unrelated])?;
    println!("source stored: {}", stored.records.len());

    let policy = PolicyAuthoringSource::interlang(POLICY.trim());
    let outcome = bounded_interlace(
        source.handle(),
        destination.handle(),
        policy.clone(),
    )?;
    assert_eq!(outcome.terminal, Terminal::Complete);
    println!("interlace outcome: complete");

    let selected = list(destination.handle(), policy)?;
    let mut selected_names = selected
        .records
        .iter()
        .map(|record| record.name().expect("selected records have names").to_string())
        .collect::<Vec<_>>();
    selected_names.sort();
    assert_eq!(selected_names, ["selected/one", "selected/two"]);
    println!("destination selected: {}", selected_names.join(", "));

    let unrelated = list(
        destination.handle(),
        PolicyAuthoringSource::interlang(UNRELATED_POLICY.trim()),
    )?;
    assert!(unrelated.records.is_empty());
    println!("destination unrelated: {}", unrelated.records.len());

    Ok(())
}

spec! builds the shared Group/App/TAI template. record! merges each record’s Name and data into it and returns a validated Record.

Runnable source: src/main.rs.

Every panel prints:

source stored: 3
interlace outcome: complete
destination selected: selected/one, selected/two
destination unrelated: 0

The second destination query is important: listing only selected/{*} would not prove that unrelated/{*} was absent. Both checks use public interlace-backed porcelain rather than raw store inspection.

A bounded interlace completes at the bilateral fixed point, when neither side requests another record in the settled round. It does not claim that the stores are equal.

Replace one participant with a remote

The operation accepts participant handles, not a special local-store or remote API. The destination argument can therefore come from a memory Lace or a connection:

Binding Local destination Connected destination
JavaScript destination from Lace.memory(...) await Lace.connectWebSocket('ws:127.0.0.1:4791/interlace')
Python destination from Lace.memory(...) Lace.connect_websocket('ws:127.0.0.1:4791/interlace')
Rust (lace-tokio) destination.handle() Lace::connect("tcp:127.0.0.1:4790").await?
Rust (lace-single) destination.handle() Lace::connect("tcp:127.0.0.1:4790")?

The synchronous lace-single operation has the same participant shape without an async executor argument:

let peer = Lace::connect("tcp:127.0.0.1:4790")?;
let outcome = bounded_interlace(source.handle(), peer, policy.clone())?;
assert_eq!(outcome.terminal, Terminal::Complete);

Pass the connected value in the same destination position of boundedInterlace/bounded_interlace. The local policy argument remains the local participant’s operand. The remote endpoint contributes its own configured operand, and records converge only where both select them; connecting does not let either side impose its selection alone.

A minimal laced host can expose the same policy to the TCP and WebSocket bindings:

Config 1
DataDir ./laced-data
Listen tcp:127.0.0.1:4790 policy=./items.interlang
Listen ws:127.0.0.1:4791/interlace policy=./items.interlang

Place the config beside items.interlang, then check and run it:

laced check-config ./laced.conf
laced --foreground --config ./laced.conf

The runtime integration guide, under “Operate laced only when a service is needed,” explains the host boundary and links the configuration reference. The rust-tokio-tcp-fs companion is a complete connected example with filesystem stores and TCP.

The local tutorial retains the destination Lace so it can inspect the result immediately. After a connected bounded session, query the remote participant through a new connected list operation or lace --at ... list instead.

What to learn next

Examples are runnable integration companions. The numbered specifications remain authoritative for the shared contracts.