Identity & visibility
Six scopes narrow every record, three visibility levels decide who can retrieve it, and one rule outranks both — authority comes from the credential, never from the request body.
Six identities, not one “user”
A store that collapses everyone into a user_id cannot answer the two questions that
matter once several agents share it: who said this, and who is it about?
actor the identity performing the operation — always the credential's principal
subject the person, system or entity the memory concerns
observer the identity whose perspective a claim represents
agent a software identity acting under a human or team policy
service a non-agent automation identity
organization the tenant boundary, plus workspace and project as collaboration scopes
An agent gets its own revocable credential. A credential shared between two agents is not a membership — it is one principal with two operators, and the private-memory boundary below is drawn around the principal.
The scope ladder
Six dimensions narrow a record. They are not all set the same way, and that difference is the authorization model.
| Scope | Where it lives | Who sets it |
|---|---|---|
| organization | org_id on every tenant-owned row |
the credential, never the body |
| workspace | workspaces and memberships rows |
workspaces:write, memberships:write |
| project | project_id on observations and claims |
resolved from a reference, checked against the org on write |
| subject | subject_id, required on every observation and claim |
the caller |
| agent | agent_id, optional |
the caller |
| run | run_id, optional |
the caller |
actor_id is stamped from the authenticated principal. agent_id and run_id are
caller-supplied labels for grouping and filtering; they carry no authority, so writing
agent_id: "agent_admin" grants nothing. project_id sits in between: the caller
supplies it, and the write path rejects an id that belongs to another organization with a
non-disclosing 404.
Observations and claims carry no workspace_id column. Workspaces and memberships are
stored and auditable, but the retrieval predicate does not join them yet, so team and
organization visibility behave identically for a credential inside one organization.
The compiled pack names the policy it ran under — p0-org-subject-visibility-temporal —
so you can tell which generation of the filter produced it.
Visibility, and what it actually enforces
Three levels, defaulting to team when an observation omits the field.
| Level | Eligible for retrieval by |
|---|---|
private |
the principal that wrote the record, and nobody else |
team |
any credential in the organization (workspace narrowing is planned) |
organization |
any credential in the organization |
Eligibility is part of the candidate query, not a filter over results. A record you may
not read never reaches ranking, so it contributes no score, no conflict entry and no
count. Compiling the same subject from two credentials shows it — the writer sees its
private claim, the other principal sees candidates: 0:
{ "data": { "context_id": "ctx_46e8806af44c455fa07d2449d926c7dd",
"scope": { "subject_id": "user_shared", "project_id": null },
"budget": { "max_tokens": 600, "used_tokens": 0 }, "items": [], "conflicts": [] },
"meta": { "degraded": { "semantic": false, "vector": "disabled", "model": "disabled" },
"candidates": 0 } }
The same boundary holds on the write path. Citing another principal’s private
observation as evidence returns 404, not 403 — an id you may not read is
indistinguishable from an id that does not exist.
Two ceilings a credential cannot climb
Every key carries a max_trust. Asserting above it fails before any mutation:
{ "error": { "code": "FORBIDDEN",
"message": "This credential may not assert \"verified\" trust." } }
Trust ranks unverified → asserted → verified → policy_approved. A claim gets a
second ceiling from its evidence: its trust may not exceed the highest-trust supporting
observation, and its visibility may not exceed the narrowest source it was derived
from. Both default to the inherited value, so a claim built from one private observation
comes back "visibility": "private" without the caller asking for it. Consolidation
cannot launder a private note into team memory.
A missing scope is a different answer from a missing ceiling:
{ "error": { "code": "FORBIDDEN", "message": "Missing required scope \"workspaces:read\"." } }
Workspaces and memberships
curl -X POST http://127.0.0.1:8787/v1/workspaces \
-H "authorization: Bearer $TITEN_API_KEY" -H 'content-type: application/json' \
-d '{"name":"Platform"}'
# → {"data":{"workspace_id":"ws_e8501b1e3aac4fb1ab6a12fd09fcc57e","name":"Platform"}}
curl -X POST http://127.0.0.1:8787/v1/memberships \
-H "authorization: Bearer $TITEN_API_KEY" -H 'content-type: application/json' \
-d '{"principal_id":"agent_reviewer","principal_kind":"agent","role":"member"}'
# → {"data":{"membership_id":"mbr_16e7a364608e4fc88b65055b0c68ab18"}}
principal_kind is human, agent or service; role is owner, admin, member
or reader; workspace_id is optional and omitting it makes the membership
organization-wide. A duplicate workspace name in one organization returns 409.
DELETE /v1/memberships/:id is a soft removal: the row gets a removed_at and stops
appearing in GET /v1/memberships, but it is not deleted, and no record that principal
wrote is touched.
Resolving a project
POST /v1/projects/resolve turns a repository reference into an opaque id, so two agents
that describe the same repo differently land on one project.
curl -X POST http://127.0.0.1:8787/v1/projects/resolve \
-H "authorization: Bearer $TITEN_API_KEY" -H 'content-type: application/json' \
-d '{"reference":"https://github.com/RamaAditya49/Titen.git","create":true}'
{ "data": { "project_id": "project_7c5cf33d4d69422f970169b855e4069a",
"reference": "ramaaditya49/titen", "created": true } }
Hosted Git origins normalize to lowercase owner/repo, so the .git suffix, the scheme
and the capitalization all collapse. A reference that would not be shareable is rejected
outright: credential material, a query string, a fragment, or a local filesystem path.
create: true additionally requires projects:create — resolving is a lookup, creating is
a capability — and without it an unknown reference returns 404. Neither one creates
membership.