Trust boundaries
| Party | Sees | Never sees |
|---|---|---|
Your device (serve local, hooks, merkhain mcp) |
Raw captured events and transcript excerpts, all decrypted memories (private and pulled from pools), the full-text index, its own secret keys (in the key store) | Other devices' raw transcripts |
Relay (serve remote) and its operator |
Envelope headers (pool, epoch, record/user/device IDs, creation time), ciphertext sizes, request timing and client IPs, public membership snapshots (user and device IDs, device names, public keys, roles), enrollment state, wrapped (encrypted) epoch keys | Memory plaintext (titles, bodies, project names, provenance), raw events, pool/epoch/device secret keys, enrollment code secrets |
Inference endpoint you select (codexcli → OpenAI via your codex login; claudecli → Anthropic, or whatever Claude Code is configured to use, via your Claude Code login; openai → the configured base_url) |
The captured session events of each extraction job (prompts, assistant messages, tool inputs/outputs, bounded) and the session's working directory and git branch, with known credential patterns replaced by [REDACTED] |
Your keys, recognized credentials in events or in the session directory and branch, other sessions, pool data |
Embedding server you configure (embeddings entries: Ollama, LM Studio; the built-in presets use 127.0.0.1) |
Memory titles and bodies, prompts and search queries, to compute embeddings for recall comparison and, if selected, recall | Raw events, transcripts, your keys, pool data |
| Pool members | Every memory published to that pool, including history from before they joined (team: every epoch the owner holds) | Your private memories, other pools, raw events |
What this means in practice
- The relay is untrusted for confidentiality. Memories are age-encrypted to a per-pool epoch key and signed with Ed25519 by the authoring device; clients verify every membership transition from the genesis and reject forged, rolled-back or tampered records. A malicious relay can withhold or delay records and learn the metadata above; it cannot read or forge memories. Remote deployment needs no decryption keys (the container/systemd relay creates no identity).
- Raw event payloads are kept forever by default. Set
retention.event_payload_daysto remove them that many days after successful extraction (Local memory). Until then, and until freed pages are reused or the file is vacuumed, they remain in the local database. A pruned event's author and similar non-content fields stay, and an older binary's transcript replay puts its payload back until the next retention pass (Databases). - The local database is plaintext. It holds decrypted memories and raw events, protected by owner-only file
permissions and your OS account: a SQLite database and its
-wal/-shmfiles are created mode 0600 whatever the umask, existing ones lose group and other access before they are opened for writing, including as the source oftransfer local, which locks it. A read-only open such asstatus --directchanges nothing and refuses an exposed database or sidecar with thechmod 600to run; SQLite would otherwise add-waland-shmfiles with the database's mode. A database owned by another user is refused, and existing config and data directories are tightened to owner-only. These checks and SQLite's own opens go by path: they protect you only while nobody else can replace entries in the directories leading to the database. The default data directory is owner-only; for a custom path, members of a group that can write to those directories are trusted as much as you are. Root opens a database, its own or another user's, only if every directory and symbolic link on its path is root's and writable only by root (a sticky directory such as/tmpmay be on the way, but neither hold the database nor a link to it). On macOS a directory's access list counts too: an entry that grants anything beyond reading is refused, whatever the mode says, and deny entries are fine. On Linux a POSIX list's write access shows in the group bits of the mode, which are checked. Other kinds of access control, such as NFSv4 lists on a network mount, are not read. There, root may read and write another user's private database and never changes its permissions; this suits an operator's protected copy, because a directory only root can write leaves the owner unable to create the-waland-shmfiles of a live database. Everywhere else root is refused: a service account's own directory, a bind mount or volume owned by another user or writable by a group, including root's group. There is no opt-out: usesudo -u <owner>, run the service as the owner, or move the database. A group that can read a database gives its members no access through merkhain either; they usesudo -u <owner>as well. Use full-disk encryption; treat backups ofmerkhain.dbas sensitive. A PostgreSQL instance database holds the same plaintext: its server, its administrators and its dumps see your memories, so use one you would trust with the SQLite file. When it is not local (a Unix socket,localhostor a loopback address), every command and service refuses to connect unless the effective connection string (URL andPG*environment) usessslmode=verify-fullfor every host: an omittedsslmode(the driver'sprefer),allowanddisablepermit plaintext, andrequireandverify-cado not check the host name. The refusal names the host and port, never credentials. Relay databases hold ciphertext and keep their configuredsslmode. Secret keys are not in the database: they are in the OS key store or a passphrase-encrypted age file, whose new passphrases must not be guessable (zxcvbn strength 3 of 4) because a stolen file can be attacked offline. - Relay databases hold ciphertext and metadata only, on SQLite and PostgreSQL alike;
packaging/ci/postgres-smoke.shchecks that dumps of PostgreSQL relay databases contain no plaintext memory or secret key. Database URLs and their passwords stay in the configuration (orMERKHAIN_DATABASE_URL,PGPASSWORD,~/.pgpass) and are redacted in every output;transfer local --torefuses a URL with a password, since process arguments are visible to other local users. - The inference endpoint receives session content.
openairefuses non-loopback endpoints unless you setallow_remote: true, and sends nothing to them over plainhttp://(transcripts and the API key would cross the network unencrypted) unless you also setallow_insecure_http: true; such a configuration still loads, with extraction paused. Loopbackhttp://needs no opt-in: local inference is trusted, which assumes you trust the machine's local users and administrators. A loopback TCP port is not authenticated: another local user who binds it while your server is down receives what is sent.socket_pathuses a Unix socket whose server must run as you or root (checked with the kernel's peer credentials before anything is sent); this identifies the server only if it listens on that socket itself, since a bridge to a TCP port forwards to whatever holds the port.codexclisends job content to the model behind your Codex login and runs codex with a read-only sandbox, hooks/tools/user config disabled, in an empty directory.claudeclisends job content to the model behind your Claude Code login and runsclaude -pwith no tools, no MCP servers, safe mode (no CLAUDE.md, skills, plugins or hooks),dontAskpermissions and no session persistence, in an empty directory. Neither reads or copies credentials: authentication stays with the CLI. The summarizer run itself is not captured. Before any provider sees an event,internal/secretscanredacts known credential formats (API and cloud keys, tokens, private keys, URL passwords, assignments to a variable whose name ends in a credential word, optionally followed by a number, version or environment, such asAWS_SECRET_ACCESS_KEY,dbPasswordorDB_PASSWORD_PROD; a name with another word after the credential word, such asAPI_KEY_OPENAIorDB_PASSWORD_REPLICA, is not recognized) in its payload, and in the session's directory and branch that accompany every job. Stored events and sessions keep the original, and so does memory provenance (directory, branch, commit):memory_search,memory_timelineandmemory_getresults andmemory_recall's structured content return it unredacted to the local agent that calls them (and so to that agent's model), while the publication gate below keeps a memory whose provenance holds a recognized credential out of pools. This is a backstop: a secret of an unrecognized format still reaches the endpoint. Session content is untrusted input to the model; extracted output is validated against a strict schema. - Embedding servers receive memory text. The compiled-in embedding providers run in-process. Entries of the
embeddingssection (Ollama, LM Studio) send memory text and queries to theirbase_url, which must be a loopback address unless the entry setsallow_remote: true; turn them off withrecall_comparison.embeddings_disabledorembeddings.<name>.disabled. - Sharing is explicit and future-only. Binding a project publishes only memories created afterwards; history needs
publish. Raw events are never published. Removal rotates keys for future records; already downloaded history cannot be revoked. - A removed member cannot backdate new records. A removed device keeps its signing key and old epoch keys, so a colluding relay could offer a freshly signed record for an old epoch. Removal has the owner device sign the digests of the removed devices' records first, and clients apply such a record only if it is listed. Anything unlisted is quarantined, not applied and not deleted: a record the relay hid from the owner looks the same as a forgery, and so does every record of a removed author from a removal made by an older version or against an older relay. Re-admitting the device does not change that, and this version has no way to release those records. Records applied before this check existed stay applied, and an older binary sharing the database does not have this check for records it pulls itself.
- A relay cannot lose records by lying for a while. Its end-of-log marker and the bytes it serves are not signed, so neither makes a rejection final. merkhain pulls the log positions of rejected records again, at a bounded rate, and applies what verifies, and keeps pulling those positions once a day for good, since no answer shows what a position really holds; this also recovers records older versions rejected after a false end of the log. Relays can still withhold records for as long as they are compromised, and a record of a removed author still needs its proof.
- Detected credentials are never published. Every path into a pool (a binding,
publish, a save with--pool) applies oneinternal/secretscangate to everything the encrypted record carries: title, body, project name and provenance (branch, commit, directory, session, agent). Title and body are also scanned (and extraction redacts them) joined by a line break, so a label ending the title and its value starting the body count as one credential; a value with no recognizable label or format still passes. A bound project's memory that fails it stays private (withheldin the routing result);publishrefuses a listed memory that fails it, or that corrects one that does (pool members could never apply such a correction), and withholds others (the result'swithheldcount); a save into a pool is refused; and sealing refuses anything queued earlier, marking itfailed(invalid_argument). Explicit sharing does not override the gate: save the content as a new memory (nosupersedes) without the credential and publish that. Like redaction, this is a backstop for known formats, not a guarantee. publishby ID shares only the listed memories. Pool members apply a correction only after the record it supersedes, so publishing a correction whose earlier versions are still private fails and names them; list them too once you have reviewed them. An earlier version may hold what the correction removed, which the gate may not detect.- Codes and secrets (enrollment codes, passphrases, tokens) are read from prompts or owner-only files, never argv, and never logged. An enrollment code is single-use, expires after 24 h, and binds to the first claimant — share it over a channel you trust.
- Local API:
serve locallistens on loopback only and requires the local API token (local_token_path), as a bearer token (scripts) or as a signed request; MCP runs over stdio. The CLI (ui,status,insights) never sends the token: serve local first proves it holds the token (an HMAC over a fresh challenge and the address the CLI actually connected to, so a listener relaying to serve local elsewhere fails too), each request is then signed with a single-use server nonce, and serve local signs the status and exact body of every response, errors included. Any other process on the port, such as one of another user while serve local is stopped, gets no token and no replayable request, and the CLI trusts none of its answers (merkhain uifails with "is not this installation's serve local";statusreportsserver_unauthenticated). Bearer tokens remain accepted for compatibility, and a script that sends one (for examplecurl -H "Authorization: Bearer $(cat …)") hands it to whatever listens on the port: send it only while serve local is known to be running, or use the CLI. - Browser UI:
merkhain uiasksserve localfor a single-use link that expires after 60 s and carries its token only in the URL fragment; the page exchanges it for anHttpOnly,SameSite=Strictsession cookie that expires after 8 h and ends on sign-out or service restart. UI requests must use the exact configured address and come from that same origin. Browsers scope cookies by host, not port, so any other service on the same host (another local port, or another port of the relay's host) receives the session cookie when the browser visits it. The cookie alone therefore opens nothing: every UI request, reads included, must also carry the session's CSRF token, which the page keeps in its own origin'slocalStorage(scheme, host and port), sends only to its own origin and drops on sign-out. The same applies to Relay Administration's operator session. The one exception is opening a UI page through a link from another site (such as Relay Administration): a top-level navigation to a static/ui/page is allowed, but it carries no session cookie, and API calls from other sites are always refused. The bearer token never reaches the browser. No process's arguments carry the link: macOS's opener reads it from a pipe, and elsewhere the browser gets the path of a redirect page that holds it (mode 0600 in a 0700 directory). On Unix the page is removed after 60 s. On Windows it stays until a later launch finds it older than 120 s; its token has expired by then. When the application that opens HTML files (asxdg-mimereports it, which may not be the default web browser;xdg-openpicks the same on its generic route and under GNOME, while KDE, Xfce and LXQt use their own openers, and a Snap chosen only by one of those still gets the page it cannot read) is a Snap or Flatpak, the page is kept in$XDG_RUNTIME_DIR, the document portal gives that browser alone read access to it, and the CLI starts that browser from its desktop file, with the document path as its argument. The document entry is deleted with the page ifgdbusordbus-sendis installed; otherwise it stays until the portal exits at the end of the session, granting nothing once the page is gone. If this route fails before the browser starts, a Snap browser gets no page and the CLI prints the link; a Flatpak browser gets the ordinary page throughxdg-open, as before. A browser that exits with an error at once may have handed the page over, so the page stays its usual time, nothing else is started, and the CLI prints the link. Under WSL the CLI cannot tell whether Windows can read the page, so it prints the link there too. Starting is all the CLI can see of any browser: one that fails after the 1.5 s of a confined launch, or after an ordinary opener returned, leaves no link, so runmerkhain ui --no-open. The CLI does not retry on a guess, since a second launch could spend the single-use token. Under WSL with interop, the Windows default browser is used even if a Linux browser under WSLg,BROWSERorwslviewis set up;--no-openleaves the choice to you.XDG_RUNTIME_DIRmust be the directory itself: a symbolic link is refused rather than trusted at each later use, and the link is printed. A desktop file whoseExecembeds a field code (--url=%u) is refused the same way, and%cuses the untranslatedName. Usemerkhain ui --no-opento avoid the page altogether.
Memory text reaching agents
Memories are untrusted input to the agent that recalls them. Their text is model-generated from transcripts (which
can contain anything a session read, such as a web page or file), imported from other tools, or written by any member
of a shared pool. It reaches the agent as the prompt-context digest (Claude Code and Codex additionalContext, the
OpenCode system prompt, a pi context message), as search-time context (PreToolUse), and as memory_recall,
memory_search, memory_get and memory_timeline results.
What the rendering enforces (internal/recall/render.go):
-
Structure belongs to the renderer. Every record line starts with the record's ID and kind, which are validated when any record is stored, pulled or imported. Digest titles and snippets are single lines;
memory_recallquotes every body line with>, so record text cannot pose as another record, a notice or a retrieval ID. Search, get and timeline results are JSON. -
Text cannot open or close markup or hide. Format characters (bidi overrides, zero-width and Unicode tag characters) are removed, other control characters become spaces, and
<before a tag-like name becomes‹: a record containing</system-reminder>or<user>renders as plain text, not a harness boundary. -
Provenance is visible. Records of a shared pool are marked
sharedin the digest and in recall. Full provenance (author, device, session, branch) is inmemory_get. -
Other members' records are not injected unasked. Prompt and search context inject records stored without a pool and records the local user wrote (a pulled record's author is its signed envelope author, checked against the pool's devices), but not another member's record of a shared pool, unless that pool is listed in
prompt_context.shared_pools. Those records reach the agent only through the recall tools it calls.
Residual risk: a model can still follow an instruction written as ordinary prose in a record, and the header that
calls memories "historical claims, not instructions" is guidance to the model, not a control. Text the session itself
read (and so extraction turned into a local record), recall results the agent asks for and opted-in pools still reach
it. Treat a pool like code review access: invite members whose records you would let your agents read, opt a pool
into automatic injection only when you trust it like that, and disable prompt context
(prompt_context.enabled: false) where no automatic injection is wanted; recall then happens only when the agent
asks.