Prosopia is a **site-to-site broker** for researcher profiles. One canonical
profile per researcher lives here, owned by that person; your application
reads it, ranks against it, and contributes to it through a single API key —
without ever holding the researcher's login. This guide is for the developer
wiring another service (a lab portal, a matcher, an authoring tool) into
Prosopia.

It answers, in order: how your app authenticates, what it is allowed to see,
how your users reach their own access, how you control identity, and how you
pull and push information. For the per-endpoint contract, see the
[API reference](/prosopia/reference/api.md); for the ideas behind access and visibility,
see [Core concepts](/prosopia/concepts.md).

Base URLs: `https://prosopia.databio.org` (public; `profiles.databio.org` is
the same app). Inside the lab cluster, services reach it at
`http://prosopia:8200`.

## The mental model

- A **profile** is keyed on a canonical `rid` — a researcher's ORCID iD, or a
  minted `local:` id for someone with no ORCID.
- A **person** owns their profile through an ORCID login. Your app never
  performs that login and never becomes an owner.
- Your app is a **consumer principal**: it authenticates with an app key that
  carries *scopes* (what operations it may perform) and a *viewer tier* (how
  much of each profile it may see). Both are set when the key is minted.
- You contribute in two distinct ways: **push** a canonical document (you are
  the system of record for that researcher), or lay a private **overlay** over
  someone else's canonical profile (you augment it; the owner decides whether
  to adopt your changes).

Everything below is a consequence of these four facts.

## Get a key and authenticate

Your app authenticates with a bearer token on every request:

```
Authorization: Bearer rpk_live_xxxxxxxxxxxxxxxx
```

An operator mints the key for your consumer from the admin panel
(`POST /api/admin/consumers/{id}/keys`); the plaintext is shown once, and only
its hash is stored. Ask the operator for a key with the scopes and tier your
integration needs.

**Two key kinds — use the app key.** App keys are prefixed `rpk_` and carry
*app scopes*. Agent keys (`rpa_`) are a different mechanism: a person mints one
against their own profile and hands it to a narrow tool, to read that profile
(the shared `read` scope) or to edit specific fields. A site-to-site
integration uses an `rpk_` app key. An `rpa_` key holding only agent scopes is
refused with `403` at any other app-scoped route.

The difference that matters is reach. An app key's minted tier IS its
authorization, and it applies to every profile the key can see. An agent key
carries no tier of its own: it reads through the grant its owner wrote, so it
reaches one profile and is worth an anonymous request everywhere else. If your
application serves many researchers and you hold one credential for all of
them, you want an app key. If each of your users brings their own profile —
pastes in a token and an id — you want them to bring you an agent key, and you
never hold a credential that spans them.

**Scopes** — request only what you use:

| Scope | Lets your app |
|---|---|
| `read` | Resolve a viewer tier for reads (reads themselves are not scope-gated; see below) |
| `match` | Call `/match` and per-profile `/search` |
| `persona` | Call the generative endpoints (`ask`, `review`, `innovate`, `riff`) |
| `push` | Create or replace canonical profiles, and download archives |

**Viewer tier** — `public`, `internal`, or `restricted`, minted onto the key.
This is the ceiling on what your app sees, applied to every read. Most lab
integrations want `internal`; `restricted` is reserved for trusted services.

### What can go wrong

- **`401`** — missing, malformed, unknown, or revoked key.
- **`403`** — the key lacks the scope for this route. The body is
  `{"detail": "consumer '<name>' lacks scope '<scope>'"}`.

(The richer `{error, required, granted, missing, hint}` body you may see
elsewhere belongs to the *agent-key* edit path, not to app-scope calls.)

## Know what you can retrieve

Reads are **not** gated by a scope. Instead, every read is projected through
your key's **viewer tier**, artifact by artifact, on every request. Your app
sees exactly what its tier allows and no more.

- An artifact above your tier is returned as **`404`** — indistinguishable
  from an artifact that does not exist. You cannot probe for what you may not
  see.
- Two **hard floors** return **`403`** for everyone, at every tier: paper
  full text, and the internal `cache/` and `.keys/` prefixes. No key,
  grant, or operator token lifts these.
