Identities and namespaces
Loom answers two questions on every call: whose row is this (the identity tuple, plus two columns the server stamps), and may this caller touch it (the token, the namespace's grants and its classification). This page explains the model. Minting and revoking tokens is in Tokens and grants; enforcement is in Security.
Where namespaces fit
- An org is the tenant.
- A namespace is the unit of sharing and access inside it. A deployment usually has
one per team or project, one per bot that must stay isolated, one
personal-<slug>per person, and onecode-<repo>[-<branch>]per indexed branch. - Every member of a namespace reads every row in it.
agentrecords who wrote a row; it is not a boundary.user_id/read_scope=own_userseparates end users inside one bot's namespace.- Access is a role (viewer/writer/admin) on a namespace, carried by a token or added by a grant.
The identity tuple
Every row carries five identity columns:
(org, namespace, agent, user_id, session_id)
| Part | Meaning | Default |
|---|---|---|
org |
The tenant. Nothing crosses an org boundary except through a wildcard operator token. | (required; single-user mode fills it, below) |
namespace |
The unit of isolation and sharing inside an org. Every read is scoped to (org, namespace). |
(required; single-user mode fills it, below) |
agent |
Who wrote the row (support-bot, claude-code). Authorship, not a read boundary. |
(required; single-user HTTP fills it on some ops, HTTP reference) |
user_id |
The end user the row is about. | - |
session_id |
One conversation or run of a conversation. | - |
Many operations also take run_id (default -) to scope a write or read to one task
execution. The token fixes the org and the namespaces a caller may reach; the rest of the
tuple travels in each request body. The SDKs bind (org, namespace, agent) when the
client is constructed and pass user_id, session_id and run_id per call, - for
each one a call does not name. Where one of these fields filters a read (searches, asks,
the startup pack, conversation and run listings, memory.update's key lookup), -
means no filter: a search that names no run reads every run's rows. Where the field is
the row's address (memory.get, memory.list, memory.stack, working memory, profile
writes, saved messages), - is the row written with no user, session or run.
Reads span every author. A read defaults to agent="*": every row of the namespace,
whoever wrote it. Passing an agent only narrows. user_id and session_id on a read
filter to rows written under them.
Why the tuple leads the tables. The sort keys of loom.memory and
loom.memory_embeddings start with (org, namespace, agent, user_id, session_id), so a
read scoped to one namespace is a primary-key range in ClickHouse rather than a scan, and
naming an agent narrows it. A read across agents, the default, filters user_id and
session_id inside the namespace's range: the key skips granules on them only as far as
the agent column allows, and no skip index covers them. Any scoped read still reads at
least a granule of every part its range spans
(memory at scale).
What the server stamps
Two more columns are decided by the server on every write and are never read from the
request body, except that a caller may downgrade source_class
(server/memory/app/provenance.py for actor, server/memory/domain/provenance.py for
source_class):
| Column | Values | Decided by |
|---|---|---|
actor |
human:<subject> or process:<subject> (a service account) |
The verified token (writer_actor). A queued job or CLI import writes as the principal that started it. |
source_class |
asserted, extracted, observed, derived, imported, external |
The operation and the source_trace convention (source_class_for). |
A request may declare only a downgrade of source_class: external (content from a tool
or page), imported or derived. Asking for anything else is a 422. The reason is that
provenance feeds ranking and memory.ask's choice of evidence: if a caller could label
scraped text asserted, the label would mean nothing. agent is caller-supplied because
it names a role in the system, while actor is the authenticated principal behind it.
Orgs and namespaces
An org is a tenant; a namespace is where rows live and the unit access is
granted on. A namespace comes into being when it is registered (namespace.register,
which makes the caller its admin), claimed at sign-up, provisioned by the server (the
single-user namespace, and a personal-<slug> namespace for each signed-in person), or
created for an indexed repository. Its directory row in loom.namespaces carries its
configuration: description, kind, visibility, join policy, conflict and proposal policy,
cost cap, retention and retrieval defaults.
| Namespace kind | Holds | Read by |
|---|---|---|
memory |
Facts, documents, conversations: the default kind | memory, knowledge and conversation operations, and fan-outs |
code |
One indexed repository and branch | the code.* operations, addressed by name; loom_search under a code scope |
bench |
A benchmark corpus | benchmarks; its traffic is classed bench |
A search that fans out over all of a caller's namespaces spans the memory kind only
(LOOM_MEMORY_SEARCH_FANOUT_KINDS): sweeping every code index for a personal fact costs
a statement per index and finds nothing.
Why namespaces, not per-agent stores. The common case is several agents and people that should see the same facts: a team's deploy window, a customer's history. Making the namespace the read boundary means sharing is the default inside it and absent across it, and a person or bot is given access by granting a role on a namespace, not by copying rows.
Visibility, joining and restriction
| Setting | Values | Effect |
|---|---|---|
visibility |
private (default), public |
A public namespace is listed in namespace.browse and can be joined. Visibility grants no read by itself. |
join_policy |
open, approval, closed, inherit (unset) |
What namespace.join does. inherit resolves to open for a public namespace that does not require approval, and approval otherwise. |
requires_approval |
true, false |
Read only under inherit: every join then waits for an admin (grants.approve). |
classification |
standard (default), restricted |
Switches every widening default off (below). |
On an open namespace a viewer join is active at once and a writer or admin join
is pending until an admin approves it; on approval every join is pending; closed
refuses joins.
A restricted namespace (classification=restricted on its directory row, or on its
entry in LOOM_CODE_REPOS_JSON) admits a call only when the token belongs to the
namespace's org, names the namespace literally (not through *), holds a role there that
reaches the operation's role, and the token's own subject holds a live direct grant. No
org-readable rule, public visibility, wildcard, inherited service-account grant,
org_admin or platform_admin bypasses it, and a refusal does not reveal whether the
namespace exists. Fan-outs, context packs and rollups skip restricted namespaces the
caller cannot read, and each admitted request is audited. The step-by-step setup is in
Tokens and grants.
Roles
Roles are ordered, and a higher role satisfies every check for a lower one. Each
operation declares the minimum role it needs (GET /v1/operations lists it).
| Role | Grants | How it is held |
|---|---|---|
viewer |
Read. | Token claim or namespace grant. |
writer |
Read, and write memory, knowledge and the other data planes. | Token claim or namespace grant. |
admin |
Administer one namespace: grants, configuration, policy, restores, promotion to the shared layer. | Token claim or namespace grant; registering a namespace makes you its admin. |
org_admin |
Org-wide views (for example cost without a namespace). | A recognised gate with no in-product assignment path. |
platform_admin |
Cross-org operator views. | Conferred at login to the handles and verified emails in LOOM_PLATFORM_ADMINS, and to the tokens of a bot registered --platform-admin; minted only for a service account, with an expiry. |
Grants carry only viewer, writer or admin, and so do the tokens Loom mints, but for a
platform_admin one: org-wide power is an operator decision in configuration or at bot
registration, not something a token can carry away. Code namespaces have readers
(viewer) and maintainers (admin) and no writer.
Who may call what
A call on (org, namespace) is allowed when the namespace is not restricted (or passes
the restricted checks) and either the token's own role for that namespace reaches the
operation's role, or a live grant does.
-
The token (
sub,org,namespaces,roles, optionalns_rolesandns_write) says which namespaces the caller reads, in priority order, and with what role. A scoped token (ns_roles, per-namespace roles) is authoritative for exactly the namespaces it lists. A flat token applies itsrolesto every namespace it names.ns_writenarrows writes to a subset, so one token can read a team namespace and write only its own:namespaces = ["team-shared", "agent-private"] ns_write = ["agent-private"]
- Grants (
loom.namespace_grants, one row per(org, namespace, subject)) add a role on one namespace above what the token carries.- A claim goes no further than the grant. Where the subject holds a grant row on a
namespace the token names, of any status, the claim reaches no further than that grant:
a revoked or lowered grant (
grants.revoke,grants.createwithreplace=true,namespace.leave, a released claim, a restore) ends every older token's claim there, and a pending or rejected request caps it at no access. A scoped token keeps its own role there, capped at the live grant. - What is not touched. A namespace the subject never held a grant on keeps its claim; revoke the token to take that away.
- When it applies. The process that changed the grant applies it at once, every other
within
LOOM_ACL_WATERMARK_POLL_SECONDS(default 1 s), on this server or another.
- A claim goes no further than the grant. Where the subject holds a grant row on a
namespace the token names, of any status, the claim reaches no further than that grant:
a revoked or lowered grant (
- Service accounts inherit. A service account with no grant of its own on a namespace uses its creator's grant there, so a token minted from the dashboard follows the person who made it, except on a restricted namespace.
- Operations addressed by an org-wide id, such as
memory.get_by_id, authorize the caller on the scope that holds the row. When the caller lacks the op's role on the row's namespace, they answer as for a missing row, so a refusal never confirms that the id exists (row-scoped operations).
Why tokens plus grants: the token is verified locally on every call without a lookup, which keeps the hot path cheap; grants let an admin give and take access without re-issuing tokens. Revocations are shared through ClickHouse and reach every replica within the revocation cache interval.
Reading several namespaces
A search can read several namespaces at once: namespace_scope is this (the addressed
namespace, the default) or all_mine (the addressed namespace plus the caller's own).
The loom_search MCP tool and context.pack use the same fan-out. Memory search lists the
namespaces in scope in namespaces_searched (a tiered search can stop before the last of
them) and the ones the cap left out in namespaces_skipped.
The caller's own namespaces depend on the token:
| Token | Namespaces all_mine adds |
|---|---|
Flat (most tokens; loom mint-token, self-signup) |
The namespaces the token names, plus every namespace the subject holds a grant on |
Scoped (ns_roles; a token for Only what you choose) |
Exactly the namespaces the token lists, in that order; grants added later do not join |
Delegated with * (a token for Everything you belong to) |
The creator's live grants, so a namespace the person joins later is searched too |
| Delegated naming namespaces | The ones it names where the creator still holds a grant |
None, auth off (single-user) |
The namespaces the single user holds a grant on: the default one and every one they registered |
Then the list is narrowed:
- Kinds. Only namespaces of the kinds in
LOOM_MEMORY_SEARCH_FANOUT_KINDS(defaultmemory) are swept. Code and benchmark namespaces are read by name or with their own tools. The exception is MCPloom_searchunder a code scope: it also reads the scoped repository's trunk code namespace, for its documents and decisions, when the caller may read it (unified search). - Restricted namespaces stay only when the caller passes the restricted check.
- The cap. Besides the addressed namespace, at most
LOOM_MEMORY_SEARCH_FANOUT_MAX_NAMESPACES(default 32) are read, in the caller's list order: a scoped token's order, or a delegated token's named list; otherwise by name. A scoped token keeps its first ones; for anyone else the cut drops public namespaces before private ones. Memory, knowledge and conversation search name the rest innamespaces_skipped(every page of a paged memory search too), andloom_searchsays so in its text; address one by name to read it.
The addressed namespace is always searched, and first: the fan-out only widens a search. Memory search then reads in tiers. For a scoped token the other namespaces follow in its order, and the search stops at the first with a confident match. For anyone else the addressed namespace and the private ones are read first, and the public ones only when that first pass comes up short.
Some namespaces a caller can read are not its own and are never swept: those reached
through a * claim or an org or platform role, public namespaces it has not joined, and
namespaces it created by writing into them (they get an owner row but no grant, so a
harness that writes a namespace per task does not widen every later search). Address
them by name. A search covers one org, the token's own.
Read scope inside a namespace
Membership is the read boundary, which is right for a team's shared memory and wrong for
one agent that serves many end users through one token: a prompt from one user could pull
another user's memories into the answer. read_scope bounds a read inside the namespace:
read_scope |
A read returns |
|---|---|
namespace (default) |
every row of the namespace |
own_user |
rows written under the user_id the request names |
own_session |
rows written under the session_id the request names |
memory.search, memory.search_trace, memory.ask, memory.recall, context.pack,
namespace.inventory, profile.render and profile.structured take it; profile.structured refuses
own_session, since a user's profile spans their sessions. A token can carry a
read_scope claim (and a pinned read_user_id) that is a ceiling: a request can narrow it but never widen it, and a
scoped read without the user_id or session_id it binds to is refused with
IDENTITY_INCOMPLETE instead of silently widening. Mint own_user tokens for
multi-user agents.
Both transports resolve the scope once, when they admit the call, and every read of
memory rows in that call carries it, whichever op made the read: memory.get,
get_by_id (another user's id answers found=false), list, bulk_get, history,
timeline, related, evidence, conflicts.list, memory.proposals, session briefs,
digests, procedures, working memory and the loom_search legs. Under a scope narrower
than namespace:
user_id="*"is refused withAUTH_INSUFFICIENT, and so issession_id="*"underown_session.- A token pinned with
read_user_idacts only as that user, on writes too: a body naming anotheruser_idis refused withAUTH_INSUFFICIENTon every op, and a body that names none (or-) runs as the pinned user. - A write's answer names no row outside the scope: a merge into, a duplicate of or a
pinned conflict with another user's row reports the verdict without that row's id,
hash or key, and
conflicts.listdrops such peers fromconflict_with. A pinned row still blocks a close write from any user (a namespace-wide invariant): a foreign pinned row within the conflict distance refuses the write, unnamed, however many of the writer's own rows sit closer. The narrowed writer's check reads the namespace's pinned rows (their type taken from the memory row) beside its own scoped read, so it costs at most about twice a namespace-wide writer's. A deferredmemory.set_from_messages(defer=true, orextraction_defer_agents) stores the request's scope with it and replays under that scope.run.beginwith another user'srun_idstarts a new run under a fresh id, andrun.completeon it answersNOT_FOUND. A begin with a caller-supplied id claims it in ClickHouse Keeper (loom.agent_run_claims) and releases the claim once its run row is written, so of two users' concurrent begins on one id exactly one keeps it. A begin that fails before its row keeps the claim, so no other user's begin can take the id. While Keeper fails a begin only checks the run row; then a narrowed token keeps a supplied id only to re-open its own existing run, and a new one gets a server-made id, so a client that makes the id itself must use the returnedrun_id. - Session briefs are kept per user (per session under
own_session): two users saving the samebrief_keyget two briefs, each sees only its own, and a namespace-wide token lists them under keys prefixed~<user>~(or~<user>~<session>~). code.searchbreaks ties with the scope's own user's recent files, whateveruser_idthe body names.- A
context.packleg the scope cannot bound (conversations, which carry no user, and the runs manifest, which reads every session of a user) answers nothing. namespace.inventorycounts memories and runs, and the agents, authors and subjects on its memory rows, inside the scope; documents and members namespace-wide (asknowledge.*andgrants.listread them), and lists records (one per key, whoever wrote it last) and integrations (webhook.listis refused) as not counted (complete: false).- A blacklist pattern refuses every user's writes but records no user (its
created_byis the token subject, which every per-user token of one backend shares), so everyblacklist.*op is refused withAUTH_INSUFFICIENT: patterns are added, listed, removed and swept with a namespace-wide token. A pattern still refuses a narrowed token's write, and that 422 names no pattern id or reason. policy.decisionslists only the caller's own decisions.memory.unlockandmemory.unpin(admin) reach only the caller's own rows, likememory.lock.- An op that cannot bound its answer to one user (the entity graph
facts.*,conversation.*, namespace exports, admin and curation ops,ops.projection_failures.*,erasure.*,audit.list,cost.agent_summary,agents.connected) is refused withAUTH_INSUFFICIENT, and so is a write to a table keyed without a user, where it would overwrite another user's row:memory.record.write,decision.record,procedure.record_runandcontext.offload.memory.set_from_messagesskips itsrecord_schemaprojection into records and says so indegraded. A token whose scope does not resolve (anown_sessionceiling and a body naming no session, or an unpinnedown_usertoken and a body naming no user) is refused on every read it scopes and on every write whose body carries a user or session (andcost.summary), rather than run as user-. Underown_session, ops with no session field (cost.summary,memory.outcome) are therefore always refused. The code-index ops (code.*) carry a user for attribution only and are admitted; a memory read inside them is refused. Namespace-wide rules (policy.set,policy.delete,memory_schema.register/propose/approve,blacklist.*) need a namespace-scoped token, and so doespolicy.check, which evaluates the caller's rules over every user's calls. The list isserver/identity/domain/read_scope.py(SCOPED_OPS,NARROW_SAFE_OPS), and an op it does not name is refused.
Agent-written rows and the shared layer
Inside a namespace, rows in loom.memory are keyed by the full tuple: the same
memory_key written by two agents, or for two users, is two rows. That is the per-agent
("private") layer, and it is what memory.search reads by default (scope="private").
"Private" here means not promoted, not hidden: every reader of the namespace can read
these rows unless read_scope narrows it.
The shared layer, loom.shared, holds one row per (org, namespace, memory_key): a
namespace-owned, canonical version of a fact that no longer belongs to the agent that
learned it. memory.promote copies a row into it, with a rationale and an optional
approver, and memory.demote retires it (the response says whether that happened
inside the promotion's reversible_for_days window, default 7, up to 90). A search with
scope="shared" reads that layer, and memory.lineage walks a shared row back to its
promotion. memory.promote and memory.demote need admin. memory.cross_promote
copies a row into another namespace, with provenance, for a caller who is admin on both.
Why two layers: agents write freely and often redundantly under their own identity, while a curated fact the whole namespace should rely on needs one canonical key, an owner's decision and an audit trail. Promotion is that decision.
Identity modes
How a caller gets a token at all is the deployment's identity mode
(LOOM_IDENTITY_PROVIDER):
single-user: no authentication; a call that names no namespace resolves to one (LOOM_DEFAULT_NAMESPACE, defaultlocal, in the orgLOOM_SELF_SIGNUP_ORG, defaultdev) on MCP for every op butcode.*. Over HTTP, a call with no token gets the org on every op butcode.*andtracker.*, and the namespace on the everyday memory and search ops; which ops, and the agent each gets, are in the HTTP reference. Anall_minesearch also reads the namespaces the user registered. For a laptop only.trust-network: self-signup with email and password; the first principal to claim a namespace name owns it. An agent can sign itself up (loom init --agent) and a person claims it later with the code it printed, which works once and is replaced by the person's own password.gateway: an upstream identity layer authenticates people and mints short-lived Loom tokens with the shared secret; Loom only verifies. A subject inLOOM_TRUSTED_CALLERSmay act for an end user withX-Loom-On-Behalf-Of, and every ledger row records the real caller. A service whose users have no Loom account signs a short-lived token per request for auser:<id>instead, with a key registered for it as an end-user issuer (act for a service's end users).
Collections
A collection is a namespace named col-… that only grants open: no token claim or org
admin role reaches it. A collection can sit inside another and inherit its grants, and a
grant on one can name a user:, group: or agent: principal, or everyone (every
request acting in the org), besides a person or a service account. People name a
collection by its title, and the server derives its col-… name from it; the name stays
its address for good. Sharing knowledge describes the model
and how a name is derived. People see a collection as a collection, in the Library, by its
title and its place there; the namespace that stores it is what grants, search scope, audit
and erasure key on, and it never appears in the dashboard's namespace picker.
When LOOM_IDENTITY_PROVIDER is empty the server infers the mode: auth off gives
single-user, auth on with LOOM_SELF_SIGNUP=open gives trust-network, auth on
otherwise gives gateway. A non-loopback bind with sign-up open also needs trust-network
set explicitly. Deployment lists
each mode's settings.
Related
- Tokens and grants: minting, claims, grants, restricted namespaces, history policy and revocation, step by step.
- Namespaces and sharing: the namespace operations and the dashboard's sharing views.
- Security: how authentication and authorization are enforced.
- Memory: the row the tuple addresses.