Brainy Brainy
Docs Brainy

brainy host

In this section

brainy host is the process that takes writes. This page is for the person who runs it: what it serves, what it refuses, how its keys work and how it stops. To stand one up on a single Linux machine from nothing, start at Run brainy on your own box.

What it is

One process, started by an operator, that opens many brains and answers for them over HTTP, MCP and SSE:

npx brainy host \
  --brains-dir /srv/brains \
  --listen 127.0.0.1:8400 \
  --keys /etc/brainy/keys \
  --revoked /etc/brainy/revoked.json                   # optional
  --issuers /etc/brainy/issuers                        # optionalBASH

Every subdirectory of --brains-dir is a brain, named by its directory. The host opens one lazily, on the first authorized request that reaches it, with the SDK's ordinary read-write open — so this process is the one holder of every brain it opens. That is the whole point: the writer lock, the memtables and the commit path live here, and every other service becomes a client of this door rather than a second process opening the same store.

It is the engine's process, never an application's

brainy host belongs to the engine. An application does not embed it, does not start it in-process, and does not open a brain of its own: it holds a key and speaks to the door. When two processes both open one store read-write, one of them loses — the writer lock is the mechanism, and an arrangement in which a store's owner is ambiguous has already lost the argument about who is responsible for its durability.

How it relates to the native server

brainy-serve is the direction of travel. The Rust binary already answers every READ natively and refuses every WRITE by name (NotYetNativeError) until the core owns the write path. As each door family goes native, it moves to serve and leaves here. Today the two run side by side on one machine: this process takes the writes, the native server answers reads (see the table in Run brainy on your own box).

Two rules make that move invisible to every client:

  1. The host never refuses what serve answers. If serve answers a door, this host answers it too, at the same path, with the same method.

  2. Everything else is refused BY NAME. No door of this process is ever quietly absent. A door no wire can carry is InProcessOnlyError; a transport this build has not landed is TransportNotWiredError; a brain that is not served is NoSuchBrainError.

When serve answers every family, this process is no longer needed and no client changes a line: the keys file is already serve's format, the paths are already serve's paths, the MCP tool names are already serve's tool names, and the refusal names are already serve's refusal names. A client cannot tell the two apart on a refusal, which is the property that makes the swap a deployment detail.

What it serves today

path

what it carries

POST /mcp

MCP over streamable HTTP — every tool the stdio bridge serves: the engine reads and writes, the VFS, memory, guardrails, the accords' reads and writes

GET /v1/brains/{brain}/changes

the change feed: replay from the generation log, caught-up, then the held live tail

/v1/…

every door of docs/serve-contract.json, at the same method and path, through the same in-process dispatch

GET /v1/health

what this process is, what it holds, and the arbiter's budget

/v1/brains, /v1/keys

provisioning, on the service key — see below

GET /v1/ws

a WebSocket session: every door by its CONTRACT name, pipelined

The route table (src/host/routes.ts) is docs/serve-contract.json transcribed, and src/host/routes.test.ts joins the two in both directions on every run — a door added to the contract without a route reds the suite by name.

Not invented here. The memory and guardrail tool families are reachable over POST /mcp only. The contract declares HTTP paths for memory.turn and memory.abstract and for nothing else in those families, and a host that minted paths for the other thirty would be publishing a surface no brainy-serve client could ever move onto.

Provisioning — the service key's own doors

Everything a sign-up does, without an operator on the box. These are doors brainy host ADDS: brainy-serve has no provisioning surface at all (its brains are named on the command line and its keys file is placed by the host), so nothing here can drift from a contract it is not in, and they are kept out of the route table for exactly that reason.

door

what it does

POST /v1/brains {name}

creates the directory and OPENS it — a caller told "created" writes to it next. A name that is taken is BrainExistsError; a name that is not one path segment is BadBrainNameError

GET /v1/brains

every brain with its state (open / closed / archived), and generation + rows for the ones the arbiter currently holds

POST /v1/brains/{brain}/archive

closes it and writes a marker. Nothing is deleted

POST /v1/brains/{brain}/unarchive

serves it again, whole

POST /v1/keys {kind, principal, brains}

mints one key, appends its line, and returns the key once

DELETE /v1/keys/{sha256}

revokes that key

Every one of them wants the service key: a principal's key that could reach them could mint itself a wider key. Every one takes effect without a restart — the key a mint returns is honoured on the very next request, not one poll interval later, because the door that wrote the file re-reads it before it answers.

Three laws they are built around.

  1. Nothing is ever deleted. Archiving closes the brain and writes a marker; the store is untouched and unarchiving serves the same rows again. There is no delete door and there will not be one — retention is not a setting. An archived brain is refused by BrainArchivedError, never by NoSuchBrainError: a caller told the latter would go looking for data that is exactly where it left it.

  2. The marker lives OUTSIDE the store — <brains-dir>/.archived/<name>.json, never a file inside a store this process did not author. A store's directory belongs to the engine; a control plane's bookkeeping does not go in it. (A name beginning with . is not a legal brain name, so the register is never itself listed as a brain.)

  3. A key is shown exactly once. The host mints one only when the service key asks — never on its own, never at startup — and the answer that carries it is the only time it exists outside the keys file. There is no door that reads a key back: a key a server will re-read aloud is a key its logs and its proxies have already seen twice. What is safe to keep is the fingerprint, which is also the id a key is revoked under.

A minted line is PARSED before it is written — a line this host would refuse to load is a line it must not add, and finding that out at the next reload with the key already handed out would be the quiet wrong answer. The keys file is appended to, never rewritten: it is the host's file and this process adds to it.

The backup door (--snapshots-dir)

persist over the wire (POST /v1/brains/{brain}/ceremonies/persist, status by GET on the same path; service key only) takes a snapshot NAME and writes it at <root>/<brain>/<name> inside the folder this flag names. It never takes a path: a caller-named path would be a write-anywhere primitive on this host's disk. The order of checks, and why each exists, is src/host/snapshotTarget.ts's module header; the operator page is docs/backup-and-restore.md.

  • The flag is optional and never defaulted. Absent, the door refuses SnapshotRootNotConfiguredError and every other door serves.

  • The host never creates the root, and never deletes anything under it.

  • A root equal to, inside or containing --brains-dir refuses to START (SnapshotRootUnusableError); the door repeats the check on real paths.

  • The door resolves and checks before it registers a run: a name that is not one legal segment, a symlinked folder or target, and a target that already holds files are synchronous refusals, never failed runs.

  • Nothing about the root's absolute path reaches the wire: refusal text is scrubbed to <snapshots-dir>, and the whole text goes to this host's log.

The reading-door callback

memory_write can hand a caller a browsable link for a file it just wrote — the engine mints nothing and contacts no one on its own. --reading-door <url> is the ONE operator flag that names an outside URL this process is allowed to reach, alongside --auth-server's own outside-facing role. Any receiver that answers the shape below works.

Requires a service-key line (! reach) in --keys — startHost REFUSES TO START when --reading-door is set and none exists, rather than accept a mint that can never authenticate and would fail identically, forever, on every write, for a reason discoverable only by reading a log line nobody is watching.

The request — one POST per path-bearing memory_write, after both the memory row and the VFS write have already landed:

POST <the --reading-door URL>
Content-Type: application/json
Authorization: Bearer <this host's own service key>

{
  "vfsPath": "/notes/idea.md",
  "owner": "person:you@example.com",
  "summary": "a one-line gist",
  "tags": ["idea", "note"],
  "contentType": "text/markdown"
}

vfsPath is the normalized path the write itself used. owner is the CALLING credential's own id (person:<email> for a static keys-file principal, the token's bare sub for a minted one) — never the service key's. summary/tags are the write's OWN classifier fields (the caller's, when a full classifier was supplied; the MIME-fallback classifier's, otherwise). contentType is read back from the file's own backing entity AFTER the write — the SAME value memory_write's own result and a later memory_read on that path both report, never the caller's raw claim.

The answer, expected within 5 seconds (READING_DOOR_MINT_TIMEOUT_MS, src/mcp/dispatch.ts): { "doorUrl": "<a URL>" }. Anything else — a non-2xx status, an answer with no doorUrl string, a timeout, or the endpoint being unreachable at all — is a MINT FAILURE, never a write failure: memory_write's own result carries doorUrl: null and a named doorUrlReason ("mint-failed: …", naming what happened) instead of doorUrl, and the memory row and the file it already wrote are unaffected.

Three conditions skip the mint outright (no request sent, no failure to report):

condition

doorUrlReason

the write carries no path (a plain memory)

(neither field is present at all — nothing invented)

--reading-door was not given

"reading-door-not-configured"

the call carried no caller credential (the stdio bridge)

"no-caller-identity"

There is no service-key row here any more. Since 12.12.0 a bare service key is refused ServiceKeyCannotWriteError before this door writes anything (see "The caller on the call" below), so "service-key-write" names a condition that can no longer arise — and a service that asserts the user on the call mints for that person, which is the row this door always wanted.

No linkId. memory_write's own result carries doorUrl only, never a second id — the receiving door (whatever answers the --reading-door URL) owns its own id space entirely; this host never sees it and mints nothing of its own to pair with it.

Keys, reach and access

The keys file is brainy-serve's, format for format — one key per line, mode 0600, # comments ignored:

# <key>            <kind>:<principal>        <brains…>
bky_9f3a…          person:you@example.com    notes team
bky_2c81…          business:acme             team
bky_7d40…          service:orders            *
bks_a19f…          service:operator          !TEXT

* is every brain the pool can open; ! is the service key — the engine mechanics (health, diagnostics, repair, warm, stats, the holder's lifecycle) on every brain, and no principal's rows. A service key is not a superuser: it opens a different set of doors, not more of the same ones.

Five questions, asked separately and in this order by one gate (src/host/authorize.ts) for every transport — the first four all before the pool is asked to open anything:

  1. Access class — is this door a mechanic's? Then it wants the ! key.

  2. Reach — does this credential name this brain?

  3. The ruling question — guardrails.ratify, guardrails.unlock and guardrails.freeze need the owner's own credential (see Minted keys), asked of every caller including a static key.

  4. Scope — may this bearer mutate at all? Asked only of a minted token; a static key carries no scope and never has.

  5. The door itself.

A caller who fails (1) or (2) learns nothing about whether the brain exists.

Which brain a call reaches. A key that reaches exactly one brain reaches it for every call, and nothing needs naming. A key that reaches several: an HTTP path names the brain, and an MCP tools/call carries brain in its arguments — a call that names none is a parameter error. initialize answers with the generated instructions and resources/list with an empty list for such a key, because neither has one store to speak for. This is native/serve/src/mcp.rs's rule, mirrored.

Refusals are flat on purpose — for a STATIC key. A wrong key, a key whose line was removed, a revoked key and a request with no Authorization header at all all answer UnauthenticatedError, with the same message. The difference between them is a probe, and this host hands nobody one.

A MINTED bk1. token is the deliberate exception: it fails loudly and specifically, by the name of the check it failed. A minted token is signed rather than secret, so there is no near-miss an attacker can enumerate — there is nothing to gain from hiding which check failed, and everything to gain from an operator being told. See Minted keys.

Minted keys

brainy host verifies bk1 tokens exactly as brainy-serve does — the same wire format, the same issuers file, the same claims, the same refusal names and statuses. A token an issuer minted for one process opens the other with no change at all, which is the property that makes the retirement rule above a deployment detail. The format itself is documented once, in docs/serve.md → Minted keys; what follows is only what is particular to this process.

The issuers file is the same file. --issuers <path>, or — left unset — $BRAINY_SERVE_ISSUERS, or ~/.config/soulcraft/brainy-serve-issuers: serve's own default placement, so a box that runs both reads one file and an operator places one. Mode 0600, refused before it is read if the group or the world can read it. A MISSING file is not a refusal to start; it means this process trusts no issuer, and every bk1 bearer is then answered BRAINY_KEY_UNKNOWN_ISSUER — the same "not placed yet" story an empty keys file tells for static keys. The file is re-read when its mtime moves, so a rotated kid lands without a restart.

The refusals, by name and status.

condition

name

status

the bearer does not parse as a token

BRAINY_KEY_MALFORMED

401

its iss (or iss/kid) names no trusted issuer

BRAINY_KEY_UNKNOWN_ISSUER

401

no key its issuer is trusted for verifies the signature

BRAINY_KEY_BAD_SIGNATURE

401

its jti is on the revoked list

BRAINY_KEY_REVOKED

401

it is past exp, beyond the 60-second skew allowance

BRAINY_KEY_EXPIRED

401

it was minted for a different brain

BRAINY_KEY_BRAIN

403

it grants read and the door mutates

BRAINY_KEY_SCOPE

403

it does not carry owner and the door RULES on guardrails

GuardrailOwnerScopeRequiredError

403

GuardrailOwnerScopeRequiredError is asked of EVERY credential, not only a minted one — so a STATIC key calling guardrail_ratify, guardrail_unlock or guardrail_freeze is now refused by that name too, where it used to meet the MCP bridge's own GuardrailOwnerCredentialUnavailableError. That is the one place a static key's behaviour changed with 12.12.0, and it is the native server's rule (it asks the ruling question of every principal, and a keys-file principal has no scope at all).

The unknown-issuer message is native/serve/src/refusal.rs's own prose, verbatim, including its closing "restart after changing it" — which is stale on both processes now that the issuers file is re-read on its mtime. It is kept word for word ON PURPOSE: a client or an operator must not be able to tell the two servers apart by their wording. It is fixed in both or in neither.

A wrong-brain refusal is BRAINY_KEY_BRAIN for a token and ForbiddenError for a keys-file key — two different credentials failing the same check, and the caller is told which one they presented rather than left guessing whether a reach needs widening. That is serve's own rule (native/serve/src/dispatch.rs::authorize_door), mirrored.

The owner triple — ["read","write","owner"], in any order — is the owner's own credential, minted by a human sign-in. It is the only thing that opens guardrail_ratify, guardrail_unlock and guardrail_freeze over POST /mcp; a ["read","write"] token that may write this brain's rows all day still cannot rule on the rules that bind it, and a static key (which carries no scope at all) is refused there too. The keys file has no owner marker and will not get one: an owner credential a host could paste into a text file would not be one. A ruling is recorded under the token's own sub.

One divergence, stated rather than hidden. brainy-serve builds each issuer key through a library that decompresses the ed25519 point and refuses 32 bytes that are not a legal one; node:crypto stores the bytes without decompressing them. So a host that placed a CORRUPT key line makes serve refuse to start, while this process starts and answers BRAINY_KEY_BAD_SIGNATURE to every token naming that key. Both refuse the token; they differ only on when the bad file is noticed.

Sign-in — this door as its own issuer

--auth-server <url> points this door at SOMEONE ELSE's issuer (an identity service you already run, or Soulcraft's). --sign-in [<specifier>] makes THIS door the issuer — the two are mutually exclusive, refused at start by name when both are given.

<specifier> is a package name or a path, resolved with this process's own module resolution, default @soulcraft/identity-kit — Soulcraft's sign-in module, the same one the hosted door runs on, so one adapter, one bk1 issuer and one set of reference test vectors serve both and cannot drift. A specifier that will not resolve, or resolves to a module with no createSignIn export, refuses to start by name (SignInModuleUnavailableError) — never a door that silently came up without the sign-in it was asked for.

Also required whenever --sign-in is given:

  • --public-url — the kit needs a fixed origin at construction, unlike every other surface here, which infers one per request from the Host header.

  • --sign-in-mail <url> — this door's own mail sender: how a sign-in code reaches a person. POSTed { email, code, expiresInMinutes }, bearer-authenticated with this host's own service key, the identical pattern --reading-door already uses. A non-2xx answer or a timeout refuses the sign-in loudly — the kit's own contract, never a silent retry.

  • a service-key line (! reach) in --keys — the mail POST's bearer.

Optional:

  • --door-id <id> — this door's own issuer id (the iss claim, the passkey RP name, the sign-in page's title). Defaults to --public-url's hostname, which is legal for an ordinary domain; named explicitly when it is not (an IPv6 literal, most plausibly).

  • --sign-in-accounts <file> — the brainFor register, below. Defaults beside --keys.

Where accounts live. The kit's better-auth adapter treats a Brainy instance as its database — accounts, sessions, passkeys. That store is a SIBLING of the served brains, never one of them: <brains-dir>/.identity, dot-prefixed so it is never listed among the subdirectories this host serves as a tenant brain (the same trick the provisioning door's own <brains-dir>/.archived marker directory already uses). It is opened once, at start, directly — never through the residency arbiter above, which budgets and evicts TENANT brains; this is the one store the process itself keeps resident for its own lifetime.

Which store a signed-in account opens. The identity store holds accounts, never a person→brain mapping — that is this host's OWN register, beside the keys file, one line per account:

# <account id, the kit's own `sub`>   <store>
person:alice@example.com              aliceTEXT

A MISSING register is not a refusal to start (no account is recognised yet, the same "not placed yet" story the issuers file already tells). An account with NO line refuses that sign-in by name — the sign-in module's own token door answers sign_in_failed and hands out no key. There is no self-serve provisioning: nobody signs in to a brand-new brain the door invented on the spot. You create a brain yourself (see "Your first brain" above) and add a line to the register; an unregistered person is refused rather than silently handed a new brain.

Verification joins the same ladder, never a second one. The door's own keyring mints bk1 tokens exactly like any other issuer's; its public key is folded into the SAME issuers table verifyToken already reads (the file --issuers names, plus this one), so a token this door minted verifies through the identical claims table and the identical refusal names a token from any other trusted issuer does. A rotated key id on the self-hosted issuer lands on the next restart: the merge happens once, at start, unlike the issuers FILE itself, which is polled for a moved mtime.

Discovery. The kit's own handle is mounted AHEAD of every other route: it claims /auth/* and serves RFC 8414 at /.well-known/oauth-authorization-server, and this door's own RFC 9728 document (/.well-known/oauth-protected-resource) then names ITSELF as the authorization server rather than --auth-server's URL — the same discovery flow described in Minted keys above, with this door standing in the external issuer's place.

Not yet enforced: the entitlement ceiling. A self-hosted issuer's token may carry entitlement absent or "expired" only; "suite" is meant to belong to the hosted issuer alone. That ceiling is not checked by token verification yet. This door never asks the sign-in module for an entitlement claim at all, so no token THIS door mints can carry "suite", but a hand-written issuers file naming a third issuer as "suite"-capable would not yet be refused for it.

Revocation

Two paths, both without a restart:

  • The keys file itself is re-read when its mtime moves. Remove the line and the key stops answering within a second.

  • --revoked is brainy-serve's own file, shape for shape: {"revoked":[{"jti":"<id>","exp":<unix seconds>}]}, re-read on its mtime. Left unset it defaults to revoked.json beside the keys file, and an absent file is not a refusal to start — it means this server has been told of no revocations, and DELETE /v1/keys/{sha256} creates it.

Serve lists MINTED tokens there by their jti, and the file's own law is that it names no secret — a jti is an opaque id, not a credential. A static key has no jti, so this host lists a static key under its SHA-256, which is an opaque id and not a credential either. The file still names no secret, it still parses on serve, and revocation is enforced for real rather than loaded and never consulted:

printf %s "$KEY" | sha256sum        # the id to put in the "jti" columnBASH

exp is the credential's own expiry, copied beside it so the list can be pruned; an entry past its exp is never matched. A static key has no expiry of its own, so a revocation this host writes carries the last second of 9999 — a real timestamp that parses everywhere and outlives any deployment that could ask, rather than a sentinel the format does not have.

This is the one place the host's shape had to be decided rather than transcribed. The alternatives were to put the key itself in a file whose whole premise is that it names no secret, or to accept --revoked and never consult it.

The change feed

GET /v1/brains/{brain}/changes?since=<generation>, Accept: text/event-stream.

Frames are brainy-serve's frames:

id: 42.0
event: change
data: {"generation":42,"index":0,"kind":"entity","op":"update","id":"…","denseInt":7,
       "hasVector":true,"entity":{"type":"document","subtype":"note",
       "metadata":{"title":"…","tags":["work:example"]},"service":"docs-ingest"}}

id: 43.0
event: change
data: {"generation":43,"index":0,"kind":"relation","op":"update","id":"…","denseInt":8,
       "hasVector":true,"relation":{"id":"…","from":"…","to":"…","type":"relatedTo",
       "fromTags":["team:alpha"],"toTags":["team:beta"]}}

event: caught-up
data: {"cursor":43,"hasMore":false,"brain":"notes","liveTail":{…},"meta":{"ms":3.1}}TEXT

One commit is one generation, however many ops it carries. transact([…]) commits atomically and advances the store exactly one generation, and the feed walks it as N frames under that generation — never a refusal, and never a split of your transaction into generations the engine never committed. A frame's id is <generation>.<index>, where index is the commit's own op order counting from 0, so a four-op transact is 42.0 … 42.3 and a single-op write is 42.0. (generation, index) is the pair you dedup on.

What a frame carries. kind is entity or relation. op is update (an after-image) or delete (a tombstone) — the log does not record whether a write was a row's first, so the feed never claims add; a consumer that needs that distinction holds its own knowledge of what it had seen. An update carries denseInt (the integer handle minted at append — the identity the engine's own projections key by) and hasVector, plus the post-commit view — built with the SAME reserved/custom split the in-process feed and every read path use, so neither can ever disagree with a get() of the same row:

field

on

carries

entity.type / .subtype

every entity update

the NounType and (when set) the subtype

entity.metadata

every entity update

the FULL custom metadata bag — every field you wrote, tags included; never file bodies (read those by path)

entity.service

an entity update whose writer set one

the writing service

relation.id / .from / .to / .type

every relation update

the edge's own id and its endpoints

relation.fromTags / .toTags

a relation update whose endpoint(s) carry a tags custom field

that endpoint's tags, read LIVE at frame-serve time (not from the wire — the wire carries no entity state) — absent when an endpoint has no tags, or no longer exists; a missing endpoint never fails the frame

A delete carries kind, op and id and nothing else: a tombstone is body-less on the wire, and the feed does not invent an int, a type, endpoints, metadata or tags it was never given.

This is BrainyChangeEvent parity, field for field (docs/change-feed.md): the in-process listener and this stream describe the SAME commit with the SAME entity/relation views, so a client holding the graph in memory never fetches a row per frame — it applies the frame directly. brainy-serve's frames do not carry this yet (see that server's own docs) — only a host you run yourself, through this door, serves the parity fields above.

Then the connection stays open. Serve answers liveTail.held: false and ends the stream — it attaches every store read-only, so no commit can happen in its process and a held connection would promise an event that cannot occur. This host is the writer, so it holds: brain.onChange wakes the stream, and the stream then drains the generation log from its cursor.

Why the wakeup drains the log instead of forwarding the event. A BrainyChangeEvent carries the API's own vocabulary (add / relate / remove), no dense int and no index. Forwarding it would put a second document shape on one stream, and a client that reconnected would then see the same change again in the other shape. One shape, from the log, for both halves — a transact's N mutations reach onChange as N events under one generation and reach this stream as the same N frames under that generation, because both halves are reading the same commit. MEASURED: a fact is readable from the log the moment onChange fires, with no flush in between, so the wakeup costs nothing in latency.

The belt is inside the server, not a client polling a door. A held stream also drains the log on its own two-second tick (FEED_POLL_MS). That is not a weaker promise than the wakeup — it is what makes the promise keepable at all: onChange registers on ONE Brainy instance, and the arbiter may evict and readmit a brain at any time, which replaces that instance. Draining the log on a tick makes the stream's correctness a property of the LOG rather than of a listener's lifetime. A client never polls: it holds one connection and is pushed to. Pinned by closing the brain out from under a live stream and requiring the next write to arrive anyway.

Resuming, and the one law behind it: a resume never lands inside a generation. A consumer holding part of a commit holds part of a transaction it can never complete, so both resume spellings land on a commit boundary:

you send

what it says

what you get

?since=<generation>

"I have all of that generation" — what caught-up hands back

everything strictly above it

?since=head

"nothing — start from now"

