# Identity & access — concepts

Identity in Vectros answers two questions on every request: *who is acting*, and *what
are they allowed to touch*. The model spans the whole range from the company that builds
on the platform down to a single end-user inside one of that company's apps — and it
turns "who is this" into "what operations, on what data" through one small set of
primitives. Every API call carries a credential; every credential resolves, at the edge,
into a principal, a tenant, an app context, and a scope before the request reaches any
application code.

Vectros began as the back-end for a HIPAA-grade clinical product — battle-tested in
production against regulated health data before it was offered as an API. The
data-isolation, audit, and scope-enforcement primitives described here were designed for
that bar, and they apply unchanged to every workload built on the platform.

This page is the mental model. For runnable steps, see [how-to.md](how-to.md); for the
exhaustive surface — every method, field, limit, and error — see
[reference.md](reference.md).

---

## The isolation moat: app contexts

Start here, because everything else rests on it. An **app context** is a hard partition.
Every piece of data you store — records, documents, folders, and the schemas that shape
them — lives inside exactly one context, identified by a `contextId`. The context is not
a label you attach and could forget: it is a **mandatory, fail-closed partition key**
that is derived from the calling credential, never accepted from request input. A caller
cannot ask to read another context's data, because there is no request shape that lets
them name a context they aren't authenticated into. Lookups are **same-context-only** by
construction.

This is the multi-tenant isolation boundary. If you are building a platform that serves
your own customers — a SaaS product, a per-clinic clinical tool, a per-team knowledge
base — you give each customer their own context and their data can never bleed across.
The guarantee is structural, not a per-handler runtime check that a later change could
quietly regress.

A `contextId` is a stable, human-meaningful string matching `^[a-z][a-z0-9-]{2,30}$` —
for example `customer-portal`, `clinic-intake`, or `internal-admin`. You create contexts
through the context lifecycle endpoints (create / get / update / list / delete). Creating
and deleting a context is a **root-key (`sk_*`) operation** — a scoped key or token cannot
provision or tear down a context, even with the wildcard `*` scope. Certain
context ids are **reserved by the platform** and cannot be created by a tenant — for
example `vectros-admin`, which backs the hosted admin surfaces, and `default`, the base
context auto-provisioned for every tenant.

A single company can run many contexts. A business that operates both a customer-facing
portal and an internal admin tool can model them as two contexts and grant the same
person different permissions in each, without one app's roles polluting the other's.

Deleting a context is deliberate and irreversible: it is a **confirm-gated, asynchronous
cascade**. The delete call must carry a `confirm` token equal to the `contextId`, or it
is rejected before anything is touched. With the token, the context flips to a *purging*
state immediately and drains all of its records, documents, folders, schemas, roles, and
profiles in the background, reaching *deleted* when the drain completes. This per-context
hard-delete is implemented and live. (It is a *different* mechanism from end-subject data
deletion — do not conflate the two.)

---

## The identity plane: users, orgs, and clients

Alongside the data, Vectros models the **people and groups** a request can be attributed
to and scoped against. These live on the identity plane, addressed through
`client.identity.*`, and are **tenant-wide** — they exist independently of any one context
and are referenced by data and by credentials rather than owned by a context.

There are three identity dimensions:

- **Users** represent individual people or machines acting in your tenant. A user is
  either a **`HUMAN`** identity — a real person who authenticates through a login system
  (your own auth, or a hosted one) — or a **`SERVICE`** identity — a machine principal
  that does not log in interactively and instead acts under a credential. The two share
  one model and one code path; only the `type` differs.
- **Orgs** are groupings you define — a clinic, a department, a team, a workspace.
- **Clients** typically represent an external customer or relationship you are serving. A
  client can be associated with an owning org through `orgId`.

Users, records, and documents can be tagged with an `orgId` and a `clientId`. The reason
orgs and clients are first-class platform entities — rather than free-form metadata — is
that they **participate directly in scope enforcement**: a credential can be narrowed to a
specific org or client, and the narrowing is checked against the row's own ownership
fields on every read, write, and search, not against a per-document access list.

### External IDs make identities idempotent

