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 record-convergence state machine your application drives in browsers, services, or embedded systems.
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 service can run it. A browser app can run it. An embedded device can run it. Any two Lace participants can interlace. The transport changes; the record-set machinery stays the same.
The normal shape is small: create a local Lace, connect another Lace, describe the records you want, and let the runtime drive interlace while your app observes validated records.
const local = await IndexedDbLace.open("todos", { records: myRecords });
const server = await Lace.connect_websocket("wss://example.com/interlace");
// Start an open interlace. After this call, new task records stored here
// or arriving from the server become visible through local operations.
const sync = start_open_interlace(
local,
server,
selector("//todo-demo//todo//")
);
await store(local, newTaskRecord);
const tasks = await list(local, selector("//todo-demo//todo/task//"));
console.log("These are my local tasks", tasks);
for await (const task of for_each(local, selector("//todo-demo//todo/task//"))) {
render(task);
}
let local = FilesystemLace::open("./lace-todos", [my_records])?;
let server = Lace::connect_websocket("wss://example.com/interlace").await?;
// Start an open interlace. After this call, new task records stored here
// or arriving from the server become visible through local operations.
let _sync = start_open_interlace(
local.handle(),
server,
selector!("//todo-demo//todo//"),
)?;
store(local.handle(), new_task_record).await?;
let tasks = list(
local.handle(),
selector!("//todo-demo//todo/task//"),
).await?;
println!("These are my local tasks: {tasks:?}");
let mut stream = for_each(
local.handle(),
selector!("//todo-demo//todo/task//"),
)?;
while let Some(task) = stream.next().await? {
render(task);
}
let local = FilesystemLace::open("./lace-todos", [my_records])?;
let server = Lace::connect_websocket("wss://example.com/interlace")?;
// Start an open interlace. Manual runtimes drive it explicitly below.
let mut sync = start_open_interlace(
local.handle(),
server,
selector!("//todo-demo//todo//"),
)?;
loop {
// Manual runtimes drive open interlaces explicitly.
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: starting an open interlace creates the driver; awaiting happens
when an async runtime drives work such as store,
list, for_each, or next_event.
Manual runtimes can step one bounded unit at a time for game loops,
embedded loops, or deterministic simulations.
For example, a shared todo list still needs policy: a lacegram
defines which membership grants count, which signed task records belong,
which device keys are allowed to write, and what a service is willing to
accept or expose. What it does not need is a todo-specific sync
protocol. Alice adds a task on her phone. Bob later connects, Lace
fetches the membership evidence, validates the task record, derives that
it belongs in the shared list, and stops at a fixed point. No
GET /todos, no POST /sync, and no app-specific
transfer loop.
A lacegram is a small Datalog program that describes a selected record set. This sketch says: authority-signed membership grants establish writer verifiers, and tasks signed by those writers belong in the selected shared list.
Member(Verifier) :-
Have(Grant),
Field(Grant,'Group',_,'todo-demo'),
Field(Grant,'App',_,'todo/member'),
Field(Grant,'Signed-By',_,'V.authority…'),
Field(Grant,'Member',_,Verifier).
SelectHave(Task) :-
Have(Task),
Field(Task,'Group',_,'todo-demo'),
Field(Task,'App',_,'todo/task'),
Field(Task,'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.
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 shared todo list, the interesting rows might look like this:
| Hash | Group | App | Name | Signed-By | Member | Role | Title | Data |
|---|---|---|---|---|---|---|---|---|
S.member…
|
todo-demo
|
todo/member
|
member/bob
|
V.authority…
|
V.bob…
|
writer
|
— | grant-v1 authorization payload |
S.bob-task…
|
todo-demo
|
todo/task
|
task/…/bob/1
|
V.bob…
|
— | — |
Book venue
|
Bob's first selected task. |
S.alice-task…
|
todo-demo
|
todo/task
|
task/…/alice/1
|
V.alice…
|
— | — |
Send agenda
|
Alice's first selected task. |
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 task
records belong in the shared list.
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: