Lace

Lace is an embedded engine for converging signed, local-first record sets.

Add it to an app, define records and rules, and Lace exchanges exactly the validated records both sides agree should exist locally. It handles validation, signatures, membership evidence, missing dependencies, and repeated sync rounds until the selected set reaches a fixed point.

Think SQLite, but for converging signed record sets instead of querying one local database file. Lace is not a framework or hosted platform; it is an embedded peer-to-peer state machine your application drives from browsers, services, embedded systems, or local IPC.

Try the browser workbench Read the spec Download the browser bundle

Add sync without designing a sync protocol

Most networked applications eventually grow the same machinery: API endpoints, permission checks, sync jobs, cache fill, watches, webhooks, queues, mirrors, identity glue, and one-off retry logic. Each feature gets another command protocol: list this, fetch that, push these bytes, subscribe to those updates, accept this write only if some authority path still holds.

Lace collapses that shape into one question:

Which validated records should this participant converge on?

Your app still owns its UI, object model, deployment, and record bodies. Lace owns the common substrate underneath: canonical records, signatures, rule evaluation, query-view boundaries, advertisement, request, validation, and convergence. Developer operations such as get, list, store, and open observation streams are friendly wrappers around the same convergence machine, not separate remote commands.

The local-first problem

Traditional platform stacks make one service, server, database, relay, or provider the place where application truth lives. They emphasize reachability, central administration, hosted identity, endpoint APIs, and operational control.

Mastodon, AT Protocol, Nostr, and similar systems push back on that with federation, relays, portable identity, signatures, or content addressing. But they still leave applications with the deeper local-first problem: what records do I want, what records do I allow, what evidence proves that, and what does the other side want or allow?

Lace is trying to solve that lower-level problem. It makes each participant a local authority over validated records, then converges the record set both sides select. It emphasizes verifiability, substitutability, explicit authority, convergence, independence, and embedding.

Lace does not compete with servers. Lace competes with bespoke sync protocols, permission endpoints, cache-fill jobs, webhook glue, and the assumption that truth has to live behind an API.

The SQLite-shaped idea

SQLite made serious local storage boring: embed a library, open a file, run queries. Lace aims for the same kind of leverage for local-first sharing: embed a library, store signed records, define the set you want, and connect another lace.

SQLiteLace
Embeddable database engineEmbeddable record-convergence engine
Tables and rowsImmutable records and field projections
SQL queries local stateDatalog rules select converging record sets
Transactions protect local mutationHashes and signatures validate received records
Your app chooses when to queryYour app chooses when and where to interlace

The practical result is a library you can put inside ordinary software. A server can participate as one peer. A browser app can participate as one peer. Two local processes can interlace. The transport changes; the record-set machinery stays the same.

What using Lace looks like

The normal shape is small: create a local Lace, connect a peer, describe the records you want, and let the runtime drive interlace while your app observes validated records.

const local = await IndexedDbLace.open("notes-demo", { records: myRecords });
const peer = await Lace.connect_websocket("wss://example.com/interlace");

const notes = await list(local, selector("//notes-demo//notes/message//"));
console.log("These are my local notes", notes);

const sync = await open_interlace(
  local,
  peer,
  selector("//notes-demo//notes//")
);

for await (const record of for_each(local, selector("//notes-demo//notes/message//"))) {
  render(record);
}
let local = FilesystemLace::open("./lace-notes", [my_records])?;
let peer = Lace::connect_websocket("wss://example.com/interlace").await?;

let notes = list(
    local.handle(),
    selector!("//notes-demo//notes/message//")
).await?;
println!("These are my local notes: {notes:?}");

let mut sync = open_interlace(
    local.handle(),
    peer,
    selector!("//notes-demo//notes//")
).await?;

while let Some(event) = sync.next_event().await? {
    if let InterlaceEvent::RecordSaved { record_hash, .. } = event {
        render(record_hash);
    }
}
let local = FilesystemLace::open("./lace-notes", [my_records])?;
let peer = Lace::connect_websocket("wss://example.com/interlace")?;

