Two shapes of store
memloom’s default shape is one human and one store. Every engine method takes an optionalownerId and falls back to a fixed sentinel, so nothing has to be passed and nothing can be
mismatched.
A host app that serves many people is the other shape. A chatbot, a team server, anything where
“whose memory is this” has more than one answer. Same store, same schema, an owner id per user.
Bind the owner, do not pass it
BecauseownerId defaults, a forgotten argument in a multi-tenant host is not a type error and
not a runtime error. It reads and writes the shared sentinel account, so one user’s memories
surface in another user’s session, silently. Every call still compiles. Every test still passes.
So a host does not call the engine directly. It calls forOwner() once per request and works
through the handle, where the owner is already bound and there is no argument left to forget.
forOwner() requires a uuid, and rejects the sentinel owner specifically. In a host the sentinel
is never a real tenant. It is what an unset user id looks like, arriving as everyone’s shared
account, so it fails loudly instead:
What the handle covers
A deliberate subset of the engine: the per-user operations a host app runs on behalf of one person.
Store-wide machinery stays on the engine: migrations, reconcile settings, embedding
fingerprints, and the import and sync connectors. Those belong to whoever operates the store,
not to any one tenant.
resolveConflict() is the one place the handle does real work rather than forwarding. The engine
method reads the owner off the conflict row and acts on whoever that turns out to be, which is
correct for a single-owner store and a cross-tenant write for anybody else. The handle checks
ownership first, and answers an id belonging to someone else exactly as it answers an id that
does not exist. Which of the two it is, is itself another tenant’s business.
Deleting an account
erase() removes one tenant completely: every row that owner has in every table, in one
transaction. Where deleteMemory() removes one belief, this removes the person.
Sizing the connection pool
PgAdapter.connect() defaults to a pool of 10, which suits a daemon that owns its database. A
serverless host is the opposite shape: every warm container holds its own pool against one shared
connection limit, so a handful of containers at 10 each exhausts it.
The daemon, MCP and the CLI
The daemon carries an owner too, so an agent and a person on one machine need not share memories. One client is bound to one owner, sent as theX-Memloom-Owner header on every
request, and every owner-scoped route reads it. Reads as well as writes: writing per user and
reading shared is worse than not separating at all, because it looks separated.
Surfaces take the owner from their environment, never from a flag or a tool argument. Which
person a surface acts for is a property of how it was launched, not of what it was asked to do,
and a model calling a tool must never be the thing that chooses whose memories to read.
MEMLOOM_OWNER_ID is deliberately not a config.env setting. That file configures the daemon,
which serves every owner; this names which owner one client process is, so it belongs to that
process’s environment.
The Console follows the daemon
The Console is a browser page served by the daemon, so it cannot send a header of its own. It gets the daemon’s default owner, which the daemon reads fromMEMLOOM_OWNER_ID in its own
environment: the shell that ran memloom serve, or the CLI or MCP process that auto-started it.
Export the variable in your shell profile and everything agrees.
That default is fixed when the daemon starts. If you set or change MEMLOOM_OWNER_ID while a
daemon is already running, restart it:
GET /health reports the default owner, so “which memories is the Console showing” is a
question with an answer rather than a guess from an empty list.
Background work follows the owner too. The reconcile scheduler runs its passes for every owner
in the store rather than only the default one, and the file watcher runs one instance per owner,
against that owner’s linked folders. A store whose memories are saved per person but maintained
for only one of them looks fine for weeks and then does not.
Isolation, and what is actually guaranteed
Owner scoping is enforced in SQL, on every read and every write, not in a wrapper you can step around. What that buys, stated plainly:- Recall, listing, and the graph never cross owners. The sentinel owner is a third tenant, not a superuser view.
- Byte-identical content saved by two owners produces two memories. The content-hash short-circuit and the dedup candidate query are both owner-scoped, so one user’s belief is never merged into another’s.
- Reading, versioning, or deleting a memory through the wrong owner fails with the same error the id would raise if it did not exist. There is no existence leak.
- Resolving a conflict names the owner, so an id belonging to someone else is refused instead of acted on. Without that argument the engine reads the owner off the conflict row and acts on whoever that turns out to be, which is right for one person and a cross-tenant write for anybody else.
owner-isolation.test.ts, which runs against both PGLite and real Postgres,
and by owner-http.test.ts, which runs the same questions through the daemon.
What it does not buy: memloom has no notion of who your users are. Authentication, authorization,
and deciding which uuid belongs to the request in front of you are yours.
The rule behind all of it: the two mistakes cost differently
Deciding which owner a request belongs to means you can be wrong two ways, and they are not symmetric. Failing to unify is the loud, cheap one. A returning person is treated as new, so they restate what they already told you and conclude the memory feature does not work. Annoying, recoverable, and visible immediately. Wrongly merging is the expensive one. Two people collapse onto one owner and one person’s private context surfaces inside another person’s session. Preferences are the harmless end of that. The other end is a health detail, a salary, or a relationship the graph inferred from one person’s memories and then served to someone else. That is not a papercut, it is a leak. So the bar for treating two things as the same owner is high, and ambiguity holds rather than merges. This is the reason behind decisions that otherwise look unhelpful:- A conflict belonging to another owner answers exactly as one that does not exist. Telling the caller which of the two it is would confirm the existence of somebody else’s data.
- A malformed owner id is refused rather than coerced to the default, at both the client and the daemon. Falling back to a shared owner because of a typo is a merge nobody chose.
forOwner()rejects the sentinel. In a host it is never a real person; it is what an unset user id looks like, and accepting it merges every caller who forgot one.