caught-up with a REAL cursor (this brain's generation at attach), no replay, then the live tail from there

Last-Event-ID: <generation>

the same

everything strictly above it

Last-Event-ID: <generation>.<index>

a FRAME id — what a browser's own EventSource echoes back. It proves you saw one op of that commit and nothing about the rest

that whole generation again, then everything above it

Last-Event-ID wins when both are present, and over ?since=head too — a header only exists because this stream already delivered that frame down that URL, which is later by construction than any query parameter.

A since beyond this brain's own head is refused, not silently accepted. since names what you have already seen; a generation past the real head is not that — it is a promise the stream can never keep (MEASURED on 12.7.0 before this law: head=1, ?since=6 answered caught-up with cursor 6 and then held a connection that would never speak). 400 BadQueryParameterError names the real head in the refusal body's head field. ?since=head itself can never trigger this — it resolves to the real head by construction.

So the feed is at-least-once per generation: a client that dropped mid-commit receives the whole commit again rather than being left holding a head with no tail. Dedup on (generation, index) — the pair is stable, so a repeat is free to discard. Within one connection nothing repeats, and a page is never cut inside a generation: ?limit= is the point past which no NEW commit is started, and a commit carrying more ops than the limit is still delivered whole (a limit that is not a positive integer refuses with BadQueryParameterError rather than quietly becoming unbounded). A heartbeat comment line goes down an idle stream every 15 seconds.

What it refuses, loudly. A store with no fact log (NoFactLogError); and, as FactLogRefusedError: a manifest version this reader does not read, a manifest naming a sealed segment without a file or listing segments out of generation order, a torn SEALED segment, a v1 segment (it predates the per-record wire this feed addresses by), and a record type this reader does not map. An incomplete frame at the end of the append target is not damage and never refuses — it is a frame that has not landed yet, and the next drain reads it. A change feed that served around damage would be a silent gap in a caller's view of its own history.

The WebSocket session

GET /v1/ws, Upgrade: websocket, Sec-WebSocket-Version: 13, and the same Authorization: Bearer every other door wants — checked BEFORE the upgrade is granted, so a caller that failed the key check is never handed a live socket.

One frame shape, text frames of JSON:

{"ref": 7, "door": "find", "brain": "notes", "id": "…", "body": {…}}
{"ref": 7, "ok": true,  "door": "find", "result": {…}, "meta": {"ms": 3.1}}
{"ref": 7, "ok": false, "door": "find", "error": {"name": "…", …}}JSON

Every answer echoes the ref, so a client pipelines without keeping a queue in step. A door is named by its CONTRACT name; a record-profile door by its QUALIFIED name (accords.read, memory.turn) — not an alternative spelling, because read and resolve exist in both families and an unqualified name has always meant the engine's door.

Deliberately small, and the omissions are refusals rather than silences: a BINARY frame is BinaryFrameError (a binary encoding over a socket is worth declaring, not inferring from an opcode); a FRAGMENTED frame is FragmentedFrameError (a session that dropped the tail of one would answer half a question); an UNMASKED client frame, a reserved bit, an oversized frame (8 MiB) and an oversized control frame each end the session by name with RFC 6455's own close code. Ping is answered with pong; close with close.

The session asks the same three questions in the same order as every other transport — access class, reach, then the door — and reaches the same dispatch, so a refusal here is the refusal there by the same class name. close is the POOL's on this transport too.

Who a write is attributed to

A profile write — an accord round, a guardrail rule, a memory turn — is attributed to a CALLER, and two of those doors refuse outright without one: AccordsHostCannotWriteError and MemoryHostCannotWriteError, both missing: "caller". That is the right refusal: a round or a turn stamped with whoever the request claimed is one nobody can be held to.

This process opens ONE instance of each store and answers many callers over it, so the instance speaks for nobody. The caller therefore comes from the credential on the request: a static key's own kind:principal (the keys file's second field), or a minted token's sub. The owner rung is a separate question with a separate answer — only a signed token can carry it, because only a signature proves it.

Every one of those doors is also reached as a METHOD of the brain (accordsCall, guardrailCall, memoryCall) rather than by reading the namespace and calling it afterwards. Reading a namespace off the residency arbiter's handle is not a door, so the call that follows is not counted in flight — and a brain with no door in flight can be evicted. Through the method, the store is held for the call's whole duration.

One gap, named rather than papered over. memory.turn() takes a caller and stamps it; remember, retire, cite, reinforce and consolidate take no caller at any layer of the memory API — there is no author field on a remembered row to put one in.

This host changes nothing about that, and it is not a release blocker: the same doors write unattributed IN-PROCESS today, so a store reached through this door is attributed exactly as well as a store opened directly. Closing the gap is a memory PROFILE change — an author dimension on the row, and the door signatures to carry it — which is the owner's decision to rule on, not a wiring fix to invent here.

The caller on the call

A multi-tenant service holds ONE key. Open a client with it and every request's principal is the service — which is right (a service is not a person, and the raw write doors refuse it ServiceKeyCannotWriteError for exactly that reason) and leaves nowhere to say who the call is actually FOR.

The shape is the usual one for an inside service: it presents its own secret AND the user on the call. So from 12.12.0 a request authenticated by a SERVICE key may carry three headers:

header

required

value

X-Brainy-Caller

yes, to assert anything

the actor the call is for — person:you@example.com, the same actor form guardrails's rule subjects read

X-Brainy-Owner

no

exactly true or false; absent reads as false

X-Brainy-Roles

no

comma-separated role names, for role:<name> rule subjects

src/host/callerAssertion.ts reads them ONCE per request — on /v1, on POST /mcp, and at the WebSocket upgrade — and hands every write path one credential: { caller, owner, roles, via: 'service:<service id>' }. Nothing downstream re-reads a header.

What an assertion changes. The request is the asserted person's for every question that asks who is writing: the actor a row is attributed to, the actor a guardrail rule binds, the roles a role:<name> subject matches. And because the caller is no longer the service itself, the doors that refuse the service key as an author stop refusing — that is the mechanism, not a side effect.

Which doors those are — entities, files AND memories. One law, three families, and since 12.12.0 no exception among them:

family

what a bare service key gets

what an asserting one gets

the eleven raw engine writes (add, update, remove, addMany, updateMany, removeMany, relate, unrelate, updateRelation, relateMany, transact), and import, whose rows land through them

ServiceKeyCannotWriteError

the write, authored by the asserted person

the ten VFS write verbs (writeFile, appendFile, mkdir, rmdir, unlink, rename, move, copy, setxattr, removexattr)

ServiceKeyCannotWriteError

the write, authored by the asserted person

the memory WRITE doors — memory_write (hostMemory.write) and every memory_* tool the engine governs or the contract declares a write (remember, turn, declare, retire, reinforce, cite, strengthen, abstract)