let mut sync = open_interlace(
    local.handle(),
    peer,
    selector!("//notes-demo//notes//")
)?;

loop {
    if let Some(event) = sync.try_next_event()? {
        game_events.push(event);
    }

    game.update();
    game.render();
}

These are API sketches. Exact names vary by runtime, but the shape is stable: handles participate in interlace; async runtimes can drive it for you; manual runtimes can step it one bounded unit at a time for game loops, embedded loops, or deterministic simulations.

For example, a notes room still needs policy: a lacegram defines which member grants count, which signed notes belong, and what a server is willing to accept or expose. What it does not need is a notes-specific sync protocol. A member-grant record admits Bob's verifier. Bob's signed note now belongs in the room. Alice connects, Lace fetches the grant, derives Bob's membership, fetches Bob's note, validates both, and stops at a fixed point. No GET /notes, no POST /sync, and no room-specific transfer loop.

Rules decide which records belong

A lacegram is a small Datalog program that describes a selected record set. This sketch says: authority-signed member grants establish writer verifiers, and notes signed by those writers belong in the selected room set.

Member(Verifier) :-
  Have(Grant),
  Field(Grant,'Group',_,'notes-demo'),
  Field(Grant,'App',_,'notes/member'),
  Field(Grant,'Signed-By',_,'V.authority…'),
  Field(Grant,'Member',_,Verifier).

SelectHave(Note) :-
  Have(Note),
  Field(Note,'Group',_,'notes-demo'),
  Field(Note,'App',_,'notes/message'),
  Field(Note,'Signed-By',_,Verifier),
  Member(Verifier).

The other side contributes its own selector. Lace compiles the two operands into an exchange plan and converges on the intersection both sides allow. The loop is intentionally boring: evaluate rules, advertise candidates, request missing records, validate received bytes, derive new facts, and repeat.

Real exchange modules also select and advertise the evidence records that bootstrap a closure, so peers can discover what to request next. Advertisements are only hints; received records become truth only after validation.

Records are table-shaped

A Lace record has a stable hash and a canonical stored form. Once validated, it produces record facts. A projection is a table view over selected fields. For a notes room, the interesting rows might look like this:

HashGroupAppNameSigned-ByMemberRoleTitleData
S.member…notes-demonotes/membermember/bobV.authority…V.bob…writergrant-v1 authorization payload
S.bob-note…notes-demonotes/messagemsg/…/bob/1V.bob…Bob helloBob's first selected note.
S.alice-note…notes-demonotes/messagemsg/…/alice/1V.alice…Alice helloAlice's first selected note.

Blank cells mean that record simply has no field with that name. Data is arbitrary application data; it is not a copy of the indexed headers. The member row is not free-form prose that asks to be trusted. It is an authority-signed Seal record with indexed Member and Role fields. Those fields admit Bob's verifier as a writer, and that evidence can make Bob-signed note records belong in the room set.

Rows are not mutable spreadsheet cells. Editing means adding another immutable record. Group/App/Name are indexed coordinate fields, not identity; many records may share them. The hash is identity.

What starts looking optional

The small claim is practical: Lace can replace a lot of custom sync code. You still write the policy that defines what belongs and what a participant accepts; you stop writing a new transfer protocol for every feature. Offline notes, approval workflows, key rotation, signed manifests, device onboarding, published wrappers, and cache fill become selected record sets instead of bespoke APIs.

Change the foundation: once records, authority, and convergence are handled by one embedded engine, much of today's platform glue stops being the default architecture.

Eventually, a whole app can be just keys, records, and rules. HTTP, DNS, SSO, queues, hosted databases, and custom sync services become ways to route, cache, bridge, or bootstrap — not where the app's truth has to live.

Scenario workbench

The scenario workbench is the fastest way to see the model. Each scenario shows records, rules, advertisements, validation, and convergence for a real application-shaped problem.

Lace is early, but the protocol model is intentionally small: records, rules, exchange plans, and interlace. If that shape fits a problem you have, the workbench is the best place to start poking at it.

More entry points: