Lace

Lace is a local-first protocol and library for policy-driven synchronization between independent participants.

Each participant keeps its own checked records and policy. An interlace connects two participants and transfers only records that both policies select. The receiver validates every copy before admission, and the exchange repeats when newly admitted evidence allows the same policies to select more records.

The internet moves packets. The interlace converges records.

Draft: Lace is an early implementation with no compatibility guarantees. The examples run today; complete conformance, security evaluation, and production performance remain open. Pin exact versions and expect breaking changes. Feedback: lace@roelof.solar.

Copying data is easy. Deciding which checked records should converge—and why—is the hard part.

When two independent participants exchange data, each 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?

Centralized platforms answer all four questions in one place. When participants govern their own records, neither can decide alone what the other should admit. Synchronization must preserve both participants' decisions without giving either participant's policy unrestricted access to the other's records.

Local-first frameworks often spread that rule across subscriptions, permissions, relays, approvals, and synchronization code. Lace makes it explicit.

Application evidence may arrive through an intermediary, so it must remain checkable after copying. Records provide that check; policy decides what the evidence means.

Each participant also decides which local records the peer's rules may inspect. Lace calls this whole-record boundary exposure. Exposing a record does not copy it.

Lace does not resolve application conflicts or require both stores to match.

Each participant starts with checked records. Exposure decides which whole records the peer's rules may inspect; convergence copies the checked records both sides select. Open the sketch fullscreen.

One checked record

Lace records combine ordinary fields and a body under a stable hash. Application constructors infer the record form from one specification: data alone creates a Blob, coordinate-bearing intent creates a Plex, and a private Lace-009 &...H3 key called a By-secret is used to make Marks for a Seal. To keep this example reproducible, it uses this throwaway By-secret value: &.1lS71lS71lS71lS71lS71lS71lS71lS71lS71lS71lS.H3.

JavaScript

const message = await Record.create({
  group: 'example-group',
  app: 'conversation/message',
  name: 'message-1',
  fields: { Room: 'planning' },
  data: 'Meet at the library at seven.',
  bySecret: '&.1lS71lS71lS71lS71lS71lS71lS71lS71lS71lS71lS.H3',
});

Python

message = Record.create(
    group="example-group",
    app="conversation/message",
    name="message-1",
    fields=[("Room", "planning")],
    data=b"Meet at the library at seven.",
    by_secret="&.1lS71lS71lS71lS71lS71lS71lS71lS71lS71lS71lS.H3",
)

Rust

let message = new_record(
    RecordSpec::new()
        .group("example-group")
        .app("conversation/message")
        .name("message-1")
        .field("Room", "planning")
        .data("Meet at the library at seven.")
        .by_secret(BySecret::parse(
            "&.1lS71lS71lS71lS71lS71lS71lS71lS71lS71lS71lS71lS.H3",
        )?),
)?;

Byte format

🖧: S.VFzUqTswwbMcVozFML_dbTIkepC6LPuNtUqqoHNOqgh.H3
By: V.8qgOfsaEJkK1tzX51sTT9NKdM8J2SrzQq6k8cRPzvP0.H3
: dEiYbPrSwvGRSFyPm6CFSR4BIs5R~lz7tnFbzhRDVcz2UquX7Luorn6543sUW54hXzH~b5oJuLfjIMc09SBf0G
🖧: P.foQ7m~R_41ZPdavTpxjjLueWgYXbz0nCqiVMdiycBMl.H3
Group: example-group
App: conversation/message
Name: message-1
: 
Room: planning
🖧: B.450OqhJH9hel~Z7JXEemHjTS2gNvk6n3AYOR1qZQZVK.H3
: 

Meet at the library at seven.

The By field contains canonical V...H3 text; V is Lace-009's verification-key type tag.

Interlang, Lace's human-facing policy language, describes the planning-room records to converge:

converge {
  Message in //example-group//conversation/message//{*}
    | Message.Room == 'planning'
    => include Message
}