- Unpublished profiles are floored at `internal`. A `public`-tier key (or an
  anonymous caller) sees only *published* profiles; an `internal`- or
  `restricted`-tier consumer keeps seeing lab profiles whether or not the
  owner has published them.

**Read the tier back from the response headers:**

- `X-RP-Viewer-Tier` — the tier your request resolved to. Present on every
  response, including refusals.
- `X-RP-Effective-Tier` — on a single-artifact read, the tier that artifact
  was projected to.
- `X-RP-Archive-Tier`, `X-RP-Archive-Digest`, `X-RP-Profile-Level` — on the
  push-scoped archive download.

**Discover the catalog** — three read routes tell you what exists and what you
may fetch:

```bash
curl -H "Authorization: Bearer $RPK" \
  https://prosopia.databio.org/api/v1/profiles          # listing (only profiles you can see)
curl -H "Authorization: Bearer $RPK" \
  https://prosopia.databio.org/api/v1/registry.json     # bundle of cards with absolute base URLs
curl -H "Authorization: Bearer $RPK" \
  https://prosopia.databio.org/api/v1/profiles/jane-doe # detail
```

The detail response is your inventory for one profile:

```json
{
  "slug": "jane-doe",
  "rid": "0000-0002-1825-0097",
  "metadata": { "...": "..." },
  "expertise": { "...": "..." },
  "soul": "...",
  "manifest": ["metadata", "expertise", "soul", "papers/…"],
  "withheld": ["…"],
  "content_hash": "sha256:…"
}
```

