# 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.

Section: Collaboration · Source: https://titen.dev/docs/identity
Derived from: https://github.com/RamaAditya49/titen/blob/main/docs/architecture/collaboration.md

---
## 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?

<div class="kv">
<strong>ROLES THE MODEL DISTINGUISHES</strong>

<p><code>actor</code> the identity performing the operation, always the credential's principal</p>
<p><code>subject</code> the person, system or entity the memory concerns</p>
<p><code>observer</code> the identity whose perspective a claim represents</p>
<p><code>agent</code> a software identity acting under a human or team policy</p>
<p><code>service</code> a non-agent automation identity</p>
<p><code>organization</code> the tenant boundary, plus workspace and project as collaboration scopes</p>
</div>

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.

<div class="table-wrap">

| 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 |

</div>

`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`.

<div class="callout">
<strong>WORKSPACE IS A RECORD, NOT YET A FILTER</strong>

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.
</div>

## Visibility

Three levels, defaulting to `team` when an observation omits the field.

<div class="table-wrap">

| 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 |

</div>

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`:

```json
{ "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:

```json
{ "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:

```json
{ "error": { "code": "FORBIDDEN", "message": "Missing required scope \"workspaces:read\"." } }
```

## Workspaces and memberships

<figure class="code"><figcaption>memberships.sh<b>bash</b></figcaption>

```sh
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"}}
```

</figure>

`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.

```sh
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}'
```

```json
{ "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.

## Next

<div class="cards">

<a href="/docs/keys-scopes"><strong>Keys &amp; scopes</strong><span>All 34 scopes, trust ceilings, and how a key mints a weaker key.</span></a>
<a href="/docs/checkpoints"><strong>Checkpoints</strong><span>Per-agent task state with a TTL, and why it is not evidence.</span></a>
<a href="/docs/channel-releases"><strong>Channel releases</strong><span>Why `verified` does not mean publishable.</span></a>

</div>