Field notes

Chapter 03 · Mechanism

Part 2 · The foundations4 of 35

Two planes: what syncs offline and what the server must decide

In a local-first app, your notes can live on the device but your billing tier and role cannot. How we split sync from server authority, and why it costs seven files per table.

The Margin4 min read

Offline-first has an obvious follow-up question that took me embarrassingly long to ask properly.

If the client holds a real database and writes to it directly, what stops a client from writing whatever it likes?

On the device itself, nothing does. A local-first architecture hands the user's machine genuine write authority, and a machine you do not control will eventually be asked to do something you would not have authorized.

Most of the time this is fine, because most of the data is theirs. If someone edits their own note badly, that is between them and their note. Sync carries it up, the server stores it, everyone's copy agrees, nobody is harmed.

Then there is the other kind of data.

The data that cannot go on the plane#

A workspace's subscription tier. A member's role. Whether a person belongs to a workspace at all.

None of that can live on the offline-first plane, because none of it is a fact about the user's own content. It is a fact about their entitlements, and a client that can write its own tier is not a business. The same goes for roles: if the device can promote itself to owner, permissions are decoration.

So there are two data planes, with different rules.

The two planes

Offline-first
boards, cards, notes, habits, expenses, everything that is the user's own material. It lives in the browser database, it works on a plane, and the server is a synchronizer rather than an authority.
Server-authoritative
billing, roles, membership, entitlements. It never syncs to the client as writable state. You ask the server, the server decides, and the answer arrives over the network or not at all. These screens do not work offline, and they should not.

Getting this boundary right is, I think, the single most consequential architectural decision in the product. It is also invisible when done well, which is why it took me a while to realize how much of the design it drives.

The tax#

Two planes means two ways of doing everything, and the cost lands on whoever adds a table.

A new synced table has to be declared in seven places. The Postgres schema. The migration. The SQLite mirror on the client, where the type system is narrower and booleans are integers and JSON is text. The sync rules that decide which users receive which rows. The ordering list that respects foreign keys, so a child never arrives before its parent. The write-authorization layer, which decides whether an incoming change from a device is allowed at all. And the server-side writer that turns an allowed change into a row, which is a separate thing from being allowed, as we found out.

Seven places, and the failure mode is the worst kind. Miss one and nothing errors. The table is simply not there on the client, or arrives without its parent, or is refused on write, and the feature is quietly empty for exactly the users whose data it holds.

That happened to us with the seasonal scoring table for the family house cup. Worked in development. Present in production. Empty on every client, because two of the places had been skipped.

Note folders were worse. Six places done, the seventh missing, so every write from a device was acknowledged and then dropped, and every note filed into a folder failed its foreign key. The client reported success both times.

The tax, in two incidents

7

places every synced table has to be declared

2

places skipped for the house cup table, which arrived empty on every client

1

place missing for note folders, and every write was acknowledged and dropped

There is a checklist now, and a CI script that checks the client schema, the ordering list and the server writer against each other on every push. Postgres row-level security sits under all of it, so a device that talks its way past the write layer still cannot touch rows in a workspace it does not belong to.

Why I would make the same split again#

The split is what lets both halves be honest. Because the user's own data is genuinely local, we can promise it works on a plane and mean it. Because entitlements are genuinely server-side, we can enforce them without pretending a client-side check is security.

The alternative designs are worse in ways that only show up later. Put everything server-side and you have a fast website that is useless in a tunnel. Put everything client-side and you have a beautiful offline app whose billing can be edited with devtools.

Two planes is more work per table, permanently. In exchange, neither promise has to be quietly weakened later.