Less protocol, more boundaries

AI Use Disclosure

The ideas here are my own. I use AI tools in various parts of the writing process, including summarizing my notes and prompts, improving grammar, tightening wording, and checking whether an argument is clear. These posts are as much notes for myself as they are for anyone else who is interested.

A few more notes about Lace. Some of the progress has been deleting things I previously thought were important.

If you have skimmed the Lace front page, the basic idea has not changed: describe which records belong in a context, then let an exchange algorithm move the missing accepted records. Validate the bytes before believing the claims. Repeat, because new evidence can change what belongs.

The changes are around what the protocol should insist on, how independently written rules fit together, and where the code using all this should actually run.

That last question led me back to a fairly obvious piece of software which I had somehow been treating mostly as a way to draw rectangles.

The browser.

The coordinates were doing too much

The older record format required four fields on every structured record:

Group
App
Name
TAI

A group, an application, a name, and a timestamp. Bare blobs did not need them, but adding application fields or sealing a record meant buying into that structure.

It is a reasonable-looking list. Most things belong somewhere, are used by something, have a name, and happened at some time.

But why does the exchange protocol get to insist on those answers?

A membership statement might belong to several groups. Two tools might interpret the same record. A piece of evidence might not need a human-readable name. And making everybody supply a timestamp does not give everybody a trustworthy clock.

The content hash already identifies the exact bytes. The coordinates were never what made a record the same record.

So I dropped the requirement.

A record can now have application fields like:

Kind: message
Room: planning

That is a field fragment, not the whole wire format. The point is what is absent: no invented application namespace, placeholder name, or timestamp just to get past the constructor.

Group, App, Name, and TAI can still be fields. They are ordinary field names now, not protocol furniture. An application can require them, or require something else. It can insist that a field occurs exactly once. If it needs revisions, it can define revisions. If it needs time, it has to say what that time means and why it trusts it.

This does not remove schemas. It stops putting one application’s schema underneath every other application.

The hash, canonical bytes, validation, and seals remain. The record format needs to know whether I have the right bytes. It does not need to know which filing cabinet I imagined putting them in.

Composition is not concatenation

The other part that has become more explicit is the Datalog composition algebra.

That sounds like the point where a blog post becomes homework. The useful question is simpler:

If two people independently describe a boundary, can I combine their rules without either boundary disappearing?

Datalog lets you derive facts from other facts. A rule can say that a record belongs when its author is a member of a team, where membership itself comes from other validated records. The interesting bit is that these are relationships, not just a list of field filters.

There are several different meanings of “combine” here.

Inside one rule, conditions work together:

select this message when
  its author is a member of this room
  AND it belongs to this room

The repeated “this room” matters. Membership in some unrelated room is not enough. This is a join: the rules connect facts through shared values.

Alternative rules can add answers:

select messages meeting the membership rule
OR select the membership evidence itself

That is useful because evidence may have to arrive before it can unlock the messages.

But when I combine independent policy constraints, I usually want something different:

tool policy:       notes
account policy:    records approved for this account
operation policy:  published records

combined selection:
  notes ∩ approved-for-this-account ∩ published

The ∩ means intersection: a record has to be in all three contributed sets.

The tool does not get to expand the account policy just because it wants more data. The account policy does not make the tool accept every kind of record just because the account may access them.

Each can describe its own answer. The composer supplies the AND between their answers.

Keep the machinery private

There was already intersection in the earlier version. The change is making composition of complete policies a well-defined operation, rather than growing a system for linking partial rule fragments together.

Each policy is self-contained. It can have its own helper rules, joins, recursive relationships, and ways of deciding what belongs. Those helpers stay private to that contribution.

If two authors both call a helper Allowed, that should not accidentally turn into one shared definition of “allowed”. That would be a fairly exciting security feature to discover in production.

The composer keeps those internals separate and combines the designated outputs. It produces ordinary Datalog, not callbacks into somebody else’s policy object or a second evaluator hidden behind the first one.

There are a few outputs to keep distinct:

Selection constraints intersect. Exposure constraints also intersect, separately. Vetoes accumulate: one contribution saying “do not transmit” is enough to keep transmission disabled.

Exposure is not something I can reconstruct from selection. As I found in the earlier failure analysis, a peer does not need to receive a private record to learn something about it. Making my requests depend on its contents can already leak an answer.

There is also a small but important distinction between “this policy says nothing about selection” and “this policy selects nothing”. A veto-only policy should not erase somebody else’s selection. A policy that explicitly contributes an empty selected set should. If nobody contributes a selection at all, that is not permission to exchange everything.

For the set outputs, the algebra gives the expected properties:

A ∩ B = B ∩ A
(A ∩ B) ∩ C = A ∩ (B ∩ C)
A ∩ A = A

Reordering constraints does not change the answer. Regrouping them does not change the answer. Repeating one does not broaden or narrow it again.

Those statements are about successful evaluations over the same facts. They are not promises that every grouping takes identical work, or that peers with different evidence see identical results. There are still execution limits, and the eventual exchange still needs both participants’ agreement.

Nor is this a claim that arbitrary programs now compose nicely. Alternatives and intermediate relationships belong inside each policy. The boundary between policies is deliberately smaller: combine these outputs without exposing or merging their internal machinery.

That is the part I want to preserve. Independently useful pieces, with a boring rule for putting them together.

Okay, but where does the code run?

Lace describes records and their exchange. It does not, by itself, provide somewhere to run an editor, a chat interface, or a tool that interprets those records.

I want small pieces of software that can be assembled without each one becoming a whole platform. Call one of those pieces an ingredient: an editor, an identity service, a viewer, something with a limited job.

They need execution, storage, a UI, and some way to call each other. They should not automatically inherit access to everything else on the machine.

At which point it is very tempting to start designing a VM.

But there is already an extremely well-developed execution environment for running code supplied by other people. It runs JavaScript and WebAssembly. It draws interfaces. It has debugging tools. It separates sites’ state. An enormous amount of engineering has gone into making it work on ordinary computers.

The browser is not a small dependency. It is, however, a rather large amount of machinery I do not have to invent.

The analogy I keep coming back to is tmux. It does not reinvent bash. It arranges shells and processes into something you can work with.

What if a small local host did that for browser ingredients? Launch them, arrange them, give them their connections, keep their private state separate.

That is what I mean by shared execution here: sharing an execution platform, not putting every ingredient into one JavaScript heap. The browser still manages its own processes. And tmux is an analogy for orchestration, not a claim about isolation.

A hostname is useful machinery

Suppose the host serves two ingredients at illustrative addresses like:

http://notes.runtime.localhost
http://identity.runtime.localhost

Both can be served by the same local program. But to the browser, they are different origins.

An origin is roughly the combination of scheme, hostname, and port. Changing a URL path would not give me this boundary. Changing the hostname does.

That means the notes ingredient does not simply get to reach into the identity ingredient’s page or browser storage. Being neighbours on the same loopback server is not the same thing as sharing an application context.

Before the ingredient’s own scripts run, the host injects a small JavaScript API. Through it, the ingredient can ask for private disk access, call an explicitly connected service, or use a granted network connection.

For example:

The notes tool may ask the identity service for a display-name summary.

Not:

The notes tool now owns the identity service.

The injected JavaScript object is not the security boundary. The ingredient can change its own JavaScript. Every privileged request has to cross into the host, which checks the authenticated caller and the actual grant. Inventing another method call or guessing a sibling’s address must not manufacture authority.

Then host-controlled response headers restrict the ordinary browser routes: where scripts and assets can come from, which connections the page may open, and so on. Workers are disabled in the current host rather than quietly becoming another execution path to police.

The intended rule is:

Compute what you like. Contact the rest of the world through the interface you were given.

That is a much more useful arrangement than giving every little tool the full network and hoping its author is feeling responsible today.

It is also where the browser gets awkward.

Blocked is not always blocked

I tested two separate questions.

First: can sibling ingredients reach into each other’s private state or use authority they were not granted?

In the tested Chromium and Firefox cases, origins kept the siblings out of each other’s private page state and storage. The host rejected unauthorized privileged operations, including cases where the browser did send a request to the server.

That last distinction matters. A request can arrive with cookies and still not be authorized. The host has to check the caller, credentials, and scope, not just notice that a cookie showed up.

Second: can a page contact another endpoint without using the injected API?

Here the answer was less convenient.

CORS, the mechanism behind many familiar browser console errors, is not a general “nothing left the machine” switch. Some cross-origin requests reached the receiver even though JavaScript was forbidden from reading the response.

Content Security Policy, or CSP, does more. Restrictive headers blocked the tested ordinary fetches, WebSockets, and a range of other routes. But blocking those APIs is not the same as blocking communication.

A page navigating itself to another URL could still carry chosen text to the receiver in that URL, even under the tested restrictive document sandbox.

And WebRTC still sent connection-discovery packets to a configured UDP endpoint under every tested policy, including the strictest CSP and document-sandbox combination.

WebRTC is the browser’s real-time communication machinery. Those discovery packets were STUN requests: part of figuring out how a connection might be established. A separate receiver observed them. This was not just a JavaScript API returning something suggestive.

That does not mean I demonstrated arbitrary secret data leaving through a WebRTC data channel. No such channel was established. It means the stronger claim, “this page cannot contact anything outside the intended API”, was already false in that setup.

These were version-specific, headless tests against loopback receivers, not a proof about every browser or a demonstration against a public Internet destination. But another local receiver is enough to show that the proposed boundary has a hole.

The browser is very good at several kinds of separation. “Run arbitrary code, but let it communicate only through my object” is not a promise these headers can make on their own. A hard no-egress guarantee needs enforcement beyond this scheme.

Two different promises

This leaves two statements that are easy to confuse:

You cannot use another ingredient’s authority.

And:

You cannot send out anything you have learned.

The first needs origin separation and host-side authorization. The experiments gave useful evidence for those boundaries in the cases tested.

The second needs control over every way information can leave. Ordinary browser policy did not achieve that. Giving a tool access to a record is still a consequential decision, even if its intended API is narrow.

This is also not an already finished browser-and-Lace security system. The local execution host is a separate piece of work; connecting it to Lace is still ahead. What it establishes so far is a useful hosting shape, and a more precise list of claims I cannot make.

But the direction feels consistent.

Stop making every record pretend to belong to one universal application hierarchy. Let complete policies constrain each other without sharing their internals. Reuse an execution environment that already exists, then be explicit about which boundaries it actually enforces.

Less protocol deciding what things mean. More care about how the pieces are allowed to meet.

First written: 2026-09-11
Last edited: 2026-09-11