It dominates
Every counter is at least the stored one, and one is higher. Your device has seen everything the server has. The write lands.
On a secure-area node the server compares two version vectors and does nothing else. It cannot read one, so it cannot merge one. What follows is that mechanism in full — the node, the rule, the wire, with the real payloads.
A node is one unit of memory. This is the entire row — there is no hidden
column. key_id is the field that matters most: it is what separates a
page we can read from one we cannot.
| Field | Meaning |
|---|---|
| id | uuid — the only stable server-side identity |
| path | Logical location. Ciphertext as soon as key_id is not none |
| scope | ACL scope name; ops is the default. Always plaintext |
| key_id | Names an encryption key. It never contains one. none marks an open-area node, and is the one value that means "readable here" |
| version | Counter. The client's own on a sync write; the server owns it in the open area, where it is also the concurrency guard |
| vclock | Version vector {deviceId: counter} — the concurrency truth for a sync write |
| content_b64 | The body. Base64 on the wire, bytea at rest. Opaque to us in the secure area |
| content_hash | Integrity hash. Client-computed on a sync push, server-computed on an open write |
| deleted | Tombstone flag |
| updated_at | Server clock, microsecond precision. Half of the read cursor |
Set key_id to none and the node is in the open
area: the path and the body are stored as markdown, we index them, search them,
and keep every prior version. That is what lets a surface holding no key read a
brain at all.
Set it to a real key and the node is in the secure area: the path and the
body arrive as ciphertext, and there is no title, no owner and no tag column to make
up the difference. The server sorts by updated_at and compares version
vectors. There is nothing else in that row for it to do.
Every node carries a version vector: one counter per device.
dominates(a, b) is true when every counter in a is at
least b's and one is strictly greater. Once a node exists, a sync push
is accepted only when the incoming vector dominates the stored one. The first write
has nothing to dominate and simply lands. That is the whole of the server's
judgement, and it never needs to see a byte of content to apply it.
Every counter is at least the stored one, and one is higher. Your device has seen everything the server has. The write lands.
Equal vectors do not dominate. A rewrite that advances nothing is refused rather than quietly applied over a copy someone else may hold.
A genuine fork: two devices edited the same node without seeing each other. The server holds ciphertext, cannot merge it, and does not pretend to.
{laptop:5, desk:2}{laptop:4, desk:2}{laptop:4, desk:3}{laptop:4, desk:3}{laptop:5, desk:2}{laptop:5, desk:3}{laptop:5, desk:4}{laptop:5, desk:2}The server decides whether. Your device decides what.
A push is validated before it reaches the database. The error says which field failed and why — and stops there, because echoing the value back would leak the thing the store exists not to hold. A test asserts it.
The vector arithmetic ships in @instibrain/protocol, the package the
server and the sync client both import. One author for the contract, so the two
sides cannot drift apart.
join(a, b)—least upper bound. The vector a merged node must carry.
bump(v, deviceId)—record one local edit against your own counter.
concurrent(a, b)—name a real fork. Only a device holding plaintext can close one.
Reject vector shapes that would poison ordering before they reach the wire.
Fetch the server's copy, decrypt both, merge where the plaintext already is,
join the two vectors, bump your own counter, push again.
That is the entire conflict story, and none of it happens on our side.
Plain HTTP for a client you write yourself, MCP for an agent that speaks it
natively. Same records, same cursor, same merge rule — the second transport
is a different door onto the first, not a second protocol to keep in step. Each
transport carries both areas, and the routes below are the ones that matter. The
full list is in @instibrain/protocol.
Mints a uuid and hands it back. No row is written, so an identity costs nothing.
Everything after the cursor, ascending, 500 at a time. The cursor is a pair—the stamp and the last id seen at it—because rows can share a microsecond.
One node. The merge path needs the server's copy before it can join anything to it.
The sync write. 200 when your vector dominates. 409 with the server's vector when it does not, or 409 path_taken—that one carries no vector—when the path is already held. 400 naming the field when the node is malformed.
How many nodes changed after a stamp, and the newest stamp. It answers for the whole brain, not a subtree—a reader checks before it trusts what it cached.
Changed nodes, ascending, 200 at a time. The same compound cursor as HTTP.
A batch in, one result per node out: accepted, concurrent with the server's vector, path_taken, or invalid naming the field that failed. The batch does not stop at the first conflict—you get the full verdict in one round trip.
Open area only. A word or phrase in, matching paths out, each with the paragraph the terms landed in. There is nothing to search in the secure area, which is the point of it.
One open-area page, by path, as markdown. list_nodes walks a folder and node_history replays how a page got here.
Editing the open area, and a separate permission from reading it. Here the server can read, so it does the work: last write wins on content, appends are concatenated server-side, and expected_version is the concurrency guard instead of a vector. The same five exist as PUT, DELETE and POST /v0/open/… over HTTP.
pull_changesImplement these and the server never surprises you. Skip the second one and your client re-reads the same node forever.
Every write is signed. The brain accepts a write only from a credential admitted to the brain, and records which one wrote. Reader control is signed the same way, in the membership chain, so both halves of who may touch a page are checkable from the outside. The sync client validates every payload against the shared contract before it sends, so rule five above is behaviour, not advice.
Read the security model and decide whether the claim holds.