ServiceKeyCannotWriteError

the write, with the row's author stamped as the asserted person

Every memory READ (memory_read, memory_list, memory_recall, memory_context, memory_stats, …) is untouched: a bare service key reads exactly what it always read. The write set is DERIVED, never hand-listed — src/mcp/dispatch.ts unions MEMORY_WRITE_DOOR_NAMES (what the engine's own guardrail governance treats as a memory write) with the namespace: 'memory', kind: 'write' entries of CONTRACT_PROFILE_DOORS, and reads HOST_MEMORY_WRITE_DOORS for memory_write itself, so a door added to either declaration joins this law without anyone remembering to add it.

Until 12.12.0 the memory family had no such check at all, so a bare service key stamped memory rows with service:<id> as their author. That is closed: a bare service key is now refused on every memory write.

What it does not change. The authorization ladder (src/host/authorize.ts) is asked of the credential that was PRESENTED, and only of it. Reach is still the key's reach. Scope is still the token's scope. And the three doors that RULE on guardrails — guardrails.ratify, guardrails.unlock, guardrails.freeze — still want a minted credential whose own scope carries the owner rung, so X-Brainy-Owner: true reaches everything PAST that ladder (the accords' own owner-only door, the credentialed guardrails namespace's owner rung, the reading-door mint) and none of those three. Asserting nothing leaves every answer exactly as it was.

Who may assert: service principals, nobody else. A PERSON's or a BUSINESS's key carrying any of the three is refused by name — CallerAssertionRefusedError, 403, reason: "not-a-service" — never ignored, because an ignored assertion is a row attributed to the wrong actor and a sender who never learns.

Two distinctions decide that gate, and both are load-bearing:

  • KIND, not reach. service:orders * may assert, and so may service:operator !. Reach is a different question, still asked separately by the same gate.

  • A STATIC key, never a minted bk1 token. src/host/token.ts builds a verified token's principal with kind: "service" too — the field is not the question it looks like — and a minted token is the opposite of a credential that may speak for somebody else: it is signed FOR one subject, carries that subject's own proven scope, and already IS the caller. Letting one assert would hand any agent holding a read/write token the power to write as a person of its choosing AND to claim an owner rung nobody signed, straight past the signature that makes a token worth verifying. It is refused by the same class, and a pin says so.

The credential that MAY assert is the one an operator placed in the keys file at mode 0600 for a service that runs inside your own environment. That is the trust boundary this whole mechanism rests on, and it is one file rather than a signature anybody holding an issuer key can mint.

A SERVICE's own assertion this host cannot read earns the same class with reason: "malformed": an empty or over-long caller, an owner header that is not exactly true/false, a roles header naming no role, or an owner/roles header with no caller beside it. Refused rather than guessed at, for the same reason.

X-Brainy-Owner is BELIEVED, and here is why. A host that could look up a brain's owner should derive the rung from that fact and refuse a header contradicting it. This one cannot: src/host/pool.ts knows a brain's name and its residency and carries no owner, and no register to resolve one from. So the assertion is believed, and the trust boundary is stated rather than implied — the service key is an in-house secret the operator placed at mode 0600, and a holder of it already reaches every brain the key names; believing its claim about which of its own users is calling adds no reach it did not have, it only makes the row say who. A host that later learns owners should derive the rung there and refuse a contradicting header. That change belongs in src/host/callerAssertion.ts, in one place, when the fact exists to make it with.

The audit half. via — the asserting service — travels on the credential into every write path and onto GuardrailRefusedError.via, so a refused write names both the person it was attributed to and the service that spoke for them. The STORED accords/memory/guardrail row do not carry it: their write context crosses into the native profile write door, whose shape is fixed in the engine binary. docs/guardrails.md says so in the same words rather than leaving a reader to assume a field that is not there.

Stopping it

SIGTERM (or SIGINT) is a stop, and a stop is REGISTERED WORK rather than a signal listener of this door's own. The engine's shutdown owner owns both signals and the process exit; brainy host hands it two tasks — the population's teardown and this server's own close — and the owner exits once, after the last of them has settled.

That is not a style preference. A second listener beside the owner's is a race between two teardowns, and the one with nothing to do reaches process.exit while the one holding the brains is still closing them: the process is gone in milliseconds, every locks/_writer.lock is still on disk, no locks/_writer.close was written, and the next open correctly complains that this store was abandoned mid-flight. src/host/cleanClose.e2e.test.ts kills a real child process and reads both files off disk.

The pool, and the arbiter under it

src/host/pool.ts answers three questions and no more: which names are brains (the subdirectories of --brains-dir), whether a name is legal, and which store root a name resolves to. Residency belongs to src/host/BrainyHost.ts, the process's own arbiter:

  • the population budget is derived from the control-group limit, total memory, the operating system's availability figure and this process's own baseline footprint — never a constant;

  • each brain's idle window is derived from that brain's observed door cadence, and never falls below the residency floor (60 s): a door arrival buys a stated minute of residency outright, because a quiet host has no cadence to derive from and a window short enough to close a brain between two sequential calls turns every second call into a whole reopen. Memory pressure still overrides the floor — the budget evicts inside it and says so — but idleness alone never does;

  • what the host charges is memory, never disk. Whether to close anything is decided by the process's own resident memory (what the kernel reports as VmRSS) minus what it held at mount, against the budget — one number the operating system keeps exactly, read on every tick for the price of a small file. A brain maps its big files and the kernel faults in only the pages a request touches, so a large store on disk is not a large store in memory, and its disk size is never counted;

  • which brain closes is ranked by what each really holds: the engine's own memory counters plus the pages of that store's files that are in memory right now, attributed per store from /proc/self/smaps. That read is made only when a close has to be ranked, and at most once a minute for the report, because the kernel walks page tables to answer it. The score is how long the brain has been idle times what it holds, over how often it is asked for. A pass that has to close several brains subtracts each one's share before choosing the next, because freed memory does not show in the process's resident figure at once;

  • the only open brain is never closed for budget. A brain that alone holds more than the budget is kept open and serving, and the condition is reported once as overBudgetAdmissions (once per episode, not once per tick): closing it would only reopen it on the next request. The answer is a box with more memory, not a reopen loop;

  • a capped process is seen as capped. The limit is read from the control group the process actually runs under (a container, a service unit or a scope), taking the smallest limit on that group or any group above it;

  • eviction is ranked over those measured footprints, and eviction is Brainy.close() — the whole instance goes, flushed and attested, with background jobs checkpointed so a heal resumes from its cursor on readmission;

  • readmission is an attach, paid transparently by the next door that reaches the brain.

GET /v1/health reports the budget, its derivation, the resident bytes (what the brains hold in memory), the process's own resident memory and the baseline it is measured from, and the resident names, under brainy-serve's own pool block names. What each store occupies on disk is walked once when the brain is admitted, in the background, and appears only in host.report()'s per-store rows.

What that means for the code around it: pool.open(name) hands back the arbiter's HANDLE, not an instance. There is no hold and no release — the arbiter counts doors in flight itself and never closes a brain mid-call — and nothing may cache a live reference across time, because a readmitted brain is a different object. POST /v1/brains/{brain}/close (and brainy_close over MCP) is an EVICTION through the arbiter, which is exactly the right shape: the instance goes, the entry stays, the next door readmits it.

A brain's name must be one path segment. %2F and .. are refused rather than resolved.

On SIGINT/SIGTERM the host ends every held stream and unmounts the arbiter, which closes the population — and close flushes first, so a shutdown never loses an accepted write. An arbiter this process merely JOINED (something else mounted it) is left mounted: two arbiters over one process would be two budgets over one memory.

Ops shape

brains dir

one directory, one subdirectory per store. A brain placed while the host runs is served without a restart.

keys

mode 0600, or the host refuses to start. Re-read on mtime.

revoked

optional, mtime-reloaded, {"revoked":[{"jti","exp"}]}

port

never 0 — a port the kernel chose is a port no client can find

bind

loopback or a private address; this process has no TLS of its own and belongs behind your own TLS-terminating proxy or private network

runtime

Node ≥ 22, and Bun — node:http only, no framework. Pinned by src/host/bun.e2e.test.ts, which starts dist/host/server.js under the real bun and speaks MCP to it.

body cap

32 MiB, serve's own — larger is BodyTooLargeError

encoding

JSON. Serve also answers MessagePack; a caller that will accept only MessagePack is refused by name (EncodingNotServedError) rather than answered in a format it did not ask for.

Differences from the native server

Everything the native server answers, this host answers at the same path with the same method. The differences below are the places the two answer differently, each measured against the running binary and each named rather than hidden.

  • The three caller headers are this host's. X-Brainy-Caller, X-Brainy-Owner and X-Brainy-Roles are read here and refused from a person's or a business's key (CallerAssertionRefusedError). The native server ignores them: it serves no writes, so it has nothing to attribute.

  • counts answers the native server's document under its own names (entities, relationships, their all-tier pair, vectoredEntities, allCountsSuspect, byType). Three differences remain: shape and lastUpdated are absent here; the per-relation map travels as byRelationTypeAllTiers and the public-tier byRelationType is absent; and topTypes is an extra field.

  • find and related answer results / relations, the brain, the store's committed generation, and total — the last only for an unpaged query, since the in-process door returns rows without a count. The native server's cost and stages are not answered here.

  • vfs is one POST door on both servers, and this host serves every operation of it — the ten writes (writeFile, appendFile, mkdir, rmdir, unlink, rename, move, copy, setxattr, removexattr) and the reads. A write binds the request's caller, so a bare service key is refused (ServiceKeyCannotWriteError). Four reads (exists, getxattr, listxattr, history) exist only here. readdir answers direct children; the native server answers every node under the prefix at any depth.

  • transact and asOf cross the wire without a pin. transact answers { generation, timestamp, receipt }. asOf takes a generation number or an ISO-8601 instant — never a snapshot directory — and with read: { door, args } runs one read on the pinned view and releases the pin inside the call. A compacted target refuses GenerationCompactedError.

  • A natural key resolves here. get and batchGet map a non-UUID id to the stable id add() stored the row under; the native server treats an id as a literal on-disk key and answers absent.

  • A held change stream does not pin its brain resident. The arbiter may evict a brain whose only interest is an open stream; the next write readmits it and the stream delivers the change.

  • /contract.json, /agent and /llms.txt are the native server's own generated documents and are not served here.