Identity & visibility
Six dimensions narrow every record, three visibility levels decide who can retrieve it, and authority comes from the credential, not the request body.
Six identities
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 one principal with two operators, and the private-memory boundary below is drawn around the principal.
The scope ladder
Six dimensions narrow a record, and they are not all set the same way.
| 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
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 message:
{ "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, 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, and without it an unknown reference
returns 404. Neither one creates membership.