Resolution order
A sealed key is pinned to its seal. That is the first rule, and it outranks the request:- The API key’s
active_tenant_user_id, if the key has one. A key sealed to an agent profile at creation is that subject. A:user_idorX-Naive-User-Idon the request may only agree with the seal — disagreeing is a 403key_subject_mismatch, never a redirect to the named user. - Explicit
:user_idin the path (/v1/users/:user_id/...) — for an un-sealed (workspace-wide) key or a signed-in session. X-Naive-User-Idheader — same callers, same validation. Also on the MCP connect, which has no path to put a:user_idin; it is resolved once when the session opens and fixes the subject for the tool list, the kit gate and every tool call on that session (see MCP connection).- The company’s default tenant_user (auto-created on signup).
/v1/projects/:project_id/..., the X-Naive-Project-Id header, the key’s pinned
active_project_id, then the organization’s default project. A pinned key is
authoritative exactly as a sealed key is — a disagreeing project id is a 403
key_project_mismatch.
The sentinels default and me mean “the caller’s own subject”. Under a sealed key they
resolve to the seal; otherwise to the company default.
Cross-tenant guard (the ballgame)
After picking a candidate user, the resolver asserts:project_id is NULL belongs to the organization’s default
project: the column is nullable so that nothing writing these tables has to know about
projects yet, and a NULL row is the default’s, never a second project’s.
X-Naive-User-Id is treated as untrusted input — validated against the key’s
company exactly like a path param, never as an identity assertion. An un-sealed
workspace-wide key can target any user in its own company, never another company’s; a
sealed key can target only its own seal (see the resolution order above).
Ids are also shape-checked: a malformed (non-uuid) :user_id / X-Naive-User-Id /
resource id returns a clean 404, never a 500 from the database rejecting the uuid
cast. (default/me sentinels are exempt — they resolve before the check.)
Every handler that loads a resource by id (card, inbox, vault entry, connection, …)
additionally passes it through assertSameCompany(resource, req) so a forged resource
id from another tenant 404s.
Key scope — three independent columns
A key narrows in three separate ways, and they are different columns onagent_api_keys:
All three are bounded by the company. That boundary is the entire premise of tenant
isolation, so the guard runs on every request — not as an afterthought.
Per-agent-profile keys minted at provision time are sealed. The company key issued on
signup is not. This is why
naive.forUser(id) works from a company key and is refused
from an agent’s own key pointed at a different tenant.