Per-user agents & comms
Two things in Subspace are scoped to a single user with no shortcuts around it: an agent run, which acts with its owner’s exact access, and a communications account (mail, calendar, IM), which no one but its owner, not even an admin, can read. Everything else in a claimed instance follows page ACLs; comms are the one place the model is different by design.
Agents act as the run owner
Section titled “Agents act as the run owner”Every agent run carries ops.agent_runs.owner_user_id. The run’s every tool call, page
read, and page edit is evaluated against that owner’s live permissions, not the
permissions of whoever’s near the run, an admin watching a shared task queue can’t see
into a run’s private pages just by opening its panel.
Ownership is set once, at the entry point that starts the run:
| Entry point | Owner |
|---|---|
agents.invoke (tRPC, interactive) |
The calling session’s user |
Scheduled runs (schedule: in the definition) |
The agent definition page’s owner |
agents.spawn (a child run started by a parent run) |
The parent run’s owner, inherited, not the definition page’s owner |
| Legacy / system runs | NULL owner, full access, the pre-multi-user behavior |
The authorize seam sits under the approval gate
Section titled “The authorize seam sits under the approval gate”Every gated tool already has an approval gate.
Per-user scoping adds a check that runs before that gate: ToolSpec.authorize(args, ctx)
evaluates the call against the owner’s ACLs at the moment the tool would run, not at
invoke time, so a grant revoked mid-run is enforced on the very next tool call in that
run.
- Denied: the authorize check returns a denial string. That string becomes the tool result directly. No approval row is created, no one is asked to approve a call that was never going to be allowed.
- Permitted: the call proceeds to the normal gate exactly as before, unchanged. A tool that requires approval still requires approval; authorize only narrows what’s reachable at all.
Tool-by-tool scoping
Section titled “Tool-by-tool scoping”| Tool family | Rule |
|---|---|
kb.* page resolution |
Owner-readable-only; an unreadable page resolves as “no such page”, the same NOT_FOUND shape a human gets. |
kb.search, kb.grep, kb.read (link-walk), node.read |
Filtered to owner-readable results. |
kb.edit, kb.append, kb.remove, node.edit, node.create |
Require write access, checked both at authorize time and again inside the write transaction (applyKbCommandAs); landed commands carry kb_commands.user_id set to the owner. |
memory.propose |
Requires write on the memory surface. |
os.exec, code.* dispatch |
Require the run owner to be an admin. Non-admin owners can’t reach a host shell or coding-agent dispatch through an agent any more than they could directly. |
Run composition is owner-scoped too
Section titled “Run composition is owner-scoped too”An agent isn’t just scoped at the tool-call boundary, it’s assembled from only what its
owner can read. At startRun, memory RAG retrieval, the injected
skills block plus any skill.load call, bound mcp/ pages, and bound code/ functions
are all resolved against the owner’s readable set. agents.list and agents.invoke
apply the same filter to the definitions themselves: a definition page the caller can’t
read doesn’t show up in the slash menu, and an unreadable definition on a scheduled or
spawned run falls back to the built-in default agent constant rather than silently running
with someone else’s private prompt.
Routing
Section titled “Routing”Approval prompts and human-question cards stamp ops.tasks.user_id to the run owner, so
the task queue shows each member only their own agent’s cards; admins
additionally see every card, including the NULL-owner system cards from legacy runs.
agents.decide and ops.tasks.resolve are owner-or-admin, anyone else gets
NOT_FOUND. The run panel, the run tree, step logs, and the run:<id> WebSocket topic
that streams a run live all follow the same owner-or-admin rule.
Comms are personal, with no admin bypass
Section titled “Comms are personal, with no admin bypass”This is the one deliberate exception to admin visibility in the whole system. Everywhere else, an admin can see what they need to administer the instance; mail, calendar, and IM are different, because they’re not instance state, they’re someone’s actual inbox.
comms.mail_accounts, comms.im_accounts, and comms.cal_calendars all carry a
user_id column, and every mail, calendar, and IM tRPC surface filters strictly to the
caller (user_id IS NOT DISTINCT FROM $caller). Someone else’s thread doesn’t error, it
reads as though it doesn’t exist, the same NOT_FOUND-shaped behavior as an unreadable
page. Rows created before claim (user_id IS NULL) go to the claiming admin as part of
the claim backfill; on a still-unclaimed instance, NULL rows are simply what everyone
sees, matching legacy single-user behavior.
Connecting your own Gmail
Section titled “Connecting your own Gmail”/oauth/gmail/start mints a one-shot state row in ops.oauth_states (purpose
gmail-connect) that carries the connecting user’s id through the OAuth round trip. The
callback stamps that user as the account owner: it adopts an unowned row if one
matches, but never reassigns an already-owned account to someone else. Calendar rows
follow the connected account’s email address automatically. Settings → Connected accounts
lists only your own accounts, with connect and disconnect (disconnect drops the account
row and its encrypted OAuth tokens).
Sends, reads, and agent tools
Section titled “Sends, reads, and agent tools”mail.send, both as a tRPC procedure and as the agent tool, resolves only the caller’s
(or the run owner’s) accounts; if there isn’t one, the call errors instead of silently
using someone else’s. Mark-read and archive are owner-scoped the same way. A mail rule
agent run acts as the account owner, not as whoever authored the rule, so a shared
rule triggers with each account holder’s own access. Mail vector search hits are filtered
by account owner, matching how page search
filters by ACL. IM accounts key on (app, user), so the same WhatsApp connector can serve
several people’s independent sessions; env-configured API connectors (as opposed to
browser-automated ones) sync into the first admin’s accounts once the instance is
claimed.
Browser job isolation
Section titled “Browser job isolation”The Chrome extension’s automation host drains ops.browser_jobs,
and every job row carries the user_id of whoever’s action enqueued it. A paired device
pulls only its own user’s jobs; admins additionally drain the system’s NULL-user sweep
jobs (scheduled polls, not tied to a person). Messages fetched by a job land in that job’s
owner’s IM account, and a “logged out, please reconnect” card routes to the job owner’s
task queue, not to whichever admin happens to be online.
How this composes with focus, triage, and notifications
Section titled “How this composes with focus, triage, and notifications”Per-user scoping isn’t a separate subsystem bolted onto the ops spine,
it’s the same filter applied consistently. Triage and the inbox show each
user their own captured items; the task queue shows each user their own
cards (admins see everything, including system cards); not-now locks are
scoped per user’s own feed quotas; and notifications deliver to the
device and user that owns the underlying event. An agent acting through mail or the task
queue on your behalf shows up exactly where a card you filed yourself would, because it’s
using your user_id throughout, not a privileged one.