You arrive with users, customers, and accounts that already have IDs in your own systems
— emails, UUIDs, your billing provider's customer IDs, your auth provider's subject IDs.
Every identity carries an **`externalId`** that you supply, so you don't have to maintain
a separate mapping table. Create is **idempotent by `externalId`**: a second create with
the same `externalId` returns the existing identity (it does not duplicate, and it does
not overwrite — the second call's other fields are ignored on the idempotent return). The
encoding that carries an external ID into storage is permissive about characters — the
variety of legitimate formats is too wide to allow-list — so values containing separators
like `:` or `#` round-trip cleanly, and you never see the encoded form.

Each identity dimension supports the full lifecycle: create, get, update (a full-replace
PUT), delete, list (filterable by `externalId`, and clients by `orgId`), and a version
history read. There is **no partial-update (PATCH) on the identity plane** today — updates
replace the identity body.

---

## The access model: scopes, profiles, and roles

A credential is only as powerful as its **scope**. Scope has two halves: which
*operations* it permits, and which *data* it may touch.

### Scope grammar

An allowed action is a string of the form **`resource:ops[:qualifier]`**. The operations
are the single letters **`c`** (create), **`r`** (read), **`u`** (update), **`d`**
(delete), freely combinable — `records:r` is read-only on records, `records:cru` is
create/read/update, `records:r:intake_form` narrows read to one record type. The data-plane
resources are `records`, `schemas`, `search`, `documents`, `folders`, and `inference`.

Two grammar facts matter enough to lead with, because getting them wrong fails silently:

- **Author explicit `resource:op` forms.** A coarse verb (a bare `read` or `write`) and
  the operations-wildcard form `resource:*` grant **nothing** at runtime. Always write the
  letter form: `records:r`, `search:r`, `documents:cru`.
- The single literal **`*`** is the only true wildcard — it grants everything, and it is
  the shape carried by root keys (below). Reserve it for that.

### Data scope

The second half is `dataScope` — a map from an ownership field (`userId`, `orgId`,
`clientId`) to the list of values the credential may touch:

```json
{ "dataScope": { "clientId": ["client_abc"] } }
```

A credential scoped to `{ clientId: ["client_abc"] }` cannot touch rows whose `clientId`
is anything else. The same filter applies to list and search as a server-side narrowing
that sits *below* any caller-supplied filter and can never be widened by it. Multiple
values in one field's list match the union; multiple fields intersect.

`dataScope` is **strict by default**: a scoped credential must include the matching filter
on each list and search call, or the request is rejected with a message like *"clientId is
required by token scope."* Strict-scope forces the caller's intent to be explicit at the
request boundary. To *also* reach **tenant-level** data — rows with no value for the scoped
field, i.e. shared data not assigned to any org or client — the caller adds a JSON `null`
to the value list: `{ clientId: ["client_abc", null] }`. The `null` is an explicit,
opt-in widening to owner-less data; it is never implicit.

### Access profiles and roles

An **access profile** is the per-principal, per-context permission row that the scope
model is built from. It binds a **principal** (a `usr_<userId>` or a `key_<keyId>`) to a
permission shape inside one context. A profile carries **exactly one of**: a set of inline
`scopes`, or a reference to a reusable **role** by `roleId` — the two are mutually
exclusive (switching from one to the other clears the unused half). Profiles can be
`active` or `suspended`, and suspending one denies access without deleting it.

A **role** is a context-scoped, **identity-agnostic** permission shape — define
`engineering-member` or `support-readonly` once, then bind many principals to it through
their profiles. This is the reusable-permission primitive: roles are multi-clause (each
clause is an `(allowed_actions, dataScope)` pair, and *any clause that matches* grants
access), which lets one role express a compound shape like "full control over the records I
own, plus read access to the rest of the team's." Roles support an ownership placeholder,
`${{ self.* }}`, that resolves to the acting principal at runtime, and a `null` data-scope
sentinel that additively grants tenant-level (owner-less) records.

A profile can be cleared safely: deleting a role that a profile still references is blocked
(the platform refuses to orphan a profile's binding). And a profile's `identityOverrides`
— the ownership values stamped onto what the principal touches — accept `orgId` and
`clientId` only; the tenant identifier and `userId` are sacred and rejected, so a profile
can never forge a different user's identity.

Profiles are addressable across contexts: a single lookup can answer *"every context this
principal has access to"* — useful for an admin view of one person's reach across all the
apps you've provisioned.

> **Note on minting profiles via the API.** The profile-create endpoint accepts **one**
> scope clause per request today. Multi-clause shapes are expressed through **roles**
> (which a profile then references) or through blueprints. See
> [reference.md](reference.md#notes--limits) for the precise limit.

---

## Credentials: root keys, scoped keys, and short-lived tokens

Three credential types cover three lifecycles. All three are presented the same way on the
wire — an `Authorization: Bearer …` header — and all three resolve through the same edge
authorizer, which classifies the credential by prefix, validates it, and injects the
resolved tenant, principal, context, and scope into the request before any application code
runs.

- **`sk_live_*` / `sk_test_*` — root keys.** One live and one test key, each with wildcard
  scope and full authority within its tenant. Intended for **server-to-server** calls from
  your own backend. The raw secret is shown **once** at creation and never again — the
  platform stores only a hash. The two prefixes track the two environments (a live tenant
  for production, a test tenant for development), with fully isolated data and indexes
  between them.
- **`ssk_live_*` / `ssk_test_*` — scoped keys.** Permanent, least-privilege keys that are
  **identity-bearing on the data plane**: the key is bound to a principal that already has
  an access profile in a context, the bound principal *is* the data-ownership identity, and
  it can never exceed that profile. Scoped keys are the right shape for a local agent, a
  per-team-member bot, or any long-running worker that has no way to refresh a token and
  where audit attribution ("Alice's agent did this") matters. The raw secret is shown once;
  there is no in-place rotation — rotate by revoking and re-issuing.
- **`st_*` — short-lived tokens.** Minted on demand, scope embedded in the token itself,
  default **1-hour** lifetime, **24-hour** maximum. These power the front-end-safe pattern
  (below). They cannot be revoked in flight — expiry is the lever, so mint with a short
  lifetime to bound blast radius.

### The front-end-safe minting pattern

Browser code cannot safely hold a root key. The front-end-safe pattern keeps your backend
in the loop only for minting:

1. Your backend holds the long-lived `sk_*` (or an `ssk_*`).
2. On login, it mints an `st_*` narrowed to that one user's scope — typically
   `dataScope: { userId: ["<that user's id>"] }` plus the session's allowed actions — with
   a short lifetime.
3. It hands the `st_*` to the browser. The browser calls Vectros directly.

The root key never crosses the network boundary, the per-session token cannot widen its own
scope, and a compromised browser exposes one user for at most the token's lifetime. Every
minted token also records which key minted it, so audit attribution is never ambiguous —
even for tokens that carry no user (a public search page, a service-to-service call).

### The blueprint scope gate

The CLI and blueprint **bootstrap** flow — which provisions a context, principal, profile,
and a narrow `ssk_*` from a declarative app definition — runs behind a hard **scope gate**.
The gate mints keys for the **data plane only**: `records`, `schemas`, `search`,
`documents`, `folders`, `inference`. Any **control-plane** scope (keys, profiles,
app-contexts, users, billing, admin, clients, orgs) and the literal wildcard are
**hard-rejected** — the bootstrap mints nothing and exits non-zero, with no override flag.
The trust boundary is the tool, not the app definition it reads: a control-plane scoped key
is something a human creates deliberately in the developer portal, never something an
automated bootstrap can be talked into minting.

---

## Revocation and propagation

Revoking a key (`sk_*` or `ssk_*`) is not instantaneous — caches at the edge mean a revoked
key keeps working until the edge authorizer cache expires, up to about five minutes.
`st_*` tokens cannot be revoked at all; they expire on
their lifetime. Plan rotations and offboarding with that propagation window in mind. See
[reference.md](reference.md#notes--limits) for exact figures.

---

## Where to go next

- [how-to.md](how-to.md) — runnable guides: create an org and a user, mint a least-privilege
  scoped key, operate inside a context, wire the front-end-safe pattern.
- [reference.md](reference.md) — exhaustive identity/access surface: every method, field,
  scope-grammar rule, limit, and error.
- [../search-rag/explanation.md](../search-rag/explanation.md) — how scope and `dataScope`
  narrow search results at the data layer.
- [../data-model/explanation.md](../data-model/explanation.md) — how records and documents
  pick up ownership and live inside a context.
- [../operations-trust/explanation.md](../operations-trust/explanation.md) — how isolation,
  least-privilege, and audit history compose into the platform's compliance posture.
- The **generated API reference** (rendered from the OpenAPI specification) — the canonical,
  always-current request and response shapes.
