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.
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.
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).
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.
Joinery runs small apps on your own computer and uses Lace for all its sharing. Each app gets its own rules.
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.
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.
const authorSecret = generateBySecret();
const message = await Record.create({
fields: [
['#Notes', 'message'],
['Room', 'planning'],
],
data: 'Meet at the library at seven.',
bySecret: authorSecret,
});
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,
)
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),
)?;
🖧: 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.
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:
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.
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.
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));
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)
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.
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 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.
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.
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.
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.