merkhain

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_days to 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/-shm files are created mode 0600 whatever the umask, existing ones lose group and other access before they are opened for writing, including as the source of transfer local, which locks it. A read-only open such as status --direct changes nothing and refuses an exposed database or sidecar with the chmod 600 to run; SQLite would otherwise add -wal and -shm files 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 /tmp may 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 -wal and -shm files 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: use sudo -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 use sudo -u <owner> as well. Use full-disk encryption; treat backups of merkhain.db as 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, localhost or a loopback address), every command and service refuses to connect unless the effective connection string (URL and PG* environment) uses sslmode=verify-full for every host: an omitted sslmode (the driver's prefer), allow and disable permit plaintext, and require and verify-ca do not check the host name. The refusal names the host and port, never credentials. Relay databases hold ciphertext and keep their configured sslmode. 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.sh checks that dumps of PostgreSQL relay databases contain no plaintext memory or secret key. Database URLs and their passwords stay in the configuration (or MERKHAIN_DATABASE_URL, PGPASSWORD, ~/.pgpass) and are redacted in every output; transfer local --to refuses a URL with a password, since process arguments are visible to other local users.
  • The inference endpoint receives session content. openai refuses non-loopback endpoints unless you set allow_remote: true, and sends nothing to them over plain http:// (transcripts and the API key would cross the network unencrypted) unless you also set allow_insecure_http: true; such a configuration still loads, with extraction paused. Loopback http:// 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_path uses 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. codexcli sends 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. claudecli sends job content to the model behind your Claude Code login and runs claude -p with no tools, no MCP servers, safe mode (no CLAUDE.md, skills, plugins or hooks), dontAsk permissions 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/secretscan redacts 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 as AWS_SECRET_ACCESS_KEY, dbPassword or DB_PASSWORD_PROD; a name with another word after the credential word, such as API_KEY_OPENAI or DB_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_timeline and memory_get results and memory_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 embeddings section (Ollama, LM Studio) send memory text and queries to their base_url, which must be a loopback address unless the entry sets allow_remote: true; turn them off with recall_comparison.embeddings_disabled or embeddings.<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 one internal/secretscan gate 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 (withheld in the routing result); publish refuses 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's withheld count); a save into a pool is refused; and sealing refuses anything queued earlier, marking it failed (invalid_argument). Explicit sharing does not override the gate: save the content as a new memory (no supersedes) without the credential and publish that. Like redaction, this is a backstop for known formats, not a guarantee.
  • publish by 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 local listens 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 ui fails with "is not this installation's serve local"; status reports server_unauthenticated). Bearer tokens remain accepted for compatibility, and a script that sends one (for example curl -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 ui asks serve local for a single-use link that expires after 60 s and carries its token only in the URL fragment; the page exchanges it for an HttpOnly, SameSite=Strict session 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's localStorage (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 (as xdg-mime reports it, which may not be the default web browser; xdg-open picks 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 if gdbus or dbus-send is 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 through xdg-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 run merkhain 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, BROWSER or wslview is set up; --no-open leaves the choice to you. XDG_RUNTIME_DIR must be the directory itself: a symbolic link is refused rather than trusted at each later use, and the link is printed. A desktop file whose Exec embeds a field code (--url=%u) is refused the same way, and %c uses the untranslated Name. Use merkhain ui --no-open to 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_recall quotes 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 shared in the digest and in recall. Full provenance (author, device, session, branch) is in memory_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.