Lace

We share information in countless ways: with friends, with a team, with family. Each app has its own rules for who gets what, and usually one server keeps those rules.

Lace is a general language for these sharing rules, and a protocol that shares any kind of information by them. With it, you can write rules like:

You write the rules; Lace follows them. Lace comes as a library for Rust, Python and JavaScript.

The aim is fewer concepts, not just less code. Membership, approvals, delegation and sharing should not each require a different system. Lace’s approach is to express them through the same building blocks: records and rules about records.

Lace does not need a central server. Like git, every computer keeps its information itself and works without a server. Unlike git, two computers do not need to hold the same information, and each one keeps its own rules. The rules do not have to agree: information moves between two computers only when the rules on both sides allow it.

Read on to see how.

Explore scenarios in your browser · Download · API reference · Specifications

Early test version. Lace changes often, and an update may break your code. Nobody has checked yet how safe or fast it is. Developers: use exact versions. Feedback: lace@roelof.solar.

Most apps come as one package: the screens you see, your data, the computer that keeps it, and the rules about who sees what. If you want to change one part, you must change everything, or leave it all behind.

Lace keeps these parts apart. Another app can show the same information. A new tool can read it. Another computer can keep copies, and anyone can check that they are exact copies. Lace does not know what the information means. Each app decides that.

Records and sharing rules

Each computer keeps its information as records. A record is one piece of information, such as a message, a photo or a list of members. A record can carry a signature made with a secret key. Anyone can check that the signature matches the record, even after it passed through other computers. Changing the signed data or fields makes that check fail.

Copying records is easy. The hard part is deciding which records should go where, and why. When two computers share, each one has to answer:

What am I willing to send?
What am I willing to accept?
What are they willing to send?
What are they willing to accept?

Most apps hide the answers in many places in their code. With Lace, each computer writes its answers down as its own sharing rules (policy). A record moves from one computer to another only when the rules on both computers allow it. Neither computer can make the other take a record. Before your computer keeps a new record, it checks the whole record.

Rules can depend on other records

The rules can depend on other records. For example: “Share messages signed by someone Anna lists as a club member.” When Anna signs a record that adds a new member, that member's messages can be shared too. Nobody has to change the rule.

The same idea works for votes, approvals, letting someone act for you, and changing to a new signing key. Apps give these records their meaning; Lace does not need a separate built-in system for each.

→ Explore the club example (the rules are shown as code).

Looking is not taking

To decide what to share, your computer also runs the other computer’s rules. Their rules may need to look at some of your records: for example, to see who is a club member. You choose which of your records their rules may look at (expose). Looking does not move a record. A record moves only when the rules on both computers choose to share it (converge).

Lace does not decide which record is right when two records disagree. That is the app’s job. The two computers also do not need to end up with the same records: each one gets only what both computers’ rules allow. And once a record is shared, it cannot be taken back.

Press play. Your computer is the left circle, the other computer is the right one. Your rules may let theirs look at a record without sharing it. Records you both already have do not move. Open the picture full screen.

Built with Lace

Joinery runs small apps on your own computer and uses Lace for all its sharing. Each app gets its own rules.

For developers

From here on, this page uses the names from the code. Each record has a hash: a fingerprint of its exact bytes. Sharing rules are a policy. Each side is a participant: a store of records with its own policy. It may be a computer, an app, or a browser tab. One exchange between two participants is an interlace.

expose and converge are the two kinds of rule from the picture. The other side's rules can look at the whole records you expose, and the other side may learn about them from what it asks for. But exposure alone does not send a record.

The internet moves packets. The interlace converges records.

This page and the scenarios are guides. The numbered specifications are the authority.

One record

Lace checks a record's complete bytes when it creates or receives it. A store keeps a record (admits it) only after that check. Each record has one exact byte form (its canonical bytes) and a stable hash. There are three kinds:

A record has only the fields its app needs. Lace gives no schema or special meaning to #Notes and Room below. #Notes: message names the app and the kind of record, as the application-kind convention describes.

The code below is short and leaves out imports. Download and setup leads to full examples. Fields make a Plex; a By-secret also wraps it in a Seal.

JavaScript

const authorSecret = generateBySecret();
const message = await Record.create({
  fields: [
    ['#Notes', 'message'],
    ['Room', 'planning'],
  ],
  data: 'Meet at the library at seven.',
  bySecret: authorSecret,
});

Python

author_secret = Record.generate_by_secret()
message = Record.create(
    fields=[
        ("#Notes", "message"),
        ("Room", "planning"),
    ],
    data=b"Meet at the library at seven.",
    by_secret=author_secret,
)

Rust

let author_secret = generate_by_secret()?;
let message = new_record(
    RecordSpec::new()
        .field("#Notes", "message")
        .field("Room", "planning")
        .data("Meet at the library at seven.")
        .by_secret(author_secret),
)?;

Byte format

🖧: S.in4w7FHhrXfQCVpvC3zhBmnGTMADemrZjd8Ns~frAa_.H3
By: V.8qgOfsaEJkK1tzX51sTT9NKdM8J2SrzQq6k8cRPzvP0.H3
: o7AwB5Tv354VqFlrsQ6D8HPI8kR5yHa1KvOTejZzhmEdxd82yOAPzPGA9nya_KVW1EBRx82rHCo2Inw1jU8LOl
🖧: P._oSyPTE9VNeDTxgjqOPxMUIo7yFvQP5SF1GZ18uwHv8.H3
#Notes: message
Room: planning
🖧: B.450OqhJH9hel~Z7JXEemHjTS2gNvk6n3AYOR1qZQZVK.H3
: 

Meet at the library at seven.