`manifest` lists the artifacts you may retrieve; a body you may not see comes
back `null` rather than erroring the whole request. `content_hash` is the value
you use for safe writes (see [Push information](#push-information)).

## Let your users reach their own access

Your app authenticates as **itself**, not as each of your users. There is no
way to mint a per-user session from your key, and that is deliberate — Prosopia
never lets a broker impersonate a person. So "what your users can access"
resolves through three mechanisms, not a proxied login:

1. **Your key's tier** is the floor for every user of your app. If your key is
   `internal`, all your users read profiles at `internal`.

2. **Owners share with your consumer.** A person can give your consumer a
   `read` (or `write`) permission on everything they hold -- their profile
   and the lenses they wrote -- from the Access tab on their profile, or with
   `POST /api/manage/permissions` and
   `{"principal_kind": "consumer", "principal_ref": "<your-consumer-id>", "grant_type": "read"}`.
   Per the [viewer-tier table](/prosopia/reference/api.md#viewer-tiers), a permission
   reads *that person's* data whole. This is how an owner opts your app into
   the deeper parts of their profile.

3. **Person-links map your users to profiles** (next section), so your app can
   answer "which canonical profile is my user #4821?" without asking the user.

If a flow genuinely needs a *person* to act as themselves — to claim a profile,
publish it, or make an owner-only edit — your site sends that person to
Prosopia's ORCID login (`/api/manage/login`) and lets them act in their own
session. Your key cannot and should not stand in for them.

## Control identity

The join key between your world and Prosopia's is the `rid`. Get it right and
everything else lines up.

**Resolve before you create.** Before onboarding a researcher, ask whether a
canonical profile already exists:

```bash
curl "https://prosopia.databio.org/api/v1/identity/resolve?orcid=0000-0002-1825-0097"
```

```json
{ "slug": "jane-doe", "rid": "0000-0002-1825-0097", "claimed": true, "owner_orcid": "0000-0002-1825-0097" }
```

`404` means the identity is not visible to *you* — anonymous callers see
only published, publicly visible profiles, so an unpublished profile 404s
here too. Do NOT treat a `404` as proof the person is absent or as a green
light to onboard them; that decision belongs to the authoritative resolver,
`POST /api/v1/identity/resolve` (send your consumer key with the `resolve`
scope), which matches or mints exactly once. Authenticate the GET with your
`rpk_` key to see unpublished profiles. `claimed` tells you whether a person
has taken ownership. You can resolve by `rid=` (ORCID or `local:`) or
`orcid=`.

**Do not invent `local:` ids yourself.** For a researcher with no ORCID, let
Prosopia mint the id at push time by opting in — either a JSON field or a query
flag on the push:

```bash
curl -H "Authorization: Bearer $RPK" \
  -H "Content-Type: application/json" \
  -X PUT https://prosopia.databio.org/api/v1/profiles/rivka-cohen \
  -d '{ "name": "Rivka Cohen", "mintLocalRid": true, "...": "..." }'
```

Prosopia returns the minted `rid` (form `local:<slug>-<hex6>`) in the response;
record it as your permanent join key. The rules are strict, so minting is never
accidental:

- No `rid` in the document and no opt-in → **`400`** ("supply an ORCID rid, or
  pass `mintLocalRid=true` / `?mint=local`").
- Opt-in with an empty `name` → **`400`**.
- A `rid` already present *and* the mint flag set → **`400`** (no silent
  overwrite).

A `local:`-rid profile stays unclaimed — its `rid` is not an ORCID, so no ORCID
login self-claims it. It is a lab-visible identity record, not a published web
profile.

**Remember the mapping with person-links.** Bind your own external id to the
canonical profile so you never re-resolve:

```bash
curl -H "Authorization: Bearer $RPK" \
  -H "Content-Type: application/json" \
  -X POST https://prosopia.databio.org/api/manage/consumers/your-app/person-links \
  -d '{ "external_id": "4821", "rid": "0000-0002-1825-0097" }'
```

Look one up with the external id in the path:

```bash
curl -H "Authorization: Bearer $RPK" \
  https://prosopia.databio.org/api/manage/consumers/your-app/person-links/4821
# -> { "consumer_id": "your-app", "external_id": "4821", "slug": "jane-doe", "rid": "…" }
```

You must call these as the named consumer; another consumer's key gets `403`.

## Pull information

**Read one profile or the whole catalog** — the routes shown under
[Know what you can retrieve](#know-what-you-can-retrieve). Fetch a single
artifact directly, and read `X-RP-Effective-Tier` to see the tier it came back
at:

```bash
curl -H "Authorization: Bearer $RPK" \
  https://prosopia.databio.org/api/v1/profiles/jane-doe/content/expertise
```

**Match across profiles** (scope `match`) — semantic ranking, the core
brokerage read:

```bash
curl -H "Authorization: Bearer $RPK" \
  -H "Content-Type: application/json" \
  -X POST https://prosopia.databio.org/api/v1/match \
  -d '{ "query": "single-cell chromatin accessibility", "k": 5 }'
```

```json
{
  "matches": [
    {
      "slug": "jane-doe",
      "name": "Jane Doe",
      "rid": "0000-0002-1825-0097",
      "orcid": "0000-0002-1825-0097",
      "score": 0.83,
      "evidence": {
        "centroid_score": 0.81,
        "top_papers": ["doe2024atlas"],
        "overlapping_topics": ["chromatin", "single-cell"],
        "top_chunks": []
      }
    }
  ]
}
```

Request fields: `query` (required), `k` (default 5), `prefilter` (10),
`require_topics` (list, optional), `diversify` (true), `lambda_` (0.5),
`topk_chunks` (5), `normalize` (true), `include_chunks` (false — set true to
populate `evidence.top_chunks`). Results you may not see at your tier are
dropped from the ranking.

**Matchability depends on an embedding index.** A profile is rankable only once
it carries a built embedding index. A profile you created with a JSON push
(next section) has *no* index (`indexed: false`) and will not appear in `/match`
until it is enriched by a full-bundle push or a build. If the deployment has no
embeddings support installed, or no profile is indexed yet, `/match` returns
`503`.

**Ask a profile** (scope `persona`) — generative endpoints grounded in one
profile's corpus: `POST /api/v1/profiles/{slug}/ask`, `.../review`,
`.../innovate`, `.../riff`. `ask` and `review` return grounded text with
citations; `innovate` and `riff` return structured idea lists. See the
[API reference](/prosopia/reference/api.md#search-and-ai) for each body.

## Push information

There are two ways to contribute, and choosing correctly is the most important
integration decision.

### Upsert the canonical document (you are the source of record)

Use this when your app authors the researcher's profile — you hold the truth
about their identity and expertise. It is a create-or-replace on the canonical
document, scope `push`:

```bash
curl -H "Authorization: Bearer $RPK" \
  -H "Content-Type: application/json" \
  -X PUT https://prosopia.databio.org/api/v1/profiles/jane-doe \
  -d '{ "rid": "0000-0002-1825-0097", "name": "Jane Doe", "...": "…the profile.jsonld document…" }'
```

The body is a full profile document (the `profile.jsonld` shape). Success is
`200` with:

```json
{ "slug": "jane-doe", "rid": "0000-0002-1825-0097", "name": "Jane Doe", "level": "lite", "indexed": false }
```

Two things to know:

- **`Content-Type: application/json` selects this path. Anything else — or a
  missing header — is treated as a gzipped-tarball bundle upload**, the older
  path that carries papers and a prebuilt embedding index. Send the JSON
  content type explicitly.
- **JSON upsert writes the document only.** It does not add or remove
  `sources/` (papers) or `cache/` (embeddings) artifacts. So it establishes
  identity and expertise cheaply, but the profile is not matchable until a
  bundle push or a build adds an index. The division of labor is the point:
  your app owns identity, Prosopia owns enrichment.

**Write safely with `If-Match`.** To avoid clobbering a concurrent edit, send
the `content_hash` you last read:

```bash
curl -H "Authorization: Bearer $RPK" \
  -H "Content-Type: application/json" \
  -H "If-Match: sha256:abcd…" \
  -X PUT https://prosopia.databio.org/api/v1/profiles/jane-doe \
  -d '{ "...": "…" }'
```

A stale hash returns **`409`** with the current value in the
`X-RP-Content-Hash` response header — re-read, merge, retry. Omitting `If-Match`
is last-writer-wins. On success, `dateModified` is stamped automatically, and
only when the content actually changed.

### Overlay someone else's profile (you augment; the owner decides)

Use this when you do *not* own the record — you want to add or correct fields
on a profile some other party is the source of record for. An **overlay** is a
consumer-private patch keyed to `(profile rid, your consumer)`. Only your app
sees the merged result; everyone else sees the canonical profile untouched.

```bash
curl -H "Authorization: Bearer $RPK" \
  -H "Content-Type: application/json" \
  -X PUT https://prosopia.databio.org/api/v1/profiles/jane-doe/overlay/metadata \
  -d '{ "patch": { "affiliation": "…as your system sees it…" } }'
```

Companion routes: `PUT .../overlay/soul` (`{"soul": <str|null>}`),
`PATCH .../overlay/visibility`, `GET .../overlay` (add `?create=merged` to get
the merged view when no overlay exists yet), and `DELETE .../overlay`. Writes
return `{overlay, merged, stale}`. Each write records the canonical
`base_hash` it was authored against; a later canonical edit flips your overlay
to `stale: true`, your signal to reconcile. Overlays are consumer-only — a
person session gets `403`.

**Your overlay is visible to the owner as a suggestion.** The owner can
`accept` it (your patch is merged into the canonical profile) or `dismiss` it
(your private overlay stays, canonical is untouched). This is how a broker
proposes improvements without seizing authorship.

## Errors at a glance

| Status | Meaning |
|---|---|
| `400` | Bad request — invalid slug, missing/invalid rid or mint opt-in, empty mint name |
| `401` | Missing, malformed, unknown, or revoked key |
| `403` | Lacks the required scope; or a hard-floor artifact (full text, `cache/`, `.keys/`); or a person session on a consumer-only route |
| `404` | No such profile, or an artifact above your viewer tier (the two are indistinguishable) |
| `409` | `If-Match` conflict (see `X-RP-Content-Hash`), or a write conflict |
| `422` | The pushed JSON document failed validation |
| `503` | `/match` unavailable — no embeddings support, or no profile is indexed yet |

## Related

- [API reference](/prosopia/reference/api.md) — every endpoint, field, and header.
- [Core concepts](/prosopia/concepts.md) — identity, ownership, and the tier engine.
- [Share your profile](/prosopia/how-to/share-your-profile.md) — the owner's side of
  granting your app access.