Evidence can unlock the next record

Alice and Bob keep membership records and messages locally. Both trust the same authority to name members. Their policies select a message for convergence only when a checked membership record recognizes the message's By key as a member.

Suppose Bob has his membership record and a message, while Alice has neither:

  1. Bob advertises both records. Advertisements are candidates, not evidence.
  2. Both policies select the membership record. Alice requests, validates, and admits it.
  3. The membership becomes checked evidence, so the same policies select Bob's message in the next round.
  4. Alice requests, validates, and admits the message.

Membership is a record, not a release phase. The trusted-members scenario expresses this exchange in Interlang and shows later membership unlocking an existing message:

membership_record(Membership, member) :=
  Membership in //members//membership//{*}
  | Membership.By == 'V.amJiQPQBKBgB6Xb7CFzrEns6qSAh3kCOSEW6DUDh_6K.H3'
  | Membership.Member == member

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

converge {
  membership_record(Membership, _) => include Membership

  Message in //members//chat//{*}
    | Message.By == member
    | recognized_member(member)
    => include Message
}

Explore the trusted-members scenario

The same pattern handles more than membership. Applications represent approvals, delegations, manifests, and key changes as records, then write policy for the set those records justify. The scenario catalog runs these and other examples.

Clay: reader-chosen feeds and discussions

Most link feeds let one service carry links, approve publishers, aggregate votes, rank results, and moderate discussion. Clay is a small local-first post feed that separates those roles by sub. Independent aggregators admit participants and publish marked Rankings and Thread manifests; a deployment-selected directory ranks ordinary self-authored #clay advertisements, and readers choose and weight which advertised aggregators they trust in each sub. Original Submissions and Comments retain participant Marks after copying.

Clay does not identify humans, stop bots, or decide which links, comments, or counts are true. It makes admission, aggregation, discussion selection, and ranking explicit records and reader choices instead of properties of whichever server carries the bytes.

A library you drive

Lace is sans-IO: it does not open sockets or choose storage for you. Your application supplies stores, transports, and lifecycle through Rust, JavaScript, or Python bindings.

With the planning-room selector bound as policy, a direct interlace looks like this:

JavaScript

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

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

This direct shorthand uses policy for both participants. A connected peer takes the destination's place:

const peer = await Lace.connectWebSocket('ws:127.0.0.1:4791/interlace');
await boundedInterlace(source, peer, { policy });

Here policy is the source's operand. The peer supplies its own. Lace moves only records both operands select.

Python

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

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

This direct shorthand uses policy for both participants. A connected peer takes the destination's place:

with Lace.connect_websocket('ws:127.0.0.1:4791/interlace') as peer:
    bounded_interlace(source, peer, policy)

Here policy is the source's operand. The peer supplies its own. Lace moves only records both operands select.

Rust

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

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

This example uses lace-tokio. Rust also has the synchronous lace-single binding.

This direct shorthand uses policy for both participants. A connected peer takes the destination's place:

let peer = Lace::connect("tcp:127.0.0.1:4790").await?;
bounded_interlace(source.handle(), peer, policy.clone()).await?;

Here policy is the source's operand. The peer supplies its own. Lace moves only records both operands select.

store, get, and list use a boundedInterlace underneath. Applications choose openInterlace for continuing convergence. See Runtime and application integration for binding-specific support.

Try Lace

Start with the trusted-members explorer above. It shows membership arrive first and unlock Bob's message in the next round.

To run the implementation locally:

git clone https://codeberg.org/rs0/lace.git
cd lace
cargo run --manifest-path examples/first-interlace/Cargo.toml

The example creates two memory-backed participants, moves two selected records, and leaves an unrelated record at the source. The first-interlace guide provides the same operation in JavaScript and Python.

Next, add Lace to an application or write an Interlang policy. See the runnable examples/ and complete application experiments in incubator/. The numbered specifications define the protocol and API.