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
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.
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.
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.
| SQLite | Lace |
|---|---|
| Embeddable database engine | Embeddable record-convergence engine |
| Tables and rows | Immutable records and field projections |
| SQL queries local state | Datalog rules select converging record sets |
| Transactions protect local mutation | Hashes and signatures validate received records |
| Your app chooses when to query | Your 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.
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.
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.
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:
| Hash | Group | App | Name | Signed-By | Member | Role | Title | Data |
|---|---|---|---|---|---|---|---|---|
S.member… | notes-demo | notes/member | member/bob | V.authority… | V.bob… | writer | — | grant-v1 authorization payload |
S.bob-note… | notes-demo | notes/message | msg/…/bob/1 | V.bob… | — | — | Bob hello | Bob's first selected note. |
S.alice-note… | notes-demo | notes/message | msg/…/alice/1 | V.alice… | — | — | Alice hello | Alice'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.
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.
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: