Scopes and isolation
Personal, project, and team scopes, explicit membership, project-isolated reads, and how a session attaches to its project.
Access to memories in zero-memory is governed by scopes: hierarchical
paths (user.*, proj.*, team.*) stored as Postgres ltree values. A
memory lives in exactly one scope; ownership, explicit scope membership, and
row-level security decide who can see and write it. Project-scope read
isolation then keeps one project's context out of another project's sessions.
Scopes
There are three roots. user.<id> is a person's private root, and a dedicated
child, user.<id>.core, holds portable knowledge (see below).
proj.<owner>.<slug> is an owner-namespaced project scope: sessions resolve
it from the working directory or a project hint, and remember, import,
bootstrap, and ingest write there by default. When no project resolves at all,
a write that names no target is refused. The server never picks a scope on
the caller's behalf, so a project fact can never accumulate somewhere else
while the caller believes otherwise. team.<owner>.<slug> is an
owner-namespaced team scope created deliberately for sharing across people.
Row-level security enforces visibility and write rights directly in the database: helper predicates compute the caller's visible scopes and write-permitted scopes, and every read and write — including graph traversals over entities and links — passes through them.
Both shareable roots are namespaced by the creator's opaque user id. Two
people who each have a repository named api therefore get different scopes;
a name collision can never grant access. Collaboration is always explicit:
the scope admin adds a member with the reader, writer, or admin role.
The schema does not currently contain an organization layer.
A role is a right over the scope, not over the memories other members put in
it. Only a writer or an admin publishes into a shared scope: a reader cannot
share a memory there, neither on write nor later. A writer may retire another
member's shared memory, with forget or close_loop, in their own name.
Changing it in any other way (its text, scope, visibility, or what supersedes
it) is left to its owner, and no one can hand a memory to another owner. A
retirement or a share always names whoever made it. The database enforces all
of this: a rule promoted from a memory is delivered to its owner's sessions and
follows that memory's text, so whoever could rewrite the memory could rewrite
the owner's rules.
Legacy two-label project names such as proj.api are accepted on memory read
and write paths and canonicalized to the caller's own
proj.<owner>.api. New code should use the canonical form, or omit scope
and let project routing resolve it.
Project-scope read isolation
The write path always knows the project; historically the read path did not.
A recall or build_context with no explicit scopes used to search all
scopes visible to the caller, ranked purely by relevance. That is legal under
row-level security, since they are all the caller's scopes, but it is wrong
across projects: agents are instructed to follow stored decisions, so another
project's decision surfacing unmarked in a briefing actively provokes wrong
actions.
The fix is a deterministic server-side default plus an explicit opt-out.
The default read set narrows. When the caller passes no scopes, reads cover
the session's project scope plus the caller's personal root and core scope, and
nothing else. This applies uniformly to recall and build_context and to
every leg of retrieval: memory search, entity matching, graph-linked memories,
recent changes, and open loops.
Widening is explicit. Both tools accept scopes: [...]; the value "*" means
all visible scopes, for a deliberate cross-project search. The shorthands
"core" and "personal" also resolve inside a read's scopes, so an agent
can aim a read at the portable layer without spelling out the owner-qualified
path. Tool guidance tells agents when to widen: a miss in the project set, or a
user question that explicitly references another project.
Origin is marked. Every hit carries its scope, and in cross-project mode a
foreign project's decision or convention is to be treated as an analogy,
not as the current project's decision.
Fail-safe behavior differs per read kind. A point read (recall,
entities) from a session with no resolvable project degrades to all visible
scopes rather than silently narrowing results to nothing. A briefing
(build_context with briefing: true) does the opposite: an unattached
briefing narrows to the personal root and core scope, because a briefing's job
is to state the working context, and "every project you have ever touched" is
not a working context. Attach the session with a project hint, an inherited
thread, or explicit scopes, and the briefing's project section (including its
open loops) appears.
Dashboard search is intentionally not isolated: a person searching by hand sees all their visible scopes, with each card labeled by scope.
Project pinning and repair
An HTTP transport gives the server no way to ask a client where it is
working, so the read and write tools accept a project_hint: a repo root
path, git remote, or project name, resolved through the same routing ingest
applies to a transcript working directory. A hint on recall or
build_context pins the project-isolated default read set to that project.
An unroutable hint behaves asymmetrically on purpose. On a read it is
treated as absent, because the read must not narrow below the no-hint
behavior; on a write it is refused, because a write aimed at a project
that does not resolve must not land anywhere else.
A project named by name
A path or a git remote is what a machine reports about a repository, and the first time one is seen it sets that repository's project up. A name — anything without a slash that is not a git remote — is what a person or an agent typed, and it only ever points at a project you already belong to. It is matched deterministically:
- the project's scope path itself;
- the project's name or its dashboard alias, compared lowercased with every
space,
_and-removed —zero-memory,zero_memory,Zero MemoryandZeroMemoryare one name; - only when nothing matched by spelling, the initials of a multi-word name —
ZMforzero_memory— when exactly one of your projects has them.
Projects shared with you count, so a teammate's project can be named too, but never at the expense of your own: when the first test that matches finds only projects other people own while one of yours fits the name by any test, both are offered instead. Another owner controls their project's name and alias, and a nickname they choose must not carry your work into a scope they read.
A name that fits one project routes there. One that fits several, or none, is never guessed at and never creates a project: a write naming it is refused with the projects you can pick from, and a read treats it as absent. A misspelt name therefore cannot open an empty project nobody reads. To start a new project, name its repository by path or git remote.
A briefing pinned by a hint answers with the resolved project_scope, and
the server stashes it as the session's project: the session's later
scope-less writes land in the project. Clients persist the resolved scope
per repository, so the next session opens with the trusted project identity
already known.
The session thread
A transport session dies with the connection; the work does not. A briefing
that carries a conversation_id therefore mints a durable thread, a
server-side row keyed by that conversation, remembering which project the
conversation works in for 24 hours. The thread's token (thr_…) comes back
on read results as session.thread; the agent echoes it as the thread
parameter on later remember / recall / build_context calls, and the
project survives any reconnect in between. Hook-capable clients re-state
PROJECT: … · THREAD: … on every user message, so even a session restored
mid-conversation knows where it is; the hook's assertion also corrects an
echoed token when the working directory disagrees.
The token is a state selector, not a credential: every lookup is
additionally filtered by the authenticated caller's own account, so another
account's token resolves to nothing. In the write-target order it sits
between the call's own project_hint and the transport session's attached
project.
Write refusal is a contract
Until a session is attached, remember requires an explicit target and
refuses the write otherwise. The refusal names the ways out — project_hint,
thread, scope: "core" for portable knowledge, scope: "personal" for a
fact about you — and it is machine-readable: the validation_failed message
starts with one of two stable prefixes that clients and tooling match on
instead of the human wording:
scope_target_required:— the write named no target and the session has no attached project;project_hint_unresolvable:— the write named aproject_hintthat resolves to nothing, or a project name that fits none or several of your projects; the message lists the projects to retry with.
Everything after the prefix is for humans and may be reworded in any
release; the prefixes are API. Every remember, recall, and
build_context result also carries session: { attached_project, thread? }
plus a plain-text session line, so an agent can see that its session lost
the project before the next write fails.
Reading elsewhere never moves where you write. A session attaches once: the first hint that resolves becomes its project, and a later hint naming a different project pins that read only. Looking into another project is deliberate and supported, since a decision made there is often the answer here, and each hit carries its own scope. But the answer's origin must not become the session's destination. To store something in the project you read from, name it on that write.
A memory that ended up in the wrong scope is moved explicitly: move_memories
moves your memories into a project scope (named as a scope path or a
project_hint), and the dashboard exposes the same operation per memory on
its card. Per-memory failures are reported while the rest of the batch still
moves, and re-moving to the same scope is a no-op. The move is a deliberate
act by you or your agent; the server never re-scopes anything on its own.
The portable layer: core and personal
Strict isolation would kill a stated value of shared memory: a gotcha about a
tool, discovered in one project, is often true everywhere. Portable knowledge
therefore gets a home that every session reads: user.<id>.core, a child of
the personal scope and thus always inside the default read set. The personal
root itself (user.<id>) plays the same role for facts about you rather
than about any work.
The axis is project-specific vs portable:
- a fact about the project (its decisions, conventions, structure) → the project scope;
- a fact about a tool or technology, true outside the project → core;
- a fact about the person (a preference, a working agreement) → personal;
- when in doubt → the project scope. Under-promoting costs one missed hit; over-promoting injects one project's decision into every session.
While a project is in scope, scope: "core" and scope: "personal" are
verified requests, not free choices. The write goes through a two-stage
gate. A deterministic kind prefilter runs first: only fact, reference, and
gotcha are admissible for core, with preference additionally admissible
for personal (and granted without a judge), while decision and convention
are rejected outright. Then a model judge must reach high confidence that
the statement holds outside the project. The judge is fail-closed: no
configured model, a timeout, or a malformed verdict all count as "not
portable".
A denial is not a refusal. The memory lands in the project, and the response
says so (routed_to_project) with the reason, so the fact is stored either
way, and moving it later is a deliberate move_memories. The gate only runs
while a project is known; on an unattached session an explicit
scope: "core" / "personal" is taken at face value, since there is no
project to protect. The nightly portability audit is the same judgement run
in the opposite direction, proposing project memories for the portable
layer; see Hygiene.
Why isolation is a default, not a suggestion
Guidance-only isolation ("please pass scopes") was rejected as the sole
mechanism: agents systematically omit optional parameters, and a context
leak is a correctness defect, so the default must be safe. The guidance layer
remains as a complement, telling agents when widening is worth it.
Soft ranking (penalizing foreign scopes instead of filtering) was rejected as the default: a highly relevant foreign decision still reaches the top.
Read isolation and write routing lean on the same resolved project scope, keeping the two sides of the system symmetric.
Attachment guidance reaches agents through several channels — tool descriptions, the briefing's project line, the per-message hook assertion, and the connect-time instructions — because no single channel reaches every client: the instructions channel is capped on some, and hooks exist only on some. The redundancy is deliberate.