instibrain
instibrain  /  how it works

One rule decides every write. Your device decides the rest.

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.

ten fields · one rule · two areas · two transports

brain.instibrain.ai/v0 desk-2a
PUT /v0/nodes/7f3a4c-…-9d21 { "vclock": { "laptop": 4, "desk": 3 }, "content_b64": "k7Qm2bR1x9…" } 409 concurrent { "server_vclock": { "laptop": 5, "desk": 2 } } # neither vector dominates. the server holds # ciphertext, so it stops here and says so. # decrypt both, merge, join, bump, push again 200 { "ok": true, "id": "7f3a4c-…-9d21" }
one 409, start to finish
the node

One node. Ten fields. One of them decides who can read it.

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.

FieldMeaning
iduuid — the only stable server-side identity
pathLogical location. Ciphertext as soon as key_id is not none
scopeACL scope name; ops is the default. Always plaintext
key_idNames an encryption key. It never contains one. none marks an open-area node, and is the one value that means "readable here"
versionCounter. The client's own on a sync write; the server owns it in the open area, where it is also the concurrency guard
vclockVersion vector {deviceId: counter} — the concurrency truth for a sync write
content_b64The body. Base64 on the wire, bytea at rest. Opaque to us in the secure area
content_hashIntegrity hash. Client-computed on a sync push, server-computed on an open write
deletedTombstone flag
updated_atServer clock, microsecond precision. Half of the read cursor
what the two values of key_id mean

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.

the rule

The server makes one decision, and it is arithmetic.

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.

case 01

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.

{laptop:5, desk:2} over {laptop:4, desk:2}
200 accepted
case 02

It is identical

Equal vectors do not dominate. A rewrite that advances nothing is refused rather than quietly applied over a copy someone else may hold.

{laptop:5, desk:2} over {laptop:5, desk:2}
409 + server_vclock
case 03

Neither dominates

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:4, desk:3} against {laptop:5, desk:2}
409 + server_vclock
1 · a write lands
laptopedits, pushes {laptop:5, desk:2}
server200 it dominates {laptop:4, desk:2}
deskedits offline {laptop:4, desk:3}
2 · a fork
deskpushes {laptop:4, desk:3}
server409 neither dominates. It returns {laptop:5, desk:2}
3 · the device merges
serverwaits it holds ciphertext and cannot merge
deskdecrypts both, merges joins to {laptop:5, desk:3}
4 · the merge lands
deskbumps, pushes {laptop:5, desk:4}
server200 it dominates {laptop:5, desk:2}

The server decides whether. Your device decides what.

the rejection

A rejection names the field and never the value.

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.

PUT /v0/nodes/7f3a4c-…-9d21  ·  a path over the 1024-character limit
# sent { "path": "cGF0aC9jaXBoZXJ0ZXh0…1400 characters…", "vclock": { "laptop": 5 } } # returned — 400 { "error": "invalid request", "field": "path", "reason": "too long" } # the field. the reason. never the bytes.
the client's half

Four functions, and the fork is yours to close.

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

join(a, b)—least upper bound. The vector a merged node must carry.

bump

bump(v, deviceId)—record one local edit against your own counter.

concurrent

concurrent(a, b)—name a real fork. Only a device holding plaintext can close one.

isValidVClock

Reject vector shapes that would poison ordering before they reach the wire.

the 409 path

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.

the wire

Two transports, two areas, one contract.

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.

POST /v0/nodes

Mints a uuid and hands it back. No row is written, so an identity costs nothing.

GET /v0/nodes

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.

GET /v0/nodes/:id

One node. The merge path needs the server's copy before it can join anything to it.

PUT /v0/nodes/:id

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.

is_fresh

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.

pull_changes

Changed nodes, ascending, 200 at a time. The same compound cursor as HTTP.

push_changes

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.

search_brain

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.

read_node

One open-area page, by path, as markdown. list_nodes walks a folder and node_history replays how a page got here.

write_node, append_node, patch_node, move_node, delete_node

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.

500
nodes per HTTP page, ascending from the cursor
200
nodes per MCP pull_changes
10MB
cap on one node's base64 content field
256
devices allowed in a single version vector
what a client must do

Six rules, and a rejection becomes a bug rather than a round trip.

Implement these and the server never surprises you. Skip the second one and your client re-reads the same node forever.

  1. Mint the node id locally, or call POST /v0/nodes.
  2. Carry the cursor as the (updated_at, id) of the last node you accepted, and send both. A stamp alone is not a cursor: rows share stamps, and a client that rounds one never moves past them.
  3. Carry a stable deviceId, and bump its counter on every local edit.
  4. On 409 concurrent: fetch the server node, merge after decryption, join, bump, retry.
  5. Validate against the shared contract package before sending.
  6. Never expect the server to resolve a secure-area fork. It refuses by design.
the write side

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.

One contract, two transports, and you choose what we can read.

Read the security model and decide whether the claim holds.