The byte-format tab shows one valid encoding of these fields and data. The code makes a new By-secret, and each Mark also uses new random input. So a real run gives a different By, Mark, and Seal hash. The Plex and Blob bytes stay the same.

Constructors sort fields by name. Fields with the same name keep the order you gave them. Once admitted, a record gives the policy facts such as:

Fact Meaning
Have(Record) the complete record is checked and stored here
Field(Record,name,index,value) one field occurrence: an application field, a header field (Data-Length, By, Mark), or a synthetic field: :Type (the kind) or :Length (the size of the complete record in bytes)
RecordLink(Record,name,index,data,Target) one application field whose value ends in a record hash; data is the text before that hash
ContainsRecord(Outer,Contained) one record inside another: a Plex holds a Blob, a Seal holds a Plex and its Blob

A policy treats records like rows and fields like columns. Interlang is Lace's policy language for people. Its field clauses say which records an app wants to converge:

converge:
  include Message:
    Message['#Notes'] == 'message'
    Message.Room == 'planning'

The code further down calls this policy planningPolicy.

How to read it: a field clause such as Message['#Notes'] == 'message' binds a record variable, which starts with a capital letter. Every indented line below a rule must hold. Each level adds two spaces. include Message: names the output. Other records that a rule uses are not selected unless they are included too. Without an index, a field must occur exactly once; [_] matches any occurrence. := defines a helper. Lowercase names are text variables, and _ matches any value. Advanced rules go in a datalog: block.

Evidence can unlock the next record

This is the club example from above, as Interlang. Alice and Bob keep membership records and messages on their own devices. Both trust one authority to say which keys belong to members. Their policy converges the membership records. It then uses the checked membership records as evidence, and selects messages marked by a known member:

membership_record(Membership, member) :=
  Membership.By == 'V.53BkUsh3Pz1d2zyNE6Psj5Cmz8s0f5t3QZetDJiSm98.H3'
  Membership.Member == member

recognized_member(member) :=
  membership_record(_, member)

converge:
  include Membership:
    membership_record(Membership, _)
  include Message:
    Message['#Notes'] == 'message'
    Message.By == member
    recognized_member(member)

Explore the trusted-members scenario

A membership record that arrives can unlock a message in the next round. Each round has three parts:

  1. Each side lists the records it holds that both policies select, with the fields that either side's rules need when asking for records. This list is an advertisement. The other side's rules may only look at records that your policy exposes.
  2. Each side asks for the missing records it wants. This is a request.
  3. The other side sends those records.

An advertisement is only a claim. The receiver checks each record's complete bytes when it arrives. Only then can it use the record as evidence.

A library you drive

Lace's core does no reading, writing, or networking itself (it is sans-IO). Its runtimes handle storage and transport. Your app sets up connections and decides when they start and stop. Rust, JavaScript, and Python use the same records, policies, and interlace.

In the code below, source and destination are participants that already have a store. Both may send and receive. This is a direct call between two local participants, so both sides use planningPolicy. With a connected peer, your side gives planningPolicy and the peer gives its own policy. A bounded interlace runs rounds until neither side asks for another record (the fixed point), or until a limit or a failure stops it. Its outcome says which one happened.

JavaScript

await store(source, message);
const outcome = await boundedInterlace(source, destination, {
  policy: planningPolicy,
});
const received = await list(destination, planningPolicy);

console.assert(outcome.terminal.type === 'complete');
console.assert(received.records.some(record => record.hash === message.hash));

Python

store(source, message)
outcome = bounded_interlace(source, destination, planningPolicy)
received = list_records(destination, planningPolicy)

assert isinstance(outcome.terminal, Complete)
assert any(record.hash == message.hash for record in received.records)

Rust

store(source.handle(), [message.clone()]).await?;
let outcome = bounded_interlace(
    source.handle(),
    destination.handle(),
    planningPolicy.clone(),
)
.await?;
let received = list(destination.handle(), planningPolicy.clone()).await?;

assert_eq!(outcome.terminal, Terminal::Complete);
assert!(received
    .records
    .iter()
    .any(|record| record.hash() == message.hash()));

This example uses lace-tokio. For synchronous code, Rust also has lace-rt.

For a remote peer, your app opens the connection, and Lace then reads and writes over it. See connected participants.

The short helpers store, get, list, and forEach also run an ordinary interlace, so they follow the same policies and checks.

Try Lace

Open the scenario workbench. The examples run in your browser, so you do not need to install anything. They go from beginner to advanced. Choose one, then step through the exchange to see which records move and why.

Download and use Lace

Download Lace source (ZIP) · Source revision

Unpack the ZIP in your app's folder. It creates a lace/ folder. All three builds need Rust and Cargo (install them with rustup). Run the commands below from your app's folder.

Rust

Use lace-rt for synchronous code, or lace-tokio for Tokio apps. Add one to Cargo.toml. For lace-rt:

[dependencies]
lace-rt = { path = "lace/core/crates/lace-rt" }

For Tokio, use lace-tokio at lace/core/crates/lace-tokio instead. Then read that crate's README.md in the lace/ folder for an example.

Python

With Python 3.10+ and uv:

uv venv
uv pip install ./lace/python

Then read lace/python/README.md for a complete example. Run it with the Python in .venv. Python API reference.

JavaScript

With Node.js and npm:

rustup target add wasm32-unknown-unknown
npm --prefix lace/js/crates/lace-js run build
npm install ./lace/js/crates/lace-js

Import from @lace/lace-js. Use @lace/lace-js/browser for browser storage or @lace/lace-js/node for file storage. Then read lace/js/crates/lace-js/README.md for examples. JavaScript API reference.

Find your way through the docs